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