update admin panel, api, and shared-types

This commit is contained in:
mahmood
2026-10-25 11:43:45 +03:30
parent 2134de99e2
commit 5bd77c33d3
12 changed files with 150 additions and 91 deletions
@@ -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) {
+4 -1
View File
@@ -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 }[]>([]);
+6 -17
View File
@@ -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 {
+1 -1
View File
@@ -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);
@@ -176,27 +176,25 @@ export class AdminMiscController {
// The mobile home slider ("اسلایدر موبایل") is stored under the same
// `Setting` table as every other `const/:key` entry (key: mobile_main_slider),
// but it gets its own permission (`custom.config.statics.slider`, per
// acl.constants.ts / permissionConst.ts) rather than the broad `config.statics`
// that guards the rest of the "مدیریت ثابت ها" screen — uploading/replacing the
// public site banner is more sensitive than editing brand/tag lists, so it
// should not be reachable by everyone who can merely open that screen.
// and is open to everyone who can use the "مدیریت ثابت ها" screen
// (`config.statics`). The older `custom.config.statics.slider` rid stays in
// the catalogue so roles that still hold it keep saving, but gates nothing.
// Routes declared ahead of `const/:key` so Nest doesn't match `const/slider`
// (the legacy REST shape) to the generic key handler instead.
@Get('const/slider')
@RequirePermission('custom.config.statics.slider')
@RequirePermission('config.statics')
getSlider() {
return this.misc.getConst('mobile_main_slider');
}
@Put('const/slider')
@RequirePermission('custom.config.statics.slider')
@RequirePermission('config.statics')
setSlider(@Body() body: Record<string, unknown>) {
return this.misc.setConst('mobile_main_slider', body.val !== undefined ? body.val : body);
}
@Delete('const/slider')
@RequirePermission('custom.config.statics.slider')
@RequirePermission('config.statics')
removeSlider() {
return this.misc.removeConst('mobile_main_slider');
}
@@ -223,11 +221,8 @@ export class AdminMiscController {
return this.misc.removeConst(key);
}
// Blocks the generic `config.statics`-gated routes from reaching the slider
// setting by its raw key — otherwise anyone with `config.statics` could
// bypass the dedicated `const/slider` routes (guarded by the stricter
// `custom.config.statics.slider`) simply by calling `const/mobile_main_slider`
// directly, since the underlying Setting row is keyed the same either way.
// Keys with a dedicated route are refused here so each has exactly one way
// in: the slider (`const/slider`) and the accounting and pricing settings.
private assertNotSliderKey(key: string) {
if (key === 'mobile_main_slider' || isAccountingSettingKey(key) || key.trim().toLowerCase().startsWith(PRICING_SETTING_PREFIX)) {
throw new ForbiddenException('این تنظیم فقط از مسیر اختصاصی خود قابل دسترسی است');
@@ -6,8 +6,8 @@ import { Public } from '../auth/guards';
* Public read of the admin-managed home slider ("اسلایدر موبایل" in
* manage/Const.vue), served at GET /api/home/slider.
*
* The admin write path (const/slider, guarded by the dedicated
* `custom.config.statics.slider` permission — see admin-misc.controller.ts)
* The admin write path (const/slider, guarded by `config.statics` — see
* admin-misc.controller.ts)
* stores it under the same `Setting` row (key: mobile_main_slider) that this
* reads. Split into its own controller/prefix, rather than exposing it under
* `admin/misc`, so the shopper-facing site does not call an `/admin/...` path
@@ -5,6 +5,20 @@ import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards'
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
import { isSuperAdmin } from '../auth/super-admin';
/**
* The website side of a product — display channel, site description, site
* featured image and site gallery — is the super admin's alone. Everyone
* else's create/update reaches the service without these keys, so a create
* takes the defaults and an update leaves the stored values untouched.
*/
const SITE_FIELDS = ['show_type', 'body_html', 'description', 'indicator_id', 'images'] as const;
function withoutSiteFields(data: Record<string, unknown>, admin: AuthenticatedAdmin) {
if (isSuperAdmin(admin.mobile)) return data;
for (const key of SITE_FIELDS) delete data[key];
return data;
}
/**
* Legacy endpoints (ProductController):
* POST products -> the product grid
@@ -107,7 +121,7 @@ export class AdminProductsController {
create(@Body() data: Record<string, unknown>, @CurrentAdmin() admin: AuthenticatedAdmin) {
// Trays are super-admin-only; anyone else's product is created without them.
if (!isSuperAdmin(admin.mobile)) delete data.ciny_ids;
return this.products.create(data);
return this.products.create(withoutSiteFields(data, admin));
}
@Post('delete/:id')
@@ -131,7 +145,11 @@ export class AdminProductsController {
@Put(':id')
@RequirePermission('product.list.edit')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.products.update(id, data);
update(
@Param('id') id: string,
@Body() data: Record<string, unknown>,
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.products.update(id, withoutSiteFields(data, admin));
}
}
@@ -1,5 +1,5 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Prisma, type ProductImageRole } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
@@ -571,15 +571,25 @@ export class AdminProductsService {
* their sortOrder refreshed, genuinely new URLs are inserted, and rows the
* form dropped are deleted.
*
* The form always posts all 4 image fields together (even as empty/absent),
* so any one of them being present means this is a real submit rather than a
* partial PATCH that happens to omit the others.
* Only the slots whose key is present are reconciled; an absent key leaves
* that slot's rows alone. The form posts all 4 (empty as null / []), but the
* controller strips the site slots for anyone but the super admin, and
* those must survive the save.
*/
private async syncImages(productId: string, data: Record<string, unknown>) {
if (!['indicator_id', 'mobile_pic', 'images', 'images_mobile'].some((k) => k in data)) return;
const slotRoles: [string, ProductImageRole][] = [
['indicator_id', 'FEATURED'],
['mobile_pic', 'FEATURED_MOBILE'],
['images', 'GALLERY'],
['images_mobile', 'GALLERY_MOBILE'],
];
const roles = slotRoles.filter(([key]) => key in data).map(([, role]) => role);
if (!roles.length) return;
const desired = this.imageCreateData(data);
const current = await this.prisma.productImage.findMany({ where: { productId } });
const current = await this.prisma.productImage.findMany({
where: { productId, role: { in: roles } },
});
// (url, role) identifies an image slot. Duplicates of the same pair are
// consumed one at a time so a gallery listing the same URL twice keeps two