admin user and ui panel

This commit is contained in:
s.esmaeili
2026-09-21 08:32:31 +03:30
parent 79226c9f13
commit ef1d4638cc
341 changed files with 2270 additions and 657 deletions
@@ -65,6 +65,14 @@ export class AdminCustomersController {
return this.customers.listSingle(query, admin.branchId ?? undefined);
}
/** Legacy `POST single` — singleCustomer.vue, the retail customer form. */
@Post('single')
@HttpCode(200)
@RequirePermission('persons.create')
createSingle(@Body() data: Record<string, unknown>) {
return this.customers.createSingle(data);
}
/**
* Legacy `GET query/reagent` — the sales-expert picker used by the customer
* form. Returns a plain array of {id, name}, as legacy did.
@@ -104,6 +112,13 @@ export class AdminCustomersController {
return this.customers.listReagents(query);
}
/** A sales agent's own detail screen — see reagentDetail()'s header comment. */
@Get('reagent/:id')
@RequirePermission('persons.list.sellers.view')
reagentDetail(@Param('id') id: string) {
return this.customers.reagentDetail(id);
}
/** Legacy `GET single/order/{id}` — that customer's orders. */
@Get(':id/orders')
@RequirePermission('persons.list.client.omde.view.orders')
@@ -22,14 +22,19 @@ import type { TableQueryDto } from '../admin-table/table-query.dto';
* `type` and Farsi `type_str` the ported grids bind to.
*/
export const CUSTOMER_TYPE_STR: Record<CustomerKind, string> = {
SALES_AGENT: ' کارشناس فروش',
SALES_AGENT: ' کارشناس فروش', // legacy value only — see CustomerKind's schema comment
APP_SHOPKEEPER: 'مغازه دار اپلیکیشن',
WALK_IN_SHOPKEEPER: ' مغازه دار حضوری',
BANKER: 'بنکدار',
WORKSHOP_SUPERVISOR: 'سرپرست کارگاه',
RETAIL: 'مشتری تک',
};
const LEGACY_TYPE_ID: Record<CustomerKind, number> = {
// legacy had no numeric type for RETAIL (it lived in a separate table/model
// entirely — see listSingle()'s header comment), so it is deliberately absent
// here; legacyTypeToKind() returns undefined for it and kindToLegacyType()
// is never called with it.
const LEGACY_TYPE_ID: Partial<Record<CustomerKind, number>> = {
SALES_AGENT: 1,
APP_SHOPKEEPER: 2,
WALK_IN_SHOPKEEPER: 3,
@@ -43,7 +48,7 @@ export const legacyTypeToKind = (t: unknown): CustomerKind | undefined =>
(k) => LEGACY_TYPE_ID[k] === Number(t),
);
export const kindToLegacyType = (k: CustomerKind): number => LEGACY_TYPE_ID[k];
export const kindToLegacyType = (k: CustomerKind): number | null => LEGACY_TYPE_ID[k] ?? null;
export type OmdeScope = 'all' | 'banker' | 'shopkeeper' | 'non_verified' | 'archived';
@@ -378,14 +383,85 @@ export class AdminCustomersService {
return this.table.respond(mapped, total, query, page, limit);
}
/**
* The sales agent's own detail screen ("مشاهده اطلاعات" on ReagentList.vue).
*
* A sales agent is an AdminUser now, not a Customer, so it has no debt/
* ledger of its own — "سند های مالی" doesn't apply the way it does for a
* wholesale customer. This shows the agent's profile plus the customers
* they referred and the orders those customers placed instead.
*/
async reagentDetail(id: string) {
const agent = await this.prisma.adminUser.findUnique({
where: { id, role: 'SALES_EXPERT' },
include: { branch: true, avatarFile: true },
});
if (!agent) throw new NotFoundException('کارشناس فروش یافت نشد');
const referred = await this.prisma.customer.findMany({
where: { referredByAgentId: id },
orderBy: { createdAt: 'desc' },
include: { city: true },
});
const orders = await this.prisma.order.findMany({
where: { customer: { referredByAgentId: id } },
orderBy: { createdAt: 'desc' },
take: 100,
include: { customer: true },
});
const gramsSoldAgg = await this.prisma.order.aggregate({
where: { customer: { referredByAgentId: id }, ...AdminCustomersService.SOLD },
_sum: { totalGoldWeightGrams: true },
});
return {
id: agent.id,
name: agent.fullName,
mobile: agent.mobile,
percent: agent.wagePercent,
active: agent.isActive ? 1 : -1,
branch: agent.branch ? { id: agent.branch.id, name: agent.branch.name } : null,
image: agent.avatarFile ? { id: agent.avatarFile.id, image: agent.avatarFile.url } : null,
caj: agent.createdAt,
grams_sell: gramsSoldAgg._sum.totalGoldWeightGrams ?? 0,
referred_customers: referred.map((c) => ({
id: c.id,
name: c.fullName,
mobile: c.mobile,
city: c.city ? { id: c.city.id, title: c.city.name } : null,
type_str: CUSTOMER_TYPE_STR[c.kind],
active: c.isActive ? 1 : -1,
caj: c.createdAt,
})),
orders: orders.map((o) => ({
id: o.id,
name: o.orderNumber,
customer: o.customer.fullName,
w: o.totalGoldWeightGrams,
status: o.status,
caj: o.createdAt,
})),
};
}
/** Orders that reached debt-recognition, reused by reagentDetail(). */
private static readonly SOLD: Prisma.OrderWhereInput = {
status: { in: ['DELIVERED_TO_AGENT', 'DELIVERED_TO_CUSTOMER'] },
};
/**
* Legacy `POST single-customer` — the retail ("تک") customer list.
*
* Legacy read this from a different table than the wholesale list
* (`orders_orders`-era SingleCustomerOrders lived alongside a separate customer
* record). The redesigned schema has one Customer model, so both lists read the
* same rows; only the column set and actions differ, exactly as the two legacy
* table models did. Same gap noted for orders in docs/ADMIN-PORT-PLAN.md.
* Legacy read this from a COMPLETELY SEPARATE table (SingleCustomer / `login`)
* that could never contain a wholesale/agent/shopkeeper/banker row — the
* separation was structural, not a WHERE-clause discipline. The redesigned
* schema folded every kind into one Customer model, so this list MUST filter
* on `kind: RETAIL` to reproduce that same separation; without it, every
* Customer row (including a mis-created sales agent — see admin-users
* module's header comment) leaked in here. Filtered explicitly rather than
* inferred, so a future creation-path bug can't quietly widen this list again.
*/
async listSingle(query: TableQueryDto, branchId?: string) {
this.table.assertNotExport(query);
@@ -397,9 +473,13 @@ export class AdminCustomersService {
defaultOrderBy: { createdAt: 'desc' },
});
const scoped: Prisma.CustomerWhereInput = {
AND: [where as Prisma.CustomerWhereInput, { kind: CustomerKind.RETAIL }],
};
const [rows, total] = await Promise.all([
this.prisma.customer.findMany({
where: where as Prisma.CustomerWhereInput,
where: scoped,
orderBy: orderBy as Prisma.CustomerOrderByWithRelationInput,
skip,
take,
@@ -409,7 +489,7 @@ export class AdminCustomersService {
referredByAgent: { include: { branch: true } },
},
}),
this.prisma.customer.count({ where: where as Prisma.CustomerWhereInput }),
this.prisma.customer.count({ where: scoped }),
]);
return this.table.respond(
@@ -428,7 +508,42 @@ export class AdminCustomersService {
);
}
/** Legacy `POST customer/create` (omdeSave) and `POST/PUT user/profile`. */
/**
* Legacy `POST single` — singleCustomer.vue, the retail ("تک") customer
* form. This endpoint never existed before (singleCustomer.vue posted to
* a path nothing served — see this method's git history / the bug report
* that led here); its edit path happened to work only because `update()`
* is generic enough to patch any Customer row, agent or otherwise.
*/
async createSingle(data: Record<string, unknown>) {
const fullName = String(data.name ?? data.fname ?? '').trim();
const mobile = String(data.mobile ?? data.phone ?? '').trim();
if (!fullName || !mobile) {
throw new BadRequestException('نام و موبایل الزامی است');
}
return this.prisma.customer.create({
data: {
fullName,
mobile,
nationalCode: mobile, // schema requires it and it is unique; singleCustomer.vue collects no ncode
passwordHash: '',
cityId: (data.city_id as string) || null,
kind: CustomerKind.RETAIL,
},
});
}
/**
* Legacy `POST customer/create` (omdeSave) and `POST/PUT user/profile`.
*
* Sales agents (`type`/`kind` SALES_AGENT) are REJECTED here — a sales
* agent is an AdminUser (role SALES_EXPERT) now, created via
* `POST admin/users`, not a Customer row. bulkCustomer.vue no longer offers
* that option; this guard is defense-in-depth against any other caller
* (e.g. reg.vue, or a stale client) still sending type=1.
*/
async create(data: Record<string, unknown>) {
const fullName = String(data.name ?? data.fname ?? '').trim();
const mobile = String(data.mobile ?? '').trim();
@@ -438,6 +553,13 @@ export class AdminCustomersService {
throw new BadRequestException('نام و موبایل الزامی است');
}
const kind = legacyTypeToKind(data.type) ?? CustomerKind.APP_SHOPKEEPER;
if (kind === CustomerKind.SALES_AGENT) {
throw new BadRequestException(
'کارشناس فروش از این فرم ثبت نمی‌شود. از بخش «افزودن مدیر» با نقش «کارشناس فروش» استفاده کنید.',
);
}
return this.prisma.customer.create({
data: {
fullName,
@@ -454,7 +576,7 @@ export class AdminCustomersService {
: new Prisma.Decimal(0),
// legacy `type` — defaults to 2 (مغازه دار اپلیکیشن), matching the
// main omde list, when the form omits it.
kind: legacyTypeToKind(data.type) ?? CustomerKind.APP_SHOPKEEPER,
kind,
phone: (data.phone as string) ?? null,
address: (data.address as string) ?? null,
storeName: (data.store_name as string) ?? null,
@@ -463,6 +585,12 @@ export class AdminCustomersService {
memberCode: (data.mcode as string) ?? null,
// legacy `image_id` from MyUpload's `ok` payload
avatarFileId: (data.image_id as string) || null,
// The three document photos the wholesale form collects. These were
// already read back in toRow() but never persisted, so every upload was
// silently discarded on save.
nationalIdImageUrl: (data.national_id_image as string) || null,
businessLicenseUrl: (data.business_license as string) || null,
shopImageUrl: (data.shop_image as string) || null,
// The sales agent who introduced this customer. The form's dropdown was
// populated from a working endpoint and the selection then thrown away.
referredByAgentId: (data.reagent_id as string) || null,
@@ -487,6 +615,13 @@ export class AdminCustomersService {
if (data.type !== undefined) {
const kind = legacyTypeToKind(data.type);
if (!kind) throw new BadRequestException('نوع مشتری نامعتبر است');
// See create()'s header comment — a Customer row must never become a
// sales agent; that's an AdminUser (role SALES_EXPERT) now.
if (kind === CustomerKind.SALES_AGENT) {
throw new BadRequestException(
'کارشناس فروش از این فرم ویرایش نمی‌شود. از بخش «افزودن مدیر» با نقش «کارشناس فروش» استفاده کنید.',
);
}
patch.kind = kind;
}
if (data.phone !== undefined) patch.phone = String(data.phone);
@@ -510,6 +645,17 @@ export class AdminCustomersService {
? { connect: { id: String(data.image_id) } }
: { disconnect: true };
}
// Same three document photos as in create(). '' means "removed", so these
// normalise to null rather than storing an empty string.
if (data.national_id_image !== undefined) {
patch.nationalIdImageUrl = (data.national_id_image as string) || null;
}
if (data.business_license !== undefined) {
patch.businessLicenseUrl = (data.business_license as string) || null;
}
if (data.shop_image !== undefined) {
patch.shopImageUrl = (data.shop_image as string) || null;
}
return this.prisma.customer.update({ where: { id }, data: patch });
}