From fbe4f38e0679e16932b4df771a68928779e3a675 Mon Sep 17 00:00:00 2001 From: "s.esmaeili" Date: Thu, 8 Oct 2026 16:29:51 +0330 Subject: [PATCH] feat(cities): cities are a tree again, as legacy's; old dashboard permissions merged (PERS-02, PERM-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/common/CityTreeSelect.vue | 81 +++++++++++ .../views/apps/customer/ReagentCustomer.vue | 13 +- .../src/views/apps/customer/bulkCustomer.vue | 47 +++---- .../views/apps/customer/singleCustomer.vue | 19 +-- .../src/views/city/CityListModel.ts | 6 +- apps/admin-panel/src/views/city/cityList.vue | 42 +++--- .../admin-panel/src/views/city/createCity.vue | 42 +++++- .../migration.sql | 14 ++ .../20261008170000_city_tree/migration.sql | 33 +++++ apps/api/prisma/schema.prisma | 9 +- apps/api/prisma/seed.ts | 19 ++- .../api/scripts/legacy-import/steps/people.ts | 57 ++++++-- .../admin-cities/admin-cities.controller.ts | 13 +- .../admin-misc/admin-misc.cities.spec.ts | 59 ++++++++ .../admin-misc/admin-misc.controller.ts | 2 +- .../modules/admin-misc/admin-misc.service.ts | 133 ++++++++++++++---- docs/parity/ISSUES.md | 4 +- 17 files changed, 467 insertions(+), 126 deletions(-) create mode 100644 apps/admin-panel/src/components/common/CityTreeSelect.vue create mode 100644 apps/api/prisma/migrations/20261008160000_dashboard_permission_merge/migration.sql create mode 100644 apps/api/prisma/migrations/20261008170000_city_tree/migration.sql create mode 100644 apps/api/src/modules/admin-misc/admin-misc.cities.spec.ts diff --git a/apps/admin-panel/src/components/common/CityTreeSelect.vue b/apps/admin-panel/src/components/common/CityTreeSelect.vue new file mode 100644 index 0000000..0792021 --- /dev/null +++ b/apps/admin-panel/src/components/common/CityTreeSelect.vue @@ -0,0 +1,81 @@ + + + diff --git a/apps/admin-panel/src/views/apps/customer/ReagentCustomer.vue b/apps/admin-panel/src/views/apps/customer/ReagentCustomer.vue index 2f5b225..f5df4d6 100644 --- a/apps/admin-panel/src/views/apps/customer/ReagentCustomer.vue +++ b/apps/admin-panel/src/views/apps/customer/ReagentCustomer.vue @@ -72,13 +72,10 @@ «شهر» also drives the commission autofill (ReagentCustomer.vue:111,215-228). --> - @@ -117,6 +114,7 @@ import { onMounted, reactive, ref } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { message } from 'ant-design-vue'; import api from '../../../api/client'; +import CityTreeSelect from '../../../components/common/CityTreeSelect.vue'; import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue'; const route = useRoute(); @@ -147,7 +145,7 @@ const form = reactive>({ }); /** Cities for the «شهر» select and the commission autofill. */ -const cities = ref>([]); +const cities = ref>([]); const err = reactive>({}); /** Snapshot of the server's record, captured after the edit form is populated. */ @@ -180,7 +178,7 @@ function onAvatar(file: Record) { * `if (!percent)`, so a city percent of 0 also falls through to the aux * lookup. Replicated rather than "fixed" (D4). */ -async function onCityChange(value: string) { +async function onCityChange(value: string | undefined) { form.city_id = value; if (!value) return; @@ -246,6 +244,7 @@ onMounted(async () => { cities.value = (data.rows ?? []).map((c: any) => ({ id: c.id, title: c.title ?? c.name, + parent: c.parent ?? null, percent: c.percent === null || c.percent === undefined ? null : Number(c.percent), })); } catch (e) { diff --git a/apps/admin-panel/src/views/apps/customer/bulkCustomer.vue b/apps/admin-panel/src/views/apps/customer/bulkCustomer.vue index 01eb674..dbe7124 100644 --- a/apps/admin-panel/src/views/apps/customer/bulkCustomer.vue +++ b/apps/admin-panel/src/views/apps/customer/bulkCustomer.vue @@ -98,17 +98,7 @@
- - - {{ c.title }} - - + @@ -211,17 +201,7 @@
- - - {{ c.title }} - - + @@ -269,6 +249,7 @@ import { message } from 'ant-design-vue'; import BaseFormModal from '../../../components/common/BaseFormModal.vue'; import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue'; import api from '../../../api/client'; +import CityTreeSelect from '../../../components/common/CityTreeSelect.vue'; const props = defineProps<{ open?: boolean; @@ -299,7 +280,7 @@ const router = useRouter(); const id = asModal ? props.id : (route.params.id as string | undefined); const loading = ref(false); -const cities = ref<{ id: string; title: string }[]>([]); +const cities = ref<{ id: string; title: string; parent?: string | null; percent?: unknown }[]>([]); const reagent = ref<{ id: string; name: string }[]>([]); const form = reactive>({ @@ -369,8 +350,24 @@ function diffFromInitial(): Record { return out; } -function filterCity(input: string, option: any) { - return String(option?.children?.[0]?.children ?? '').includes(input); +/** + * Legacy getPercent (henza_admin bulkCustomer.vue:260-272): picking a city fills + * «درصد» with the city's own percent, else its commission percent — which, as + * legacy's _getP, a city without one takes from its parent. + */ +async function onCityChange(value: string | undefined) { + if (!value) return; + let percent: unknown = cities.value.find((c) => c.id === value)?.percent; + if (!Number(percent)) { + try { + const { data } = await api.get(`cities/get-percent-aux/${value}`); + percent = data; + } catch (e) { + console.log(e); + return; + } + } + form.percent = percent === null || percent === undefined ? undefined : Number(percent); } function validate(): boolean { diff --git a/apps/admin-panel/src/views/apps/customer/singleCustomer.vue b/apps/admin-panel/src/views/apps/customer/singleCustomer.vue index 2c75f4c..c298e60 100644 --- a/apps/admin-panel/src/views/apps/customer/singleCustomer.vue +++ b/apps/admin-panel/src/views/apps/customer/singleCustomer.vue @@ -32,17 +32,7 @@ - - - {{ c.title }} - - +
@@ -57,13 +47,14 @@ import { onMounted, reactive, ref } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { message } from 'ant-design-vue'; import api from '../../../api/client'; +import CityTreeSelect from '../../../components/common/CityTreeSelect.vue'; const route = useRoute(); const router = useRouter(); const id = route.params.id as string | undefined; const loading = ref(false); -const cities = ref<{ id: string; title: string }[]>([]); +const cities = ref<{ id: string; title: string; parent?: string | null }[]>([]); const form = reactive>({ fname: '', @@ -74,10 +65,6 @@ const form = reactive>({ const err = reactive>({}); const REQUIRED = 'لطفا مقدار را وارد کنید'; -function filterOption(input: string, option: any) { - return String(option?.children?.[0]?.children ?? '').includes(input); -} - async function handleSubmit() { Object.keys(err).forEach((k) => delete err[k]); if (!form.fname) err.fname = REQUIRED; diff --git a/apps/admin-panel/src/views/city/CityListModel.ts b/apps/admin-panel/src/views/city/CityListModel.ts index e8ed860..eb6c22b 100644 --- a/apps/admin-panel/src/views/city/CityListModel.ts +++ b/apps/admin-panel/src/views/city/CityListModel.ts @@ -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')); diff --git a/apps/admin-panel/src/views/city/cityList.vue b/apps/admin-panel/src/views/city/cityList.vue index 193e525..31c10e1 100644 --- a/apps/admin-panel/src/views/city/cityList.vue +++ b/apps/admin-panel/src/views/city/cityList.vue @@ -1,8 +1,9 @@ + + @@ -43,6 +48,15 @@ + + + @@ -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 } | null>(null); const addOpen = ref(false); const visible = ref(false); const tempRow = ref | undefined>(undefined); -const form = reactive>({ title: '', percent: null, percent_aux: null }); +const form = reactive>({ 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) { 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, }); diff --git a/apps/admin-panel/src/views/city/createCity.vue b/apps/admin-panel/src/views/city/createCity.vue index 3aa6efa..3e5ba72 100644 --- a/apps/admin-panel/src/views/city/createCity.vue +++ b/apps/admin-panel/src/views/city/createCity.vue @@ -22,8 +22,14 @@ - - + + + + + + + +
@@ -36,8 +42,14 @@ - - + + + + + + + + ثبت اطلاعات @@ -47,11 +59,12 @@