Merge pull request 'jskim-node' (#26) from jskim-node into main

Reviewed-on: https://g.wace.me/jskim/vexplor_dev/pulls/26
This commit is contained in:
jskim
2026-04-13 04:40:22 +00:00
44 changed files with 4163 additions and 7856 deletions
@@ -38,7 +38,6 @@ import {
Save,
ChevronRight,
ChevronLeft,
ChevronDown,
ChevronsLeft,
ChevronsRight,
Inbox,
@@ -116,41 +115,8 @@ const GRID_COLUMNS = [
{ key: "remark", label: "비고" },
];
// 마스터 헤더 레이아웃 (출고번호 뒤)
const MASTER_BODY_LAYOUT = [
{ key: "outbound_type", label: "출고유형", colSpan: 1 },
{ key: "outbound_date", label: "출고일", colSpan: 1 },
{ key: "reference_number", label: "참조번호", colSpan: 1 },
{ key: "customer_name", label: "거래처", colSpan: 1 },
{ key: "warehouse_name", label: "창고", colSpan: 1 },
{ key: "outbound_status", label: "출고상태", colSpan: 1 },
{ key: "memo", label: "비고", colSpan: 1 },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "source_type", label: "출처" },
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "specification", label: "규격" },
{ key: "outbound_qty", label: "출고수량" },
{ key: "unit_price", label: "단가" },
{ key: "total_amount", label: "금액" },
];
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["outbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_type",
item_number: "item_code",
item_name: "item_name",
spec: "specification",
outbound_qty: "outbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16
const TOTAL_COLS = 16;
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -258,30 +224,6 @@ interface SelectedSourceItem {
export default function OutboundPage() {
const ts = useTableSettings("c16-outbound", "outbound_mng", GRID_COLUMNS);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<OutboundItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -290,9 +232,7 @@ export default function OutboundPage() {
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 마스터-디테일 그룹 테이블 state
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
// 헤더 필터 & 정렬
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
@@ -403,121 +343,59 @@ export default function OutboundPage() {
})();
}, []);
// --- 마스터-디테일 그룹핑, 필터, 정렬 ---
// 플랫 행 생성 (출고유형 코드→라벨 변환, source_type→한글 등)
const flatRows = useMemo(() => {
return data.map((row) => ({
...row,
_raw_outbound_type: row.outbound_type,
outbound_type: resolveCat("outbound_type", row.outbound_type) || row.outbound_type || "",
source_type: row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "",
item_number: row.item_code || (row as any).item_number || "",
spec: row.specification || (row as any).spec || "",
}));
}, [data, resolveCat]);
// outbound_number 기준 그룹핑 + 필터 + 정렬
const filteredGroups = useMemo(() => {
// 1차: outbound_number 기준 그룹핑
const allGroups: Record<string, { master: OutboundItem; details: OutboundItem[] }> = {};
for (const row of data) {
const key = row.outbound_number || "_no_number";
if (!allGroups[key]) {
allGroups[key] = { master: row, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
const raw = (group.master as any)?.[colKey] ?? "";
return values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([outNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
let cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
if (colKey === "source_type") cellVal = SOURCE_TYPE_LABEL[cellVal] || cellVal;
return values.has(cellVal);
})
);
return [outNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
entries.sort(([, a], [, b]) => {
const av = (a.master as any)?.[key] ?? "";
const bv = (b.master as any)?.[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
} else {
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
let av: any = (a as any)[key] ?? "";
let bv: any = (b as any)[key] ?? "";
if (key === "source_type") { av = SOURCE_TYPE_LABEL[av] || av; bv = SOURCE_TYPE_LABEL[bv] || bv; }
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [data, headerFilters, sortState]);
// 마스터 컬럼별 고유값 (헤더 필터용)
const masterUniqueValues = useMemo(() => {
// 컬럼별 고유값 (헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
const seenMasters = new Map<string, OutboundItem>();
data.forEach((row) => {
if (row.outbound_number && !seenMasters.has(row.outbound_number)) {
seenMasters.set(row.outbound_number, row);
}
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "outbound_number", label: "출고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) {
for (const col of GRID_COLUMNS) {
const values = new Set<string>();
masters.forEach((m) => {
const val = (m as any)?.[col.key];
flatRows.forEach((row) => {
const val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
}, [flatRows]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
const values = new Set<string>();
data.forEach((row) => {
let val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "source_type") val = SOURCE_TYPE_LABEL[val] || val;
values.add(String(val));
}
// 필터 + 정렬 적용된 플랫 데이터
const filteredRows = useMemo(() => {
let rows = [...flatRows];
// 1차: 헤더 필터 적용
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
rows = rows.filter((row) => {
const cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
return values.has(cellVal);
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
// 2차: 정렬
if (sortState) {
const { key, direction } = sortState;
rows.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}
return rows;
}, [flatRows, headerFilters, sortState]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
@@ -938,7 +816,7 @@ export default function OutboundPage() {
dataCount={data.length}
/>
{/* 출고 목록 테이블 */}
{/* 출고 목록 테이블 (플랫 리스트) */}
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
{/* 패널 헤더 */}
<div className="flex items-center justify-between border-b bg-muted/30 px-4 py-2.5 shrink-0">
@@ -946,7 +824,7 @@ export default function OutboundPage() {
<PackageOpen className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<span className="rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
{data.length}
{filteredRows.length}
</span>
</div>
<div className="flex gap-2">
@@ -971,78 +849,68 @@ export default function OutboundPage() {
</div>
<div className="h-full overflow-auto">
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<Table noWrapper style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 출고번호 */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("outbound_number")}>
<span className="truncate"></span>
{sortState?.key === "outbound_number" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["outbound_number"] || []).length > 0 && (
<HeaderFilterPopover
colKey="outbound_number" colLabel="출고번호"
uniqueValues={masterUniqueValues["outbound_number"] || []}
filterValues={headerFilters["outbound_number"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
{GRID_COLUMNS.map((col) => {
const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key);
return (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none", isRight && "text-right")}>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(columnUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={columnUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
@@ -1052,7 +920,7 @@ export default function OutboundPage() {
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredGroups).length === 0 ? (
) : filteredRows.length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
@@ -1062,214 +930,59 @@ export default function OutboundPage() {
</TableCell>
</TableRow>
) : (
Object.entries(filteredGroups).map(([outboundNo, group]) => {
const isExpanded = expandedOrders.has(outboundNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
filteredRows.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={outboundNo}>
{/* 마스터 행 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(outboundNo)) {
setClosingOrders((prev) => new Set(prev).add(outboundNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(outboundNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(outboundNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(outboundNo));
}
}}
onDoubleClick={() => openEditModal(group.details[0])}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 출고번호 */}
<TableCell className="font-mono whitespace-nowrap">
{outboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "outbound_type": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{resolveCat("outbound_type", master.outbound_type) || "-"}
</Badge>
</TableCell>
);
case "outbound_date": return (
<TableCell key={col.key} className="whitespace-nowrap text-[13px]">
{master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "customer_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.customer_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "outbound_status": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(master.outbound_status))}>
{master.outbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground">
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(outboundNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell />
{visibleDetailCols.map((col) => {
const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => {
let v = (d as any)[col.key];
if (col.key === "source_type") v = SOURCE_TYPE_LABEL[v] || v;
return v;
}).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
</TableRow>
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(outboundNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={`${row.id}-${detailIdx}`}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_type": return <TableCell key={col.key} className="text-[13px]">{row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}</TableCell>;
case "item_code": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_code || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "specification": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.specification || ""}</TableCell>;
case "outbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
})}
</React.Fragment>
}}
onDoubleClick={() => openEditModal(row as any)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="font-mono whitespace-nowrap text-[13px]">{row.outbound_number || ""}</TableCell>
<TableCell className="text-[13px]">
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(row.outbound_type))}>
{row.outbound_type || "-"}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-[13px]">
{row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""}
</TableCell>
<TableCell className="text-[13px] truncate max-w-[120px]"><span className="block truncate">{row.reference_number || ""}</span></TableCell>
<TableCell className="text-[13px]">{row.source_type || ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[110px]"><span className="block truncate">{row.customer_name || ""}</span></TableCell>
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
<TableCell className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[100px]"><span className="block truncate">{row.warehouse_name || row.warehouse_code || ""}</span></TableCell>
<TableCell className="text-[13px]">
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(row.outbound_status))}>
{row.outbound_status || "-"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.remark || row.memo || ""}</span></TableCell>
</TableRow>
);
})
)}
@@ -43,7 +43,6 @@ import {
X,
Save,
ChevronRight,
ChevronDown,
ChevronLeft,
ChevronsLeft,
ChevronsRight,
@@ -64,6 +63,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea
import {
getReceivingList,
createReceiving,
updateReceiving,
deleteReceiving,
generateReceivingNumber,
getReceivingWarehouses,
@@ -95,41 +95,8 @@ const GRID_COLUMNS = [
{ key: "remark", label: "비고" },
];
// 마스터 헤더 레이아웃 (입고번호 뒤, 디테일 7컬럼 위에 colSpan으로 맵핑)
const MASTER_BODY_LAYOUT = [
{ key: "inbound_type", label: "입고유형", colSpan: 1 },
{ key: "inbound_date", label: "입고일", colSpan: 1 },
{ key: "reference_number", label: "참조번호", colSpan: 1 },
{ key: "supplier_name", label: "공급처", colSpan: 1 },
{ key: "warehouse_name", label: "창고", colSpan: 1 },
{ key: "inbound_status", label: "입고상태", colSpan: 1 },
{ key: "memo", label: "비고", colSpan: 1 },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "source_table", label: "출처" },
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "spec", label: "규격" },
{ key: "inbound_qty", label: "입고수량" },
{ key: "unit_price", label: "단가" },
{ key: "total_amount", label: "금액" },
];
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_table",
item_number: "item_number",
item_name: "item_name",
spec: "spec",
inbound_qty: "inbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16
const TOTAL_COLS = 16;
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -287,30 +254,6 @@ interface SelectedSourceItem {
export default function ReceivingPage() {
const ts = useTableSettings("c16-receiving", "inbound_mng", GRID_COLUMNS);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<InboundItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -319,9 +262,7 @@ export default function ReceivingPage() {
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 마스터-디테일 그룹 테이블
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
// 헤더 필터 & 정렬
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
@@ -338,6 +279,10 @@ export default function ReceivingPage() {
const [selectedItems, setSelectedItems] = useState<SelectedSourceItem[]>([]);
const [saving, setSaving] = useState(false);
// 수정 모드
const [editMode, setEditMode] = useState(false);
const [editItemIds, setEditItemIds] = useState<string[]>([]);
// 소스 데이터
const [sourceKeyword, setSourceKeyword] = useState("");
const [sourceLoading, setSourceLoading] = useState(false);
@@ -377,7 +322,7 @@ export default function ReceivingPage() {
Promise.all(
["material", "unit"].map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_10`);
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_16`);
const items = flatten(res.data?.data || []);
map[col] = {};
for (const item of items) map[col][item.code] = item.label;
@@ -434,124 +379,56 @@ export default function ReceivingPage() {
})();
}, []);
// 필터 + 정렬 적용된 데이터 -> 그룹핑
const filteredGroups = useMemo(() => {
// 1차: inbound_number 기준 그룹핑
const allGroups: Record<string, { master: InboundItem; details: InboundItem[] }> = {};
for (const row of data) {
const key = row.inbound_number || "_no_inbound";
if (!allGroups[key]) {
allGroups[key] = { master: row, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
let raw = (group.master as any)?.[colKey] ?? "";
// 입고유형은 코드→라벨 변환된 값으로 비교
if (colKey === "inbound_type") raw = resolveInboundType(String(raw));
return values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([inboundNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
let cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
if (colKey === "source_table") cellVal = SOURCE_TABLE_LABEL[cellVal] || cellVal;
return values.has(cellVal);
})
);
return [inboundNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
entries.sort(([, a], [, b]) => {
let av: any = (a.master as any)?.[key] ?? "";
let bv: any = (b.master as any)?.[key] ?? "";
if (key === "inbound_type") { av = resolveInboundType(String(av)); bv = resolveInboundType(String(bv)); }
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
} else {
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [data, headerFilters, sortState]);
// 마스터 컬럼별 고유값 (마스터 헤더 필터용)
const masterUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
const seenMasters = new Map<string, InboundItem>();
data.forEach((row) => {
if (row.inbound_number && !seenMasters.has(row.inbound_number)) {
seenMasters.set(row.inbound_number, row);
}
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "inbound_number", label: "입고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) {
const values = new Set<string>();
masters.forEach((m) => {
let val = (m as any)?.[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "inbound_type") val = resolveInboundType(String(val));
values.add(String(val));
}
});
result[col.key] = Array.from(values).sort();
}
return result;
// 플랫 행 생성 (입고유형 코드→라벨 변환, source_table→한글 등)
const flatRows = useMemo(() => {
return data.map((row) => ({
...row,
inbound_type: resolveInboundType(row.inbound_type),
source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "",
}));
}, [data]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
// 컬럼별 고유값 (헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
for (const col of GRID_COLUMNS) {
const values = new Set<string>();
data.forEach((row) => {
let val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "source_table") val = SOURCE_TABLE_LABEL[val] || val;
values.add(String(val));
}
flatRows.forEach((row) => {
const val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
}, [flatRows]);
// 필터 + 정렬 적용된 플랫 데이터
const filteredRows = useMemo(() => {
let rows = [...flatRows];
// 1차: 헤더 필터 적용
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
rows = rows.filter((row) => {
const cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
return values.has(cellVal);
});
}
// 2차: 정렬
if (sortState) {
const { key, direction } = sortState;
rows.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}
return rows;
}, [flatRows, headerFilters, sortState]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
@@ -633,6 +510,8 @@ export default function ReceivingPage() {
const openRegisterModal = async () => {
const defaultType = "구매입고";
setEditMode(false);
setEditItemIds([]);
setModalInboundType(defaultType);
setModalInboundDate(new Date().toISOString().split("T")[0]);
setModalWarehouse("");
@@ -661,6 +540,51 @@ export default function ReceivingPage() {
}
};
// 수정 모달 열기
const openEditModal = (row: InboundItem) => {
const inNo = row.inbound_number;
const grouped = data.filter((d) => d.inbound_number === inNo);
const first = grouped[0] || row;
setEditMode(true);
setEditItemIds(grouped.map((g) => g.id));
setModalInboundNo(inNo);
setModalInboundType(first.inbound_type || "구매입고");
setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : "");
setModalWarehouse(first.warehouse_code || "");
setModalLocation(first.location_code || "");
setModalInspector((first as any).inspector || "");
setModalManager((first as any).manager || "");
setModalMemo(first.memo || "");
setSelectedItems(
grouped.map((g) => ({
key: g.id,
inbound_type: g.inbound_type || "",
reference_number: g.reference_number || "",
supplier_code: (g as any).supplier_code || "",
supplier_name: g.supplier_name || "",
item_number: g.item_number || "",
item_name: g.item_name || "",
spec: g.spec || "",
material: (g as any).material || "",
unit: (g as any).unit || "",
inbound_qty: Number(g.inbound_qty) || 0,
unit_price: Number(g.unit_price) || 0,
total_amount: Number(g.total_amount) || 0,
source_table: (g as any).source_table || "",
source_id: (g as any).source_id || "",
}))
);
setSourceKeyword("");
setPurchaseOrders([]);
setShipments([]);
setItems([]);
setSourcePage(1);
setSourceTotalCount(0);
setIsModalOpen(true);
loadSourceData(first.inbound_type || "구매입고", undefined, 1);
};
// 검색 버튼 클릭 시
const searchSourceData = useCallback(async () => {
setSourcePage(1);
@@ -811,41 +735,97 @@ export default function ReceivingPage() {
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: selectedItems.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
});
if (editMode) {
// 기존 item 수정 + 삭제된 item 삭제 + 새 item 추가
const currentKeys = new Set(selectedItems.map((i) => i.key));
const toDelete = editItemIds.filter((id) => !currentKeys.has(id));
const toUpdate = selectedItems.filter((i) => editItemIds.includes(i.key));
const toCreate = selectedItems.filter((i) => !editItemIds.includes(i.key));
if (res.success) {
alert(res.message || "입고 등록 완료");
await Promise.all([
...toDelete.map((id) => deleteReceiving(id)),
...toUpdate.map((item) =>
updateReceiving(item.key, {
inbound_date: modalInboundDate,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
warehouse_code: modalWarehouse || undefined,
location_code: modalLocation || undefined,
memo: modalMemo || undefined,
} as any)
),
...(toCreate.length > 0
? [createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: toCreate.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
})]
: []),
]);
toast.success("입고 정보를 수정했어요");
setIsModalOpen(false);
fetchList();
} else {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: selectedItems.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
});
if (res.success) {
toast.success(res.message || "입고 등록 완료");
setIsModalOpen(false);
fetchList();
}
}
} catch {
alert("입고 등록 중 오류가 발생했습니다.");
} catch (err: any) {
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "입고 등록 중 오류가 발생했습니다.");
toast.error(msg);
} finally {
setSaving(false);
}
@@ -897,13 +877,13 @@ export default function ReceivingPage() {
}
/>
{/* 입고 목록 테이블 (마스터-디테일 그룹) */}
{/* 입고 목록 테이블 (플랫 리스트) */}
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
<div className="flex items-center justify-between border-b bg-muted/50 px-4 py-2.5">
<div className="flex items-center gap-2">
<h3 className="text-[13px] font-bold"> </h3>
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
{Object.keys(filteredGroups).length}
{filteredRows.length}
</span>
</div>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
@@ -912,78 +892,68 @@ export default function ReceivingPage() {
</div>
<div className="h-[calc(100%-44px)] overflow-auto">
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<Table noWrapper style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 입고번호 (별도 컬럼) */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("inbound_number")}>
<span className="truncate"></span>
{sortState?.key === "inbound_number" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["inbound_number"] || []).length > 0 && (
<HeaderFilterPopover
colKey="inbound_number" colLabel="입고번호"
uniqueValues={masterUniqueValues["inbound_number"] || []}
filterValues={headerFilters["inbound_number"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
{GRID_COLUMNS.map((col) => {
const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key);
return (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none", isRight && "text-right")}>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(columnUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={columnUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
@@ -993,7 +963,7 @@ export default function ReceivingPage() {
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredGroups).length === 0 ? (
) : filteredRows.length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
@@ -1003,212 +973,59 @@ export default function ReceivingPage() {
</TableCell>
</TableRow>
) : (
Object.entries(filteredGroups).map(([inboundNo, group]) => {
const isExpanded = expandedOrders.has(inboundNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
filteredRows.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={inboundNo}>
{/* 마스터 행 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(inboundNo)) {
setClosingOrders((prev) => new Set(prev).add(inboundNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(inboundNo));
}
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row as any)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 입고번호 */}
<TableCell className="font-mono whitespace-nowrap">
{inboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "inbound_type": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getTypeVariant(resolveInboundType(master.inbound_type))} className="text-[11px]">
{resolveInboundType(master.inbound_type)}
</Badge>
</TableCell>
);
case "inbound_date": return (
<TableCell key={col.key} className="text-[13px] whitespace-nowrap">
{master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "supplier_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.supplier_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "inbound_status": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getStatusVariant(master.inbound_status)} className="text-[11px]">
{master.inbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground text-[13px]">
<span className="block truncate max-w-[110px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(inboundNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell />
{visibleDetailCols.map((col) => {
const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => {
let v = (d as any)[col.key];
if (col.key === "source_table") v = SOURCE_TABLE_LABEL[v] || v;
return v;
}).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
</TableRow>
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(inboundNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={`${row.id}-${detailIdx}`}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_table": return <TableCell key={col.key} className="text-[13px]">{row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}</TableCell>;
case "item_number": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_number || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[160px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "spec": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>;
case "inbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
);
})}
</React.Fragment>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="font-mono whitespace-nowrap text-[13px]">{row.inbound_number || ""}</TableCell>
<TableCell className="text-[13px]">
<Badge variant={getTypeVariant(row.inbound_type)} className="text-[11px]">
{row.inbound_type || "-"}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-[13px]">
{row.inbound_date ? new Date(row.inbound_date).toLocaleDateString("ko-KR") : ""}
</TableCell>
<TableCell className="text-[13px] truncate max-w-[120px]"><span className="block truncate">{row.reference_number || ""}</span></TableCell>
<TableCell className="text-[13px]">{row.source_type || ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[110px]"><span className="block truncate">{row.supplier_name || ""}</span></TableCell>
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
<TableCell className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[100px]"><span className="block truncate">{row.warehouse_name || (row as any).warehouse_code || ""}</span></TableCell>
<TableCell className="text-[13px]">
<Badge variant={getStatusVariant(row.inbound_status)} className="text-[11px]">
{row.inbound_status || "-"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.remark || row.memo || ""}</span></TableCell>
</TableRow>
);
})
)}
@@ -1221,9 +1038,9 @@ export default function ReceivingPage() {
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="sm:max-w-[1600px] w-[95vw] h-[90vh] p-0 flex flex-col overflow-hidden">
<DialogHeader className="shrink-0 px-6 pt-4 pb-2 border-b">
<DialogTitle> </DialogTitle>
<DialogTitle>{editMode ? "입고 수정" : "입고 등록"}</DialogTitle>
<DialogDescription>
, .
{editMode ? "입고 정보를 수정해 주세요." : "입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요."}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col">
@@ -1053,14 +1053,14 @@ export default function ProductionPlanManagementPage() {
return (
<Table style={{ minWidth: "900px" }}>
<TableHeader className="sticky top-0 z-10">
<TableHeader className="sticky top-0 z-20">
<TableRow className="bg-muted hover:bg-muted">
<TableHead style={{ width: "30px", minWidth: "30px" }}>
<TableHead className="sticky left-0 z-20 bg-muted" style={{ width: "30px", minWidth: "30px" }}>
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead style={{ width: "40px", minWidth: "40px" }} />
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="sticky left-[30px] z-20 bg-muted" style={{ width: "40px", minWidth: "40px" }} />
<TableHead className="sticky left-[70px] z-20 bg-muted text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={{ width: "140px", minWidth: "140px" }}></TableHead>
<TableHead className="sticky left-[210px] z-20 bg-muted text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground border-r" style={{ width: "140px", minWidth: "140px" }}></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} style={{ ...ts.thStyle(col.key), minWidth: "80px" }} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
{col.label}
@@ -1073,9 +1073,9 @@ export default function ProductionPlanManagementPage() {
if (item._isGroupSummary) {
return (
<TableRow key={`summary-${rowIdx}`} className="bg-muted/60 font-semibold border-t border-primary/20">
<TableCell />
<TableCell />
<TableCell colSpan={2} />
<TableCell className="sticky left-0 z-10 bg-muted/60" />
<TableCell className="sticky left-[30px] z-10 bg-muted/60" />
<TableCell colSpan={2} className="sticky left-[70px] z-10 bg-muted/60 border-r" />
{ts.visibleColumns.map((col) => {
const v = (item as any)[col.key];
return (
@@ -1090,14 +1090,14 @@ export default function ProductionPlanManagementPage() {
return (
<React.Fragment key={item.item_code || rowIdx}>
<TableRow className={cn("cursor-pointer border-t-2 border-t-primary/30 bg-primary/5 font-semibold hover:bg-primary/10", selectedItemGroups.has(item.item_code) && "bg-primary/10")}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<TableCell className="sticky left-0 z-10 bg-background text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selectedItemGroups.has(item.item_code)} onCheckedChange={() => toggleItemGroupSelect(item.item_code)} className="h-4 w-4" />
</TableCell>
<TableCell className="text-center" onClick={() => toggleItemExpand(item.item_code)}>
<TableCell className="sticky left-[30px] z-10 bg-background text-center" onClick={() => toggleItemExpand(item.item_code)}>
<ChevronRight className={cn("h-4 w-4 transition-transform duration-200", expandedItems.has(item.item_code) && "rotate-90")} />
</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
<TableCell className="sticky left-[70px] z-10 bg-background text-[13px] text-primary truncate" style={{ width: "140px", minWidth: "140px" }} onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell className="sticky left-[210px] z-10 bg-background text-[13px] text-primary truncate border-r" style={{ width: "140px", minWidth: "140px" }} onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
{ts.visibleColumns.map((col) => renderGroupCell(col, item))}
</TableRow>
{expandedItems.has(item.item_code) && item.orders?.map((detail: any) => {
@@ -1107,9 +1107,9 @@ export default function ProductionPlanManagementPage() {
}
return (
<TableRow key={detail.id || detail.order_no} className="cursor-pointer hover:bg-muted/50">
<TableCell />
<TableCell />
<TableCell colSpan={2} className="pl-10">
<TableCell className="sticky left-0 z-10 bg-card" />
<TableCell className="sticky left-[30px] z-10 bg-card" />
<TableCell colSpan={2} className="sticky left-[70px] z-10 bg-card pl-10 border-r">
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{detail.order_no}</span>
@@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Package, ChevronDown,
ClipboardList, Pencil, Search, X, Package,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2, GripVertical,
} from "lucide-react";
@@ -88,18 +88,18 @@ const GRID_COLUMNS_CONFIG = [
];
const MODAL_DETAIL_COLUMNS = [
{ key: "item_code", label: "품번", width: "w-[120px]" },
{ key: "item_name", label: "품명", width: "w-[120px]" },
{ key: "supplier", label: "공급업체", width: "w-[150px]" },
{ key: "spec", label: "규격", width: "w-[80px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "order_qty", label: "발주수량", width: "w-[90px]" },
{ key: "received_qty", label: "입고수량", width: "w-[90px]" },
{ key: "remain_qty", label: "잔량", width: "w-[80px]" },
{ key: "unit_price", label: "단가", width: "w-[100px]" },
{ key: "amount", label: "금액", width: "w-[100px]" },
{ key: "due_date", label: "납기일", width: "w-[160px]" },
{ key: "memo", label: "메모", width: "w-[120px]" },
{ key: "item_code", label: "품번", width: "min-w-[120px]" },
{ key: "item_name", label: "품명", width: "min-w-[150px]" },
{ key: "supplier", label: "공급업체", width: "min-w-[150px]" },
{ key: "spec", label: "규격", width: "min-w-[80px]" },
{ key: "unit", label: "단위", width: "min-w-[90px]" },
{ key: "order_qty", label: "발주수량", width: "min-w-[90px]" },
{ key: "received_qty", label: "입고수량", width: "min-w-[90px]" },
{ key: "remain_qty", label: "잔량", width: "min-w-[80px]" },
{ key: "unit_price", label: "단가", width: "min-w-[100px]" },
{ key: "amount", label: "금액", width: "min-w-[100px]" },
{ key: "due_date", label: "납기일", width: "min-w-[160px]" },
{ key: "memo", label: "메모", width: "min-w-[120px]" },
];
const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16";
@@ -162,7 +162,6 @@ export default function PurchaseOrderPage() {
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
// 테이블 설정
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
@@ -238,7 +237,7 @@ export default function PurchaseOrderPage() {
);
try {
const suppRes = await apiClient.post(`/table-management/tables/supplier_mng/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 5000, autoFilter: true,
});
const supps = suppRes.data?.data?.data || suppRes.data?.data?.rows || [];
optMap["supplier_code"] = supps.map((s: any) => ({
@@ -248,7 +247,7 @@ export default function PurchaseOrderPage() {
} catch { /* skip */ }
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 5000, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
optMap["manager"] = users.map((u: any) => ({
@@ -294,7 +293,7 @@ export default function PurchaseOrderPage() {
}
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: detailFilters.length > 0 ? { enabled: true, filters: detailFilters } : undefined,
autoFilter: true,
sort: { columnName: "purchase_no", order: "desc" },
@@ -372,20 +371,6 @@ export default function PurchaseOrderPage() {
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// purchase_no 기준 그룹핑
const orderGroups = useMemo(() => {
const map: Record<string, { master: any; details: any[] }> = {};
for (const row of orders) {
const key = row.purchase_no || row.id;
if (!map[key]) {
map[key] = {
master: { purchase_no: row.purchase_no, order_date: row.order_date, supplier_name: row.supplier_name, status: row.status, memo: row.memo },
details: [],
};
}
map[key].details.push(row);
}
return map;
}, [orders]);
const getCategoryLabel = (col: string, code: string) => {
if (!code) return "";
@@ -553,7 +538,7 @@ export default function PurchaseOrderPage() {
}
};
// 품목 검색
// 품목 검색 (수주관리와 동일한 서버 페이징 방식)
const searchItems = async (page?: number, size?: number) => {
const p = page ?? itemPage;
const s = size ?? itemPageSize;
@@ -563,25 +548,24 @@ export default function PurchaseOrderPage() {
if (itemSearchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
}
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
const divValues = [itemSearchDivision];
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
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)));
const rows = resData?.data || resData?.rows || [];
const serverTotal = resData?.total || resData?.totalCount || rows.length;
setItemSearchResults(rows);
setItemTotal(serverTotal);
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
@@ -623,7 +607,7 @@ export default function PurchaseOrderPage() {
try {
const itemIds = selected.map((item) => item.item_number || item.id);
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: {
enabled: true,
filters: [
@@ -686,7 +670,7 @@ export default function PurchaseOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
autoFilter: true,
});
@@ -708,7 +692,7 @@ export default function PurchaseOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: supplierCode },
{ columnName: "item_id", operator: "in", value: itemCodes },
@@ -811,125 +795,83 @@ export default function PurchaseOrderPage() {
</div>
</div>
{/* 데이터 테이블 — 아코디언 그룹핑 */}
{/* 데이터 테이블 — 플랫 리스트 */}
<div className="flex-1 overflow-auto border rounded-lg bg-card">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : Object.keys(orderGroups).length === 0 ? (
) : orders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Package className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
(() => {
const MASTER_KEYS = new Set(["purchase_no", "order_date", "supplier_name", "status", "memo"]);
const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]);
// ts.visibleColumns 순서를 따르되, 마스터/디테일 컬럼을 분리
// 고정 컬럼(품목수)은 마스터 선행 컬럼 뒤에 배치
const leadingMaster: typeof ts.visibleColumns = [];
const detailCols: typeof ts.visibleColumns = [];
const trailingMaster: typeof ts.visibleColumns = [];
let passedFirstDetail = false;
for (const col of ts.visibleColumns) {
if (MASTER_KEYS.has(col.key)) {
if (passedFirstDetail) trailingMaster.push(col);
else leadingMaster.push(col);
} else {
passedFirstDetail = true;
detailCols.push(col);
}
}
const renderDetailCell = (row: any, key: string) => {
const renderCell = (row: any, key: string) => {
const val = row[key];
if (key === "status") return val ? <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[val] || "")}>{val}</span> : "-";
if (numCols.has(key)) return <span className="font-mono">{val ? Number(val).toLocaleString() : "0"}</span>;
return val || "-";
if (key === "order_date" || key === "due_date") return val ? new Date(val).toLocaleDateString("ko-KR") : "-";
if (numCols.has(key)) return <span className="font-mono">{val ? Number(val).toLocaleString() : ""}</span>;
return val || "";
};
const renderMasterHead = (col: { key: string; label: string }) => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", col.key === "status" && "text-center")}>
{col.label}
</TableHead>
);
const renderMasterCell = (col: { key: string }, m: any, purchaseNo: string) => {
if (col.key === "purchase_no") return <TableCell key={col.key} className="text-sm font-semibold text-primary">{purchaseNo}</TableCell>;
if (col.key === "order_date") return <TableCell key={col.key} className="text-sm">{m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}</TableCell>;
if (col.key === "supplier_name") return <TableCell key={col.key} className="text-sm">{m.supplier_name || "-"}</TableCell>;
if (col.key === "status") return <TableCell key={col.key} className="text-center">{m.status && <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[m.status] || "")}>{m.status}</span>}</TableCell>;
if (col.key === "memo") return <TableCell key={col.key} className="text-sm text-muted-foreground max-w-[120px] truncate">{m.memo || ""}</TableCell>;
return <TableCell key={col.key} />;
};
return (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-8" />
<TableHead className="w-10" />
{leadingMaster.map(renderMasterHead)}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground text-center"></TableHead>
{detailCols.map(col => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", numCols.has(col.key) && "text-right")}>
{col.label}{col.key === "order_qty" || col.key === "amount" ? " 합계" : ""}
<TableHead
className="w-10 text-center cursor-pointer"
onClick={() => {
const allIds = orders.map((r) => r.id);
const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allIds);
}}
>
<Checkbox
checked={orders.length > 0 && orders.every((r) => checkedIds.includes(r.id))}
onCheckedChange={() => {}}
/>
</TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", numCols.has(col.key) && "text-right", col.key === "status" && "text-center")}>
{col.label}
</TableHead>
))}
{trailingMaster.map(renderMasterHead)}
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(orderGroups).map(([purchaseNo, group]) => {
const isExpanded = expandedOrders.has(purchaseNo);
const detailIds = group.details.map(d => d.id);
const allChecked = detailIds.length > 0 && detailIds.every(id => checkedIds.includes(id));
const someChecked = detailIds.some(id => checkedIds.includes(id));
const m = group.master;
const totalQty = group.details.reduce((s, d) => s + (Number(d.order_qty) || 0), 0);
const totalAmt = group.details.reduce((s, d) => s + (Number(d.amount) || 0), 0);
{orders.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={purchaseNo}>
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn("cursor-pointer border-l-[3px] border-l-transparent font-semibold", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedOrders(prev => { const next = new Set(prev); if (next.has(purchaseNo)) next.delete(purchaseNo); else next.add(purchaseNo); return next; })}
onDoubleClick={() => openEditModal(purchaseNo)}
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row.purchase_no)}
>
<TableCell
className="text-center"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
{ts.visibleColumns.map((col) => (
<TableCell key={col.key} className={cn("text-[13px]", numCols.has(col.key) && "text-right font-mono", col.key === "status" && "text-center")}>
{renderCell(row, col.key)}
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !detailIds.includes(id)) : [...new Set([...prev, ...detailIds])]); }}>
<Checkbox checked={allChecked} data-state={someChecked && !allChecked ? "indeterminate" : undefined} onCheckedChange={() => {}} />
</TableCell>
{leadingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
<TableCell className="text-sm text-center"><Badge variant="secondary" className="text-[10px]">{group.details.length}</Badge></TableCell>
{detailCols.map(col => (
<TableCell key={col.key} className={cn("text-sm", numCols.has(col.key) && "text-right font-mono")}>
{col.key === "order_qty" ? totalQty.toLocaleString()
: col.key === "amount" ? totalAmt.toLocaleString()
: ""}
</TableCell>
))}
{trailingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
</TableRow>
{isExpanded && group.details.map((row) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell className="text-center" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id]); }}>
<Checkbox checked={checkedIds.includes(row.id)} onCheckedChange={() => {}} />
</TableCell>
{leadingMaster.map(col => <TableCell key={col.key} />)}
<TableCell />
{detailCols.map(col => (
<TableCell key={col.key} className={cn(numCols.has(col.key) && "text-right")}>
{renderDetailCell(row, col.key)}
</TableCell>
))}
{trailingMaster.map(col => <TableCell key={col.key} />)}
</TableRow>
))}
</React.Fragment>
</TableRow>
);
})}
</TableBody>
@@ -938,7 +880,7 @@ export default function PurchaseOrderPage() {
})()
)}
<div className="px-4 py-2 border-t text-xs text-muted-foreground">
{Object.keys(orderGroups).length} ( ) / {orders.length} ( )
{orders.length}
</div>
</div>
@@ -1094,7 +1036,7 @@ export default function PurchaseOrderPage() {
) : (
<div className="border rounded-lg overflow-x-auto">
<DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
<Table className="table-fixed">
<Table>
<TableHeader className="sticky top-0 z-10">
<SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableRow className="bg-muted hover:bg-muted">
@@ -1118,12 +1060,12 @@ export default function PurchaseOrderPage() {
{visibleModalColumns.map((col) => {
switch (col.key) {
case "item_code":
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
return <TableCell key={col.key} className="text-[13px] font-mono whitespace-nowrap"><span title={row.item_code}>{row.item_code}</span></TableCell>;
case "item_name":
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
return <TableCell key={col.key} className="text-[13px] whitespace-nowrap"><span title={row.item_name}>{row.item_name}</span></TableCell>;
case "supplier":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[150px]">
{isReadOnly ? (
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
) : (
@@ -1133,7 +1075,7 @@ export default function PurchaseOrderPage() {
updateDetailRow(idx, "supplier_code", v);
updateDetailRow(idx, "supplier_name", name);
}}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectTrigger className="h-8 text-xs w-full"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
@@ -1149,11 +1091,11 @@ export default function PurchaseOrderPage() {
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
case "order_qty":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[110px]">
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono w-full" />
)}
</TableCell>
);
@@ -1163,11 +1105,11 @@ export default function PurchaseOrderPage() {
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":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[120px]">
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{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" />
<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" />
)}
</TableCell>
);
@@ -1175,21 +1117,21 @@ export default function PurchaseOrderPage() {
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
case "due_date":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[160px]">
{isReadOnly ? (
<span className="text-xs">{row.due_date}</span>
) : (
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs w-full" />
)}
</TableCell>
);
case "memo":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[140px]">
{isReadOnly ? (
<span className="text-xs">{row.memo}</span>
) : (
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs w-full" />
)}
</TableCell>
);
@@ -424,9 +424,10 @@ export default function ItemInspectionInfoPage() {
case "inspection_type": return (
<TableCell key={col.key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>
))}
{group.types.map((t: string) => {
const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t;
return <Badge key={t} variant="secondary" className="text-[10px]">{label}</Badge>;
})}
</div>
</TableCell>
);
@@ -494,7 +495,7 @@ export default function ItemInspectionInfoPage() {
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
)}
>
{type}
{inspTypeCatOptions.find((o) => o.code === type)?.label || type}
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
@@ -624,6 +624,15 @@ export default function SalesOrderPage() {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
// 코드 또는 라벨이 저장된 경우 모두 조회하기 위해 in 연산자 사용
const divValues = [itemSearchDivision];
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const partnerId = masterForm.partner_id;
@@ -632,7 +641,7 @@ export default function SalesOrderPage() {
if (isCustomerPrice && partnerId) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
autoFilter: true,
});
@@ -642,31 +651,22 @@ export default function SalesOrderPage() {
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
let allRows = resData?.data || resData?.rows || [];
let rows = resData?.data || resData?.rows || [];
const serverTotal = resData?.total || resData?.totalCount || rows.length;
// 거래처우선일 때 연결된 품목만 표시
// 거래처우선일 때 연결된 품목만 표시 (클라이언트 필터)
if (customerItemIds) {
allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
rows = rows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
}
// 관리품목 필터 (코드/라벨 혼재 대응)
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)));
setItemSearchResults(rows);
setItemTotal(serverTotal);
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
@@ -1320,44 +1320,44 @@ export default function SalesOrderPage() {
<Table noWrapper className="min-w-max w-full">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="w-28 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-32 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-32 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">/</TableHead>
<TableHead className="min-w-[40px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[120px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[110px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">/</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
<TableCell className="text-center font-mono text-[13px] text-muted-foreground/50">{idx + 1}</TableCell>
<TableCell className="max-w-[112px]">
<span className="block truncate font-mono text-[13px]" title={row.part_code}>{row.part_code}</span>
<TableCell className="whitespace-nowrap">
<span className="font-mono text-[13px]" title={row.part_code}>{row.part_code}</span>
</TableCell>
<TableCell className="max-w-[128px]">
<span className="block truncate text-[13px]" title={row.part_name}>{row.part_name}</span>
<TableCell className="whitespace-nowrap">
<span className="text-[13px]" title={row.part_name}>{row.part_name}</span>
</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.spec}</TableCell>
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.material}</TableCell>
<TableCell>
<Input
value={row.packing_material || ""}
onChange={(e) => updateDetailRow(idx, "packing_material", e.target.value)}
placeholder="포장재"
className="h-8 text-xs"
className="h-8 text-xs w-full"
/>
</TableCell>
<TableCell>
<Select value={row.unit || ""} onValueChange={(v) => updateDetailRow(idx, "unit", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="단위" /></SelectTrigger>
<SelectTrigger className="h-8 text-xs w-full"><SelectValue placeholder="단위" /></SelectTrigger>
<SelectContent>
{(categoryOptions["item_unit"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
@@ -1371,7 +1371,7 @@ export default function SalesOrderPage() {
min="1"
value={row.qty || "1"}
onChange={(e) => updateDetailRow(idx, "qty", e.target.value)}
className="h-8 text-xs text-right font-mono w-16"
className="h-8 text-xs text-right font-mono w-full"
/>
</TableCell>
<TableCell>
@@ -1380,7 +1380,7 @@ export default function SalesOrderPage() {
min="0"
value={row.pack_qty || "0"}
onChange={(e) => updateDetailRow(idx, "pack_qty", e.target.value)}
className="h-8 text-xs text-right font-mono w-16"
className="h-8 text-xs text-right font-mono w-full"
/>
</TableCell>
<TableCell>
@@ -1388,10 +1388,10 @@ export default function SalesOrderPage() {
value={formatNumber(row.unit_price || "")}
onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))}
readOnly={!allowPriceEdit}
className={cn("h-8 text-xs text-right font-mono w-20", !allowPriceEdit && "bg-muted cursor-not-allowed")}
className={cn("h-8 text-xs text-right font-mono w-full", !allowPriceEdit && "bg-muted cursor-not-allowed")}
/>
</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">
<TableCell className="text-right font-mono text-[13px] font-semibold whitespace-nowrap">
{row.amount ? Number(row.amount).toLocaleString() : "0"}
</TableCell>
<TableCell>
@@ -1399,7 +1399,7 @@ export default function SalesOrderPage() {
type="date"
value={row.due_date || ""}
onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)}
className="h-8 text-xs"
className="h-8 text-xs w-full"
/>
</TableCell>
<TableCell className="text-center">
@@ -1474,15 +1474,7 @@ export default function SalesOrderPage() {
onKeyDown={(e) => e.key === "Enter" && triggerNewSearch()}
className="h-9 flex-1"
/>
<Select value={itemSearchDivision} onValueChange={setItemSearchDivision}>
<SelectTrigger className="h-9 w-[130px]"><SelectValue placeholder="관리품목" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["item_division"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="h-9 px-3 flex items-center rounded-md border border-input bg-muted text-xs font-medium text-muted-foreground whitespace-nowrap"></div>
<Button size="sm" onClick={triggerNewSearch} disabled={itemSearchLoading} className="h-9">
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /></>}
</Button>
@@ -38,7 +38,6 @@ import {
Save,
ChevronRight,
ChevronLeft,
ChevronDown,
ChevronsLeft,
ChevronsRight,
Inbox,
@@ -116,41 +115,8 @@ const GRID_COLUMNS = [
{ key: "remark", label: "비고" },
];
// 마스터 헤더 레이아웃 (출고번호 뒤)
const MASTER_BODY_LAYOUT = [
{ key: "outbound_type", label: "출고유형", colSpan: 1 },
{ key: "outbound_date", label: "출고일", colSpan: 1 },
{ key: "reference_number", label: "참조번호", colSpan: 1 },
{ key: "customer_name", label: "거래처", colSpan: 1 },
{ key: "warehouse_name", label: "창고", colSpan: 1 },
{ key: "outbound_status", label: "출고상태", colSpan: 1 },
{ key: "memo", label: "비고", colSpan: 1 },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "source_type", label: "출처" },
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "specification", label: "규격" },
{ key: "outbound_qty", label: "출고수량" },
{ key: "unit_price", label: "단가" },
{ key: "total_amount", label: "금액" },
];
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["outbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_type",
item_number: "item_code",
item_name: "item_name",
spec: "specification",
outbound_qty: "outbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16
const TOTAL_COLS = 16;
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -258,30 +224,6 @@ interface SelectedSourceItem {
export default function OutboundPage() {
const ts = useTableSettings("c16-outbound", "outbound_mng", GRID_COLUMNS);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<OutboundItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -290,9 +232,7 @@ export default function OutboundPage() {
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 마스터-디테일 그룹 테이블 state
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
// 헤더 필터 & 정렬
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
@@ -403,121 +343,59 @@ export default function OutboundPage() {
})();
}, []);
// --- 마스터-디테일 그룹핑, 필터, 정렬 ---
// 플랫 행 생성 (출고유형 코드→라벨 변환, source_type→한글 등)
const flatRows = useMemo(() => {
return data.map((row) => ({
...row,
_raw_outbound_type: row.outbound_type,
outbound_type: resolveCat("outbound_type", row.outbound_type) || row.outbound_type || "",
source_type: row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "",
item_number: row.item_code || (row as any).item_number || "",
spec: row.specification || (row as any).spec || "",
}));
}, [data, resolveCat]);
// outbound_number 기준 그룹핑 + 필터 + 정렬
const filteredGroups = useMemo(() => {
// 1차: outbound_number 기준 그룹핑
const allGroups: Record<string, { master: OutboundItem; details: OutboundItem[] }> = {};
for (const row of data) {
const key = row.outbound_number || "_no_number";
if (!allGroups[key]) {
allGroups[key] = { master: row, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
const raw = (group.master as any)?.[colKey] ?? "";
return values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([outNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
let cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
if (colKey === "source_type") cellVal = SOURCE_TYPE_LABEL[cellVal] || cellVal;
return values.has(cellVal);
})
);
return [outNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
entries.sort(([, a], [, b]) => {
const av = (a.master as any)?.[key] ?? "";
const bv = (b.master as any)?.[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
} else {
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
let av: any = (a as any)[key] ?? "";
let bv: any = (b as any)[key] ?? "";
if (key === "source_type") { av = SOURCE_TYPE_LABEL[av] || av; bv = SOURCE_TYPE_LABEL[bv] || bv; }
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [data, headerFilters, sortState]);
// 마스터 컬럼별 고유값 (헤더 필터용)
const masterUniqueValues = useMemo(() => {
// 컬럼별 고유값 (헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
const seenMasters = new Map<string, OutboundItem>();
data.forEach((row) => {
if (row.outbound_number && !seenMasters.has(row.outbound_number)) {
seenMasters.set(row.outbound_number, row);
}
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "outbound_number", label: "출고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) {
for (const col of GRID_COLUMNS) {
const values = new Set<string>();
masters.forEach((m) => {
const val = (m as any)?.[col.key];
flatRows.forEach((row) => {
const val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
}, [flatRows]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
const values = new Set<string>();
data.forEach((row) => {
let val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "source_type") val = SOURCE_TYPE_LABEL[val] || val;
values.add(String(val));
}
// 필터 + 정렬 적용된 플랫 데이터
const filteredRows = useMemo(() => {
let rows = [...flatRows];
// 1차: 헤더 필터 적용
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
rows = rows.filter((row) => {
const cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
return values.has(cellVal);
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
// 2차: 정렬
if (sortState) {
const { key, direction } = sortState;
rows.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}
return rows;
}, [flatRows, headerFilters, sortState]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
@@ -938,7 +816,7 @@ export default function OutboundPage() {
dataCount={data.length}
/>
{/* 출고 목록 테이블 */}
{/* 출고 목록 테이블 (플랫 리스트) */}
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
{/* 패널 헤더 */}
<div className="flex items-center justify-between border-b bg-muted/30 px-4 py-2.5 shrink-0">
@@ -946,7 +824,7 @@ export default function OutboundPage() {
<PackageOpen className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<span className="rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
{data.length}
{filteredRows.length}
</span>
</div>
<div className="flex gap-2">
@@ -971,78 +849,68 @@ export default function OutboundPage() {
</div>
<div className="h-full overflow-auto">
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<Table noWrapper style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 출고번호 */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("outbound_number")}>
<span className="truncate"></span>
{sortState?.key === "outbound_number" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["outbound_number"] || []).length > 0 && (
<HeaderFilterPopover
colKey="outbound_number" colLabel="출고번호"
uniqueValues={masterUniqueValues["outbound_number"] || []}
filterValues={headerFilters["outbound_number"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
{GRID_COLUMNS.map((col) => {
const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key);
return (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none", isRight && "text-right")}>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(columnUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={columnUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
@@ -1052,7 +920,7 @@ export default function OutboundPage() {
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredGroups).length === 0 ? (
) : filteredRows.length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
@@ -1062,214 +930,59 @@ export default function OutboundPage() {
</TableCell>
</TableRow>
) : (
Object.entries(filteredGroups).map(([outboundNo, group]) => {
const isExpanded = expandedOrders.has(outboundNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
filteredRows.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={outboundNo}>
{/* 마스터 행 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(outboundNo)) {
setClosingOrders((prev) => new Set(prev).add(outboundNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(outboundNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(outboundNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(outboundNo));
}
}}
onDoubleClick={() => openEditModal(group.details[0])}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 출고번호 */}
<TableCell className="font-mono whitespace-nowrap">
{outboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "outbound_type": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{resolveCat("outbound_type", master.outbound_type) || "-"}
</Badge>
</TableCell>
);
case "outbound_date": return (
<TableCell key={col.key} className="whitespace-nowrap text-[13px]">
{master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "customer_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.customer_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "outbound_status": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(master.outbound_status))}>
{master.outbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground">
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(outboundNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell />
{visibleDetailCols.map((col) => {
const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => {
let v = (d as any)[col.key];
if (col.key === "source_type") v = SOURCE_TYPE_LABEL[v] || v;
return v;
}).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
</TableRow>
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(outboundNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={`${row.id}-${detailIdx}`}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_type": return <TableCell key={col.key} className="text-[13px]">{row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}</TableCell>;
case "item_code": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_code || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "specification": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.specification || ""}</TableCell>;
case "outbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
})}
</React.Fragment>
}}
onDoubleClick={() => openEditModal(row as any)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="font-mono whitespace-nowrap text-[13px]">{row.outbound_number || ""}</TableCell>
<TableCell className="text-[13px]">
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(row.outbound_type))}>
{row.outbound_type || "-"}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-[13px]">
{row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""}
</TableCell>
<TableCell className="text-[13px] truncate max-w-[120px]"><span className="block truncate">{row.reference_number || ""}</span></TableCell>
<TableCell className="text-[13px]">{row.source_type || ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[110px]"><span className="block truncate">{row.customer_name || ""}</span></TableCell>
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
<TableCell className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[100px]"><span className="block truncate">{row.warehouse_name || row.warehouse_code || ""}</span></TableCell>
<TableCell className="text-[13px]">
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(row.outbound_status))}>
{row.outbound_status || "-"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.remark || row.memo || ""}</span></TableCell>
</TableRow>
);
})
)}
@@ -43,7 +43,6 @@ import {
X,
Save,
ChevronRight,
ChevronDown,
ChevronLeft,
ChevronsLeft,
ChevronsRight,
@@ -64,6 +63,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea
import {
getReceivingList,
createReceiving,
updateReceiving,
deleteReceiving,
generateReceivingNumber,
getReceivingWarehouses,
@@ -95,41 +95,8 @@ const GRID_COLUMNS = [
{ key: "remark", label: "비고" },
];
// 마스터 헤더 레이아웃 (입고번호 뒤, 디테일 7컬럼 위에 colSpan으로 맵핑)
const MASTER_BODY_LAYOUT = [
{ key: "inbound_type", label: "입고유형", colSpan: 1 },
{ key: "inbound_date", label: "입고일", colSpan: 1 },
{ key: "reference_number", label: "참조번호", colSpan: 1 },
{ key: "supplier_name", label: "공급처", colSpan: 1 },
{ key: "warehouse_name", label: "창고", colSpan: 1 },
{ key: "inbound_status", label: "입고상태", colSpan: 1 },
{ key: "memo", label: "비고", colSpan: 1 },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "source_table", label: "출처" },
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "spec", label: "규격" },
{ key: "inbound_qty", label: "입고수량" },
{ key: "unit_price", label: "단가" },
{ key: "total_amount", label: "금액" },
];
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_table",
item_number: "item_number",
item_name: "item_name",
spec: "spec",
inbound_qty: "inbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16
const TOTAL_COLS = 16;
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -287,30 +254,6 @@ interface SelectedSourceItem {
export default function ReceivingPage() {
const ts = useTableSettings("c16-receiving", "inbound_mng", GRID_COLUMNS);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<InboundItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -319,9 +262,7 @@ export default function ReceivingPage() {
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 마스터-디테일 그룹 테이블
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
// 헤더 필터 & 정렬
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
@@ -338,6 +279,10 @@ export default function ReceivingPage() {
const [selectedItems, setSelectedItems] = useState<SelectedSourceItem[]>([]);
const [saving, setSaving] = useState(false);
// 수정 모드
const [editMode, setEditMode] = useState(false);
const [editItemIds, setEditItemIds] = useState<string[]>([]);
// 소스 데이터
const [sourceKeyword, setSourceKeyword] = useState("");
const [sourceLoading, setSourceLoading] = useState(false);
@@ -434,124 +379,56 @@ export default function ReceivingPage() {
})();
}, []);
// 필터 + 정렬 적용된 데이터 -> 그룹핑
const filteredGroups = useMemo(() => {
// 1차: inbound_number 기준 그룹핑
const allGroups: Record<string, { master: InboundItem; details: InboundItem[] }> = {};
for (const row of data) {
const key = row.inbound_number || "_no_inbound";
if (!allGroups[key]) {
allGroups[key] = { master: row, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
let raw = (group.master as any)?.[colKey] ?? "";
// 입고유형은 코드→라벨 변환된 값으로 비교
if (colKey === "inbound_type") raw = resolveInboundType(String(raw));
return values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([inboundNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
let cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
if (colKey === "source_table") cellVal = SOURCE_TABLE_LABEL[cellVal] || cellVal;
return values.has(cellVal);
})
);
return [inboundNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
entries.sort(([, a], [, b]) => {
let av: any = (a.master as any)?.[key] ?? "";
let bv: any = (b.master as any)?.[key] ?? "";
if (key === "inbound_type") { av = resolveInboundType(String(av)); bv = resolveInboundType(String(bv)); }
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
} else {
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [data, headerFilters, sortState]);
// 마스터 컬럼별 고유값 (마스터 헤더 필터용)
const masterUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
const seenMasters = new Map<string, InboundItem>();
data.forEach((row) => {
if (row.inbound_number && !seenMasters.has(row.inbound_number)) {
seenMasters.set(row.inbound_number, row);
}
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "inbound_number", label: "입고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) {
const values = new Set<string>();
masters.forEach((m) => {
let val = (m as any)?.[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "inbound_type") val = resolveInboundType(String(val));
values.add(String(val));
}
});
result[col.key] = Array.from(values).sort();
}
return result;
// 플랫 행 생성 (입고유형 코드→라벨 변환, source_table→한글 등)
const flatRows = useMemo(() => {
return data.map((row) => ({
...row,
inbound_type: resolveInboundType(row.inbound_type),
source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "",
}));
}, [data]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
// 컬럼별 고유값 (헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
for (const col of GRID_COLUMNS) {
const values = new Set<string>();
data.forEach((row) => {
let val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "source_table") val = SOURCE_TABLE_LABEL[val] || val;
values.add(String(val));
}
flatRows.forEach((row) => {
const val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
}, [flatRows]);
// 필터 + 정렬 적용된 플랫 데이터
const filteredRows = useMemo(() => {
let rows = [...flatRows];
// 1차: 헤더 필터 적용
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
rows = rows.filter((row) => {
const cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
return values.has(cellVal);
});
}
// 2차: 정렬
if (sortState) {
const { key, direction } = sortState;
rows.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}
return rows;
}, [flatRows, headerFilters, sortState]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
@@ -633,6 +510,8 @@ export default function ReceivingPage() {
const openRegisterModal = async () => {
const defaultType = "구매입고";
setEditMode(false);
setEditItemIds([]);
setModalInboundType(defaultType);
setModalInboundDate(new Date().toISOString().split("T")[0]);
setModalWarehouse("");
@@ -661,6 +540,51 @@ export default function ReceivingPage() {
}
};
// 수정 모달 열기
const openEditModal = (row: InboundItem) => {
const inNo = row.inbound_number;
const grouped = data.filter((d) => d.inbound_number === inNo);
const first = grouped[0] || row;
setEditMode(true);
setEditItemIds(grouped.map((g) => g.id));
setModalInboundNo(inNo);
setModalInboundType(first.inbound_type || "구매입고");
setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : "");
setModalWarehouse(first.warehouse_code || "");
setModalLocation(first.location_code || "");
setModalInspector((first as any).inspector || "");
setModalManager((first as any).manager || "");
setModalMemo(first.memo || "");
setSelectedItems(
grouped.map((g) => ({
key: g.id,
inbound_type: g.inbound_type || "",
reference_number: g.reference_number || "",
supplier_code: (g as any).supplier_code || "",
supplier_name: g.supplier_name || "",
item_number: g.item_number || "",
item_name: g.item_name || "",
spec: g.spec || "",
material: (g as any).material || "",
unit: (g as any).unit || "",
inbound_qty: Number(g.inbound_qty) || 0,
unit_price: Number(g.unit_price) || 0,
total_amount: Number(g.total_amount) || 0,
source_table: (g as any).source_table || "",
source_id: (g as any).source_id || "",
}))
);
setSourceKeyword("");
setPurchaseOrders([]);
setShipments([]);
setItems([]);
setSourcePage(1);
setSourceTotalCount(0);
setIsModalOpen(true);
loadSourceData(first.inbound_type || "구매입고", undefined, 1);
};
// 검색 버튼 클릭 시
const searchSourceData = useCallback(async () => {
setSourcePage(1);
@@ -811,41 +735,97 @@ export default function ReceivingPage() {
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: selectedItems.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
});
if (editMode) {
// 기존 item 수정 + 삭제된 item 삭제 + 새 item 추가
const currentKeys = new Set(selectedItems.map((i) => i.key));
const toDelete = editItemIds.filter((id) => !currentKeys.has(id));
const toUpdate = selectedItems.filter((i) => editItemIds.includes(i.key));
const toCreate = selectedItems.filter((i) => !editItemIds.includes(i.key));
if (res.success) {
alert(res.message || "입고 등록 완료");
await Promise.all([
...toDelete.map((id) => deleteReceiving(id)),
...toUpdate.map((item) =>
updateReceiving(item.key, {
inbound_date: modalInboundDate,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
warehouse_code: modalWarehouse || undefined,
location_code: modalLocation || undefined,
memo: modalMemo || undefined,
} as any)
),
...(toCreate.length > 0
? [createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: toCreate.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
})]
: []),
]);
toast.success("입고 정보를 수정했어요");
setIsModalOpen(false);
fetchList();
} else {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: selectedItems.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
});
if (res.success) {
toast.success(res.message || "입고 등록 완료");
setIsModalOpen(false);
fetchList();
}
}
} catch {
alert("입고 등록 중 오류가 발생했습니다.");
} catch (err: any) {
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "입고 등록 중 오류가 발생했습니다.");
toast.error(msg);
} finally {
setSaving(false);
}
@@ -897,13 +877,13 @@ export default function ReceivingPage() {
}
/>
{/* 입고 목록 테이블 (마스터-디테일 그룹) */}
{/* 입고 목록 테이블 (플랫 리스트) */}
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
<div className="flex items-center justify-between border-b bg-muted/50 px-4 py-2.5">
<div className="flex items-center gap-2">
<h3 className="text-[13px] font-bold"> </h3>
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
{Object.keys(filteredGroups).length}
{filteredRows.length}
</span>
</div>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
@@ -912,78 +892,68 @@ export default function ReceivingPage() {
</div>
<div className="h-[calc(100%-44px)] overflow-auto">
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<Table noWrapper style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 입고번호 (별도 컬럼) */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("inbound_number")}>
<span className="truncate"></span>
{sortState?.key === "inbound_number" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["inbound_number"] || []).length > 0 && (
<HeaderFilterPopover
colKey="inbound_number" colLabel="입고번호"
uniqueValues={masterUniqueValues["inbound_number"] || []}
filterValues={headerFilters["inbound_number"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
{GRID_COLUMNS.map((col) => {
const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key);
return (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none", isRight && "text-right")}>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(columnUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={columnUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
@@ -993,7 +963,7 @@ export default function ReceivingPage() {
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredGroups).length === 0 ? (
) : filteredRows.length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
@@ -1003,212 +973,59 @@ export default function ReceivingPage() {
</TableCell>
</TableRow>
) : (
Object.entries(filteredGroups).map(([inboundNo, group]) => {
const isExpanded = expandedOrders.has(inboundNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
filteredRows.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={inboundNo}>
{/* 마스터 행 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(inboundNo)) {
setClosingOrders((prev) => new Set(prev).add(inboundNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(inboundNo));
}
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row as any)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 입고번호 */}
<TableCell className="font-mono whitespace-nowrap">
{inboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "inbound_type": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getTypeVariant(resolveInboundType(master.inbound_type))} className="text-[11px]">
{resolveInboundType(master.inbound_type)}
</Badge>
</TableCell>
);
case "inbound_date": return (
<TableCell key={col.key} className="text-[13px] whitespace-nowrap">
{master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "supplier_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.supplier_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "inbound_status": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getStatusVariant(master.inbound_status)} className="text-[11px]">
{master.inbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground text-[13px]">
<span className="block truncate max-w-[110px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(inboundNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell />
{visibleDetailCols.map((col) => {
const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => {
let v = (d as any)[col.key];
if (col.key === "source_table") v = SOURCE_TABLE_LABEL[v] || v;
return v;
}).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
</TableRow>
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(inboundNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={`${row.id}-${detailIdx}`}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_table": return <TableCell key={col.key} className="text-[13px]">{row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}</TableCell>;
case "item_number": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_number || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[160px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "spec": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>;
case "inbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
);
})}
</React.Fragment>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="font-mono whitespace-nowrap text-[13px]">{row.inbound_number || ""}</TableCell>
<TableCell className="text-[13px]">
<Badge variant={getTypeVariant(row.inbound_type)} className="text-[11px]">
{row.inbound_type || "-"}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-[13px]">
{row.inbound_date ? new Date(row.inbound_date).toLocaleDateString("ko-KR") : ""}
</TableCell>
<TableCell className="text-[13px] truncate max-w-[120px]"><span className="block truncate">{row.reference_number || ""}</span></TableCell>
<TableCell className="text-[13px]">{row.source_type || ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[110px]"><span className="block truncate">{row.supplier_name || ""}</span></TableCell>
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
<TableCell className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[100px]"><span className="block truncate">{row.warehouse_name || (row as any).warehouse_code || ""}</span></TableCell>
<TableCell className="text-[13px]">
<Badge variant={getStatusVariant(row.inbound_status)} className="text-[11px]">
{row.inbound_status || "-"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.remark || row.memo || ""}</span></TableCell>
</TableRow>
);
})
)}
@@ -1221,9 +1038,9 @@ export default function ReceivingPage() {
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="sm:max-w-[1600px] w-[95vw] h-[90vh] p-0 flex flex-col overflow-hidden">
<DialogHeader className="shrink-0 px-6 pt-4 pb-2 border-b">
<DialogTitle> </DialogTitle>
<DialogTitle>{editMode ? "입고 수정" : "입고 등록"}</DialogTitle>
<DialogDescription>
, .
{editMode ? "입고 정보를 수정해 주세요." : "입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요."}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col">
@@ -1053,14 +1053,14 @@ export default function ProductionPlanManagementPage() {
return (
<Table style={{ minWidth: "900px" }}>
<TableHeader className="sticky top-0 z-10">
<TableHeader className="sticky top-0 z-20">
<TableRow className="bg-muted hover:bg-muted">
<TableHead style={{ width: "30px", minWidth: "30px" }}>
<TableHead className="sticky left-0 z-20 bg-muted" style={{ width: "30px", minWidth: "30px" }}>
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead style={{ width: "40px", minWidth: "40px" }} />
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="sticky left-[30px] z-20 bg-muted" style={{ width: "40px", minWidth: "40px" }} />
<TableHead className="sticky left-[70px] z-20 bg-muted text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={{ width: "140px", minWidth: "140px" }}></TableHead>
<TableHead className="sticky left-[210px] z-20 bg-muted text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground border-r" style={{ width: "140px", minWidth: "140px" }}></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} style={{ ...ts.thStyle(col.key), minWidth: "80px" }} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
{col.label}
@@ -1073,9 +1073,9 @@ export default function ProductionPlanManagementPage() {
if (item._isGroupSummary) {
return (
<TableRow key={`summary-${rowIdx}`} className="bg-muted/60 font-semibold border-t border-primary/20">
<TableCell />
<TableCell />
<TableCell colSpan={2} />
<TableCell className="sticky left-0 z-10 bg-muted/60" />
<TableCell className="sticky left-[30px] z-10 bg-muted/60" />
<TableCell colSpan={2} className="sticky left-[70px] z-10 bg-muted/60 border-r" />
{ts.visibleColumns.map((col) => {
const v = (item as any)[col.key];
return (
@@ -1090,14 +1090,14 @@ export default function ProductionPlanManagementPage() {
return (
<React.Fragment key={item.item_code || rowIdx}>
<TableRow className={cn("cursor-pointer border-t-2 border-t-primary/30 bg-primary/5 font-semibold hover:bg-primary/10", selectedItemGroups.has(item.item_code) && "bg-primary/10")}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<TableCell className="sticky left-0 z-10 bg-background text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selectedItemGroups.has(item.item_code)} onCheckedChange={() => toggleItemGroupSelect(item.item_code)} className="h-4 w-4" />
</TableCell>
<TableCell className="text-center" onClick={() => toggleItemExpand(item.item_code)}>
<TableCell className="sticky left-[30px] z-10 bg-background text-center" onClick={() => toggleItemExpand(item.item_code)}>
<ChevronRight className={cn("h-4 w-4 transition-transform duration-200", expandedItems.has(item.item_code) && "rotate-90")} />
</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
<TableCell className="sticky left-[70px] z-10 bg-background text-[13px] text-primary truncate" style={{ width: "140px", minWidth: "140px" }} onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell className="sticky left-[210px] z-10 bg-background text-[13px] text-primary truncate border-r" style={{ width: "140px", minWidth: "140px" }} onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
{ts.visibleColumns.map((col) => renderGroupCell(col, item))}
</TableRow>
{expandedItems.has(item.item_code) && item.orders?.map((detail: any) => {
@@ -1107,9 +1107,9 @@ export default function ProductionPlanManagementPage() {
}
return (
<TableRow key={detail.id || detail.order_no} className="cursor-pointer hover:bg-muted/50">
<TableCell />
<TableCell />
<TableCell colSpan={2} className="pl-10">
<TableCell className="sticky left-0 z-10 bg-card" />
<TableCell className="sticky left-[30px] z-10 bg-card" />
<TableCell colSpan={2} className="sticky left-[70px] z-10 bg-card pl-10 border-r">
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{detail.order_no}</span>
@@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Package, ChevronDown,
ClipboardList, Pencil, Search, X, Package,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2, GripVertical,
} from "lucide-react";
@@ -88,18 +88,18 @@ const GRID_COLUMNS_CONFIG = [
];
const MODAL_DETAIL_COLUMNS = [
{ key: "item_code", label: "품번", width: "w-[120px]" },
{ key: "item_name", label: "품명", width: "w-[120px]" },
{ key: "supplier", label: "공급업체", width: "w-[150px]" },
{ key: "spec", label: "규격", width: "w-[80px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "order_qty", label: "발주수량", width: "w-[90px]" },
{ key: "received_qty", label: "입고수량", width: "w-[90px]" },
{ key: "remain_qty", label: "잔량", width: "w-[80px]" },
{ key: "unit_price", label: "단가", width: "w-[100px]" },
{ key: "amount", label: "금액", width: "w-[100px]" },
{ key: "due_date", label: "납기일", width: "w-[160px]" },
{ key: "memo", label: "메모", width: "w-[120px]" },
{ key: "item_code", label: "품번", width: "min-w-[120px]" },
{ key: "item_name", label: "품명", width: "min-w-[150px]" },
{ key: "supplier", label: "공급업체", width: "min-w-[150px]" },
{ key: "spec", label: "규격", width: "min-w-[80px]" },
{ key: "unit", label: "단위", width: "min-w-[90px]" },
{ key: "order_qty", label: "발주수량", width: "min-w-[90px]" },
{ key: "received_qty", label: "입고수량", width: "min-w-[90px]" },
{ key: "remain_qty", label: "잔량", width: "min-w-[80px]" },
{ key: "unit_price", label: "단가", width: "min-w-[100px]" },
{ key: "amount", label: "금액", width: "min-w-[100px]" },
{ key: "due_date", label: "납기일", width: "min-w-[160px]" },
{ key: "memo", label: "메모", width: "min-w-[120px]" },
];
const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16";
@@ -162,7 +162,6 @@ export default function PurchaseOrderPage() {
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
// 테이블 설정
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
@@ -238,7 +237,7 @@ export default function PurchaseOrderPage() {
);
try {
const suppRes = await apiClient.post(`/table-management/tables/supplier_mng/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 5000, autoFilter: true,
});
const supps = suppRes.data?.data?.data || suppRes.data?.data?.rows || [];
optMap["supplier_code"] = supps.map((s: any) => ({
@@ -248,7 +247,7 @@ export default function PurchaseOrderPage() {
} catch { /* skip */ }
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 5000, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
optMap["manager"] = users.map((u: any) => ({
@@ -294,7 +293,7 @@ export default function PurchaseOrderPage() {
}
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: detailFilters.length > 0 ? { enabled: true, filters: detailFilters } : undefined,
autoFilter: true,
sort: { columnName: "purchase_no", order: "desc" },
@@ -372,20 +371,6 @@ export default function PurchaseOrderPage() {
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// purchase_no 기준 그룹핑
const orderGroups = useMemo(() => {
const map: Record<string, { master: any; details: any[] }> = {};
for (const row of orders) {
const key = row.purchase_no || row.id;
if (!map[key]) {
map[key] = {
master: { purchase_no: row.purchase_no, order_date: row.order_date, supplier_name: row.supplier_name, status: row.status, memo: row.memo },
details: [],
};
}
map[key].details.push(row);
}
return map;
}, [orders]);
const getCategoryLabel = (col: string, code: string) => {
if (!code) return "";
@@ -553,7 +538,7 @@ export default function PurchaseOrderPage() {
}
};
// 품목 검색
// 품목 검색 (수주관리와 동일한 서버 페이징 방식)
const searchItems = async (page?: number, size?: number) => {
const p = page ?? itemPage;
const s = size ?? itemPageSize;
@@ -563,25 +548,24 @@ export default function PurchaseOrderPage() {
if (itemSearchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
}
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
const divValues = [itemSearchDivision];
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
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)));
const rows = resData?.data || resData?.rows || [];
const serverTotal = resData?.total || resData?.totalCount || rows.length;
setItemSearchResults(rows);
setItemTotal(serverTotal);
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
@@ -623,7 +607,7 @@ export default function PurchaseOrderPage() {
try {
const itemIds = selected.map((item) => item.item_number || item.id);
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: {
enabled: true,
filters: [
@@ -686,7 +670,7 @@ export default function PurchaseOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
autoFilter: true,
});
@@ -708,7 +692,7 @@ export default function PurchaseOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: supplierCode },
{ columnName: "item_id", operator: "in", value: itemCodes },
@@ -811,125 +795,83 @@ export default function PurchaseOrderPage() {
</div>
</div>
{/* 데이터 테이블 — 아코디언 그룹핑 */}
{/* 데이터 테이블 — 플랫 리스트 */}
<div className="flex-1 overflow-auto border rounded-lg bg-card">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : Object.keys(orderGroups).length === 0 ? (
) : orders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Package className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
(() => {
const MASTER_KEYS = new Set(["purchase_no", "order_date", "supplier_name", "status", "memo"]);
const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]);
// ts.visibleColumns 순서를 따르되, 마스터/디테일 컬럼을 분리
// 고정 컬럼(품목수)은 마스터 선행 컬럼 뒤에 배치
const leadingMaster: typeof ts.visibleColumns = [];
const detailCols: typeof ts.visibleColumns = [];
const trailingMaster: typeof ts.visibleColumns = [];
let passedFirstDetail = false;
for (const col of ts.visibleColumns) {
if (MASTER_KEYS.has(col.key)) {
if (passedFirstDetail) trailingMaster.push(col);
else leadingMaster.push(col);
} else {
passedFirstDetail = true;
detailCols.push(col);
}
}
const renderDetailCell = (row: any, key: string) => {
const renderCell = (row: any, key: string) => {
const val = row[key];
if (key === "status") return val ? <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[val] || "")}>{val}</span> : "-";
if (numCols.has(key)) return <span className="font-mono">{val ? Number(val).toLocaleString() : "0"}</span>;
return val || "-";
if (key === "order_date" || key === "due_date") return val ? new Date(val).toLocaleDateString("ko-KR") : "-";
if (numCols.has(key)) return <span className="font-mono">{val ? Number(val).toLocaleString() : ""}</span>;
return val || "";
};
const renderMasterHead = (col: { key: string; label: string }) => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", col.key === "status" && "text-center")}>
{col.label}
</TableHead>
);
const renderMasterCell = (col: { key: string }, m: any, purchaseNo: string) => {
if (col.key === "purchase_no") return <TableCell key={col.key} className="text-sm font-semibold text-primary">{purchaseNo}</TableCell>;
if (col.key === "order_date") return <TableCell key={col.key} className="text-sm">{m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}</TableCell>;
if (col.key === "supplier_name") return <TableCell key={col.key} className="text-sm">{m.supplier_name || "-"}</TableCell>;
if (col.key === "status") return <TableCell key={col.key} className="text-center">{m.status && <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[m.status] || "")}>{m.status}</span>}</TableCell>;
if (col.key === "memo") return <TableCell key={col.key} className="text-sm text-muted-foreground max-w-[120px] truncate">{m.memo || ""}</TableCell>;
return <TableCell key={col.key} />;
};
return (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-8" />
<TableHead className="w-10" />
{leadingMaster.map(renderMasterHead)}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground text-center"></TableHead>
{detailCols.map(col => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", numCols.has(col.key) && "text-right")}>
{col.label}{col.key === "order_qty" || col.key === "amount" ? " 합계" : ""}
<TableHead
className="w-10 text-center cursor-pointer"
onClick={() => {
const allIds = orders.map((r) => r.id);
const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allIds);
}}
>
<Checkbox
checked={orders.length > 0 && orders.every((r) => checkedIds.includes(r.id))}
onCheckedChange={() => {}}
/>
</TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", numCols.has(col.key) && "text-right", col.key === "status" && "text-center")}>
{col.label}
</TableHead>
))}
{trailingMaster.map(renderMasterHead)}
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(orderGroups).map(([purchaseNo, group]) => {
const isExpanded = expandedOrders.has(purchaseNo);
const detailIds = group.details.map(d => d.id);
const allChecked = detailIds.length > 0 && detailIds.every(id => checkedIds.includes(id));
const someChecked = detailIds.some(id => checkedIds.includes(id));
const m = group.master;
const totalQty = group.details.reduce((s, d) => s + (Number(d.order_qty) || 0), 0);
const totalAmt = group.details.reduce((s, d) => s + (Number(d.amount) || 0), 0);
{orders.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={purchaseNo}>
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn("cursor-pointer border-l-[3px] border-l-transparent font-semibold", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedOrders(prev => { const next = new Set(prev); if (next.has(purchaseNo)) next.delete(purchaseNo); else next.add(purchaseNo); return next; })}
onDoubleClick={() => openEditModal(purchaseNo)}
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row.purchase_no)}
>
<TableCell
className="text-center"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
{ts.visibleColumns.map((col) => (
<TableCell key={col.key} className={cn("text-[13px]", numCols.has(col.key) && "text-right font-mono", col.key === "status" && "text-center")}>
{renderCell(row, col.key)}
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !detailIds.includes(id)) : [...new Set([...prev, ...detailIds])]); }}>
<Checkbox checked={allChecked} data-state={someChecked && !allChecked ? "indeterminate" : undefined} onCheckedChange={() => {}} />
</TableCell>
{leadingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
<TableCell className="text-sm text-center"><Badge variant="secondary" className="text-[10px]">{group.details.length}</Badge></TableCell>
{detailCols.map(col => (
<TableCell key={col.key} className={cn("text-sm", numCols.has(col.key) && "text-right font-mono")}>
{col.key === "order_qty" ? totalQty.toLocaleString()
: col.key === "amount" ? totalAmt.toLocaleString()
: ""}
</TableCell>
))}
{trailingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
</TableRow>
{isExpanded && group.details.map((row) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell className="text-center" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id]); }}>
<Checkbox checked={checkedIds.includes(row.id)} onCheckedChange={() => {}} />
</TableCell>
{leadingMaster.map(col => <TableCell key={col.key} />)}
<TableCell />
{detailCols.map(col => (
<TableCell key={col.key} className={cn(numCols.has(col.key) && "text-right")}>
{renderDetailCell(row, col.key)}
</TableCell>
))}
{trailingMaster.map(col => <TableCell key={col.key} />)}
</TableRow>
))}
</React.Fragment>
</TableRow>
);
})}
</TableBody>
@@ -938,7 +880,7 @@ export default function PurchaseOrderPage() {
})()
)}
<div className="px-4 py-2 border-t text-xs text-muted-foreground">
{Object.keys(orderGroups).length} ( ) / {orders.length} ( )
{orders.length}
</div>
</div>
@@ -1094,7 +1036,7 @@ export default function PurchaseOrderPage() {
) : (
<div className="border rounded-lg overflow-x-auto">
<DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
<Table className="table-fixed">
<Table>
<TableHeader className="sticky top-0 z-10">
<SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableRow className="bg-muted hover:bg-muted">
@@ -1118,12 +1060,12 @@ export default function PurchaseOrderPage() {
{visibleModalColumns.map((col) => {
switch (col.key) {
case "item_code":
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
return <TableCell key={col.key} className="text-[13px] font-mono whitespace-nowrap"><span title={row.item_code}>{row.item_code}</span></TableCell>;
case "item_name":
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
return <TableCell key={col.key} className="text-[13px] whitespace-nowrap"><span title={row.item_name}>{row.item_name}</span></TableCell>;
case "supplier":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[150px]">
{isReadOnly ? (
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
) : (
@@ -1133,7 +1075,7 @@ export default function PurchaseOrderPage() {
updateDetailRow(idx, "supplier_code", v);
updateDetailRow(idx, "supplier_name", name);
}}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectTrigger className="h-8 text-xs w-full"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
@@ -1149,11 +1091,11 @@ export default function PurchaseOrderPage() {
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
case "order_qty":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[110px]">
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono w-full" />
)}
</TableCell>
);
@@ -1163,11 +1105,11 @@ export default function PurchaseOrderPage() {
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":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[120px]">
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{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" />
<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" />
)}
</TableCell>
);
@@ -1175,21 +1117,21 @@ export default function PurchaseOrderPage() {
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
case "due_date":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[160px]">
{isReadOnly ? (
<span className="text-xs">{row.due_date}</span>
) : (
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs w-full" />
)}
</TableCell>
);
case "memo":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[140px]">
{isReadOnly ? (
<span className="text-xs">{row.memo}</span>
) : (
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs w-full" />
)}
</TableCell>
);
@@ -424,9 +424,10 @@ export default function ItemInspectionInfoPage() {
case "inspection_type": return (
<TableCell key={col.key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>
))}
{group.types.map((t: string) => {
const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t;
return <Badge key={t} variant="secondary" className="text-[10px]">{label}</Badge>;
})}
</div>
</TableCell>
);
@@ -494,7 +495,7 @@ export default function ItemInspectionInfoPage() {
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
)}
>
{type}
{inspTypeCatOptions.find((o) => o.code === type)?.label || type}
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
@@ -624,6 +624,15 @@ export default function SalesOrderPage() {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
// 코드 또는 라벨이 저장된 경우 모두 조회하기 위해 in 연산자 사용
const divValues = [itemSearchDivision];
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const partnerId = masterForm.partner_id;
@@ -632,7 +641,7 @@ export default function SalesOrderPage() {
if (isCustomerPrice && partnerId) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
autoFilter: true,
});
@@ -642,31 +651,22 @@ export default function SalesOrderPage() {
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
let allRows = resData?.data || resData?.rows || [];
let rows = resData?.data || resData?.rows || [];
const serverTotal = resData?.total || resData?.totalCount || rows.length;
// 거래처우선일 때 연결된 품목만 표시
// 거래처우선일 때 연결된 품목만 표시 (클라이언트 필터)
if (customerItemIds) {
allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
rows = rows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
}
// 관리품목 필터 (코드/라벨 혼재 대응)
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)));
setItemSearchResults(rows);
setItemTotal(serverTotal);
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
@@ -1320,44 +1320,44 @@ export default function SalesOrderPage() {
<Table noWrapper className="min-w-max w-full">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="w-28 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-32 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-32 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">/</TableHead>
<TableHead className="min-w-[40px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[120px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[110px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">/</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
<TableCell className="text-center font-mono text-[13px] text-muted-foreground/50">{idx + 1}</TableCell>
<TableCell className="max-w-[112px]">
<span className="block truncate font-mono text-[13px]" title={row.part_code}>{row.part_code}</span>
<TableCell className="whitespace-nowrap">
<span className="font-mono text-[13px]" title={row.part_code}>{row.part_code}</span>
</TableCell>
<TableCell className="max-w-[128px]">
<span className="block truncate text-[13px]" title={row.part_name}>{row.part_name}</span>
<TableCell className="whitespace-nowrap">
<span className="text-[13px]" title={row.part_name}>{row.part_name}</span>
</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.spec}</TableCell>
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.material}</TableCell>
<TableCell>
<Input
value={row.packing_material || ""}
onChange={(e) => updateDetailRow(idx, "packing_material", e.target.value)}
placeholder="포장재"
className="h-8 text-xs"
className="h-8 text-xs w-full"
/>
</TableCell>
<TableCell>
<Select value={row.unit || ""} onValueChange={(v) => updateDetailRow(idx, "unit", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="단위" /></SelectTrigger>
<SelectTrigger className="h-8 text-xs w-full"><SelectValue placeholder="단위" /></SelectTrigger>
<SelectContent>
{(categoryOptions["item_unit"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
@@ -1371,7 +1371,7 @@ export default function SalesOrderPage() {
min="1"
value={row.qty || "1"}
onChange={(e) => updateDetailRow(idx, "qty", e.target.value)}
className="h-8 text-xs text-right font-mono w-16"
className="h-8 text-xs text-right font-mono w-full"
/>
</TableCell>
<TableCell>
@@ -1380,7 +1380,7 @@ export default function SalesOrderPage() {
min="0"
value={row.pack_qty || "0"}
onChange={(e) => updateDetailRow(idx, "pack_qty", e.target.value)}
className="h-8 text-xs text-right font-mono w-16"
className="h-8 text-xs text-right font-mono w-full"
/>
</TableCell>
<TableCell>
@@ -1388,10 +1388,10 @@ export default function SalesOrderPage() {
value={formatNumber(row.unit_price || "")}
onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))}
readOnly={!allowPriceEdit}
className={cn("h-8 text-xs text-right font-mono w-20", !allowPriceEdit && "bg-muted cursor-not-allowed")}
className={cn("h-8 text-xs text-right font-mono w-full", !allowPriceEdit && "bg-muted cursor-not-allowed")}
/>
</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">
<TableCell className="text-right font-mono text-[13px] font-semibold whitespace-nowrap">
{row.amount ? Number(row.amount).toLocaleString() : "0"}
</TableCell>
<TableCell>
@@ -1399,7 +1399,7 @@ export default function SalesOrderPage() {
type="date"
value={row.due_date || ""}
onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)}
className="h-8 text-xs"
className="h-8 text-xs w-full"
/>
</TableCell>
<TableCell className="text-center">
@@ -1474,15 +1474,7 @@ export default function SalesOrderPage() {
onKeyDown={(e) => e.key === "Enter" && triggerNewSearch()}
className="h-9 flex-1"
/>
<Select value={itemSearchDivision} onValueChange={setItemSearchDivision}>
<SelectTrigger className="h-9 w-[130px]"><SelectValue placeholder="관리품목" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["item_division"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="h-9 px-3 flex items-center rounded-md border border-input bg-muted text-xs font-medium text-muted-foreground whitespace-nowrap"></div>
<Button size="sm" onClick={triggerNewSearch} disabled={itemSearchLoading} className="h-9">
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /></>}
</Button>
@@ -38,7 +38,6 @@ import {
Save,
ChevronRight,
ChevronLeft,
ChevronDown,
ChevronsLeft,
ChevronsRight,
Inbox,
@@ -116,41 +115,8 @@ const GRID_COLUMNS = [
{ key: "remark", label: "비고" },
];
// 마스터 헤더 레이아웃 (출고번호 뒤)
const MASTER_BODY_LAYOUT = [
{ key: "outbound_type", label: "출고유형", colSpan: 1 },
{ key: "outbound_date", label: "출고일", colSpan: 1 },
{ key: "reference_number", label: "참조번호", colSpan: 1 },
{ key: "customer_name", label: "거래처", colSpan: 1 },
{ key: "warehouse_name", label: "창고", colSpan: 1 },
{ key: "outbound_status", label: "출고상태", colSpan: 1 },
{ key: "memo", label: "비고", colSpan: 1 },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "source_type", label: "출처" },
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "specification", label: "규격" },
{ key: "outbound_qty", label: "출고수량" },
{ key: "unit_price", label: "단가" },
{ key: "total_amount", label: "금액" },
];
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["outbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_type",
item_number: "item_code",
item_name: "item_name",
spec: "specification",
outbound_qty: "outbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16
const TOTAL_COLS = 16;
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -258,30 +224,6 @@ interface SelectedSourceItem {
export default function OutboundPage() {
const ts = useTableSettings("c16-outbound", "outbound_mng", GRID_COLUMNS);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<OutboundItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -290,9 +232,7 @@ export default function OutboundPage() {
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 마스터-디테일 그룹 테이블 state
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
// 헤더 필터 & 정렬
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
@@ -403,121 +343,59 @@ export default function OutboundPage() {
})();
}, []);
// --- 마스터-디테일 그룹핑, 필터, 정렬 ---
// 플랫 행 생성 (출고유형 코드→라벨 변환, source_type→한글 등)
const flatRows = useMemo(() => {
return data.map((row) => ({
...row,
_raw_outbound_type: row.outbound_type,
outbound_type: resolveCat("outbound_type", row.outbound_type) || row.outbound_type || "",
source_type: row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "",
item_number: row.item_code || (row as any).item_number || "",
spec: row.specification || (row as any).spec || "",
}));
}, [data, resolveCat]);
// outbound_number 기준 그룹핑 + 필터 + 정렬
const filteredGroups = useMemo(() => {
// 1차: outbound_number 기준 그룹핑
const allGroups: Record<string, { master: OutboundItem; details: OutboundItem[] }> = {};
for (const row of data) {
const key = row.outbound_number || "_no_number";
if (!allGroups[key]) {
allGroups[key] = { master: row, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
const raw = (group.master as any)?.[colKey] ?? "";
return values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([outNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
let cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
if (colKey === "source_type") cellVal = SOURCE_TYPE_LABEL[cellVal] || cellVal;
return values.has(cellVal);
})
);
return [outNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
entries.sort(([, a], [, b]) => {
const av = (a.master as any)?.[key] ?? "";
const bv = (b.master as any)?.[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
} else {
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
let av: any = (a as any)[key] ?? "";
let bv: any = (b as any)[key] ?? "";
if (key === "source_type") { av = SOURCE_TYPE_LABEL[av] || av; bv = SOURCE_TYPE_LABEL[bv] || bv; }
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [data, headerFilters, sortState]);
// 마스터 컬럼별 고유값 (헤더 필터용)
const masterUniqueValues = useMemo(() => {
// 컬럼별 고유값 (헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
const seenMasters = new Map<string, OutboundItem>();
data.forEach((row) => {
if (row.outbound_number && !seenMasters.has(row.outbound_number)) {
seenMasters.set(row.outbound_number, row);
}
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "outbound_number", label: "출고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) {
for (const col of GRID_COLUMNS) {
const values = new Set<string>();
masters.forEach((m) => {
const val = (m as any)?.[col.key];
flatRows.forEach((row) => {
const val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
}, [flatRows]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
const values = new Set<string>();
data.forEach((row) => {
let val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "source_type") val = SOURCE_TYPE_LABEL[val] || val;
values.add(String(val));
}
// 필터 + 정렬 적용된 플랫 데이터
const filteredRows = useMemo(() => {
let rows = [...flatRows];
// 1차: 헤더 필터 적용
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
rows = rows.filter((row) => {
const cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
return values.has(cellVal);
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
// 2차: 정렬
if (sortState) {
const { key, direction } = sortState;
rows.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}
return rows;
}, [flatRows, headerFilters, sortState]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
@@ -938,7 +816,7 @@ export default function OutboundPage() {
dataCount={data.length}
/>
{/* 출고 목록 테이블 */}
{/* 출고 목록 테이블 (플랫 리스트) */}
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
{/* 패널 헤더 */}
<div className="flex items-center justify-between border-b bg-muted/30 px-4 py-2.5 shrink-0">
@@ -946,7 +824,7 @@ export default function OutboundPage() {
<PackageOpen className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<span className="rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
{data.length}
{filteredRows.length}
</span>
</div>
<div className="flex gap-2">
@@ -971,78 +849,68 @@ export default function OutboundPage() {
</div>
<div className="h-full overflow-auto">
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<Table noWrapper style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 출고번호 */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("outbound_number")}>
<span className="truncate"></span>
{sortState?.key === "outbound_number" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["outbound_number"] || []).length > 0 && (
<HeaderFilterPopover
colKey="outbound_number" colLabel="출고번호"
uniqueValues={masterUniqueValues["outbound_number"] || []}
filterValues={headerFilters["outbound_number"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
{GRID_COLUMNS.map((col) => {
const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key);
return (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none", isRight && "text-right")}>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(columnUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={columnUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
@@ -1052,7 +920,7 @@ export default function OutboundPage() {
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredGroups).length === 0 ? (
) : filteredRows.length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
@@ -1062,214 +930,59 @@ export default function OutboundPage() {
</TableCell>
</TableRow>
) : (
Object.entries(filteredGroups).map(([outboundNo, group]) => {
const isExpanded = expandedOrders.has(outboundNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
filteredRows.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={outboundNo}>
{/* 마스터 행 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(outboundNo)) {
setClosingOrders((prev) => new Set(prev).add(outboundNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(outboundNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(outboundNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(outboundNo));
}
}}
onDoubleClick={() => openEditModal(group.details[0])}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 출고번호 */}
<TableCell className="font-mono whitespace-nowrap">
{outboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "outbound_type": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{resolveCat("outbound_type", master.outbound_type) || "-"}
</Badge>
</TableCell>
);
case "outbound_date": return (
<TableCell key={col.key} className="whitespace-nowrap text-[13px]">
{master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "customer_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.customer_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "outbound_status": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(master.outbound_status))}>
{master.outbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground">
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(outboundNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell />
{visibleDetailCols.map((col) => {
const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => {
let v = (d as any)[col.key];
if (col.key === "source_type") v = SOURCE_TYPE_LABEL[v] || v;
return v;
}).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
</TableRow>
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(outboundNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={`${row.id}-${detailIdx}`}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_type": return <TableCell key={col.key} className="text-[13px]">{row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}</TableCell>;
case "item_code": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_code || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "specification": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.specification || ""}</TableCell>;
case "outbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
})}
</React.Fragment>
}}
onDoubleClick={() => openEditModal(row as any)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="font-mono whitespace-nowrap text-[13px]">{row.outbound_number || ""}</TableCell>
<TableCell className="text-[13px]">
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(row.outbound_type))}>
{row.outbound_type || "-"}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-[13px]">
{row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""}
</TableCell>
<TableCell className="text-[13px] truncate max-w-[120px]"><span className="block truncate">{row.reference_number || ""}</span></TableCell>
<TableCell className="text-[13px]">{row.source_type || ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[110px]"><span className="block truncate">{row.customer_name || ""}</span></TableCell>
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
<TableCell className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[100px]"><span className="block truncate">{row.warehouse_name || row.warehouse_code || ""}</span></TableCell>
<TableCell className="text-[13px]">
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(row.outbound_status))}>
{row.outbound_status || "-"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.remark || row.memo || ""}</span></TableCell>
</TableRow>
);
})
)}
@@ -43,7 +43,6 @@ import {
X,
Save,
ChevronRight,
ChevronDown,
ChevronLeft,
ChevronsLeft,
ChevronsRight,
@@ -64,6 +63,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea
import {
getReceivingList,
createReceiving,
updateReceiving,
deleteReceiving,
generateReceivingNumber,
getReceivingWarehouses,
@@ -95,41 +95,8 @@ const GRID_COLUMNS = [
{ key: "remark", label: "비고" },
];
// 마스터 헤더 레이아웃 (입고번호 뒤, 디테일 7컬럼 위에 colSpan으로 맵핑)
const MASTER_BODY_LAYOUT = [
{ key: "inbound_type", label: "입고유형", colSpan: 1 },
{ key: "inbound_date", label: "입고일", colSpan: 1 },
{ key: "reference_number", label: "참조번호", colSpan: 1 },
{ key: "supplier_name", label: "공급처", colSpan: 1 },
{ key: "warehouse_name", label: "창고", colSpan: 1 },
{ key: "inbound_status", label: "입고상태", colSpan: 1 },
{ key: "memo", label: "비고", colSpan: 1 },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "source_table", label: "출처" },
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "spec", label: "규격" },
{ key: "inbound_qty", label: "입고수량" },
{ key: "unit_price", label: "단가" },
{ key: "total_amount", label: "금액" },
];
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_table",
item_number: "item_number",
item_name: "item_name",
spec: "spec",
inbound_qty: "inbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16
const TOTAL_COLS = 16;
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -287,30 +254,6 @@ interface SelectedSourceItem {
export default function ReceivingPage() {
const ts = useTableSettings("c16-receiving", "inbound_mng", GRID_COLUMNS);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<InboundItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -319,9 +262,7 @@ export default function ReceivingPage() {
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 마스터-디테일 그룹 테이블
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
// 헤더 필터 & 정렬
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
@@ -338,6 +279,10 @@ export default function ReceivingPage() {
const [selectedItems, setSelectedItems] = useState<SelectedSourceItem[]>([]);
const [saving, setSaving] = useState(false);
// 수정 모드
const [editMode, setEditMode] = useState(false);
const [editItemIds, setEditItemIds] = useState<string[]>([]);
// 소스 데이터
const [sourceKeyword, setSourceKeyword] = useState("");
const [sourceLoading, setSourceLoading] = useState(false);
@@ -377,7 +322,7 @@ export default function ReceivingPage() {
Promise.all(
["material", "unit"].map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_29`);
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_16`);
const items = flatten(res.data?.data || []);
map[col] = {};
for (const item of items) map[col][item.code] = item.label;
@@ -434,124 +379,56 @@ export default function ReceivingPage() {
})();
}, []);
// 필터 + 정렬 적용된 데이터 -> 그룹핑
const filteredGroups = useMemo(() => {
// 1차: inbound_number 기준 그룹핑
const allGroups: Record<string, { master: InboundItem; details: InboundItem[] }> = {};
for (const row of data) {
const key = row.inbound_number || "_no_inbound";
if (!allGroups[key]) {
allGroups[key] = { master: row, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
let raw = (group.master as any)?.[colKey] ?? "";
// 입고유형은 코드→라벨 변환된 값으로 비교
if (colKey === "inbound_type") raw = resolveInboundType(String(raw));
return values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([inboundNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
let cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
if (colKey === "source_table") cellVal = SOURCE_TABLE_LABEL[cellVal] || cellVal;
return values.has(cellVal);
})
);
return [inboundNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
entries.sort(([, a], [, b]) => {
let av: any = (a.master as any)?.[key] ?? "";
let bv: any = (b.master as any)?.[key] ?? "";
if (key === "inbound_type") { av = resolveInboundType(String(av)); bv = resolveInboundType(String(bv)); }
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
} else {
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [data, headerFilters, sortState]);
// 마스터 컬럼별 고유값 (마스터 헤더 필터용)
const masterUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
const seenMasters = new Map<string, InboundItem>();
data.forEach((row) => {
if (row.inbound_number && !seenMasters.has(row.inbound_number)) {
seenMasters.set(row.inbound_number, row);
}
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "inbound_number", label: "입고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) {
const values = new Set<string>();
masters.forEach((m) => {
let val = (m as any)?.[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "inbound_type") val = resolveInboundType(String(val));
values.add(String(val));
}
});
result[col.key] = Array.from(values).sort();
}
return result;
// 플랫 행 생성 (입고유형 코드→라벨 변환, source_table→한글 등)
const flatRows = useMemo(() => {
return data.map((row) => ({
...row,
inbound_type: resolveInboundType(row.inbound_type),
source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "",
}));
}, [data]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
// 컬럼별 고유값 (헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
for (const col of GRID_COLUMNS) {
const values = new Set<string>();
data.forEach((row) => {
let val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "source_table") val = SOURCE_TABLE_LABEL[val] || val;
values.add(String(val));
}
flatRows.forEach((row) => {
const val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
}, [flatRows]);
// 필터 + 정렬 적용된 플랫 데이터
const filteredRows = useMemo(() => {
let rows = [...flatRows];
// 1차: 헤더 필터 적용
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
rows = rows.filter((row) => {
const cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
return values.has(cellVal);
});
}
// 2차: 정렬
if (sortState) {
const { key, direction } = sortState;
rows.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}
return rows;
}, [flatRows, headerFilters, sortState]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
@@ -633,6 +510,8 @@ export default function ReceivingPage() {
const openRegisterModal = async () => {
const defaultType = "구매입고";
setEditMode(false);
setEditItemIds([]);
setModalInboundType(defaultType);
setModalInboundDate(new Date().toISOString().split("T")[0]);
setModalWarehouse("");
@@ -661,6 +540,51 @@ export default function ReceivingPage() {
}
};
// 수정 모달 열기
const openEditModal = (row: InboundItem) => {
const inNo = row.inbound_number;
const grouped = data.filter((d) => d.inbound_number === inNo);
const first = grouped[0] || row;
setEditMode(true);
setEditItemIds(grouped.map((g) => g.id));
setModalInboundNo(inNo);
setModalInboundType(first.inbound_type || "구매입고");
setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : "");
setModalWarehouse(first.warehouse_code || "");
setModalLocation(first.location_code || "");
setModalInspector((first as any).inspector || "");
setModalManager((first as any).manager || "");
setModalMemo(first.memo || "");
setSelectedItems(
grouped.map((g) => ({
key: g.id,
inbound_type: g.inbound_type || "",
reference_number: g.reference_number || "",
supplier_code: (g as any).supplier_code || "",
supplier_name: g.supplier_name || "",
item_number: g.item_number || "",
item_name: g.item_name || "",
spec: g.spec || "",
material: (g as any).material || "",
unit: (g as any).unit || "",
inbound_qty: Number(g.inbound_qty) || 0,
unit_price: Number(g.unit_price) || 0,
total_amount: Number(g.total_amount) || 0,
source_table: (g as any).source_table || "",
source_id: (g as any).source_id || "",
}))
);
setSourceKeyword("");
setPurchaseOrders([]);
setShipments([]);
setItems([]);
setSourcePage(1);
setSourceTotalCount(0);
setIsModalOpen(true);
loadSourceData(first.inbound_type || "구매입고", undefined, 1);
};
// 검색 버튼 클릭 시
const searchSourceData = useCallback(async () => {
setSourcePage(1);
@@ -811,41 +735,97 @@ export default function ReceivingPage() {
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: selectedItems.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
});
if (editMode) {
// 기존 item 수정 + 삭제된 item 삭제 + 새 item 추가
const currentKeys = new Set(selectedItems.map((i) => i.key));
const toDelete = editItemIds.filter((id) => !currentKeys.has(id));
const toUpdate = selectedItems.filter((i) => editItemIds.includes(i.key));
const toCreate = selectedItems.filter((i) => !editItemIds.includes(i.key));
if (res.success) {
alert(res.message || "입고 등록 완료");
await Promise.all([
...toDelete.map((id) => deleteReceiving(id)),
...toUpdate.map((item) =>
updateReceiving(item.key, {
inbound_date: modalInboundDate,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
warehouse_code: modalWarehouse || undefined,
location_code: modalLocation || undefined,
memo: modalMemo || undefined,
} as any)
),
...(toCreate.length > 0
? [createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: toCreate.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
})]
: []),
]);
toast.success("입고 정보를 수정했어요");
setIsModalOpen(false);
fetchList();
} else {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: selectedItems.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
});
if (res.success) {
toast.success(res.message || "입고 등록 완료");
setIsModalOpen(false);
fetchList();
}
}
} catch {
alert("입고 등록 중 오류가 발생했습니다.");
} catch (err: any) {
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "입고 등록 중 오류가 발생했습니다.");
toast.error(msg);
} finally {
setSaving(false);
}
@@ -897,13 +877,13 @@ export default function ReceivingPage() {
}
/>
{/* 입고 목록 테이블 (마스터-디테일 그룹) */}
{/* 입고 목록 테이블 (플랫 리스트) */}
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
<div className="flex items-center justify-between border-b bg-muted/50 px-4 py-2.5">
<div className="flex items-center gap-2">
<h3 className="text-[13px] font-bold"> </h3>
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
{Object.keys(filteredGroups).length}
{filteredRows.length}
</span>
</div>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
@@ -912,78 +892,68 @@ export default function ReceivingPage() {
</div>
<div className="h-[calc(100%-44px)] overflow-auto">
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<Table noWrapper style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 입고번호 (별도 컬럼) */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("inbound_number")}>
<span className="truncate"></span>
{sortState?.key === "inbound_number" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["inbound_number"] || []).length > 0 && (
<HeaderFilterPopover
colKey="inbound_number" colLabel="입고번호"
uniqueValues={masterUniqueValues["inbound_number"] || []}
filterValues={headerFilters["inbound_number"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
{GRID_COLUMNS.map((col) => {
const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key);
return (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none", isRight && "text-right")}>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(columnUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={columnUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
@@ -993,7 +963,7 @@ export default function ReceivingPage() {
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredGroups).length === 0 ? (
) : filteredRows.length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
@@ -1003,212 +973,59 @@ export default function ReceivingPage() {
</TableCell>
</TableRow>
) : (
Object.entries(filteredGroups).map(([inboundNo, group]) => {
const isExpanded = expandedOrders.has(inboundNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
filteredRows.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={inboundNo}>
{/* 마스터 행 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(inboundNo)) {
setClosingOrders((prev) => new Set(prev).add(inboundNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(inboundNo));
}
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row as any)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 입고번호 */}
<TableCell className="font-mono whitespace-nowrap">
{inboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "inbound_type": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getTypeVariant(resolveInboundType(master.inbound_type))} className="text-[11px]">
{resolveInboundType(master.inbound_type)}
</Badge>
</TableCell>
);
case "inbound_date": return (
<TableCell key={col.key} className="text-[13px] whitespace-nowrap">
{master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "supplier_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.supplier_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "inbound_status": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getStatusVariant(master.inbound_status)} className="text-[11px]">
{master.inbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground text-[13px]">
<span className="block truncate max-w-[110px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(inboundNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell />
{visibleDetailCols.map((col) => {
const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => {
let v = (d as any)[col.key];
if (col.key === "source_table") v = SOURCE_TABLE_LABEL[v] || v;
return v;
}).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
</TableRow>
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(inboundNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={`${row.id}-${detailIdx}`}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_table": return <TableCell key={col.key} className="text-[13px]">{row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}</TableCell>;
case "item_number": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_number || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[160px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "spec": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>;
case "inbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
);
})}
</React.Fragment>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="font-mono whitespace-nowrap text-[13px]">{row.inbound_number || ""}</TableCell>
<TableCell className="text-[13px]">
<Badge variant={getTypeVariant(row.inbound_type)} className="text-[11px]">
{row.inbound_type || "-"}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-[13px]">
{row.inbound_date ? new Date(row.inbound_date).toLocaleDateString("ko-KR") : ""}
</TableCell>
<TableCell className="text-[13px] truncate max-w-[120px]"><span className="block truncate">{row.reference_number || ""}</span></TableCell>
<TableCell className="text-[13px]">{row.source_type || ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[110px]"><span className="block truncate">{row.supplier_name || ""}</span></TableCell>
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
<TableCell className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[100px]"><span className="block truncate">{row.warehouse_name || (row as any).warehouse_code || ""}</span></TableCell>
<TableCell className="text-[13px]">
<Badge variant={getStatusVariant(row.inbound_status)} className="text-[11px]">
{row.inbound_status || "-"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.remark || row.memo || ""}</span></TableCell>
</TableRow>
);
})
)}
@@ -1221,9 +1038,9 @@ export default function ReceivingPage() {
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="sm:max-w-[1600px] w-[95vw] h-[90vh] p-0 flex flex-col overflow-hidden">
<DialogHeader className="shrink-0 px-6 pt-4 pb-2 border-b">
<DialogTitle> </DialogTitle>
<DialogTitle>{editMode ? "입고 수정" : "입고 등록"}</DialogTitle>
<DialogDescription>
, .
{editMode ? "입고 정보를 수정해 주세요." : "입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요."}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col">
@@ -1053,14 +1053,14 @@ export default function ProductionPlanManagementPage() {
return (
<Table style={{ minWidth: "900px" }}>
<TableHeader className="sticky top-0 z-10">
<TableHeader className="sticky top-0 z-20">
<TableRow className="bg-muted hover:bg-muted">
<TableHead style={{ width: "30px", minWidth: "30px" }}>
<TableHead className="sticky left-0 z-20 bg-muted" style={{ width: "30px", minWidth: "30px" }}>
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead style={{ width: "40px", minWidth: "40px" }} />
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="sticky left-[30px] z-20 bg-muted" style={{ width: "40px", minWidth: "40px" }} />
<TableHead className="sticky left-[70px] z-20 bg-muted text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={{ width: "140px", minWidth: "140px" }}></TableHead>
<TableHead className="sticky left-[210px] z-20 bg-muted text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground border-r" style={{ width: "140px", minWidth: "140px" }}></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} style={{ ...ts.thStyle(col.key), minWidth: "80px" }} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
{col.label}
@@ -1073,9 +1073,9 @@ export default function ProductionPlanManagementPage() {
if (item._isGroupSummary) {
return (
<TableRow key={`summary-${rowIdx}`} className="bg-muted/60 font-semibold border-t border-primary/20">
<TableCell />
<TableCell />
<TableCell colSpan={2} />
<TableCell className="sticky left-0 z-10 bg-muted/60" />
<TableCell className="sticky left-[30px] z-10 bg-muted/60" />
<TableCell colSpan={2} className="sticky left-[70px] z-10 bg-muted/60 border-r" />
{ts.visibleColumns.map((col) => {
const v = (item as any)[col.key];
return (
@@ -1090,14 +1090,14 @@ export default function ProductionPlanManagementPage() {
return (
<React.Fragment key={item.item_code || rowIdx}>
<TableRow className={cn("cursor-pointer border-t-2 border-t-primary/30 bg-primary/5 font-semibold hover:bg-primary/10", selectedItemGroups.has(item.item_code) && "bg-primary/10")}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<TableCell className="sticky left-0 z-10 bg-background text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selectedItemGroups.has(item.item_code)} onCheckedChange={() => toggleItemGroupSelect(item.item_code)} className="h-4 w-4" />
</TableCell>
<TableCell className="text-center" onClick={() => toggleItemExpand(item.item_code)}>
<TableCell className="sticky left-[30px] z-10 bg-background text-center" onClick={() => toggleItemExpand(item.item_code)}>
<ChevronRight className={cn("h-4 w-4 transition-transform duration-200", expandedItems.has(item.item_code) && "rotate-90")} />
</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
<TableCell className="sticky left-[70px] z-10 bg-background text-[13px] text-primary truncate" style={{ width: "140px", minWidth: "140px" }} onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell className="sticky left-[210px] z-10 bg-background text-[13px] text-primary truncate border-r" style={{ width: "140px", minWidth: "140px" }} onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
{ts.visibleColumns.map((col) => renderGroupCell(col, item))}
</TableRow>
{expandedItems.has(item.item_code) && item.orders?.map((detail: any) => {
@@ -1107,9 +1107,9 @@ export default function ProductionPlanManagementPage() {
}
return (
<TableRow key={detail.id || detail.order_no} className="cursor-pointer hover:bg-muted/50">
<TableCell />
<TableCell />
<TableCell colSpan={2} className="pl-10">
<TableCell className="sticky left-0 z-10 bg-card" />
<TableCell className="sticky left-[30px] z-10 bg-card" />
<TableCell colSpan={2} className="sticky left-[70px] z-10 bg-card pl-10 border-r">
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{detail.order_no}</span>
@@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Package, ChevronDown,
ClipboardList, Pencil, Search, X, Package,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2, GripVertical,
} from "lucide-react";
@@ -88,18 +88,18 @@ const GRID_COLUMNS_CONFIG = [
];
const MODAL_DETAIL_COLUMNS = [
{ key: "item_code", label: "품번", width: "w-[120px]" },
{ key: "item_name", label: "품명", width: "w-[120px]" },
{ key: "supplier", label: "공급업체", width: "w-[150px]" },
{ key: "spec", label: "규격", width: "w-[80px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "order_qty", label: "발주수량", width: "w-[90px]" },
{ key: "received_qty", label: "입고수량", width: "w-[90px]" },
{ key: "remain_qty", label: "잔량", width: "w-[80px]" },
{ key: "unit_price", label: "단가", width: "w-[100px]" },
{ key: "amount", label: "금액", width: "w-[100px]" },
{ key: "due_date", label: "납기일", width: "w-[160px]" },
{ key: "memo", label: "메모", width: "w-[120px]" },
{ key: "item_code", label: "품번", width: "min-w-[120px]" },
{ key: "item_name", label: "품명", width: "min-w-[150px]" },
{ key: "supplier", label: "공급업체", width: "min-w-[150px]" },
{ key: "spec", label: "규격", width: "min-w-[80px]" },
{ key: "unit", label: "단위", width: "min-w-[90px]" },
{ key: "order_qty", label: "발주수량", width: "min-w-[90px]" },
{ key: "received_qty", label: "입고수량", width: "min-w-[90px]" },
{ key: "remain_qty", label: "잔량", width: "min-w-[80px]" },
{ key: "unit_price", label: "단가", width: "min-w-[100px]" },
{ key: "amount", label: "금액", width: "min-w-[100px]" },
{ key: "due_date", label: "납기일", width: "min-w-[160px]" },
{ key: "memo", label: "메모", width: "min-w-[120px]" },
];
const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16";
@@ -162,7 +162,6 @@ export default function PurchaseOrderPage() {
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
// 테이블 설정
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
@@ -238,7 +237,7 @@ export default function PurchaseOrderPage() {
);
try {
const suppRes = await apiClient.post(`/table-management/tables/supplier_mng/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 5000, autoFilter: true,
});
const supps = suppRes.data?.data?.data || suppRes.data?.data?.rows || [];
optMap["supplier_code"] = supps.map((s: any) => ({
@@ -248,7 +247,7 @@ export default function PurchaseOrderPage() {
} catch { /* skip */ }
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 5000, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
optMap["manager"] = users.map((u: any) => ({
@@ -294,7 +293,7 @@ export default function PurchaseOrderPage() {
}
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: detailFilters.length > 0 ? { enabled: true, filters: detailFilters } : undefined,
autoFilter: true,
sort: { columnName: "purchase_no", order: "desc" },
@@ -372,20 +371,6 @@ export default function PurchaseOrderPage() {
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// purchase_no 기준 그룹핑
const orderGroups = useMemo(() => {
const map: Record<string, { master: any; details: any[] }> = {};
for (const row of orders) {
const key = row.purchase_no || row.id;
if (!map[key]) {
map[key] = {
master: { purchase_no: row.purchase_no, order_date: row.order_date, supplier_name: row.supplier_name, status: row.status, memo: row.memo },
details: [],
};
}
map[key].details.push(row);
}
return map;
}, [orders]);
const getCategoryLabel = (col: string, code: string) => {
if (!code) return "";
@@ -553,7 +538,7 @@ export default function PurchaseOrderPage() {
}
};
// 품목 검색
// 품목 검색 (수주관리와 동일한 서버 페이징 방식)
const searchItems = async (page?: number, size?: number) => {
const p = page ?? itemPage;
const s = size ?? itemPageSize;
@@ -563,25 +548,24 @@ export default function PurchaseOrderPage() {
if (itemSearchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
}
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
const divValues = [itemSearchDivision];
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
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)));
const rows = resData?.data || resData?.rows || [];
const serverTotal = resData?.total || resData?.totalCount || rows.length;
setItemSearchResults(rows);
setItemTotal(serverTotal);
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
@@ -623,7 +607,7 @@ export default function PurchaseOrderPage() {
try {
const itemIds = selected.map((item) => item.item_number || item.id);
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: {
enabled: true,
filters: [
@@ -686,7 +670,7 @@ export default function PurchaseOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
autoFilter: true,
});
@@ -708,7 +692,7 @@ export default function PurchaseOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: supplierCode },
{ columnName: "item_id", operator: "in", value: itemCodes },
@@ -811,125 +795,83 @@ export default function PurchaseOrderPage() {
</div>
</div>
{/* 데이터 테이블 — 아코디언 그룹핑 */}
{/* 데이터 테이블 — 플랫 리스트 */}
<div className="flex-1 overflow-auto border rounded-lg bg-card">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : Object.keys(orderGroups).length === 0 ? (
) : orders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Package className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
(() => {
const MASTER_KEYS = new Set(["purchase_no", "order_date", "supplier_name", "status", "memo"]);
const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]);
// ts.visibleColumns 순서를 따르되, 마스터/디테일 컬럼을 분리
// 고정 컬럼(품목수)은 마스터 선행 컬럼 뒤에 배치
const leadingMaster: typeof ts.visibleColumns = [];
const detailCols: typeof ts.visibleColumns = [];
const trailingMaster: typeof ts.visibleColumns = [];
let passedFirstDetail = false;
for (const col of ts.visibleColumns) {
if (MASTER_KEYS.has(col.key)) {
if (passedFirstDetail) trailingMaster.push(col);
else leadingMaster.push(col);
} else {
passedFirstDetail = true;
detailCols.push(col);
}
}
const renderDetailCell = (row: any, key: string) => {
const renderCell = (row: any, key: string) => {
const val = row[key];
if (key === "status") return val ? <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[val] || "")}>{val}</span> : "-";
if (numCols.has(key)) return <span className="font-mono">{val ? Number(val).toLocaleString() : "0"}</span>;
return val || "-";
if (key === "order_date" || key === "due_date") return val ? new Date(val).toLocaleDateString("ko-KR") : "-";
if (numCols.has(key)) return <span className="font-mono">{val ? Number(val).toLocaleString() : ""}</span>;
return val || "";
};
const renderMasterHead = (col: { key: string; label: string }) => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", col.key === "status" && "text-center")}>
{col.label}
</TableHead>
);
const renderMasterCell = (col: { key: string }, m: any, purchaseNo: string) => {
if (col.key === "purchase_no") return <TableCell key={col.key} className="text-sm font-semibold text-primary">{purchaseNo}</TableCell>;
if (col.key === "order_date") return <TableCell key={col.key} className="text-sm">{m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}</TableCell>;
if (col.key === "supplier_name") return <TableCell key={col.key} className="text-sm">{m.supplier_name || "-"}</TableCell>;
if (col.key === "status") return <TableCell key={col.key} className="text-center">{m.status && <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[m.status] || "")}>{m.status}</span>}</TableCell>;
if (col.key === "memo") return <TableCell key={col.key} className="text-sm text-muted-foreground max-w-[120px] truncate">{m.memo || ""}</TableCell>;
return <TableCell key={col.key} />;
};
return (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-8" />
<TableHead className="w-10" />
{leadingMaster.map(renderMasterHead)}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground text-center"></TableHead>
{detailCols.map(col => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", numCols.has(col.key) && "text-right")}>
{col.label}{col.key === "order_qty" || col.key === "amount" ? " 합계" : ""}
<TableHead
className="w-10 text-center cursor-pointer"
onClick={() => {
const allIds = orders.map((r) => r.id);
const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allIds);
}}
>
<Checkbox
checked={orders.length > 0 && orders.every((r) => checkedIds.includes(r.id))}
onCheckedChange={() => {}}
/>
</TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", numCols.has(col.key) && "text-right", col.key === "status" && "text-center")}>
{col.label}
</TableHead>
))}
{trailingMaster.map(renderMasterHead)}
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(orderGroups).map(([purchaseNo, group]) => {
const isExpanded = expandedOrders.has(purchaseNo);
const detailIds = group.details.map(d => d.id);
const allChecked = detailIds.length > 0 && detailIds.every(id => checkedIds.includes(id));
const someChecked = detailIds.some(id => checkedIds.includes(id));
const m = group.master;
const totalQty = group.details.reduce((s, d) => s + (Number(d.order_qty) || 0), 0);
const totalAmt = group.details.reduce((s, d) => s + (Number(d.amount) || 0), 0);
{orders.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={purchaseNo}>
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn("cursor-pointer border-l-[3px] border-l-transparent font-semibold", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedOrders(prev => { const next = new Set(prev); if (next.has(purchaseNo)) next.delete(purchaseNo); else next.add(purchaseNo); return next; })}
onDoubleClick={() => openEditModal(purchaseNo)}
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row.purchase_no)}
>
<TableCell
className="text-center"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
{ts.visibleColumns.map((col) => (
<TableCell key={col.key} className={cn("text-[13px]", numCols.has(col.key) && "text-right font-mono", col.key === "status" && "text-center")}>
{renderCell(row, col.key)}
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !detailIds.includes(id)) : [...new Set([...prev, ...detailIds])]); }}>
<Checkbox checked={allChecked} data-state={someChecked && !allChecked ? "indeterminate" : undefined} onCheckedChange={() => {}} />
</TableCell>
{leadingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
<TableCell className="text-sm text-center"><Badge variant="secondary" className="text-[10px]">{group.details.length}</Badge></TableCell>
{detailCols.map(col => (
<TableCell key={col.key} className={cn("text-sm", numCols.has(col.key) && "text-right font-mono")}>
{col.key === "order_qty" ? totalQty.toLocaleString()
: col.key === "amount" ? totalAmt.toLocaleString()
: ""}
</TableCell>
))}
{trailingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
</TableRow>
{isExpanded && group.details.map((row) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell className="text-center" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id]); }}>
<Checkbox checked={checkedIds.includes(row.id)} onCheckedChange={() => {}} />
</TableCell>
{leadingMaster.map(col => <TableCell key={col.key} />)}
<TableCell />
{detailCols.map(col => (
<TableCell key={col.key} className={cn(numCols.has(col.key) && "text-right")}>
{renderDetailCell(row, col.key)}
</TableCell>
))}
{trailingMaster.map(col => <TableCell key={col.key} />)}
</TableRow>
))}
</React.Fragment>
</TableRow>
);
})}
</TableBody>
@@ -938,7 +880,7 @@ export default function PurchaseOrderPage() {
})()
)}
<div className="px-4 py-2 border-t text-xs text-muted-foreground">
{Object.keys(orderGroups).length} ( ) / {orders.length} ( )
{orders.length}
</div>
</div>
@@ -1094,7 +1036,7 @@ export default function PurchaseOrderPage() {
) : (
<div className="border rounded-lg overflow-x-auto">
<DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
<Table className="table-fixed">
<Table>
<TableHeader className="sticky top-0 z-10">
<SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableRow className="bg-muted hover:bg-muted">
@@ -1118,12 +1060,12 @@ export default function PurchaseOrderPage() {
{visibleModalColumns.map((col) => {
switch (col.key) {
case "item_code":
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
return <TableCell key={col.key} className="text-[13px] font-mono whitespace-nowrap"><span title={row.item_code}>{row.item_code}</span></TableCell>;
case "item_name":
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
return <TableCell key={col.key} className="text-[13px] whitespace-nowrap"><span title={row.item_name}>{row.item_name}</span></TableCell>;
case "supplier":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[150px]">
{isReadOnly ? (
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
) : (
@@ -1133,7 +1075,7 @@ export default function PurchaseOrderPage() {
updateDetailRow(idx, "supplier_code", v);
updateDetailRow(idx, "supplier_name", name);
}}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectTrigger className="h-8 text-xs w-full"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
@@ -1149,11 +1091,11 @@ export default function PurchaseOrderPage() {
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
case "order_qty":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[110px]">
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono w-full" />
)}
</TableCell>
);
@@ -1163,11 +1105,11 @@ export default function PurchaseOrderPage() {
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":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[120px]">
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{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" />
<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" />
)}
</TableCell>
);
@@ -1175,21 +1117,21 @@ export default function PurchaseOrderPage() {
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
case "due_date":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[160px]">
{isReadOnly ? (
<span className="text-xs">{row.due_date}</span>
) : (
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs w-full" />
)}
</TableCell>
);
case "memo":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[140px]">
{isReadOnly ? (
<span className="text-xs">{row.memo}</span>
) : (
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs w-full" />
)}
</TableCell>
);
@@ -424,9 +424,10 @@ export default function ItemInspectionInfoPage() {
case "inspection_type": return (
<TableCell key={col.key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>
))}
{group.types.map((t: string) => {
const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t;
return <Badge key={t} variant="secondary" className="text-[10px]">{label}</Badge>;
})}
</div>
</TableCell>
);
@@ -494,7 +495,7 @@ export default function ItemInspectionInfoPage() {
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
)}
>
{type}
{inspTypeCatOptions.find((o) => o.code === type)?.label || type}
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
@@ -624,6 +624,15 @@ export default function SalesOrderPage() {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
// 코드 또는 라벨이 저장된 경우 모두 조회하기 위해 in 연산자 사용
const divValues = [itemSearchDivision];
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const partnerId = masterForm.partner_id;
@@ -632,7 +641,7 @@ export default function SalesOrderPage() {
if (isCustomerPrice && partnerId) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
autoFilter: true,
});
@@ -642,31 +651,22 @@ export default function SalesOrderPage() {
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
let allRows = resData?.data || resData?.rows || [];
let rows = resData?.data || resData?.rows || [];
const serverTotal = resData?.total || resData?.totalCount || rows.length;
// 거래처우선일 때 연결된 품목만 표시
// 거래처우선일 때 연결된 품목만 표시 (클라이언트 필터)
if (customerItemIds) {
allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
rows = rows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
}
// 관리품목 필터 (코드/라벨 혼재 대응)
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)));
setItemSearchResults(rows);
setItemTotal(serverTotal);
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
@@ -1320,44 +1320,44 @@ export default function SalesOrderPage() {
<Table noWrapper className="min-w-max w-full">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="w-28 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-32 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-32 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">/</TableHead>
<TableHead className="min-w-[40px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[120px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[110px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">/</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
<TableCell className="text-center font-mono text-[13px] text-muted-foreground/50">{idx + 1}</TableCell>
<TableCell className="max-w-[112px]">
<span className="block truncate font-mono text-[13px]" title={row.part_code}>{row.part_code}</span>
<TableCell className="whitespace-nowrap">
<span className="font-mono text-[13px]" title={row.part_code}>{row.part_code}</span>
</TableCell>
<TableCell className="max-w-[128px]">
<span className="block truncate text-[13px]" title={row.part_name}>{row.part_name}</span>
<TableCell className="whitespace-nowrap">
<span className="text-[13px]" title={row.part_name}>{row.part_name}</span>
</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.spec}</TableCell>
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.material}</TableCell>
<TableCell>
<Input
value={row.packing_material || ""}
onChange={(e) => updateDetailRow(idx, "packing_material", e.target.value)}
placeholder="포장재"
className="h-8 text-xs"
className="h-8 text-xs w-full"
/>
</TableCell>
<TableCell>
<Select value={row.unit || ""} onValueChange={(v) => updateDetailRow(idx, "unit", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="단위" /></SelectTrigger>
<SelectTrigger className="h-8 text-xs w-full"><SelectValue placeholder="단위" /></SelectTrigger>
<SelectContent>
{(categoryOptions["item_unit"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
@@ -1371,7 +1371,7 @@ export default function SalesOrderPage() {
min="1"
value={row.qty || "1"}
onChange={(e) => updateDetailRow(idx, "qty", e.target.value)}
className="h-8 text-xs text-right font-mono w-16"
className="h-8 text-xs text-right font-mono w-full"
/>
</TableCell>
<TableCell>
@@ -1380,7 +1380,7 @@ export default function SalesOrderPage() {
min="0"
value={row.pack_qty || "0"}
onChange={(e) => updateDetailRow(idx, "pack_qty", e.target.value)}
className="h-8 text-xs text-right font-mono w-16"
className="h-8 text-xs text-right font-mono w-full"
/>
</TableCell>
<TableCell>
@@ -1388,10 +1388,10 @@ export default function SalesOrderPage() {
value={formatNumber(row.unit_price || "")}
onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))}
readOnly={!allowPriceEdit}
className={cn("h-8 text-xs text-right font-mono w-20", !allowPriceEdit && "bg-muted cursor-not-allowed")}
className={cn("h-8 text-xs text-right font-mono w-full", !allowPriceEdit && "bg-muted cursor-not-allowed")}
/>
</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">
<TableCell className="text-right font-mono text-[13px] font-semibold whitespace-nowrap">
{row.amount ? Number(row.amount).toLocaleString() : "0"}
</TableCell>
<TableCell>
@@ -1399,7 +1399,7 @@ export default function SalesOrderPage() {
type="date"
value={row.due_date || ""}
onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)}
className="h-8 text-xs"
className="h-8 text-xs w-full"
/>
</TableCell>
<TableCell className="text-center">
@@ -1474,15 +1474,7 @@ export default function SalesOrderPage() {
onKeyDown={(e) => e.key === "Enter" && triggerNewSearch()}
className="h-9 flex-1"
/>
<Select value={itemSearchDivision} onValueChange={setItemSearchDivision}>
<SelectTrigger className="h-9 w-[130px]"><SelectValue placeholder="관리품목" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["item_division"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="h-9 px-3 flex items-center rounded-md border border-input bg-muted text-xs font-medium text-muted-foreground whitespace-nowrap"></div>
<Button size="sm" onClick={triggerNewSearch} disabled={itemSearchLoading} className="h-9">
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /></>}
</Button>
@@ -38,7 +38,6 @@ import {
Save,
ChevronRight,
ChevronLeft,
ChevronDown,
ChevronsLeft,
ChevronsRight,
Inbox,
@@ -116,41 +115,8 @@ const GRID_COLUMNS = [
{ key: "remark", label: "비고" },
];
// 마스터 헤더 레이아웃 (출고번호 뒤)
const MASTER_BODY_LAYOUT = [
{ key: "outbound_type", label: "출고유형", colSpan: 1 },
{ key: "outbound_date", label: "출고일", colSpan: 1 },
{ key: "reference_number", label: "참조번호", colSpan: 1 },
{ key: "customer_name", label: "거래처", colSpan: 1 },
{ key: "warehouse_name", label: "창고", colSpan: 1 },
{ key: "outbound_status", label: "출고상태", colSpan: 1 },
{ key: "memo", label: "비고", colSpan: 1 },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "source_type", label: "출처" },
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "specification", label: "규격" },
{ key: "outbound_qty", label: "출고수량" },
{ key: "unit_price", label: "단가" },
{ key: "total_amount", label: "금액" },
];
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["outbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_type",
item_number: "item_code",
item_name: "item_name",
spec: "specification",
outbound_qty: "outbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16
const TOTAL_COLS = 16;
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -258,30 +224,6 @@ interface SelectedSourceItem {
export default function OutboundPage() {
const ts = useTableSettings("c16-outbound", "outbound_mng", GRID_COLUMNS);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<OutboundItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -290,9 +232,7 @@ export default function OutboundPage() {
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 마스터-디테일 그룹 테이블 state
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
// 헤더 필터 & 정렬
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
@@ -403,121 +343,59 @@ export default function OutboundPage() {
})();
}, []);
// --- 마스터-디테일 그룹핑, 필터, 정렬 ---
// 플랫 행 생성 (출고유형 코드→라벨 변환, source_type→한글 등)
const flatRows = useMemo(() => {
return data.map((row) => ({
...row,
_raw_outbound_type: row.outbound_type,
outbound_type: resolveCat("outbound_type", row.outbound_type) || row.outbound_type || "",
source_type: row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "",
item_number: row.item_code || (row as any).item_number || "",
spec: row.specification || (row as any).spec || "",
}));
}, [data, resolveCat]);
// outbound_number 기준 그룹핑 + 필터 + 정렬
const filteredGroups = useMemo(() => {
// 1차: outbound_number 기준 그룹핑
const allGroups: Record<string, { master: OutboundItem; details: OutboundItem[] }> = {};
for (const row of data) {
const key = row.outbound_number || "_no_number";
if (!allGroups[key]) {
allGroups[key] = { master: row, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
const raw = (group.master as any)?.[colKey] ?? "";
return values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([outNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
let cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
if (colKey === "source_type") cellVal = SOURCE_TYPE_LABEL[cellVal] || cellVal;
return values.has(cellVal);
})
);
return [outNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
entries.sort(([, a], [, b]) => {
const av = (a.master as any)?.[key] ?? "";
const bv = (b.master as any)?.[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
} else {
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
let av: any = (a as any)[key] ?? "";
let bv: any = (b as any)[key] ?? "";
if (key === "source_type") { av = SOURCE_TYPE_LABEL[av] || av; bv = SOURCE_TYPE_LABEL[bv] || bv; }
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [data, headerFilters, sortState]);
// 마스터 컬럼별 고유값 (헤더 필터용)
const masterUniqueValues = useMemo(() => {
// 컬럼별 고유값 (헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
const seenMasters = new Map<string, OutboundItem>();
data.forEach((row) => {
if (row.outbound_number && !seenMasters.has(row.outbound_number)) {
seenMasters.set(row.outbound_number, row);
}
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "outbound_number", label: "출고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) {
for (const col of GRID_COLUMNS) {
const values = new Set<string>();
masters.forEach((m) => {
const val = (m as any)?.[col.key];
flatRows.forEach((row) => {
const val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
}, [flatRows]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
const values = new Set<string>();
data.forEach((row) => {
let val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "source_type") val = SOURCE_TYPE_LABEL[val] || val;
values.add(String(val));
}
// 필터 + 정렬 적용된 플랫 데이터
const filteredRows = useMemo(() => {
let rows = [...flatRows];
// 1차: 헤더 필터 적용
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
rows = rows.filter((row) => {
const cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
return values.has(cellVal);
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
// 2차: 정렬
if (sortState) {
const { key, direction } = sortState;
rows.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}
return rows;
}, [flatRows, headerFilters, sortState]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
@@ -938,7 +816,7 @@ export default function OutboundPage() {
dataCount={data.length}
/>
{/* 출고 목록 테이블 */}
{/* 출고 목록 테이블 (플랫 리스트) */}
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
{/* 패널 헤더 */}
<div className="flex items-center justify-between border-b bg-muted/30 px-4 py-2.5 shrink-0">
@@ -946,7 +824,7 @@ export default function OutboundPage() {
<PackageOpen className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<span className="rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
{data.length}
{filteredRows.length}
</span>
</div>
<div className="flex gap-2">
@@ -971,78 +849,68 @@ export default function OutboundPage() {
</div>
<div className="h-full overflow-auto">
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<Table noWrapper style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 출고번호 */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("outbound_number")}>
<span className="truncate"></span>
{sortState?.key === "outbound_number" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["outbound_number"] || []).length > 0 && (
<HeaderFilterPopover
colKey="outbound_number" colLabel="출고번호"
uniqueValues={masterUniqueValues["outbound_number"] || []}
filterValues={headerFilters["outbound_number"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
{GRID_COLUMNS.map((col) => {
const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key);
return (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none", isRight && "text-right")}>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(columnUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={columnUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
@@ -1052,7 +920,7 @@ export default function OutboundPage() {
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredGroups).length === 0 ? (
) : filteredRows.length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
@@ -1062,214 +930,59 @@ export default function OutboundPage() {
</TableCell>
</TableRow>
) : (
Object.entries(filteredGroups).map(([outboundNo, group]) => {
const isExpanded = expandedOrders.has(outboundNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
filteredRows.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={outboundNo}>
{/* 마스터 행 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(outboundNo)) {
setClosingOrders((prev) => new Set(prev).add(outboundNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(outboundNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(outboundNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(outboundNo));
}
}}
onDoubleClick={() => openEditModal(group.details[0])}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 출고번호 */}
<TableCell className="font-mono whitespace-nowrap">
{outboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "outbound_type": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{resolveCat("outbound_type", master.outbound_type) || "-"}
</Badge>
</TableCell>
);
case "outbound_date": return (
<TableCell key={col.key} className="whitespace-nowrap text-[13px]">
{master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "customer_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.customer_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "outbound_status": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(master.outbound_status))}>
{master.outbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground">
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(outboundNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell />
{visibleDetailCols.map((col) => {
const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => {
let v = (d as any)[col.key];
if (col.key === "source_type") v = SOURCE_TYPE_LABEL[v] || v;
return v;
}).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
</TableRow>
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(outboundNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={`${row.id}-${detailIdx}`}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_type": return <TableCell key={col.key} className="text-[13px]">{row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}</TableCell>;
case "item_code": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_code || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "specification": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.specification || ""}</TableCell>;
case "outbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
})}
</React.Fragment>
}}
onDoubleClick={() => openEditModal(row as any)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="font-mono whitespace-nowrap text-[13px]">{row.outbound_number || ""}</TableCell>
<TableCell className="text-[13px]">
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(row.outbound_type))}>
{row.outbound_type || "-"}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-[13px]">
{row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""}
</TableCell>
<TableCell className="text-[13px] truncate max-w-[120px]"><span className="block truncate">{row.reference_number || ""}</span></TableCell>
<TableCell className="text-[13px]">{row.source_type || ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[110px]"><span className="block truncate">{row.customer_name || ""}</span></TableCell>
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
<TableCell className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[100px]"><span className="block truncate">{row.warehouse_name || row.warehouse_code || ""}</span></TableCell>
<TableCell className="text-[13px]">
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(row.outbound_status))}>
{row.outbound_status || "-"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.remark || row.memo || ""}</span></TableCell>
</TableRow>
);
})
)}
@@ -43,7 +43,6 @@ import {
X,
Save,
ChevronRight,
ChevronDown,
ChevronLeft,
ChevronsLeft,
ChevronsRight,
@@ -64,6 +63,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea
import {
getReceivingList,
createReceiving,
updateReceiving,
deleteReceiving,
generateReceivingNumber,
getReceivingWarehouses,
@@ -95,41 +95,8 @@ const GRID_COLUMNS = [
{ key: "remark", label: "비고" },
];
// 마스터 헤더 레이아웃 (입고번호 뒤, 디테일 7컬럼 위에 colSpan으로 맵핑)
const MASTER_BODY_LAYOUT = [
{ key: "inbound_type", label: "입고유형", colSpan: 1 },
{ key: "inbound_date", label: "입고일", colSpan: 1 },
{ key: "reference_number", label: "참조번호", colSpan: 1 },
{ key: "supplier_name", label: "공급처", colSpan: 1 },
{ key: "warehouse_name", label: "창고", colSpan: 1 },
{ key: "inbound_status", label: "입고상태", colSpan: 1 },
{ key: "memo", label: "비고", colSpan: 1 },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "source_table", label: "출처" },
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "spec", label: "규격" },
{ key: "inbound_qty", label: "입고수량" },
{ key: "unit_price", label: "단가" },
{ key: "total_amount", label: "금액" },
];
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_table",
item_number: "item_number",
item_name: "item_name",
spec: "spec",
inbound_qty: "inbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16
const TOTAL_COLS = 16;
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -287,30 +254,6 @@ interface SelectedSourceItem {
export default function ReceivingPage() {
const ts = useTableSettings("c16-receiving", "inbound_mng", GRID_COLUMNS);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<InboundItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -319,9 +262,7 @@ export default function ReceivingPage() {
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 마스터-디테일 그룹 테이블
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
// 헤더 필터 & 정렬
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
@@ -338,6 +279,10 @@ export default function ReceivingPage() {
const [selectedItems, setSelectedItems] = useState<SelectedSourceItem[]>([]);
const [saving, setSaving] = useState(false);
// 수정 모드
const [editMode, setEditMode] = useState(false);
const [editItemIds, setEditItemIds] = useState<string[]>([]);
// 소스 데이터
const [sourceKeyword, setSourceKeyword] = useState("");
const [sourceLoading, setSourceLoading] = useState(false);
@@ -377,7 +322,7 @@ export default function ReceivingPage() {
Promise.all(
["material", "unit"].map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_30`);
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_16`);
const items = flatten(res.data?.data || []);
map[col] = {};
for (const item of items) map[col][item.code] = item.label;
@@ -434,124 +379,56 @@ export default function ReceivingPage() {
})();
}, []);
// 필터 + 정렬 적용된 데이터 -> 그룹핑
const filteredGroups = useMemo(() => {
// 1차: inbound_number 기준 그룹핑
const allGroups: Record<string, { master: InboundItem; details: InboundItem[] }> = {};
for (const row of data) {
const key = row.inbound_number || "_no_inbound";
if (!allGroups[key]) {
allGroups[key] = { master: row, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
let raw = (group.master as any)?.[colKey] ?? "";
// 입고유형은 코드→라벨 변환된 값으로 비교
if (colKey === "inbound_type") raw = resolveInboundType(String(raw));
return values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([inboundNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
let cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
if (colKey === "source_table") cellVal = SOURCE_TABLE_LABEL[cellVal] || cellVal;
return values.has(cellVal);
})
);
return [inboundNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
entries.sort(([, a], [, b]) => {
let av: any = (a.master as any)?.[key] ?? "";
let bv: any = (b.master as any)?.[key] ?? "";
if (key === "inbound_type") { av = resolveInboundType(String(av)); bv = resolveInboundType(String(bv)); }
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
} else {
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [data, headerFilters, sortState]);
// 마스터 컬럼별 고유값 (마스터 헤더 필터용)
const masterUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
const seenMasters = new Map<string, InboundItem>();
data.forEach((row) => {
if (row.inbound_number && !seenMasters.has(row.inbound_number)) {
seenMasters.set(row.inbound_number, row);
}
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "inbound_number", label: "입고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) {
const values = new Set<string>();
masters.forEach((m) => {
let val = (m as any)?.[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "inbound_type") val = resolveInboundType(String(val));
values.add(String(val));
}
});
result[col.key] = Array.from(values).sort();
}
return result;
// 플랫 행 생성 (입고유형 코드→라벨 변환, source_table→한글 등)
const flatRows = useMemo(() => {
return data.map((row) => ({
...row,
inbound_type: resolveInboundType(row.inbound_type),
source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "",
}));
}, [data]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
// 컬럼별 고유값 (헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
for (const col of GRID_COLUMNS) {
const values = new Set<string>();
data.forEach((row) => {
let val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "source_table") val = SOURCE_TABLE_LABEL[val] || val;
values.add(String(val));
}
flatRows.forEach((row) => {
const val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
}, [flatRows]);
// 필터 + 정렬 적용된 플랫 데이터
const filteredRows = useMemo(() => {
let rows = [...flatRows];
// 1차: 헤더 필터 적용
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
rows = rows.filter((row) => {
const cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
return values.has(cellVal);
});
}
// 2차: 정렬
if (sortState) {
const { key, direction } = sortState;
rows.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}
return rows;
}, [flatRows, headerFilters, sortState]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
@@ -633,6 +510,8 @@ export default function ReceivingPage() {
const openRegisterModal = async () => {
const defaultType = "구매입고";
setEditMode(false);
setEditItemIds([]);
setModalInboundType(defaultType);
setModalInboundDate(new Date().toISOString().split("T")[0]);
setModalWarehouse("");
@@ -661,6 +540,51 @@ export default function ReceivingPage() {
}
};
// 수정 모달 열기
const openEditModal = (row: InboundItem) => {
const inNo = row.inbound_number;
const grouped = data.filter((d) => d.inbound_number === inNo);
const first = grouped[0] || row;
setEditMode(true);
setEditItemIds(grouped.map((g) => g.id));
setModalInboundNo(inNo);
setModalInboundType(first.inbound_type || "구매입고");
setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : "");
setModalWarehouse(first.warehouse_code || "");
setModalLocation(first.location_code || "");
setModalInspector((first as any).inspector || "");
setModalManager((first as any).manager || "");
setModalMemo(first.memo || "");
setSelectedItems(
grouped.map((g) => ({
key: g.id,
inbound_type: g.inbound_type || "",
reference_number: g.reference_number || "",
supplier_code: (g as any).supplier_code || "",
supplier_name: g.supplier_name || "",
item_number: g.item_number || "",
item_name: g.item_name || "",
spec: g.spec || "",
material: (g as any).material || "",
unit: (g as any).unit || "",
inbound_qty: Number(g.inbound_qty) || 0,
unit_price: Number(g.unit_price) || 0,
total_amount: Number(g.total_amount) || 0,
source_table: (g as any).source_table || "",
source_id: (g as any).source_id || "",
}))
);
setSourceKeyword("");
setPurchaseOrders([]);
setShipments([]);
setItems([]);
setSourcePage(1);
setSourceTotalCount(0);
setIsModalOpen(true);
loadSourceData(first.inbound_type || "구매입고", undefined, 1);
};
// 검색 버튼 클릭 시
const searchSourceData = useCallback(async () => {
setSourcePage(1);
@@ -811,41 +735,97 @@ export default function ReceivingPage() {
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: selectedItems.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
});
if (editMode) {
// 기존 item 수정 + 삭제된 item 삭제 + 새 item 추가
const currentKeys = new Set(selectedItems.map((i) => i.key));
const toDelete = editItemIds.filter((id) => !currentKeys.has(id));
const toUpdate = selectedItems.filter((i) => editItemIds.includes(i.key));
const toCreate = selectedItems.filter((i) => !editItemIds.includes(i.key));
if (res.success) {
alert(res.message || "입고 등록 완료");
await Promise.all([
...toDelete.map((id) => deleteReceiving(id)),
...toUpdate.map((item) =>
updateReceiving(item.key, {
inbound_date: modalInboundDate,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
warehouse_code: modalWarehouse || undefined,
location_code: modalLocation || undefined,
memo: modalMemo || undefined,
} as any)
),
...(toCreate.length > 0
? [createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: toCreate.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
})]
: []),
]);
toast.success("입고 정보를 수정했어요");
setIsModalOpen(false);
fetchList();
} else {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: selectedItems.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
});
if (res.success) {
toast.success(res.message || "입고 등록 완료");
setIsModalOpen(false);
fetchList();
}
}
} catch {
alert("입고 등록 중 오류가 발생했습니다.");
} catch (err: any) {
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "입고 등록 중 오류가 발생했습니다.");
toast.error(msg);
} finally {
setSaving(false);
}
@@ -897,13 +877,13 @@ export default function ReceivingPage() {
}
/>
{/* 입고 목록 테이블 (마스터-디테일 그룹) */}
{/* 입고 목록 테이블 (플랫 리스트) */}
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
<div className="flex items-center justify-between border-b bg-muted/50 px-4 py-2.5">
<div className="flex items-center gap-2">
<h3 className="text-[13px] font-bold"> </h3>
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
{Object.keys(filteredGroups).length}
{filteredRows.length}
</span>
</div>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
@@ -912,78 +892,68 @@ export default function ReceivingPage() {
</div>
<div className="h-[calc(100%-44px)] overflow-auto">
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<Table noWrapper style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 입고번호 (별도 컬럼) */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("inbound_number")}>
<span className="truncate"></span>
{sortState?.key === "inbound_number" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["inbound_number"] || []).length > 0 && (
<HeaderFilterPopover
colKey="inbound_number" colLabel="입고번호"
uniqueValues={masterUniqueValues["inbound_number"] || []}
filterValues={headerFilters["inbound_number"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
{GRID_COLUMNS.map((col) => {
const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key);
return (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none", isRight && "text-right")}>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(columnUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={columnUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
@@ -993,7 +963,7 @@ export default function ReceivingPage() {
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredGroups).length === 0 ? (
) : filteredRows.length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
@@ -1003,212 +973,59 @@ export default function ReceivingPage() {
</TableCell>
</TableRow>
) : (
Object.entries(filteredGroups).map(([inboundNo, group]) => {
const isExpanded = expandedOrders.has(inboundNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
filteredRows.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={inboundNo}>
{/* 마스터 행 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(inboundNo)) {
setClosingOrders((prev) => new Set(prev).add(inboundNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(inboundNo));
}
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row as any)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 입고번호 */}
<TableCell className="font-mono whitespace-nowrap">
{inboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "inbound_type": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getTypeVariant(resolveInboundType(master.inbound_type))} className="text-[11px]">
{resolveInboundType(master.inbound_type)}
</Badge>
</TableCell>
);
case "inbound_date": return (
<TableCell key={col.key} className="text-[13px] whitespace-nowrap">
{master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "supplier_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.supplier_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "inbound_status": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getStatusVariant(master.inbound_status)} className="text-[11px]">
{master.inbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground text-[13px]">
<span className="block truncate max-w-[110px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(inboundNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell />
{visibleDetailCols.map((col) => {
const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => {
let v = (d as any)[col.key];
if (col.key === "source_table") v = SOURCE_TABLE_LABEL[v] || v;
return v;
}).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
</TableRow>
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(inboundNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={`${row.id}-${detailIdx}`}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_table": return <TableCell key={col.key} className="text-[13px]">{row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}</TableCell>;
case "item_number": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_number || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[160px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "spec": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>;
case "inbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
);
})}
</React.Fragment>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="font-mono whitespace-nowrap text-[13px]">{row.inbound_number || ""}</TableCell>
<TableCell className="text-[13px]">
<Badge variant={getTypeVariant(row.inbound_type)} className="text-[11px]">
{row.inbound_type || "-"}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-[13px]">
{row.inbound_date ? new Date(row.inbound_date).toLocaleDateString("ko-KR") : ""}
</TableCell>
<TableCell className="text-[13px] truncate max-w-[120px]"><span className="block truncate">{row.reference_number || ""}</span></TableCell>
<TableCell className="text-[13px]">{row.source_type || ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[110px]"><span className="block truncate">{row.supplier_name || ""}</span></TableCell>
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
<TableCell className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[100px]"><span className="block truncate">{row.warehouse_name || (row as any).warehouse_code || ""}</span></TableCell>
<TableCell className="text-[13px]">
<Badge variant={getStatusVariant(row.inbound_status)} className="text-[11px]">
{row.inbound_status || "-"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.remark || row.memo || ""}</span></TableCell>
</TableRow>
);
})
)}
@@ -1221,9 +1038,9 @@ export default function ReceivingPage() {
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="sm:max-w-[1600px] w-[95vw] h-[90vh] p-0 flex flex-col overflow-hidden">
<DialogHeader className="shrink-0 px-6 pt-4 pb-2 border-b">
<DialogTitle> </DialogTitle>
<DialogTitle>{editMode ? "입고 수정" : "입고 등록"}</DialogTitle>
<DialogDescription>
, .
{editMode ? "입고 정보를 수정해 주세요." : "입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요."}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col">
@@ -1053,14 +1053,14 @@ export default function ProductionPlanManagementPage() {
return (
<Table style={{ minWidth: "900px" }}>
<TableHeader className="sticky top-0 z-10">
<TableHeader className="sticky top-0 z-20">
<TableRow className="bg-muted hover:bg-muted">
<TableHead style={{ width: "30px", minWidth: "30px" }}>
<TableHead className="sticky left-0 z-20 bg-muted" style={{ width: "30px", minWidth: "30px" }}>
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead style={{ width: "40px", minWidth: "40px" }} />
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="sticky left-[30px] z-20 bg-muted" style={{ width: "40px", minWidth: "40px" }} />
<TableHead className="sticky left-[70px] z-20 bg-muted text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={{ width: "140px", minWidth: "140px" }}></TableHead>
<TableHead className="sticky left-[210px] z-20 bg-muted text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground border-r" style={{ width: "140px", minWidth: "140px" }}></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} style={{ ...ts.thStyle(col.key), minWidth: "80px" }} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
{col.label}
@@ -1073,9 +1073,9 @@ export default function ProductionPlanManagementPage() {
if (item._isGroupSummary) {
return (
<TableRow key={`summary-${rowIdx}`} className="bg-muted/60 font-semibold border-t border-primary/20">
<TableCell />
<TableCell />
<TableCell colSpan={2} />
<TableCell className="sticky left-0 z-10 bg-muted/60" />
<TableCell className="sticky left-[30px] z-10 bg-muted/60" />
<TableCell colSpan={2} className="sticky left-[70px] z-10 bg-muted/60 border-r" />
{ts.visibleColumns.map((col) => {
const v = (item as any)[col.key];
return (
@@ -1090,14 +1090,14 @@ export default function ProductionPlanManagementPage() {
return (
<React.Fragment key={item.item_code || rowIdx}>
<TableRow className={cn("cursor-pointer border-t-2 border-t-primary/30 bg-primary/5 font-semibold hover:bg-primary/10", selectedItemGroups.has(item.item_code) && "bg-primary/10")}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<TableCell className="sticky left-0 z-10 bg-background text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selectedItemGroups.has(item.item_code)} onCheckedChange={() => toggleItemGroupSelect(item.item_code)} className="h-4 w-4" />
</TableCell>
<TableCell className="text-center" onClick={() => toggleItemExpand(item.item_code)}>
<TableCell className="sticky left-[30px] z-10 bg-background text-center" onClick={() => toggleItemExpand(item.item_code)}>
<ChevronRight className={cn("h-4 w-4 transition-transform duration-200", expandedItems.has(item.item_code) && "rotate-90")} />
</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
<TableCell className="sticky left-[70px] z-10 bg-background text-[13px] text-primary truncate" style={{ width: "140px", minWidth: "140px" }} onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell className="sticky left-[210px] z-10 bg-background text-[13px] text-primary truncate border-r" style={{ width: "140px", minWidth: "140px" }} onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
{ts.visibleColumns.map((col) => renderGroupCell(col, item))}
</TableRow>
{expandedItems.has(item.item_code) && item.orders?.map((detail: any) => {
@@ -1107,9 +1107,9 @@ export default function ProductionPlanManagementPage() {
}
return (
<TableRow key={detail.id || detail.order_no} className="cursor-pointer hover:bg-muted/50">
<TableCell />
<TableCell />
<TableCell colSpan={2} className="pl-10">
<TableCell className="sticky left-0 z-10 bg-card" />
<TableCell className="sticky left-[30px] z-10 bg-card" />
<TableCell colSpan={2} className="sticky left-[70px] z-10 bg-card pl-10 border-r">
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{detail.order_no}</span>
@@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Package, ChevronDown,
ClipboardList, Pencil, Search, X, Package,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2, GripVertical,
} from "lucide-react";
@@ -88,18 +88,18 @@ const GRID_COLUMNS_CONFIG = [
];
const MODAL_DETAIL_COLUMNS = [
{ key: "item_code", label: "품번", width: "w-[120px]" },
{ key: "item_name", label: "품명", width: "w-[120px]" },
{ key: "supplier", label: "공급업체", width: "w-[150px]" },
{ key: "spec", label: "규격", width: "w-[80px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "order_qty", label: "발주수량", width: "w-[90px]" },
{ key: "received_qty", label: "입고수량", width: "w-[90px]" },
{ key: "remain_qty", label: "잔량", width: "w-[80px]" },
{ key: "unit_price", label: "단가", width: "w-[100px]" },
{ key: "amount", label: "금액", width: "w-[100px]" },
{ key: "due_date", label: "납기일", width: "w-[160px]" },
{ key: "memo", label: "메모", width: "w-[120px]" },
{ key: "item_code", label: "품번", width: "min-w-[120px]" },
{ key: "item_name", label: "품명", width: "min-w-[150px]" },
{ key: "supplier", label: "공급업체", width: "min-w-[150px]" },
{ key: "spec", label: "규격", width: "min-w-[80px]" },
{ key: "unit", label: "단위", width: "min-w-[90px]" },
{ key: "order_qty", label: "발주수량", width: "min-w-[90px]" },
{ key: "received_qty", label: "입고수량", width: "min-w-[90px]" },
{ key: "remain_qty", label: "잔량", width: "min-w-[80px]" },
{ key: "unit_price", label: "단가", width: "min-w-[100px]" },
{ key: "amount", label: "금액", width: "min-w-[100px]" },
{ key: "due_date", label: "납기일", width: "min-w-[160px]" },
{ key: "memo", label: "메모", width: "min-w-[120px]" },
];
const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16";
@@ -162,7 +162,6 @@ export default function PurchaseOrderPage() {
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
// 테이블 설정
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
@@ -238,7 +237,7 @@ export default function PurchaseOrderPage() {
);
try {
const suppRes = await apiClient.post(`/table-management/tables/supplier_mng/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 5000, autoFilter: true,
});
const supps = suppRes.data?.data?.data || suppRes.data?.data?.rows || [];
optMap["supplier_code"] = supps.map((s: any) => ({
@@ -248,7 +247,7 @@ export default function PurchaseOrderPage() {
} catch { /* skip */ }
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 5000, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
optMap["manager"] = users.map((u: any) => ({
@@ -294,7 +293,7 @@ export default function PurchaseOrderPage() {
}
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: detailFilters.length > 0 ? { enabled: true, filters: detailFilters } : undefined,
autoFilter: true,
sort: { columnName: "purchase_no", order: "desc" },
@@ -372,20 +371,6 @@ export default function PurchaseOrderPage() {
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// purchase_no 기준 그룹핑
const orderGroups = useMemo(() => {
const map: Record<string, { master: any; details: any[] }> = {};
for (const row of orders) {
const key = row.purchase_no || row.id;
if (!map[key]) {
map[key] = {
master: { purchase_no: row.purchase_no, order_date: row.order_date, supplier_name: row.supplier_name, status: row.status, memo: row.memo },
details: [],
};
}
map[key].details.push(row);
}
return map;
}, [orders]);
const getCategoryLabel = (col: string, code: string) => {
if (!code) return "";
@@ -553,7 +538,7 @@ export default function PurchaseOrderPage() {
}
};
// 품목 검색
// 품목 검색 (수주관리와 동일한 서버 페이징 방식)
const searchItems = async (page?: number, size?: number) => {
const p = page ?? itemPage;
const s = size ?? itemPageSize;
@@ -563,25 +548,24 @@ export default function PurchaseOrderPage() {
if (itemSearchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
}
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
const divValues = [itemSearchDivision];
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
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)));
const rows = resData?.data || resData?.rows || [];
const serverTotal = resData?.total || resData?.totalCount || rows.length;
setItemSearchResults(rows);
setItemTotal(serverTotal);
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
@@ -623,7 +607,7 @@ export default function PurchaseOrderPage() {
try {
const itemIds = selected.map((item) => item.item_number || item.id);
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: {
enabled: true,
filters: [
@@ -686,7 +670,7 @@ export default function PurchaseOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
autoFilter: true,
});
@@ -708,7 +692,7 @@ export default function PurchaseOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: supplierCode },
{ columnName: "item_id", operator: "in", value: itemCodes },
@@ -811,125 +795,83 @@ export default function PurchaseOrderPage() {
</div>
</div>
{/* 데이터 테이블 — 아코디언 그룹핑 */}
{/* 데이터 테이블 — 플랫 리스트 */}
<div className="flex-1 overflow-auto border rounded-lg bg-card">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : Object.keys(orderGroups).length === 0 ? (
) : orders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Package className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
(() => {
const MASTER_KEYS = new Set(["purchase_no", "order_date", "supplier_name", "status", "memo"]);
const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]);
// ts.visibleColumns 순서를 따르되, 마스터/디테일 컬럼을 분리
// 고정 컬럼(품목수)은 마스터 선행 컬럼 뒤에 배치
const leadingMaster: typeof ts.visibleColumns = [];
const detailCols: typeof ts.visibleColumns = [];
const trailingMaster: typeof ts.visibleColumns = [];
let passedFirstDetail = false;
for (const col of ts.visibleColumns) {
if (MASTER_KEYS.has(col.key)) {
if (passedFirstDetail) trailingMaster.push(col);
else leadingMaster.push(col);
} else {
passedFirstDetail = true;
detailCols.push(col);
}
}
const renderDetailCell = (row: any, key: string) => {
const renderCell = (row: any, key: string) => {
const val = row[key];
if (key === "status") return val ? <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[val] || "")}>{val}</span> : "-";
if (numCols.has(key)) return <span className="font-mono">{val ? Number(val).toLocaleString() : "0"}</span>;
return val || "-";
if (key === "order_date" || key === "due_date") return val ? new Date(val).toLocaleDateString("ko-KR") : "-";
if (numCols.has(key)) return <span className="font-mono">{val ? Number(val).toLocaleString() : ""}</span>;
return val || "";
};
const renderMasterHead = (col: { key: string; label: string }) => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", col.key === "status" && "text-center")}>
{col.label}
</TableHead>
);
const renderMasterCell = (col: { key: string }, m: any, purchaseNo: string) => {
if (col.key === "purchase_no") return <TableCell key={col.key} className="text-sm font-semibold text-primary">{purchaseNo}</TableCell>;
if (col.key === "order_date") return <TableCell key={col.key} className="text-sm">{m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}</TableCell>;
if (col.key === "supplier_name") return <TableCell key={col.key} className="text-sm">{m.supplier_name || "-"}</TableCell>;
if (col.key === "status") return <TableCell key={col.key} className="text-center">{m.status && <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[m.status] || "")}>{m.status}</span>}</TableCell>;
if (col.key === "memo") return <TableCell key={col.key} className="text-sm text-muted-foreground max-w-[120px] truncate">{m.memo || ""}</TableCell>;
return <TableCell key={col.key} />;
};
return (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-8" />
<TableHead className="w-10" />
{leadingMaster.map(renderMasterHead)}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground text-center"></TableHead>
{detailCols.map(col => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", numCols.has(col.key) && "text-right")}>
{col.label}{col.key === "order_qty" || col.key === "amount" ? " 합계" : ""}
<TableHead
className="w-10 text-center cursor-pointer"
onClick={() => {
const allIds = orders.map((r) => r.id);
const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allIds);
}}
>
<Checkbox
checked={orders.length > 0 && orders.every((r) => checkedIds.includes(r.id))}
onCheckedChange={() => {}}
/>
</TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", numCols.has(col.key) && "text-right", col.key === "status" && "text-center")}>
{col.label}
</TableHead>
))}
{trailingMaster.map(renderMasterHead)}
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(orderGroups).map(([purchaseNo, group]) => {
const isExpanded = expandedOrders.has(purchaseNo);
const detailIds = group.details.map(d => d.id);
const allChecked = detailIds.length > 0 && detailIds.every(id => checkedIds.includes(id));
const someChecked = detailIds.some(id => checkedIds.includes(id));
const m = group.master;
const totalQty = group.details.reduce((s, d) => s + (Number(d.order_qty) || 0), 0);
const totalAmt = group.details.reduce((s, d) => s + (Number(d.amount) || 0), 0);
{orders.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={purchaseNo}>
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn("cursor-pointer border-l-[3px] border-l-transparent font-semibold", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedOrders(prev => { const next = new Set(prev); if (next.has(purchaseNo)) next.delete(purchaseNo); else next.add(purchaseNo); return next; })}
onDoubleClick={() => openEditModal(purchaseNo)}
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row.purchase_no)}
>
<TableCell
className="text-center"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
{ts.visibleColumns.map((col) => (
<TableCell key={col.key} className={cn("text-[13px]", numCols.has(col.key) && "text-right font-mono", col.key === "status" && "text-center")}>
{renderCell(row, col.key)}
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !detailIds.includes(id)) : [...new Set([...prev, ...detailIds])]); }}>
<Checkbox checked={allChecked} data-state={someChecked && !allChecked ? "indeterminate" : undefined} onCheckedChange={() => {}} />
</TableCell>
{leadingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
<TableCell className="text-sm text-center"><Badge variant="secondary" className="text-[10px]">{group.details.length}</Badge></TableCell>
{detailCols.map(col => (
<TableCell key={col.key} className={cn("text-sm", numCols.has(col.key) && "text-right font-mono")}>
{col.key === "order_qty" ? totalQty.toLocaleString()
: col.key === "amount" ? totalAmt.toLocaleString()
: ""}
</TableCell>
))}
{trailingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
</TableRow>
{isExpanded && group.details.map((row) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell className="text-center" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id]); }}>
<Checkbox checked={checkedIds.includes(row.id)} onCheckedChange={() => {}} />
</TableCell>
{leadingMaster.map(col => <TableCell key={col.key} />)}
<TableCell />
{detailCols.map(col => (
<TableCell key={col.key} className={cn(numCols.has(col.key) && "text-right")}>
{renderDetailCell(row, col.key)}
</TableCell>
))}
{trailingMaster.map(col => <TableCell key={col.key} />)}
</TableRow>
))}
</React.Fragment>
</TableRow>
);
})}
</TableBody>
@@ -938,7 +880,7 @@ export default function PurchaseOrderPage() {
})()
)}
<div className="px-4 py-2 border-t text-xs text-muted-foreground">
{Object.keys(orderGroups).length} ( ) / {orders.length} ( )
{orders.length}
</div>
</div>
@@ -1094,7 +1036,7 @@ export default function PurchaseOrderPage() {
) : (
<div className="border rounded-lg overflow-x-auto">
<DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
<Table className="table-fixed">
<Table>
<TableHeader className="sticky top-0 z-10">
<SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableRow className="bg-muted hover:bg-muted">
@@ -1118,12 +1060,12 @@ export default function PurchaseOrderPage() {
{visibleModalColumns.map((col) => {
switch (col.key) {
case "item_code":
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
return <TableCell key={col.key} className="text-[13px] font-mono whitespace-nowrap"><span title={row.item_code}>{row.item_code}</span></TableCell>;
case "item_name":
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
return <TableCell key={col.key} className="text-[13px] whitespace-nowrap"><span title={row.item_name}>{row.item_name}</span></TableCell>;
case "supplier":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[150px]">
{isReadOnly ? (
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
) : (
@@ -1133,7 +1075,7 @@ export default function PurchaseOrderPage() {
updateDetailRow(idx, "supplier_code", v);
updateDetailRow(idx, "supplier_name", name);
}}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectTrigger className="h-8 text-xs w-full"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
@@ -1149,11 +1091,11 @@ export default function PurchaseOrderPage() {
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
case "order_qty":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[110px]">
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono w-full" />
)}
</TableCell>
);
@@ -1163,11 +1105,11 @@ export default function PurchaseOrderPage() {
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":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[120px]">
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{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" />
<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" />
)}
</TableCell>
);
@@ -1175,21 +1117,21 @@ export default function PurchaseOrderPage() {
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
case "due_date":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[160px]">
{isReadOnly ? (
<span className="text-xs">{row.due_date}</span>
) : (
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs w-full" />
)}
</TableCell>
);
case "memo":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[140px]">
{isReadOnly ? (
<span className="text-xs">{row.memo}</span>
) : (
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs w-full" />
)}
</TableCell>
);
@@ -142,6 +142,10 @@ const FORM_FIELDS = [
{ key: "division", label: "관리품목", type: "multi-category" },
{ key: "type", label: "품목구분", type: "category" },
{ key: "size", label: "규격", type: "text" },
{ key: "width", label: "가로", type: "text", placeholder: "숫자 입력 (mm)" },
{ key: "height", label: "세로", type: "text", placeholder: "숫자 입력 (mm)" },
{ key: "thickness", label: "두께", type: "text", placeholder: "숫자 입력 (mm)" },
{ key: "area", label: "면적", type: "text", placeholder: "숫자 입력 (㎡)" },
{ key: "unit", label: "단위", type: "category" },
{ key: "material", label: "재질", type: "category" },
{ key: "status", label: "상태", type: "category" },
@@ -175,6 +179,10 @@ const ITEM_GRID_COLUMNS = [
{ key: "item_number", label: "품번" },
{ key: "item_name", label: "품명" },
{ key: "size", label: "규격" },
{ key: "width", label: "가로" },
{ key: "height", label: "세로" },
{ key: "thickness", label: "두께" },
{ key: "area", label: "면적" },
{ key: "unit", label: "단위" },
{ key: "standard_price", label: "기준단가/구매단가" },
{ key: "currency_code", label: "통화" },
@@ -1605,9 +1613,25 @@ export default function PurchaseItemPage() {
) : (
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={"placeholder" in field ? field.placeholder : field.label}
className="h-9"
readOnly={field.key === "area"}
onChange={(e) => {
if (field.key === "area") return;
const v = e.target.value;
setFormData((prev) => {
const next = { ...prev, [field.key]: v };
// 가로/세로 변경 시 면적(㎡) 자동 계산: (가로mm × 세로mm) / 1,000,000
if (field.key === "width" || field.key === "height") {
const w = Number(field.key === "width" ? v : prev.width);
const h = Number(field.key === "height" ? v : prev.height);
if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) {
next.area = ((w * h) / 1_000_000).toFixed(4);
}
}
return next;
});
}}
placeholder={field.key === "area" ? "자동 계산" : ("placeholder" in field ? field.placeholder : field.label)}
className={cn("h-9", field.key === "area" && "bg-muted cursor-not-allowed")}
/>
)}
</div>
@@ -424,9 +424,10 @@ export default function ItemInspectionInfoPage() {
case "inspection_type": return (
<TableCell key={col.key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>
))}
{group.types.map((t: string) => {
const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t;
return <Badge key={t} variant="secondary" className="text-[10px]">{label}</Badge>;
})}
</div>
</TableCell>
);
@@ -494,7 +495,7 @@ export default function ItemInspectionInfoPage() {
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
)}
>
{type}
{inspTypeCatOptions.find((o) => o.code === type)?.label || type}
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
@@ -517,42 +517,44 @@ export default function SalesOrderPage() {
}
};
// 삭제 (마스터 + 디테일)
// 삭제 (선택한 디테일 삭제 → 디테일 0건인 마스터 자동 삭제)
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; }
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
const ok = await confirm(`${orderNos.length}건의 수주를 삭제하시겠습니까?`, {
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요."); return; }
const ok = await confirm(`${checkedIds.length}건의 수주 항목을 삭제하시겠습니까?`, {
description: "삭제된 데이터는 복구할 수 없습니다.",
variant: "destructive",
confirmText: "삭제",
});
if (!ok) return;
try {
// 1. 선택한 디테일 삭제
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
data: checkedIds.map((id) => ({ id })),
});
// 2. 영향받는 수주번호의 잔여 디테일 확인 → 0건이면 마스터도 삭제
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
for (const orderNo of orderNos) {
// 디테일 삭제
const detailRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 9999,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const details = detailRes.data?.data?.data || detailRes.data?.data?.rows || [];
if (details.length > 0) {
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
data: details.map((d: any) => ({ id: d.id })),
});
}
// 마스터 삭제
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
const remainRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
if (masters.length > 0) {
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
data: masters.map((m: any) => ({ id: m.id })),
const remaining = remainRes.data?.data?.data || remainRes.data?.data?.rows || [];
if (remaining.length === 0) {
// 디테일 0건 → 마스터 삭제
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
if (masters.length > 0) {
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
data: masters.map((m: any) => ({ id: m.id })),
});
}
}
}
toast.success("삭제되었습니다.");
@@ -622,6 +624,15 @@ export default function SalesOrderPage() {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
// 코드 또는 라벨이 저장된 경우 모두 조회하기 위해 in 연산자 사용
const divValues = [itemSearchDivision];
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const partnerId = masterForm.partner_id;
@@ -630,7 +641,7 @@ export default function SalesOrderPage() {
if (isCustomerPrice && partnerId) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
autoFilter: true,
});
@@ -640,31 +651,22 @@ export default function SalesOrderPage() {
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
let allRows = resData?.data || resData?.rows || [];
let rows = resData?.data || resData?.rows || [];
const serverTotal = resData?.total || resData?.totalCount || rows.length;
// 거래처우선일 때 연결된 품목만 표시
// 거래처우선일 때 연결된 품목만 표시 (클라이언트 필터)
if (customerItemIds) {
allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
rows = rows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
}
// 관리품목 필터 (코드/라벨 혼재 대응)
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)));
setItemSearchResults(rows);
setItemTotal(serverTotal);
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
@@ -1318,44 +1320,44 @@ export default function SalesOrderPage() {
<Table noWrapper className="min-w-max w-full">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="w-28 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-32 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-32 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">/</TableHead>
<TableHead className="min-w-[40px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[120px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[110px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">/</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
<TableCell className="text-center font-mono text-[13px] text-muted-foreground/50">{idx + 1}</TableCell>
<TableCell className="max-w-[112px]">
<span className="block truncate font-mono text-[13px]" title={row.part_code}>{row.part_code}</span>
<TableCell className="whitespace-nowrap">
<span className="font-mono text-[13px]" title={row.part_code}>{row.part_code}</span>
</TableCell>
<TableCell className="max-w-[128px]">
<span className="block truncate text-[13px]" title={row.part_name}>{row.part_name}</span>
<TableCell className="whitespace-nowrap">
<span className="text-[13px]" title={row.part_name}>{row.part_name}</span>
</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.spec}</TableCell>
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.material}</TableCell>
<TableCell>
<Input
value={row.packing_material || ""}
onChange={(e) => updateDetailRow(idx, "packing_material", e.target.value)}
placeholder="포장재"
className="h-8 text-xs"
className="h-8 text-xs w-full"
/>
</TableCell>
<TableCell>
<Select value={row.unit || ""} onValueChange={(v) => updateDetailRow(idx, "unit", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="단위" /></SelectTrigger>
<SelectTrigger className="h-8 text-xs w-full"><SelectValue placeholder="단위" /></SelectTrigger>
<SelectContent>
{(categoryOptions["item_unit"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
@@ -1369,7 +1371,7 @@ export default function SalesOrderPage() {
min="1"
value={row.qty || "1"}
onChange={(e) => updateDetailRow(idx, "qty", e.target.value)}
className="h-8 text-xs text-right font-mono w-16"
className="h-8 text-xs text-right font-mono w-full"
/>
</TableCell>
<TableCell>
@@ -1378,7 +1380,7 @@ export default function SalesOrderPage() {
min="0"
value={row.pack_qty || "0"}
onChange={(e) => updateDetailRow(idx, "pack_qty", e.target.value)}
className="h-8 text-xs text-right font-mono w-16"
className="h-8 text-xs text-right font-mono w-full"
/>
</TableCell>
<TableCell>
@@ -1386,10 +1388,10 @@ export default function SalesOrderPage() {
value={formatNumber(row.unit_price || "")}
onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))}
readOnly={!allowPriceEdit}
className={cn("h-8 text-xs text-right font-mono w-20", !allowPriceEdit && "bg-muted cursor-not-allowed")}
className={cn("h-8 text-xs text-right font-mono w-full", !allowPriceEdit && "bg-muted cursor-not-allowed")}
/>
</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">
<TableCell className="text-right font-mono text-[13px] font-semibold whitespace-nowrap">
{row.amount ? Number(row.amount).toLocaleString() : "0"}
</TableCell>
<TableCell>
@@ -1397,7 +1399,7 @@ export default function SalesOrderPage() {
type="date"
value={row.due_date || ""}
onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)}
className="h-8 text-xs"
className="h-8 text-xs w-full"
/>
</TableCell>
<TableCell className="text-center">
@@ -1472,15 +1474,7 @@ export default function SalesOrderPage() {
onKeyDown={(e) => e.key === "Enter" && triggerNewSearch()}
className="h-9 flex-1"
/>
<Select value={itemSearchDivision} onValueChange={setItemSearchDivision}>
<SelectTrigger className="h-9 w-[130px]"><SelectValue placeholder="관리품목" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["item_division"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="h-9 px-3 flex items-center rounded-md border border-input bg-muted text-xs font-medium text-muted-foreground whitespace-nowrap"></div>
<Button size="sm" onClick={triggerNewSearch} disabled={itemSearchLoading} className="h-9">
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /></>}
</Button>
@@ -38,7 +38,6 @@ import {
Save,
ChevronRight,
ChevronLeft,
ChevronDown,
ChevronsLeft,
ChevronsRight,
Inbox,
@@ -116,41 +115,8 @@ const GRID_COLUMNS = [
{ key: "remark", label: "비고" },
];
// 마스터 헤더 레이아웃 (출고번호 뒤)
const MASTER_BODY_LAYOUT = [
{ key: "outbound_type", label: "출고유형", colSpan: 1 },
{ key: "outbound_date", label: "출고일", colSpan: 1 },
{ key: "reference_number", label: "참조번호", colSpan: 1 },
{ key: "customer_name", label: "거래처", colSpan: 1 },
{ key: "warehouse_name", label: "창고", colSpan: 1 },
{ key: "outbound_status", label: "출고상태", colSpan: 1 },
{ key: "memo", label: "비고", colSpan: 1 },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "source_type", label: "출처" },
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "specification", label: "규격" },
{ key: "outbound_qty", label: "출고수량" },
{ key: "unit_price", label: "단가" },
{ key: "total_amount", label: "금액" },
];
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["outbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_type",
item_number: "item_code",
item_name: "item_name",
spec: "specification",
outbound_qty: "outbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16
const TOTAL_COLS = 16;
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -258,30 +224,6 @@ interface SelectedSourceItem {
export default function OutboundPage() {
const ts = useTableSettings("c16-outbound", "outbound_mng", GRID_COLUMNS);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<OutboundItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -290,9 +232,7 @@ export default function OutboundPage() {
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 마스터-디테일 그룹 테이블 state
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
// 헤더 필터 & 정렬
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
@@ -403,121 +343,59 @@ export default function OutboundPage() {
})();
}, []);
// --- 마스터-디테일 그룹핑, 필터, 정렬 ---
// 플랫 행 생성 (출고유형 코드→라벨 변환, source_type→한글 등)
const flatRows = useMemo(() => {
return data.map((row) => ({
...row,
_raw_outbound_type: row.outbound_type,
outbound_type: resolveCat("outbound_type", row.outbound_type) || row.outbound_type || "",
source_type: row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "",
item_number: row.item_code || (row as any).item_number || "",
spec: row.specification || (row as any).spec || "",
}));
}, [data, resolveCat]);
// outbound_number 기준 그룹핑 + 필터 + 정렬
const filteredGroups = useMemo(() => {
// 1차: outbound_number 기준 그룹핑
const allGroups: Record<string, { master: OutboundItem; details: OutboundItem[] }> = {};
for (const row of data) {
const key = row.outbound_number || "_no_number";
if (!allGroups[key]) {
allGroups[key] = { master: row, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
const raw = (group.master as any)?.[colKey] ?? "";
return values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([outNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
let cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
if (colKey === "source_type") cellVal = SOURCE_TYPE_LABEL[cellVal] || cellVal;
return values.has(cellVal);
})
);
return [outNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
entries.sort(([, a], [, b]) => {
const av = (a.master as any)?.[key] ?? "";
const bv = (b.master as any)?.[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
} else {
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
let av: any = (a as any)[key] ?? "";
let bv: any = (b as any)[key] ?? "";
if (key === "source_type") { av = SOURCE_TYPE_LABEL[av] || av; bv = SOURCE_TYPE_LABEL[bv] || bv; }
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [data, headerFilters, sortState]);
// 마스터 컬럼별 고유값 (헤더 필터용)
const masterUniqueValues = useMemo(() => {
// 컬럼별 고유값 (헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
const seenMasters = new Map<string, OutboundItem>();
data.forEach((row) => {
if (row.outbound_number && !seenMasters.has(row.outbound_number)) {
seenMasters.set(row.outbound_number, row);
}
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "outbound_number", label: "출고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) {
for (const col of GRID_COLUMNS) {
const values = new Set<string>();
masters.forEach((m) => {
const val = (m as any)?.[col.key];
flatRows.forEach((row) => {
const val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
}, [flatRows]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
const values = new Set<string>();
data.forEach((row) => {
let val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "source_type") val = SOURCE_TYPE_LABEL[val] || val;
values.add(String(val));
}
// 필터 + 정렬 적용된 플랫 데이터
const filteredRows = useMemo(() => {
let rows = [...flatRows];
// 1차: 헤더 필터 적용
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
rows = rows.filter((row) => {
const cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
return values.has(cellVal);
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
// 2차: 정렬
if (sortState) {
const { key, direction } = sortState;
rows.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}
return rows;
}, [flatRows, headerFilters, sortState]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
@@ -938,7 +816,7 @@ export default function OutboundPage() {
dataCount={data.length}
/>
{/* 출고 목록 테이블 */}
{/* 출고 목록 테이블 (플랫 리스트) */}
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
{/* 패널 헤더 */}
<div className="flex items-center justify-between border-b bg-muted/30 px-4 py-2.5 shrink-0">
@@ -946,7 +824,7 @@ export default function OutboundPage() {
<PackageOpen className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<span className="rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
{data.length}
{filteredRows.length}
</span>
</div>
<div className="flex gap-2">
@@ -971,78 +849,68 @@ export default function OutboundPage() {
</div>
<div className="h-full overflow-auto">
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<Table noWrapper style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 출고번호 */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("outbound_number")}>
<span className="truncate"></span>
{sortState?.key === "outbound_number" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["outbound_number"] || []).length > 0 && (
<HeaderFilterPopover
colKey="outbound_number" colLabel="출고번호"
uniqueValues={masterUniqueValues["outbound_number"] || []}
filterValues={headerFilters["outbound_number"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
{GRID_COLUMNS.map((col) => {
const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key);
return (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none", isRight && "text-right")}>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(columnUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={columnUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
@@ -1052,7 +920,7 @@ export default function OutboundPage() {
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredGroups).length === 0 ? (
) : filteredRows.length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
@@ -1062,214 +930,59 @@ export default function OutboundPage() {
</TableCell>
</TableRow>
) : (
Object.entries(filteredGroups).map(([outboundNo, group]) => {
const isExpanded = expandedOrders.has(outboundNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
filteredRows.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={outboundNo}>
{/* 마스터 행 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(outboundNo)) {
setClosingOrders((prev) => new Set(prev).add(outboundNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(outboundNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(outboundNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(outboundNo));
}
}}
onDoubleClick={() => openEditModal(group.details[0])}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 출고번호 */}
<TableCell className="font-mono whitespace-nowrap">
{outboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "outbound_type": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{resolveCat("outbound_type", master.outbound_type) || "-"}
</Badge>
</TableCell>
);
case "outbound_date": return (
<TableCell key={col.key} className="whitespace-nowrap text-[13px]">
{master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "customer_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.customer_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "outbound_status": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(master.outbound_status))}>
{master.outbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground">
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(outboundNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell />
{visibleDetailCols.map((col) => {
const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => {
let v = (d as any)[col.key];
if (col.key === "source_type") v = SOURCE_TYPE_LABEL[v] || v;
return v;
}).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
</TableRow>
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(outboundNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={`${row.id}-${detailIdx}`}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_type": return <TableCell key={col.key} className="text-[13px]">{row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}</TableCell>;
case "item_code": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_code || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "specification": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.specification || ""}</TableCell>;
case "outbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
})}
</React.Fragment>
}}
onDoubleClick={() => openEditModal(row as any)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="font-mono whitespace-nowrap text-[13px]">{row.outbound_number || ""}</TableCell>
<TableCell className="text-[13px]">
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(row.outbound_type))}>
{row.outbound_type || "-"}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-[13px]">
{row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""}
</TableCell>
<TableCell className="text-[13px] truncate max-w-[120px]"><span className="block truncate">{row.reference_number || ""}</span></TableCell>
<TableCell className="text-[13px]">{row.source_type || ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[110px]"><span className="block truncate">{row.customer_name || ""}</span></TableCell>
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
<TableCell className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[100px]"><span className="block truncate">{row.warehouse_name || row.warehouse_code || ""}</span></TableCell>
<TableCell className="text-[13px]">
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(row.outbound_status))}>
{row.outbound_status || "-"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.remark || row.memo || ""}</span></TableCell>
</TableRow>
);
})
)}
@@ -43,7 +43,6 @@ import {
X,
Save,
ChevronRight,
ChevronDown,
ChevronLeft,
ChevronsLeft,
ChevronsRight,
@@ -64,6 +63,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea
import {
getReceivingList,
createReceiving,
updateReceiving,
deleteReceiving,
generateReceivingNumber,
getReceivingWarehouses,
@@ -95,41 +95,8 @@ const GRID_COLUMNS = [
{ key: "remark", label: "비고" },
];
// 마스터 헤더 레이아웃 (입고번호 뒤, 디테일 7컬럼 위에 colSpan으로 맵핑)
const MASTER_BODY_LAYOUT = [
{ key: "inbound_type", label: "입고유형", colSpan: 1 },
{ key: "inbound_date", label: "입고일", colSpan: 1 },
{ key: "reference_number", label: "참조번호", colSpan: 1 },
{ key: "supplier_name", label: "공급처", colSpan: 1 },
{ key: "warehouse_name", label: "창고", colSpan: 1 },
{ key: "inbound_status", label: "입고상태", colSpan: 1 },
{ key: "memo", label: "비고", colSpan: 1 },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "source_table", label: "출처" },
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "spec", label: "규격" },
{ key: "inbound_qty", label: "입고수량" },
{ key: "unit_price", label: "단가" },
{ key: "total_amount", label: "금액" },
];
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_table",
item_number: "item_number",
item_name: "item_name",
spec: "spec",
inbound_qty: "inbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16
const TOTAL_COLS = 16;
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -287,30 +254,6 @@ interface SelectedSourceItem {
export default function ReceivingPage() {
const ts = useTableSettings("c16-receiving", "inbound_mng", GRID_COLUMNS);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<InboundItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -319,9 +262,7 @@ export default function ReceivingPage() {
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 마스터-디테일 그룹 테이블
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
// 헤더 필터 & 정렬
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
@@ -338,6 +279,10 @@ export default function ReceivingPage() {
const [selectedItems, setSelectedItems] = useState<SelectedSourceItem[]>([]);
const [saving, setSaving] = useState(false);
// 수정 모드
const [editMode, setEditMode] = useState(false);
const [editItemIds, setEditItemIds] = useState<string[]>([]);
// 소스 데이터
const [sourceKeyword, setSourceKeyword] = useState("");
const [sourceLoading, setSourceLoading] = useState(false);
@@ -377,7 +322,7 @@ export default function ReceivingPage() {
Promise.all(
["material", "unit"].map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_7`);
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_16`);
const items = flatten(res.data?.data || []);
map[col] = {};
for (const item of items) map[col][item.code] = item.label;
@@ -434,124 +379,56 @@ export default function ReceivingPage() {
})();
}, []);
// 필터 + 정렬 적용된 데이터 -> 그룹핑
const filteredGroups = useMemo(() => {
// 1차: inbound_number 기준 그룹핑
const allGroups: Record<string, { master: InboundItem; details: InboundItem[] }> = {};
for (const row of data) {
const key = row.inbound_number || "_no_inbound";
if (!allGroups[key]) {
allGroups[key] = { master: row, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
let raw = (group.master as any)?.[colKey] ?? "";
// 입고유형은 코드→라벨 변환된 값으로 비교
if (colKey === "inbound_type") raw = resolveInboundType(String(raw));
return values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([inboundNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
let cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
if (colKey === "source_table") cellVal = SOURCE_TABLE_LABEL[cellVal] || cellVal;
return values.has(cellVal);
})
);
return [inboundNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
entries.sort(([, a], [, b]) => {
let av: any = (a.master as any)?.[key] ?? "";
let bv: any = (b.master as any)?.[key] ?? "";
if (key === "inbound_type") { av = resolveInboundType(String(av)); bv = resolveInboundType(String(bv)); }
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
} else {
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [data, headerFilters, sortState]);
// 마스터 컬럼별 고유값 (마스터 헤더 필터용)
const masterUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
const seenMasters = new Map<string, InboundItem>();
data.forEach((row) => {
if (row.inbound_number && !seenMasters.has(row.inbound_number)) {
seenMasters.set(row.inbound_number, row);
}
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "inbound_number", label: "입고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) {
const values = new Set<string>();
masters.forEach((m) => {
let val = (m as any)?.[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "inbound_type") val = resolveInboundType(String(val));
values.add(String(val));
}
});
result[col.key] = Array.from(values).sort();
}
return result;
// 플랫 행 생성 (입고유형 코드→라벨 변환, source_table→한글 등)
const flatRows = useMemo(() => {
return data.map((row) => ({
...row,
inbound_type: resolveInboundType(row.inbound_type),
source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "",
}));
}, [data]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
// 컬럼별 고유값 (헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
for (const col of GRID_COLUMNS) {
const values = new Set<string>();
data.forEach((row) => {
let val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "source_table") val = SOURCE_TABLE_LABEL[val] || val;
values.add(String(val));
}
flatRows.forEach((row) => {
const val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
}, [flatRows]);
// 필터 + 정렬 적용된 플랫 데이터
const filteredRows = useMemo(() => {
let rows = [...flatRows];
// 1차: 헤더 필터 적용
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
rows = rows.filter((row) => {
const cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
return values.has(cellVal);
});
}
// 2차: 정렬
if (sortState) {
const { key, direction } = sortState;
rows.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}
return rows;
}, [flatRows, headerFilters, sortState]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
@@ -633,6 +510,8 @@ export default function ReceivingPage() {
const openRegisterModal = async () => {
const defaultType = "구매입고";
setEditMode(false);
setEditItemIds([]);
setModalInboundType(defaultType);
setModalInboundDate(new Date().toISOString().split("T")[0]);
setModalWarehouse("");
@@ -661,6 +540,51 @@ export default function ReceivingPage() {
}
};
// 수정 모달 열기
const openEditModal = (row: InboundItem) => {
const inNo = row.inbound_number;
const grouped = data.filter((d) => d.inbound_number === inNo);
const first = grouped[0] || row;
setEditMode(true);
setEditItemIds(grouped.map((g) => g.id));
setModalInboundNo(inNo);
setModalInboundType(first.inbound_type || "구매입고");
setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : "");
setModalWarehouse(first.warehouse_code || "");
setModalLocation(first.location_code || "");
setModalInspector((first as any).inspector || "");
setModalManager((first as any).manager || "");
setModalMemo(first.memo || "");
setSelectedItems(
grouped.map((g) => ({
key: g.id,
inbound_type: g.inbound_type || "",
reference_number: g.reference_number || "",
supplier_code: (g as any).supplier_code || "",
supplier_name: g.supplier_name || "",
item_number: g.item_number || "",
item_name: g.item_name || "",
spec: g.spec || "",
material: (g as any).material || "",
unit: (g as any).unit || "",
inbound_qty: Number(g.inbound_qty) || 0,
unit_price: Number(g.unit_price) || 0,
total_amount: Number(g.total_amount) || 0,
source_table: (g as any).source_table || "",
source_id: (g as any).source_id || "",
}))
);
setSourceKeyword("");
setPurchaseOrders([]);
setShipments([]);
setItems([]);
setSourcePage(1);
setSourceTotalCount(0);
setIsModalOpen(true);
loadSourceData(first.inbound_type || "구매입고", undefined, 1);
};
// 검색 버튼 클릭 시
const searchSourceData = useCallback(async () => {
setSourcePage(1);
@@ -811,41 +735,97 @@ export default function ReceivingPage() {
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: selectedItems.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
});
if (editMode) {
// 기존 item 수정 + 삭제된 item 삭제 + 새 item 추가
const currentKeys = new Set(selectedItems.map((i) => i.key));
const toDelete = editItemIds.filter((id) => !currentKeys.has(id));
const toUpdate = selectedItems.filter((i) => editItemIds.includes(i.key));
const toCreate = selectedItems.filter((i) => !editItemIds.includes(i.key));
if (res.success) {
alert(res.message || "입고 등록 완료");
await Promise.all([
...toDelete.map((id) => deleteReceiving(id)),
...toUpdate.map((item) =>
updateReceiving(item.key, {
inbound_date: modalInboundDate,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
warehouse_code: modalWarehouse || undefined,
location_code: modalLocation || undefined,
memo: modalMemo || undefined,
} as any)
),
...(toCreate.length > 0
? [createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: toCreate.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
})]
: []),
]);
toast.success("입고 정보를 수정했어요");
setIsModalOpen(false);
fetchList();
} else {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: selectedItems.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
});
if (res.success) {
toast.success(res.message || "입고 등록 완료");
setIsModalOpen(false);
fetchList();
}
}
} catch {
alert("입고 등록 중 오류가 발생했습니다.");
} catch (err: any) {
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "입고 등록 중 오류가 발생했습니다.");
toast.error(msg);
} finally {
setSaving(false);
}
@@ -897,13 +877,13 @@ export default function ReceivingPage() {
}
/>
{/* 입고 목록 테이블 (마스터-디테일 그룹) */}
{/* 입고 목록 테이블 (플랫 리스트) */}
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
<div className="flex items-center justify-between border-b bg-muted/50 px-4 py-2.5">
<div className="flex items-center gap-2">
<h3 className="text-[13px] font-bold"> </h3>
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
{Object.keys(filteredGroups).length}
{filteredRows.length}
</span>
</div>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
@@ -912,78 +892,68 @@ export default function ReceivingPage() {
</div>
<div className="h-[calc(100%-44px)] overflow-auto">
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<Table noWrapper style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 입고번호 (별도 컬럼) */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("inbound_number")}>
<span className="truncate"></span>
{sortState?.key === "inbound_number" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["inbound_number"] || []).length > 0 && (
<HeaderFilterPopover
colKey="inbound_number" colLabel="입고번호"
uniqueValues={masterUniqueValues["inbound_number"] || []}
filterValues={headerFilters["inbound_number"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
{GRID_COLUMNS.map((col) => {
const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key);
return (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none", isRight && "text-right")}>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(columnUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={columnUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
@@ -993,7 +963,7 @@ export default function ReceivingPage() {
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredGroups).length === 0 ? (
) : filteredRows.length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
@@ -1003,212 +973,59 @@ export default function ReceivingPage() {
</TableCell>
</TableRow>
) : (
Object.entries(filteredGroups).map(([inboundNo, group]) => {
const isExpanded = expandedOrders.has(inboundNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
filteredRows.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={inboundNo}>
{/* 마스터 행 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(inboundNo)) {
setClosingOrders((prev) => new Set(prev).add(inboundNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(inboundNo));
}
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row as any)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 입고번호 */}
<TableCell className="font-mono whitespace-nowrap">
{inboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "inbound_type": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getTypeVariant(resolveInboundType(master.inbound_type))} className="text-[11px]">
{resolveInboundType(master.inbound_type)}
</Badge>
</TableCell>
);
case "inbound_date": return (
<TableCell key={col.key} className="text-[13px] whitespace-nowrap">
{master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "supplier_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.supplier_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "inbound_status": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getStatusVariant(master.inbound_status)} className="text-[11px]">
{master.inbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground text-[13px]">
<span className="block truncate max-w-[110px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(inboundNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell />
{visibleDetailCols.map((col) => {
const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => {
let v = (d as any)[col.key];
if (col.key === "source_table") v = SOURCE_TABLE_LABEL[v] || v;
return v;
}).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
</TableRow>
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(inboundNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={`${row.id}-${detailIdx}`}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_table": return <TableCell key={col.key} className="text-[13px]">{row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}</TableCell>;
case "item_number": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_number || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[160px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "spec": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>;
case "inbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
);
})}
</React.Fragment>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="font-mono whitespace-nowrap text-[13px]">{row.inbound_number || ""}</TableCell>
<TableCell className="text-[13px]">
<Badge variant={getTypeVariant(row.inbound_type)} className="text-[11px]">
{row.inbound_type || "-"}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-[13px]">
{row.inbound_date ? new Date(row.inbound_date).toLocaleDateString("ko-KR") : ""}
</TableCell>
<TableCell className="text-[13px] truncate max-w-[120px]"><span className="block truncate">{row.reference_number || ""}</span></TableCell>
<TableCell className="text-[13px]">{row.source_type || ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[110px]"><span className="block truncate">{row.supplier_name || ""}</span></TableCell>
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
<TableCell className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[100px]"><span className="block truncate">{row.warehouse_name || (row as any).warehouse_code || ""}</span></TableCell>
<TableCell className="text-[13px]">
<Badge variant={getStatusVariant(row.inbound_status)} className="text-[11px]">
{row.inbound_status || "-"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.remark || row.memo || ""}</span></TableCell>
</TableRow>
);
})
)}
@@ -1221,9 +1038,9 @@ export default function ReceivingPage() {
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="sm:max-w-[1600px] w-[95vw] h-[90vh] p-0 flex flex-col overflow-hidden">
<DialogHeader className="shrink-0 px-6 pt-4 pb-2 border-b">
<DialogTitle> </DialogTitle>
<DialogTitle>{editMode ? "입고 수정" : "입고 등록"}</DialogTitle>
<DialogDescription>
, .
{editMode ? "입고 정보를 수정해 주세요." : "입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요."}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col">
@@ -1053,14 +1053,14 @@ export default function ProductionPlanManagementPage() {
return (
<Table style={{ minWidth: "900px" }}>
<TableHeader className="sticky top-0 z-10">
<TableHeader className="sticky top-0 z-20">
<TableRow className="bg-muted hover:bg-muted">
<TableHead style={{ width: "30px", minWidth: "30px" }}>
<TableHead className="sticky left-0 z-20 bg-muted" style={{ width: "30px", minWidth: "30px" }}>
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead style={{ width: "40px", minWidth: "40px" }} />
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="sticky left-[30px] z-20 bg-muted" style={{ width: "40px", minWidth: "40px" }} />
<TableHead className="sticky left-[70px] z-20 bg-muted text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={{ width: "140px", minWidth: "140px" }}></TableHead>
<TableHead className="sticky left-[210px] z-20 bg-muted text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground border-r" style={{ width: "140px", minWidth: "140px" }}></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} style={{ ...ts.thStyle(col.key), minWidth: "80px" }} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
{col.label}
@@ -1073,9 +1073,9 @@ export default function ProductionPlanManagementPage() {
if (item._isGroupSummary) {
return (
<TableRow key={`summary-${rowIdx}`} className="bg-muted/60 font-semibold border-t border-primary/20">
<TableCell />
<TableCell />
<TableCell colSpan={2} />
<TableCell className="sticky left-0 z-10 bg-muted/60" />
<TableCell className="sticky left-[30px] z-10 bg-muted/60" />
<TableCell colSpan={2} className="sticky left-[70px] z-10 bg-muted/60 border-r" />
{ts.visibleColumns.map((col) => {
const v = (item as any)[col.key];
return (
@@ -1090,14 +1090,14 @@ export default function ProductionPlanManagementPage() {
return (
<React.Fragment key={item.item_code || rowIdx}>
<TableRow className={cn("cursor-pointer border-t-2 border-t-primary/30 bg-primary/5 font-semibold hover:bg-primary/10", selectedItemGroups.has(item.item_code) && "bg-primary/10")}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<TableCell className="sticky left-0 z-10 bg-background text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selectedItemGroups.has(item.item_code)} onCheckedChange={() => toggleItemGroupSelect(item.item_code)} className="h-4 w-4" />
</TableCell>
<TableCell className="text-center" onClick={() => toggleItemExpand(item.item_code)}>
<TableCell className="sticky left-[30px] z-10 bg-background text-center" onClick={() => toggleItemExpand(item.item_code)}>
<ChevronRight className={cn("h-4 w-4 transition-transform duration-200", expandedItems.has(item.item_code) && "rotate-90")} />
</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
<TableCell className="sticky left-[70px] z-10 bg-background text-[13px] text-primary truncate" style={{ width: "140px", minWidth: "140px" }} onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell className="sticky left-[210px] z-10 bg-background text-[13px] text-primary truncate border-r" style={{ width: "140px", minWidth: "140px" }} onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
{ts.visibleColumns.map((col) => renderGroupCell(col, item))}
</TableRow>
{expandedItems.has(item.item_code) && item.orders?.map((detail: any) => {
@@ -1107,9 +1107,9 @@ export default function ProductionPlanManagementPage() {
}
return (
<TableRow key={detail.id || detail.order_no} className="cursor-pointer hover:bg-muted/50">
<TableCell />
<TableCell />
<TableCell colSpan={2} className="pl-10">
<TableCell className="sticky left-0 z-10 bg-card" />
<TableCell className="sticky left-[30px] z-10 bg-card" />
<TableCell colSpan={2} className="sticky left-[70px] z-10 bg-card pl-10 border-r">
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{detail.order_no}</span>
@@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Package, ChevronDown,
ClipboardList, Pencil, Search, X, Package,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2, GripVertical,
} from "lucide-react";
@@ -88,18 +88,18 @@ const GRID_COLUMNS_CONFIG = [
];
const MODAL_DETAIL_COLUMNS = [
{ key: "item_code", label: "품번", width: "w-[120px]" },
{ key: "item_name", label: "품명", width: "w-[120px]" },
{ key: "supplier", label: "공급업체", width: "w-[150px]" },
{ key: "spec", label: "규격", width: "w-[80px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "order_qty", label: "발주수량", width: "w-[90px]" },
{ key: "received_qty", label: "입고수량", width: "w-[90px]" },
{ key: "remain_qty", label: "잔량", width: "w-[80px]" },
{ key: "unit_price", label: "단가", width: "w-[100px]" },
{ key: "amount", label: "금액", width: "w-[100px]" },
{ key: "due_date", label: "납기일", width: "w-[160px]" },
{ key: "memo", label: "메모", width: "w-[120px]" },
{ key: "item_code", label: "품번", width: "min-w-[120px]" },
{ key: "item_name", label: "품명", width: "min-w-[150px]" },
{ key: "supplier", label: "공급업체", width: "min-w-[150px]" },
{ key: "spec", label: "규격", width: "min-w-[80px]" },
{ key: "unit", label: "단위", width: "min-w-[90px]" },
{ key: "order_qty", label: "발주수량", width: "min-w-[90px]" },
{ key: "received_qty", label: "입고수량", width: "min-w-[90px]" },
{ key: "remain_qty", label: "잔량", width: "min-w-[80px]" },
{ key: "unit_price", label: "단가", width: "min-w-[100px]" },
{ key: "amount", label: "금액", width: "min-w-[100px]" },
{ key: "due_date", label: "납기일", width: "min-w-[160px]" },
{ key: "memo", label: "메모", width: "min-w-[120px]" },
];
const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16";
@@ -162,7 +162,6 @@ export default function PurchaseOrderPage() {
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
// 테이블 설정
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
@@ -238,7 +237,7 @@ export default function PurchaseOrderPage() {
);
try {
const suppRes = await apiClient.post(`/table-management/tables/supplier_mng/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 5000, autoFilter: true,
});
const supps = suppRes.data?.data?.data || suppRes.data?.data?.rows || [];
optMap["supplier_code"] = supps.map((s: any) => ({
@@ -248,7 +247,7 @@ export default function PurchaseOrderPage() {
} catch { /* skip */ }
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 5000, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
optMap["manager"] = users.map((u: any) => ({
@@ -294,7 +293,7 @@ export default function PurchaseOrderPage() {
}
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: detailFilters.length > 0 ? { enabled: true, filters: detailFilters } : undefined,
autoFilter: true,
sort: { columnName: "purchase_no", order: "desc" },
@@ -372,20 +371,6 @@ export default function PurchaseOrderPage() {
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// purchase_no 기준 그룹핑
const orderGroups = useMemo(() => {
const map: Record<string, { master: any; details: any[] }> = {};
for (const row of orders) {
const key = row.purchase_no || row.id;
if (!map[key]) {
map[key] = {
master: { purchase_no: row.purchase_no, order_date: row.order_date, supplier_name: row.supplier_name, status: row.status, memo: row.memo },
details: [],
};
}
map[key].details.push(row);
}
return map;
}, [orders]);
const getCategoryLabel = (col: string, code: string) => {
if (!code) return "";
@@ -553,7 +538,7 @@ export default function PurchaseOrderPage() {
}
};
// 품목 검색
// 품목 검색 (수주관리와 동일한 서버 페이징 방식)
const searchItems = async (page?: number, size?: number) => {
const p = page ?? itemPage;
const s = size ?? itemPageSize;
@@ -563,25 +548,24 @@ export default function PurchaseOrderPage() {
if (itemSearchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
}
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
const divValues = [itemSearchDivision];
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
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)));
const rows = resData?.data || resData?.rows || [];
const serverTotal = resData?.total || resData?.totalCount || rows.length;
setItemSearchResults(rows);
setItemTotal(serverTotal);
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
@@ -623,7 +607,7 @@ export default function PurchaseOrderPage() {
try {
const itemIds = selected.map((item) => item.item_number || item.id);
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: {
enabled: true,
filters: [
@@ -686,7 +670,7 @@ export default function PurchaseOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
autoFilter: true,
});
@@ -708,7 +692,7 @@ export default function PurchaseOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: supplierCode },
{ columnName: "item_id", operator: "in", value: itemCodes },
@@ -811,125 +795,83 @@ export default function PurchaseOrderPage() {
</div>
</div>
{/* 데이터 테이블 — 아코디언 그룹핑 */}
{/* 데이터 테이블 — 플랫 리스트 */}
<div className="flex-1 overflow-auto border rounded-lg bg-card">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : Object.keys(orderGroups).length === 0 ? (
) : orders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Package className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
(() => {
const MASTER_KEYS = new Set(["purchase_no", "order_date", "supplier_name", "status", "memo"]);
const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]);
// ts.visibleColumns 순서를 따르되, 마스터/디테일 컬럼을 분리
// 고정 컬럼(품목수)은 마스터 선행 컬럼 뒤에 배치
const leadingMaster: typeof ts.visibleColumns = [];
const detailCols: typeof ts.visibleColumns = [];
const trailingMaster: typeof ts.visibleColumns = [];
let passedFirstDetail = false;
for (const col of ts.visibleColumns) {
if (MASTER_KEYS.has(col.key)) {
if (passedFirstDetail) trailingMaster.push(col);
else leadingMaster.push(col);
} else {
passedFirstDetail = true;
detailCols.push(col);
}
}
const renderDetailCell = (row: any, key: string) => {
const renderCell = (row: any, key: string) => {
const val = row[key];
if (key === "status") return val ? <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[val] || "")}>{val}</span> : "-";
if (numCols.has(key)) return <span className="font-mono">{val ? Number(val).toLocaleString() : "0"}</span>;
return val || "-";
if (key === "order_date" || key === "due_date") return val ? new Date(val).toLocaleDateString("ko-KR") : "-";
if (numCols.has(key)) return <span className="font-mono">{val ? Number(val).toLocaleString() : ""}</span>;
return val || "";
};
const renderMasterHead = (col: { key: string; label: string }) => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", col.key === "status" && "text-center")}>
{col.label}
</TableHead>
);
const renderMasterCell = (col: { key: string }, m: any, purchaseNo: string) => {
if (col.key === "purchase_no") return <TableCell key={col.key} className="text-sm font-semibold text-primary">{purchaseNo}</TableCell>;
if (col.key === "order_date") return <TableCell key={col.key} className="text-sm">{m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}</TableCell>;
if (col.key === "supplier_name") return <TableCell key={col.key} className="text-sm">{m.supplier_name || "-"}</TableCell>;
if (col.key === "status") return <TableCell key={col.key} className="text-center">{m.status && <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[m.status] || "")}>{m.status}</span>}</TableCell>;
if (col.key === "memo") return <TableCell key={col.key} className="text-sm text-muted-foreground max-w-[120px] truncate">{m.memo || ""}</TableCell>;
return <TableCell key={col.key} />;
};
return (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-8" />
<TableHead className="w-10" />
{leadingMaster.map(renderMasterHead)}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground text-center"></TableHead>
{detailCols.map(col => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", numCols.has(col.key) && "text-right")}>
{col.label}{col.key === "order_qty" || col.key === "amount" ? " 합계" : ""}
<TableHead
className="w-10 text-center cursor-pointer"
onClick={() => {
const allIds = orders.map((r) => r.id);
const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allIds);
}}
>
<Checkbox
checked={orders.length > 0 && orders.every((r) => checkedIds.includes(r.id))}
onCheckedChange={() => {}}
/>
</TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", numCols.has(col.key) && "text-right", col.key === "status" && "text-center")}>
{col.label}
</TableHead>
))}
{trailingMaster.map(renderMasterHead)}
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(orderGroups).map(([purchaseNo, group]) => {
const isExpanded = expandedOrders.has(purchaseNo);
const detailIds = group.details.map(d => d.id);
const allChecked = detailIds.length > 0 && detailIds.every(id => checkedIds.includes(id));
const someChecked = detailIds.some(id => checkedIds.includes(id));
const m = group.master;
const totalQty = group.details.reduce((s, d) => s + (Number(d.order_qty) || 0), 0);
const totalAmt = group.details.reduce((s, d) => s + (Number(d.amount) || 0), 0);
{orders.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={purchaseNo}>
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn("cursor-pointer border-l-[3px] border-l-transparent font-semibold", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedOrders(prev => { const next = new Set(prev); if (next.has(purchaseNo)) next.delete(purchaseNo); else next.add(purchaseNo); return next; })}
onDoubleClick={() => openEditModal(purchaseNo)}
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row.purchase_no)}
>
<TableCell
className="text-center"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
{ts.visibleColumns.map((col) => (
<TableCell key={col.key} className={cn("text-[13px]", numCols.has(col.key) && "text-right font-mono", col.key === "status" && "text-center")}>
{renderCell(row, col.key)}
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !detailIds.includes(id)) : [...new Set([...prev, ...detailIds])]); }}>
<Checkbox checked={allChecked} data-state={someChecked && !allChecked ? "indeterminate" : undefined} onCheckedChange={() => {}} />
</TableCell>
{leadingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
<TableCell className="text-sm text-center"><Badge variant="secondary" className="text-[10px]">{group.details.length}</Badge></TableCell>
{detailCols.map(col => (
<TableCell key={col.key} className={cn("text-sm", numCols.has(col.key) && "text-right font-mono")}>
{col.key === "order_qty" ? totalQty.toLocaleString()
: col.key === "amount" ? totalAmt.toLocaleString()
: ""}
</TableCell>
))}
{trailingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
</TableRow>
{isExpanded && group.details.map((row) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell className="text-center" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id]); }}>
<Checkbox checked={checkedIds.includes(row.id)} onCheckedChange={() => {}} />
</TableCell>
{leadingMaster.map(col => <TableCell key={col.key} />)}
<TableCell />
{detailCols.map(col => (
<TableCell key={col.key} className={cn(numCols.has(col.key) && "text-right")}>
{renderDetailCell(row, col.key)}
</TableCell>
))}
{trailingMaster.map(col => <TableCell key={col.key} />)}
</TableRow>
))}
</React.Fragment>
</TableRow>
);
})}
</TableBody>
@@ -938,7 +880,7 @@ export default function PurchaseOrderPage() {
})()
)}
<div className="px-4 py-2 border-t text-xs text-muted-foreground">
{Object.keys(orderGroups).length} ( ) / {orders.length} ( )
{orders.length}
</div>
</div>
@@ -1094,7 +1036,7 @@ export default function PurchaseOrderPage() {
) : (
<div className="border rounded-lg overflow-x-auto">
<DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
<Table className="table-fixed">
<Table>
<TableHeader className="sticky top-0 z-10">
<SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableRow className="bg-muted hover:bg-muted">
@@ -1118,12 +1060,12 @@ export default function PurchaseOrderPage() {
{visibleModalColumns.map((col) => {
switch (col.key) {
case "item_code":
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
return <TableCell key={col.key} className="text-[13px] font-mono whitespace-nowrap"><span title={row.item_code}>{row.item_code}</span></TableCell>;
case "item_name":
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
return <TableCell key={col.key} className="text-[13px] whitespace-nowrap"><span title={row.item_name}>{row.item_name}</span></TableCell>;
case "supplier":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[150px]">
{isReadOnly ? (
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
) : (
@@ -1133,7 +1075,7 @@ export default function PurchaseOrderPage() {
updateDetailRow(idx, "supplier_code", v);
updateDetailRow(idx, "supplier_name", name);
}}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectTrigger className="h-8 text-xs w-full"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
@@ -1149,11 +1091,11 @@ export default function PurchaseOrderPage() {
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
case "order_qty":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[110px]">
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono w-full" />
)}
</TableCell>
);
@@ -1163,11 +1105,11 @@ export default function PurchaseOrderPage() {
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":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[120px]">
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{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" />
<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" />
)}
</TableCell>
);
@@ -1175,21 +1117,21 @@ export default function PurchaseOrderPage() {
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
case "due_date":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[160px]">
{isReadOnly ? (
<span className="text-xs">{row.due_date}</span>
) : (
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs w-full" />
)}
</TableCell>
);
case "memo":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[140px]">
{isReadOnly ? (
<span className="text-xs">{row.memo}</span>
) : (
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs w-full" />
)}
</TableCell>
);
@@ -424,9 +424,10 @@ export default function ItemInspectionInfoPage() {
case "inspection_type": return (
<TableCell key={col.key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>
))}
{group.types.map((t: string) => {
const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t;
return <Badge key={t} variant="secondary" className="text-[10px]">{label}</Badge>;
})}
</div>
</TableCell>
);
@@ -494,7 +495,7 @@ export default function ItemInspectionInfoPage() {
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
)}
>
{type}
{inspTypeCatOptions.find((o) => o.code === type)?.label || type}
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
@@ -624,6 +624,15 @@ export default function SalesOrderPage() {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
// 코드 또는 라벨이 저장된 경우 모두 조회하기 위해 in 연산자 사용
const divValues = [itemSearchDivision];
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const partnerId = masterForm.partner_id;
@@ -632,7 +641,7 @@ export default function SalesOrderPage() {
if (isCustomerPrice && partnerId) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
autoFilter: true,
});
@@ -642,31 +651,22 @@ export default function SalesOrderPage() {
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
let allRows = resData?.data || resData?.rows || [];
let rows = resData?.data || resData?.rows || [];
const serverTotal = resData?.total || resData?.totalCount || rows.length;
// 거래처우선일 때 연결된 품목만 표시
// 거래처우선일 때 연결된 품목만 표시 (클라이언트 필터)
if (customerItemIds) {
allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
rows = rows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
}
// 관리품목 필터 (코드/라벨 혼재 대응)
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)));
setItemSearchResults(rows);
setItemTotal(serverTotal);
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
@@ -1320,44 +1320,44 @@ export default function SalesOrderPage() {
<Table noWrapper className="min-w-max w-full">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="w-28 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-32 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-32 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">/</TableHead>
<TableHead className="min-w-[40px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[120px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[110px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">/</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
<TableCell className="text-center font-mono text-[13px] text-muted-foreground/50">{idx + 1}</TableCell>
<TableCell className="max-w-[112px]">
<span className="block truncate font-mono text-[13px]" title={row.part_code}>{row.part_code}</span>
<TableCell className="whitespace-nowrap">
<span className="font-mono text-[13px]" title={row.part_code}>{row.part_code}</span>
</TableCell>
<TableCell className="max-w-[128px]">
<span className="block truncate text-[13px]" title={row.part_name}>{row.part_name}</span>
<TableCell className="whitespace-nowrap">
<span className="text-[13px]" title={row.part_name}>{row.part_name}</span>
</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.spec}</TableCell>
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.material}</TableCell>
<TableCell>
<Input
value={row.packing_material || ""}
onChange={(e) => updateDetailRow(idx, "packing_material", e.target.value)}
placeholder="포장재"
className="h-8 text-xs"
className="h-8 text-xs w-full"
/>
</TableCell>
<TableCell>
<Select value={row.unit || ""} onValueChange={(v) => updateDetailRow(idx, "unit", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="단위" /></SelectTrigger>
<SelectTrigger className="h-8 text-xs w-full"><SelectValue placeholder="단위" /></SelectTrigger>
<SelectContent>
{(categoryOptions["item_unit"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
@@ -1371,7 +1371,7 @@ export default function SalesOrderPage() {
min="1"
value={row.qty || "1"}
onChange={(e) => updateDetailRow(idx, "qty", e.target.value)}
className="h-8 text-xs text-right font-mono w-16"
className="h-8 text-xs text-right font-mono w-full"
/>
</TableCell>
<TableCell>
@@ -1380,7 +1380,7 @@ export default function SalesOrderPage() {
min="0"
value={row.pack_qty || "0"}
onChange={(e) => updateDetailRow(idx, "pack_qty", e.target.value)}
className="h-8 text-xs text-right font-mono w-16"
className="h-8 text-xs text-right font-mono w-full"
/>
</TableCell>
<TableCell>
@@ -1388,10 +1388,10 @@ export default function SalesOrderPage() {
value={formatNumber(row.unit_price || "")}
onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))}
readOnly={!allowPriceEdit}
className={cn("h-8 text-xs text-right font-mono w-20", !allowPriceEdit && "bg-muted cursor-not-allowed")}
className={cn("h-8 text-xs text-right font-mono w-full", !allowPriceEdit && "bg-muted cursor-not-allowed")}
/>
</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">
<TableCell className="text-right font-mono text-[13px] font-semibold whitespace-nowrap">
{row.amount ? Number(row.amount).toLocaleString() : "0"}
</TableCell>
<TableCell>
@@ -1399,7 +1399,7 @@ export default function SalesOrderPage() {
type="date"
value={row.due_date || ""}
onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)}
className="h-8 text-xs"
className="h-8 text-xs w-full"
/>
</TableCell>
<TableCell className="text-center">
@@ -1474,15 +1474,7 @@ export default function SalesOrderPage() {
onKeyDown={(e) => e.key === "Enter" && triggerNewSearch()}
className="h-9 flex-1"
/>
<Select value={itemSearchDivision} onValueChange={setItemSearchDivision}>
<SelectTrigger className="h-9 w-[130px]"><SelectValue placeholder="관리품목" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["item_division"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="h-9 px-3 flex items-center rounded-md border border-input bg-muted text-xs font-medium text-muted-foreground whitespace-nowrap"></div>
<Button size="sm" onClick={triggerNewSearch} disabled={itemSearchLoading} className="h-9">
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /></>}
</Button>
@@ -38,7 +38,6 @@ import {
Save,
ChevronRight,
ChevronLeft,
ChevronDown,
ChevronsLeft,
ChevronsRight,
Inbox,
@@ -116,41 +115,8 @@ const GRID_COLUMNS = [
{ key: "remark", label: "비고" },
];
// 마스터 헤더 레이아웃 (출고번호 뒤)
const MASTER_BODY_LAYOUT = [
{ key: "outbound_type", label: "출고유형", colSpan: 1 },
{ key: "outbound_date", label: "출고일", colSpan: 1 },
{ key: "reference_number", label: "참조번호", colSpan: 1 },
{ key: "customer_name", label: "거래처", colSpan: 1 },
{ key: "warehouse_name", label: "창고", colSpan: 1 },
{ key: "outbound_status", label: "출고상태", colSpan: 1 },
{ key: "memo", label: "비고", colSpan: 1 },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "source_type", label: "출처" },
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "specification", label: "규격" },
{ key: "outbound_qty", label: "출고수량" },
{ key: "unit_price", label: "단가" },
{ key: "total_amount", label: "금액" },
];
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["outbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_type",
item_number: "item_code",
item_name: "item_name",
spec: "specification",
outbound_qty: "outbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16
const TOTAL_COLS = 16;
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -258,30 +224,6 @@ interface SelectedSourceItem {
export default function OutboundPage() {
const ts = useTableSettings("c16-outbound", "outbound_mng", GRID_COLUMNS);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<OutboundItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -290,9 +232,7 @@ export default function OutboundPage() {
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 마스터-디테일 그룹 테이블 state
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
// 헤더 필터 & 정렬
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
@@ -403,121 +343,59 @@ export default function OutboundPage() {
})();
}, []);
// --- 마스터-디테일 그룹핑, 필터, 정렬 ---
// 플랫 행 생성 (출고유형 코드→라벨 변환, source_type→한글 등)
const flatRows = useMemo(() => {
return data.map((row) => ({
...row,
_raw_outbound_type: row.outbound_type,
outbound_type: resolveCat("outbound_type", row.outbound_type) || row.outbound_type || "",
source_type: row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "",
item_number: row.item_code || (row as any).item_number || "",
spec: row.specification || (row as any).spec || "",
}));
}, [data, resolveCat]);
// outbound_number 기준 그룹핑 + 필터 + 정렬
const filteredGroups = useMemo(() => {
// 1차: outbound_number 기준 그룹핑
const allGroups: Record<string, { master: OutboundItem; details: OutboundItem[] }> = {};
for (const row of data) {
const key = row.outbound_number || "_no_number";
if (!allGroups[key]) {
allGroups[key] = { master: row, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
const raw = (group.master as any)?.[colKey] ?? "";
return values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([outNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
let cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
if (colKey === "source_type") cellVal = SOURCE_TYPE_LABEL[cellVal] || cellVal;
return values.has(cellVal);
})
);
return [outNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
entries.sort(([, a], [, b]) => {
const av = (a.master as any)?.[key] ?? "";
const bv = (b.master as any)?.[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
} else {
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
let av: any = (a as any)[key] ?? "";
let bv: any = (b as any)[key] ?? "";
if (key === "source_type") { av = SOURCE_TYPE_LABEL[av] || av; bv = SOURCE_TYPE_LABEL[bv] || bv; }
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [data, headerFilters, sortState]);
// 마스터 컬럼별 고유값 (헤더 필터용)
const masterUniqueValues = useMemo(() => {
// 컬럼별 고유값 (헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
const seenMasters = new Map<string, OutboundItem>();
data.forEach((row) => {
if (row.outbound_number && !seenMasters.has(row.outbound_number)) {
seenMasters.set(row.outbound_number, row);
}
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "outbound_number", label: "출고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) {
for (const col of GRID_COLUMNS) {
const values = new Set<string>();
masters.forEach((m) => {
const val = (m as any)?.[col.key];
flatRows.forEach((row) => {
const val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
}, [flatRows]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
const values = new Set<string>();
data.forEach((row) => {
let val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "source_type") val = SOURCE_TYPE_LABEL[val] || val;
values.add(String(val));
}
// 필터 + 정렬 적용된 플랫 데이터
const filteredRows = useMemo(() => {
let rows = [...flatRows];
// 1차: 헤더 필터 적용
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
rows = rows.filter((row) => {
const cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
return values.has(cellVal);
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
// 2차: 정렬
if (sortState) {
const { key, direction } = sortState;
rows.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}
return rows;
}, [flatRows, headerFilters, sortState]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
@@ -938,7 +816,7 @@ export default function OutboundPage() {
dataCount={data.length}
/>
{/* 출고 목록 테이블 */}
{/* 출고 목록 테이블 (플랫 리스트) */}
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
{/* 패널 헤더 */}
<div className="flex items-center justify-between border-b bg-muted/30 px-4 py-2.5 shrink-0">
@@ -946,7 +824,7 @@ export default function OutboundPage() {
<PackageOpen className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<span className="rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
{data.length}
{filteredRows.length}
</span>
</div>
<div className="flex gap-2">
@@ -971,78 +849,68 @@ export default function OutboundPage() {
</div>
<div className="h-full overflow-auto">
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<Table noWrapper style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 출고번호 */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("outbound_number")}>
<span className="truncate"></span>
{sortState?.key === "outbound_number" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["outbound_number"] || []).length > 0 && (
<HeaderFilterPopover
colKey="outbound_number" colLabel="출고번호"
uniqueValues={masterUniqueValues["outbound_number"] || []}
filterValues={headerFilters["outbound_number"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
{GRID_COLUMNS.map((col) => {
const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key);
return (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none", isRight && "text-right")}>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(columnUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={columnUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
@@ -1052,7 +920,7 @@ export default function OutboundPage() {
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredGroups).length === 0 ? (
) : filteredRows.length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
@@ -1062,214 +930,59 @@ export default function OutboundPage() {
</TableCell>
</TableRow>
) : (
Object.entries(filteredGroups).map(([outboundNo, group]) => {
const isExpanded = expandedOrders.has(outboundNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
filteredRows.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={outboundNo}>
{/* 마스터 행 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(outboundNo)) {
setClosingOrders((prev) => new Set(prev).add(outboundNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(outboundNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(outboundNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(outboundNo));
}
}}
onDoubleClick={() => openEditModal(group.details[0])}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 출고번호 */}
<TableCell className="font-mono whitespace-nowrap">
{outboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "outbound_type": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{resolveCat("outbound_type", master.outbound_type) || "-"}
</Badge>
</TableCell>
);
case "outbound_date": return (
<TableCell key={col.key} className="whitespace-nowrap text-[13px]">
{master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "customer_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.customer_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "outbound_status": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(master.outbound_status))}>
{master.outbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground">
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(outboundNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell />
{visibleDetailCols.map((col) => {
const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => {
let v = (d as any)[col.key];
if (col.key === "source_type") v = SOURCE_TYPE_LABEL[v] || v;
return v;
}).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
</TableRow>
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(outboundNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={`${row.id}-${detailIdx}`}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_type": return <TableCell key={col.key} className="text-[13px]">{row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}</TableCell>;
case "item_code": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_code || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "specification": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.specification || ""}</TableCell>;
case "outbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
})}
</React.Fragment>
}}
onDoubleClick={() => openEditModal(row as any)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="font-mono whitespace-nowrap text-[13px]">{row.outbound_number || ""}</TableCell>
<TableCell className="text-[13px]">
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(row.outbound_type))}>
{row.outbound_type || "-"}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-[13px]">
{row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""}
</TableCell>
<TableCell className="text-[13px] truncate max-w-[120px]"><span className="block truncate">{row.reference_number || ""}</span></TableCell>
<TableCell className="text-[13px]">{row.source_type || ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[110px]"><span className="block truncate">{row.customer_name || ""}</span></TableCell>
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
<TableCell className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[100px]"><span className="block truncate">{row.warehouse_name || row.warehouse_code || ""}</span></TableCell>
<TableCell className="text-[13px]">
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(row.outbound_status))}>
{row.outbound_status || "-"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.remark || row.memo || ""}</span></TableCell>
</TableRow>
);
})
)}
@@ -43,7 +43,6 @@ import {
X,
Save,
ChevronRight,
ChevronDown,
ChevronLeft,
ChevronsLeft,
ChevronsRight,
@@ -64,6 +63,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea
import {
getReceivingList,
createReceiving,
updateReceiving,
deleteReceiving,
generateReceivingNumber,
getReceivingWarehouses,
@@ -95,41 +95,8 @@ const GRID_COLUMNS = [
{ key: "remark", label: "비고" },
];
// 마스터 헤더 레이아웃 (입고번호 뒤, 디테일 7컬럼 위에 colSpan으로 맵핑)
const MASTER_BODY_LAYOUT = [
{ key: "inbound_type", label: "입고유형", colSpan: 1 },
{ key: "inbound_date", label: "입고일", colSpan: 1 },
{ key: "reference_number", label: "참조번호", colSpan: 1 },
{ key: "supplier_name", label: "공급처", colSpan: 1 },
{ key: "warehouse_name", label: "창고", colSpan: 1 },
{ key: "inbound_status", label: "입고상태", colSpan: 1 },
{ key: "memo", label: "비고", colSpan: 1 },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "source_table", label: "출처" },
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "spec", label: "규격" },
{ key: "inbound_qty", label: "입고수량" },
{ key: "unit_price", label: "단가" },
{ key: "total_amount", label: "금액" },
];
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_table",
item_number: "item_number",
item_name: "item_name",
spec: "spec",
inbound_qty: "inbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16
const TOTAL_COLS = 16;
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -287,30 +254,6 @@ interface SelectedSourceItem {
export default function ReceivingPage() {
const ts = useTableSettings("c16-receiving", "inbound_mng", GRID_COLUMNS);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<InboundItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -319,9 +262,7 @@ export default function ReceivingPage() {
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 마스터-디테일 그룹 테이블
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
// 헤더 필터 & 정렬
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
@@ -338,6 +279,10 @@ export default function ReceivingPage() {
const [selectedItems, setSelectedItems] = useState<SelectedSourceItem[]>([]);
const [saving, setSaving] = useState(false);
// 수정 모드
const [editMode, setEditMode] = useState(false);
const [editItemIds, setEditItemIds] = useState<string[]>([]);
// 소스 데이터
const [sourceKeyword, setSourceKeyword] = useState("");
const [sourceLoading, setSourceLoading] = useState(false);
@@ -377,7 +322,7 @@ export default function ReceivingPage() {
Promise.all(
["material", "unit"].map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_8`);
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_16`);
const items = flatten(res.data?.data || []);
map[col] = {};
for (const item of items) map[col][item.code] = item.label;
@@ -434,124 +379,56 @@ export default function ReceivingPage() {
})();
}, []);
// 필터 + 정렬 적용된 데이터 -> 그룹핑
const filteredGroups = useMemo(() => {
// 1차: inbound_number 기준 그룹핑
const allGroups: Record<string, { master: InboundItem; details: InboundItem[] }> = {};
for (const row of data) {
const key = row.inbound_number || "_no_inbound";
if (!allGroups[key]) {
allGroups[key] = { master: row, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
let raw = (group.master as any)?.[colKey] ?? "";
// 입고유형은 코드→라벨 변환된 값으로 비교
if (colKey === "inbound_type") raw = resolveInboundType(String(raw));
return values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([inboundNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
let cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
if (colKey === "source_table") cellVal = SOURCE_TABLE_LABEL[cellVal] || cellVal;
return values.has(cellVal);
})
);
return [inboundNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
entries.sort(([, a], [, b]) => {
let av: any = (a.master as any)?.[key] ?? "";
let bv: any = (b.master as any)?.[key] ?? "";
if (key === "inbound_type") { av = resolveInboundType(String(av)); bv = resolveInboundType(String(bv)); }
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
} else {
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [data, headerFilters, sortState]);
// 마스터 컬럼별 고유값 (마스터 헤더 필터용)
const masterUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
const seenMasters = new Map<string, InboundItem>();
data.forEach((row) => {
if (row.inbound_number && !seenMasters.has(row.inbound_number)) {
seenMasters.set(row.inbound_number, row);
}
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "inbound_number", label: "입고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) {
const values = new Set<string>();
masters.forEach((m) => {
let val = (m as any)?.[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "inbound_type") val = resolveInboundType(String(val));
values.add(String(val));
}
});
result[col.key] = Array.from(values).sort();
}
return result;
// 플랫 행 생성 (입고유형 코드→라벨 변환, source_table→한글 등)
const flatRows = useMemo(() => {
return data.map((row) => ({
...row,
inbound_type: resolveInboundType(row.inbound_type),
source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "",
}));
}, [data]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
// 컬럼별 고유값 (헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
for (const col of GRID_COLUMNS) {
const values = new Set<string>();
data.forEach((row) => {
let val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "source_table") val = SOURCE_TABLE_LABEL[val] || val;
values.add(String(val));
}
flatRows.forEach((row) => {
const val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
}, [flatRows]);
// 필터 + 정렬 적용된 플랫 데이터
const filteredRows = useMemo(() => {
let rows = [...flatRows];
// 1차: 헤더 필터 적용
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
rows = rows.filter((row) => {
const cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
return values.has(cellVal);
});
}
// 2차: 정렬
if (sortState) {
const { key, direction } = sortState;
rows.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}
return rows;
}, [flatRows, headerFilters, sortState]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
@@ -633,6 +510,8 @@ export default function ReceivingPage() {
const openRegisterModal = async () => {
const defaultType = "구매입고";
setEditMode(false);
setEditItemIds([]);
setModalInboundType(defaultType);
setModalInboundDate(new Date().toISOString().split("T")[0]);
setModalWarehouse("");
@@ -661,6 +540,51 @@ export default function ReceivingPage() {
}
};
// 수정 모달 열기
const openEditModal = (row: InboundItem) => {
const inNo = row.inbound_number;
const grouped = data.filter((d) => d.inbound_number === inNo);
const first = grouped[0] || row;
setEditMode(true);
setEditItemIds(grouped.map((g) => g.id));
setModalInboundNo(inNo);
setModalInboundType(first.inbound_type || "구매입고");
setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : "");
setModalWarehouse(first.warehouse_code || "");
setModalLocation(first.location_code || "");
setModalInspector((first as any).inspector || "");
setModalManager((first as any).manager || "");
setModalMemo(first.memo || "");
setSelectedItems(
grouped.map((g) => ({
key: g.id,
inbound_type: g.inbound_type || "",
reference_number: g.reference_number || "",
supplier_code: (g as any).supplier_code || "",
supplier_name: g.supplier_name || "",
item_number: g.item_number || "",
item_name: g.item_name || "",
spec: g.spec || "",
material: (g as any).material || "",
unit: (g as any).unit || "",
inbound_qty: Number(g.inbound_qty) || 0,
unit_price: Number(g.unit_price) || 0,
total_amount: Number(g.total_amount) || 0,
source_table: (g as any).source_table || "",
source_id: (g as any).source_id || "",
}))
);
setSourceKeyword("");
setPurchaseOrders([]);
setShipments([]);
setItems([]);
setSourcePage(1);
setSourceTotalCount(0);
setIsModalOpen(true);
loadSourceData(first.inbound_type || "구매입고", undefined, 1);
};
// 검색 버튼 클릭 시
const searchSourceData = useCallback(async () => {
setSourcePage(1);
@@ -811,41 +735,97 @@ export default function ReceivingPage() {
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: selectedItems.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
});
if (editMode) {
// 기존 item 수정 + 삭제된 item 삭제 + 새 item 추가
const currentKeys = new Set(selectedItems.map((i) => i.key));
const toDelete = editItemIds.filter((id) => !currentKeys.has(id));
const toUpdate = selectedItems.filter((i) => editItemIds.includes(i.key));
const toCreate = selectedItems.filter((i) => !editItemIds.includes(i.key));
if (res.success) {
alert(res.message || "입고 등록 완료");
await Promise.all([
...toDelete.map((id) => deleteReceiving(id)),
...toUpdate.map((item) =>
updateReceiving(item.key, {
inbound_date: modalInboundDate,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
warehouse_code: modalWarehouse || undefined,
location_code: modalLocation || undefined,
memo: modalMemo || undefined,
} as any)
),
...(toCreate.length > 0
? [createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: toCreate.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
})]
: []),
]);
toast.success("입고 정보를 수정했어요");
setIsModalOpen(false);
fetchList();
} else {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: selectedItems.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
});
if (res.success) {
toast.success(res.message || "입고 등록 완료");
setIsModalOpen(false);
fetchList();
}
}
} catch {
alert("입고 등록 중 오류가 발생했습니다.");
} catch (err: any) {
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "입고 등록 중 오류가 발생했습니다.");
toast.error(msg);
} finally {
setSaving(false);
}
@@ -897,13 +877,13 @@ export default function ReceivingPage() {
}
/>
{/* 입고 목록 테이블 (마스터-디테일 그룹) */}
{/* 입고 목록 테이블 (플랫 리스트) */}
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
<div className="flex items-center justify-between border-b bg-muted/50 px-4 py-2.5">
<div className="flex items-center gap-2">
<h3 className="text-[13px] font-bold"> </h3>
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
{Object.keys(filteredGroups).length}
{filteredRows.length}
</span>
</div>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
@@ -912,78 +892,68 @@ export default function ReceivingPage() {
</div>
<div className="h-[calc(100%-44px)] overflow-auto">
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<Table noWrapper style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 입고번호 (별도 컬럼) */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("inbound_number")}>
<span className="truncate"></span>
{sortState?.key === "inbound_number" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["inbound_number"] || []).length > 0 && (
<HeaderFilterPopover
colKey="inbound_number" colLabel="입고번호"
uniqueValues={masterUniqueValues["inbound_number"] || []}
filterValues={headerFilters["inbound_number"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
{GRID_COLUMNS.map((col) => {
const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key);
return (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none", isRight && "text-right")}>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(columnUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={columnUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
@@ -993,7 +963,7 @@ export default function ReceivingPage() {
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredGroups).length === 0 ? (
) : filteredRows.length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
@@ -1003,212 +973,59 @@ export default function ReceivingPage() {
</TableCell>
</TableRow>
) : (
Object.entries(filteredGroups).map(([inboundNo, group]) => {
const isExpanded = expandedOrders.has(inboundNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
filteredRows.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={inboundNo}>
{/* 마스터 행 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(inboundNo)) {
setClosingOrders((prev) => new Set(prev).add(inboundNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(inboundNo));
}
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row as any)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 입고번호 */}
<TableCell className="font-mono whitespace-nowrap">
{inboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "inbound_type": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getTypeVariant(resolveInboundType(master.inbound_type))} className="text-[11px]">
{resolveInboundType(master.inbound_type)}
</Badge>
</TableCell>
);
case "inbound_date": return (
<TableCell key={col.key} className="text-[13px] whitespace-nowrap">
{master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "supplier_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.supplier_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "inbound_status": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getStatusVariant(master.inbound_status)} className="text-[11px]">
{master.inbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground text-[13px]">
<span className="block truncate max-w-[110px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(inboundNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell />
{visibleDetailCols.map((col) => {
const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => {
let v = (d as any)[col.key];
if (col.key === "source_table") v = SOURCE_TABLE_LABEL[v] || v;
return v;
}).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
</TableRow>
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(inboundNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={`${row.id}-${detailIdx}`}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_table": return <TableCell key={col.key} className="text-[13px]">{row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}</TableCell>;
case "item_number": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_number || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[160px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "spec": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>;
case "inbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
);
})}
</React.Fragment>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="font-mono whitespace-nowrap text-[13px]">{row.inbound_number || ""}</TableCell>
<TableCell className="text-[13px]">
<Badge variant={getTypeVariant(row.inbound_type)} className="text-[11px]">
{row.inbound_type || "-"}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-[13px]">
{row.inbound_date ? new Date(row.inbound_date).toLocaleDateString("ko-KR") : ""}
</TableCell>
<TableCell className="text-[13px] truncate max-w-[120px]"><span className="block truncate">{row.reference_number || ""}</span></TableCell>
<TableCell className="text-[13px]">{row.source_type || ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[110px]"><span className="block truncate">{row.supplier_name || ""}</span></TableCell>
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
<TableCell className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[100px]"><span className="block truncate">{row.warehouse_name || (row as any).warehouse_code || ""}</span></TableCell>
<TableCell className="text-[13px]">
<Badge variant={getStatusVariant(row.inbound_status)} className="text-[11px]">
{row.inbound_status || "-"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.remark || row.memo || ""}</span></TableCell>
</TableRow>
);
})
)}
@@ -1221,9 +1038,9 @@ export default function ReceivingPage() {
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="sm:max-w-[1600px] w-[95vw] h-[90vh] p-0 flex flex-col overflow-hidden">
<DialogHeader className="shrink-0 px-6 pt-4 pb-2 border-b">
<DialogTitle> </DialogTitle>
<DialogTitle>{editMode ? "입고 수정" : "입고 등록"}</DialogTitle>
<DialogDescription>
, .
{editMode ? "입고 정보를 수정해 주세요." : "입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요."}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col">
@@ -1053,14 +1053,14 @@ export default function ProductionPlanManagementPage() {
return (
<Table style={{ minWidth: "900px" }}>
<TableHeader className="sticky top-0 z-10">
<TableHeader className="sticky top-0 z-20">
<TableRow className="bg-muted hover:bg-muted">
<TableHead style={{ width: "30px", minWidth: "30px" }}>
<TableHead className="sticky left-0 z-20 bg-muted" style={{ width: "30px", minWidth: "30px" }}>
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead style={{ width: "40px", minWidth: "40px" }} />
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="sticky left-[30px] z-20 bg-muted" style={{ width: "40px", minWidth: "40px" }} />
<TableHead className="sticky left-[70px] z-20 bg-muted text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={{ width: "140px", minWidth: "140px" }}></TableHead>
<TableHead className="sticky left-[210px] z-20 bg-muted text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground border-r" style={{ width: "140px", minWidth: "140px" }}></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} style={{ ...ts.thStyle(col.key), minWidth: "80px" }} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
{col.label}
@@ -1073,9 +1073,9 @@ export default function ProductionPlanManagementPage() {
if (item._isGroupSummary) {
return (
<TableRow key={`summary-${rowIdx}`} className="bg-muted/60 font-semibold border-t border-primary/20">
<TableCell />
<TableCell />
<TableCell colSpan={2} />
<TableCell className="sticky left-0 z-10 bg-muted/60" />
<TableCell className="sticky left-[30px] z-10 bg-muted/60" />
<TableCell colSpan={2} className="sticky left-[70px] z-10 bg-muted/60 border-r" />
{ts.visibleColumns.map((col) => {
const v = (item as any)[col.key];
return (
@@ -1090,14 +1090,14 @@ export default function ProductionPlanManagementPage() {
return (
<React.Fragment key={item.item_code || rowIdx}>
<TableRow className={cn("cursor-pointer border-t-2 border-t-primary/30 bg-primary/5 font-semibold hover:bg-primary/10", selectedItemGroups.has(item.item_code) && "bg-primary/10")}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<TableCell className="sticky left-0 z-10 bg-background text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selectedItemGroups.has(item.item_code)} onCheckedChange={() => toggleItemGroupSelect(item.item_code)} className="h-4 w-4" />
</TableCell>
<TableCell className="text-center" onClick={() => toggleItemExpand(item.item_code)}>
<TableCell className="sticky left-[30px] z-10 bg-background text-center" onClick={() => toggleItemExpand(item.item_code)}>
<ChevronRight className={cn("h-4 w-4 transition-transform duration-200", expandedItems.has(item.item_code) && "rotate-90")} />
</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
<TableCell className="sticky left-[70px] z-10 bg-background text-[13px] text-primary truncate" style={{ width: "140px", minWidth: "140px" }} onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell className="sticky left-[210px] z-10 bg-background text-[13px] text-primary truncate border-r" style={{ width: "140px", minWidth: "140px" }} onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
{ts.visibleColumns.map((col) => renderGroupCell(col, item))}
</TableRow>
{expandedItems.has(item.item_code) && item.orders?.map((detail: any) => {
@@ -1107,9 +1107,9 @@ export default function ProductionPlanManagementPage() {
}
return (
<TableRow key={detail.id || detail.order_no} className="cursor-pointer hover:bg-muted/50">
<TableCell />
<TableCell />
<TableCell colSpan={2} className="pl-10">
<TableCell className="sticky left-0 z-10 bg-card" />
<TableCell className="sticky left-[30px] z-10 bg-card" />
<TableCell colSpan={2} className="sticky left-[70px] z-10 bg-card pl-10 border-r">
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{detail.order_no}</span>
@@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Package, ChevronDown,
ClipboardList, Pencil, Search, X, Package,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2, GripVertical,
} from "lucide-react";
@@ -88,18 +88,18 @@ const GRID_COLUMNS_CONFIG = [
];
const MODAL_DETAIL_COLUMNS = [
{ key: "item_code", label: "품번", width: "w-[120px]" },
{ key: "item_name", label: "품명", width: "w-[120px]" },
{ key: "supplier", label: "공급업체", width: "w-[150px]" },
{ key: "spec", label: "규격", width: "w-[80px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "order_qty", label: "발주수량", width: "w-[90px]" },
{ key: "received_qty", label: "입고수량", width: "w-[90px]" },
{ key: "remain_qty", label: "잔량", width: "w-[80px]" },
{ key: "unit_price", label: "단가", width: "w-[100px]" },
{ key: "amount", label: "금액", width: "w-[100px]" },
{ key: "due_date", label: "납기일", width: "w-[160px]" },
{ key: "memo", label: "메모", width: "w-[120px]" },
{ key: "item_code", label: "품번", width: "min-w-[120px]" },
{ key: "item_name", label: "품명", width: "min-w-[150px]" },
{ key: "supplier", label: "공급업체", width: "min-w-[150px]" },
{ key: "spec", label: "규격", width: "min-w-[80px]" },
{ key: "unit", label: "단위", width: "min-w-[90px]" },
{ key: "order_qty", label: "발주수량", width: "min-w-[90px]" },
{ key: "received_qty", label: "입고수량", width: "min-w-[90px]" },
{ key: "remain_qty", label: "잔량", width: "min-w-[80px]" },
{ key: "unit_price", label: "단가", width: "min-w-[100px]" },
{ key: "amount", label: "금액", width: "min-w-[100px]" },
{ key: "due_date", label: "납기일", width: "min-w-[160px]" },
{ key: "memo", label: "메모", width: "min-w-[120px]" },
];
const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16";
@@ -162,7 +162,6 @@ export default function PurchaseOrderPage() {
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
// 테이블 설정
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
@@ -238,7 +237,7 @@ export default function PurchaseOrderPage() {
);
try {
const suppRes = await apiClient.post(`/table-management/tables/supplier_mng/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 5000, autoFilter: true,
});
const supps = suppRes.data?.data?.data || suppRes.data?.data?.rows || [];
optMap["supplier_code"] = supps.map((s: any) => ({
@@ -248,7 +247,7 @@ export default function PurchaseOrderPage() {
} catch { /* skip */ }
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 5000, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
optMap["manager"] = users.map((u: any) => ({
@@ -294,7 +293,7 @@ export default function PurchaseOrderPage() {
}
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: detailFilters.length > 0 ? { enabled: true, filters: detailFilters } : undefined,
autoFilter: true,
sort: { columnName: "purchase_no", order: "desc" },
@@ -372,20 +371,6 @@ export default function PurchaseOrderPage() {
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// purchase_no 기준 그룹핑
const orderGroups = useMemo(() => {
const map: Record<string, { master: any; details: any[] }> = {};
for (const row of orders) {
const key = row.purchase_no || row.id;
if (!map[key]) {
map[key] = {
master: { purchase_no: row.purchase_no, order_date: row.order_date, supplier_name: row.supplier_name, status: row.status, memo: row.memo },
details: [],
};
}
map[key].details.push(row);
}
return map;
}, [orders]);
const getCategoryLabel = (col: string, code: string) => {
if (!code) return "";
@@ -553,7 +538,7 @@ export default function PurchaseOrderPage() {
}
};
// 품목 검색
// 품목 검색 (수주관리와 동일한 서버 페이징 방식)
const searchItems = async (page?: number, size?: number) => {
const p = page ?? itemPage;
const s = size ?? itemPageSize;
@@ -563,25 +548,24 @@ export default function PurchaseOrderPage() {
if (itemSearchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
}
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
const divValues = [itemSearchDivision];
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
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)));
const rows = resData?.data || resData?.rows || [];
const serverTotal = resData?.total || resData?.totalCount || rows.length;
setItemSearchResults(rows);
setItemTotal(serverTotal);
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
@@ -623,7 +607,7 @@ export default function PurchaseOrderPage() {
try {
const itemIds = selected.map((item) => item.item_number || item.id);
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: {
enabled: true,
filters: [
@@ -686,7 +670,7 @@ export default function PurchaseOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
autoFilter: true,
});
@@ -708,7 +692,7 @@ export default function PurchaseOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: supplierCode },
{ columnName: "item_id", operator: "in", value: itemCodes },
@@ -811,125 +795,83 @@ export default function PurchaseOrderPage() {
</div>
</div>
{/* 데이터 테이블 — 아코디언 그룹핑 */}
{/* 데이터 테이블 — 플랫 리스트 */}
<div className="flex-1 overflow-auto border rounded-lg bg-card">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : Object.keys(orderGroups).length === 0 ? (
) : orders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Package className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
(() => {
const MASTER_KEYS = new Set(["purchase_no", "order_date", "supplier_name", "status", "memo"]);
const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]);
// ts.visibleColumns 순서를 따르되, 마스터/디테일 컬럼을 분리
// 고정 컬럼(품목수)은 마스터 선행 컬럼 뒤에 배치
const leadingMaster: typeof ts.visibleColumns = [];
const detailCols: typeof ts.visibleColumns = [];
const trailingMaster: typeof ts.visibleColumns = [];
let passedFirstDetail = false;
for (const col of ts.visibleColumns) {
if (MASTER_KEYS.has(col.key)) {
if (passedFirstDetail) trailingMaster.push(col);
else leadingMaster.push(col);
} else {
passedFirstDetail = true;
detailCols.push(col);
}
}
const renderDetailCell = (row: any, key: string) => {
const renderCell = (row: any, key: string) => {
const val = row[key];
if (key === "status") return val ? <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[val] || "")}>{val}</span> : "-";
if (numCols.has(key)) return <span className="font-mono">{val ? Number(val).toLocaleString() : "0"}</span>;
return val || "-";
if (key === "order_date" || key === "due_date") return val ? new Date(val).toLocaleDateString("ko-KR") : "-";
if (numCols.has(key)) return <span className="font-mono">{val ? Number(val).toLocaleString() : ""}</span>;
return val || "";
};
const renderMasterHead = (col: { key: string; label: string }) => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", col.key === "status" && "text-center")}>
{col.label}
</TableHead>
);
const renderMasterCell = (col: { key: string }, m: any, purchaseNo: string) => {
if (col.key === "purchase_no") return <TableCell key={col.key} className="text-sm font-semibold text-primary">{purchaseNo}</TableCell>;
if (col.key === "order_date") return <TableCell key={col.key} className="text-sm">{m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}</TableCell>;
if (col.key === "supplier_name") return <TableCell key={col.key} className="text-sm">{m.supplier_name || "-"}</TableCell>;
if (col.key === "status") return <TableCell key={col.key} className="text-center">{m.status && <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[m.status] || "")}>{m.status}</span>}</TableCell>;
if (col.key === "memo") return <TableCell key={col.key} className="text-sm text-muted-foreground max-w-[120px] truncate">{m.memo || ""}</TableCell>;
return <TableCell key={col.key} />;
};
return (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-8" />
<TableHead className="w-10" />
{leadingMaster.map(renderMasterHead)}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground text-center"></TableHead>
{detailCols.map(col => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", numCols.has(col.key) && "text-right")}>
{col.label}{col.key === "order_qty" || col.key === "amount" ? " 합계" : ""}
<TableHead
className="w-10 text-center cursor-pointer"
onClick={() => {
const allIds = orders.map((r) => r.id);
const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allIds);
}}
>
<Checkbox
checked={orders.length > 0 && orders.every((r) => checkedIds.includes(r.id))}
onCheckedChange={() => {}}
/>
</TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", numCols.has(col.key) && "text-right", col.key === "status" && "text-center")}>
{col.label}
</TableHead>
))}
{trailingMaster.map(renderMasterHead)}
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(orderGroups).map(([purchaseNo, group]) => {
const isExpanded = expandedOrders.has(purchaseNo);
const detailIds = group.details.map(d => d.id);
const allChecked = detailIds.length > 0 && detailIds.every(id => checkedIds.includes(id));
const someChecked = detailIds.some(id => checkedIds.includes(id));
const m = group.master;
const totalQty = group.details.reduce((s, d) => s + (Number(d.order_qty) || 0), 0);
const totalAmt = group.details.reduce((s, d) => s + (Number(d.amount) || 0), 0);
{orders.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={purchaseNo}>
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn("cursor-pointer border-l-[3px] border-l-transparent font-semibold", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedOrders(prev => { const next = new Set(prev); if (next.has(purchaseNo)) next.delete(purchaseNo); else next.add(purchaseNo); return next; })}
onDoubleClick={() => openEditModal(purchaseNo)}
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row.purchase_no)}
>
<TableCell
className="text-center"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
{ts.visibleColumns.map((col) => (
<TableCell key={col.key} className={cn("text-[13px]", numCols.has(col.key) && "text-right font-mono", col.key === "status" && "text-center")}>
{renderCell(row, col.key)}
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !detailIds.includes(id)) : [...new Set([...prev, ...detailIds])]); }}>
<Checkbox checked={allChecked} data-state={someChecked && !allChecked ? "indeterminate" : undefined} onCheckedChange={() => {}} />
</TableCell>
{leadingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
<TableCell className="text-sm text-center"><Badge variant="secondary" className="text-[10px]">{group.details.length}</Badge></TableCell>
{detailCols.map(col => (
<TableCell key={col.key} className={cn("text-sm", numCols.has(col.key) && "text-right font-mono")}>
{col.key === "order_qty" ? totalQty.toLocaleString()
: col.key === "amount" ? totalAmt.toLocaleString()
: ""}
</TableCell>
))}
{trailingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
</TableRow>
{isExpanded && group.details.map((row) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell className="text-center" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id]); }}>
<Checkbox checked={checkedIds.includes(row.id)} onCheckedChange={() => {}} />
</TableCell>
{leadingMaster.map(col => <TableCell key={col.key} />)}
<TableCell />
{detailCols.map(col => (
<TableCell key={col.key} className={cn(numCols.has(col.key) && "text-right")}>
{renderDetailCell(row, col.key)}
</TableCell>
))}
{trailingMaster.map(col => <TableCell key={col.key} />)}
</TableRow>
))}
</React.Fragment>
</TableRow>
);
})}
</TableBody>
@@ -938,7 +880,7 @@ export default function PurchaseOrderPage() {
})()
)}
<div className="px-4 py-2 border-t text-xs text-muted-foreground">
{Object.keys(orderGroups).length} ( ) / {orders.length} ( )
{orders.length}
</div>
</div>
@@ -1094,7 +1036,7 @@ export default function PurchaseOrderPage() {
) : (
<div className="border rounded-lg overflow-x-auto">
<DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
<Table className="table-fixed">
<Table>
<TableHeader className="sticky top-0 z-10">
<SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableRow className="bg-muted hover:bg-muted">
@@ -1118,12 +1060,12 @@ export default function PurchaseOrderPage() {
{visibleModalColumns.map((col) => {
switch (col.key) {
case "item_code":
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
return <TableCell key={col.key} className="text-[13px] font-mono whitespace-nowrap"><span title={row.item_code}>{row.item_code}</span></TableCell>;
case "item_name":
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
return <TableCell key={col.key} className="text-[13px] whitespace-nowrap"><span title={row.item_name}>{row.item_name}</span></TableCell>;
case "supplier":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[150px]">
{isReadOnly ? (
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
) : (
@@ -1133,7 +1075,7 @@ export default function PurchaseOrderPage() {
updateDetailRow(idx, "supplier_code", v);
updateDetailRow(idx, "supplier_name", name);
}}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectTrigger className="h-8 text-xs w-full"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
@@ -1149,11 +1091,11 @@ export default function PurchaseOrderPage() {
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
case "order_qty":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[110px]">
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono w-full" />
)}
</TableCell>
);
@@ -1163,11 +1105,11 @@ export default function PurchaseOrderPage() {
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":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[120px]">
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{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" />
<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" />
)}
</TableCell>
);
@@ -1175,21 +1117,21 @@ export default function PurchaseOrderPage() {
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
case "due_date":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[160px]">
{isReadOnly ? (
<span className="text-xs">{row.due_date}</span>
) : (
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs w-full" />
)}
</TableCell>
);
case "memo":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[140px]">
{isReadOnly ? (
<span className="text-xs">{row.memo}</span>
) : (
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs w-full" />
)}
</TableCell>
);
@@ -424,9 +424,10 @@ export default function ItemInspectionInfoPage() {
case "inspection_type": return (
<TableCell key={col.key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>
))}
{group.types.map((t: string) => {
const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t;
return <Badge key={t} variant="secondary" className="text-[10px]">{label}</Badge>;
})}
</div>
</TableCell>
);
@@ -494,7 +495,7 @@ export default function ItemInspectionInfoPage() {
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
)}
>
{type}
{inspTypeCatOptions.find((o) => o.code === type)?.label || type}
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
@@ -624,6 +624,15 @@ export default function SalesOrderPage() {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
// 코드 또는 라벨이 저장된 경우 모두 조회하기 위해 in 연산자 사용
const divValues = [itemSearchDivision];
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const partnerId = masterForm.partner_id;
@@ -632,7 +641,7 @@ export default function SalesOrderPage() {
if (isCustomerPrice && partnerId) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
autoFilter: true,
});
@@ -642,31 +651,22 @@ export default function SalesOrderPage() {
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
let allRows = resData?.data || resData?.rows || [];
let rows = resData?.data || resData?.rows || [];
const serverTotal = resData?.total || resData?.totalCount || rows.length;
// 거래처우선일 때 연결된 품목만 표시
// 거래처우선일 때 연결된 품목만 표시 (클라이언트 필터)
if (customerItemIds) {
allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
rows = rows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
}
// 관리품목 필터 (코드/라벨 혼재 대응)
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)));
setItemSearchResults(rows);
setItemTotal(serverTotal);
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
@@ -1320,44 +1320,44 @@ export default function SalesOrderPage() {
<Table noWrapper className="min-w-max w-full">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="w-28 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-32 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-32 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">/</TableHead>
<TableHead className="min-w-[40px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[120px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[110px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">/</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
<TableCell className="text-center font-mono text-[13px] text-muted-foreground/50">{idx + 1}</TableCell>
<TableCell className="max-w-[112px]">
<span className="block truncate font-mono text-[13px]" title={row.part_code}>{row.part_code}</span>
<TableCell className="whitespace-nowrap">
<span className="font-mono text-[13px]" title={row.part_code}>{row.part_code}</span>
</TableCell>
<TableCell className="max-w-[128px]">
<span className="block truncate text-[13px]" title={row.part_name}>{row.part_name}</span>
<TableCell className="whitespace-nowrap">
<span className="text-[13px]" title={row.part_name}>{row.part_name}</span>
</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.spec}</TableCell>
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.material}</TableCell>
<TableCell>
<Input
value={row.packing_material || ""}
onChange={(e) => updateDetailRow(idx, "packing_material", e.target.value)}
placeholder="포장재"
className="h-8 text-xs"
className="h-8 text-xs w-full"
/>
</TableCell>
<TableCell>
<Select value={row.unit || ""} onValueChange={(v) => updateDetailRow(idx, "unit", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="단위" /></SelectTrigger>
<SelectTrigger className="h-8 text-xs w-full"><SelectValue placeholder="단위" /></SelectTrigger>
<SelectContent>
{(categoryOptions["item_unit"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
@@ -1371,7 +1371,7 @@ export default function SalesOrderPage() {
min="1"
value={row.qty || "1"}
onChange={(e) => updateDetailRow(idx, "qty", e.target.value)}
className="h-8 text-xs text-right font-mono w-16"
className="h-8 text-xs text-right font-mono w-full"
/>
</TableCell>
<TableCell>
@@ -1380,7 +1380,7 @@ export default function SalesOrderPage() {
min="0"
value={row.pack_qty || "0"}
onChange={(e) => updateDetailRow(idx, "pack_qty", e.target.value)}
className="h-8 text-xs text-right font-mono w-16"
className="h-8 text-xs text-right font-mono w-full"
/>
</TableCell>
<TableCell>
@@ -1388,10 +1388,10 @@ export default function SalesOrderPage() {
value={formatNumber(row.unit_price || "")}
onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))}
readOnly={!allowPriceEdit}
className={cn("h-8 text-xs text-right font-mono w-20", !allowPriceEdit && "bg-muted cursor-not-allowed")}
className={cn("h-8 text-xs text-right font-mono w-full", !allowPriceEdit && "bg-muted cursor-not-allowed")}
/>
</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">
<TableCell className="text-right font-mono text-[13px] font-semibold whitespace-nowrap">
{row.amount ? Number(row.amount).toLocaleString() : "0"}
</TableCell>
<TableCell>
@@ -1399,7 +1399,7 @@ export default function SalesOrderPage() {
type="date"
value={row.due_date || ""}
onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)}
className="h-8 text-xs"
className="h-8 text-xs w-full"
/>
</TableCell>
<TableCell className="text-center">
@@ -1474,15 +1474,7 @@ export default function SalesOrderPage() {
onKeyDown={(e) => e.key === "Enter" && triggerNewSearch()}
className="h-9 flex-1"
/>
<Select value={itemSearchDivision} onValueChange={setItemSearchDivision}>
<SelectTrigger className="h-9 w-[130px]"><SelectValue placeholder="관리품목" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["item_division"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="h-9 px-3 flex items-center rounded-md border border-input bg-muted text-xs font-medium text-muted-foreground whitespace-nowrap"></div>
<Button size="sm" onClick={triggerNewSearch} disabled={itemSearchLoading} className="h-9">
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /></>}
</Button>
@@ -38,7 +38,6 @@ import {
Save,
ChevronRight,
ChevronLeft,
ChevronDown,
ChevronsLeft,
ChevronsRight,
Inbox,
@@ -116,41 +115,8 @@ const GRID_COLUMNS = [
{ key: "remark", label: "비고" },
];
// 마스터 헤더 레이아웃 (출고번호 뒤)
const MASTER_BODY_LAYOUT = [
{ key: "outbound_type", label: "출고유형", colSpan: 1 },
{ key: "outbound_date", label: "출고일", colSpan: 1 },
{ key: "reference_number", label: "참조번호", colSpan: 1 },
{ key: "customer_name", label: "거래처", colSpan: 1 },
{ key: "warehouse_name", label: "창고", colSpan: 1 },
{ key: "outbound_status", label: "출고상태", colSpan: 1 },
{ key: "memo", label: "비고", colSpan: 1 },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "source_type", label: "출처" },
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "specification", label: "규격" },
{ key: "outbound_qty", label: "출고수량" },
{ key: "unit_price", label: "단가" },
{ key: "total_amount", label: "금액" },
];
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["outbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_type",
item_number: "item_code",
item_name: "item_name",
spec: "specification",
outbound_qty: "outbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16
const TOTAL_COLS = 16;
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -258,30 +224,6 @@ interface SelectedSourceItem {
export default function OutboundPage() {
const ts = useTableSettings("c16-outbound", "outbound_mng", GRID_COLUMNS);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<OutboundItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -290,9 +232,7 @@ export default function OutboundPage() {
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 마스터-디테일 그룹 테이블 state
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
// 헤더 필터 & 정렬
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
@@ -403,121 +343,59 @@ export default function OutboundPage() {
})();
}, []);
// --- 마스터-디테일 그룹핑, 필터, 정렬 ---
// 플랫 행 생성 (출고유형 코드→라벨 변환, source_type→한글 등)
const flatRows = useMemo(() => {
return data.map((row) => ({
...row,
_raw_outbound_type: row.outbound_type,
outbound_type: resolveCat("outbound_type", row.outbound_type) || row.outbound_type || "",
source_type: row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "",
item_number: row.item_code || (row as any).item_number || "",
spec: row.specification || (row as any).spec || "",
}));
}, [data, resolveCat]);
// outbound_number 기준 그룹핑 + 필터 + 정렬
const filteredGroups = useMemo(() => {
// 1차: outbound_number 기준 그룹핑
const allGroups: Record<string, { master: OutboundItem; details: OutboundItem[] }> = {};
for (const row of data) {
const key = row.outbound_number || "_no_number";
if (!allGroups[key]) {
allGroups[key] = { master: row, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
const raw = (group.master as any)?.[colKey] ?? "";
return values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([outNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
let cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
if (colKey === "source_type") cellVal = SOURCE_TYPE_LABEL[cellVal] || cellVal;
return values.has(cellVal);
})
);
return [outNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
entries.sort(([, a], [, b]) => {
const av = (a.master as any)?.[key] ?? "";
const bv = (b.master as any)?.[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
} else {
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
let av: any = (a as any)[key] ?? "";
let bv: any = (b as any)[key] ?? "";
if (key === "source_type") { av = SOURCE_TYPE_LABEL[av] || av; bv = SOURCE_TYPE_LABEL[bv] || bv; }
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [data, headerFilters, sortState]);
// 마스터 컬럼별 고유값 (헤더 필터용)
const masterUniqueValues = useMemo(() => {
// 컬럼별 고유값 (헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
const seenMasters = new Map<string, OutboundItem>();
data.forEach((row) => {
if (row.outbound_number && !seenMasters.has(row.outbound_number)) {
seenMasters.set(row.outbound_number, row);
}
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "outbound_number", label: "출고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) {
for (const col of GRID_COLUMNS) {
const values = new Set<string>();
masters.forEach((m) => {
const val = (m as any)?.[col.key];
flatRows.forEach((row) => {
const val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
}, [flatRows]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
const values = new Set<string>();
data.forEach((row) => {
let val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "source_type") val = SOURCE_TYPE_LABEL[val] || val;
values.add(String(val));
}
// 필터 + 정렬 적용된 플랫 데이터
const filteredRows = useMemo(() => {
let rows = [...flatRows];
// 1차: 헤더 필터 적용
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
rows = rows.filter((row) => {
const cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
return values.has(cellVal);
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
// 2차: 정렬
if (sortState) {
const { key, direction } = sortState;
rows.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}
return rows;
}, [flatRows, headerFilters, sortState]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
@@ -938,7 +816,7 @@ export default function OutboundPage() {
dataCount={data.length}
/>
{/* 출고 목록 테이블 */}
{/* 출고 목록 테이블 (플랫 리스트) */}
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
{/* 패널 헤더 */}
<div className="flex items-center justify-between border-b bg-muted/30 px-4 py-2.5 shrink-0">
@@ -946,7 +824,7 @@ export default function OutboundPage() {
<PackageOpen className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<span className="rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
{data.length}
{filteredRows.length}
</span>
</div>
<div className="flex gap-2">
@@ -971,78 +849,68 @@ export default function OutboundPage() {
</div>
<div className="h-full overflow-auto">
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<Table noWrapper style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 출고번호 */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("outbound_number")}>
<span className="truncate"></span>
{sortState?.key === "outbound_number" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["outbound_number"] || []).length > 0 && (
<HeaderFilterPopover
colKey="outbound_number" colLabel="출고번호"
uniqueValues={masterUniqueValues["outbound_number"] || []}
filterValues={headerFilters["outbound_number"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
{GRID_COLUMNS.map((col) => {
const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key);
return (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none", isRight && "text-right")}>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(columnUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={columnUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
@@ -1052,7 +920,7 @@ export default function OutboundPage() {
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredGroups).length === 0 ? (
) : filteredRows.length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
@@ -1062,214 +930,59 @@ export default function OutboundPage() {
</TableCell>
</TableRow>
) : (
Object.entries(filteredGroups).map(([outboundNo, group]) => {
const isExpanded = expandedOrders.has(outboundNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
filteredRows.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={outboundNo}>
{/* 마스터 행 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(outboundNo)) {
setClosingOrders((prev) => new Set(prev).add(outboundNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(outboundNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(outboundNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(outboundNo));
}
}}
onDoubleClick={() => openEditModal(group.details[0])}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 출고번호 */}
<TableCell className="font-mono whitespace-nowrap">
{outboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "outbound_type": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{resolveCat("outbound_type", master.outbound_type) || "-"}
</Badge>
</TableCell>
);
case "outbound_date": return (
<TableCell key={col.key} className="whitespace-nowrap text-[13px]">
{master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "customer_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.customer_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "outbound_status": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(master.outbound_status))}>
{master.outbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground">
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(outboundNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell />
{visibleDetailCols.map((col) => {
const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => {
let v = (d as any)[col.key];
if (col.key === "source_type") v = SOURCE_TYPE_LABEL[v] || v;
return v;
}).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
</TableRow>
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(outboundNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={`${row.id}-${detailIdx}`}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_type": return <TableCell key={col.key} className="text-[13px]">{row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}</TableCell>;
case "item_code": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_code || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "specification": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.specification || ""}</TableCell>;
case "outbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
})}
</React.Fragment>
}}
onDoubleClick={() => openEditModal(row as any)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="font-mono whitespace-nowrap text-[13px]">{row.outbound_number || ""}</TableCell>
<TableCell className="text-[13px]">
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(row.outbound_type))}>
{row.outbound_type || "-"}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-[13px]">
{row.outbound_date ? new Date(row.outbound_date).toLocaleDateString("ko-KR") : ""}
</TableCell>
<TableCell className="text-[13px] truncate max-w-[120px]"><span className="block truncate">{row.reference_number || ""}</span></TableCell>
<TableCell className="text-[13px]">{row.source_type || ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[110px]"><span className="block truncate">{row.customer_name || ""}</span></TableCell>
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
<TableCell className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[100px]"><span className="block truncate">{row.warehouse_name || row.warehouse_code || ""}</span></TableCell>
<TableCell className="text-[13px]">
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(row.outbound_status))}>
{row.outbound_status || "-"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.remark || row.memo || ""}</span></TableCell>
</TableRow>
);
})
)}
@@ -43,7 +43,6 @@ import {
X,
Save,
ChevronRight,
ChevronDown,
ChevronLeft,
ChevronsLeft,
ChevronsRight,
@@ -64,6 +63,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea
import {
getReceivingList,
createReceiving,
updateReceiving,
deleteReceiving,
generateReceivingNumber,
getReceivingWarehouses,
@@ -95,41 +95,8 @@ const GRID_COLUMNS = [
{ key: "remark", label: "비고" },
];
// 마스터 헤더 레이아웃 (입고번호 뒤, 디테일 7컬럼 위에 colSpan으로 맵핑)
const MASTER_BODY_LAYOUT = [
{ key: "inbound_type", label: "입고유형", colSpan: 1 },
{ key: "inbound_date", label: "입고일", colSpan: 1 },
{ key: "reference_number", label: "참조번호", colSpan: 1 },
{ key: "supplier_name", label: "공급처", colSpan: 1 },
{ key: "warehouse_name", label: "창고", colSpan: 1 },
{ key: "inbound_status", label: "입고상태", colSpan: 1 },
{ key: "memo", label: "비고", colSpan: 1 },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "source_table", label: "출처" },
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "spec", label: "규격" },
{ key: "inbound_qty", label: "입고수량" },
{ key: "unit_price", label: "단가" },
{ key: "total_amount", label: "금액" },
];
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_table",
item_number: "item_number",
item_name: "item_name",
spec: "spec",
inbound_qty: "inbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16
const TOTAL_COLS = 16;
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -287,30 +254,6 @@ interface SelectedSourceItem {
export default function ReceivingPage() {
const ts = useTableSettings("c16-receiving", "inbound_mng", GRID_COLUMNS);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<InboundItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -319,9 +262,7 @@ export default function ReceivingPage() {
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 마스터-디테일 그룹 테이블
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
// 헤더 필터 & 정렬
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
@@ -338,6 +279,10 @@ export default function ReceivingPage() {
const [selectedItems, setSelectedItems] = useState<SelectedSourceItem[]>([]);
const [saving, setSaving] = useState(false);
// 수정 모드
const [editMode, setEditMode] = useState(false);
const [editItemIds, setEditItemIds] = useState<string[]>([]);
// 소스 데이터
const [sourceKeyword, setSourceKeyword] = useState("");
const [sourceLoading, setSourceLoading] = useState(false);
@@ -377,7 +322,7 @@ export default function ReceivingPage() {
Promise.all(
["material", "unit"].map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_9`);
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_16`);
const items = flatten(res.data?.data || []);
map[col] = {};
for (const item of items) map[col][item.code] = item.label;
@@ -434,124 +379,56 @@ export default function ReceivingPage() {
})();
}, []);
// 필터 + 정렬 적용된 데이터 -> 그룹핑
const filteredGroups = useMemo(() => {
// 1차: inbound_number 기준 그룹핑
const allGroups: Record<string, { master: InboundItem; details: InboundItem[] }> = {};
for (const row of data) {
const key = row.inbound_number || "_no_inbound";
if (!allGroups[key]) {
allGroups[key] = { master: row, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
let raw = (group.master as any)?.[colKey] ?? "";
// 입고유형은 코드→라벨 변환된 값으로 비교
if (colKey === "inbound_type") raw = resolveInboundType(String(raw));
return values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([inboundNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
let cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
if (colKey === "source_table") cellVal = SOURCE_TABLE_LABEL[cellVal] || cellVal;
return values.has(cellVal);
})
);
return [inboundNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
entries.sort(([, a], [, b]) => {
let av: any = (a.master as any)?.[key] ?? "";
let bv: any = (b.master as any)?.[key] ?? "";
if (key === "inbound_type") { av = resolveInboundType(String(av)); bv = resolveInboundType(String(bv)); }
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
} else {
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [data, headerFilters, sortState]);
// 마스터 컬럼별 고유값 (마스터 헤더 필터용)
const masterUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
const seenMasters = new Map<string, InboundItem>();
data.forEach((row) => {
if (row.inbound_number && !seenMasters.has(row.inbound_number)) {
seenMasters.set(row.inbound_number, row);
}
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "inbound_number", label: "입고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) {
const values = new Set<string>();
masters.forEach((m) => {
let val = (m as any)?.[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "inbound_type") val = resolveInboundType(String(val));
values.add(String(val));
}
});
result[col.key] = Array.from(values).sort();
}
return result;
// 플랫 행 생성 (입고유형 코드→라벨 변환, source_table→한글 등)
const flatRows = useMemo(() => {
return data.map((row) => ({
...row,
inbound_type: resolveInboundType(row.inbound_type),
source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "",
}));
}, [data]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
// 컬럼별 고유값 (헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
for (const col of GRID_COLUMNS) {
const values = new Set<string>();
data.forEach((row) => {
let val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "source_table") val = SOURCE_TABLE_LABEL[val] || val;
values.add(String(val));
}
flatRows.forEach((row) => {
const val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
}, [flatRows]);
// 필터 + 정렬 적용된 플랫 데이터
const filteredRows = useMemo(() => {
let rows = [...flatRows];
// 1차: 헤더 필터 적용
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
rows = rows.filter((row) => {
const cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
return values.has(cellVal);
});
}
// 2차: 정렬
if (sortState) {
const { key, direction } = sortState;
rows.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}
return rows;
}, [flatRows, headerFilters, sortState]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
@@ -633,6 +510,8 @@ export default function ReceivingPage() {
const openRegisterModal = async () => {
const defaultType = "구매입고";
setEditMode(false);
setEditItemIds([]);
setModalInboundType(defaultType);
setModalInboundDate(new Date().toISOString().split("T")[0]);
setModalWarehouse("");
@@ -661,6 +540,51 @@ export default function ReceivingPage() {
}
};
// 수정 모달 열기
const openEditModal = (row: InboundItem) => {
const inNo = row.inbound_number;
const grouped = data.filter((d) => d.inbound_number === inNo);
const first = grouped[0] || row;
setEditMode(true);
setEditItemIds(grouped.map((g) => g.id));
setModalInboundNo(inNo);
setModalInboundType(first.inbound_type || "구매입고");
setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : "");
setModalWarehouse(first.warehouse_code || "");
setModalLocation(first.location_code || "");
setModalInspector((first as any).inspector || "");
setModalManager((first as any).manager || "");
setModalMemo(first.memo || "");
setSelectedItems(
grouped.map((g) => ({
key: g.id,
inbound_type: g.inbound_type || "",
reference_number: g.reference_number || "",
supplier_code: (g as any).supplier_code || "",
supplier_name: g.supplier_name || "",
item_number: g.item_number || "",
item_name: g.item_name || "",
spec: g.spec || "",
material: (g as any).material || "",
unit: (g as any).unit || "",
inbound_qty: Number(g.inbound_qty) || 0,
unit_price: Number(g.unit_price) || 0,
total_amount: Number(g.total_amount) || 0,
source_table: (g as any).source_table || "",
source_id: (g as any).source_id || "",
}))
);
setSourceKeyword("");
setPurchaseOrders([]);
setShipments([]);
setItems([]);
setSourcePage(1);
setSourceTotalCount(0);
setIsModalOpen(true);
loadSourceData(first.inbound_type || "구매입고", undefined, 1);
};
// 검색 버튼 클릭 시
const searchSourceData = useCallback(async () => {
setSourcePage(1);
@@ -811,41 +735,97 @@ export default function ReceivingPage() {
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: selectedItems.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
});
if (editMode) {
// 기존 item 수정 + 삭제된 item 삭제 + 새 item 추가
const currentKeys = new Set(selectedItems.map((i) => i.key));
const toDelete = editItemIds.filter((id) => !currentKeys.has(id));
const toUpdate = selectedItems.filter((i) => editItemIds.includes(i.key));
const toCreate = selectedItems.filter((i) => !editItemIds.includes(i.key));
if (res.success) {
alert(res.message || "입고 등록 완료");
await Promise.all([
...toDelete.map((id) => deleteReceiving(id)),
...toUpdate.map((item) =>
updateReceiving(item.key, {
inbound_date: modalInboundDate,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
warehouse_code: modalWarehouse || undefined,
location_code: modalLocation || undefined,
memo: modalMemo || undefined,
} as any)
),
...(toCreate.length > 0
? [createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: toCreate.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
})]
: []),
]);
toast.success("입고 정보를 수정했어요");
setIsModalOpen(false);
fetchList();
} else {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
memo: modalMemo || undefined,
items: selectedItems.map((item) => ({
inbound_type: item.inbound_type,
reference_number: item.reference_number,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
item_number: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_table: item.source_table,
source_id: item.source_id,
inbound_status: "입고완료",
inspection_status: "대기",
})),
});
if (res.success) {
toast.success(res.message || "입고 등록 완료");
setIsModalOpen(false);
fetchList();
}
}
} catch {
alert("입고 등록 중 오류가 발생했습니다.");
} catch (err: any) {
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "입고 등록 중 오류가 발생했습니다.");
toast.error(msg);
} finally {
setSaving(false);
}
@@ -897,13 +877,13 @@ export default function ReceivingPage() {
}
/>
{/* 입고 목록 테이블 (마스터-디테일 그룹) */}
{/* 입고 목록 테이블 (플랫 리스트) */}
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
<div className="flex items-center justify-between border-b bg-muted/50 px-4 py-2.5">
<div className="flex items-center gap-2">
<h3 className="text-[13px] font-bold"> </h3>
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
{Object.keys(filteredGroups).length}
{filteredRows.length}
</span>
</div>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
@@ -912,78 +892,68 @@ export default function ReceivingPage() {
</div>
<div className="h-[calc(100%-44px)] overflow-auto">
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<Table noWrapper style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredRows.map((r) => r.id);
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 입고번호 (별도 컬럼) */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("inbound_number")}>
<span className="truncate"></span>
{sortState?.key === "inbound_number" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["inbound_number"] || []).length > 0 && (
<HeaderFilterPopover
colKey="inbound_number" colLabel="입고번호"
uniqueValues={masterUniqueValues["inbound_number"] || []}
filterValues={headerFilters["inbound_number"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
{GRID_COLUMNS.map((col) => {
const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key);
return (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none", isRight && "text-right")}>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(columnUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={columnUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
@@ -993,7 +963,7 @@ export default function ReceivingPage() {
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredGroups).length === 0 ? (
) : filteredRows.length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
@@ -1003,212 +973,59 @@ export default function ReceivingPage() {
</TableCell>
</TableRow>
) : (
Object.entries(filteredGroups).map(([inboundNo, group]) => {
const isExpanded = expandedOrders.has(inboundNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
filteredRows.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={inboundNo}>
{/* 마스터 행 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(inboundNo)) {
setClosingOrders((prev) => new Set(prev).add(inboundNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(inboundNo));
}
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row as any)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 입고번호 */}
<TableCell className="font-mono whitespace-nowrap">
{inboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "inbound_type": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getTypeVariant(resolveInboundType(master.inbound_type))} className="text-[11px]">
{resolveInboundType(master.inbound_type)}
</Badge>
</TableCell>
);
case "inbound_date": return (
<TableCell key={col.key} className="text-[13px] whitespace-nowrap">
{master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "supplier_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.supplier_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "inbound_status": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getStatusVariant(master.inbound_status)} className="text-[11px]">
{master.inbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground text-[13px]">
<span className="block truncate max-w-[110px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(inboundNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell />
{visibleDetailCols.map((col) => {
const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => {
let v = (d as any)[col.key];
if (col.key === "source_table") v = SOURCE_TABLE_LABEL[v] || v;
return v;
}).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
</TableRow>
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(inboundNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={`${row.id}-${detailIdx}`}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_table": return <TableCell key={col.key} className="text-[13px]">{row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}</TableCell>;
case "item_number": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_number || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[160px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "spec": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>;
case "inbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
);
})}
</React.Fragment>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="font-mono whitespace-nowrap text-[13px]">{row.inbound_number || ""}</TableCell>
<TableCell className="text-[13px]">
<Badge variant={getTypeVariant(row.inbound_type)} className="text-[11px]">
{row.inbound_type || "-"}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-[13px]">
{row.inbound_date ? new Date(row.inbound_date).toLocaleDateString("ko-KR") : ""}
</TableCell>
<TableCell className="text-[13px] truncate max-w-[120px]"><span className="block truncate">{row.reference_number || ""}</span></TableCell>
<TableCell className="text-[13px]">{row.source_type || ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[110px]"><span className="block truncate">{row.supplier_name || ""}</span></TableCell>
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
<TableCell className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px] truncate max-w-[100px]"><span className="block truncate">{row.warehouse_name || (row as any).warehouse_code || ""}</span></TableCell>
<TableCell className="text-[13px]">
<Badge variant={getStatusVariant(row.inbound_status)} className="text-[11px]">
{row.inbound_status || "-"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.remark || row.memo || ""}</span></TableCell>
</TableRow>
);
})
)}
@@ -1221,9 +1038,9 @@ export default function ReceivingPage() {
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="sm:max-w-[1600px] w-[95vw] h-[90vh] p-0 flex flex-col overflow-hidden">
<DialogHeader className="shrink-0 px-6 pt-4 pb-2 border-b">
<DialogTitle> </DialogTitle>
<DialogTitle>{editMode ? "입고 수정" : "입고 등록"}</DialogTitle>
<DialogDescription>
, .
{editMode ? "입고 정보를 수정해 주세요." : "입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해 주세요."}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col">
@@ -1053,14 +1053,14 @@ export default function ProductionPlanManagementPage() {
return (
<Table style={{ minWidth: "900px" }}>
<TableHeader className="sticky top-0 z-10">
<TableHeader className="sticky top-0 z-20">
<TableRow className="bg-muted hover:bg-muted">
<TableHead style={{ width: "30px", minWidth: "30px" }}>
<TableHead className="sticky left-0 z-20 bg-muted" style={{ width: "30px", minWidth: "30px" }}>
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead style={{ width: "40px", minWidth: "40px" }} />
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="sticky left-[30px] z-20 bg-muted" style={{ width: "40px", minWidth: "40px" }} />
<TableHead className="sticky left-[70px] z-20 bg-muted text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={{ width: "140px", minWidth: "140px" }}></TableHead>
<TableHead className="sticky left-[210px] z-20 bg-muted text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground border-r" style={{ width: "140px", minWidth: "140px" }}></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} style={{ ...ts.thStyle(col.key), minWidth: "80px" }} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
{col.label}
@@ -1073,9 +1073,9 @@ export default function ProductionPlanManagementPage() {
if (item._isGroupSummary) {
return (
<TableRow key={`summary-${rowIdx}`} className="bg-muted/60 font-semibold border-t border-primary/20">
<TableCell />
<TableCell />
<TableCell colSpan={2} />
<TableCell className="sticky left-0 z-10 bg-muted/60" />
<TableCell className="sticky left-[30px] z-10 bg-muted/60" />
<TableCell colSpan={2} className="sticky left-[70px] z-10 bg-muted/60 border-r" />
{ts.visibleColumns.map((col) => {
const v = (item as any)[col.key];
return (
@@ -1090,14 +1090,14 @@ export default function ProductionPlanManagementPage() {
return (
<React.Fragment key={item.item_code || rowIdx}>
<TableRow className={cn("cursor-pointer border-t-2 border-t-primary/30 bg-primary/5 font-semibold hover:bg-primary/10", selectedItemGroups.has(item.item_code) && "bg-primary/10")}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<TableCell className="sticky left-0 z-10 bg-background text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selectedItemGroups.has(item.item_code)} onCheckedChange={() => toggleItemGroupSelect(item.item_code)} className="h-4 w-4" />
</TableCell>
<TableCell className="text-center" onClick={() => toggleItemExpand(item.item_code)}>
<TableCell className="sticky left-[30px] z-10 bg-background text-center" onClick={() => toggleItemExpand(item.item_code)}>
<ChevronRight className={cn("h-4 w-4 transition-transform duration-200", expandedItems.has(item.item_code) && "rotate-90")} />
</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
<TableCell className="sticky left-[70px] z-10 bg-background text-[13px] text-primary truncate" style={{ width: "140px", minWidth: "140px" }} onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell className="sticky left-[210px] z-10 bg-background text-[13px] text-primary truncate border-r" style={{ width: "140px", minWidth: "140px" }} onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
{ts.visibleColumns.map((col) => renderGroupCell(col, item))}
</TableRow>
{expandedItems.has(item.item_code) && item.orders?.map((detail: any) => {
@@ -1107,9 +1107,9 @@ export default function ProductionPlanManagementPage() {
}
return (
<TableRow key={detail.id || detail.order_no} className="cursor-pointer hover:bg-muted/50">
<TableCell />
<TableCell />
<TableCell colSpan={2} className="pl-10">
<TableCell className="sticky left-0 z-10 bg-card" />
<TableCell className="sticky left-[30px] z-10 bg-card" />
<TableCell colSpan={2} className="sticky left-[70px] z-10 bg-card pl-10 border-r">
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{detail.order_no}</span>
@@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Package, ChevronDown,
ClipboardList, Pencil, Search, X, Package,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2, GripVertical,
} from "lucide-react";
@@ -88,18 +88,18 @@ const GRID_COLUMNS_CONFIG = [
];
const MODAL_DETAIL_COLUMNS = [
{ key: "item_code", label: "품번", width: "w-[120px]" },
{ key: "item_name", label: "품명", width: "w-[120px]" },
{ key: "supplier", label: "공급업체", width: "w-[150px]" },
{ key: "spec", label: "규격", width: "w-[80px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "order_qty", label: "발주수량", width: "w-[90px]" },
{ key: "received_qty", label: "입고수량", width: "w-[90px]" },
{ key: "remain_qty", label: "잔량", width: "w-[80px]" },
{ key: "unit_price", label: "단가", width: "w-[100px]" },
{ key: "amount", label: "금액", width: "w-[100px]" },
{ key: "due_date", label: "납기일", width: "w-[160px]" },
{ key: "memo", label: "메모", width: "w-[120px]" },
{ key: "item_code", label: "품번", width: "min-w-[120px]" },
{ key: "item_name", label: "품명", width: "min-w-[150px]" },
{ key: "supplier", label: "공급업체", width: "min-w-[150px]" },
{ key: "spec", label: "규격", width: "min-w-[80px]" },
{ key: "unit", label: "단위", width: "min-w-[90px]" },
{ key: "order_qty", label: "발주수량", width: "min-w-[90px]" },
{ key: "received_qty", label: "입고수량", width: "min-w-[90px]" },
{ key: "remain_qty", label: "잔량", width: "min-w-[80px]" },
{ key: "unit_price", label: "단가", width: "min-w-[100px]" },
{ key: "amount", label: "금액", width: "min-w-[100px]" },
{ key: "due_date", label: "납기일", width: "min-w-[160px]" },
{ key: "memo", label: "메모", width: "min-w-[120px]" },
];
const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16";
@@ -162,7 +162,6 @@ export default function PurchaseOrderPage() {
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
// 테이블 설정
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
@@ -238,7 +237,7 @@ export default function PurchaseOrderPage() {
);
try {
const suppRes = await apiClient.post(`/table-management/tables/supplier_mng/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 5000, autoFilter: true,
});
const supps = suppRes.data?.data?.data || suppRes.data?.data?.rows || [];
optMap["supplier_code"] = supps.map((s: any) => ({
@@ -248,7 +247,7 @@ export default function PurchaseOrderPage() {
} catch { /* skip */ }
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 5000, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
optMap["manager"] = users.map((u: any) => ({
@@ -294,7 +293,7 @@ export default function PurchaseOrderPage() {
}
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: detailFilters.length > 0 ? { enabled: true, filters: detailFilters } : undefined,
autoFilter: true,
sort: { columnName: "purchase_no", order: "desc" },
@@ -372,20 +371,6 @@ export default function PurchaseOrderPage() {
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// purchase_no 기준 그룹핑
const orderGroups = useMemo(() => {
const map: Record<string, { master: any; details: any[] }> = {};
for (const row of orders) {
const key = row.purchase_no || row.id;
if (!map[key]) {
map[key] = {
master: { purchase_no: row.purchase_no, order_date: row.order_date, supplier_name: row.supplier_name, status: row.status, memo: row.memo },
details: [],
};
}
map[key].details.push(row);
}
return map;
}, [orders]);
const getCategoryLabel = (col: string, code: string) => {
if (!code) return "";
@@ -553,7 +538,7 @@ export default function PurchaseOrderPage() {
}
};
// 품목 검색
// 품목 검색 (수주관리와 동일한 서버 페이징 방식)
const searchItems = async (page?: number, size?: number) => {
const p = page ?? itemPage;
const s = size ?? itemPageSize;
@@ -563,25 +548,24 @@ export default function PurchaseOrderPage() {
if (itemSearchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
}
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
const divValues = [itemSearchDivision];
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
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)));
const rows = resData?.data || resData?.rows || [];
const serverTotal = resData?.total || resData?.totalCount || rows.length;
setItemSearchResults(rows);
setItemTotal(serverTotal);
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
@@ -623,7 +607,7 @@ export default function PurchaseOrderPage() {
try {
const itemIds = selected.map((item) => item.item_number || item.id);
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: {
enabled: true,
filters: [
@@ -686,7 +670,7 @@ export default function PurchaseOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
autoFilter: true,
});
@@ -708,7 +692,7 @@ export default function PurchaseOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: supplierCode },
{ columnName: "item_id", operator: "in", value: itemCodes },
@@ -811,125 +795,83 @@ export default function PurchaseOrderPage() {
</div>
</div>
{/* 데이터 테이블 — 아코디언 그룹핑 */}
{/* 데이터 테이블 — 플랫 리스트 */}
<div className="flex-1 overflow-auto border rounded-lg bg-card">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : Object.keys(orderGroups).length === 0 ? (
) : orders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Package className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
(() => {
const MASTER_KEYS = new Set(["purchase_no", "order_date", "supplier_name", "status", "memo"]);
const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]);
// ts.visibleColumns 순서를 따르되, 마스터/디테일 컬럼을 분리
// 고정 컬럼(품목수)은 마스터 선행 컬럼 뒤에 배치
const leadingMaster: typeof ts.visibleColumns = [];
const detailCols: typeof ts.visibleColumns = [];
const trailingMaster: typeof ts.visibleColumns = [];
let passedFirstDetail = false;
for (const col of ts.visibleColumns) {
if (MASTER_KEYS.has(col.key)) {
if (passedFirstDetail) trailingMaster.push(col);
else leadingMaster.push(col);
} else {
passedFirstDetail = true;
detailCols.push(col);
}
}
const renderDetailCell = (row: any, key: string) => {
const renderCell = (row: any, key: string) => {
const val = row[key];
if (key === "status") return val ? <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[val] || "")}>{val}</span> : "-";
if (numCols.has(key)) return <span className="font-mono">{val ? Number(val).toLocaleString() : "0"}</span>;
return val || "-";
if (key === "order_date" || key === "due_date") return val ? new Date(val).toLocaleDateString("ko-KR") : "-";
if (numCols.has(key)) return <span className="font-mono">{val ? Number(val).toLocaleString() : ""}</span>;
return val || "";
};
const renderMasterHead = (col: { key: string; label: string }) => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", col.key === "status" && "text-center")}>
{col.label}
</TableHead>
);
const renderMasterCell = (col: { key: string }, m: any, purchaseNo: string) => {
if (col.key === "purchase_no") return <TableCell key={col.key} className="text-sm font-semibold text-primary">{purchaseNo}</TableCell>;
if (col.key === "order_date") return <TableCell key={col.key} className="text-sm">{m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}</TableCell>;
if (col.key === "supplier_name") return <TableCell key={col.key} className="text-sm">{m.supplier_name || "-"}</TableCell>;
if (col.key === "status") return <TableCell key={col.key} className="text-center">{m.status && <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[m.status] || "")}>{m.status}</span>}</TableCell>;
if (col.key === "memo") return <TableCell key={col.key} className="text-sm text-muted-foreground max-w-[120px] truncate">{m.memo || ""}</TableCell>;
return <TableCell key={col.key} />;
};
return (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-8" />
<TableHead className="w-10" />
{leadingMaster.map(renderMasterHead)}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground text-center"></TableHead>
{detailCols.map(col => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", numCols.has(col.key) && "text-right")}>
{col.label}{col.key === "order_qty" || col.key === "amount" ? " 합계" : ""}
<TableHead
className="w-10 text-center cursor-pointer"
onClick={() => {
const allIds = orders.map((r) => r.id);
const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allIds);
}}
>
<Checkbox
checked={orders.length > 0 && orders.every((r) => checkedIds.includes(r.id))}
onCheckedChange={() => {}}
/>
</TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", numCols.has(col.key) && "text-right", col.key === "status" && "text-center")}>
{col.label}
</TableHead>
))}
{trailingMaster.map(renderMasterHead)}
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(orderGroups).map(([purchaseNo, group]) => {
const isExpanded = expandedOrders.has(purchaseNo);
const detailIds = group.details.map(d => d.id);
const allChecked = detailIds.length > 0 && detailIds.every(id => checkedIds.includes(id));
const someChecked = detailIds.some(id => checkedIds.includes(id));
const m = group.master;
const totalQty = group.details.reduce((s, d) => s + (Number(d.order_qty) || 0), 0);
const totalAmt = group.details.reduce((s, d) => s + (Number(d.amount) || 0), 0);
{orders.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={purchaseNo}>
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn("cursor-pointer border-l-[3px] border-l-transparent font-semibold", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedOrders(prev => { const next = new Set(prev); if (next.has(purchaseNo)) next.delete(purchaseNo); else next.add(purchaseNo); return next; })}
onDoubleClick={() => openEditModal(purchaseNo)}
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row.purchase_no)}
>
<TableCell
className="text-center"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
{ts.visibleColumns.map((col) => (
<TableCell key={col.key} className={cn("text-[13px]", numCols.has(col.key) && "text-right font-mono", col.key === "status" && "text-center")}>
{renderCell(row, col.key)}
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !detailIds.includes(id)) : [...new Set([...prev, ...detailIds])]); }}>
<Checkbox checked={allChecked} data-state={someChecked && !allChecked ? "indeterminate" : undefined} onCheckedChange={() => {}} />
</TableCell>
{leadingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
<TableCell className="text-sm text-center"><Badge variant="secondary" className="text-[10px]">{group.details.length}</Badge></TableCell>
{detailCols.map(col => (
<TableCell key={col.key} className={cn("text-sm", numCols.has(col.key) && "text-right font-mono")}>
{col.key === "order_qty" ? totalQty.toLocaleString()
: col.key === "amount" ? totalAmt.toLocaleString()
: ""}
</TableCell>
))}
{trailingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
</TableRow>
{isExpanded && group.details.map((row) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell className="text-center" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id]); }}>
<Checkbox checked={checkedIds.includes(row.id)} onCheckedChange={() => {}} />
</TableCell>
{leadingMaster.map(col => <TableCell key={col.key} />)}
<TableCell />
{detailCols.map(col => (
<TableCell key={col.key} className={cn(numCols.has(col.key) && "text-right")}>
{renderDetailCell(row, col.key)}
</TableCell>
))}
{trailingMaster.map(col => <TableCell key={col.key} />)}
</TableRow>
))}
</React.Fragment>
</TableRow>
);
})}
</TableBody>
@@ -938,7 +880,7 @@ export default function PurchaseOrderPage() {
})()
)}
<div className="px-4 py-2 border-t text-xs text-muted-foreground">
{Object.keys(orderGroups).length} ( ) / {orders.length} ( )
{orders.length}
</div>
</div>
@@ -1094,7 +1036,7 @@ export default function PurchaseOrderPage() {
) : (
<div className="border rounded-lg overflow-x-auto">
<DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
<Table className="table-fixed">
<Table>
<TableHeader className="sticky top-0 z-10">
<SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableRow className="bg-muted hover:bg-muted">
@@ -1118,12 +1060,12 @@ export default function PurchaseOrderPage() {
{visibleModalColumns.map((col) => {
switch (col.key) {
case "item_code":
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
return <TableCell key={col.key} className="text-[13px] font-mono whitespace-nowrap"><span title={row.item_code}>{row.item_code}</span></TableCell>;
case "item_name":
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
return <TableCell key={col.key} className="text-[13px] whitespace-nowrap"><span title={row.item_name}>{row.item_name}</span></TableCell>;
case "supplier":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[150px]">
{isReadOnly ? (
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
) : (
@@ -1133,7 +1075,7 @@ export default function PurchaseOrderPage() {
updateDetailRow(idx, "supplier_code", v);
updateDetailRow(idx, "supplier_name", name);
}}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectTrigger className="h-8 text-xs w-full"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
@@ -1149,11 +1091,11 @@ export default function PurchaseOrderPage() {
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
case "order_qty":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[110px]">
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono w-full" />
)}
</TableCell>
);
@@ -1163,11 +1105,11 @@ export default function PurchaseOrderPage() {
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":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[120px]">
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{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" />
<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" />
)}
</TableCell>
);
@@ -1175,21 +1117,21 @@ export default function PurchaseOrderPage() {
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
case "due_date":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[160px]">
{isReadOnly ? (
<span className="text-xs">{row.due_date}</span>
) : (
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs w-full" />
)}
</TableCell>
);
case "memo":
return (
<TableCell key={col.key}>
<TableCell key={col.key} className="min-w-[140px]">
{isReadOnly ? (
<span className="text-xs">{row.memo}</span>
) : (
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs w-full" />
)}
</TableCell>
);
@@ -142,6 +142,10 @@ const FORM_FIELDS = [
{ key: "division", label: "관리품목", type: "multi-category" },
{ key: "type", label: "품목구분", type: "category" },
{ key: "size", label: "규격", type: "text" },
{ key: "width", label: "가로", type: "text", placeholder: "숫자 입력 (mm)" },
{ key: "height", label: "세로", type: "text", placeholder: "숫자 입력 (mm)" },
{ key: "thickness", label: "두께", type: "text", placeholder: "숫자 입력 (mm)" },
{ key: "area", label: "면적", type: "text", placeholder: "숫자 입력 (㎡)" },
{ key: "unit", label: "단위", type: "category" },
{ key: "material", label: "재질", type: "category" },
{ key: "status", label: "상태", type: "category" },
@@ -175,6 +179,10 @@ const ITEM_GRID_COLUMNS = [
{ key: "item_number", label: "품번" },
{ key: "item_name", label: "품명" },
{ key: "size", label: "규격" },
{ key: "width", label: "가로" },
{ key: "height", label: "세로" },
{ key: "thickness", label: "두께" },
{ key: "area", label: "면적" },
{ key: "unit", label: "단위" },
{ key: "standard_price", label: "기준단가/구매단가" },
{ key: "currency_code", label: "통화" },
@@ -1605,9 +1613,25 @@ export default function PurchaseItemPage() {
) : (
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={"placeholder" in field ? field.placeholder : field.label}
className="h-9"
readOnly={field.key === "area"}
onChange={(e) => {
if (field.key === "area") return;
const v = e.target.value;
setFormData((prev) => {
const next = { ...prev, [field.key]: v };
// 가로/세로 변경 시 면적(㎡) 자동 계산: (가로mm × 세로mm) / 1,000,000
if (field.key === "width" || field.key === "height") {
const w = Number(field.key === "width" ? v : prev.width);
const h = Number(field.key === "height" ? v : prev.height);
if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) {
next.area = ((w * h) / 1_000_000).toFixed(4);
}
}
return next;
});
}}
placeholder={field.key === "area" ? "자동 계산" : ("placeholder" in field ? field.placeholder : field.label)}
className={cn("h-9", field.key === "area" && "bg-muted cursor-not-allowed")}
/>
)}
</div>
@@ -424,9 +424,10 @@ export default function ItemInspectionInfoPage() {
case "inspection_type": return (
<TableCell key={col.key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>
))}
{group.types.map((t: string) => {
const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t;
return <Badge key={t} variant="secondary" className="text-[10px]">{label}</Badge>;
})}
</div>
</TableCell>
);
@@ -494,7 +495,7 @@ export default function ItemInspectionInfoPage() {
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
)}
>
{type}
{inspTypeCatOptions.find((o) => o.code === type)?.label || type}
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
@@ -517,42 +517,44 @@ export default function SalesOrderPage() {
}
};
// 삭제 (마스터 + 디테일)
// 삭제 (선택한 디테일 삭제 → 디테일 0건인 마스터 자동 삭제)
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; }
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
const ok = await confirm(`${orderNos.length}건의 수주를 삭제하시겠습니까?`, {
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요."); return; }
const ok = await confirm(`${checkedIds.length}건의 수주 항목을 삭제하시겠습니까?`, {
description: "삭제된 데이터는 복구할 수 없습니다.",
variant: "destructive",
confirmText: "삭제",
});
if (!ok) return;
try {
// 1. 선택한 디테일 삭제
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
data: checkedIds.map((id) => ({ id })),
});
// 2. 영향받는 수주번호의 잔여 디테일 확인 → 0건이면 마스터도 삭제
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
for (const orderNo of orderNos) {
// 디테일 삭제
const detailRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 9999,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const details = detailRes.data?.data?.data || detailRes.data?.data?.rows || [];
if (details.length > 0) {
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
data: details.map((d: any) => ({ id: d.id })),
});
}
// 마스터 삭제
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
const remainRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
if (masters.length > 0) {
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
data: masters.map((m: any) => ({ id: m.id })),
const remaining = remainRes.data?.data?.data || remainRes.data?.data?.rows || [];
if (remaining.length === 0) {
// 디테일 0건 → 마스터 삭제
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
if (masters.length > 0) {
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
data: masters.map((m: any) => ({ id: m.id })),
});
}
}
}
toast.success("삭제되었습니다.");
@@ -622,6 +624,15 @@ export default function SalesOrderPage() {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
// 코드 또는 라벨이 저장된 경우 모두 조회하기 위해 in 연산자 사용
const divValues = [itemSearchDivision];
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const partnerId = masterForm.partner_id;
@@ -630,7 +641,7 @@ export default function SalesOrderPage() {
if (isCustomerPrice && partnerId) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
autoFilter: true,
});
@@ -640,31 +651,22 @@ export default function SalesOrderPage() {
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
let allRows = resData?.data || resData?.rows || [];
let rows = resData?.data || resData?.rows || [];
const serverTotal = resData?.total || resData?.totalCount || rows.length;
// 거래처우선일 때 연결된 품목만 표시
// 거래처우선일 때 연결된 품목만 표시 (클라이언트 필터)
if (customerItemIds) {
allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
rows = rows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
}
// 관리품목 필터 (코드/라벨 혼재 대응)
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)));
setItemSearchResults(rows);
setItemTotal(serverTotal);
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
@@ -1318,44 +1320,44 @@ export default function SalesOrderPage() {
<Table noWrapper className="min-w-max w-full">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="w-28 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-32 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-32 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">/</TableHead>
<TableHead className="min-w-[40px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[120px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[110px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">/</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
<TableCell className="text-center font-mono text-[13px] text-muted-foreground/50">{idx + 1}</TableCell>
<TableCell className="max-w-[112px]">
<span className="block truncate font-mono text-[13px]" title={row.part_code}>{row.part_code}</span>
<TableCell className="whitespace-nowrap">
<span className="font-mono text-[13px]" title={row.part_code}>{row.part_code}</span>
</TableCell>
<TableCell className="max-w-[128px]">
<span className="block truncate text-[13px]" title={row.part_name}>{row.part_name}</span>
<TableCell className="whitespace-nowrap">
<span className="text-[13px]" title={row.part_name}>{row.part_name}</span>
</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.spec}</TableCell>
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.material}</TableCell>
<TableCell>
<Input
value={row.packing_material || ""}
onChange={(e) => updateDetailRow(idx, "packing_material", e.target.value)}
placeholder="포장재"
className="h-8 text-xs"
className="h-8 text-xs w-full"
/>
</TableCell>
<TableCell>
<Select value={row.unit || ""} onValueChange={(v) => updateDetailRow(idx, "unit", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="단위" /></SelectTrigger>
<SelectTrigger className="h-8 text-xs w-full"><SelectValue placeholder="단위" /></SelectTrigger>
<SelectContent>
{(categoryOptions["item_unit"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
@@ -1369,7 +1371,7 @@ export default function SalesOrderPage() {
min="1"
value={row.qty || "1"}
onChange={(e) => updateDetailRow(idx, "qty", e.target.value)}
className="h-8 text-xs text-right font-mono w-16"
className="h-8 text-xs text-right font-mono w-full"
/>
</TableCell>
<TableCell>
@@ -1378,7 +1380,7 @@ export default function SalesOrderPage() {
min="0"
value={row.pack_qty || "0"}
onChange={(e) => updateDetailRow(idx, "pack_qty", e.target.value)}
className="h-8 text-xs text-right font-mono w-16"
className="h-8 text-xs text-right font-mono w-full"
/>
</TableCell>
<TableCell>
@@ -1386,10 +1388,10 @@ export default function SalesOrderPage() {
value={formatNumber(row.unit_price || "")}
onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))}
readOnly={!allowPriceEdit}
className={cn("h-8 text-xs text-right font-mono w-20", !allowPriceEdit && "bg-muted cursor-not-allowed")}
className={cn("h-8 text-xs text-right font-mono w-full", !allowPriceEdit && "bg-muted cursor-not-allowed")}
/>
</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">
<TableCell className="text-right font-mono text-[13px] font-semibold whitespace-nowrap">
{row.amount ? Number(row.amount).toLocaleString() : "0"}
</TableCell>
<TableCell>
@@ -1397,7 +1399,7 @@ export default function SalesOrderPage() {
type="date"
value={row.due_date || ""}
onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)}
className="h-8 text-xs"
className="h-8 text-xs w-full"
/>
</TableCell>
<TableCell className="text-center">
@@ -1472,15 +1474,7 @@ export default function SalesOrderPage() {
onKeyDown={(e) => e.key === "Enter" && triggerNewSearch()}
className="h-9 flex-1"
/>
<Select value={itemSearchDivision} onValueChange={setItemSearchDivision}>
<SelectTrigger className="h-9 w-[130px]"><SelectValue placeholder="관리품목" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["item_division"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="h-9 px-3 flex items-center rounded-md border border-input bg-muted text-xs font-medium text-muted-foreground whitespace-nowrap"></div>
<Button size="sm" onClick={triggerNewSearch} disabled={itemSearchLoading} className="h-9">
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /></>}
</Button>