refactor: Streamline logistics pages by removing unused variables and enhancing header filters
- Removed unnecessary variables and commented-out code related to master-detail grouping in the outbound and receiving pages. - Simplified the header filter and sorting logic to improve performance and readability. - Updated the column mapping and filtering mechanisms to ensure a more efficient data handling process. - These changes aim to enhance the overall user experience and maintainability of the logistics management interface across multiple company implementations.
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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 "";
|
||||
@@ -811,125 +796,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 +881,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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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 "";
|
||||
@@ -811,125 +796,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 +881,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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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 "";
|
||||
@@ -811,125 +796,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 +881,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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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 "";
|
||||
@@ -811,125 +796,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 +881,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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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 "";
|
||||
@@ -811,125 +796,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 +881,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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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 "";
|
||||
@@ -811,125 +796,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 +881,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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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 "";
|
||||
@@ -811,125 +796,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 +881,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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user