feat(cities): cities are a tree again, as legacy's; old dashboard permissions merged (PERS-02, PERM-01)
Cities (owner: as legacy) - City.parentId (self relation) replaces the flat `province` label; the migration turns each label into a parent row («استان …») first, so nothing is lost. - Admin pickers are legacy's tree (CityTreeSelect): the top level, the country, is a heading. The city form and grid get «والد». - Restored legacy _getP: a city without its own percent takes its ancestor's. Restored the omde-customer form's city → percent autofill. - Import copies legacy parents; delete refuses a city with children; a parent loop is refused. Permissions (owner approved the deletion) - dashboard / dashboard.omde / dashboard.tak rows become dashboard.view, then are deleted. - Import maps them the same way: legacy types 102, 104, 105, 108 and 135 had lost the dashboard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fbe7b98ada
commit
fbe4f38e06
@@ -1,13 +1,13 @@
|
||||
// Ported from legacy henza_admin/src/views/city/CityListModel.js
|
||||
//
|
||||
// Note the 'level' column: legacy declares it but the cities payload has no such
|
||||
// field, so the cell always rendered blank. Preserved — see the port plan.
|
||||
// Legacy's 'level' column always rendered blank (the payload had no such
|
||||
// field); it is the parent city here, now that cities are a tree (PERS-02).
|
||||
import XTbl, { Xtc } from '../../components/x-table/model';
|
||||
|
||||
export function buildCityListModel(): XTbl {
|
||||
const tbl = new XTbl('admin/misc/cities/list', 'لیست شهر ها');
|
||||
tbl.add(new Xtc('title', 'نام شهر').filterKey('name').filterText());
|
||||
tbl.add(new Xtc('level', 'سطح').filterNumber().noSort());
|
||||
tbl.add(new Xtc('parent_title', 'والد').renderSlot('parent_title').noSort());
|
||||
tbl.add(new Xtc('percent', 'درصد مشتری عمده').renderSlot('percent').noSort());
|
||||
tbl.add(new Xtc('percent_aux', 'درصد کارشناس فروش').renderSlot('percent_aux').noSort());
|
||||
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/views/city/cityList.vue.
|
||||
The two percent columns are edited in a modal that legacy loads through two
|
||||
separate GETs (get-percent / get-percent-aux) before opening.
|
||||
The edit modal shows the city's OWN percents. Legacy filled it from
|
||||
get-percent / get-percent-aux, which return the parent's percent when the
|
||||
city has none — so saving silently copied the parent's onto the child.
|
||||
-->
|
||||
<div id="page-user-list">
|
||||
<a-card title="لیست شهر ها">
|
||||
@@ -11,6 +12,10 @@
|
||||
<ListPageHeader rid="cities.create" add-label="افزودن شهر" @add="addOpen = true" />
|
||||
</template>
|
||||
|
||||
<template #parent_title="{ text }">
|
||||
{{ text ?? '—' }}
|
||||
</template>
|
||||
|
||||
<template #percent="{ text }">
|
||||
{{ text ?? '-' }}
|
||||
</template>
|
||||
@@ -43,6 +48,15 @@
|
||||
<a-form-item label="نام شهر">
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
<a-form-item label="والد" help="خالی = سرشاخه، مثل کشور">
|
||||
<CityTreeSelect
|
||||
v-model:value="form.parent"
|
||||
:cities="cities"
|
||||
parent-picker
|
||||
:exclude-id="tempRow?.id"
|
||||
placeholder="والد را انتخاب کنید"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="درصد مشتری عمده">
|
||||
<a-input-number v-model:value="form.percent" class="w-full" :step="0.1" />
|
||||
</a-form-item>
|
||||
@@ -68,6 +82,7 @@ import { buildCityListModel } from './CityListModel';
|
||||
import { useUsersStore } from '../../stores/users';
|
||||
import { useDeleteHandler } from '../../composables/useDeleteHandler';
|
||||
import api from '../../api/client';
|
||||
import CityTreeSelect from '../../components/common/CityTreeSelect.vue';
|
||||
|
||||
const users = useUsersStore();
|
||||
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
|
||||
@@ -77,26 +92,20 @@ const citiesTable = ref<{ fetch: () => Promise<void> } | null>(null);
|
||||
const addOpen = ref(false);
|
||||
const visible = ref(false);
|
||||
const tempRow = ref<Record<string, any> | undefined>(undefined);
|
||||
const form = reactive<Record<string, any>>({ title: '', percent: null, percent_aux: null });
|
||||
const form = reactive<Record<string, any>>({ title: '', parent: undefined, percent: null, percent_aux: null });
|
||||
const cities = ref<{ id: string; title: string; parent?: string | null }[]>([]);
|
||||
|
||||
/**
|
||||
* legacy edit(): fetches the two percents separately before opening the modal.
|
||||
* Both are already on the row here, but the legacy calls are kept so the two
|
||||
* endpoints stay exercised and the values are always current.
|
||||
*/
|
||||
async function edit(row: Record<string, any>) {
|
||||
tempRow.value = row;
|
||||
form.title = row.title ?? '';
|
||||
form.parent = row.parent ?? undefined;
|
||||
form.percent = row.percent === null || row.percent === undefined ? null : Number(row.percent);
|
||||
form.percent_aux = row.percent_aux === null || row.percent_aux === undefined ? null : Number(row.percent_aux);
|
||||
try {
|
||||
const [p, pa] = await Promise.all([
|
||||
api.get(`cities/get-percent/${row.id}`),
|
||||
api.get(`cities/get-percent-aux/${row.id}`),
|
||||
]);
|
||||
form.percent = p.data === null ? null : Number(p.data);
|
||||
form.percent_aux = pa.data === null ? null : Number(pa.data);
|
||||
const { data } = await api.post('cities/list');
|
||||
cities.value = data.rows ?? [];
|
||||
} catch (e) {
|
||||
form.percent = row.percent === null ? null : Number(row.percent);
|
||||
form.percent_aux = row.percent_aux === null ? null : Number(row.percent_aux);
|
||||
console.log(e);
|
||||
}
|
||||
visible.value = true;
|
||||
}
|
||||
@@ -105,6 +114,7 @@ async function save() {
|
||||
try {
|
||||
await api.put(`cities/${tempRow.value?.id}`, {
|
||||
title: form.title,
|
||||
parent: form.parent ?? null,
|
||||
percent: form.percent,
|
||||
percent_aux: form.percent_aux,
|
||||
});
|
||||
|
||||
@@ -22,8 +22,14 @@
|
||||
<a-form-item label="نام شهر" :validate-status="err.title ? 'error' : ''" :help="err.title">
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
<a-form-item label="استان">
|
||||
<a-input v-model:value="form.province" />
|
||||
<a-form-item label="والد" help="خالی = سرشاخه، مثل کشور">
|
||||
<CityTreeSelect v-model:value="form.parent" :cities="cities" parent-picker placeholder="والد را انتخاب کنید" />
|
||||
</a-form-item>
|
||||
<a-form-item label="درصد مشتری عمده">
|
||||
<a-input-number v-model:value="form.percent" class="w-full" :min="0" :max="100" :step="0.1" />
|
||||
</a-form-item>
|
||||
<a-form-item label="درصد کارشناس فروش">
|
||||
<a-input-number v-model:value="form.percent_aux" class="w-full" :min="0" :max="100" :step="0.1" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
</BaseFormModal>
|
||||
@@ -36,8 +42,14 @@
|
||||
<a-form-item label="نام شهر" :validate-status="err.title ? 'error' : ''" :help="err.title">
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
<a-form-item label="استان">
|
||||
<a-input v-model:value="form.province" />
|
||||
<a-form-item label="والد" help="خالی = سرشاخه، مثل کشور">
|
||||
<CityTreeSelect v-model:value="form.parent" :cities="cities" parent-picker placeholder="والد را انتخاب کنید" />
|
||||
</a-form-item>
|
||||
<a-form-item label="درصد مشتری عمده">
|
||||
<a-input-number v-model:value="form.percent" class="w-full" :min="0" :max="100" :step="0.1" />
|
||||
</a-form-item>
|
||||
<a-form-item label="درصد کارشناس فروش">
|
||||
<a-input-number v-model:value="form.percent_aux" class="w-full" :min="0" :max="100" :step="0.1" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<a-button type="primary" @click="handleSubmit">ثبت اطلاعات</a-button>
|
||||
@@ -47,11 +59,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import BaseFormModal from '../../components/common/BaseFormModal.vue';
|
||||
import api from '../../api/client';
|
||||
import CityTreeSelect from '../../components/common/CityTreeSelect.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
/** Present -> rendered as a modal by the parent list page. */
|
||||
@@ -71,7 +84,20 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(false);
|
||||
const form = reactive<Record<string, any>>({ title: '', province: '' });
|
||||
const form = reactive<Record<string, any>>({ title: '', parent: undefined, percent: null, percent_aux: null });
|
||||
const cities = ref<{ id: string; title: string; parent?: string | null }[]>([]);
|
||||
|
||||
async function loadCities() {
|
||||
try {
|
||||
const { data } = await api.post('cities/list');
|
||||
cities.value = data.rows ?? [];
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
// Fresh each time the modal opens, so a city added a moment ago can be a parent.
|
||||
if (asModal) watch(() => props.open, (o) => o && loadCities(), { immediate: true });
|
||||
else onMounted(loadCities);
|
||||
const err = reactive<Record<string, string>>({});
|
||||
|
||||
async function handleSubmit() {
|
||||
@@ -82,9 +108,11 @@ async function handleSubmit() {
|
||||
}
|
||||
try {
|
||||
loading.value = true;
|
||||
await api.post('cities', form);
|
||||
await api.post('cities', { ...form, parent: form.parent ?? null });
|
||||
message.info('اطلاعات با موفقیت به ثبت رسید.');
|
||||
if (asModal) {
|
||||
// Keep the parent: cities are usually added several under the same one.
|
||||
Object.assign(form, { title: '', percent: null, percent_aux: null });
|
||||
emit('success');
|
||||
emit('update:open', false);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user