update admin panel, api, and shared-types
This commit is contained in:
@@ -2,19 +2,21 @@
|
||||
<!--
|
||||
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.
|
||||
that cannot be picked. Rendered as a plain a-select like every other picker
|
||||
in the panel: each top-level row is an option group, everything under it a
|
||||
normal option (deeper levels carry their path, e.g. «تهران › منطقه ۱»).
|
||||
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.
|
||||
there (labelled with its full path) except the city being edited and its
|
||||
own descendants.
|
||||
-->
|
||||
<a-tree-select
|
||||
<a-select
|
||||
:value="value ?? undefined"
|
||||
:tree-data="treeData"
|
||||
:options="options"
|
||||
show-search
|
||||
allow-clear
|
||||
tree-default-expand-all
|
||||
tree-node-filter-prop="title"
|
||||
:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }"
|
||||
option-filter-prop="search"
|
||||
:placeholder="placeholder ?? 'شهر را انتخاب کنید'"
|
||||
class="w-full"
|
||||
@update:value="onChange"
|
||||
@@ -31,11 +33,22 @@ export interface CityRow {
|
||||
}
|
||||
|
||||
interface Node {
|
||||
value: string;
|
||||
key: string;
|
||||
id: string;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
children?: Node[];
|
||||
children: Node[];
|
||||
}
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
/** What the search box matches: the row's path, its group included. */
|
||||
search: string;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
key: string;
|
||||
label: string;
|
||||
options: Option[];
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -51,7 +64,7 @@ const emit = defineEmits<{
|
||||
(e: 'change', v: string | undefined): void;
|
||||
}>();
|
||||
|
||||
const treeData = computed<Node[]>(() => {
|
||||
const tree = 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) {
|
||||
@@ -60,18 +73,37 @@ const treeData = computed<Node[]>(() => {
|
||||
kids.get(p)!.push(c);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const build = (parent: string | null, depth: number): Node[] =>
|
||||
const build = (parent: string | null): 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 { id: c.id, title: c.title, children: build(c.id) };
|
||||
});
|
||||
return build(null, 0);
|
||||
return build(null);
|
||||
});
|
||||
|
||||
const SEP = ' › ';
|
||||
|
||||
/** Every row under `nodes`, depth-first, labelled with its path from there. */
|
||||
function flatten(nodes: Node[], prefix: string[], groupTitle = ''): Option[] {
|
||||
return nodes.flatMap((n) => {
|
||||
const path = [...prefix, n.title];
|
||||
const label = path.join(SEP);
|
||||
return [
|
||||
{ value: n.id, label, search: groupTitle ? `${groupTitle}${SEP}${label}` : label },
|
||||
...flatten(n.children, path, groupTitle),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
const options = computed<(Option | Group)[]>(() => {
|
||||
if (props.parentPicker) return flatten(tree.value, []);
|
||||
return tree.value.map((top) =>
|
||||
top.children.length
|
||||
? { key: top.id, label: top.title, options: flatten(top.children, [], top.title) }
|
||||
: { value: top.id, label: top.title, search: top.title },
|
||||
);
|
||||
});
|
||||
|
||||
function onChange(v: string | undefined) {
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
</section>
|
||||
|
||||
<!-- ── trend + channel ── -->
|
||||
<section class="row row--main">
|
||||
<section class="row" :class="isSuper ? 'row--main' : 'row--single'">
|
||||
<div class="panel">
|
||||
<div class="panel__head">
|
||||
<div>
|
||||
@@ -708,6 +708,9 @@ b {
|
||||
.row--half {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.row--single {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* ── KPI cards ── */
|
||||
.kpi-grid {
|
||||
|
||||
@@ -77,12 +77,17 @@
|
||||
<a-input v-model:value="form.title" placeholder="مثلاً: گردنبند طلا" :disabled="accountingLocked" />
|
||||
</a-form-item>
|
||||
|
||||
<div class="pf-grid-2">
|
||||
<a-form-item label="کد محصول" :help="accountingLocked ? 'از نرمافزار حسابداری' : undefined">
|
||||
<!-- site fields (نوع نمایش، شرح سایت، تصویر شاخص و گالری سایت) — super admin only -->
|
||||
<div :class="{ 'pf-grid-2': users.isSuperAdmin }">
|
||||
<a-form-item
|
||||
label="کد محصول"
|
||||
:class="{ 'mb-0': !users.isSuperAdmin }"
|
||||
:help="accountingLocked ? 'از نرمافزار حسابداری' : undefined"
|
||||
>
|
||||
<a-input v-model:value="form.product_code" :disabled="accountingLocked" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="نوع نمایش">
|
||||
<a-form-item v-if="users.isSuperAdmin" label="نوع نمایش">
|
||||
<a-select v-model:value="form.show_type">
|
||||
<a-select-option :value="1">سایت</a-select-option>
|
||||
<a-select-option :value="2">گوشی</a-select-option>
|
||||
@@ -91,7 +96,7 @@
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<a-form-item label="شرح محصول (سایت)" class="mb-0">
|
||||
<a-form-item v-if="users.isSuperAdmin" label="شرح محصول (سایت)" class="mb-0">
|
||||
<a-textarea
|
||||
v-model:value="form.body_html"
|
||||
:auto-size="{ minRows: 5, maxRows: 12 }"
|
||||
@@ -193,19 +198,21 @@
|
||||
<span class="pf-section__icon"><PictureOutlined /></span>
|
||||
<div>
|
||||
<h3>گالری تصاویر</h3>
|
||||
<p>تصاویر جداگانه برای نمایش در سایت و اپلیکیشن موبایل</p>
|
||||
<p>{{ users.isSuperAdmin ? 'تصاویر جداگانه برای نمایش در سایت و اپلیکیشن موبایل' : 'تصاویر نمایش در اپلیکیشن موبایل' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-form-item label="گالری تصاویر سایت">
|
||||
<GalleryUploadInput
|
||||
v-model="gallerySite"
|
||||
purpose="product.gallery"
|
||||
placeholder="افزودن تصاویر گالری سایت"
|
||||
/>
|
||||
</a-form-item>
|
||||
<template v-if="users.isSuperAdmin">
|
||||
<a-form-item label="گالری تصاویر سایت">
|
||||
<GalleryUploadInput
|
||||
v-model="gallerySite"
|
||||
purpose="product.gallery"
|
||||
placeholder="افزودن تصاویر گالری سایت"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<div class="pf-divider"></div>
|
||||
<div class="pf-divider"></div>
|
||||
</template>
|
||||
|
||||
<a-form-item label="گالری تصاویر موبایل" class="mb-0">
|
||||
<GalleryUploadInput
|
||||
@@ -286,7 +293,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-uploads">
|
||||
<a-form-item label="سایت" class="mb-0">
|
||||
<a-form-item v-if="users.isSuperAdmin" label="سایت" class="mb-0">
|
||||
<ImageUploadInput v-model="form.indicator_id" purpose="product.indicator" placeholder="تصویر شاخص" />
|
||||
</a-form-item>
|
||||
<a-form-item label="موبایل" class="mb-0">
|
||||
@@ -383,7 +390,7 @@
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-form>
|
||||
<p class="variant-form__hint">تصویر بارگذاریشده به گالری سایت محصول هم اضافه میشود.</p>
|
||||
<p v-if="users.isSuperAdmin" class="variant-form__hint">تصویر بارگذاریشده به گالری سایت محصول هم اضافه میشود.</p>
|
||||
<template #footer>
|
||||
<a-button @click="variantModal = false">انصراف</a-button>
|
||||
<a-button type="primary" @click="saveVariant">ثبت</a-button>
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
<a-descriptions-item label="دسته بندی">
|
||||
{{ tempRow.category?.title || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="نوع نمایش">{{ tempRow.show_type_str }}</a-descriptions-item>
|
||||
<a-descriptions-item v-if="users.isSuperAdmin" label="نوع نمایش">{{ tempRow.show_type_str }}</a-descriptions-item>
|
||||
<a-descriptions-item label="تعداد تنوع">
|
||||
{{ tempRow.variants_all?.length ?? tempRow.variants_count ?? 0 }}
|
||||
</a-descriptions-item>
|
||||
@@ -129,7 +129,7 @@ const router = useRouter();
|
||||
const users = useUsersStore();
|
||||
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
|
||||
|
||||
const model = buildProductListModel();
|
||||
const model = buildProductListModel(users.isSuperAdmin);
|
||||
const tblProducts = ref<{ fetch: () => Promise<void> } | null>(null);
|
||||
const selectedRows = ref<Record<string, any>[]>([]);
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
// ColCreator block above the live XTbl definition — not revived.
|
||||
import XTbl, { Xtc } from '../../../components/x-table/model';
|
||||
|
||||
export function buildProductListModel(): XTbl {
|
||||
// `withShowType` — the display channel is a site field, shown to the super admin only.
|
||||
export function buildProductListModel(withShowType = true): XTbl {
|
||||
const tbl = new XTbl('admin/products/list', 'لیست محصولات');
|
||||
tbl.add(new Xtc('id', 'کد').filterNumber());
|
||||
tbl.add(new Xtc('ProductImage', 'تصویر محصول').renderSlot('image').noSort());
|
||||
@@ -14,15 +15,17 @@ export function buildProductListModel(): XTbl {
|
||||
// legacy key is 'category.collection.title'; the mapping layer aliases it
|
||||
tbl.add(new Xtc('category.collection.title', 'دسته بندی').filterText());
|
||||
tbl.add(new Xtc('product_code', 'کد محصول').filterText());
|
||||
tbl.add(
|
||||
new Xtc('show_type', 'نوع نمایش')
|
||||
.filterSelect({
|
||||
1: 'سایت',
|
||||
2: 'گوشی',
|
||||
3: 'سایت و گوشی',
|
||||
})
|
||||
.renderSlot('show_type'),
|
||||
);
|
||||
if (withShowType) {
|
||||
tbl.add(
|
||||
new Xtc('show_type', 'نوع نمایش')
|
||||
.filterSelect({
|
||||
1: 'سایت',
|
||||
2: 'گوشی',
|
||||
3: 'سایت و گوشی',
|
||||
})
|
||||
.renderSlot('show_type'),
|
||||
);
|
||||
}
|
||||
// D27 — stock in the accounting software, and whether the app shows it at all.
|
||||
tbl.add(new Xtc('accounting_stock', 'حسابداری / نمایش').renderSlot('accounting').noSort());
|
||||
tbl.add(new Xtc('action', 'عملیات').renderSlot('op').noSort());
|
||||
|
||||
@@ -125,13 +125,15 @@ import { CloseOutlined, DownOutlined } from '@ant-design/icons-vue';
|
||||
import XTable from '../../../components/x-table/XTable.vue';
|
||||
import { buildProductListModel } from '../../apps/product/productListModel';
|
||||
import { useCartStore, type CartItem, type CartVariant } from '../../../stores/cart';
|
||||
import { useUsersStore } from '../../../stores/users';
|
||||
import api from '../../../api/client';
|
||||
|
||||
const router = useRouter();
|
||||
const cart = useCartStore();
|
||||
const users = useUsersStore();
|
||||
|
||||
const noImage = '/img/no-items.png';
|
||||
const cols = buildProductListModel();
|
||||
const cols = buildProductListModel(users.isSuperAdmin);
|
||||
const tblProducts = ref<{ fetch: () => Promise<void> } | null>(null);
|
||||
const loading = ref(false);
|
||||
const customers = ref<{ id: string; fname: string; lname: string }[]>([]);
|
||||
|
||||
@@ -94,13 +94,8 @@
|
||||
<a-button class="mt-4" type="primary" @click="saveTags">ذخیره برچسب ها</a-button>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- اسلایدر موبایل — دسترسی جدا از بقیهی این صفحه، چون آپلود بنر عمومی
|
||||
سایت حساستر از ویرایش برند/برچسب است (custom.config.statics.slider) -->
|
||||
<a-tab-pane
|
||||
v-if="hasPermission('custom.config.statics.slider')"
|
||||
key="slider"
|
||||
tab="اسلایدر موبایل"
|
||||
>
|
||||
<!-- اسلایدر موبایل — برای هر کسی که این صفحه را میبیند (config.statics) -->
|
||||
<a-tab-pane key="slider" tab="اسلایدر موبایل">
|
||||
<div class="slider-toolbar">
|
||||
<span class="slider-count">{{ sliders.length }} اسلاید</span>
|
||||
<div class="row">
|
||||
@@ -309,11 +304,8 @@ function removeSlide(index: number) {
|
||||
sliders.value.splice(index, 1);
|
||||
}
|
||||
|
||||
// Gated server-side by its own `custom.config.statics.slider` permission (not
|
||||
// the broad `config.statics` that guards the rest of this screen) — see
|
||||
// admin-misc.controller.ts. Uses the dedicated `const/slider` route rather
|
||||
// than the generic `const/:key`, which the server only accepts under that
|
||||
// specific permission.
|
||||
// Uses the dedicated `const/slider` route: the generic `const/:key` refuses the
|
||||
// slider's key (admin-misc.controller.ts).
|
||||
async function saveSliders() {
|
||||
try {
|
||||
await api.put('const/slider', { val: sliders.value });
|
||||
@@ -371,12 +363,9 @@ onMounted(async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
await Promise.all([loadBrands(), users.isSuperAdmin ? loadCollections() : Promise.resolve()]);
|
||||
const [t, s] = await Promise.all([
|
||||
api.get('const/__list__tags'),
|
||||
hasPermission('custom.config.statics.slider') ? api.get('const/slider') : Promise.resolve(null),
|
||||
]);
|
||||
const [t, s] = await Promise.all([api.get('const/__list__tags'), api.get('const/slider')]);
|
||||
tags.value = Array.isArray(t.data) ? t.data : [];
|
||||
sliders.value = Array.isArray(s?.data) ? s.data : [];
|
||||
sliders.value = Array.isArray(s.data) ? s.data : [];
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
} finally {
|
||||
|
||||
@@ -216,7 +216,7 @@ const cart = useCartStore();
|
||||
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
|
||||
|
||||
const noImage = '/img/no-items.png';
|
||||
const cols = buildProductListModel();
|
||||
const cols = buildProductListModel(users.isSuperAdmin);
|
||||
const tblProducts = ref<{ fetch: () => Promise<void> } | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user