样式修改
This commit is contained in:
+228
-129
@@ -68,9 +68,22 @@ import { getProductOptions } from '@/api/product/goods.ts';
|
||||
import type ISupplier from '@/domain/iSupplier.ts';
|
||||
import type IProduct from '@/domain/iProduct.ts';
|
||||
import AuthButton from '@/components/AuthButton';
|
||||
import useAuth from '@/hooks/useAuth.ts';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/** 商品明细可行内编辑的字段 */
|
||||
type InlineEditField = 'product_name' | 'supplier_id' | 'product_spec' | 'unit' | 'cost_price';
|
||||
|
||||
/** 行内编辑字段中文名(校验提示用) */
|
||||
const INLINE_FIELD_LABELS: Record<InlineEditField, string> = {
|
||||
product_name: '品名',
|
||||
supplier_id: '供应商',
|
||||
product_spec: '包规',
|
||||
unit: '单位',
|
||||
cost_price: '成本',
|
||||
};
|
||||
|
||||
/** 每单位参考价 = 整单价(成本/售价) ÷ 包规数值(包规解析不出正数时按 1 处理,与后端同口径;仅展示参考,不参与金额计算) */
|
||||
const calcUnitRefPrice = (total: number, spec: string): number => {
|
||||
const pack = parseFloat(spec);
|
||||
@@ -83,6 +96,7 @@ const calcUnitRefPrice = (total: number, spec: string): number => {
|
||||
*/
|
||||
const PurchaseOrderPage: React.FC = () => {
|
||||
const tableRef = useRef<XinTableInstance<IPurchaseOrder>>(null);
|
||||
const { auth } = useAuth();
|
||||
|
||||
// 详情抽屉
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
@@ -91,10 +105,8 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [completing, setCompleting] = useState(false);
|
||||
|
||||
// 行修改弹窗
|
||||
const [editingRow, setEditingRow] = useState<IPurchaseDetailRow | null>(null);
|
||||
const [rowSaving, setRowSaving] = useState(false);
|
||||
const [editForm] = Form.useForm<PurchaseRowUpdateParams>();
|
||||
// 行内编辑:正在编辑的单元格(点击 品名/供应商/包规/单位/成本 进入编辑态)
|
||||
const [editingCell, setEditingCell] = useState<{ productId: number; dataIndex: InlineEditField } | null>(null);
|
||||
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
||||
|
||||
// 单元格下钻弹窗(门店 × 商品订货明细)
|
||||
@@ -131,6 +143,9 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
/** 市场筛选('' = 全部市场) */
|
||||
const [marketFilter, setMarketFilter] = useState<string>('');
|
||||
|
||||
// 商品明细页签:供应商/市场列筛选值(antd Table 受控筛选,用于底部统计行联动)
|
||||
const [itemColumnFilters, setItemColumnFilters] = useState<Record<string, (React.Key | boolean)[] | null>>({});
|
||||
|
||||
// 导出弹窗:商品明细(供应商筛选)/ 门店购买详情 / 供应商采购明细
|
||||
const [itemExportOpen, setItemExportOpen] = useState(false);
|
||||
const [itemExportSupplier, setItemExportSupplier] = useState<number>(0);
|
||||
@@ -180,6 +195,26 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
}));
|
||||
}, [detail, supplierId, marketFilter]);
|
||||
|
||||
/** 商品明细市场列筛选选项(采购单内出现的全部市场) */
|
||||
const itemMarketOptions = useMemo(() => {
|
||||
const markets = new Set<string>();
|
||||
(detail?.items ?? []).forEach((row) => {
|
||||
if (row.market) {
|
||||
markets.add(row.market);
|
||||
}
|
||||
});
|
||||
return Array.from(markets);
|
||||
}, [detail?.items]);
|
||||
|
||||
/** 商品明细当前可见行:按列筛选值本地过滤,驱动底部成本统计与列筛选联动 */
|
||||
const summaryItems = useMemo<IPurchaseDetailRow[]>(() => {
|
||||
const supplierKeys = itemColumnFilters.supplier;
|
||||
const marketKeys = itemColumnFilters.market;
|
||||
return (detail?.items ?? [])
|
||||
.filter((row) => !supplierKeys?.length || supplierKeys.includes(row.supplier_id))
|
||||
.filter((row) => !marketKeys?.length || marketKeys.includes(row.market ?? ''));
|
||||
}, [detail?.items, itemColumnFilters]);
|
||||
|
||||
// 生成账单(已完成采购单按门店生成:填写配送费/周转筐/托盘数量,金额只读)
|
||||
const [billOpen, setBillOpen] = useState(false);
|
||||
const [billPrepare, setBillPrepare] = useState<IBillPrepare | null>(null);
|
||||
@@ -251,6 +286,7 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
const openDetail = async (id: number) => {
|
||||
setDetailTab('items');
|
||||
setStoreSummary(null);
|
||||
setItemColumnFilters({});
|
||||
setDetailOpen(true);
|
||||
await loadDetail(id);
|
||||
};
|
||||
@@ -396,35 +432,142 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (row: IPurchaseDetailRow) => {
|
||||
setEditingRow(row);
|
||||
editForm.setFieldsValue({
|
||||
/** 商品明细是否可行内编辑(进行中采购单 + 修改权限) */
|
||||
const canUpdateRow = detail?.purchase.status === 0 && auth('purchase.order.update');
|
||||
|
||||
/**
|
||||
* 行内编辑保存:合并该行当前值整行提交(后端要求全字段);未变更 / 校验失败时不请求。
|
||||
* Enter 或失焦触发保存,保存后关闭编辑态并刷新详情与列表。
|
||||
*/
|
||||
const handleInlineSave = async (row: IPurchaseDetailRow, dataIndex: InlineEditField, rawValue: string | number) => {
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
setEditingCell(null);
|
||||
|
||||
const params: PurchaseRowUpdateParams = {
|
||||
product_name: row.product_name,
|
||||
supplier_id: row.supplier_id > 0 ? row.supplier_id : undefined,
|
||||
supplier_id: row.supplier_id,
|
||||
product_spec: row.product_spec,
|
||||
unit: row.unit,
|
||||
cost_price: Number(row.cost_price),
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
/** 提交行修改:同步该商品全部订货明细;syncTarget=product 时追加同步商品档案 */
|
||||
const handleEditSave = async (values: PurchaseRowUpdateParams) => {
|
||||
if (!detail || !editingRow) {
|
||||
if (dataIndex === 'cost_price') {
|
||||
const cost = Number(rawValue);
|
||||
if (rawValue === '' || !Number.isFinite(cost) || cost < 0) {
|
||||
message.warning('成本无效,已取消修改');
|
||||
return;
|
||||
}
|
||||
if (cost === Number(row.cost_price)) {
|
||||
return;
|
||||
}
|
||||
params.cost_price = cost;
|
||||
} else if (dataIndex === 'supplier_id') {
|
||||
const supplierId = Number(rawValue);
|
||||
if (supplierId === row.supplier_id) {
|
||||
return;
|
||||
}
|
||||
params.supplier_id = supplierId;
|
||||
} else {
|
||||
const text = String(rawValue).trim();
|
||||
if (!text) {
|
||||
message.warning(`${INLINE_FIELD_LABELS[dataIndex]}不能为空,已取消修改`);
|
||||
return;
|
||||
}
|
||||
if (text === row[dataIndex]) {
|
||||
return;
|
||||
}
|
||||
params[dataIndex] = text;
|
||||
}
|
||||
|
||||
if (params.supplier_id <= 0) {
|
||||
message.warning('请先通过供应商单元格为该商品设置供应商');
|
||||
return;
|
||||
}
|
||||
setRowSaving(true);
|
||||
try {
|
||||
const res = await updatePurchaseRow(detail.purchase.id!, editingRow.product_id, {
|
||||
...values,
|
||||
supplier_id: values.supplier_id ?? 0,
|
||||
});
|
||||
message.success(`已更新 ${res.data.data?.count ?? 0} 条订货明细`);
|
||||
setEditingRow(null);
|
||||
await loadDetail(detail.purchase.id!);
|
||||
await tableRef.current?.reload();
|
||||
} finally {
|
||||
setRowSaving(false);
|
||||
|
||||
const res = await updatePurchaseRow(detail.purchase.id!, row.product_id, params);
|
||||
message.success(`已更新 ${res.data.data?.count ?? 0} 条订货明细`);
|
||||
await loadDetail(detail.purchase.id!);
|
||||
await tableRef.current?.reload();
|
||||
};
|
||||
|
||||
/** 可编辑单元格:点击进入编辑态(文本 Input / 供应商 Select / 成本 InputNumber),Esc 取消 */
|
||||
const renderEditableCell = (
|
||||
row: IPurchaseDetailRow,
|
||||
dataIndex: InlineEditField,
|
||||
display: React.ReactNode,
|
||||
): React.ReactNode => {
|
||||
if (!canUpdateRow) {
|
||||
return display;
|
||||
}
|
||||
const editing = editingCell?.productId === row.product_id && editingCell.dataIndex === dataIndex;
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<div
|
||||
className="-mx-1 cursor-pointer rounded px-1 transition-colors hover:bg-blue-50"
|
||||
title="点击修改"
|
||||
onClick={() => setEditingCell({ productId: row.product_id, dataIndex })}
|
||||
>
|
||||
{display}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cancelOnEscape = {
|
||||
onKeyDown: (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setEditingCell(null);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if (dataIndex === 'supplier_id') {
|
||||
return (
|
||||
<Select
|
||||
size="small"
|
||||
autoFocus
|
||||
defaultOpen
|
||||
className="w-full"
|
||||
defaultValue={row.supplier_id > 0 ? row.supplier_id : undefined}
|
||||
placeholder="选择供应商"
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
options={suppliers.map((s) => ({ value: s.id, label: s.name }))}
|
||||
onChange={(value) => void handleInlineSave(row, dataIndex, value)}
|
||||
onBlur={() => setEditingCell(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (dataIndex === 'cost_price') {
|
||||
return (
|
||||
<InputNumber
|
||||
size="small"
|
||||
autoFocus
|
||||
min={0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
className="w-full"
|
||||
defaultValue={Number(row.cost_price)}
|
||||
onPressEnter={(e) => void handleInlineSave(row, dataIndex, (e.target as HTMLInputElement).value)}
|
||||
onBlur={(e) => void handleInlineSave(row, dataIndex, e.target.value)}
|
||||
{...cancelOnEscape}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
size="small"
|
||||
autoFocus
|
||||
defaultValue={row[dataIndex]}
|
||||
maxLength={dataIndex === 'unit' ? 20 : 100}
|
||||
onPressEnter={(e) => void handleInlineSave(row, dataIndex, (e.target as HTMLInputElement).value)}
|
||||
onBlur={(e) => void handleInlineSave(row, dataIndex, e.target.value)}
|
||||
{...cancelOnEscape}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/** 打开生成账单弹窗:拉取按门店汇总的商品金额(只读),初始化配送费/周转筐/托盘数量 */
|
||||
@@ -483,19 +626,40 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
};
|
||||
|
||||
|
||||
/** 明细矩阵列:品名/供应商/单价/包规/单位/成本 + 每门店一列(数量)+ 合计 + 操作 */
|
||||
/** 明细矩阵列:品名/供应商/单价/包规/单位/成本(可点击行内编辑)+ 每门店一列(数量)+ 合计 */
|
||||
const buildItemColumns = (): TableProps<IPurchaseDetailRow>['columns'] => {
|
||||
const fixed: NonNullable<TableProps<IPurchaseDetailRow>['columns']> = [
|
||||
{ title: '品名', dataIndex: 'product_name', width: 160, align: 'center', fixed: 'left', ellipsis: true },
|
||||
{
|
||||
title: '品名',
|
||||
dataIndex: 'product_name',
|
||||
width: 160,
|
||||
align: 'center',
|
||||
fixed: 'left',
|
||||
ellipsis: true,
|
||||
render: (_, row) => renderEditableCell(row, 'product_name', row.product_name),
|
||||
},
|
||||
{
|
||||
title: '供应商',
|
||||
dataIndex: 'supplier',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
fixed: 'left',
|
||||
render: (_, row) => row.supplier?.name ?? '-',
|
||||
filters: purchaseSuppliers.map((s) => ({ text: s.name, value: s.id })),
|
||||
filteredValue: itemColumnFilters.supplier ?? null,
|
||||
onFilter: (value, row) => row.supplier_id === Number(value),
|
||||
render: (_, row) => renderEditableCell(row, 'supplier_id', row.supplier?.name ?? '-'),
|
||||
},
|
||||
{
|
||||
title: '市场',
|
||||
fixed: 'left',
|
||||
dataIndex: 'market',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
filters: itemMarketOptions.map((m) => ({ text: m, value: m })),
|
||||
filteredValue: itemColumnFilters.market ?? null,
|
||||
onFilter: (value, row) => (row.market ?? '') === value,
|
||||
render: (v) => v || '-',
|
||||
},
|
||||
{ title: '市场', fixed: 'left', dataIndex: 'market', width: 90, align: 'center', render: (v) => v || '-' },
|
||||
{
|
||||
title: '单价',
|
||||
key: 'retail_price',
|
||||
@@ -509,9 +673,27 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
return `¥${calcUnitRefPrice(weightedPrice, row.product_spec ?? '').toFixed(2)}`;
|
||||
},
|
||||
},
|
||||
{ title: '包规', dataIndex: 'product_spec', align: 'center', width: 90, render: (v) => v || '-' },
|
||||
{ title: '单位', dataIndex: 'unit', width: 70, align: 'center', render: (v) => v || '-' },
|
||||
{ title: '成本', dataIndex: 'cost_price', width: 90, align: 'center', render: (v) => `¥${Number(v).toFixed(2)}` },
|
||||
{
|
||||
title: '包规',
|
||||
dataIndex: 'product_spec',
|
||||
align: 'center',
|
||||
width: 90,
|
||||
render: (v, row) => renderEditableCell(row, 'product_spec', v || '-'),
|
||||
},
|
||||
{
|
||||
title: '单位',
|
||||
dataIndex: 'unit',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
render: (v, row) => renderEditableCell(row, 'unit', v || '-'),
|
||||
},
|
||||
{
|
||||
title: '成本',
|
||||
dataIndex: 'cost_price',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (v, row) => renderEditableCell(row, 'cost_price', `¥${Number(v).toFixed(2)}`),
|
||||
},
|
||||
{
|
||||
title: '合计数量',
|
||||
key: 'total_quantity',
|
||||
@@ -536,39 +718,13 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
},
|
||||
}));
|
||||
|
||||
const tail: NonNullable<TableProps<IPurchaseDetailRow>['columns']> = [
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 90,
|
||||
fixed: 'right',
|
||||
align: 'center',
|
||||
render: (_, row) => (
|
||||
<>
|
||||
{ detail?.purchase.status === 0 ? (
|
||||
<AuthButton auth="purchase.order.update">
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
修改
|
||||
</Button>
|
||||
</AuthButton>
|
||||
) : '-' }
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return [...fixed, ...storeColumns, ...tail];
|
||||
return [...fixed, ...storeColumns];
|
||||
};
|
||||
|
||||
/** 底部统计行:按门店统计金额(Σ 门店数量 × 行单价)+ 合计 */
|
||||
/** 底部统计行:按门店统计金额(Σ 门店数量 × 行单价)+ 合计(随供应商/市场列筛选联动) */
|
||||
const renderSummary = () => {
|
||||
const stores = detail?.stores ?? [];
|
||||
const items = detail?.items ?? [];
|
||||
const items = summaryItems;
|
||||
const storeTotals = stores.map((store) =>
|
||||
items.reduce(
|
||||
(sum, row) => sum + (row.cells[store.id] ?? 0) * Number(row.cost_price),
|
||||
@@ -581,9 +737,12 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
return (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={7} align="center">
|
||||
<Text strong>成本统计(按门店)</Text>
|
||||
<Space size={16}>
|
||||
<Text strong>成本统计(按门店)</Text>
|
||||
<Text strong type="danger">总计 ¥{totalAmount.toFixed(2)}</Text>
|
||||
</Space>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={8} align="center">
|
||||
<Table.Summary.Cell index={7} align="center">
|
||||
<Text strong>{totalQuantity}</Text>
|
||||
</Table.Summary.Cell>
|
||||
{storeTotals.map((amount, index) => (
|
||||
@@ -591,10 +750,6 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
<Text strong>¥{amount.toFixed(2)}</Text>
|
||||
</Table.Summary.Cell>
|
||||
))}
|
||||
|
||||
<Table.Summary.Cell index={9 + stores.length} align="center">
|
||||
<Text strong>¥{totalAmount.toFixed(2)}</Text>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
);
|
||||
};
|
||||
@@ -1195,6 +1350,11 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
导出
|
||||
</Button>
|
||||
</AuthButton>
|
||||
{canUpdateRow && (
|
||||
<Text type="secondary" className="text-xs">
|
||||
点击 品名/供应商/包规/单位/成本 单元格可直接修改;保存将同步该商品全部订货明细与商品档案,成本变化时各门店单价按等级上浮自动重算
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
<Table<IPurchaseDetailRow>
|
||||
rowKey="product_id"
|
||||
@@ -1202,6 +1362,7 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
bordered
|
||||
columns={buildItemColumns()}
|
||||
dataSource={detail.items}
|
||||
onChange={(_pagination, filters) => setItemColumnFilters(filters)}
|
||||
pagination={false}
|
||||
scroll={{ x: 1200, y: 800 }}
|
||||
summary={renderSummary}
|
||||
@@ -1212,68 +1373,6 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* 行修改:品名/供应商/包规/单位/成本 */}
|
||||
<Modal
|
||||
title={editingRow ? `修改「${editingRow.product_name}」` : '修改明细行'}
|
||||
open={editingRow !== null}
|
||||
onCancel={() => setEditingRow(null)}
|
||||
destroyOnHidden
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => setEditingRow(null)}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button key="purchase" type="primary" loading={rowSaving} onClick={() => editForm.submit()}>
|
||||
保存
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<div className="py-2 text-gray-500">
|
||||
保存将同步修改本采购单中该商品的所有订单项,并同步保存到商品档案(商品列表);修改成本时,各门店单价按等级上浮比例自动重算。
|
||||
</div>
|
||||
<Form form={editForm} layout="vertical" onFinish={handleEditSave}>
|
||||
<Form.Item
|
||||
label="品名"
|
||||
name="product_name"
|
||||
rules={[{ required: true, message: '请输入品名' }, { max: 100 }]}
|
||||
>
|
||||
<Input maxLength={100} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="供应商"
|
||||
name="supplier_id"
|
||||
rules={[{ required: true, message: '请选择供应商' }]}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
placeholder="选择供应商"
|
||||
options={suppliers.map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="包规"
|
||||
name="product_spec"
|
||||
rules={[{ required: true, message: '请输入包规' }, { max: 100 }]}
|
||||
>
|
||||
<Input maxLength={100} placeholder="如:10斤/箱" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="单位"
|
||||
name="unit"
|
||||
rules={[{ required: true, message: '请输入单位' },{ max: 20 }]}
|
||||
>
|
||||
<Input maxLength={20} placeholder="如:斤" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="成本"
|
||||
name="cost_price"
|
||||
rules={[{ required: true, message: '请输入成本' }]}
|
||||
>
|
||||
<InputNumber min={0} precision={2} prefix="¥" className="w-full" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 单元格下钻 */}
|
||||
<Modal
|
||||
title={cellData ? `${cellData.store?.name ?? ''} · ${cellData.product?.name ?? ''}` : '门店商品明细'}
|
||||
|
||||
Reference in New Issue
Block a user