feat: Update Purchase Order and Sales Order pages for improved functionality

- Added a new manager selection dropdown in the Purchase Order page, displayed conditionally based on the input mode.
- Adjusted the layout of the supplier information section to improve UI consistency.
- Enhanced the Sales Order page to include dynamic filtering based on division codes, ensuring only relevant items are displayed.
- Implemented recalculation of prices based on selected pricing modes and partner IDs, improving pricing accuracy during order processing.

These changes aim to enhance user experience and streamline order management processes.
This commit is contained in:
kjs
2026-04-06 10:11:35 +09:00
parent f8a02235f8
commit 68e7d5763d
4 changed files with 261 additions and 52 deletions

View File

@@ -464,18 +464,25 @@ export default function PurchaseOrderPage() {
if (itemSearchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
}
if (itemSearchDivision !== "all") {
filters.push({ columnName: "division", operator: "contains", value: itemSearchDivision });
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: p, size: s,
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
setItemSearchResults(resData?.data || resData?.rows || []);
setItemTotal(resData?.total || 0);
setItemTotalPages(resData?.totalPages || Math.ceil((resData?.total || 0) / s));
let allRows = resData?.data || resData?.rows || [];
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
allRows = allRows.filter((item: any) => {
const div = item.division || "";
return div.includes(itemSearchDivision) || (divLabel && div.includes(divLabel));
});
}
const total = allRows.length;
const start = (p - 1) * s;
setItemSearchResults(allRows.slice(start, start + s));
setItemTotal(total);
setItemTotalPages(Math.max(1, Math.ceil(total / s)));
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
@@ -510,32 +517,42 @@ export default function PurchaseOrderPage() {
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
const supplierCode = masterForm.supplier_code;
const isStandard = masterForm.price_mode === "standard";
const isSupplier = masterForm.price_mode === "supplier";
let supplierPriceMap: Record<string, string> = {};
if (supplierCode) {
if (isSupplier && supplierCode) {
try {
const itemIds = selected.map((item) => item.item_number || item.id);
const res = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, {
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 500,
dataFilter: {
enabled: true,
filters: [
{ columnName: "supplier_code", operator: "equals", value: supplierCode },
{ columnName: "item_code", operator: "in", value: itemIds },
{ columnName: "supplier_id", operator: "equals", value: supplierCode },
{ columnName: "item_id", operator: "in", value: itemIds },
],
},
autoFilter: true,
});
const mappings = res.data?.data?.data || res.data?.data?.rows || [];
for (const m of mappings) {
const price = m.base_price || m.unit_price || "";
if (price) supplierPriceMap[m.item_code] = String(price);
const prices = res.data?.data?.data || res.data?.data?.rows || [];
const today = new Date().toISOString().slice(0, 10);
for (const p of prices) {
if (p.start_date && p.start_date > today) continue;
if (p.end_date && p.end_date < today) continue;
const price = p.calculated_price || p.base_price || p.unit_price || "";
if (price && Number(price) > 0) supplierPriceMap[p.item_id] = String(price);
}
} catch { /* skip */ }
}
const newRows = selected.map((item) => {
const itemCode = item.item_number || item.id;
const unitPrice = supplierPriceMap[itemCode] || item.purchase_price || item.standard_price || "";
let unitPrice = "";
if (isStandard) {
unitPrice = item.purchase_price || item.standard_price || "";
} else if (isSupplier && supplierCode) {
unitPrice = supplierPriceMap[itemCode] || "";
}
return {
_id: `new_${Date.now()}_${Math.random()}`,
item_code: itemCode,
@@ -559,6 +576,65 @@ export default function PurchaseOrderPage() {
setItemSelectOpen(false);
};
// 단가 재계산: 단가방식/공급업체 변경 시 기존 품목 단가 갱신
const recalcPrices = useCallback(async (priceMode: string, supplierCode: string) => {
if (detailRows.length === 0) return;
const isStandard = priceMode === "standard";
const isSupplier = priceMode === "supplier";
if (isStandard) {
const itemCodes = detailRows.map((r) => r.item_code).filter(Boolean);
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
autoFilter: true,
});
const items = res.data?.data?.data || res.data?.data?.rows || [];
const priceMap: Record<string, string> = {};
for (const item of items) {
const price = item.purchase_price || item.standard_price || "";
if (price) priceMap[item.item_number] = String(price);
}
setDetailRows((prev) => prev.map((row) => {
const up = priceMap[row.item_code] || "";
const qty = parseFloat(row.order_qty) || 0;
const price = parseFloat(up) || 0;
return { ...row, unit_price: up, amount: (qty * price).toString() };
}));
} catch { /* skip */ }
} else if (isSupplier && supplierCode) {
const itemCodes = detailRows.map((r) => r.item_code).filter(Boolean);
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: supplierCode },
{ columnName: "item_id", operator: "in", value: itemCodes },
]},
autoFilter: true,
});
const prices = res.data?.data?.data || res.data?.data?.rows || [];
const today = new Date().toISOString().slice(0, 10);
const priceMap: Record<string, string> = {};
for (const p of prices) {
if (p.start_date && p.start_date > today) continue;
if (p.end_date && p.end_date < today) continue;
const price = p.calculated_price || p.base_price || p.unit_price || "";
if (price && Number(price) > 0) priceMap[p.item_id] = String(price);
}
setDetailRows((prev) => prev.map((row) => {
const up = priceMap[row.item_code] || "";
const qty = parseFloat(row.order_qty) || 0;
const price = parseFloat(up) || 0;
return { ...row, unit_price: up, amount: (qty * price).toString() };
}));
} catch { /* skip */ }
}
}, [detailRows]);
const updateDetailRow = (idx: number, field: string, value: string) => {
setDetailRows((prev) => {
const next = [...prev];
@@ -733,7 +809,7 @@ export default function PurchaseOrderPage() {
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide"></Label>
<Select value={masterForm.price_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, price_mode: v }))} disabled={isReadOnly}>
<Select value={masterForm.price_mode || ""} onValueChange={(v) => { setMasterForm((p) => ({ ...p, price_mode: v })); recalcPrices(v, masterForm.supplier_code || ""); }} disabled={isReadOnly}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["price_mode"] || []).map((o) => (
@@ -742,15 +818,27 @@ export default function PurchaseOrderPage() {
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide"></Label>
<Select value={masterForm.manager || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, manager: v }))} disabled={isReadOnly}>
<SelectTrigger className="h-9"><SelectValue placeholder="담당자 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["manager"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* 공급업체 / 담당자 */}
{/* 공급업체 / 담당자 — 입력방식이 '공급업체 우선'일 때만 표시 */}
{masterForm.input_mode === "supplierFirst" && (
<div className="p-4 bg-muted/50 border border-dashed border-border rounded-lg space-y-3">
<div className="text-xs font-semibold text-primary flex items-center gap-1.5">
<ClipboardList className="w-3.5 h-3.5" />
</div>
<div className="grid grid-cols-3 gap-3.5">
<div className="grid grid-cols-2 gap-3.5">
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide"></Label>
<Select
@@ -759,6 +847,7 @@ export default function PurchaseOrderPage() {
const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v);
const name = supp?.label.replace(` (${v})`, "") || "";
setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name }));
recalcPrices(masterForm.price_mode || "", v);
}}
disabled={isReadOnly}
>
@@ -770,23 +859,9 @@ export default function PurchaseOrderPage() {
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide"></Label>
<Select
value={masterForm.manager || ""}
onValueChange={(v) => setMasterForm((p) => ({ ...p, manager: v }))}
disabled={isReadOnly}
>
<SelectTrigger className="h-9"><SelectValue placeholder="담당자 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["manager"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
)}
{/* 품목 내역 */}
<div className="space-y-2.5">
@@ -823,6 +898,9 @@ export default function PurchaseOrderPage() {
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{masterForm.input_mode === "itemFirst" && (
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
)}
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -846,6 +924,27 @@ export default function PurchaseOrderPage() {
)}
<TableCell className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>
<TableCell className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>
{masterForm.input_mode === "itemFirst" && (
<TableCell>
{isReadOnly ? (
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
) : (
<Select value={row.supplier_code || ""} onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
const name = supp?.label.replace(` (${v})`, "") || "";
updateDetailRow(idx, "supplier_code", v);
updateDetailRow(idx, "supplier_name", name);
}}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
</TableCell>
)}
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
<TableCell className="text-[13px]">{row.unit}</TableCell>
<TableCell>

