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
@@ -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'));
|
||||
|
||||
@@ -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