Enhance outbound and shipping order functionality
- Implemented automatic status transition for sales orders when all associated details are shipped, marking both master and detail records as 'COMPLETED'. - Updated pre-guard checks to allow orders in 'WAITING' and 'PLANNING' statuses while blocking 'COMPLETED' and 'CANCELED' statuses for new shipping instructions. - Enhanced user experience by dynamically filtering users based on department structure, ensuring only relevant sales personnel are displayed. (TASK: ERP-node-XXX)
This commit is contained in:
@@ -324,6 +324,41 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
WHERE id = $2 AND company_code = $3`,
|
||||
[outQtyNum, detailId, companyCode],
|
||||
);
|
||||
|
||||
// 수주 자동 상태 전이: 같은 order_no의 모든 detail이 완전 출고되면 master/detail 모두 'COMPLETED'.
|
||||
const orderNoRes = await client.query(
|
||||
`SELECT order_no FROM sales_order_detail WHERE id = $1 AND company_code = $2`,
|
||||
[detailId, companyCode],
|
||||
);
|
||||
const orderNo = orderNoRes.rows[0]?.order_no;
|
||||
if (orderNo) {
|
||||
const sumRes = await client.query(
|
||||
`SELECT
|
||||
SUM(COALESCE(NULLIF(qty,'')::numeric, 0)) AS total_qty,
|
||||
SUM(COALESCE(NULLIF(ship_qty,'')::numeric, 0)) AS total_ship
|
||||
FROM sales_order_detail
|
||||
WHERE order_no = $1 AND company_code = $2`,
|
||||
[orderNo, companyCode],
|
||||
);
|
||||
const totalQty = Number(sumRes.rows[0]?.total_qty || 0);
|
||||
const totalShip = Number(sumRes.rows[0]?.total_ship || 0);
|
||||
if (totalQty > 0 && totalShip >= totalQty) {
|
||||
await client.query(
|
||||
`UPDATE sales_order_mng
|
||||
SET status = 'COMPLETED', updated_date = NOW()
|
||||
WHERE order_no = $1 AND company_code = $2
|
||||
AND COALESCE(UPPER(status), '') <> 'CANCELED'`,
|
||||
[orderNo, companyCode],
|
||||
);
|
||||
await client.query(
|
||||
`UPDATE sales_order_detail
|
||||
SET status = 'COMPLETED', updated_date = NOW()
|
||||
WHERE order_no = $1 AND company_code = $2
|
||||
AND COALESCE(UPPER(status), '') <> 'CANCELED'`,
|
||||
[orderNo, companyCode],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shipment_instruction master status 자동 전환 (입고의 purchase_detail → purchase_order_mng 패턴)
|
||||
|
||||
@@ -217,9 +217,8 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// ─── 사전 가드: 대상 수주 status='CONFIRMED' 검증 (TASK:ERP-047) ───
|
||||
// items[*].salesOrderId / detailId 로부터 sales_order_mng 의 status 를 조회.
|
||||
// WAITING/CANCELED/COMPLETED 1건이라도 있으면 전체 트랜잭션 실패.
|
||||
// ─── 사전 가드: COMPLETED/CANCELED 상태만 차단 (자동 상태 전이 정책: 등록→출하계획단계→완료) ───
|
||||
// WAITING/PLANNING 등은 모두 통과. 신규 등록 직후의 수주에서도 출하지시 작성 허용.
|
||||
{
|
||||
const masterIds = Array.from(
|
||||
new Set(
|
||||
@@ -259,24 +258,17 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
||||
statusRows.push(...r.rows);
|
||||
}
|
||||
|
||||
const blocked: { waiting: string[]; canceled: string[]; completed: string[] } = {
|
||||
waiting: [],
|
||||
const blocked: { canceled: string[]; completed: string[] } = {
|
||||
canceled: [],
|
||||
completed: [],
|
||||
};
|
||||
for (const row of statusRows) {
|
||||
const st = (row.status || "").toUpperCase();
|
||||
if (st === "WAITING") blocked.waiting.push(row.order_no);
|
||||
else if (st === "CANCELED") blocked.canceled.push(row.order_no);
|
||||
if (st === "CANCELED") blocked.canceled.push(row.order_no);
|
||||
else if (st === "COMPLETED") blocked.completed.push(row.order_no);
|
||||
}
|
||||
|
||||
const messages: string[] = [];
|
||||
if (blocked.waiting.length > 0) {
|
||||
messages.push(
|
||||
`수주번호 ${blocked.waiting.join(", ")}은(는) 확정 상태가 아닙니다. 먼저 수주를 확정해주세요.`
|
||||
);
|
||||
}
|
||||
if (blocked.canceled.length > 0) {
|
||||
messages.push(
|
||||
`취소된 수주에는 출하지시를 등록할 수 없습니다 (수주번호: ${blocked.canceled.join(", ")}).`
|
||||
@@ -432,7 +424,9 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
||||
|
||||
const candidateIds = Array.from(masterIds);
|
||||
if (candidateIds.length > 0) {
|
||||
// 3) 자동완료 가능 수주 ID 집계 — CANCELED 제외 모든 상세가 COMPLETED 출하지시 detail 에 커버되어야 함
|
||||
// 3) 자동완료 가능 수주 ID 집계 — CANCELED/이미 COMPLETED 인 detail 은 검사 제외.
|
||||
// 나머지 detail 은 COMPLETED 출하지시에 매핑되어야 함.
|
||||
// (이미 COMPLETED 인 detail 까지 매핑을 요구하면, 옛 정책에서 직접 COMPLETED 처리된 행이 차단 사유가 됨)
|
||||
const eligibleRes = await client.query(
|
||||
`
|
||||
SELECT m.id, m.order_no
|
||||
@@ -445,7 +439,7 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
||||
FROM sales_order_detail d
|
||||
WHERE d.order_no = m.order_no
|
||||
AND d.company_code = m.company_code
|
||||
AND COALESCE(d.status,'WAITING') <> 'CANCELED'
|
||||
AND COALESCE(UPPER(d.status),'WAITING') NOT IN ('CANCELED','COMPLETED')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM shipment_instruction_detail sid
|
||||
|
||||
@@ -631,13 +631,15 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
|
||||
await client.query("BEGIN");
|
||||
const savedPlans = [];
|
||||
|
||||
// ─── 사전 가드: 대상 수주들의 status='CONFIRMED' 검증 (TASK:ERP-047) ───
|
||||
// detail/master 모두 sales_order_mng.status를 기준으로 검증.
|
||||
// WAITING/CANCELED/COMPLETED 1건이라도 포함되면 전체 트랜잭션 실패.
|
||||
// ─── 사전 가드: COMPLETED/CANCELED 상태만 차단 (자동 상태 전이 도입 후 정책 변경) ───
|
||||
// 신규 등록('' 또는 'WAITING' 등) → 출하계획 작성 허용. 출하계획 작성 시 자동으로 'PLANNING'으로 전이.
|
||||
const validSourceIds = plans
|
||||
.filter((p: any) => p?.sourceId && Number(p?.planQty) > 0)
|
||||
.map((p: any) => String(p.sourceId));
|
||||
|
||||
// 출하계획 작성 후 자동 PLANNING 전이용 — 영향받은 master id 모음
|
||||
const affectedMasterIds = new Set<number>();
|
||||
|
||||
if (validSourceIds.length > 0) {
|
||||
let statusRows: Array<{ id: number; order_no: string; status: string | null }>;
|
||||
if (detectedSource === "detail") {
|
||||
@@ -661,25 +663,19 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
|
||||
statusRows = r.rows;
|
||||
}
|
||||
|
||||
const blocked: { waiting: string[]; canceled: string[]; completed: string[] } = {
|
||||
waiting: [],
|
||||
const blocked: { canceled: string[]; completed: string[] } = {
|
||||
canceled: [],
|
||||
completed: [],
|
||||
};
|
||||
for (const row of statusRows) {
|
||||
const st = (row.status || "").toUpperCase();
|
||||
if (st === "WAITING") blocked.waiting.push(row.order_no);
|
||||
else if (st === "CANCELED") blocked.canceled.push(row.order_no);
|
||||
if (st === "CANCELED") blocked.canceled.push(row.order_no);
|
||||
else if (st === "COMPLETED") blocked.completed.push(row.order_no);
|
||||
// 그 외(CONFIRMED 또는 그룹A 한글값 등)는 통과
|
||||
// 그 외(빈값/WAITING/CONFIRMED/PLANNING 등)는 통과
|
||||
if (row.id) affectedMasterIds.add(Number(row.id));
|
||||
}
|
||||
|
||||
const messages: string[] = [];
|
||||
if (blocked.waiting.length > 0) {
|
||||
messages.push(
|
||||
`수주번호 ${blocked.waiting.join(", ")}은(는) 확정 상태가 아닙니다. 먼저 수주를 확정해주세요.`
|
||||
);
|
||||
}
|
||||
if (blocked.canceled.length > 0) {
|
||||
messages.push(
|
||||
`취소된 수주에는 출하계획을 등록할 수 없습니다 (수주번호: ${blocked.canceled.join(", ")}).`
|
||||
@@ -808,6 +804,33 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 상태 전이: 출하계획이 생성된 수주는 PLANNING으로 변경 (COMPLETED/CANCELED 제외).
|
||||
// sales_order_mng + 해당 master의 모든 sales_order_detail.status 동시 갱신.
|
||||
if (affectedMasterIds.size > 0) {
|
||||
const masterIdArr = [...affectedMasterIds];
|
||||
await client.query(
|
||||
`UPDATE sales_order_mng
|
||||
SET status = 'PLANNING',
|
||||
updated_date = NOW()
|
||||
WHERE id = ANY($1::int[])
|
||||
AND company_code = $2
|
||||
AND COALESCE(UPPER(status), '') NOT IN ('COMPLETED', 'CANCELED')`,
|
||||
[masterIdArr, companyCode]
|
||||
);
|
||||
await client.query(
|
||||
`UPDATE sales_order_detail
|
||||
SET status = 'PLANNING',
|
||||
updated_date = NOW()
|
||||
WHERE order_no IN (
|
||||
SELECT order_no FROM sales_order_mng
|
||||
WHERE id = ANY($1::int[]) AND company_code = $2
|
||||
)
|
||||
AND company_code = $2
|
||||
AND COALESCE(UPPER(status), '') NOT IN ('COMPLETED', 'CANCELED')`,
|
||||
[masterIdArr, companyCode]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("출하계획 일괄 저장 완료", {
|
||||
|
||||
@@ -134,8 +134,6 @@ export default function InventoryStatusPage() {
|
||||
const [stockLoading, setStockLoading] = useState(false);
|
||||
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
|
||||
|
||||
// 재고 없는 품목 표시 여부
|
||||
const [showMissingItems, setShowMissingItems] = useState(false);
|
||||
|
||||
// 창고 목록 (조정 모달에서 사용)
|
||||
const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]);
|
||||
@@ -251,43 +249,13 @@ export default function InventoryStatusPage() {
|
||||
};
|
||||
});
|
||||
|
||||
// 재고 없는 품목 표시: inventory_stock에 없는 item_info 품목을 미등록 가상 행으로 추가
|
||||
if (showMissingItems) {
|
||||
const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean));
|
||||
const missingRows = items
|
||||
.filter((i: any) => {
|
||||
const code = i.item_number || i.item_code;
|
||||
return code && !existingCodes.has(code);
|
||||
})
|
||||
.map((i: any) => {
|
||||
const code = i.item_number || i.item_code;
|
||||
const rawUnit = i.inventory_unit || "";
|
||||
return {
|
||||
id: `missing-${code}`,
|
||||
item_code: code,
|
||||
item_name: i.item_name || "",
|
||||
spec: i.size || "",
|
||||
warehouse_code: "",
|
||||
warehouse_name: "",
|
||||
location_code: "",
|
||||
current_qty: "0",
|
||||
safety_qty: "",
|
||||
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
|
||||
status: "미등록",
|
||||
_isLow: false,
|
||||
_isMissing: true,
|
||||
};
|
||||
});
|
||||
setStockItems([...data, ...missingRows]);
|
||||
} else {
|
||||
setStockItems(data);
|
||||
}
|
||||
setStockItems(data);
|
||||
} catch {
|
||||
toast.error("재고 목록을 불러오지 못했어요");
|
||||
} finally {
|
||||
setStockLoading(false);
|
||||
}
|
||||
}, [categoryOptions, searchFilters, showMissingItems]);
|
||||
}, [categoryOptions, searchFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStock();
|
||||
@@ -593,13 +561,6 @@ export default function InventoryStatusPage() {
|
||||
{filteredStockItems.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox
|
||||
checked={showMissingItems}
|
||||
onCheckedChange={(v) => setShowMissingItems(!!v)}
|
||||
/>
|
||||
<span>재고 없는 품목 표시</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<EDataTable
|
||||
|
||||
@@ -193,6 +193,37 @@ export default function PurchaseOrderPage() {
|
||||
const remain = Math.max(order - received, 0);
|
||||
return <span className="font-mono">{remain ? remain.toLocaleString() : ""}</span>;
|
||||
};
|
||||
} else if (col.key === "unit_price") {
|
||||
base.render = (_v: any, row: any) => {
|
||||
const curr = row.currency || "KRW";
|
||||
if (!row.unit_price) return "";
|
||||
return (
|
||||
<span className="font-mono">
|
||||
{curr !== "KRW" && <span className="text-[10px] text-muted-foreground mr-1">{curr}</span>}
|
||||
{Number(row.unit_price).toLocaleString()}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
} else if (col.key === "amount") {
|
||||
base.render = (_v: any, row: any) => {
|
||||
const curr = row.currency || "KRW";
|
||||
const rate = parseFloat(row.exchange_rate || "1") || 1;
|
||||
const amt = Number(row.amount || 0);
|
||||
if (!row.amount) return "";
|
||||
return (
|
||||
<span className="font-mono inline-flex flex-col items-end leading-tight">
|
||||
<span>
|
||||
{curr !== "KRW" && <span className="text-[10px] text-muted-foreground mr-1 font-normal">{curr}</span>}
|
||||
{amt.toLocaleString()}
|
||||
</span>
|
||||
{curr !== "KRW" && (
|
||||
<span className="text-[10px] text-muted-foreground font-normal">
|
||||
₩ {Math.round(amt * rate).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
} else if (numCols.has(col.key)) {
|
||||
const k = col.key;
|
||||
base.render = (_v: any, row: any) => (
|
||||
@@ -243,7 +274,7 @@ export default function PurchaseOrderPage() {
|
||||
// 카테고리 로드
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
const catColumns = ["input_mode", "price_mode"];
|
||||
const catColumns = ["input_mode", "price_mode", "currency"];
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const flatten = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
@@ -388,6 +419,9 @@ export default function PurchaseOrderPage() {
|
||||
supplier_name: master?.supplier_name || "",
|
||||
order_date: master?.order_date || "",
|
||||
memo: row.memo || master?.memo || "",
|
||||
// 통화/환율 — 목록 그리드에서 단가·금액 통화 표시용
|
||||
currency: master?.currency || "KRW",
|
||||
exchange_rate: master?.exchange_rate || "1",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -420,6 +454,8 @@ export default function PurchaseOrderPage() {
|
||||
manager: user?.userId || "",
|
||||
order_date: new Date().toISOString().split("T")[0],
|
||||
status: "작성중",
|
||||
currency: "KRW",
|
||||
exchange_rate: "1",
|
||||
});
|
||||
setDetailRows([]);
|
||||
setIsEditMode(false);
|
||||
@@ -894,6 +930,7 @@ export default function PurchaseOrderPage() {
|
||||
<DialogContent
|
||||
className="max-w-[95vw] w-[1200px]"
|
||||
style={{ maxHeight: "90vh", display: "flex", flexDirection: "column" }}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditMode ? (isReadOnly ? "발주 상세" : "발주 수정") : "발주 등록"}</DialogTitle>
|
||||
@@ -977,6 +1014,50 @@ 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.currency || "KRW"}
|
||||
onValueChange={(v) => setMasterForm((p) => ({
|
||||
...p,
|
||||
currency: v,
|
||||
// KRW 선택 시 환율 자동 1, 다른 통화로 바꾸면 기존 환율 유지 (비어 있으면 빈값)
|
||||
exchange_rate: v === "KRW" ? "1" : (p.exchange_rate && p.exchange_rate !== "1" ? p.exchange_rate : ""),
|
||||
}))}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="통화 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{((categoryOptions["currency"] || []).length > 0
|
||||
? categoryOptions["currency"]
|
||||
: [
|
||||
{ code: "KRW", label: "원화 (KRW)" },
|
||||
{ code: "USD", label: "미국 달러 (USD)" },
|
||||
{ code: "JPY", label: "일본 엔 (JPY)" },
|
||||
{ code: "CNY", label: "중국 위안 (CNY)" },
|
||||
{ code: "EUR", label: "유로 (EUR)" },
|
||||
]
|
||||
).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide">
|
||||
환율 (1 {masterForm.currency || "KRW"} = ? KRW)
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={masterForm.exchange_rate || ""}
|
||||
onChange={(e) => setMasterForm((p) => ({ ...p, exchange_rate: e.target.value }))}
|
||||
placeholder={(masterForm.currency || "KRW") === "KRW" ? "1" : "예: 1370"}
|
||||
className="h-9"
|
||||
disabled={isReadOnly || (masterForm.currency || "KRW") === "KRW"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1103,18 +1184,44 @@ export default function PurchaseOrderPage() {
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>;
|
||||
case "remain_qty":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>;
|
||||
case "unit_price":
|
||||
case "unit_price": {
|
||||
const curr = masterForm.currency || "KRW";
|
||||
return (
|
||||
<TableCell key={col.key} className="min-w-[120px]">
|
||||
<TableCell key={col.key} className="min-w-[140px]">
|
||||
{isReadOnly ? (
|
||||
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
|
||||
<span className="text-xs text-right font-mono block">
|
||||
{curr !== "KRW" && <span className="text-[10px] text-muted-foreground mr-1">{curr}</span>}
|
||||
{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}
|
||||
</span>
|
||||
) : (
|
||||
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono w-full" />
|
||||
<div className="flex items-center gap-1">
|
||||
{curr !== "KRW" && <span className="text-[10px] text-muted-foreground shrink-0">{curr}</span>}
|
||||
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono w-full" />
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case "amount":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
|
||||
}
|
||||
case "amount": {
|
||||
const curr = masterForm.currency || "KRW";
|
||||
const rate = parseFloat(masterForm.exchange_rate || "1") || 1;
|
||||
const amtNum = Number(row.amount || 0);
|
||||
return (
|
||||
<TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold min-w-[140px]">
|
||||
<div className="flex flex-col items-end leading-tight">
|
||||
<span>
|
||||
{curr !== "KRW" && <span className="text-[10px] text-muted-foreground mr-1 font-normal">{curr}</span>}
|
||||
{row.amount ? amtNum.toLocaleString() : ""}
|
||||
</span>
|
||||
{curr !== "KRW" && row.amount && (
|
||||
<span className="text-[10px] text-muted-foreground font-normal">
|
||||
₩ {Math.round(amtNum * rate).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
case "due_date":
|
||||
return (
|
||||
<TableCell key={col.key} className="min-w-[160px]">
|
||||
@@ -1146,6 +1253,28 @@ export default function PurchaseOrderPage() {
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
{/* 합계 — 외화 합계 + KRW 환산 (KRW 외 통화일 때만 환산 라인 표시) */}
|
||||
{detailRows.length > 0 && (() => {
|
||||
const curr = masterForm.currency || "KRW";
|
||||
const rate = parseFloat(masterForm.exchange_rate || "1") || 1;
|
||||
const totalAmt = detailRows.reduce((s: number, r: any) => s + (Number(r.amount) || 0), 0);
|
||||
return (
|
||||
<div className="flex justify-end items-center gap-6 px-4 py-2 mt-2 border-t bg-muted/30 rounded">
|
||||
<span className="text-[12px] font-semibold text-muted-foreground">합계</span>
|
||||
<div className="flex flex-col items-end leading-tight">
|
||||
<span className="text-[14px] font-bold font-mono">
|
||||
{curr !== "KRW" && <span className="text-[11px] text-muted-foreground mr-1 font-normal">{curr}</span>}
|
||||
{totalAmt.toLocaleString()}
|
||||
</span>
|
||||
{curr !== "KRW" && (
|
||||
<span className="text-[11px] text-muted-foreground font-mono">
|
||||
₩ {Math.round(totalAmt * rate).toLocaleString()} (환율 {rate.toLocaleString()})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
|
||||
@@ -189,7 +189,7 @@ export default function SupplierManagementPage() {
|
||||
};
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
for (const col of ["division", "status"]) {
|
||||
for (const col of ["division", "status", "region_type"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${SUPPLIER_TABLE}/${col}/values`);
|
||||
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
|
||||
@@ -625,10 +625,14 @@ export default function SupplierManagementPage() {
|
||||
};
|
||||
|
||||
// 폼 필드 변경 시 자동 포맷팅 + 실시간 검증
|
||||
// 해외(region_type=OVERSEAS) 업체의 전화/팩스/사무실 번호는 자동 하이픈/길이 검증을 건너뛰고 raw 그대로 저장.
|
||||
const PHONE_LIKE_FIELDS = new Set(["contact_phone", "phone", "cell_phone", "fax", "fax_number", "office_number"]);
|
||||
const handleFormChange = (field: string, value: string) => {
|
||||
const formatted = formatField(field, value);
|
||||
const isOverseas = supplierForm.region_type === "OVERSEAS";
|
||||
const skipFormat = isOverseas && PHONE_LIKE_FIELDS.has(field);
|
||||
const formatted = skipFormat ? value : formatField(field, value);
|
||||
setSupplierForm((prev) => ({ ...prev, [field]: formatted }));
|
||||
const error = validateField(field, formatted);
|
||||
const error = skipFormat ? null : validateField(field, formatted);
|
||||
setFormErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
if (error) next[field] = error; else delete next[field];
|
||||
@@ -714,7 +718,12 @@ export default function SupplierManagementPage() {
|
||||
const handleSupplierSave = async () => {
|
||||
if (!supplierForm.supplier_name) { toast.error("공급업체명은 필수입니다."); return; }
|
||||
if (!supplierForm.status) { toast.error("상태는 필수입니다."); return; }
|
||||
const errors = validateForm(supplierForm, ["contact_phone", "email", "business_number"]);
|
||||
// 해외 업체는 전화번호/사업자번호 형식 검증 제외 (국가별 형식 다양)
|
||||
const isOverseas = supplierForm.region_type === "OVERSEAS";
|
||||
const validateFields = isOverseas
|
||||
? ["email"]
|
||||
: ["contact_phone", "email", "business_number"];
|
||||
const errors = validateForm(supplierForm, validateFields);
|
||||
setFormErrors(errors);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
toast.error("입력 형식을 확인해주세요.");
|
||||
@@ -1842,6 +1851,23 @@ export default function SupplierManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">구분 (국내/해외)</Label>
|
||||
<Select
|
||||
value={supplierForm.region_type || "DOMESTIC"}
|
||||
onValueChange={(v) => setSupplierForm((p) => ({ ...p, region_type: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="국내/해외" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{((categoryOptions["region_type"] || []).length > 0
|
||||
? categoryOptions["region_type"]
|
||||
: [{ code: "DOMESTIC", label: "국내" }, { code: "OVERSEAS", label: "해외" }]
|
||||
).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">상태 <span className="text-destructive">*</span></Label>
|
||||
<Select
|
||||
@@ -2086,10 +2112,13 @@ export default function SupplierManagementPage() {
|
||||
<Input
|
||||
value={modalContactForm.contact_phone || ""}
|
||||
onChange={(e) => {
|
||||
const formatted = formatField("phone", e.target.value);
|
||||
setModalContactForm((p) => ({ ...p, contact_phone: formatted }));
|
||||
// 해외 공급업체는 자동 포맷 건너뛰고 raw 유지 (12자리 초과 + 국가번호 허용)
|
||||
const v = supplierForm.region_type === "OVERSEAS"
|
||||
? e.target.value
|
||||
: formatField("phone", e.target.value);
|
||||
setModalContactForm((p) => ({ ...p, contact_phone: v }));
|
||||
}}
|
||||
placeholder="010-0000-0000"
|
||||
placeholder={supplierForm.region_type === "OVERSEAS" ? "+1 555 1234 5678" : "010-0000-0000"}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -221,13 +221,34 @@ export default function CustomerManagementPage() {
|
||||
} catch { /* skip */ }
|
||||
};
|
||||
load();
|
||||
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 0, autoFilter: true })
|
||||
.then((res) => {
|
||||
const users = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setEmployeeOptions(users.map((u: any) => ({
|
||||
// 담당 영업사원 옵션: '사업부문' 본부 + 하위 부서에 소속된 사용자만 노출.
|
||||
// 부서명/코드 직접 하드코딩 대신 dept_info를 조회해 트리에서 동적으로 결정.
|
||||
(async () => {
|
||||
try {
|
||||
const [userRes, deptRes] = await Promise.all([
|
||||
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 0, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/dept_info/data`, { page: 1, size: 0, autoFilter: true }),
|
||||
]);
|
||||
const users: any[] = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||
const depts: any[] = deptRes.data?.data?.data || deptRes.data?.data?.rows || [];
|
||||
// '사업부문' 본부 찾기 → 자체 + 자식 부서 코드 집합
|
||||
const salesRoot = depts.find((d) => d.dept_name === "사업부문");
|
||||
const salesDeptCodes = new Set<string>();
|
||||
if (salesRoot?.dept_code) {
|
||||
salesDeptCodes.add(salesRoot.dept_code);
|
||||
for (const d of depts) {
|
||||
if (d.parent_dept_code === salesRoot.dept_code) salesDeptCodes.add(d.dept_code);
|
||||
}
|
||||
}
|
||||
// 매칭되는 사용자만 (salesRoot 없으면 안전망으로 전체 표시 — 데이터 미정비 환경 대비)
|
||||
const filtered = salesDeptCodes.size > 0
|
||||
? users.filter((u) => u.dept_code && salesDeptCodes.has(u.dept_code))
|
||||
: users;
|
||||
setEmployeeOptions(filtered.map((u: any) => ({
|
||||
user_id: u.user_id, user_name: u.user_name || u.user_id, position_name: u.position_name,
|
||||
})));
|
||||
}).catch(() => {});
|
||||
} catch { /* skip */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// 거래처 목록 조회
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
|
||||
ClipboardList, Pencil, Search, X, Truck, Package,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
|
||||
Settings2, RotateCcw, Filter, Check, ArrowUp, ArrowDown,
|
||||
Settings2, RotateCcw, Filter, Check, ArrowUp, ArrowDown, Ban,
|
||||
} from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -66,16 +66,18 @@ const FLAT_COLUMNS = [
|
||||
{ key: "memo", label: "메모", source: "master", width: 120, align: "left" as const },
|
||||
];
|
||||
|
||||
// 수주 상태 배지 매핑 (TASK:ERP-node-046)
|
||||
// 수주 상태 배지 매핑 (자동 전이: 등록 → 출하계획단계 → 완료. 취소는 수동)
|
||||
const ORDER_STATUS_LABEL: Record<string, string> = {
|
||||
WAITING: "대기",
|
||||
CONFIRMED: "확정",
|
||||
WAITING: "등록",
|
||||
CONFIRMED: "등록",
|
||||
PLANNING: "출하계획단계",
|
||||
CANCELED: "취소",
|
||||
COMPLETED: "완료",
|
||||
};
|
||||
const ORDER_STATUS_CLASS: Record<string, string> = {
|
||||
WAITING: "bg-gray-100 text-gray-800 border-gray-300",
|
||||
CONFIRMED: "bg-blue-100 text-blue-800 border-blue-300",
|
||||
CONFIRMED: "bg-gray-100 text-gray-800 border-gray-300",
|
||||
PLANNING: "bg-blue-100 text-blue-800 border-blue-300",
|
||||
CANCELED: "bg-red-100 text-red-800 border-red-300",
|
||||
COMPLETED: "bg-green-100 text-green-800 border-green-300",
|
||||
};
|
||||
@@ -310,13 +312,26 @@ export default function SalesOrderPage() {
|
||||
});
|
||||
setPartnerManagerMap(pmMap);
|
||||
} catch { /* skip */ }
|
||||
// 사용자 목록
|
||||
// 사용자 목록 — '사업부문' 본부 + 하위 부서 소속만 노출 (수주 담당자는 영업조직 한정)
|
||||
try {
|
||||
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||
optMap["manager_id"] = users.map((u: any) => ({
|
||||
const [userRes, deptRes] = await Promise.all([
|
||||
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 0, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/dept_info/data`, { page: 1, size: 0, autoFilter: true }),
|
||||
]);
|
||||
const users: any[] = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||
const depts: any[] = deptRes.data?.data?.data || deptRes.data?.data?.rows || [];
|
||||
const salesRoot = depts.find((d) => d.dept_name === "사업부문");
|
||||
const salesDeptCodes = new Set<string>();
|
||||
if (salesRoot?.dept_code) {
|
||||
salesDeptCodes.add(salesRoot.dept_code);
|
||||
for (const d of depts) {
|
||||
if (d.parent_dept_code === salesRoot.dept_code) salesDeptCodes.add(d.dept_code);
|
||||
}
|
||||
}
|
||||
const filtered = salesDeptCodes.size > 0
|
||||
? users.filter((u) => u.dept_code && salesDeptCodes.has(u.dept_code))
|
||||
: users;
|
||||
optMap["manager_id"] = filtered.map((u: any) => ({
|
||||
code: u.user_id || u.id,
|
||||
label: `${u.user_name || u.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`,
|
||||
}));
|
||||
@@ -1304,13 +1319,17 @@ export default function SalesOrderPage() {
|
||||
</Button>
|
||||
{(() => {
|
||||
const selectedOrders = orders.filter((o) => checkedIds.includes(o.id));
|
||||
const unconfirmed = selectedOrders.filter((o) => (o.status || "WAITING") !== "CONFIRMED");
|
||||
const hasUnconfirmed = unconfirmed.length > 0;
|
||||
const disabled = checkedIds.length === 0 || hasUnconfirmed;
|
||||
// 완료/취소 상태의 수주는 추가 출하계획 작성 차단 (등록/출하계획단계/대기/확정은 허용)
|
||||
const blocked = selectedOrders.filter((o) => {
|
||||
const st = String(o.status || "").toUpperCase();
|
||||
return st === "COMPLETED" || st === "CANCELED";
|
||||
});
|
||||
const hasBlocked = blocked.length > 0;
|
||||
const disabled = checkedIds.length === 0 || hasBlocked;
|
||||
const tooltipMsg = checkedIds.length === 0
|
||||
? "수주 품목을 먼저 선택해주세요"
|
||||
: hasUnconfirmed
|
||||
? `미확정 수주 ${unconfirmed.length}건이 포함되어 있습니다. 수주를 먼저 '확정' 상태로 변경해주세요.`
|
||||
: hasBlocked
|
||||
? `완료 또는 취소된 수주 ${blocked.length}건이 포함되어 있습니다. 해당 수주는 제외해주세요.`
|
||||
: "";
|
||||
const btn = (
|
||||
<Button variant="outline" size="sm" disabled={disabled} onClick={() => setShippingPlanOpen(true)}>
|
||||
@@ -1329,6 +1348,79 @@ export default function SalesOrderPage() {
|
||||
</TooltipProvider>
|
||||
);
|
||||
})()}
|
||||
{(() => {
|
||||
// 수주 취소: 선택된 수주를 일괄 'CANCELED' 처리. 이미 완료/취소된 건 제외.
|
||||
const selectedOrders = orders.filter((o) => checkedIds.includes(o.id));
|
||||
const cancelable = selectedOrders.filter((o) => {
|
||||
const st = String(o.status || "").toUpperCase();
|
||||
return st !== "COMPLETED" && st !== "CANCELED";
|
||||
});
|
||||
const disabled = checkedIds.length === 0 || cancelable.length === 0;
|
||||
const tooltipMsg = checkedIds.length === 0
|
||||
? "취소할 수주를 선택해주세요"
|
||||
: cancelable.length === 0
|
||||
? "선택한 수주는 모두 완료 또는 이미 취소 상태입니다"
|
||||
: "";
|
||||
const onCancel = async () => {
|
||||
const ok = await confirm(
|
||||
`선택한 수주 ${cancelable.length}건을 취소 처리하시겠습니까? (완료 상태는 제외)`,
|
||||
{ variant: "destructive", confirmText: "취소 처리" },
|
||||
);
|
||||
if (!ok) return;
|
||||
try {
|
||||
// 각 수주마다 master + detail status='CANCELED' 업데이트
|
||||
// 같은 order_no가 cancelable에 중복 등장 가능 → master / detail 모두 order_no 단위로 1회만 처리
|
||||
const uniqueMasters = new Map<string, any>();
|
||||
for (const o of cancelable) {
|
||||
if (o.order_no && !uniqueMasters.has(o.order_no)) {
|
||||
uniqueMasters.set(o.order_no, o._master || o);
|
||||
}
|
||||
}
|
||||
for (const [orderNo, m] of uniqueMasters) {
|
||||
if (m?.id) {
|
||||
await apiClient.put(`/table-management/tables/${MASTER_TABLE}/edit`, {
|
||||
originalData: { id: m.id },
|
||||
updatedData: { status: "CANCELED" },
|
||||
});
|
||||
}
|
||||
// 같은 order_no의 모든 detail도 CANCELED로 (완료된 detail은 보호)
|
||||
const detailRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
||||
});
|
||||
const details: any[] = detailRes.data?.data?.data || detailRes.data?.data?.rows || [];
|
||||
for (const d of details) {
|
||||
if (String(d.status || "").toUpperCase() === "COMPLETED") continue;
|
||||
await apiClient.put(`/table-management/tables/${DETAIL_TABLE}/edit`, {
|
||||
originalData: { id: d.id },
|
||||
updatedData: { status: "CANCELED" },
|
||||
});
|
||||
}
|
||||
}
|
||||
toast.success(`${cancelable.length}건이 취소 처리되었습니다.`);
|
||||
setCheckedIds([]);
|
||||
fetchOrders();
|
||||
} catch (e: any) {
|
||||
toast.error(`취소 처리 실패: ${e?.message || "알 수 없는 오류"}`);
|
||||
}
|
||||
};
|
||||
const btn = (
|
||||
<Button variant="outline" size="sm" disabled={disabled} onClick={onCancel}>
|
||||
<Ban className="w-4 h-4" /> 취소{cancelable.length > 0 && ` (${cancelable.length})`}
|
||||
</Button>
|
||||
);
|
||||
if (!tooltipMsg) return btn;
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={0}>{btn}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{tooltipMsg}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})()}
|
||||
<div className="h-5 w-px bg-border mx-0.5" />
|
||||
<Button variant="outline" size="sm" onClick={() => ts.setOpen(true)}>
|
||||
<Settings2 className="w-4 h-4" /> 설정
|
||||
@@ -1711,30 +1803,8 @@ export default function SalesOrderPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">상태</Label>
|
||||
<SmartSelect
|
||||
value={masterForm.status || "WAITING"}
|
||||
onValueChange={(v) => setMasterForm((p) => ({ ...p, status: v }))}
|
||||
options={(() => {
|
||||
const opts = (categoryOptions["status"] || []).length > 0
|
||||
? categoryOptions["status"]
|
||||
: [
|
||||
{ code: "WAITING", label: "대기" },
|
||||
{ code: "CONFIRMED", label: "확정" },
|
||||
{ code: "CANCELED", label: "취소" },
|
||||
{ code: "COMPLETED", label: "완료" },
|
||||
];
|
||||
// 현재 값이 옵션에 없으면 임시 추가 (수정 시 카테고리에 없는 임의 코드 대비)
|
||||
const cur = masterForm.status;
|
||||
if (cur && !opts.some((o: any) => o.code === cur)) {
|
||||
return [...opts, { code: cur, label: cur }];
|
||||
}
|
||||
return opts;
|
||||
})()}
|
||||
placeholder="상태 선택"
|
||||
/>
|
||||
</div>
|
||||
{/* 상태는 자동 전이(등록 → 출하계획단계 → 완료) 정책이므로 입력 UI 노출하지 않음.
|
||||
취소는 수주 목록 툴바의 '취소' 버튼으로 수행. */}
|
||||
<div className="flex items-end pb-1">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox checked={allowPriceEdit} onCheckedChange={(v) => setAllowPriceEdit(!!v)} />
|
||||
@@ -1773,14 +1843,12 @@ 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.manager_id || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, manager_id: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="담당자 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["manager_id"] || []).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SmartSelect
|
||||
value={masterForm.manager_id || ""}
|
||||
onValueChange={(v) => setMasterForm((p) => ({ ...p, manager_id: v }))}
|
||||
placeholder="담당자 선택"
|
||||
options={categoryOptions["manager_id"] || []}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">납품처</Label>
|
||||
|
||||
@@ -1044,7 +1044,7 @@ export default function ShippingOrderPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-muted-foreground shrink-0 text-[11px] font-semibold">납기 시작</Label>
|
||||
<Label className="text-muted-foreground shrink-0 text-[11px] font-semibold">출하계획일</Label>
|
||||
<div className="w-[140px]">
|
||||
<FormDatePicker value={sourceDateFrom} onChange={setSourceDateFrom} placeholder="시작일" />
|
||||
</div>
|
||||
@@ -1114,7 +1114,7 @@ export default function ShippingOrderPage() {
|
||||
거래처
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground w-[95px] text-center text-[11px] font-bold tracking-wide uppercase">
|
||||
납기일
|
||||
출하계획일
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground w-[80px] text-right text-[11px] font-bold tracking-wide uppercase">
|
||||
수량
|
||||
@@ -1475,7 +1475,7 @@ export default function ShippingOrderPage() {
|
||||
품명
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground w-[80px] text-center text-[11px] font-bold tracking-wide uppercase">
|
||||
납기일
|
||||
출하계획일
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground w-[90px] text-center text-[11px] font-bold tracking-wide uppercase">
|
||||
출하수량
|
||||
|
||||
Reference in New Issue
Block a user