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:
kjs
2026-05-28 11:01:35 +09:00
parent 07e1932254
commit b05f86c563
9 changed files with 398 additions and 138 deletions

View File

@@ -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 패턴)

View File

@@ -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

View File

@@ -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("출하계획 일괄 저장 완료", {

View File

@@ -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

View File

@@ -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>
{/* 비고 */}

View File

@@ -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>

View File

@@ -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 */ }
})();
}, []);
// 거래처 목록 조회

View File

@@ -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>

View File

@@ -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">