Fix wholesale app orders, polish admin/user chrome, and harden order detail UX.

Customer placements now store WHOLESALE (with a migration for prior RETAIL rows), order status opens in a right drawer, logout asks for confirm, and the dashboard profile nudge plus IRANSans panel typeface are tightened for readability.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
s.esmaeili
2026-10-16 23:39:15 +03:30
co-authored by Cursor
parent f444a19845
commit 7fca434da6
32 changed files with 1755 additions and 527 deletions
@@ -0,0 +1,11 @@
-- App-placed orders were defaulting to RETAIL while operators watch the
-- WHOLESALE (عمده) list. Move every existing RETAIL row onto WHOLESALE so they
-- appear in «لیست سفارش های عمده». Going forward, customer POST /orders also
-- stores WHOLESALE (see OrdersService.createOrder).
--
-- Legitimate admin-created orders were already WHOLESALE. RETAIL was only the
-- mistaken app default (and optional seed demos).
UPDATE "Order"
SET "channel" = 'WHOLESALE'
WHERE "channel" = 'RETAIL';
+4 -2
View File
@@ -545,7 +545,9 @@ async function main() {
orderNumber: 'ORD-SEED-0002',
customerId: customer2.id,
branchId: branchTehran.id,
channel: OrderChannel.RETAIL,
// App / customer channel is wholesale (عمده); keep seed aligned with
// OrdersService customer placements and the omde admin list.
channel: OrderChannel.WHOLESALE,
status: 'PLACED',
totalGoldWeightGrams: computed,
wageWeightGrams: (parseFloat(computed) - parseFloat(rash)).toFixed(4),
@@ -577,7 +579,7 @@ async function main() {
});
await prisma.order.updateMany({
where: { orderNumber: 'ORD-SEED-0002' },
data: { channel: OrderChannel.RETAIL },
data: { channel: OrderChannel.WHOLESALE },
});
// ── A delivered order + posted debt, so the dashboard shows real numbers ──
@@ -36,6 +36,23 @@ const STATUS_FLOW: OrderStatus[] = [
OrderStatus.DELIVERED_TO_CUSTOMER,
];
/** Shared include for list + detail rows (images needed for the view modal). */
const ORDER_ROW_INCLUDE = {
customer: true,
branch: true,
salesAgent: true,
items: {
include: {
productVariant: {
include: {
image: true,
product: { include: { images: { orderBy: { sortOrder: 'asc' as const }, take: 1 } } },
},
},
},
},
} satisfies Prisma.OrderInclude;
@Injectable()
export class AdminOrdersService {
constructor(
@@ -90,11 +107,7 @@ export class AdminOrdersService {
orderBy: orderBy as Prisma.OrderOrderByWithRelationInput,
skip,
take,
include: {
customer: true,
branch: true,
items: { include: { productVariant: { include: { product: true } } } },
},
include: ORDER_ROW_INCLUDE,
}),
this.prisma.order.count({ where: scoped }),
]);
@@ -105,16 +118,18 @@ export class AdminOrdersService {
/**
* Shapes a row the way the ported list view reads it.
*
* Legacy field names are preserved (`sts_str`, `caj`, `total_price`, `items`)
* so the ported template bindings keep working unchanged.
* Legacy field names are preserved (`sts_str`, `caj`, `total_price`, `items`,
* `input`, `customer.name`) so the ported template bindings keep working.
*/
private toRow(o: Prisma.OrderGetPayload<{
include: {
customer: true;
branch: true;
items: { include: { productVariant: { include: { product: true } } } };
};
}>) {
private toRow(o: Prisma.OrderGetPayload<{ include: typeof ORDER_ROW_INCLUDE }>) {
const first = o.customer.firstName?.trim() || '';
const last = o.customer.lastName?.trim() || '';
// Empty-string parts must not win over fullName (`??` would keep "").
const fname = first || o.customer.fullName;
const lname = first ? last : '';
const weight = Number(o.totalGoldWeightGrams);
const rial = Number(o.flatRialCharge);
return {
id: o.id,
orderNumber: o.orderNumber,
@@ -122,30 +137,42 @@ export class AdminOrdersService {
sts_str: STATUS_LABELS[o.status],
caj: o.createdAt,
uaj: o.updatedAt,
total_price: o.flatRialCharge,
totalGoldWeightGrams: o.totalGoldWeightGrams,
wageWeightGrams: o.wageWeightGrams,
total_price: rial,
// Omde list column dataIndex is still `input` (legacy weight total).
input: weight,
totalGoldWeightGrams: weight,
wageWeightGrams: Number(o.wageWeightGrams),
isSuspendedOverCreditLimit: o.isSuspendedOverCreditLimit,
deliveredAt: o.deliveredAt,
branch_id: o.branchId,
branch: o.branch ? { id: o.branch.id, name: o.branch.name } : null,
customer: {
id: o.customer.id,
// Legacy split names into fname/lname; the new schema has one fullName.
// Both are exposed so ported templates binding either keep working.
fname: o.customer.firstName ?? o.customer.fullName,
lname: o.customer.lastName ?? '',
fname,
lname,
name: o.customer.fullName,
fullName: o.customer.fullName,
phone: o.customer.mobile,
mobile: o.customer.mobile,
},
items: o.items.map((it) => ({
id: it.id,
title: it.productVariant.product.title,
variant_title: it.productVariant.title,
qty: it.quantity,
grams: it.computedWeightGrams,
rash: it.rashWeightGramsSnapshot,
})),
// «ثبت کننده» / sales credit — set once at placement.
user: o.salesAgent
? { id: o.salesAgent.id, name: o.salesAgent.fullName, mobile: o.salesAgent.mobile }
: null,
items: o.items.map((it) => {
const image =
it.productVariant.image?.url ?? it.productVariant.product.images[0]?.url ?? null;
return {
id: it.id,
title: it.productVariant.product.title,
variant_title: it.productVariant.title,
qty: it.quantity,
grams: Number(it.computedWeightGrams),
rash: Number(it.rashWeightGramsSnapshot),
image,
note: '',
};
}),
};
}
@@ -195,11 +222,7 @@ export class AdminOrdersService {
orderBy: orderBy as Prisma.OrderOrderByWithRelationInput,
skip,
take,
include: {
customer: true,
branch: true,
items: { include: { productVariant: { include: { product: true } } } },
},
include: ORDER_ROW_INCLUDE,
}),
this.prisma.order.count({ where: scoped }),
]);
@@ -240,9 +263,7 @@ export class AdminOrdersService {
const order = await this.prisma.order.findUnique({
where: { id },
include: {
customer: true,
branch: true,
items: { include: { productVariant: { include: { product: true } } } },
...ORDER_ROW_INCLUDE,
statusEvents: { include: { actorAdminUser: true }, orderBy: { createdAt: 'asc' } },
},
});
@@ -47,7 +47,10 @@ export class CreateOrderDto {
/**
* Which sales channel placed the order. Legacy had no such field because the
* two channels lived in separate tables (orders vs orders_orders); the one
* Order model carries the distinction here. Defaults to RETAIL.
* Order model carries the distinction here.
*
* Customer-app placements always store WHOLESALE (legacy mobile setOrder),
* regardless of this field. Operator placements default to RETAIL when omitted.
*/
@IsOptional()
@IsEnum(OrderChannel)
@@ -141,7 +141,13 @@ export class OrdersService {
orderNumber: this.generateOrderNumber(),
customerId,
branchId: dto.branchId,
channel: dto.channel ?? OrderChannel.RETAIL,
// Customer app orders are wholesale (عمده), matching legacy mobile
// setOrder → `orders` table. Admin omde list filters WHOLESALE; the
// old RETAIL default hid every app order from that screen.
channel:
opts.placedBy === 'customer'
? OrderChannel.WHOLESALE
: (dto.channel ?? OrderChannel.RETAIL),
status: initialStatus,
totalGoldWeightGrams,
wageWeightGrams,