Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node

This commit is contained in:
DDD1542
2026-04-06 17:23:21 +09:00
13 changed files with 2436 additions and 290 deletions
@@ -37,17 +37,22 @@ import {
Save,
ChevronRight,
ChevronLeft,
ChevronDown,
ChevronsLeft,
ChevronsRight,
Inbox,
Settings2,
Filter,
Check,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
// API: /outbound/*
import {
getOutboundList,
@@ -110,6 +115,118 @@ 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)]);
// 총 컬럼 수: 체크박스(1) + 화살표(1) + 출고번호(1) + 마스터필드(7) = 10
const TOTAL_COLS = 10;
// 헤더 필터 Popover
function HeaderFilterPopover({
colKey, colLabel, uniqueValues, filterValues, onToggle, onClear,
}: {
colKey: string;
colLabel: string;
uniqueValues: string[];
filterValues: Set<string>;
onToggle: (colKey: string, value: string) => void;
onClear: (colKey: string) => void;
}) {
const [filterSearch, setFilterSearch] = useState("");
const hasFilter = filterValues.size > 0;
const filteredValues = uniqueValues.filter(
(v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase())
);
return (
<Popover>
<PopoverTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className={cn(
"hover:bg-primary/20 rounded p-0.5 transition-colors shrink-0",
hasFilter && "text-primary bg-primary/10",
)}
title="필터"
>
<Filter className="h-3 w-3" />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2" align="start" onClick={(e) => e.stopPropagation()}>
<div className="space-y-2">
<div className="flex items-center justify-between border-b pb-2">
<span className="text-xs font-medium">: {colLabel}</span>
{hasFilter && (
<button onClick={() => onClear(colKey)} className="text-primary text-xs hover:underline">
</button>
)}
</div>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
value={filterSearch}
onChange={(e) => setFilterSearch(e.target.value)}
placeholder="검색..."
className="h-7 text-xs pl-7"
/>
</div>
<div className="max-h-52 space-y-0.5 overflow-y-auto">
{filteredValues.slice(0, 100).map((val) => {
const isSelected = filterValues.has(val);
return (
<div
key={val}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => onToggle(colKey, val)}
>
<div className={cn(
"flex h-4 w-4 items-center justify-center rounded border shrink-0",
isSelected ? "bg-primary border-primary" : "border-input",
)}>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{val || "(빈 값)"}</span>
</div>
);
})}
{filteredValues.length > 100 && (
<div className="text-muted-foreground px-2 py-1 text-xs">
... {filteredValues.length - 100}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
}
// 선택된 소스 아이템 (등록 모달에서 사용)
interface SelectedSourceItem {
key: string;
@@ -139,6 +256,12 @@ 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);
// 등록 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalOutboundType, setModalOutboundType] = useState("판매출고");
@@ -210,14 +333,146 @@ export default function OutboundPage() {
})();
}, []);
// 체크박스
const allChecked = data.length > 0 && checkedIds.length === data.length;
const toggleCheckAll = () => {
setCheckedIds(allChecked ? [] : data.map((d) => d.id));
// --- 마스터-디테일 그룹핑, 필터, 정렬 ---
// 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 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 }))]) {
const values = new Set<string>();
masters.forEach((m) => {
const val = (m as any)?.[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
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));
}
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
setHeaderFilters((prev) => {
const next = { ...prev };
const set = new Set(next[colKey] || []);
if (set.has(value)) set.delete(value); else set.add(value);
if (set.size === 0) delete next[colKey]; else next[colKey] = set;
return next;
});
};
const toggleCheck = (id: string) => {
setCheckedIds((prev) =>
prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id]
const clearHeaderFilter = (colKey: string) => {
setHeaderFilters((prev) => {
const next = { ...prev };
delete next[colKey];
return next;
});
};
const handleSort = (key: string) => {
setSortState((prev) =>
prev?.key === key
? prev.direction === "asc" ? { key, direction: "desc" } : null
: { key, direction: "asc" }
);
};
@@ -316,7 +571,7 @@ export default function OutboundPage() {
unit_price: Number(g.unit_price) || 0,
total_amount: Number(g.total_amount) || 0,
source_type: g.source_type || "",
source_id: g.source_id || "",
source_id: (g as any).source_id || "",
}))
);
setSourceKeyword("");
@@ -644,40 +899,294 @@ export default function OutboundPage() {
</div>
</div>
<EDataTable
columns={[
{ key: "outbound_number", label: "출고번호", width: "w-[130px]" },
{ key: "outbound_type", label: "출고유형", width: "w-[90px]", render: (v) => (
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(v))}>{v || "-"}</Badge>
)},
{ key: "outbound_date", label: "출고일", width: "w-[100px]", render: (v) => v ? new Date(v).toLocaleDateString("ko-KR") : "-" },
{ key: "reference_number", label: "참조번호", width: "w-[120px]" },
{ key: "source_type", label: "데이터출처", width: "w-[80px]", render: (v) => v ? SOURCE_TYPE_LABEL[v] || v : "-" },
{ key: "customer_name", label: "거래처", width: "w-[120px]" },
{ key: "item_code", label: "품목코드", width: "w-[100px]" },
{ key: "item_name", label: "품목명", minWidth: "min-w-[150px]" },
{ key: "specification", label: "규격", width: "w-[80px]" },
{ key: "outbound_qty", label: "출고수량", width: "w-[80px]", align: "right", formatNumber: true },
{ key: "unit_price", label: "단가", width: "w-[90px]", align: "right", formatNumber: true },
{ key: "total_amount", label: "금액", width: "w-[100px]", align: "right", formatNumber: true },
{ key: "warehouse_name", label: "창고", width: "w-[100px]", render: (_v, row) => row.warehouse_name || row.warehouse_code || "-" },
{ key: "outbound_status", label: "출고상태", width: "w-[90px]", align: "center", render: (v) => (
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(v))}>{v || "-"}</Badge>
)},
{ key: "memo", label: "비고", width: "w-[100px]" },
] as EDataTableColumn<OutboundItem>[]}
data={ts.groupData(data)}
rowKey={(row) => row.id}
loading={loading}
emptyMessage="등록된 출고 내역이 없어요"
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
onRowDoubleClick={(row) => openEditModal(row)}
showPagination
draggableColumns
columnOrderKey="c16-outbound"
/>
<div className="h-full overflow-auto">
<Table style={{ minWidth: "1200px" }}>
<colgroup><col style={{ width: "40px" }} /><col style={{ width: "36px" }} /><col style={{ width: "140px" }} /><col style={{ width: "90px" }} /><col style={{ width: "100px" }} /><col style={{ width: "120px" }} /><col style={{ width: "120px" }} /><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 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));
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>
{/* 마스터 필드 헤더 */}
{MASTER_BODY_LAYOUT.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" />
)}
</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>
))}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredGroups).length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<PackageOpen className="h-8 w-8 opacity-30" />
<span className="text-sm"> </span>
</div>
</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;
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>
{/* 출고유형 */}
<TableCell>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{master.outbound_type || "-"}
</Badge>
</TableCell>
{/* 출고일 */}
<TableCell className="whitespace-nowrap text-[13px]">
{master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
{/* 참조번호 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
{/* 거래처 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.customer_name || ""}</span>
</TableCell>
{/* 창고 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
{/* 출고상태 */}
<TableCell>
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(master.outbound_status))}>
{master.outbound_status || "-"}
</Badge>
</TableCell>
{/* 비고 */}
<TableCell className="text-muted-foreground">
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
</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 />
{DETAIL_HEADER_COLS.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>
)}
{/* 디테일 행 (펼쳤을 때만) */}
{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 />
{/* 출처 */}
<TableCell className="text-[13px]">{row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}</TableCell>
{/* 품목코드 */}
<TableCell className="font-mono text-[13px]">{row.item_code || ""}</TableCell>
{/* 품목명 */}
<TableCell className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
{/* 규격 */}
<TableCell className="text-[13px] text-muted-foreground">{row.specification || ""}</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>
</TableRow>
);
})}
</React.Fragment>
);
})
)}
</TableBody>
</Table>
</div>
</div>
{/* 출고 등록 모달 */}
@@ -43,17 +43,23 @@ import {
X,
Save,
ChevronRight,
ChevronDown,
ChevronLeft,
ChevronsLeft,
ChevronsRight,
Settings2,
Filter,
Check,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
// EDataTable 제거 — 마스터-디테일 그룹 테이블로 교체
// API: /receiving/*
import {
getReceivingList,
@@ -89,13 +95,137 @@ 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: "금액" },
];
// 총 컬럼 수: 체크박스(1) + 화살표(1) + 입고번호(1) + 디테일(7) = 10
const TOTAL_COLS = 10;
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 헤더 필터 Popover
function HeaderFilterPopover({
colKey, colLabel, uniqueValues, filterValues, onToggle, onClear,
}: {
colKey: string;
colLabel: string;
uniqueValues: string[];
filterValues: Set<string>;
onToggle: (colKey: string, value: string) => void;
onClear: (colKey: string) => void;
}) {
const [filterSearch, setFilterSearch] = useState("");
const hasFilter = filterValues.size > 0;
const filteredValues = uniqueValues.filter(
(v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase())
);
return (
<Popover>
<PopoverTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className={cn(
"hover:bg-primary/20 rounded p-0.5 transition-colors shrink-0",
hasFilter && "text-primary bg-primary/10",
)}
title="필터"
>
<Filter className="h-3 w-3" />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2" align="start" onClick={(e) => e.stopPropagation()}>
<div className="space-y-2">
<div className="flex items-center justify-between border-b pb-2">
<span className="text-xs font-medium">: {colLabel}</span>
{hasFilter && (
<button onClick={() => onClear(colKey)} className="text-primary text-xs hover:underline">
</button>
)}
</div>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
value={filterSearch}
onChange={(e) => setFilterSearch(e.target.value)}
placeholder="검색..."
className="h-7 text-xs pl-7"
/>
</div>
<div className="max-h-52 space-y-0.5 overflow-y-auto">
{filteredValues.slice(0, 100).map((val) => {
const isSelected = filterValues.has(val);
return (
<div
key={val}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => onToggle(colKey, val)}
>
<div className={cn(
"flex h-4 w-4 items-center justify-center rounded border shrink-0",
isSelected ? "bg-primary border-primary" : "border-input",
)}>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{val || "(빈 값)"}</span>
</div>
);
})}
{filteredValues.length > 100 && (
<div className="text-muted-foreground px-2 py-1 text-xs">
... {filteredValues.length - 100}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
}
// 입고유형 옵션
const INBOUND_TYPES = [
{ value: "구매입고", label: "구매입고" },
{ value: "외주입고", label: "외주입고" },
{ value: "사급자재입고", label: "사급자재입고" },
{ value: "반품입고", label: "반품입고" },
{ value: "기타입고", label: "기타입고" },
];
// 입고유형 카테고리 코드→라벨 매핑
const INBOUND_TYPE_CODE_MAP: Record<string, string> = {
CAT_MLYTB8ON_A3AU: "구매입고",
CAT_MLYTBMH6_9AB7: "외주입고",
CAT_MLYTBSLW_5N81: "사급자재입고",
CAT_MLYTBGEV_N23U: "반품입고",
CAT_MLYTBYLU_0Z5T: "기타입고",
};
const resolveInboundType = (v: string) => INBOUND_TYPE_CODE_MAP[v] || v || "-";
const INBOUND_STATUS_OPTIONS = [
{ value: "대기", label: "대기" },
{ value: "입고완료", label: "입고완료" },
@@ -156,6 +286,12 @@ 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);
// 등록 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalInboundType, setModalInboundType] = useState("구매입고");
@@ -237,14 +373,149 @@ export default function ReceivingPage() {
})();
}, []);
// 체크박스
const allChecked = data.length > 0 && checkedIds.length === data.length;
const toggleCheckAll = () => {
setCheckedIds(allChecked ? [] : data.map((d) => d.id));
// 필터 + 정렬 적용된 데이터 -> 그룹핑
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;
}, [data]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
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_table") val = SOURCE_TABLE_LABEL[val] || val;
values.add(String(val));
}
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
setHeaderFilters((prev) => {
const next = { ...prev };
const set = new Set(next[colKey] || []);
if (set.has(value)) set.delete(value); else set.add(value);
if (set.size === 0) delete next[colKey]; else next[colKey] = set;
return next;
});
};
const toggleCheck = (id: string) => {
setCheckedIds((prev) =>
prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id]
const clearHeaderFilter = (colKey: string) => {
setHeaderFilters((prev) => {
const next = { ...prev };
delete next[colKey];
return next;
});
};
const handleSort = (key: string) => {
setSortState((prev) =>
prev?.key === key
? prev.direction === "asc" ? { key, direction: "desc" } : null
: { key, direction: "asc" }
);
};
@@ -561,13 +832,13 @@ export default function ReceivingPage() {
}
/>
{/* 입고 목록 테이블 */}
<div className="flex-1 overflow-hidden rounded-lg border bg-card">
{/* 입고 목록 테이블 (마스터-디테일 그룹) */}
<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">
{data.length}
{Object.keys(filteredGroups).length}
</span>
</div>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
@@ -575,39 +846,292 @@ export default function ReceivingPage() {
</Button>
</div>
<EDataTable
columns={[
{ key: "inbound_number", label: "입고번호", width: "w-[130px]" },
{ key: "inbound_type", label: "입고유형", width: "w-[90px]", render: (v) => (
<Badge variant={getTypeVariant(v)} className="text-[11px]">{v || "-"}</Badge>
)},
{ key: "inbound_date", label: "입고일", width: "w-[100px]", render: (v) => v ? new Date(v).toLocaleDateString("ko-KR") : "-" },
{ key: "reference_number", label: "참조번호", width: "w-[120px]" },
{ key: "source_table", label: "데이터출처", width: "w-[80px]", render: (v) => v ? SOURCE_TABLE_LABEL[v] || v : "-" },
{ key: "supplier_name", label: "공급처", width: "w-[120px]" },
{ key: "item_number", label: "품목코드", width: "w-[100px]" },
{ key: "item_name", label: "품목명", minWidth: "min-w-[150px]" },
{ key: "spec", label: "규격", width: "w-[80px]" },
{ key: "inbound_qty", label: "입고수량", width: "w-[80px]", align: "right", formatNumber: true },
{ key: "unit_price", label: "단가", width: "w-[90px]", align: "right", formatNumber: true },
{ key: "total_amount", label: "금액", width: "w-[100px]", align: "right", formatNumber: true },
{ key: "warehouse_name", label: "창고", width: "w-[100px]", render: (_v, row) => row.warehouse_name || row.warehouse_code || "-" },
{ key: "inbound_status", label: "입고상태", width: "w-[90px]", align: "center", render: (v) => (
<Badge variant={getStatusVariant(v)} className="text-[11px]">{v || "-"}</Badge>
)},
{ key: "memo", label: "비고", width: "w-[100px]" },
] as EDataTableColumn<InboundItem>[]}
data={ts.groupData(data)}
rowKey={(row) => row.id}
loading={loading}
emptyMessage="등록된 입고 내역이 없어요"
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
showPagination
draggableColumns
columnOrderKey="c16-receiving"
/>
<div className="h-[calc(100%-44px)] overflow-auto">
<Table style={{ minWidth: "1100px" }}>
<colgroup><col style={{ width: "40px" }} /><col style={{ width: "36px" }} /><col style={{ width: "140px" }} /><col style={{ width: "90px" }} /><col style={{ width: "110px" }} /><col style={{ width: "160px" }} /><col style={{ width: "90px" }} /><col style={{ width: "90px" }} /><col style={{ width: "90px" }} /><col style={{ width: "110px" }} /></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 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));
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>
{/* 마스터 필드 헤더 (colSpan으로 디테일 컬럼 위에 맵핑) */}
{MASTER_BODY_LAYOUT.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" />
)}
</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>
))}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredGroups).length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Inbox className="h-8 w-8 opacity-30" />
<span className="text-sm"> </span>
</div>
</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;
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));
}
}}
>
<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>
{/* 입고유형 */}
<TableCell className="text-[13px]">
<Badge variant={getTypeVariant(resolveInboundType(master.inbound_type))} className="text-[11px]">
{resolveInboundType(master.inbound_type)}
</Badge>
</TableCell>
{/* 입고일 */}
<TableCell className="text-[13px] whitespace-nowrap">
{master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
{/* 참조번호 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
{/* 공급처 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.supplier_name || ""}</span>
</TableCell>
{/* 창고 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
{/* 입고상태 */}
<TableCell className="text-[13px]">
<Badge variant={getStatusVariant(master.inbound_status)} className="text-[11px]">
{master.inbound_status || "-"}
</Badge>
</TableCell>
{/* 비고 */}
<TableCell className="text-muted-foreground text-[13px]">
<span className="block truncate max-w-[110px]">{master.memo || ""}</span>
</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 />
{DETAIL_HEADER_COLS.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 />
{/* 출처 */}
<TableCell className="text-[13px]">{row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}</TableCell>
{/* 품목코드 */}
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
{/* 품목명 */}
<TableCell className="text-[13px] max-w-[160px]"><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>
</TableRow>
);
})}
</React.Fragment>
);
})
)}
</TableBody>
</Table>
</div>
</div>
{/* 입고 등록 모달 */}
@@ -1033,11 +1557,11 @@ function SourcePurchaseOrderTable({
</TableRow>
</TableHeader>
<TableBody>
{data.map((po) => {
{data.map((po, idx) => {
const isSelected = selectedKeys.includes(`po-${po.id}`);
return (
<TableRow
key={po.id}
key={`${po.source_table || 'po'}-${po.id}-${idx}`}
className={cn(
"cursor-pointer text-xs transition-colors",
isSelected && "bg-primary/5"
@@ -61,6 +61,7 @@ const GRID_COLUMNS = [
{ key: "inventory_unit", label: "재고단위" },
{ key: "user_type01", label: "대분류" },
{ key: "user_type02", label: "중분류" },
{ key: "lead_time", label: "생산 리드타임(일)", align: "right" as const },
];
const FORM_FIELDS = [
@@ -81,6 +82,7 @@ const FORM_FIELDS = [
{ key: "currency_code", label: "통화", type: "category" },
{ key: "user_type01", label: "대분류", type: "category" },
{ key: "user_type02", label: "중분류", type: "category" },
{ key: "lead_time", label: "생산 리드타임(일)", type: "text", placeholder: "숫자 입력 (예: 7)" },
{ key: "meno", label: "메모", type: "textarea" },
];
@@ -183,12 +185,28 @@ export default function ItemInfoPage() {
fetchItems();
}, [fetchItems]);
// 채번 미리보기 로드
const loadNumberingPreview = async () => {
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
const rule = ruleRes.data?.data;
if (rule?.ruleId) {
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
return previewRes.data?.data?.generatedCode || "";
}
} catch { /* 채번 규칙 없으면 무시 */ }
return "";
};
// 등록 모달 열기
const openRegisterModal = () => {
const openRegisterModal = async () => {
setFormData({});
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
// 채번 컬럼 자동 로드
const code = await loadNumberingPreview();
if (code) setFormData(prev => ({ ...prev, item_number: code }));
};
// 수정 모달 열기
@@ -200,11 +218,13 @@ export default function ItemInfoPage() {
};
// 복사 모달 열기
const openCopyModal = (item: any) => {
const openCopyModal = async (item: any) => {
const { id, item_number, created_date, updated_date, writer, ...rest } = item;
setFormData(rest);
setIsEditMode(false);
setEditId(null);
const code = await loadNumberingPreview();
if (code) setFormData(prev => ({ ...prev, item_number: code }));
setIsModalOpen(true);
};
@@ -407,13 +407,18 @@ export default function SubcontractorManagementPage() {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 50,
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
setItemSearchResults(allItems.filter((item: any) => !existingItemIds.has(item.item_number) && !existingItemIds.has(item.id)));
const OUTSOURCING_CODE = "CAT_MMDJB7R4_TO3T";
setItemSearchResults(allItems.filter((item: any) => {
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
const div = item.division || "";
return div.includes(OUTSOURCING_CODE) || div.includes("외주");
}));
} catch { /* skip */ } finally { setItemSearchLoading(false); }
};
File diff suppressed because it is too large Load Diff
@@ -1026,8 +1026,8 @@ export default function ProductionPlanManagementPage() {
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead className="w-[40px]" />
<TableHead className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead 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 style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{isColVisible("total_order_qty") && <TableHead style={ts.thStyle("total_order_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("total_ship_qty") && <TableHead style={ts.thStyle("total_ship_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("total_balance_qty") && <TableHead style={ts.thStyle("total_balance_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
@@ -1066,8 +1066,8 @@ export default function ProductionPlanManagementPage() {
<TableCell className="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 className="text-[13px] text-primary" onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell className="text-[13px] text-primary" onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</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>
{isColVisible("total_order_qty") && <TableCell style={ts.thStyle("total_order_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_order_qty)}</TableCell>}
{isColVisible("total_ship_qty") && <TableCell style={ts.thStyle("total_ship_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_ship_qty)}</TableCell>}
{isColVisible("total_balance_qty") && <TableCell style={ts.thStyle("total_balance_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_balance_qty)}</TableCell>}
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -9,10 +9,11 @@ import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Package,
ClipboardList, Pencil, Search, X, Package, ChevronDown,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2,
} from "lucide-react";
@@ -115,6 +116,7 @@ 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);
@@ -288,6 +290,22 @@ 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 "";
const found = categoryOptions[col]?.find((o) => o.code === code);
@@ -712,33 +730,115 @@ export default function PurchaseOrderPage() {
</div>
</div>
{/* 데이터 테이블 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-card">
<EDataTable
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
align: ["order_qty", "received_qty", "remain_qty", "unit_price", "amount"].includes(col.key) ? "right" : undefined,
formatNumber: ["order_qty", "received_qty", "remain_qty", "unit_price", "amount"].includes(col.key),
render: col.key === "status"
? (val: any, row: any) => row.status ? (
<span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[row.status] || "")}>
{row.status}
</span>
) : null
: undefined,
}))}
data={ts.groupData(orders)}
loading={loading}
emptyMessage="등록된 발주가 없어요"
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
onRowDoubleClick={(row) => openEditModal(row.purchase_no)}
showPagination
draggableColumns={false}
columnOrderKey="c16-purchase-order"
/>
{/* 데이터 테이블 — 아코디언 그룹핑 */}
<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 ? (
<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 detailCols = ts.visibleColumns.filter(c => !MASTER_KEYS.has(c.key));
const masterCols = ts.visibleColumns.filter(c => MASTER_KEYS.has(c.key));
const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]);
const renderDetailCell = (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 || "-";
};
return (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-8" />
<TableHead className="w-10" />
{ts.isVisible("purchase_no") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{ts.isVisible("order_date") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{ts.isVisible("supplier_name") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
<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>
))}
{ts.isVisible("status") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground text-center"></TableHead>}
{ts.isVisible("memo") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
</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);
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)}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
</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>
{ts.isVisible("purchase_no") && <TableCell className="text-sm font-semibold text-primary">{purchaseNo}</TableCell>}
{ts.isVisible("order_date") && <TableCell className="text-sm">{m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}</TableCell>}
{ts.isVisible("supplier_name") && <TableCell className="text-sm">{m.supplier_name || "-"}</TableCell>}
<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>
))}
{ts.isVisible("status") && <TableCell 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>}
{ts.isVisible("memo") && <TableCell className="text-sm text-muted-foreground max-w-[120px] truncate">{m.memo || ""}</TableCell>}
</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>
{ts.isVisible("purchase_no") && <TableCell />}
{ts.isVisible("order_date") && <TableCell />}
{ts.isVisible("supplier_name") && <TableCell />}
<TableCell />
{detailCols.map(col => (
<TableCell key={col.key} className={cn(numCols.has(col.key) && "text-right")}>
{renderDetailCell(row, col.key)}
</TableCell>
))}
{ts.isVisible("status") && <TableCell />}
{ts.isVisible("memo") && <TableCell />}
</TableRow>
))}
</React.Fragment>
);
})}
</TableBody>
</Table>
);
})()
)}
<div className="px-4 py-2 border-t text-xs text-muted-foreground">
{Object.keys(orderGroups).length} ( ) / {orders.length} ( )
</div>
</div>
{/* 발주 등록/수정 모달 */}
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -27,19 +27,15 @@ const TABLE_NAME = "item_inspection_info";
const GRID_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "inspection_standard_id", label: "검사기준ID" },
{ key: "inspection_standard_name", label: "검사기준명" },
{ key: "inspection_level", label: "검사수준" },
{ key: "sampling_method", label: "샘플링방법" },
{ key: "inspection_type", label: "검사유형" },
{ key: "is_active", label: "사용여부" },
];
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const INSPECTION_TYPES = [
{ key: "incoming_inspection", label: "입검사", matchLabels: ["입검사", "입검사", "입", "입"] },
{ key: "outgoing_inspection", label: "출검사", matchLabels: ["출검사", "출검사", "출", "출"] },
{ key: "inventory_inspection", label: "재고검사", matchLabels: ["재고검사", "재고"] },
{ key: "incoming_inspection", label: "입검사", matchLabels: ["입검사", "입검사", "입", "입"] },
{ key: "outgoing_inspection", label: "출검사", matchLabels: ["출검사", "출검사", "출", "출"] },
{ key: "process_inspection", label: "공정검사", matchLabels: ["공정검사", "공정"] },
{ key: "final_inspection", label: "최종검사", matchLabels: ["최종검사", "최종", "완제품검사"] },
] as const;
@@ -64,6 +60,7 @@ export default function ItemInspectionInfoPage() {
const [totalCount, setTotalCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const [modalOpen, setModalOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
@@ -172,15 +169,73 @@ export default function ItemInspectionInfoPage() {
useEffect(() => { fetchData(); }, [fetchData]);
// item_code별 그룹핑
const groupedData = useMemo(() => {
const map: Record<string, { item_code: string; item_name: string; is_active: string; types: string[]; rows: any[] }> = {};
for (const row of data) {
const key = row.item_code || row.id;
if (!map[key]) {
map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] };
}
map[key].rows.push(row);
if (row.inspection_type && !map[key].types.includes(row.inspection_type)) {
map[key].types.push(row.inspection_type);
}
}
return Object.values(map);
}, [data]);
// 검사기준 ID → 라벨 resolve
const resolveInspLabel = useCallback((id: string) => {
const opt = inspOptions.find(o => o.code === id);
return opt?.label || id || "-";
}, [inspOptions]);
/* ═══════════════════ CRUD ═══════════════════ */
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
const openEdit = (row: any) => {
const openEdit = async (row: any) => {
setForm({ ...row });
setEditMode(true);
// 저장된 검사항목 rows 복원
const saved = row.inspection_items || {};
setInspectionRows(saved);
setCollapsedTypes({});
// 같은 item_code의 모든 검사항목 행을 조회하여 유형별로 분류
try {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: row.item_code }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
const rowMap: Record<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
for (const r of allRows) {
const inspType = r.inspection_type || "";
// 카테고리 코드/라벨로 INSPECTION_TYPES 키 매칭
const matched = INSPECTION_TYPES.find(t =>
t.matchLabels.some(ml => inspType.includes(ml)) ||
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
);
const typeKey = matched?.key || "";
if (!typeKey) continue;
typeFlags[typeKey] = true;
if (!rowMap[typeKey]) rowMap[typeKey] = [];
rowMap[typeKey].push({
id: r.id,
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: r.inspection_method || "",
apply_process: "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
});
}
setInspectionRows(rowMap);
setForm(p => ({ ...p, ...typeFlags }));
} catch {
setInspectionRows({});
}
setModalOpen(true);
};
@@ -239,18 +294,65 @@ export default function ItemInspectionInfoPage() {
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
const saveData = { ...form, inspection_items: inspectionRows };
setSaving(true);
try {
// 기존 행 삭제 (수정 모드)
if (editMode) {
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
originalData: { id: form.id }, updatedData: saveData,
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: form.item_code }] },
autoFilter: true,
});
toast.success("품목검사정보를 수정했어요");
} else {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...saveData });
toast.success("품목검사정보를 등록했어요");
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: existing.map((r: any) => ({ id: r.id })),
});
}
}
// 검사유형별 항목을 개별 행으로 INSERT
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
for (const t of enabledTypes) {
const typeLabel = t.label;
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
// 유형만 체크하고 항목 없는 경우에도 1행 생성
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
});
} else {
for (const r of typeRows) {
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
inspection_standard_id: r.inspection_standard_id || "",
inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "",
pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false",
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
});
}
}
}
for (const row of rows) {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row);
}
toast.success(editMode ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
setModalOpen(false);
fetchData();
} catch { toast.error("저장에 실패했어요"); }
@@ -291,8 +393,10 @@ export default function ItemInspectionInfoPage() {
<Button size="sm" onClick={openCreate}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="outline" onClick={() => {
const sel = data.find(r => checkedIds.includes(r.id));
if (sel) openEdit(sel);
else toast.error("수정할 항목을 선택해주세요");
if (sel) {
const group = groupedData.find(g => g.item_code === sel.item_code);
openEdit(group?.rows[0] || sel);
} else toast.error("수정할 항목을 선택해주세요");
}}><Pencil className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="destructive" onClick={handleDelete}><Trash2 className="w-4 h-4 mr-1" /></Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
@@ -302,30 +406,77 @@ export default function ItemInspectionInfoPage() {
}
/>
</div>
<div className="p-3">
<EDataTable
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
render: col.key === "is_active"
? (val: any, row: any) => (
<Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">
{row.is_active ? "사용" : "미사용"}
</Badge>
)
: undefined,
}))}
data={ts.groupData(data)}
loading={loading}
emptyMessage="등록된 품목검사정보가 없어요"
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
onRowDoubleClick={(row) => openEdit(row)}
showPagination
draggableColumns={false}
columnOrderKey="c16-item-inspection"
/>
<div className="p-3 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10" />
<TableHead className="w-10"><Checkbox checked={groupedData.length > 0 && checkedIds.length === data.length} onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])} /></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupedData.map((group) => {
const isExpanded = expandedItems.has(group.item_code);
const groupIds = group.rows.map(r => r.id);
const allChecked = groupIds.every(id => checkedIds.includes(id));
return (
<React.Fragment key={group.item_code}>
<TableRow
className={cn("cursor-pointer border-l-2 border-l-transparent", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedItems(prev => { const next = new Set(prev); if (next.has(group.item_code)) next.delete(group.item_code); else next.add(group.item_code); return next; })}
onDoubleClick={() => openEdit(group.rows[0])}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !groupIds.includes(id)) : [...new Set([...prev, ...groupIds])]); }}>
<Checkbox checked={allChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="text-sm font-medium text-primary">{group.item_code}</TableCell>
<TableCell className="text-sm">{group.item_name}</TableCell>
<TableCell>
<div className="flex gap-1 flex-wrap">
{group.types.map(t => <Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>)}
</div>
</TableCell>
<TableCell className="text-sm text-center">{group.rows.filter(r => r.inspection_standard_id).length}</TableCell>
<TableCell>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
</TableRow>
{isExpanded && group.rows.filter(r => r.inspection_standard_id).map((row, i) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell />
<TableCell className="pl-6 text-muted-foreground">{row.inspection_type}</TableCell>
<TableCell>{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell>{row.inspection_item_name || "-"}</TableCell>
<TableCell>{row.inspection_method || "-"}</TableCell>
<TableCell>{row.pass_criteria || "-"}</TableCell>
</TableRow>
))}
</React.Fragment>
);
})}
</TableBody>
</Table>
)}
<div className="mt-2 text-xs text-muted-foreground"> {groupedData.length} ( )</div>
</div>
</div>