first version

This commit is contained in:
liu
2026-07-23 20:41:25 +08:00
parent 00a0938a1b
commit 10cff754a4
211 changed files with 11577 additions and 842 deletions
+100
View File
@@ -0,0 +1,100 @@
import axios from 'axios';
/**
* 公共文件下载工具(blob 请求 + 触发浏览器保存)
*
* - 成功:解析响应头 Content-Disposition 的 filenameRFC 5987 `filename*=UTF-8''` 优先),兜底用 fallbackName
* - 失败兜底:后端业务错误也以 blob 返回(JSON),blob.type 为 application/json 时解析 msg 提示
* - 401:清 token 跳登录页(与 createAxios 行为一致)
*/
export async function downloadBlob(
url: string,
params: Record<string, unknown>,
fallbackName: string
): Promise<void> {
const token = localStorage.getItem('token');
let blob: Blob;
let disposition = '';
try {
const response = await axios.get(url, {
baseURL: import.meta.env.VITE_BASE_URL || '',
params,
responseType: 'blob',
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
blob = response.data as Blob;
disposition = (response.headers['content-disposition'] as string) || '';
} catch (err: any) {
const status = err?.response?.status;
const errBlob: Blob | undefined = err?.response?.data;
if (status === 401) {
window.$message?.error('您未登录,或者登录已经超时,请先登录!');
localStorage.removeItem('token');
localStorage.removeItem('auth-storage');
window.location.href = '/login';
return;
}
if (errBlob instanceof Blob) {
await showBlobError(errBlob);
return;
}
window.$message?.error('下载失败,请稍后重试');
return;
}
// 后端业务错误以 JSON blob 返回
if (blob.type.includes('application/json')) {
await showBlobError(blob);
return;
}
const filename = parseFilename(disposition) || fallbackName;
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(objectUrl);
}
/**
* 解析 blob 形式的错误响应并提示
*/
async function showBlobError(blob: Blob): Promise<void> {
try {
const body = JSON.parse(await blob.text());
window.$message?.error(body?.msg || '导出失败');
} catch {
window.$message?.error('导出失败');
}
}
/**
* 从 Content-Disposition 解析文件名:RFC 5987 filename*=UTF-8'' 优先,其次 filename="..."
*/
function parseFilename(disposition: string): string | null {
if (!disposition) {
return null;
}
const rfc5987 = disposition.match(/filename\*\s*=\s*(?:UTF-8|utf-8)''([^;]+)/i);
if (rfc5987?.[1]) {
try {
return decodeURIComponent(rfc5987[1].trim());
} catch {
return null;
}
}
const plain = disposition.match(/filename\s*=\s*"?([^";]+)"?/i);
if (plain?.[1]) {
try {
return decodeURIComponent(plain[1].trim());
} catch {
return plain[1].trim();
}
}
return null;
}
+10
View File
@@ -0,0 +1,10 @@
import createAxios from '@/utils/request';
import type ICustomerLevel from '@/domain/iCustomerLevel.ts';
/** 客户等级下拉选项(门店表单 / 价格矩阵用) */
export async function getLevelOptions() {
return createAxios<ICustomerLevel[]>({
url: '/customer/level/options',
method: 'get',
});
}
+26
View File
@@ -0,0 +1,26 @@
import createAxios from '@/utils/request';
export interface MiniUserBindParams {
/** 1门店 2供应商 */
type: number;
store_id?: number;
supplier_id?: number;
}
/** 绑定门店/供应商 */
export async function bindMiniUser(id: number, data: MiniUserBindParams) {
return createAxios({
url: `/customer/miniUser/${id}/bind`,
method: 'put',
data,
});
}
/** 启用/停用小程序用户 */
export async function toggleMiniUserStatus(id: number, status: number) {
return createAxios({
url: `/customer/miniUser/${id}/status`,
method: 'put',
data: { status },
});
}
+10
View File
@@ -0,0 +1,10 @@
import createAxios from '@/utils/request';
import type IStore from '@/domain/iStore.ts';
/** 门店下拉选项(小程序用户绑定、订单筛选用) */
export async function getStoreOptions() {
return createAxios<IStore[]>({
url: '/customer/store/options',
method: 'get',
});
}
+10
View File
@@ -0,0 +1,10 @@
import createAxios from '@/utils/request';
import type ISupplier from '@/domain/iSupplier.ts';
/** 供应商下拉选项 */
export async function getSupplierOptions() {
return createAxios<ISupplier[]>({
url: '/customer/supplier/options',
method: 'get',
});
}
+28
View File
@@ -0,0 +1,28 @@
import createAxios from '@/utils/request';
import type IStoreOrder from '@/domain/iStoreOrder.ts';
import type { IOrderSummaryRow } from '@/domain/iStoreOrder.ts';
/** 订单详情(头 + 明细) */
export async function getStoreOrder(id: number) {
return createAxios<IStoreOrder>({
url: `/order/store/${id}`,
method: 'get',
});
}
/** 订单状态流转(2配送中 3已完成 9取消) */
export async function updateOrderStatus(id: number, status: number) {
return createAxios({
url: `/order/store/${id}/status`,
method: 'put',
data: { status },
});
}
/** 待汇总预览(按商品聚合) */
export async function getOrderSummary() {
return createAxios<IOrderSummaryRow[]>({
url: '/order/store/summary',
method: 'get',
});
}
+18
View File
@@ -0,0 +1,18 @@
import createAxios from '@/utils/request';
import type IProductCategory from '@/domain/iProductCategory.ts';
/** 分类级联树(商品表单分类下拉、对账筛选用,仅启用分类) */
export async function getCategoryTree() {
return createAxios<IProductCategory[]>({
url: '/product/category/tree',
method: 'get',
});
}
/** 完整分类树(后台树表展示) */
export async function getCategoryTable() {
return createAxios<IProductCategory[]>({
url: '/product/category',
method: 'get',
});
}
+35
View File
@@ -0,0 +1,35 @@
import createAxios from '@/utils/request';
import type IProduct from '@/domain/iProduct.ts';
import type { IBatchPriceUpdate, IPriceMatrix } from '@/domain/iProduct.ts';
export interface PriceMatrixParams {
category_id?: number;
keyword?: string;
}
/** A2 价格矩阵:行=商品,列=启用等级,值=price(缺失 null */
export async function getPriceMatrix(params?: PriceMatrixParams) {
return createAxios<IPriceMatrix>({
url: '/product/goods/priceMatrix',
method: 'get',
params,
});
}
/** A2 批量调价(提交后给受影响门店生成价格变更通知) */
export async function batchPrice(updates: IBatchPriceUpdate[]) {
return createAxios({
url: '/product/goods/batchPrice',
method: 'put',
data: { updates },
});
}
/** 商品下拉选项(仅上架) */
export async function getProductOptions(keyword?: string) {
return createAxios<IProduct[]>({
url: '/product/goods/options',
method: 'get',
params: keyword ? { keyword } : {},
});
}
+70
View File
@@ -0,0 +1,70 @@
import createAxios from '@/utils/request';
import type {
ExportFormat,
IAllocationResult,
IPurchaseOrderItem,
PurchaseExportType,
} from '@/domain/iPurchaseOrder.ts';
import { downloadBlob } from '@/api/common/download.ts';
export interface PurchaseItemUpdateParams {
product_name?: string;
product_spec?: string;
price: number | string;
quantity: number | string;
weight?: number | string;
remark?: string;
}
/** C1 按门店订单汇总生成采购单 */
export async function generatePurchase(purchase_date: string) {
return createAxios<{ id: number; purchase_no: string }>({
url: '/purchase/order/generate',
method: 'post',
data: { purchase_date },
});
}
/** C2/C3 导出采购单(blob 下载) */
export async function exportPurchase(id: number, type: PurchaseExportType, format: ExportFormat) {
return downloadBlob(
`/purchase/order/${id}/export`,
{ type, format },
`采购单_${id}.${format}`
);
}
/** C4 修改采购明细(amount 由后端重算) */
export async function updatePurchaseItem(id: number, data: PurchaseItemUpdateParams) {
return createAxios<{ amount: string }>({
url: `/purchase/order/item/${id}`,
method: 'put',
data,
});
}
/** C5/C6 明细发送供应商 */
export async function sendPurchaseItem(id: number) {
return createAxios({
url: `/purchase/order/item/${id}/send`,
method: 'put',
});
}
/** D3 执行金额分摊 */
export async function allocatePurchase(id: number) {
return createAxios<{ count: number }>({
url: `/purchase/order/${id}/allocate`,
method: 'post',
});
}
/** 分摊结果(按门店 / 按商品聚合) */
export async function getAllocation(id: number) {
return createAxios<IAllocationResult>({
url: `/purchase/order/${id}/allocation`,
method: 'get',
});
}
export type { IPurchaseOrderItem };
+60
View File
@@ -0,0 +1,60 @@
import createAxios from '@/utils/request';
import type { IReconDiff } from '@/domain/iReconciliation.ts';
export interface ReconItemUpdateParams {
product_name?: string;
quantity?: number | string;
weight?: number | string;
publish_amount?: number | string;
actual_amount?: number | string;
}
/** 生成对账明细(按周期 + 品类 + 供应商拉取采购分摊数据) */
export async function buildRecon(id: number) {
return createAxios<{ count: number }>({
url: `/recon/list/${id}/build`,
method: 'post',
});
}
/** D4 修改对账明细(diff 与头汇总后端重算) */
export async function updateReconItem(id: number, data: ReconItemUpdateParams) {
return createAxios<{ diff_amount: string }>({
url: `/recon/item/${id}`,
method: 'put',
data,
});
}
/** D8 对账状态标记翻转 */
export async function toggleReconItem(id: number) {
return createAxios<{ is_reconciled: number }>({
url: `/recon/item/${id}/toggle`,
method: 'put',
});
}
/** D6 单品级门店备注 */
export async function remarkReconItem(id: number, store_remark: string) {
return createAxios({
url: `/recon/item/${id}/remark`,
method: 'put',
data: { store_remark },
});
}
/** D5 差额对比视图(按门店 / 按商品 + 合计) */
export async function getReconDiff(id: number) {
return createAxios<IReconDiff>({
url: `/recon/list/${id}/diff`,
method: 'get',
});
}
/** D9 生成结算表 */
export async function settleRecon(id: number) {
return createAxios<{ count: number }>({
url: `/recon/list/${id}/settle`,
method: 'post',
});
}
+11
View File
@@ -0,0 +1,11 @@
import type { ExportFormat } from '@/domain/iPurchaseOrder.ts';
import { downloadBlob } from '@/api/common/download.ts';
/** D10 结算表下载(blob,成功后后端回写 file_path 存档标记) */
export async function downloadSettlement(id: number, format: ExportFormat) {
return downloadBlob(
`/recon/settlement/${id}/download`,
{ format },
`结算表_${id}.${format}`
);
}
+16
View File
@@ -0,0 +1,16 @@
/** 客户等级 */
export default interface ICustomerLevel {
id?: number;
/** 等级名称 */
name?: string;
sort?: number;
status?: number;
remark?: string;
created_at?: string;
updated_at?: string;
}
export const CUSTOMER_LEVEL_STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '停用', color: 'error' },
1: { text: '正常', color: 'success' },
};
+30
View File
@@ -0,0 +1,30 @@
import type IStore from '@/domain/iStore.ts';
import type ISupplier from '@/domain/iSupplier.ts';
/** 小程序用户 */
export default interface IMiniUser {
id?: number;
nickname?: string;
phone?: string;
avatar?: string;
/** 0待绑定 1门店 2供应商 */
type?: number;
store_id?: number;
supplier_id?: number;
store?: IStore;
supplier?: ISupplier;
status?: number;
last_login_at?: string;
created_at?: string;
}
export const MINI_USER_TYPE_MAP: Record<number, { text: string; color: string }> = {
0: { text: '待绑定', color: 'default' },
1: { text: '门店', color: 'blue' },
2: { text: '供应商', color: 'purple' },
};
export const MINI_USER_STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '停用', color: 'error' },
1: { text: '正常', color: 'success' },
};
+24
View File
@@ -0,0 +1,24 @@
/** 通知(user_id=0 为全员广播) */
export default interface INotice {
id?: number;
user_id?: number;
/** order订单 price价格 system系统 */
type?: string;
title?: string;
content?: string;
data?: Record<string, unknown>;
is_read?: number;
read_at?: string;
created_at?: string;
}
export const NOTICE_TYPE_MAP: Record<string, { text: string; color: string }> = {
order: { text: '订单', color: 'blue' },
price: { text: '价格', color: 'gold' },
system: { text: '系统', color: 'default' },
};
export const NOTICE_READ_MAP: Record<number, { text: string; color: string }> = {
0: { text: '未读', color: 'warning' },
1: { text: '已读', color: 'default' },
};
+58
View File
@@ -0,0 +1,58 @@
import type IProductCategory from '@/domain/iProductCategory.ts';
import type ISupplier from '@/domain/iSupplier.ts';
/** 商品等级价格行 */
export interface IProductPrice {
id?: number;
product_id?: number;
level_id?: number;
price?: string | number;
level?: { id: number; name: string };
}
/** 商品档案 */
export default interface IProduct {
id?: number;
category_id?: number;
supplier_id?: number;
name?: string;
/** 规格/包规 */
spec?: string;
/** 商品等级 */
grade?: string;
/** 计价单位 */
unit?: string;
image?: string;
sort?: number;
status?: number;
remark?: string;
category?: IProductCategory;
supplier?: ISupplier;
/** 多等级价格 */
prices?: IProductPrice[];
created_at?: string;
}
export const PRODUCT_STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '下架', color: 'default' },
1: { text: '上架', color: 'success' },
};
/** 价格矩阵行(price_{level_id} 动态列) */
export type IPriceMatrixRow = {
id: number;
name: string;
spec?: string;
unit?: string;
} & Record<string, string | number | null | undefined>;
export interface IPriceMatrix {
levels: { id: number; name: string }[];
rows: IPriceMatrixRow[];
}
export interface IBatchPriceUpdate {
product_id: number;
level_id: number;
price: number | string;
}
+15
View File
@@ -0,0 +1,15 @@
/** 商品分类(多级,children 由后端组装) */
export default interface IProductCategory {
id?: number;
parent_id?: number;
name?: string;
sort?: number;
status?: number;
children?: IProductCategory[];
created_at?: string;
}
export const CATEGORY_STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '停用', color: 'error' },
1: { text: '正常', color: 'success' },
};
+80
View File
@@ -0,0 +1,80 @@
/** 采购分摊记录 */
export interface IPurchaseAllocation {
id?: number;
purchase_item_id?: number;
order_item_id?: number;
store_id?: number;
product_id?: number;
quantity?: string;
weight?: string;
amount?: string;
store?: { id: number; name: string };
product?: { id: number; name: string; unit: string };
}
/** 采购明细 */
export interface IPurchaseOrderItem {
id?: number;
purchase_id?: number;
product_id?: number;
supplier_id?: number;
product_name?: string;
product_spec?: string;
price?: string;
quantity?: string;
weight?: string;
amount?: string;
sort?: number;
is_sent?: number;
sent_at?: string;
supplier_confirmed_at?: string;
remark?: string;
supplier?: { id: number; name: string };
allocations?: IPurchaseAllocation[];
}
/** 采购单 */
export default interface IPurchaseOrder {
id?: number;
purchase_no?: string;
purchase_date?: string;
/** 0待发送 1部分发送 2全部发送 3已完成 */
status?: number;
total_quantity?: string;
total_weight?: string;
estimate_amount?: string;
actual_amount?: string;
operator_id?: number;
operator?: { id: number; nickname: string };
remark?: string;
items?: IPurchaseOrderItem[];
created_at?: string;
}
export const PURCHASE_STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '待发送', color: 'default' },
1: { text: '部分发送', color: 'processing' },
2: { text: '全部发送', color: 'cyan' },
3: { text: '已完成', color: 'success' },
};
/** 分摊结果聚合行 */
export interface IAllocationAggRow {
store_id?: number;
store_name?: string;
product_id?: number;
product_name?: string;
unit?: string;
quantity: number;
weight: number;
amount: number;
}
export interface IAllocationResult {
by_store: IAllocationAggRow[];
by_product: IAllocationAggRow[];
total_amount: number;
}
export type PurchaseExportType = 'all' | 'category';
export type ExportFormat = 'xlsx' | 'pdf';
+71
View File
@@ -0,0 +1,71 @@
/** 对账明细 */
export interface IReconciliationItem {
id?: number;
recon_id?: number;
store_id?: number;
purchase_item_id?: number;
order_item_id?: number;
product_id?: number;
product_name?: string;
quantity?: string;
weight?: string;
/** 公布金额(订货金额) */
publish_amount?: string;
/** 实际金额(分摊金额) */
actual_amount?: string;
/** 差额 = publish actual */
diff_amount?: string;
is_reconciled?: number;
store_remark?: string;
sort?: number;
store?: { id: number; name: string };
}
/** 对账单 */
export default interface IReconciliation {
id?: number;
recon_no?: string;
title?: string;
period_start?: string;
period_end?: string;
category_id?: number;
supplier_id?: number;
publish_amount?: string;
actual_amount?: string;
diff_amount?: string;
/** 0草稿 1对账中 2已结算 */
status?: number;
operator_id?: number;
operator?: { id: number; nickname: string };
remark?: string;
items?: IReconciliationItem[];
created_at?: string;
}
export const RECON_STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '草稿', color: 'default' },
1: { text: '对账中', color: 'processing' },
2: { text: '已结算', color: 'success' },
};
export const RECONCILED_MAP: Record<number, { text: string; color: string }> = {
0: { text: '未对账', color: 'warning' },
1: { text: '已对账', color: 'success' },
};
/** D5 差额对比视图 */
export interface IReconDiffRow {
store_id?: number;
store_name?: string;
product_id?: number;
product_name?: string;
publish: number;
actual: number;
diff: number;
}
export interface IReconDiff {
by_store: IReconDiffRow[];
by_product: IReconDiffRow[];
total: { publish: number; actual: number; diff: number };
}
+27
View File
@@ -0,0 +1,27 @@
/** 结算表 */
export default interface ISettlement {
id?: number;
settlement_no?: string;
recon_id?: number;
store_id?: number;
store?: { id: number; name: string };
recon?: { id: number; recon_no: string; title: string };
period_start?: string;
period_end?: string;
total_amount?: string;
actual_amount?: string;
diff_amount?: string;
/** 0待结算 1已结算 */
status?: number;
file_path?: string;
operator_id?: number;
operator?: { id: number; nickname: string };
settled_at?: string;
remark?: string;
created_at?: string;
}
export const SETTLEMENT_STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '待结算', color: 'default' },
1: { text: '已结算', color: 'success' },
};
+43
View File
@@ -0,0 +1,43 @@
/** 门店对账单明细 */
export interface IStatementItem {
id?: number;
statement_id?: number;
order_id?: number;
order_item_id?: number;
product_id?: number;
product_name?: string;
price?: string;
quantity?: string;
weight?: string;
amount?: string;
is_reconciled?: number;
store_remark?: string;
}
/** 门店对账单 */
export default interface IStatement {
id?: number;
statement_no?: string;
store_id?: number;
store?: { id: number; name: string };
period_start?: string;
period_end?: string;
total_amount?: string;
/** 回款周期快照(天) */
payment_cycle_days?: number;
/** 应结算日期 = period_end + 回款周期 */
settlement_date?: string;
/** 0待对账 1已对账 2已结算 */
status?: number;
reconciled_at?: string;
settled_at?: string;
remark?: string;
items?: IStatementItem[];
created_at?: string;
}
export const STATEMENT_STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '待对账', color: 'default' },
1: { text: '已对账', color: 'processing' },
2: { text: '已结算', color: 'success' },
};
+26
View File
@@ -0,0 +1,26 @@
import type ICustomerLevel from '@/domain/iCustomerLevel.ts';
/** 门店 */
export default interface IStore {
id?: number;
name?: string;
/** 门店编码 */
code?: string;
/** 客户等级ID(决定商品价格) */
level_id?: number;
level?: ICustomerLevel;
contact?: string;
phone?: string;
address?: string;
/** 回款周期(天) */
payment_cycle_days?: number;
status?: number;
remark?: string;
created_at?: string;
updated_at?: string;
}
export const STORE_STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '停用', color: 'error' },
1: { text: '正常', color: 'success' },
};
+49
View File
@@ -0,0 +1,49 @@
/** 门店订单明细(商品快照) */
export interface IStoreOrderItem {
id?: number;
order_id?: number;
store_id?: number;
product_id?: number;
product_name?: string;
product_spec?: string;
price?: string;
quantity?: string;
weight?: string;
amount?: string;
remark?: string;
}
/** 门店订单 */
export default interface IStoreOrder {
id?: number;
order_no?: string;
store_id?: number;
store?: { id: number; name: string };
order_date?: string;
total_quantity?: string;
total_weight?: string;
total_amount?: string;
/** 0待汇总 1已汇总 2配送中 3已完成 9已取消 */
status?: number;
remark?: string;
items?: IStoreOrderItem[];
created_at?: string;
}
export const STORE_ORDER_STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '待汇总', color: 'default' },
1: { text: '已汇总', color: 'processing' },
2: { text: '配送中', color: 'warning' },
3: { text: '已完成', color: 'success' },
9: { text: '已取消', color: 'error' },
};
/** 待汇总预览行 */
export interface IOrderSummaryRow {
product_id: number;
product_name: string;
product_spec: string;
unit: string;
total_quantity: string;
store_count: number;
}
+19
View File
@@ -0,0 +1,19 @@
/** 供应商 */
export default interface ISupplier {
id?: number;
name?: string;
contact?: string;
phone?: string;
address?: string;
/** 主营品类 */
main_products?: string;
status?: number;
remark?: string;
created_at?: string;
updated_at?: string;
}
export const SUPPLIER_STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '停用', color: 'error' },
1: { text: '正常', color: 'success' },
};
+93
View File
@@ -0,0 +1,93 @@
import React from 'react';
import { Tag, Typography } from 'antd';
import XinTable from '@/components/XinTable';
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
import type ICustomerLevel from '@/domain/iCustomerLevel.ts';
import { CUSTOMER_LEVEL_STATUS_MAP } from '@/domain/iCustomerLevel.ts';
const { Title, Text } = Typography;
/**
* 客户等级管理
*/
const CustomerLevelPage: React.FC = () => {
const columns: XinTableColumn<ICustomerLevel>[] = [
{
title: 'ID',
dataIndex: 'id',
hideInForm: true,
hideInSearch: true,
width: 70,
align: 'center',
},
{
title: '等级名称',
dataIndex: 'name',
valueType: 'text',
required: true,
rules: [{ required: true, message: '请输入等级名称' }],
},
{
title: '排序',
dataIndex: 'sort',
valueType: 'digit',
hideInSearch: true,
fieldProps: { min: 0 },
},
{
title: '状态',
dataIndex: 'status',
valueType: 'radioButton',
initialValue: 1,
fieldProps: {
options: [
{ value: 1, label: '正常' },
{ value: 0, label: '停用' },
],
},
render: (_, record) => {
const item = CUSTOMER_LEVEL_STATUS_MAP[record.status ?? 1];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
},
{
title: '备注',
dataIndex: 'remark',
valueType: 'textarea',
hideInSearch: true,
fieldProps: { rows: 2 },
},
{
title: '创建时间',
dataIndex: 'created_at',
hideInForm: true,
hideInSearch: true,
align: 'center',
},
];
const tableProps: XinTableProps<ICustomerLevel> = {
api: '/customer/level',
columns,
rowKey: 'id',
accessName: 'customer.level',
formProps: {
grid: true,
colProps: { span: 12 },
layout: 'vertical',
},
modalProps: { width: 640 },
};
return (
<>
<div className="mb-5">
<Title level={3}></Title>
<Text type="secondary"></Text>
</div>
<XinTable<ICustomerLevel> {...tableProps} />
</>
);
};
export default CustomerLevelPage;
+294
View File
@@ -0,0 +1,294 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Button,
Form,
message,
Modal,
Popconfirm,
Radio,
Select,
Space,
Tag,
Typography,
} from 'antd';
import XinTable from '@/components/XinTable';
import type {
XinTableColumn,
XinTableInstance,
XinTableProps,
} from '@/components/XinTable/typings.ts';
import type IMiniUser from '@/domain/iMiniUser.ts';
import { MINI_USER_STATUS_MAP, MINI_USER_TYPE_MAP } from '@/domain/iMiniUser.ts';
import type IStore from '@/domain/iStore.ts';
import type ISupplier from '@/domain/iSupplier.ts';
import { getStoreOptions } from '@/api/customer/store.ts';
import { getSupplierOptions } from '@/api/customer/supplier.ts';
import { bindMiniUser, toggleMiniUserStatus } from '@/api/customer/miniUser.ts';
import AuthButton from '@/components/AuthButton';
const { Title, Text } = Typography;
interface BindFormValues {
type: number;
store_id?: number;
supplier_id?: number;
}
/**
* 小程序用户管理(用户由小程序登录自动生成,后台只做绑定与状态管理)
*/
const MiniUserPage: React.FC = () => {
const tableRef = useRef<XinTableInstance<IMiniUser>>(null);
const [stores, setStores] = useState<IStore[]>([]);
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
// 绑定弹窗
const [bindOpen, setBindOpen] = useState(false);
const [bindTarget, setBindTarget] = useState<IMiniUser | null>(null);
const [bindLoading, setBindLoading] = useState(false);
const [bindForm] = Form.useForm<BindFormValues>();
useEffect(() => {
getStoreOptions().then((res) => setStores(res.data.data ?? []));
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
}, []);
const openBind = (record: IMiniUser) => {
setBindTarget(record);
bindForm.setFieldsValue({
type: record.type && record.type > 0 ? record.type : undefined,
store_id: record.store_id || undefined,
supplier_id: record.supplier_id || undefined,
});
setBindOpen(true);
};
const handleBind = async (values: BindFormValues) => {
if (!bindTarget?.id) {
return;
}
setBindLoading(true);
try {
await bindMiniUser(bindTarget.id, {
type: values.type,
store_id: values.type === 1 ? values.store_id : undefined,
supplier_id: values.type === 2 ? values.supplier_id : undefined,
});
message.success('绑定成功');
setBindOpen(false);
await tableRef.current?.reload();
} finally {
setBindLoading(false);
}
};
const handleToggleStatus = async (record: IMiniUser) => {
await toggleMiniUserStatus(record.id!, record.status === 1 ? 0 : 1);
message.success(record.status === 1 ? '已停用' : '已启用');
await tableRef.current?.reload();
};
const columns: XinTableColumn<IMiniUser>[] = [
{
title: 'ID',
dataIndex: 'id',
hideInSearch: true,
width: 70,
align: 'center',
},
{
title: '昵称',
dataIndex: 'nickname',
valueType: 'text',
hideInSearch: true,
render: (_, record) => (
<Space size={8}>
{record.avatar ? (
<img src={record.avatar} alt="" className="h-6 w-6 rounded-full" />
) : null}
<span>{record.nickname || '-'}</span>
</Space>
),
},
{
title: '手机号',
dataIndex: 'phone',
valueType: 'text',
hideInSearch: true,
render: (_, record) => record.phone || <Text type="secondary"></Text>,
},
{
title: '用户类型',
dataIndex: 'type',
valueType: 'select',
fieldProps: {
options: [
{ value: 0, label: '待绑定' },
{ value: 1, label: '门店' },
{ value: 2, label: '供应商' },
],
},
render: (_, record) => {
const item = MINI_USER_TYPE_MAP[record.type ?? 0];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
align: 'center',
},
{
title: '绑定主体',
dataIndex: 'bound_name',
hideInSearch: true,
render: (_, record) => {
if (record.type === 1) {
return <Tag color="blue">{record.store?.name ?? `门店#${record.store_id}`}</Tag>;
}
if (record.type === 2) {
return (
<Tag color="purple">{record.supplier?.name ?? `供应商#${record.supplier_id}`}</Tag>
);
}
return <Text type="secondary"></Text>;
},
},
{
title: '状态',
dataIndex: 'status',
valueType: 'select',
fieldProps: {
options: [
{ value: 1, label: '正常' },
{ value: 0, label: '停用' },
],
},
render: (_, record) => {
const item = MINI_USER_STATUS_MAP[record.status ?? 1];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
align: 'center',
},
{
title: '最后登录',
dataIndex: 'last_login_at',
hideInSearch: true,
align: 'center',
render: (_, record) => record.last_login_at ?? <Text type="secondary"></Text>,
},
];
const operateRender: XinTableProps<IMiniUser>['operateRender'] = (record) => [
<AuthButton key="bind" auth="customer.miniUser.bind">
<Button size="small" color="blue" variant="outlined" onClick={() => openBind(record)}>
</Button>
</AuthButton>,
<AuthButton key="status" auth="customer.miniUser.update">
<Popconfirm
title={record.status === 1 ? '确定停用该账号?' : '确定启用该账号?'}
description={record.status === 1 ? '停用后该用户将无法登录小程序' : undefined}
onConfirm={() => handleToggleStatus(record)}
>
<Button size="small" danger={record.status === 1}>
{record.status === 1 ? '停用' : '启用'}
</Button>
</Popconfirm>
</AuthButton>,
];
const tableProps: XinTableProps<IMiniUser> = {
api: '/customer/miniUser',
columns,
rowKey: 'id',
accessName: 'customer.miniUser',
tableRef,
operateRender,
// 无新增/编辑表单
formProps: false,
};
return (
<>
<div className="mb-5">
<Title level={3}></Title>
<Text type="secondary">
/
</Text>
</div>
<XinTable<IMiniUser> {...tableProps} />
<Modal
title={`绑定主体 · ${bindTarget?.nickname || ''}`}
open={bindOpen}
onCancel={() => setBindOpen(false)}
onOk={() => bindForm.submit()}
confirmLoading={bindLoading}
okText="确认绑定"
destroyOnHidden
>
<Form<BindFormValues>
form={bindForm}
layout="vertical"
onFinish={handleBind}
className="mt-4"
>
<Form.Item
label="用户类型"
name="type"
rules={[{ required: true, message: '请选择用户类型' }]}
>
<Radio.Group
options={[
{ label: '门店', value: 1 },
{ label: '供应商', value: 2 },
]}
optionType="button"
buttonStyle="solid"
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.type !== cur.type}>
{({ getFieldValue }) => {
const type = getFieldValue('type');
if (type === 1) {
return (
<Form.Item
label="绑定门店"
name="store_id"
rules={[{ required: true, message: '请选择门店' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder="选择门店"
options={stores.map((s) => ({
label: `${s.name}${s.code}`,
value: s.id,
}))}
/>
</Form.Item>
);
}
if (type === 2) {
return (
<Form.Item
label="绑定供应商"
name="supplier_id"
rules={[{ required: true, message: '请选择供应商' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder="选择供应商"
options={suppliers.map((s) => ({ label: s.name, value: s.id }))}
/>
</Form.Item>
);
}
return null;
}}
</Form.Item>
</Form>
</Modal>
</>
);
};
export default MiniUserPage;
+130
View File
@@ -0,0 +1,130 @@
import React from 'react';
import { Tag, Typography } from 'antd';
import XinTable from '@/components/XinTable';
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
import type INotice from '@/domain/iNotice.ts';
import { NOTICE_READ_MAP, NOTICE_TYPE_MAP } from '@/domain/iNotice.ts';
const { Title, Text } = Typography;
/**
* 通知管理(user_id=0 为全员广播)
*/
const NoticePage: React.FC = () => {
const columns: XinTableColumn<INotice>[] = [
{
title: 'ID',
dataIndex: 'id',
hideInForm: true,
hideInSearch: true,
width: 70,
align: 'center',
},
{
title: '标题',
dataIndex: 'title',
valueType: 'text',
required: true,
rules: [{ required: true, message: '请输入通知标题' }],
},
{
title: '类型',
dataIndex: 'type',
valueType: 'select',
initialValue: 'system',
required: true,
rules: [{ required: true, message: '请选择通知类型' }],
fieldProps: {
options: [
{ value: 'system', label: '系统' },
{ value: 'order', label: '订单' },
{ value: 'price', label: '价格' },
],
},
render: (_, record) => {
const item = NOTICE_TYPE_MAP[record.type ?? 'system'];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
},
{
title: '接收对象',
dataIndex: 'user_id',
valueType: 'digit',
hideInTable: false,
hideInSearch: true,
fieldProps: {
min: 0,
precision: 0,
placeholder: '留空或 0 = 全员广播',
},
align: 'center',
render: (_, record) =>
record.user_id === 0 ? (
<Tag color="gold">广</Tag>
) : (
<Tag>{`用户 #${record.user_id}`}</Tag>
),
},
{
title: '内容',
dataIndex: 'content',
valueType: 'textarea',
hideInSearch: true,
hideInTable: true,
fieldProps: { rows: 3 },
},
{
title: '阅读状态',
dataIndex: 'is_read',
valueType: 'select',
hideInForm: true,
fieldProps: {
options: [
{ value: 0, label: '未读' },
{ value: 1, label: '已读' },
],
},
render: (_, record) => {
const item = NOTICE_READ_MAP[record.is_read ?? 0];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
align: 'center',
},
{
title: '创建时间',
dataIndex: 'created_at',
hideInForm: true,
hideInSearch: true,
align: 'center',
},
];
const tableProps: XinTableProps<INotice> = {
api: '/customer/notice',
columns,
rowKey: 'id',
accessName: 'customer.notice',
// 通知只有新增与删除,无编辑
editShow: () => false,
formProps: {
grid: true,
colProps: { span: 12 },
layout: 'vertical',
},
modalProps: { width: 640 },
};
return (
<>
<div className="mb-5">
<Title level={3}></Title>
<Text type="secondary">
0 广
</Text>
</div>
<XinTable<INotice> {...tableProps} />
</>
);
};
export default NoticePage;
+140
View File
@@ -0,0 +1,140 @@
import React, { useEffect, useState } from 'react';
import { Tag, Typography } from 'antd';
import XinTable from '@/components/XinTable';
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
import type IStore from '@/domain/iStore.ts';
import { STORE_STATUS_MAP } from '@/domain/iStore.ts';
import type ICustomerLevel from '@/domain/iCustomerLevel.ts';
import { getLevelOptions } from '@/api/customer/level.ts';
const { Title, Text } = Typography;
/**
* 门店管理
*/
const StorePage: React.FC = () => {
const [levels, setLevels] = useState<ICustomerLevel[]>([]);
useEffect(() => {
getLevelOptions().then((res) => setLevels(res.data.data ?? []));
}, []);
const levelOptions = levels.map((l) => ({ label: l.name, value: l.id }));
const columns: XinTableColumn<IStore>[] = [
{
title: 'ID',
dataIndex: 'id',
hideInForm: true,
hideInSearch: true,
width: 70,
align: 'center',
},
{
title: '门店名称',
dataIndex: 'name',
valueType: 'text',
required: true,
rules: [{ required: true, message: '请输入门店名称' }],
},
{
title: '门店编码',
dataIndex: 'code',
valueType: 'text',
required: true,
rules: [{ required: true, message: '请输入门店编码' }],
},
{
title: '客户等级',
dataIndex: 'level_id',
valueType: 'select',
required: true,
rules: [{ required: true, message: '请选择客户等级' }],
fieldProps: {
options: levelOptions,
showSearch: true,
optionFilterProp: 'label',
},
render: (_, record) =>
record.level ? <Tag color="blue">{record.level.name}</Tag> : <Tag></Tag>,
},
{
title: '联系人',
dataIndex: 'contact',
valueType: 'text',
hideInSearch: true,
},
{
title: '联系电话',
dataIndex: 'phone',
valueType: 'text',
hideInSearch: true,
},
{
title: '回款周期(天)',
dataIndex: 'payment_cycle_days',
valueType: 'digit',
hideInSearch: true,
initialValue: 0,
fieldProps: { min: 0, precision: 0 },
align: 'center',
},
{
title: '状态',
dataIndex: 'status',
valueType: 'radioButton',
initialValue: 1,
fieldProps: {
options: [
{ value: 1, label: '正常' },
{ value: 0, label: '停用' },
],
},
render: (_, record) => {
const item = STORE_STATUS_MAP[record.status ?? 1];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
},
{
title: '门店地址',
dataIndex: 'address',
valueType: 'textarea',
hideInSearch: true,
hideInTable: true,
fieldProps: { rows: 2 },
},
{
title: '备注',
dataIndex: 'remark',
valueType: 'textarea',
hideInSearch: true,
hideInTable: true,
fieldProps: { rows: 2 },
},
];
const tableProps: XinTableProps<IStore> = {
api: '/customer/store',
columns,
rowKey: 'id',
accessName: 'customer.store',
formProps: {
grid: true,
colProps: { span: 12 },
layout: 'vertical',
},
modalProps: { width: 720 },
};
return (
<>
<div className="mb-5">
<Title level={3}></Title>
<Text type="secondary"></Text>
</div>
<XinTable<IStore> {...tableProps} />
</>
);
};
export default StorePage;
+114
View File
@@ -0,0 +1,114 @@
import React from 'react';
import { Tag, Typography } from 'antd';
import XinTable from '@/components/XinTable';
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
import type ISupplier from '@/domain/iSupplier.ts';
import { SUPPLIER_STATUS_MAP } from '@/domain/iSupplier.ts';
const { Title, Text } = Typography;
/**
* 供应商管理
*/
const SupplierPage: React.FC = () => {
const columns: XinTableColumn<ISupplier>[] = [
{
title: 'ID',
dataIndex: 'id',
hideInForm: true,
hideInSearch: true,
width: 70,
align: 'center',
},
{
title: '供应商名称',
dataIndex: 'name',
valueType: 'text',
required: true,
rules: [{ required: true, message: '请输入供应商名称' }],
},
{
title: '联系人',
dataIndex: 'contact',
valueType: 'text',
hideInSearch: true,
},
{
title: '联系电话',
dataIndex: 'phone',
valueType: 'text',
hideInSearch: true,
},
{
title: '主营品类',
dataIndex: 'main_products',
valueType: 'text',
hideInSearch: true,
render: (_, record) =>
record.main_products
? record.main_products.split('/').map((item) => (
<Tag key={item} color="green">
{item}
</Tag>
))
: '-',
},
{
title: '状态',
dataIndex: 'status',
valueType: 'radioButton',
initialValue: 1,
fieldProps: {
options: [
{ value: 1, label: '正常' },
{ value: 0, label: '停用' },
],
},
render: (_, record) => {
const item = SUPPLIER_STATUS_MAP[record.status ?? 1];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
},
{
title: '地址',
dataIndex: 'address',
valueType: 'textarea',
hideInSearch: true,
hideInTable: true,
fieldProps: { rows: 2 },
},
{
title: '备注',
dataIndex: 'remark',
valueType: 'textarea',
hideInSearch: true,
hideInTable: true,
fieldProps: { rows: 2 },
},
];
const tableProps: XinTableProps<ISupplier> = {
api: '/customer/supplier',
columns,
rowKey: 'id',
accessName: 'customer.supplier',
formProps: {
grid: true,
colProps: { span: 12 },
layout: 'vertical',
},
modalProps: { width: 720 },
};
return (
<>
<div className="mb-5">
<Title level={3}></Title>
<Text type="secondary"></Text>
</div>
<XinTable<ISupplier> {...tableProps} />
</>
);
};
export default SupplierPage;
+252
View File
@@ -0,0 +1,252 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Button,
Descriptions,
Drawer,
message,
Popconfirm,
Space,
Table,
Tag,
Typography,
} from 'antd';
import type { TableProps } from 'antd';
import XinTable from '@/components/XinTable';
import type {
XinTableColumn,
XinTableInstance,
XinTableProps,
} from '@/components/XinTable/typings.ts';
import type IStoreOrder from '@/domain/iStoreOrder.ts';
import type { IStoreOrderItem } from '@/domain/iStoreOrder.ts';
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
import { getStoreOrder, updateOrderStatus } from '@/api/order/store.ts';
import { getStoreOptions } from '@/api/customer/store.ts';
import type IStore from '@/domain/iStore.ts';
import AuthButton from '@/components/AuthButton';
const { Title, Text } = Typography;
/**
* 状态流转合法路径:待汇总→配送中/取消;已汇总→配送中;配送中→已完成
*/
const NEXT_STATUS: Record<number, { status: number; label: string; danger?: boolean }[]> = {
0: [
{ status: 2, label: '开始配送' },
{ status: 9, label: '取消订单', danger: true },
],
1: [{ status: 2, label: '开始配送' }],
2: [{ status: 3, label: '完成订单' }],
};
/**
* 门店订单管理(只读 + 状态流转,订单由小程序端创建)
*/
const StoreOrderPage: React.FC = () => {
const tableRef = useRef<XinTableInstance<IStoreOrder>>(null);
const [stores, setStores] = useState<IStore[]>([]);
const [detailOpen, setDetailOpen] = useState(false);
const [detail, setDetail] = useState<IStoreOrder | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
useEffect(() => {
getStoreOptions().then((res) => setStores(res.data.data ?? []));
}, []);
const openDetail = async (id: number) => {
setDetailOpen(true);
setDetailLoading(true);
try {
const res = await getStoreOrder(id);
setDetail(res.data.data ?? null);
} finally {
setDetailLoading(false);
}
};
const handleStatusChange = async (id: number, status: number) => {
await updateOrderStatus(id, status);
message.success('状态已更新');
setDetailOpen(false);
await tableRef.current?.reload();
};
const itemColumns: TableProps<IStoreOrderItem>['columns'] = [
{ title: '品名', dataIndex: 'product_name' },
{ title: '规格', dataIndex: 'product_spec', render: (v) => v || '-' },
{ title: '单价', dataIndex: 'price', align: 'right', render: (v) => `¥${v}` },
{ title: '订货量', dataIndex: 'quantity', align: 'right' },
{ title: '金额', dataIndex: 'amount', align: 'right', render: (v) => `¥${v}` },
{ title: '备注', dataIndex: 'remark', render: (v) => v || '-' },
];
const columns: XinTableColumn<IStoreOrder>[] = [
{
title: '订单号',
dataIndex: 'order_no',
valueType: 'text',
hideInForm: true,
render: (_, record) => <Text copyable={{ text: record.order_no }}>{record.order_no}</Text>,
},
{
title: '门店',
dataIndex: 'store_id',
valueType: 'select',
hideInForm: true,
fieldProps: {
options: stores.map((s) => ({ label: s.name, value: s.id })),
showSearch: true,
optionFilterProp: 'label',
},
render: (_, record) => record.store?.name ?? '-',
},
{
title: '订货日期',
dataIndex: 'order_date',
valueType: 'dateRange',
hideInForm: true,
align: 'center',
render: (_, record) => record.order_date,
},
{
title: '订货总量',
dataIndex: 'total_quantity',
hideInForm: true,
hideInSearch: true,
align: 'right',
},
{
title: '订单金额',
dataIndex: 'total_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) => <Text strong>¥{record.total_amount}</Text>,
},
{
title: '状态',
dataIndex: 'status',
valueType: 'select',
hideInForm: true,
fieldProps: {
options: Object.entries(STORE_ORDER_STATUS_MAP).map(([value, item]) => ({
value: Number(value),
label: item.text,
})),
},
render: (_, record) => {
const item = STORE_ORDER_STATUS_MAP[record.status ?? 0];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
align: 'center',
},
{
title: '备注',
dataIndex: 'remark',
hideInForm: true,
hideInSearch: true,
ellipsis: true,
render: (_, record) => record.remark || '-',
},
];
const operateRender: XinTableProps<IStoreOrder>['operateRender'] = (record) => [
<Button key="detail" size="small" onClick={() => openDetail(record.id!)}>
</Button>,
];
const tableProps: XinTableProps<IStoreOrder> = {
api: '/order/store',
columns,
rowKey: 'id',
accessName: 'order.store',
tableRef,
operateRender,
formProps: false,
};
return (
<>
<div className="mb-5">
<Title level={3}></Title>
<Text type="secondary">
</Text>
</div>
<XinTable<IStoreOrder> {...tableProps} />
<Drawer
title="订单详情"
open={detailOpen}
onClose={() => setDetailOpen(false)}
width={720}
loading={detailLoading}
footer={
detail && NEXT_STATUS[detail.status ?? -1] ? (
<Space className="flex justify-end">
{NEXT_STATUS[detail.status!].map((action) => (
<AuthButton key={action.status} auth="order.store.update">
<Popconfirm
title={`确认将订单状态更新为「${action.label}」?`}
onConfirm={() => handleStatusChange(detail.id!, action.status)}
>
<Button type={action.danger ? undefined : 'primary'} danger={action.danger}>
{action.label}
</Button>
</Popconfirm>
</AuthButton>
))}
</Space>
) : null
}
>
{detail ? (
<>
<Descriptions column={2} size="small" bordered>
<Descriptions.Item label="订单号">{detail.order_no}</Descriptions.Item>
<Descriptions.Item label="门店">{detail.store?.name}</Descriptions.Item>
<Descriptions.Item label="订货日期">{detail.order_date}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STORE_ORDER_STATUS_MAP[detail.status ?? 0]?.color}>
{STORE_ORDER_STATUS_MAP[detail.status ?? 0]?.text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="订货总量">{detail.total_quantity}</Descriptions.Item>
<Descriptions.Item label="订单金额">¥{detail.total_amount}</Descriptions.Item>
{detail.remark ? (
<Descriptions.Item label="备注" span={2}>
{detail.remark}
</Descriptions.Item>
) : null}
</Descriptions>
<Title level={5} className="!mt-6 !mb-3">
</Title>
<Table<IStoreOrderItem>
rowKey="id"
size="small"
columns={itemColumns}
dataSource={detail.items ?? []}
pagination={false}
summary={() => (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={4} align="right">
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
<Text strong>¥{detail.total_amount}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} />
</Table.Summary.Row>
)}
/>
</>
) : null}
</Drawer>
</>
);
};
export default StoreOrderPage;
+151
View File
@@ -0,0 +1,151 @@
import React, { useEffect, useState } from 'react';
import { Button, Space, Tag, Typography } from 'antd';
import { NodeExpandOutlined } from '@ant-design/icons';
import XinTable from '@/components/XinTable';
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
import type IProductCategory from '@/domain/iProductCategory.ts';
import { CATEGORY_STATUS_MAP } from '@/domain/iProductCategory.ts';
import { getCategoryTable } from '@/api/product/category.ts';
const { Title, Text } = Typography;
/**
* 递归收集全部节点 id(用于展开整棵树)
*/
function collectAllIds(nodes: IProductCategory[]): number[] {
const ids: number[] = [];
const walk = (list: IProductCategory[]) => {
list.forEach((node) => {
if (node.id !== undefined) {
ids.push(node.id);
}
if (node.children?.length) {
walk(node.children);
}
});
};
walk(nodes);
return ids;
}
/**
* 商品分类管理(多级分类树表)
*/
const ProductCategoryPage: React.FC = () => {
const [expandedKeys, setExpandedKeys] = useState<number[]>([]);
const [allIds, setAllIds] = useState<number[]>([]);
const [categoryTree, setCategoryTree] = useState<IProductCategory[]>([]);
const loadTree = async () => {
const res = await getCategoryTable();
const tree = res.data.data ?? [];
setCategoryTree(tree);
setAllIds(collectAllIds(tree));
};
useEffect(() => {
loadTree();
}, []);
const columns: XinTableColumn<IProductCategory>[] = [
{
title: '分类名称',
dataIndex: 'name',
valueType: 'text',
required: true,
rules: [{ required: true, message: '请输入分类名称' }],
},
{
title: '上级分类',
dataIndex: 'parent_id',
valueType: 'treeSelect',
hideInTable: true,
hideInSearch: true,
initialValue: 0,
fieldProps: {
treeData: [{ id: 0, name: '顶级分类', children: categoryTree }],
fieldNames: { label: 'name', value: 'id', children: 'children' },
placeholder: '默认顶级分类',
treeDefaultExpandAll: true,
},
},
{
title: '排序',
dataIndex: 'sort',
valueType: 'digit',
hideInSearch: true,
fieldProps: { min: 0 },
align: 'center',
},
{
title: '状态',
dataIndex: 'status',
valueType: 'radioButton',
initialValue: 1,
hideInSearch: true,
fieldProps: {
options: [
{ value: 1, label: '正常' },
{ value: 0, label: '停用' },
],
},
render: (_, record) => {
const item = CATEGORY_STATUS_MAP[record.status ?? 1];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
align: 'center',
},
];
const tableProps: XinTableProps<IProductCategory> = {
api: '/product/category',
columns,
rowKey: 'id',
accessName: 'product.category',
// 后端直接返回分类树,不走分页接口
handleRequest: async () => {
const res = await getCategoryTable();
const tree = res.data.data ?? [];
setCategoryTree(tree);
setAllIds(collectAllIds(tree));
return { data: tree, total: tree.length };
},
pagination: { pageSize: 200 },
expandable: {
expandedRowKeys: expandedKeys,
onExpandedRowsChange: (keys) => setExpandedKeys([...keys] as number[]),
},
formProps: {
grid: true,
colProps: { span: 12 },
layout: 'vertical',
},
modalProps: { width: 640 },
};
return (
<>
<div className="mb-5 flex items-start justify-between">
<div>
<Title level={3}></Title>
<Text type="secondary">
//
</Text>
</div>
<Space>
<Button
icon={<NodeExpandOutlined />}
onClick={() =>
setExpandedKeys(expandedKeys.length ? [] : allIds)
}
>
{expandedKeys.length ? '全部收起' : '全部展开'}
</Button>
</Space>
</div>
<XinTable<IProductCategory> {...tableProps} />
</>
);
};
export default ProductCategoryPage;
+414
View File
@@ -0,0 +1,414 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Drawer,
Form,
Input,
InputNumber,
message,
Select,
Space,
Table,
Tag,
TreeSelect,
Typography,
} from 'antd';
import { MinusCircleOutlined, PlusOutlined, TableOutlined } from '@ant-design/icons';
import type { TableProps } from 'antd';
import XinTable from '@/components/XinTable';
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
import type IProduct from '@/domain/iProduct.ts';
import type { IBatchPriceUpdate, IPriceMatrixRow } from '@/domain/iProduct.ts';
import { PRODUCT_STATUS_MAP } from '@/domain/iProduct.ts';
import type ICustomerLevel from '@/domain/iCustomerLevel.ts';
import type IProductCategory from '@/domain/iProductCategory.ts';
import { getLevelOptions } from '@/api/customer/level.ts';
import { getSupplierOptions } from '@/api/customer/supplier.ts';
import type ISupplier from '@/domain/iSupplier.ts';
import { getCategoryTree } from '@/api/product/category.ts';
import { batchPrice, getPriceMatrix } from '@/api/product/goods.ts';
const { Title, Text } = Typography;
/**
* 商品档案管理(A1 商品列表 / A2 价格矩阵与批量调价)
*/
const ProductGoodsPage: React.FC = () => {
const [levels, setLevels] = useState<ICustomerLevel[]>([]);
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
const [categoryTree, setCategoryTree] = useState<IProductCategory[]>([]);
// ===== 价格矩阵抽屉 =====
const [matrixOpen, setMatrixOpen] = useState(false);
const [matrixLoading, setMatrixLoading] = useState(false);
const [saveLoading, setSaveLoading] = useState(false);
const [matrixRows, setMatrixRows] = useState<IPriceMatrixRow[]>([]);
const [matrixSnapshot, setMatrixSnapshot] = useState<IPriceMatrixRow[]>([]);
const [matrixLevels, setMatrixLevels] = useState<ICustomerLevel[]>([]);
const [matrixKeyword, setMatrixKeyword] = useState('');
const [matrixCategory, setMatrixCategory] = useState<number | undefined>(undefined);
useEffect(() => {
getLevelOptions().then((res) => setLevels(res.data.data ?? []));
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
getCategoryTree().then((res) => setCategoryTree(res.data.data ?? []));
}, []);
const loadMatrix = async (
keyword = matrixKeyword,
categoryId = matrixCategory
) => {
setMatrixLoading(true);
try {
const res = await getPriceMatrix({
keyword: keyword || undefined,
category_id: categoryId,
});
const rows = res.data.data?.rows ?? [];
setMatrixRows(rows);
setMatrixSnapshot(JSON.parse(JSON.stringify(rows)));
setMatrixLevels(res.data.data?.levels ?? []);
} finally {
setMatrixLoading(false);
}
};
const openMatrix = () => {
setMatrixOpen(true);
loadMatrix('', undefined);
};
const onMatrixPriceChange = (
productId: number,
levelId: number,
value: number | null
) => {
setMatrixRows((prev) =>
prev.map((row) =>
row.id === productId ? { ...row, [`price_${levelId}`]: value } : row
)
);
};
/** diff 出被修改的价格行,提交批量调价 */
const saveMatrix = async () => {
const updates: IBatchPriceUpdate[] = [];
for (const row of matrixRows) {
const old = matrixSnapshot.find((r) => r.id === row.id);
for (const level of matrixLevels) {
const key = `price_${level.id}`;
const next = row[key];
const prev = old?.[key];
if (next !== null && next !== undefined && String(next) !== String(prev ?? '')) {
updates.push({ product_id: row.id, level_id: level.id!, price: next as number });
}
}
}
if (updates.length === 0) {
message.info('没有需要保存的价格调整');
return;
}
setSaveLoading(true);
try {
await batchPrice(updates);
message.success(`已更新 ${updates.length} 条价格,受影响门店将收到通知`);
await loadMatrix();
} finally {
setSaveLoading(false);
}
};
const matrixColumns: TableProps<IPriceMatrixRow>['columns'] = [
{
title: '商品',
dataIndex: 'name',
fixed: 'left',
width: 160,
render: (name: string, row) => (
<div>
<div className="font-medium">{name}</div>
<Text type="secondary" className="text-xs">
{row.spec}
{row.unit ? ` / ${row.unit}` : ''}
</Text>
</div>
),
},
...matrixLevels.map((level) => ({
title: level.name,
key: `price_${level.id}`,
width: 150,
render: (_: unknown, row: IPriceMatrixRow) => (
<InputNumber
size="small"
min={0}
precision={2}
prefix="¥"
value={row[`price_${level.id}`] as number | null}
onChange={(v) => onMatrixPriceChange(row.id, level.id!, v)}
className="w-32"
/>
),
})),
];
const columns: XinTableColumn<IProduct>[] = [
{
title: 'ID',
dataIndex: 'id',
hideInForm: true,
hideInSearch: true,
width: 70,
align: 'center',
},
{
title: '商品名称',
dataIndex: 'name',
valueType: 'text',
required: true,
rules: [{ required: true, message: '请输入商品名称' }],
},
{
title: '规格/包规',
dataIndex: 'spec',
valueType: 'text',
hideInSearch: true,
},
{
title: '等级',
dataIndex: 'grade',
valueType: 'text',
hideInSearch: true,
},
{
title: '单位',
dataIndex: 'unit',
valueType: 'text',
hideInSearch: true,
initialValue: '斤',
},
{
title: '分类',
dataIndex: 'category_id',
valueType: 'treeSelect',
required: true,
rules: [{ required: true, message: '请选择分类' }],
fieldProps: {
treeData: categoryTree,
fieldNames: { label: 'name', value: 'id', children: 'children' },
treeDefaultExpandAll: true,
showSearch: true,
treeNodeFilterProp: 'name',
placeholder: '选择分类',
},
render: (_, record) =>
record.category ? <Tag color="cyan">{record.category.name}</Tag> : '-',
},
{
title: '供应商',
dataIndex: 'supplier_id',
valueType: 'select',
hideInSearch: true,
fieldProps: {
options: suppliers.map((s) => ({ label: s.name, value: s.id })),
showSearch: true,
optionFilterProp: 'label',
allowClear: true,
placeholder: '默认供应商(可选)',
},
render: (_, record) => record.supplier?.name ?? '-',
},
{
title: '等级价格',
dataIndex: 'prices',
hideInForm: true,
hideInSearch: true,
render: (_, record) => (
<Space size={[0, 4]} wrap>
{record.prices?.length
? record.prices.map((p) => (
<Tag key={p.level_id} color="geekblue">
{p.level?.name ?? `等级${p.level_id}`} ¥{p.price}
</Tag>
))
: '-'}
</Space>
),
},
{
title: '排序',
dataIndex: 'sort',
valueType: 'digit',
hideInSearch: true,
fieldProps: { min: 0 },
align: 'center',
},
{
title: '状态',
dataIndex: 'status',
valueType: 'radioButton',
initialValue: 1,
fieldProps: {
options: [
{ value: 1, label: '上架' },
{ value: 0, label: '下架' },
],
},
render: (_, record) => {
const item = PRODUCT_STATUS_MAP[record.status ?? 1];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
align: 'center',
},
{
title: '备注',
dataIndex: 'remark',
valueType: 'textarea',
hideInSearch: true,
hideInTable: true,
fieldProps: { rows: 2 },
},
{
title: '等级价格设置',
dataIndex: 'prices',
hideInTable: true,
hideInSearch: true,
fieldRender: () => (
<Form.List name="prices">
{(fields, { add, remove }) => (
<div className="space-y-2">
{fields.map(({ key, name, ...restField }) => (
<Space key={key} align="baseline" className="flex">
<Form.Item
{...restField}
name={[name, 'level_id']}
rules={[{ required: true, message: '请选择等级' }]}
className="!mb-0"
>
<Select
style={{ width: 180 }}
placeholder="选择客户等级"
options={levels.map((l) => ({ label: l.name, value: l.id }))}
/>
</Form.Item>
<Form.Item
{...restField}
name={[name, 'price']}
rules={[{ required: true, message: '请输入单价' }]}
className="!mb-0"
>
<InputNumber
min={0}
precision={2}
prefix="¥"
placeholder="单价"
className="w-32"
/>
</Form.Item>
<Button
type="text"
danger
icon={<MinusCircleOutlined />}
onClick={() => remove(name)}
/>
</Space>
))}
<Button
type="dashed"
block
icon={<PlusOutlined />}
onClick={() => add()}
>
</Button>
</div>
)}
</Form.List>
),
},
];
const tableProps: XinTableProps<IProduct> = {
api: '/product/goods',
columns,
rowKey: 'id',
accessName: 'product.goods',
scroll: { x: 1200 },
actionBarRender: (dom) => [
dom.add,
<Button key="matrix" icon={<TableOutlined />} onClick={openMatrix}>
</Button>,
dom.keywordSearch,
],
formProps: {
grid: true,
colProps: { span: 12 },
layout: 'vertical',
},
modalProps: { width: 800 },
};
return (
<>
<div className="mb-5">
<Title level={3}></Title>
<Text type="secondary">
</Text>
</div>
<XinTable<IProduct> {...tableProps} />
<Drawer
title="价格矩阵 · 批量调价"
open={matrixOpen}
onClose={() => setMatrixOpen(false)}
width={matrixLevels.length * 150 + 260}
extra={
<Space>
<Button onClick={() => loadMatrix()}></Button>
<Button type="primary" loading={saveLoading} onClick={saveMatrix}>
</Button>
</Space>
}
>
<Space className="mb-4" wrap>
<TreeSelect
style={{ width: 180 }}
placeholder="按分类筛选"
allowClear
treeDefaultExpandAll
treeNodeFilterProp="name"
fieldNames={{ label: 'name', value: 'id', children: 'children' }}
treeData={categoryTree}
value={matrixCategory}
onChange={(v) => {
setMatrixCategory(v);
loadMatrix(matrixKeyword, v);
}}
/>
<Input.Search
style={{ width: 240 }}
placeholder="搜索品名/规格"
allowClear
value={matrixKeyword}
onChange={(e) => setMatrixKeyword(e.target.value)}
onSearch={(v) => loadMatrix(v, matrixCategory)}
/>
</Space>
<Table<IPriceMatrixRow>
rowKey="id"
size="small"
loading={matrixLoading}
columns={matrixColumns}
dataSource={matrixRows}
pagination={{ pageSize: 20, showSizeChanger: false }}
scroll={{ x: matrixLevels.length * 150 + 160 }}
/>
</Drawer>
</>
);
};
export default ProductGoodsPage;
+590
View File
@@ -0,0 +1,590 @@
import React, { useRef, useState } from 'react';
import {
Button,
DatePicker,
Descriptions,
Drawer,
Dropdown,
Empty,
Form,
InputNumber,
message,
Modal,
Popconfirm,
Space,
Table,
Tabs,
Tag,
Typography,
} from 'antd';
import {
DownloadOutlined,
PlusOutlined,
SendOutlined,
SplitCellsOutlined,
} from '@ant-design/icons';
import type { TableProps } from 'antd';
import dayjs from 'dayjs';
import XinTable from '@/components/XinTable';
import type {
XinTableColumn,
XinTableInstance,
XinTableProps,
} from '@/components/XinTable/typings.ts';
import type IPurchaseOrder from '@/domain/iPurchaseOrder.ts';
import type {
IAllocationAggRow,
IPurchaseOrderItem,
} from '@/domain/iPurchaseOrder.ts';
import { PURCHASE_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
import {
allocatePurchase,
exportPurchase,
generatePurchase,
getAllocation,
sendPurchaseItem,
updatePurchaseItem,
} from '@/api/purchase/order.ts';
import { Get } from '@/api/common/table.ts';
import AuthButton from '@/components/AuthButton';
const { Title, Text } = Typography;
/** 行内编辑中的明细值 */
interface EditingItem {
price: number;
quantity: number;
weight: number;
}
/**
* 采购单管理(C1 汇总生成 / C2-C3 导出 / C4 明细修改 / C5-C6 发送 / D3 分摊)
*/
const PurchaseOrderPage: React.FC = () => {
const tableRef = useRef<XinTableInstance<IPurchaseOrder>>(null);
// 生成采购单
const [generateOpen, setGenerateOpen] = useState(false);
const [generateLoading, setGenerateLoading] = useState(false);
const [generateForm] = Form.useForm<{ purchase_date: dayjs.Dayjs }>();
// 详情抽屉
const [detailOpen, setDetailOpen] = useState(false);
const [detail, setDetail] = useState<IPurchaseOrder | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [editing, setEditing] = useState<Record<number, EditingItem>>({});
const [savingItemId, setSavingItemId] = useState<number | null>(null);
// 分摊
const [allocating, setAllocating] = useState(false);
const [allocation, setAllocation] = useState<{
byStore: IAllocationAggRow[];
byProduct: IAllocationAggRow[];
total: number;
} | null>(null);
const loadDetail = async (id: number) => {
setDetailLoading(true);
try {
const res = await Get<IPurchaseOrder>('/purchase/order', id);
const purchase = res.data.data ?? null;
setDetail(purchase);
const editingMap: Record<number, EditingItem> = {};
purchase?.items?.forEach((item) => {
if (item.id !== undefined) {
editingMap[item.id] = {
price: Number(item.price ?? 0),
quantity: Number(item.quantity ?? 0),
weight: Number(item.weight ?? 0),
};
}
});
setEditing(editingMap);
} finally {
setDetailLoading(false);
}
};
const openDetail = async (id: number) => {
setAllocation(null);
setDetailOpen(true);
await loadDetail(id);
await loadAllocation(id);
};
const loadAllocation = async (id: number) => {
try {
const res = await getAllocation(id);
const data = res.data.data;
if (data) {
setAllocation({
byStore: data.by_store ?? [],
byProduct: data.by_product ?? [],
total: data.total_amount ?? 0,
});
}
} catch {
// 未分摊时忽略
}
};
const handleGenerate = async (values: { purchase_date: dayjs.Dayjs }) => {
setGenerateLoading(true);
try {
const res = await generatePurchase(values.purchase_date.format('YYYY-MM-DD'));
message.success(`采购单 ${res.data.data?.purchase_no} 已生成`);
setGenerateOpen(false);
await tableRef.current?.reload();
await openDetail(res.data.data!.id);
} finally {
setGenerateLoading(false);
}
};
const isItemDirty = (item: IPurchaseOrderItem): boolean => {
const edit = editing[item.id!];
if (!edit) {
return false;
}
return (
edit.price !== Number(item.price ?? 0) ||
edit.quantity !== Number(item.quantity ?? 0) ||
edit.weight !== Number(item.weight ?? 0)
);
};
const saveItem = async (item: IPurchaseOrderItem) => {
const edit = editing[item.id!];
if (!edit || !isItemDirty(item)) {
return;
}
setSavingItemId(item.id!);
try {
const res = await updatePurchaseItem(item.id!, {
price: edit.price,
quantity: edit.quantity,
weight: edit.weight,
});
message.success(`金额已重算:¥${res.data.data?.amount}`);
await loadDetail(detail!.id!);
} finally {
setSavingItemId(null);
}
};
const handleSend = async (item: IPurchaseOrderItem) => {
await sendPurchaseItem(item.id!);
message.success('已发送给供应商');
await loadDetail(detail!.id!);
await tableRef.current?.reload();
};
const handleAllocate = async () => {
setAllocating(true);
try {
const res = await allocatePurchase(detail!.id!);
message.success(`分摊完成,共 ${res.data.data?.count} 条记录`);
await loadAllocation(detail!.id!);
} finally {
setAllocating(false);
}
};
const itemColumns: TableProps<IPurchaseOrderItem>['columns'] = [
{ title: '序号', dataIndex: 'sort', width: 60, align: 'center' },
{ title: '品名', dataIndex: 'product_name', width: 130 },
{ title: '规格', dataIndex: 'product_spec', width: 110, render: (v) => v || '-' },
{
title: '供应商',
dataIndex: 'supplier',
width: 130,
render: (_, record) => record.supplier?.name ?? '-',
},
{
title: '单价',
dataIndex: 'price',
width: 130,
render: (_, record) => (
<InputNumber
size="small"
min={0}
precision={2}
prefix="¥"
value={editing[record.id!]?.price}
onChange={(v) =>
setEditing((prev) => ({
...prev,
[record.id!]: { ...prev[record.id!], price: v ?? 0 },
}))
}
className="!w-24"
/>
),
},
{
title: '数量',
dataIndex: 'quantity',
width: 120,
render: (_, record) => (
<InputNumber
size="small"
min={0}
precision={2}
value={editing[record.id!]?.quantity}
onChange={(v) =>
setEditing((prev) => ({
...prev,
[record.id!]: { ...prev[record.id!], quantity: v ?? 0 },
}))
}
className="!w-20"
/>
),
},
{
title: '实际称重',
dataIndex: 'weight',
width: 130,
render: (_, record) => (
<InputNumber
size="small"
min={0}
precision={3}
value={editing[record.id!]?.weight}
onChange={(v) =>
setEditing((prev) => ({
...prev,
[record.id!]: { ...prev[record.id!], weight: v ?? 0 },
}))
}
className="!w-24"
/>
),
},
{
title: '金额',
dataIndex: 'amount',
width: 100,
align: 'right',
render: (v) => <Text strong>¥{String(v)}</Text>,
},
{
title: '发送状态',
dataIndex: 'is_sent',
width: 150,
render: (_, record) =>
record.is_sent === 1 ? (
<Tag color="success">
{record.sent_at ? ` ${record.sent_at}` : ''}
</Tag>
) : (
<Tag></Tag>
),
},
{
title: '操作',
key: 'action',
width: 150,
fixed: 'right',
render: (_, record) => (
<Space size={4}>
<AuthButton auth="purchase.order.update">
<Button
size="small"
type="link"
disabled={!isItemDirty(record)}
loading={savingItemId === record.id}
onClick={() => saveItem(record)}
>
</Button>
</AuthButton>
{record.is_sent !== 1 ? (
<AuthButton auth="purchase.order.send">
<Popconfirm
title="确认发送该明细给供应商?"
onConfirm={() => handleSend(record)}
>
<Button size="small" type="link" icon={<SendOutlined />}>
</Button>
</Popconfirm>
</AuthButton>
) : null}
</Space>
),
},
];
const aggColumns = (nameTitle: string): TableProps<IAllocationAggRow>['columns'] => [
{
title: nameTitle,
key: 'name',
render: (_, row) => row.store_name ?? row.product_name ?? '-',
},
{ title: '数量', dataIndex: 'quantity', align: 'right' },
{ title: '重量', dataIndex: 'weight', align: 'right' },
{
title: '金额',
dataIndex: 'amount',
align: 'right',
render: (v) => `¥${v}`,
},
];
const columns: XinTableColumn<IPurchaseOrder>[] = [
{
title: '采购单号',
dataIndex: 'purchase_no',
valueType: 'text',
hideInForm: true,
render: (_, record) => <Text copyable={{ text: record.purchase_no }}>{record.purchase_no}</Text>,
},
{
title: '采购日期',
dataIndex: 'purchase_date',
valueType: 'dateRange',
hideInForm: true,
align: 'center',
render: (_, record) => record.purchase_date,
},
{
title: '预估金额',
dataIndex: 'estimate_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) => `¥${record.estimate_amount}`,
},
{
title: '实际金额',
dataIndex: 'actual_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) =>
Number(record.actual_amount) > 0 ? (
<Text strong>¥{record.actual_amount}</Text>
) : (
<Text type="secondary"></Text>
),
},
{
title: '状态',
dataIndex: 'status',
valueType: 'select',
hideInForm: true,
fieldProps: {
options: Object.entries(PURCHASE_STATUS_MAP).map(([value, item]) => ({
value: Number(value),
label: item.text,
})),
},
render: (_, record) => {
const item = PURCHASE_STATUS_MAP[record.status ?? 0];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
align: 'center',
},
{
title: '制单人',
dataIndex: 'operator',
hideInForm: true,
hideInSearch: true,
render: (_, record) => record.operator?.nickname ?? '-',
align: 'center',
},
];
const operateRender: XinTableProps<IPurchaseOrder>['operateRender'] = (record) => [
<Button key="detail" size="small" onClick={() => openDetail(record.id!)}>
</Button>,
<AuthButton key="export" auth="purchase.order.export">
<Dropdown
menu={{
items: [
{ key: 'all-xlsx', label: '全品类 Excel', onClick: () => exportPurchase(record.id!, 'all', 'xlsx') },
{ key: 'all-pdf', label: '全品类 PDF', onClick: () => exportPurchase(record.id!, 'all', 'pdf') },
{ key: 'category-xlsx', label: '蔬果分类 Excel', onClick: () => exportPurchase(record.id!, 'category', 'xlsx') },
{ key: 'category-pdf', label: '蔬果分类 PDF', onClick: () => exportPurchase(record.id!, 'category', 'pdf') },
],
}}
>
<Button size="small" icon={<DownloadOutlined />} />
</Dropdown>
</AuthButton>,
];
const tableProps: XinTableProps<IPurchaseOrder> = {
api: '/purchase/order',
columns,
rowKey: 'id',
accessName: 'purchase.order',
tableRef,
operateRender,
formProps: false,
actionBarRender: (dom) => [
<AuthButton key="generate" auth="purchase.order.generate">
<Button type="primary" icon={<PlusOutlined />} onClick={() => setGenerateOpen(true)}>
</Button>
</AuthButton>,
dom.search,
dom.keywordSearch,
],
};
return (
<>
<div className="mb-5">
<Title level={3}></Title>
<Text type="secondary">
/
</Text>
</div>
<XinTable<IPurchaseOrder> {...tableProps} />
{/* 生成采购单 */}
<Modal
title="生成采购单"
open={generateOpen}
onCancel={() => setGenerateOpen(false)}
onOk={() => generateForm.submit()}
confirmLoading={generateLoading}
okText="确认生成"
destroyOnHidden
>
<div className="py-2 text-gray-500">
</div>
<Form
form={generateForm}
layout="vertical"
onFinish={handleGenerate}
initialValues={{ purchase_date: dayjs() }}
>
<Form.Item
label="采购日期"
name="purchase_date"
rules={[{ required: true, message: '请选择采购日期' }]}
>
<DatePicker className="w-full" allowClear={false} />
</Form.Item>
</Form>
</Modal>
{/* 采购单详情 */}
<Drawer
title={detail ? `采购单 ${detail.purchase_no}` : '采购单详情'}
open={detailOpen}
onClose={() => setDetailOpen(false)}
width={1080}
loading={detailLoading}
>
{detail ? (
<>
<Descriptions column={3} size="small" bordered>
<Descriptions.Item label="采购日期">{detail.purchase_date}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={PURCHASE_STATUS_MAP[detail.status ?? 0]?.color}>
{PURCHASE_STATUS_MAP[detail.status ?? 0]?.text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="制单人">
{detail.operator?.nickname ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="预估金额">¥{detail.estimate_amount}</Descriptions.Item>
<Descriptions.Item label="实际金额">¥{detail.actual_amount}</Descriptions.Item>
<Descriptions.Item label="总重量">{detail.total_weight}</Descriptions.Item>
</Descriptions>
<Tabs
className="mt-4"
items={[
{
key: 'items',
label: `采购明细(${detail.items?.length ?? 0}`,
children: (
<>
<div className="mb-2 text-gray-500">
&gt;0 × ×
</div>
<Table<IPurchaseOrderItem>
rowKey="id"
size="small"
columns={itemColumns}
dataSource={detail.items ?? []}
pagination={false}
scroll={{ x: 1200 }}
/>
</>
),
},
{
key: 'allocation',
label: '金额分摊',
children: (
<>
<Space className="mb-3">
<AuthButton auth="purchase.order.allocate">
<Popconfirm
title="执行金额分摊?"
description="按订货比例将实际金额摊到各门店单品,重复执行会先清空旧分摊记录。"
onConfirm={handleAllocate}
>
<Button
type="primary"
icon={<SplitCellsOutlined />}
loading={allocating}
>
</Button>
</Popconfirm>
</AuthButton>
{allocation ? (
<Text type="secondary">
¥{allocation.total}
</Text>
) : null}
</Space>
{allocation && (allocation.byStore.length > 0 || allocation.byProduct.length > 0) ? (
<div className="grid grid-cols-2 gap-4">
<div>
<Title level={5}></Title>
<Table<IAllocationAggRow>
rowKey={(row) => String(row.store_id)}
size="small"
columns={aggColumns('门店')}
dataSource={allocation.byStore}
pagination={false}
/>
</div>
<div>
<Title level={5}></Title>
<Table<IAllocationAggRow>
rowKey={(row) => String(row.product_id)}
size="small"
columns={aggColumns('商品')}
dataSource={allocation.byProduct}
pagination={false}
/>
</div>
</div>
) : (
<Empty description="暂无分摊记录,请先录入实际金额后执行分摊" />
)}
</>
),
},
]}
/>
</>
) : null}
</Drawer>
</>
);
};
export default PurchaseOrderPage;
+719
View File
@@ -0,0 +1,719 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Input,
InputNumber,
message,
Modal,
Popconfirm,
Space,
Switch,
Table,
Tabs,
Tag,
Typography,
} from 'antd';
import {
CheckSquareOutlined,
FileDoneOutlined,
ToolOutlined,
} from '@ant-design/icons';
import type { TableProps } from 'antd';
import XinTable from '@/components/XinTable';
import type {
XinTableColumn,
XinTableInstance,
XinTableProps,
} from '@/components/XinTable/typings.ts';
import type IReconciliation from '@/domain/iReconciliation.ts';
import type { IReconDiff, IReconciliationItem } from '@/domain/iReconciliation.ts';
import { RECON_STATUS_MAP } from '@/domain/iReconciliation.ts';
import type IProductCategory from '@/domain/iProductCategory.ts';
import type ISupplier from '@/domain/iSupplier.ts';
import { getCategoryTree } from '@/api/product/category.ts';
import { getSupplierOptions } from '@/api/customer/supplier.ts';
import {
buildRecon,
getReconDiff,
remarkReconItem,
settleRecon,
toggleReconItem,
updateReconItem,
} from '@/api/recon/list.ts';
import { Get } from '@/api/common/table.ts';
import AuthButton from '@/components/AuthButton';
const { Title, Text } = Typography;
interface EditingItem {
product_name: string;
quantity: number;
weight: number;
publish_amount: number;
actual_amount: number;
}
/**
* 财务对账(D1/D2 筛选建单、D4 明细修改、D5 差额对比、D6 备注、D8 标记、D9 结算)
*/
const ReconListPage: React.FC = () => {
const tableRef = useRef<XinTableInstance<IReconciliation>>(null);
const [categoryTree, setCategoryTree] = useState<IProductCategory[]>([]);
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
// 工作台抽屉
const [workOpen, setWorkOpen] = useState(false);
const [workLoading, setWorkLoading] = useState(false);
const [recon, setRecon] = useState<IReconciliation | null>(null);
const [editing, setEditing] = useState<Record<number, EditingItem>>({});
const [savingItemId, setSavingItemId] = useState<number | null>(null);
const [diff, setDiff] = useState<IReconDiff | null>(null);
// 备注弹窗
const [remarkOpen, setRemarkOpen] = useState(false);
const [remarkTarget, setRemarkTarget] = useState<IReconciliationItem | null>(null);
const [remarkValue, setRemarkValue] = useState('');
useEffect(() => {
getCategoryTree().then((res) => setCategoryTree(res.data.data ?? []));
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
}, []);
const loadRecon = async (id: number) => {
const res = await Get<IReconciliation>('/recon/list', id);
const data = res.data.data ?? null;
setRecon(data);
const editingMap: Record<number, EditingItem> = {};
data?.items?.forEach((item) => {
if (item.id !== undefined) {
editingMap[item.id] = {
product_name: item.product_name ?? '',
quantity: Number(item.quantity ?? 0),
weight: Number(item.weight ?? 0),
publish_amount: Number(item.publish_amount ?? 0),
actual_amount: Number(item.actual_amount ?? 0),
};
}
});
setEditing(editingMap);
return data;
};
const openWorkbench = async (id: number) => {
setWorkOpen(true);
setWorkLoading(true);
setDiff(null);
try {
await loadRecon(id);
} finally {
setWorkLoading(false);
}
};
const loadDiff = async (id: number) => {
const res = await getReconDiff(id);
setDiff(res.data.data ?? null);
};
const handleBuild = async (record: IReconciliation) => {
const res = await buildRecon(record.id!);
message.success(`已生成 ${res.data.data?.count} 条对账明细`);
await tableRef.current?.reload();
};
const handleSettle = async (record: IReconciliation) => {
const res = await settleRecon(record.id!);
message.success(`已生成 ${res.data.data?.count} 张结算表`);
await tableRef.current?.reload();
};
const isItemDirty = (item: IReconciliationItem): boolean => {
const edit = editing[item.id!];
if (!edit) {
return false;
}
return (
edit.product_name !== (item.product_name ?? '') ||
edit.quantity !== Number(item.quantity ?? 0) ||
edit.weight !== Number(item.weight ?? 0) ||
edit.publish_amount !== Number(item.publish_amount ?? 0) ||
edit.actual_amount !== Number(item.actual_amount ?? 0)
);
};
const saveItem = async (item: IReconciliationItem) => {
const edit = editing[item.id!];
if (!edit || !isItemDirty(item)) {
return;
}
setSavingItemId(item.id!);
try {
const res = await updateReconItem(item.id!, {
product_name: edit.product_name,
quantity: edit.quantity,
weight: edit.weight,
publish_amount: edit.publish_amount,
actual_amount: edit.actual_amount,
});
message.success(`已保存,差额 ¥${res.data.data?.diff_amount}`);
await loadRecon(recon!.id!);
await loadDiff(recon!.id!);
} finally {
setSavingItemId(null);
}
};
const handleToggle = async (item: IReconciliationItem) => {
await toggleReconItem(item.id!);
await loadRecon(recon!.id!);
};
const openRemark = (item: IReconciliationItem) => {
setRemarkTarget(item);
setRemarkValue(item.store_remark ?? '');
setRemarkOpen(true);
};
const saveRemark = async () => {
await remarkReconItem(remarkTarget!.id!, remarkValue);
message.success('备注已保存');
setRemarkOpen(false);
await loadRecon(recon!.id!);
};
const readonly = recon?.status === 2;
const itemColumns: TableProps<IReconciliationItem>['columns'] = [
{
title: '品名',
dataIndex: 'product_name',
width: 160,
render: (_, record) =>
readonly ? (
record.product_name
) : (
<Input
size="small"
value={editing[record.id!]?.product_name}
onChange={(e) =>
setEditing((prev) => ({
...prev,
[record.id!]: { ...prev[record.id!], product_name: e.target.value },
}))
}
/>
),
},
{
title: '门店',
dataIndex: 'store',
width: 130,
render: (_, record) => record.store?.name ?? `门店#${record.store_id}`,
},
{
title: '订货量',
dataIndex: 'quantity',
width: 110,
render: (_, record) =>
readonly ? (
record.quantity
) : (
<InputNumber
size="small"
min={0}
precision={2}
value={editing[record.id!]?.quantity}
onChange={(v) =>
setEditing((prev) => ({
...prev,
[record.id!]: { ...prev[record.id!], quantity: v ?? 0 },
}))
}
className="!w-20"
/>
),
},
{
title: '称重',
dataIndex: 'weight',
width: 110,
render: (_, record) =>
readonly ? (
record.weight
) : (
<InputNumber
size="small"
min={0}
precision={3}
value={editing[record.id!]?.weight}
onChange={(v) =>
setEditing((prev) => ({
...prev,
[record.id!]: { ...prev[record.id!], weight: v ?? 0 },
}))
}
className="!w-20"
/>
),
},
{
title: '公布金额',
dataIndex: 'publish_amount',
width: 120,
render: (_, record) =>
readonly ? (
`¥${record.publish_amount}`
) : (
<InputNumber
size="small"
min={0}
precision={2}
prefix="¥"
value={editing[record.id!]?.publish_amount}
onChange={(v) =>
setEditing((prev) => ({
...prev,
[record.id!]: { ...prev[record.id!], publish_amount: v ?? 0 },
}))
}
className="!w-24"
/>
),
},
{
title: '实际金额',
dataIndex: 'actual_amount',
width: 120,
render: (_, record) =>
readonly ? (
`¥${record.actual_amount}`
) : (
<InputNumber
size="small"
min={0}
precision={2}
prefix="¥"
value={editing[record.id!]?.actual_amount}
onChange={(v) =>
setEditing((prev) => ({
...prev,
[record.id!]: { ...prev[record.id!], actual_amount: v ?? 0 },
}))
}
className="!w-24"
/>
),
},
{
title: '差额',
dataIndex: 'diff_amount',
width: 100,
align: 'right',
render: (v) => {
const num = Number(v ?? 0);
return (
<Text type={num === 0 ? 'secondary' : 'danger'} strong={num !== 0}>
¥{String(v)}
</Text>
);
},
},
{
title: '对账',
dataIndex: 'is_reconciled',
width: 80,
align: 'center',
render: (_, record) => (
<Switch
size="small"
disabled={readonly}
checked={record.is_reconciled === 1}
onChange={() => handleToggle(record)}
/>
),
},
{
title: '门店备注',
dataIndex: 'store_remark',
width: 120,
ellipsis: true,
render: (_, record) =>
record.store_remark || <Text type="secondary"></Text>,
},
{
title: '操作',
key: 'action',
width: 130,
fixed: 'right',
render: (_, record) =>
readonly ? null : (
<Space size={4}>
<AuthButton auth="recon.item.item.update">
<Button
size="small"
type="link"
disabled={!isItemDirty(record)}
loading={savingItemId === record.id}
onClick={() => saveItem(record)}
>
</Button>
</AuthButton>
<AuthButton auth="recon.item.item.update">
<Button size="small" type="link" onClick={() => openRemark(record)}>
</Button>
</AuthButton>
</Space>
),
},
];
const diffColumns = (nameTitle: string, nameKey: 'store_name' | 'product_name') => [
{ title: nameTitle, dataIndex: nameKey, render: (v: string) => v || '-' },
{ title: '公布金额', dataIndex: 'publish', align: 'right' as const, render: (v: number) => `¥${v}` },
{ title: '实际金额', dataIndex: 'actual', align: 'right' as const, render: (v: number) => `¥${v}` },
{
title: '差额',
dataIndex: 'diff',
align: 'right' as const,
render: (v: number) => (
<Text type={v === 0 ? 'secondary' : 'danger'} strong={v !== 0}>
¥{v}
</Text>
),
},
];
const columns: XinTableColumn<IReconciliation>[] = [
{
title: '对账单号',
dataIndex: 'recon_no',
valueType: 'text',
hideInForm: true,
},
{
title: '标题',
dataIndex: 'title',
valueType: 'text',
required: true,
rules: [{ required: true, message: '请输入对账标题' }],
},
{
title: '对账周期',
dataIndex: 'period',
hideInForm: true,
hideInSearch: true,
render: (_, record) => `${record.period_start} ~ ${record.period_end}`,
},
{
title: '开始日期',
dataIndex: 'period_start',
valueType: 'date',
hideInTable: true,
required: true,
rules: [{ required: true, message: '请选择开始日期' }],
},
{
title: '结束日期',
dataIndex: 'period_end',
valueType: 'date',
hideInTable: true,
required: true,
rules: [{ required: true, message: '请选择结束日期' }],
},
{
title: '商品分类',
dataIndex: 'category_id',
valueType: 'treeSelect',
hideInTable: true,
initialValue: 0,
fieldProps: {
treeData: [{ id: 0, name: '全部分类', children: categoryTree }],
fieldNames: { label: 'name', value: 'id', children: 'children' },
treeDefaultExpandAll: true,
},
},
{
title: '供应商',
dataIndex: 'supplier_id',
valueType: 'select',
hideInTable: true,
initialValue: 0,
fieldProps: {
options: [
{ label: '全部供应商', value: 0 },
...suppliers.map((s) => ({ label: s.name, value: s.id })),
],
},
},
{
title: '公布金额',
dataIndex: 'publish_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) => `¥${record.publish_amount}`,
},
{
title: '实际金额',
dataIndex: 'actual_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) => `¥${record.actual_amount}`,
},
{
title: '差额',
dataIndex: 'diff_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) => {
const num = Number(record.diff_amount ?? 0);
return (
<Text type={num === 0 ? 'secondary' : 'danger'} strong={num !== 0}>
¥{record.diff_amount}
</Text>
);
},
},
{
title: '状态',
dataIndex: 'status',
valueType: 'select',
hideInForm: true,
fieldProps: {
options: Object.entries(RECON_STATUS_MAP).map(([value, item]) => ({
value: Number(value),
label: item.text,
})),
},
render: (_, record) => {
const item = RECON_STATUS_MAP[record.status ?? 0];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
align: 'center',
},
{
title: '备注',
dataIndex: 'remark',
valueType: 'textarea',
hideInTable: true,
hideInSearch: true,
fieldProps: { rows: 2 },
},
];
const operateRender: XinTableProps<IReconciliation>['operateRender'] = (record, dom) => [
<AuthButton key="build" auth="recon.list.build">
<Popconfirm
title="生成对账明细?"
description="按周期/品类/供应商拉取已分摊的采购数据,重复生成会清空现有明细。"
disabled={record.status === 2}
onConfirm={() => handleBuild(record)}
>
<Button size="small" disabled={record.status === 2}>
</Button>
</Popconfirm>
</AuthButton>,
<Button
key="workbench"
size="small"
type="primary"
ghost
icon={<ToolOutlined />}
disabled={record.status === 0}
onClick={() => openWorkbench(record.id!)}
>
</Button>,
<AuthButton key="settle" auth="recon.list.settle">
<Popconfirm
title="生成结算表?"
description="按门店聚合对账明细生成结算表,对账单将变为已结算且不可再修改。"
disabled={record.status !== 1}
onConfirm={() => handleSettle(record)}
>
<Button
size="small"
type="primary"
icon={<FileDoneOutlined />}
disabled={record.status !== 1}
>
</Button>
</Popconfirm>
</AuthButton>,
// 编辑/删除由 XinTable 默认提供;删除仅草稿可用,由后端校验拦截
dom.edit,
dom.del,
];
const tableProps: XinTableProps<IReconciliation> = {
api: '/recon/list',
columns,
rowKey: 'id',
accessName: 'recon.list',
tableRef,
operateRender,
scroll: { x: 1300 },
formProps: {
grid: true,
colProps: { span: 12 },
layout: 'vertical',
},
modalProps: { width: 720 },
};
return (
<>
<div className="mb-5">
<Title level={3}></Title>
<Text type="secondary">
//
</Text>
</div>
<XinTable<IReconciliation> {...tableProps} />
{/* 对账工作台 */}
<Drawer
title={recon ? `对账工作台 · ${recon.recon_no}` : '对账工作台'}
open={workOpen}
onClose={() => setWorkOpen(false)}
width={1200}
loading={workLoading}
>
{recon ? (
<>
<Descriptions column={4} size="small" bordered>
<Descriptions.Item label="标题">{recon.title}</Descriptions.Item>
<Descriptions.Item label="周期">
{recon.period_start} ~ {recon.period_end}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={RECON_STATUS_MAP[recon.status ?? 0]?.color}>
{RECON_STATUS_MAP[recon.status ?? 0]?.text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="差额">
<Text
type={Number(recon.diff_amount) === 0 ? 'secondary' : 'danger'}
strong
>
¥{recon.diff_amount}
</Text>
</Descriptions.Item>
</Descriptions>
<Tabs
className="mt-4"
items={[
{
key: 'items',
label: (
<span>
<CheckSquareOutlined /> {recon.items?.length ?? 0}
</span>
),
children: (
<>
{!readonly ? (
<div className="mb-2 text-gray-500">
///
</div>
) : null}
<Table<IReconciliationItem>
rowKey="id"
size="small"
columns={itemColumns}
dataSource={recon.items ?? []}
pagination={{ pageSize: 15, showSizeChanger: false }}
scroll={{ x: 1250 }}
/>
</>
),
},
{
key: 'diff',
label: '差额对比',
children: (
<>
<Space className="mb-3">
<Button onClick={() => loadDiff(recon.id!)}></Button>
{diff ? (
<Text type="secondary">
¥{diff.total.publish} / ¥{diff.total.actual} /{' '}
<Text type={diff.total.diff === 0 ? 'secondary' : 'danger'} strong>
¥{diff.total.diff}
</Text>
</Text>
) : null}
</Space>
{diff ? (
<div className="grid grid-cols-2 gap-4">
<div>
<Title level={5}></Title>
<Table
rowKey={(row) => String(row.store_id)}
size="small"
columns={diffColumns('门店', 'store_name')}
dataSource={diff.by_store}
pagination={false}
/>
</div>
<div>
<Title level={5}></Title>
<Table
rowKey={(row) => String(row.product_id)}
size="small"
columns={diffColumns('商品', 'product_name')}
dataSource={diff.by_product}
pagination={false}
/>
</div>
</div>
) : (
<Button type="primary" onClick={() => loadDiff(recon.id!)}>
</Button>
)}
</>
),
},
]}
/>
</>
) : null}
</Drawer>
{/* 门店备注弹窗 */}
<Modal
title="单品门店备注"
open={remarkOpen}
onCancel={() => setRemarkOpen(false)}
onOk={saveRemark}
okText="保存备注"
destroyOnHidden
>
<div className="mb-2 text-gray-500">
{remarkTarget?.product_name}
{remarkTarget?.store ? ` · ${remarkTarget.store.name}` : ''}
</div>
<Input.TextArea
rows={3}
maxLength={255}
showCount
value={remarkValue}
onChange={(e) => setRemarkValue(e.target.value)}
placeholder="填写该单品针对该门店的备注(如质量异常、补货说明等)"
/>
</Modal>
</>
);
};
export default ReconListPage;
+236
View File
@@ -0,0 +1,236 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Dropdown,
Tag,
Typography,
} from 'antd';
import { DownloadOutlined } from '@ant-design/icons';
import XinTable from '@/components/XinTable';
import type {
XinTableColumn,
XinTableInstance,
XinTableProps,
} from '@/components/XinTable/typings.ts';
import type ISettlement from '@/domain/iSettlement.ts';
import { SETTLEMENT_STATUS_MAP } from '@/domain/iSettlement.ts';
import { getStoreOptions } from '@/api/customer/store.ts';
import type IStore from '@/domain/iStore.ts';
import { downloadSettlement } from '@/api/recon/settlement.ts';
import { Get } from '@/api/common/table.ts';
import AuthButton from '@/components/AuthButton';
const { Title, Text } = Typography;
/**
* 结算表(D9 生成于对账结算,D10 导出存档)
*/
const SettlementPage: React.FC = () => {
const tableRef = useRef<XinTableInstance<ISettlement>>(null);
const [stores, setStores] = useState<IStore[]>([]);
const [detailOpen, setDetailOpen] = useState(false);
const [detail, setDetail] = useState<ISettlement | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
useEffect(() => {
getStoreOptions().then((res) => setStores(res.data.data ?? []));
}, []);
const openDetail = async (id: number) => {
setDetailOpen(true);
setDetailLoading(true);
try {
const res = await Get<ISettlement>('/recon/settlement', id);
setDetail(res.data.data ?? null);
} finally {
setDetailLoading(false);
}
};
const columns: XinTableColumn<ISettlement>[] = [
{
title: '结算单号',
dataIndex: 'settlement_no',
valueType: 'text',
hideInForm: true,
render: (_, record) => <Text copyable={{ text: record.settlement_no }}>{record.settlement_no}</Text>,
},
{
title: '门店',
dataIndex: 'store_id',
valueType: 'select',
hideInForm: true,
fieldProps: {
options: stores.map((s) => ({ label: s.name, value: s.id })),
showSearch: true,
optionFilterProp: 'label',
},
render: (_, record) => record.store?.name ?? '-',
},
{
title: '来源对账单',
dataIndex: 'recon',
hideInForm: true,
hideInSearch: true,
render: (_, record) =>
record.recon ? (
<span>
{record.recon.recon_no}
<Text type="secondary">{record.recon.title}</Text>
</span>
) : (
'-'
),
},
{
title: '结算周期',
dataIndex: 'period',
hideInForm: true,
hideInSearch: true,
render: (_, record) => `${record.period_start} ~ ${record.period_end}`,
},
{
title: '公布金额',
dataIndex: 'total_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) => `¥${record.total_amount}`,
},
{
title: '实际金额',
dataIndex: 'actual_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) => <Text strong>¥{record.actual_amount}</Text>,
},
{
title: '差额',
dataIndex: 'diff_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) => {
const num = Number(record.diff_amount ?? 0);
return (
<Text type={num === 0 ? 'secondary' : 'danger'} strong={num !== 0}>
¥{record.diff_amount}
</Text>
);
},
},
{
title: '状态',
dataIndex: 'status',
valueType: 'select',
hideInForm: true,
fieldProps: {
options: Object.entries(SETTLEMENT_STATUS_MAP).map(([value, item]) => ({
value: Number(value),
label: item.text,
})),
},
render: (_, record) => {
const item = SETTLEMENT_STATUS_MAP[record.status ?? 0];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
align: 'center',
},
{
title: '结算时间',
dataIndex: 'settled_at',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => record.settled_at ?? '-',
},
];
const operateRender: XinTableProps<ISettlement>['operateRender'] = (record) => [
<Button key="detail" size="small" onClick={() => openDetail(record.id!)}>
</Button>,
<AuthButton key="download" auth="recon.settlement.download">
<Dropdown
menu={{
items: [
{ key: 'xlsx', label: '下载 Excel', onClick: () => downloadSettlement(record.id!, 'xlsx') },
{ key: 'pdf', label: '下载 PDF', onClick: () => downloadSettlement(record.id!, 'pdf') },
],
}}
>
<Button size="small" type="primary" ghost icon={<DownloadOutlined />}>
</Button>
</Dropdown>
</AuthButton>,
];
const tableProps: XinTableProps<ISettlement> = {
api: '/recon/settlement',
columns,
rowKey: 'id',
accessName: 'recon.settlement',
tableRef,
operateRender,
formProps: false,
scroll: { x: 1200 },
};
return (
<>
<div className="mb-5">
<Title level={3}></Title>
<Text type="secondary">
Excel / PDF
</Text>
</div>
<XinTable<ISettlement> {...tableProps} />
<Drawer
title={detail ? `结算表 ${detail.settlement_no}` : '结算表详情'}
open={detailOpen}
onClose={() => setDetailOpen(false)}
width={640}
loading={detailLoading}
>
{detail ? (
<Descriptions column={2} size="small" bordered>
<Descriptions.Item label="门店">{detail.store?.name}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={SETTLEMENT_STATUS_MAP[detail.status ?? 0]?.color}>
{SETTLEMENT_STATUS_MAP[detail.status ?? 0]?.text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="来源对账单">
{detail.recon?.recon_no ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="结算周期">
{detail.period_start} ~ {detail.period_end}
</Descriptions.Item>
<Descriptions.Item label="公布金额">¥{detail.total_amount}</Descriptions.Item>
<Descriptions.Item label="实际金额">¥{detail.actual_amount}</Descriptions.Item>
<Descriptions.Item label="差额">¥{detail.diff_amount}</Descriptions.Item>
<Descriptions.Item label="结算时间">
{detail.settled_at ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="存档文件" span={2}>
{detail.file_path ?? <Text type="secondary"></Text>}
</Descriptions.Item>
{detail.remark ? (
<Descriptions.Item label="备注" span={2}>
{detail.remark}
</Descriptions.Item>
) : null}
</Descriptions>
) : null}
</Drawer>
</>
);
};
export default SettlementPage;
+239
View File
@@ -0,0 +1,239 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Table,
Tag,
Typography,
} from 'antd';
import type { TableProps } from 'antd';
import XinTable from '@/components/XinTable';
import type {
XinTableColumn,
XinTableInstance,
XinTableProps,
} from '@/components/XinTable/typings.ts';
import type IStatement from '@/domain/iStatement.ts';
import type { IStatementItem } from '@/domain/iStatement.ts';
import { STATEMENT_STATUS_MAP } from '@/domain/iStatement.ts';
import { RECONCILED_MAP } from '@/domain/iReconciliation.ts';
import { getStoreOptions } from '@/api/customer/store.ts';
import type IStore from '@/domain/iStore.ts';
import { Get } from '@/api/common/table.ts';
const { Title, Text } = Typography;
/**
* 门店对账单(后台只读视角;生成/导出在小程序端)
*/
const StatementPage: React.FC = () => {
const tableRef = useRef<XinTableInstance<IStatement>>(null);
const [stores, setStores] = useState<IStore[]>([]);
const [detailOpen, setDetailOpen] = useState(false);
const [detail, setDetail] = useState<IStatement | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
useEffect(() => {
getStoreOptions().then((res) => setStores(res.data.data ?? []));
}, []);
const openDetail = async (id: number) => {
setDetailOpen(true);
setDetailLoading(true);
try {
const res = await Get<IStatement>('/recon/statement', id);
setDetail(res.data.data ?? null);
} finally {
setDetailLoading(false);
}
};
const itemColumns: TableProps<IStatementItem>['columns'] = [
{ title: '品名', dataIndex: 'product_name' },
{ title: '单价', dataIndex: 'price', align: 'right', render: (v) => `¥${v}` },
{ title: '数量', dataIndex: 'quantity', align: 'right' },
{ title: '重量', dataIndex: 'weight', align: 'right' },
{ title: '金额', dataIndex: 'amount', align: 'right', render: (v) => `¥${v}` },
{
title: '对账状态',
dataIndex: 'is_reconciled',
align: 'center',
render: (v) => {
const item = RECONCILED_MAP[Number(v ?? 0)];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
},
{ title: '备注', dataIndex: 'store_remark', render: (v) => v || '-' },
];
const columns: XinTableColumn<IStatement>[] = [
{
title: '对账单号',
dataIndex: 'statement_no',
valueType: 'text',
hideInForm: true,
render: (_, record) => <Text copyable={{ text: record.statement_no }}>{record.statement_no}</Text>,
},
{
title: '门店',
dataIndex: 'store_id',
valueType: 'select',
hideInForm: true,
fieldProps: {
options: stores.map((s) => ({ label: s.name, value: s.id })),
showSearch: true,
optionFilterProp: 'label',
},
render: (_, record) => record.store?.name ?? '-',
},
{
title: '对账周期',
dataIndex: 'period_start',
valueType: 'dateRange',
hideInForm: true,
render: (_, record) => `${record.period_start} ~ ${record.period_end}`,
},
{
title: '总金额',
dataIndex: 'total_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) => <Text strong>¥{record.total_amount}</Text>,
},
{
title: '回款周期',
dataIndex: 'payment_cycle_days',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => `${record.payment_cycle_days}`,
},
{
title: '应结算日期',
dataIndex: 'settlement_date',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => {
const overdue =
record.status !== 2 && record.settlement_date
? new Date(record.settlement_date).getTime() < Date.now()
: false;
return (
<Text type={overdue ? 'danger' : undefined} strong={overdue}>
{record.settlement_date}
{overdue ? '(逾期)' : ''}
</Text>
);
},
},
{
title: '状态',
dataIndex: 'status',
valueType: 'select',
hideInForm: true,
fieldProps: {
options: Object.entries(STATEMENT_STATUS_MAP).map(([value, item]) => ({
value: Number(value),
label: item.text,
})),
},
render: (_, record) => {
const item = STATEMENT_STATUS_MAP[record.status ?? 0];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
align: 'center',
},
];
const operateRender: XinTableProps<IStatement>['operateRender'] = (record) => [
<Button key="detail" size="small" onClick={() => openDetail(record.id!)}>
</Button>,
];
const tableProps: XinTableProps<IStatement> = {
api: '/recon/statement',
columns,
rowKey: 'id',
accessName: 'recon.statement',
tableRef,
operateRender,
formProps: false,
actionBarRender: (dom) => [dom.search, dom.keywordSearch],
};
return (
<>
<div className="mb-5">
<Title level={3}></Title>
<Text type="secondary">
= +
</Text>
</div>
<XinTable<IStatement> {...tableProps} />
<Drawer
title={detail ? `对账单 ${detail.statement_no}` : '对账单详情'}
open={detailOpen}
onClose={() => setDetailOpen(false)}
width={860}
loading={detailLoading}
>
{detail ? (
<>
<Descriptions column={2} size="small" bordered>
<Descriptions.Item label="门店">{detail.store?.name}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATEMENT_STATUS_MAP[detail.status ?? 0]?.color}>
{STATEMENT_STATUS_MAP[detail.status ?? 0]?.text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="对账周期">
{detail.period_start} ~ {detail.period_end}
</Descriptions.Item>
<Descriptions.Item label="回款周期(快照)">
{detail.payment_cycle_days}
</Descriptions.Item>
<Descriptions.Item label="应结算日期">
{detail.settlement_date}
</Descriptions.Item>
<Descriptions.Item label="总金额">¥{detail.total_amount}</Descriptions.Item>
{detail.remark ? (
<Descriptions.Item label="备注" span={2}>
{detail.remark}
</Descriptions.Item>
) : null}
</Descriptions>
<Title level={5} className="!mt-6 !mb-3">
{detail.items?.length ?? 0}
</Title>
<Table<IStatementItem>
rowKey="id"
size="small"
columns={itemColumns}
dataSource={detail.items ?? []}
pagination={false}
summary={() => (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={4} align="right">
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
<Text strong>¥{detail.total_amount}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} colSpan={2} />
</Table.Summary.Row>
)}
/>
</>
) : null}
</Drawer>
</>
);
};
export default StatementPage;