View File

@@ -105,14 +105,39 @@ export default function PurchaseItemPage() {
load();
}, []);
// 좌측: 품목 조회
// 구매품 division 코드 조회
const [purchaseDivCodes, setPurchaseDivCodes] = useState<string[]>([]);
useEffect(() => {
const loadDiv = async () => {
try {
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/division/values`);
const flatten = (vals: any[]): { code: string; label: string }[] => {
const r: { code: string; label: string }[] = [];
for (const v of vals) { r.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) r.push(...flatten(v.children)); }
return r;
};
const all = flatten(res.data?.data || []);
const codes = all.filter(o => o.label.includes("구매")).map(o => o.code);
setPurchaseDivCodes(codes);
} catch { /* skip */ }
};
loadDiv();
}, []);
// 좌측: 품목 조회 (관리품목이 구매관리/구매품인 것만)
const fetchItems = useCallback(async () => {
if (purchaseDivCodes.length === 0) return;
setItemLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
// division 필터 추가: 구매 관련 코드만 (in 연산자)
const allFilters = [
...filters,
{ columnName: "division", operator: "in", value: purchaseDivCodes },
];
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
dataFilter: { enabled: true, filters: allFilters },
autoFilter: true,
});
setItems(res.data?.data?.data || res.data?.data?.rows || []);
@@ -121,7 +146,7 @@ export default function PurchaseItemPage() {
} finally {
setItemLoading(false);
}
}, [searchFilters]);
}, [searchFilters, purchaseDivCodes]);
useEffect(() => { fetchItems(); }, [fetchItems]);

View File

@@ -808,15 +808,19 @@ export default function CustomerManagementPage() {
try {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
filters.push({ columnName: "division", operator: "contains", value: "CAT_ML8ZFVEL_1TOR" });
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 50,
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
setItemSearchResults(allItems.filter((item: any) => !existingItemIds.has(item.item_number) && !existingItemIds.has(item.id)));
const SALES_CODE = "CAT_ML8ZFVEL_1TOR";
setItemSearchResults(allItems.filter((item: any) => {
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
const div = item.division || "";
return div.includes(SALES_CODE) || div.includes("영업");
}));
} catch { /* skip */ } finally { setItemSearchLoading(false); }
};

View File

@@ -684,16 +684,26 @@ export default function SalesOrderPage() {
try {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
if (itemSearchDivision !== "all") filters.push({ columnName: "division", operator: "contains", value: itemSearchDivision });
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: p, size: s,
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
setItemSearchResults(resData?.data || resData?.rows || []);
setItemTotal(resData?.total || 0);
setItemTotalPages(resData?.totalPages || Math.ceil((resData?.total || 0) / s));
let allRows = resData?.data || resData?.rows || [];
// 관리품목 필터 (코드/라벨 혼재 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
allRows = allRows.filter((item: any) => {
const div = item.division || "";
return div.includes(itemSearchDivision) || (divLabel && div.includes(divLabel));
});
}
const total = allRows.length;
const start = (p - 1) * s;
setItemSearchResults(allRows.slice(start, start + s));
setItemTotal(total);
setItemTotalPages(Math.max(1, Math.ceil(total / s)));
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
@@ -732,7 +742,7 @@ export default function SalesOrderPage() {
if (isCustomerPrice && partnerId) {
try {
const itemIds = selected.map((item) => item.item_number || item.id);
const res = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
const res = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 500,
dataFilter: {
enabled: true,
@@ -743,10 +753,18 @@ export default function SalesOrderPage() {
},
autoFilter: true,
});
const mappings = res.data?.data?.data || res.data?.data?.rows || [];
for (const m of mappings) {
const price = m.calculated_price || m.current_unit_price || "";
if (price) customerPriceMap[m.item_id] = String(price);
const prices = res.data?.data?.data || res.data?.data?.rows || [];
const today = new Date().toISOString().slice(0, 10);
for (const p of prices) {
const start = p.start_date || "";
const end = p.end_date || "";
if (start && start > today) continue;
if (end && end < today) continue;
const price = p.calculated_price || p.base_price || p.unit_price || "";
if (price && Number(price) > 0) {
const existing = customerPriceMap[p.item_id];
if (!existing || Number(price) > 0) customerPriceMap[p.item_id] = String(price);
}
}
} catch { /* skip */ }
}
@@ -781,6 +799,69 @@ export default function SalesOrderPage() {
setItemSelectOpen(false);
};
// 단가 재계산: 단가방식/거래처 변경 시 기존 품목 단가 갱신
const recalcPrices = useCallback(async (priceMode: string, partnerId: string) => {
if (detailRows.length === 0) return;
const STANDARD_CODES = ["CAT_MM0BUZKL_HJ7U", "CAT_MLKG792S_54WJ"];
const CUSTOMER_CODES = ["CAT_MM0BV3OS_41DX", "CAT_MLKG7D8K_N8SI"];
const isStandard = STANDARD_CODES.includes(priceMode);
const isCustomer = CUSTOMER_CODES.includes(priceMode);
if (isStandard) {
// 품목 기준단가 조회
const itemCodes = detailRows.map((r) => r.part_code).filter(Boolean);
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
autoFilter: true,
});
const items = res.data?.data?.data || res.data?.data?.rows || [];
const priceMap: Record<string, string> = {};
for (const item of items) {
const price = item.standard_price || item.selling_price || "";
if (price) priceMap[item.item_number] = String(price);
}
setDetailRows((prev) => prev.map((row) => {
const up = priceMap[row.part_code] || "";
const qty = parseFloat(row.qty) || 0;
const price = parseFloat(up) || 0;
return { ...row, unit_price: up, amount: (qty * price).toString() };
}));
} catch { /* skip */ }
} else if (isCustomer && partnerId) {
// 거래처별 단가 조회
const itemCodes = detailRows.map((r) => r.part_code).filter(Boolean);
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: partnerId },
{ columnName: "item_id", operator: "in", value: itemCodes },
]},
autoFilter: true,
});
const prices = res.data?.data?.data || res.data?.data?.rows || [];
const today = new Date().toISOString().slice(0, 10);
const priceMap: Record<string, string> = {};
for (const p of prices) {
if (p.start_date && p.start_date > today) continue;
if (p.end_date && p.end_date < today) continue;
const price = p.calculated_price || p.base_price || p.unit_price || "";
if (price && Number(price) > 0) priceMap[p.item_id] = String(price);
}
setDetailRows((prev) => prev.map((row) => {
const up = priceMap[row.part_code] || "";
const qty = parseFloat(row.qty) || 0;
const price = parseFloat(up) || 0;
return { ...row, unit_price: up, amount: (qty * price).toString() };
}));
} catch { /* skip */ }
}
}, [detailRows]);
const updateDetailRow = (idx: number, field: string, value: string) => {
setDetailRows((prev) => {
const next = [...prev];
@@ -1277,7 +1358,7 @@ export default function SalesOrderPage() {
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={masterForm.price_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, price_mode: v }))}>
<Select value={masterForm.price_mode || ""} onValueChange={(v) => { setMasterForm((p) => ({ ...p, price_mode: v })); recalcPrices(v, masterForm.partner_id || ""); }}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["price_mode"] || []).map((o) => (
@@ -1306,7 +1387,7 @@ export default function SalesOrderPage() {
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select
value={masterForm.partner_id || ""}
onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); }}
onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); recalcPrices(masterForm.price_mode || "", v); }}
>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>