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:
s.esmaeili
2026-10-08 16:31:12 +03:30
co-authored by Claude Opus 5.5
parent fbe7b98ada
commit fbe4f38e06
17 changed files with 467 additions and 126 deletions
@@ -0,0 +1,81 @@
<template>
<!--
Legacy's city picker (henza_admin mixins/citiesModel.js): the cities as a
tree — country > province > city > district — with the top level a heading
that cannot be picked. A top-level row with nothing under it stays pickable,
so a city entered without a parent is never unreachable.
`parentPicker` is the «والد» field of the city form: every row is pickable
there except the city being edited and its own descendants.
-->
<a-tree-select
:value="value ?? undefined"
:tree-data="treeData"
show-search
allow-clear
tree-default-expand-all
tree-node-filter-prop="title"
:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }"
:placeholder="placeholder ?? 'شهر را انتخاب کنید'"
class="w-full"
@update:value="onChange"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue';
export interface CityRow {
id: string;
title: string;
parent?: string | null;
}
interface Node {
value: string;
key: string;
title: string;
disabled?: boolean;
children?: Node[];
}
const props = defineProps<{
value?: string | null;
cities: CityRow[];
parentPicker?: boolean;
/** parentPicker: the city being edited — it and its subtree are not offered. */
excludeId?: string;
placeholder?: string;
}>();
const emit = defineEmits<{
(e: 'update:value', v: string | undefined): void;
(e: 'change', v: string | undefined): void;
}>();
const treeData = computed<Node[]>(() => {
const ids = new Set(props.cities.map((c) => c.id));
const kids = new Map<string | null, CityRow[]>();
for (const c of props.cities) {
const p = c.parent && ids.has(c.parent) && c.parent !== c.id ? c.parent : null;
if (!kids.has(p)) kids.set(p, []);
kids.get(p)!.push(c);
}
const seen = new Set<string>();
const build = (parent: string | null, depth: number): Node[] =>
(kids.get(parent) ?? [])
.filter((c) => !seen.has(c.id) && !(props.parentPicker && c.id === props.excludeId))
.map((c) => {
seen.add(c.id);
const children = build(c.id, depth + 1);
const node: Node = { value: c.id, key: c.id, title: c.title };
if (children.length) node.children = children;
if (!props.parentPicker && depth === 0 && children.length) node.disabled = true;
return node;
});
return build(null, 0);
});
function onChange(v: string | undefined) {
emit('update:value', v ?? undefined);
emit('change', v ?? undefined);
}
</script>
@@ -72,13 +72,10 @@
«شهر» also drives the commission autofill (ReagentCustomer.vue:111,215-228).
-->
<a-form-item label="شهر">
<a-select
<CityTreeSelect
:value="form.city_id"
show-search
allow-clear
option-filter-prop="label"
:cities="cities"
placeholder="شهر را انتخاب نمایید"
:options="cities.map((c) => ({ value: c.id, label: c.title }))"
@change="onCityChange"
/>
</a-form-item>
@@ -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<Record<string, any>>({
});
/** Cities for the «شهر» select and the commission autofill. */
const cities = ref<Array<{ id: string; title: string; percent?: number | null }>>([]);
const cities = ref<Array<{ id: string; title: string; parent?: string | null; percent?: number | null }>>([]);
const err = reactive<Record<string, string>>({});
/** Snapshot of the server's record, captured after the edit form is populated. */
@@ -180,7 +178,7 @@ function onAvatar(file: Record<string, unknown>) {
* `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) {
@@ -98,17 +98,7 @@
<a-card title="اطلاعات تکمیلی" class="mb-base">
<div class="grid-2">
<a-form-item label="شهر">
<a-select
v-model:value="form.city_id"
show-search
allow-clear
placeholder="انتخاب کنید"
:filter-option="filterCity"
>
<a-select-option v-for="c in cities" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
<CityTreeSelect v-model:value="form.city_id" :cities="cities" @change="onCityChange" />
</a-form-item>
<a-form-item label="آدرس">
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
@@ -211,17 +201,7 @@
<a-card title="اطلاعات تکمیلی" class="mb-base">
<div class="grid-2">
<a-form-item label="شهر">
<a-select
v-model:value="form.city_id"
show-search
allow-clear
placeholder="انتخاب کنید"
:filter-option="filterCity"
>
<a-select-option v-for="c in cities" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
<CityTreeSelect v-model:value="form.city_id" :cities="cities" @change="onCityChange" />
</a-form-item>
<a-form-item label="آدرس">
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
@@ -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<Record<string, any>>({
@@ -369,8 +350,24 @@ function diffFromInitial(): Record<string, any> {
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 {
@@ -32,17 +32,7 @@
</a-form-item>
<a-form-item label="شهر">
<a-select
v-model:value="form.city_id"
show-search
allow-clear
placeholder="انتخاب کنید"
:filter-option="filterOption"
>
<a-select-option v-for="c in cities" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
<CityTreeSelect v-model:value="form.city_id" :cities="cities" />
</a-form-item>
</div>
@@ -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<Record<string, any>>({
fname: '',
@@ -74,10 +65,6 @@ const form = reactive<Record<string, any>>({
const err = reactive<Record<string, string>>({});
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;
@@ -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'));
+26 -16
View File
@@ -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,
});
+35 -7
View File
@@ -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 {