refactor: Update packaging page to enhance data handling and user experience

- Changed column key for maximum weight from `max_weight` to `max_load_kg` for clarity.
- Added a new status label `UNREGISTERED` to improve item status tracking.
- Enhanced data loading logic to merge item information with packaging units, ensuring accurate representation of available items.
- Removed the registration button, allowing users to click on unregistered items to open the registration modal directly.
- These changes aim to streamline the packaging management process and improve usability across multiple company implementations.
This commit is contained in:
kjs
2026-04-13 18:31:35 +09:00
parent 2c75677394
commit f54d763373
6 changed files with 822 additions and 588 deletions
@@ -37,7 +37,7 @@ const GRID_COLUMNS = [
{ key: "pkg_name", label: "포장명" },
{ key: "pkg_type", label: "유형" },
{ key: "size", label: "크기(mm)" },
{ key: "max_weight", label: "최대중량" },
{ key: "max_load_kg", label: "최대중량" },
{ key: "status", label: "상태" },
];
@@ -51,7 +51,7 @@ const LOADING_TYPE_LABEL: Record<string, string> = {
ALU_PALLET: "알루미늄파렛트", CONTAINER: "컨테이너", STEEL_BOX: "철재함",
CAGE: "케이지", ETC: "기타",
};
const STATUS_LABEL: Record<string, string> = { ACTIVE: "사용", INACTIVE: "미사용" };
const STATUS_LABEL: Record<string, string> = { ACTIVE: "사용", INACTIVE: "미사용", UNREGISTERED: "미등록" };
const getStatusColor = (s: string) => s === "ACTIVE" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground";
const fmtSize = (w: any, l: any, h: any) => {
@@ -118,20 +118,80 @@ export default function PackagingPage() {
const [saving, setSaving] = useState(false);
// --- 데이터 로드 ---
// --- 데이터 로드 (item_info 기반 + pkg_unit/loading_unit LEFT JOIN 방식) ---
const fetchPkgUnits = useCallback(async () => {
setPkgLoading(true);
try {
const res = await getPkgUnits();
if (res.success) setPkgUnits(res.data);
const [itemRes, pkgRes] = await Promise.all([
getItemsByDivision("포장재"),
getPkgUnits(),
]);
const items = itemRes.success ? itemRes.data : [];
const existing = pkgRes.success ? pkgRes.data : [];
// item_info 기준으로 병합: item_number 매칭되는 pkg_unit이 있으면 그 데이터 사용, 없으면 미등록 placeholder
const merged: PkgUnit[] = items.map((item) => {
const match = existing.find((p) => p.item_number === item.item_number || p.pkg_code === item.item_number);
if (match) return { ...match, pkg_code: match.pkg_code || item.item_number, pkg_name: match.pkg_name || item.item_name };
const dims = parseSpecDimensions(item.size);
return {
id: "",
company_code: "",
pkg_code: item.item_number,
pkg_name: item.item_name,
pkg_type: "",
status: "INACTIVE",
width_mm: dims.w || null,
length_mm: dims.l || null,
height_mm: dims.h || null,
self_weight_kg: null,
max_load_kg: null,
volume_l: null,
remarks: null,
created_date: "",
writer: null,
item_number: item.item_number,
} as PkgUnit;
});
// 품목정보에 없지만 pkg_unit에만 있는 항목 (고아) 추가
const orphans = existing.filter((p) => !items.some((i) => i.item_number === p.item_number || i.item_number === p.pkg_code));
setPkgUnits([...merged, ...orphans]);
} catch { /* ignore */ } finally { setPkgLoading(false); }
}, []);
const fetchLoadingUnits = useCallback(async () => {
setLoadingLoading(true);
try {
const res = await getLoadingUnits();
if (res.success) setLoadingUnits(res.data);
const [itemRes, luRes] = await Promise.all([
getItemsByDivision("적재함"),
getLoadingUnits(),
]);
const items = itemRes.success ? itemRes.data : [];
const existing = luRes.success ? luRes.data : [];
const merged: LoadingUnit[] = items.map((item) => {
const match = existing.find((l) => l.item_number === item.item_number || l.loading_code === item.item_number);
if (match) return { ...match, loading_code: match.loading_code || item.item_number, loading_name: match.loading_name || item.item_name };
const dims = parseSpecDimensions(item.size);
return {
id: "",
company_code: "",
loading_code: item.item_number,
loading_name: item.item_name,
loading_type: "",
status: "INACTIVE",
width_mm: dims.w || null,
length_mm: dims.l || null,
height_mm: dims.h || null,
self_weight_kg: null,
max_load_kg: null,
max_stack: null,
remarks: null,
created_date: "",
writer: null,
item_number: item.item_number,
} as LoadingUnit;
});
const orphans = existing.filter((l) => !items.some((i) => i.item_number === l.item_number || i.item_number === l.loading_code));
setLoadingUnits([...merged, ...orphans]);
} catch { /* ignore */ } finally { setLoadingLoading(false); }
}, []);
@@ -177,12 +237,21 @@ export default function PackagingPage() {
});
// --- 포장재 등록/수정 모달 ---
const openPkgModal = async (mode: "create" | "edit") => {
setPkgModalMode(mode);
if (mode === "edit" && selectedPkg) {
setPkgForm({ ...selectedPkg });
// row: 리스트에서 클릭한 행. id가 빈 문자열이면 미등록 상태(item_info만 있음) → 등록 모달
const openPkgModal = async (mode: "create" | "edit", row?: PkgUnit) => {
const target = row || selectedPkg;
const isRegistered = !!(target && target.id);
const actualMode: "create" | "edit" = isRegistered ? "edit" : "create";
setPkgModalMode(actualMode);
if (target) {
// 기존 데이터 or 품목정보 기반 초기값 세팅
setPkgForm({
...target,
status: target.status || "ACTIVE",
item_number: target.item_number || target.pkg_code || "",
});
} else {
setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "" });
setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "", item_number: "" });
}
setPkgItemPopoverOpen(false);
try {
@@ -232,12 +301,19 @@ export default function PackagingPage() {
};
// --- 적재함 등록/수정 모달 ---
const openLoadModal = async (mode: "create" | "edit") => {
setLoadModalMode(mode);
if (mode === "edit" && selectedLoading) {
setLoadForm({ ...selectedLoading });
const openLoadModal = async (mode: "create" | "edit", row?: LoadingUnit) => {
const target = row || selectedLoading;
const isRegistered = !!(target && target.id);
const actualMode: "create" | "edit" = isRegistered ? "edit" : "create";
setLoadModalMode(actualMode);
if (target) {
setLoadForm({
...target,
status: target.status || "ACTIVE",
item_number: target.item_number || target.loading_code || "",
});
} else {
setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "" });
setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "", item_number: "" });
}
setLoadItemPopoverOpen(false);
try {
@@ -442,12 +518,8 @@ export default function PackagingPage() {
<div className="h-4 w-px bg-border" />
</>
)}
<Button
size="sm"
onClick={() => activeTab === "packing" ? openPkgModal("create") : openLoadModal("create")}
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
{/* 메인 리스트가 품목정보 기반으로 자동 구성되므로 등록 버튼 제거.
미등록 상태의 품목을 직접 클릭하면 등록 모달이 열림. */}
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
@@ -466,22 +538,30 @@ export default function PackagingPage() {
size: { width: "w-[100px]", render: (_v: any, row: any) => fmtSize(row.width_mm, row.length_mm, row.height_mm) },
max_weight: { width: "w-[80px]", align: "right", render: (v: any) => Number(v || 0) > 0 ? `${v}kg` : "-" },
max_load_kg: { width: "w-[80px]", align: "right", render: (v: any) => Number(v || 0) > 0 ? `${v}kg` : "-" },
status: { width: "w-[60px]", align: "center", render: (v: any) => (
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(v))}>
{STATUS_LABEL[v] || v}
</span>
)},
status: { width: "w-[60px]", align: "center", render: (v: any, row: any) => {
const isUnreg = !row.id;
const cls = isUnreg ? "bg-warning/10 text-warning" : getStatusColor(v);
const label = isUnreg ? "미등록" : (STATUS_LABEL[v] || v);
return <span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", cls)}>{label}</span>;
}},
};
return { key: col.key, label: col.label, ...renderMap[col.key] };
})}
data={ts.groupData(filteredPkgUnits)}
rowKey={(row) => String(row.id)}
rowKey={(row) => row.id ? `reg-${row.id}` : `new-${row.pkg_code || row.item_number}`}
loading={pkgLoading}
emptyMessage="등록된 포장재가 없어요"
selectedId={selectedPkg ? String(selectedPkg.id) : null}
emptyMessage="포장재 품목이 없어요. 품목정보에서 관리품목을 '포장재'로 등록해주세요."
selectedId={selectedPkg ? (selectedPkg.id ? `reg-${selectedPkg.id}` : `new-${selectedPkg.pkg_code || selectedPkg.item_number}`) : null}
onSelect={(id) => {
const pkg = filteredPkgUnits.find((p) => String(p.id) === id);
if (pkg) selectPkg(pkg);
const pkg = filteredPkgUnits.find((p) => (p.id ? `reg-${p.id}` : `new-${p.pkg_code || p.item_number}`) === id);
if (pkg) {
if (!pkg.id) {
// 미등록 → 바로 등록 모달
openPkgModal("create", pkg);
} else {
selectPkg(pkg);
}
}
}}
showPagination={false}
draggableColumns
@@ -592,14 +672,24 @@ export default function PackagingPage() {
</div>
</TableCell>
</TableRow>
) : filteredLoadingUnits.map((l) => (
) : filteredLoadingUnits.map((l) => {
const isUnreg = !l.id;
const rowKey = l.id ? `reg-${l.id}` : `new-${l.loading_code || l.item_number}`;
const selKey = selectedLoading ? (selectedLoading.id ? `reg-${selectedLoading.id}` : `new-${selectedLoading.loading_code || selectedLoading.item_number}`) : null;
return (
<TableRow
key={l.id}
key={rowKey}
className={cn(
"cursor-pointer text-xs transition-colors",
selectedLoading?.id === l.id ? "bg-primary/5" : "hover:bg-muted/50"
selKey === rowKey ? "bg-primary/5" : "hover:bg-muted/50"
)}
onClick={() => selectLoading(l)}
onClick={() => {
if (isUnreg) {
openLoadModal("create", l);
} else {
selectLoading(l);
}
}}
>
<TableCell className="p-2 font-medium">{l.loading_code}</TableCell>
<TableCell className="p-2">{l.loading_name}</TableCell>
@@ -607,12 +697,13 @@ export default function PackagingPage() {
<TableCell className="p-2 text-[10px] tabular-nums">{fmtSize(l.width_mm, l.length_mm, l.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(l.max_load_kg || 0) > 0 ? `${l.max_load_kg}kg` : "-"}</TableCell>
<TableCell className="p-2 text-center">
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(l.status))}>
{STATUS_LABEL[l.status] || l.status}
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", isUnreg ? "bg-warning/10 text-warning" : getStatusColor(l.status))}>
{isUnreg ? "미등록" : (STATUS_LABEL[l.status] || l.status)}
</span>
</TableCell>
</TableRow>
))}
);
})}
</TableBody>
</Table>
</div>
@@ -699,35 +790,9 @@ export default function PackagingPage() {
{pkgModalMode === "create" && (
<div className="rounded-lg border bg-muted/50 p-4">
<Label className="mb-2 block text-xs font-semibold"> (구분: 포장재)</Label>
<Popover open={pkgItemPopoverOpen} onOpenChange={setPkgItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-9 w-full justify-between text-sm font-normal">
{pkgForm.pkg_code ? `${pkgForm.pkg_name} (${pkgForm.pkg_code})` : "품목정보에서 포장재를 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = pkgItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{pkgItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onPkgItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", pkgForm.pkg_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<div className="h-9 w-full rounded-md border border-input bg-muted px-3 py-2 text-sm text-muted-foreground flex items-center">
{pkgForm.pkg_code ? `${pkgForm.pkg_name} (${pkgForm.pkg_code})` : "-"}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
@@ -781,35 +846,9 @@ export default function PackagingPage() {
{loadModalMode === "create" && (
<div className="rounded-lg border bg-muted/50 p-4">
<Label className="mb-2 block text-xs font-semibold"> (구분: 적재함)</Label>
<Popover open={loadItemPopoverOpen} onOpenChange={setLoadItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-9 w-full justify-between text-sm font-normal">
{loadForm.loading_code ? `${loadForm.loading_name} (${loadForm.loading_code})` : "품목정보에서 적재함을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = loadItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{loadItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onLoadItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", loadForm.loading_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<div className="h-9 w-full rounded-md border border-input bg-muted px-3 py-2 text-sm text-muted-foreground flex items-center">
{loadForm.loading_code ? `${loadForm.loading_name} (${loadForm.loading_code})` : "-"}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
@@ -37,7 +37,7 @@ const GRID_COLUMNS = [
{ key: "pkg_name", label: "포장명" },
{ key: "pkg_type", label: "유형" },
{ key: "size", label: "크기(mm)" },
{ key: "max_weight", label: "최대중량" },
{ key: "max_load_kg", label: "최대중량" },
{ key: "status", label: "상태" },
];
@@ -51,7 +51,7 @@ const LOADING_TYPE_LABEL: Record<string, string> = {
ALU_PALLET: "알루미늄파렛트", CONTAINER: "컨테이너", STEEL_BOX: "철재함",
CAGE: "케이지", ETC: "기타",
};
const STATUS_LABEL: Record<string, string> = { ACTIVE: "사용", INACTIVE: "미사용" };
const STATUS_LABEL: Record<string, string> = { ACTIVE: "사용", INACTIVE: "미사용", UNREGISTERED: "미등록" };
const getStatusColor = (s: string) => s === "ACTIVE" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground";
const fmtSize = (w: any, l: any, h: any) => {
@@ -118,20 +118,80 @@ export default function PackagingPage() {
const [saving, setSaving] = useState(false);
// --- 데이터 로드 ---
// --- 데이터 로드 (item_info 기반 + pkg_unit/loading_unit LEFT JOIN 방식) ---
const fetchPkgUnits = useCallback(async () => {
setPkgLoading(true);
try {
const res = await getPkgUnits();
if (res.success) setPkgUnits(res.data);
const [itemRes, pkgRes] = await Promise.all([
getItemsByDivision("포장재"),
getPkgUnits(),
]);
const items = itemRes.success ? itemRes.data : [];
const existing = pkgRes.success ? pkgRes.data : [];
// item_info 기준으로 병합: item_number 매칭되는 pkg_unit이 있으면 그 데이터 사용, 없으면 미등록 placeholder
const merged: PkgUnit[] = items.map((item) => {
const match = existing.find((p) => p.item_number === item.item_number || p.pkg_code === item.item_number);
if (match) return { ...match, pkg_code: match.pkg_code || item.item_number, pkg_name: match.pkg_name || item.item_name };
const dims = parseSpecDimensions(item.size);
return {
id: "",
company_code: "",
pkg_code: item.item_number,
pkg_name: item.item_name,
pkg_type: "",
status: "INACTIVE",
width_mm: dims.w || null,
length_mm: dims.l || null,
height_mm: dims.h || null,
self_weight_kg: null,
max_load_kg: null,
volume_l: null,
remarks: null,
created_date: "",
writer: null,
item_number: item.item_number,
} as PkgUnit;
});
// 품목정보에 없지만 pkg_unit에만 있는 항목 (고아) 추가
const orphans = existing.filter((p) => !items.some((i) => i.item_number === p.item_number || i.item_number === p.pkg_code));
setPkgUnits([...merged, ...orphans]);
} catch { /* ignore */ } finally { setPkgLoading(false); }
}, []);
const fetchLoadingUnits = useCallback(async () => {
setLoadingLoading(true);
try {
const res = await getLoadingUnits();
if (res.success) setLoadingUnits(res.data);
const [itemRes, luRes] = await Promise.all([
getItemsByDivision("적재함"),
getLoadingUnits(),
]);
const items = itemRes.success ? itemRes.data : [];
const existing = luRes.success ? luRes.data : [];
const merged: LoadingUnit[] = items.map((item) => {
const match = existing.find((l) => l.item_number === item.item_number || l.loading_code === item.item_number);
if (match) return { ...match, loading_code: match.loading_code || item.item_number, loading_name: match.loading_name || item.item_name };
const dims = parseSpecDimensions(item.size);
return {
id: "",
company_code: "",
loading_code: item.item_number,
loading_name: item.item_name,
loading_type: "",
status: "INACTIVE",
width_mm: dims.w || null,
length_mm: dims.l || null,
height_mm: dims.h || null,
self_weight_kg: null,
max_load_kg: null,
max_stack: null,
remarks: null,
created_date: "",
writer: null,
item_number: item.item_number,
} as LoadingUnit;
});
const orphans = existing.filter((l) => !items.some((i) => i.item_number === l.item_number || i.item_number === l.loading_code));
setLoadingUnits([...merged, ...orphans]);
} catch { /* ignore */ } finally { setLoadingLoading(false); }
}, []);
@@ -177,12 +237,21 @@ export default function PackagingPage() {
});
// --- 포장재 등록/수정 모달 ---
const openPkgModal = async (mode: "create" | "edit") => {
setPkgModalMode(mode);
if (mode === "edit" && selectedPkg) {
setPkgForm({ ...selectedPkg });
// row: 리스트에서 클릭한 행. id가 빈 문자열이면 미등록 상태(item_info만 있음) → 등록 모달
const openPkgModal = async (mode: "create" | "edit", row?: PkgUnit) => {
const target = row || selectedPkg;
const isRegistered = !!(target && target.id);
const actualMode: "create" | "edit" = isRegistered ? "edit" : "create";
setPkgModalMode(actualMode);
if (target) {
// 기존 데이터 or 품목정보 기반 초기값 세팅
setPkgForm({
...target,
status: target.status || "ACTIVE",
item_number: target.item_number || target.pkg_code || "",
});
} else {
setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "" });
setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "", item_number: "" });
}
setPkgItemPopoverOpen(false);
try {
@@ -232,12 +301,19 @@ export default function PackagingPage() {
};
// --- 적재함 등록/수정 모달 ---
const openLoadModal = async (mode: "create" | "edit") => {
setLoadModalMode(mode);
if (mode === "edit" && selectedLoading) {
setLoadForm({ ...selectedLoading });
const openLoadModal = async (mode: "create" | "edit", row?: LoadingUnit) => {
const target = row || selectedLoading;
const isRegistered = !!(target && target.id);
const actualMode: "create" | "edit" = isRegistered ? "edit" : "create";
setLoadModalMode(actualMode);
if (target) {
setLoadForm({
...target,
status: target.status || "ACTIVE",
item_number: target.item_number || target.loading_code || "",
});
} else {
setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "" });
setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "", item_number: "" });
}
setLoadItemPopoverOpen(false);
try {
@@ -442,12 +518,8 @@ export default function PackagingPage() {
<div className="h-4 w-px bg-border" />
</>
)}
<Button
size="sm"
onClick={() => activeTab === "packing" ? openPkgModal("create") : openLoadModal("create")}
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
{/* 메인 리스트가 품목정보 기반으로 자동 구성되므로 등록 버튼 제거.
미등록 상태의 품목을 직접 클릭하면 등록 모달이 열림. */}
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
@@ -466,22 +538,30 @@ export default function PackagingPage() {
size: { width: "w-[100px]", render: (_v: any, row: any) => fmtSize(row.width_mm, row.length_mm, row.height_mm) },
max_weight: { width: "w-[80px]", align: "right", render: (v: any) => Number(v || 0) > 0 ? `${v}kg` : "-" },
max_load_kg: { width: "w-[80px]", align: "right", render: (v: any) => Number(v || 0) > 0 ? `${v}kg` : "-" },
status: { width: "w-[60px]", align: "center", render: (v: any) => (
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(v))}>
{STATUS_LABEL[v] || v}
</span>
)},
status: { width: "w-[60px]", align: "center", render: (v: any, row: any) => {
const isUnreg = !row.id;
const cls = isUnreg ? "bg-warning/10 text-warning" : getStatusColor(v);
const label = isUnreg ? "미등록" : (STATUS_LABEL[v] || v);
return <span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", cls)}>{label}</span>;
}},
};
return { key: col.key, label: col.label, ...renderMap[col.key] };
})}
data={ts.groupData(filteredPkgUnits)}
rowKey={(row) => String(row.id)}
rowKey={(row) => row.id ? `reg-${row.id}` : `new-${row.pkg_code || row.item_number}`}
loading={pkgLoading}
emptyMessage="등록된 포장재가 없어요"
selectedId={selectedPkg ? String(selectedPkg.id) : null}
emptyMessage="포장재 품목이 없어요. 품목정보에서 관리품목을 '포장재'로 등록해주세요."
selectedId={selectedPkg ? (selectedPkg.id ? `reg-${selectedPkg.id}` : `new-${selectedPkg.pkg_code || selectedPkg.item_number}`) : null}
onSelect={(id) => {
const pkg = filteredPkgUnits.find((p) => String(p.id) === id);
if (pkg) selectPkg(pkg);
const pkg = filteredPkgUnits.find((p) => (p.id ? `reg-${p.id}` : `new-${p.pkg_code || p.item_number}`) === id);
if (pkg) {
if (!pkg.id) {
// 미등록 → 바로 등록 모달
openPkgModal("create", pkg);
} else {
selectPkg(pkg);
}
}
}}
showPagination={false}
draggableColumns
@@ -592,14 +672,24 @@ export default function PackagingPage() {
</div>
</TableCell>
</TableRow>
) : filteredLoadingUnits.map((l) => (
) : filteredLoadingUnits.map((l) => {
const isUnreg = !l.id;
const rowKey = l.id ? `reg-${l.id}` : `new-${l.loading_code || l.item_number}`;
const selKey = selectedLoading ? (selectedLoading.id ? `reg-${selectedLoading.id}` : `new-${selectedLoading.loading_code || selectedLoading.item_number}`) : null;
return (
<TableRow
key={l.id}
key={rowKey}
className={cn(
"cursor-pointer text-xs transition-colors",
selectedLoading?.id === l.id ? "bg-primary/5" : "hover:bg-muted/50"
selKey === rowKey ? "bg-primary/5" : "hover:bg-muted/50"
)}
onClick={() => selectLoading(l)}
onClick={() => {
if (isUnreg) {
openLoadModal("create", l);
} else {
selectLoading(l);
}
}}
>
<TableCell className="p-2 font-medium">{l.loading_code}</TableCell>
<TableCell className="p-2">{l.loading_name}</TableCell>
@@ -607,12 +697,13 @@ export default function PackagingPage() {
<TableCell className="p-2 text-[10px] tabular-nums">{fmtSize(l.width_mm, l.length_mm, l.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(l.max_load_kg || 0) > 0 ? `${l.max_load_kg}kg` : "-"}</TableCell>
<TableCell className="p-2 text-center">
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(l.status))}>
{STATUS_LABEL[l.status] || l.status}
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", isUnreg ? "bg-warning/10 text-warning" : getStatusColor(l.status))}>
{isUnreg ? "미등록" : (STATUS_LABEL[l.status] || l.status)}
</span>
</TableCell>
</TableRow>
))}
);
})}
</TableBody>
</Table>
</div>
@@ -699,35 +790,9 @@ export default function PackagingPage() {
{pkgModalMode === "create" && (
<div className="rounded-lg border bg-muted/50 p-4">
<Label className="mb-2 block text-xs font-semibold"> (구분: 포장재)</Label>
<Popover open={pkgItemPopoverOpen} onOpenChange={setPkgItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-9 w-full justify-between text-sm font-normal">
{pkgForm.pkg_code ? `${pkgForm.pkg_name} (${pkgForm.pkg_code})` : "품목정보에서 포장재를 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = pkgItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{pkgItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onPkgItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", pkgForm.pkg_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<div className="h-9 w-full rounded-md border border-input bg-muted px-3 py-2 text-sm text-muted-foreground flex items-center">
{pkgForm.pkg_code ? `${pkgForm.pkg_name} (${pkgForm.pkg_code})` : "-"}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
@@ -781,35 +846,9 @@ export default function PackagingPage() {
{loadModalMode === "create" && (
<div className="rounded-lg border bg-muted/50 p-4">
<Label className="mb-2 block text-xs font-semibold"> (구분: 적재함)</Label>
<Popover open={loadItemPopoverOpen} onOpenChange={setLoadItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-9 w-full justify-between text-sm font-normal">
{loadForm.loading_code ? `${loadForm.loading_name} (${loadForm.loading_code})` : "품목정보에서 적재함을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = loadItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{loadItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onLoadItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", loadForm.loading_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<div className="h-9 w-full rounded-md border border-input bg-muted px-3 py-2 text-sm text-muted-foreground flex items-center">
{loadForm.loading_code ? `${loadForm.loading_name} (${loadForm.loading_code})` : "-"}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
@@ -37,7 +37,7 @@ const GRID_COLUMNS = [
{ key: "pkg_name", label: "포장명" },
{ key: "pkg_type", label: "유형" },
{ key: "size", label: "크기(mm)" },
{ key: "max_weight", label: "최대중량" },
{ key: "max_load_kg", label: "최대중량" },
{ key: "status", label: "상태" },
];
@@ -51,7 +51,7 @@ const LOADING_TYPE_LABEL: Record<string, string> = {
ALU_PALLET: "알루미늄파렛트", CONTAINER: "컨테이너", STEEL_BOX: "철재함",
CAGE: "케이지", ETC: "기타",
};
const STATUS_LABEL: Record<string, string> = { ACTIVE: "사용", INACTIVE: "미사용" };
const STATUS_LABEL: Record<string, string> = { ACTIVE: "사용", INACTIVE: "미사용", UNREGISTERED: "미등록" };
const getStatusColor = (s: string) => s === "ACTIVE" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground";
const fmtSize = (w: any, l: any, h: any) => {
@@ -118,20 +118,80 @@ export default function PackagingPage() {
const [saving, setSaving] = useState(false);
// --- 데이터 로드 ---
// --- 데이터 로드 (item_info 기반 + pkg_unit/loading_unit LEFT JOIN 방식) ---
const fetchPkgUnits = useCallback(async () => {
setPkgLoading(true);
try {
const res = await getPkgUnits();
if (res.success) setPkgUnits(res.data);
const [itemRes, pkgRes] = await Promise.all([
getItemsByDivision("포장재"),
getPkgUnits(),
]);
const items = itemRes.success ? itemRes.data : [];
const existing = pkgRes.success ? pkgRes.data : [];
// item_info 기준으로 병합: item_number 매칭되는 pkg_unit이 있으면 그 데이터 사용, 없으면 미등록 placeholder
const merged: PkgUnit[] = items.map((item) => {
const match = existing.find((p) => p.item_number === item.item_number || p.pkg_code === item.item_number);
if (match) return { ...match, pkg_code: match.pkg_code || item.item_number, pkg_name: match.pkg_name || item.item_name };
const dims = parseSpecDimensions(item.size);
return {
id: "",
company_code: "",
pkg_code: item.item_number,
pkg_name: item.item_name,
pkg_type: "",
status: "INACTIVE",
width_mm: dims.w || null,
length_mm: dims.l || null,
height_mm: dims.h || null,
self_weight_kg: null,
max_load_kg: null,
volume_l: null,
remarks: null,
created_date: "",
writer: null,
item_number: item.item_number,
} as PkgUnit;
});
// 품목정보에 없지만 pkg_unit에만 있는 항목 (고아) 추가
const orphans = existing.filter((p) => !items.some((i) => i.item_number === p.item_number || i.item_number === p.pkg_code));
setPkgUnits([...merged, ...orphans]);
} catch { /* ignore */ } finally { setPkgLoading(false); }
}, []);
const fetchLoadingUnits = useCallback(async () => {
setLoadingLoading(true);
try {
const res = await getLoadingUnits();
if (res.success) setLoadingUnits(res.data);
const [itemRes, luRes] = await Promise.all([
getItemsByDivision("적재함"),
getLoadingUnits(),
]);
const items = itemRes.success ? itemRes.data : [];
const existing = luRes.success ? luRes.data : [];
const merged: LoadingUnit[] = items.map((item) => {
const match = existing.find((l) => l.item_number === item.item_number || l.loading_code === item.item_number);
if (match) return { ...match, loading_code: match.loading_code || item.item_number, loading_name: match.loading_name || item.item_name };
const dims = parseSpecDimensions(item.size);
return {
id: "",
company_code: "",
loading_code: item.item_number,
loading_name: item.item_name,
loading_type: "",
status: "INACTIVE",
width_mm: dims.w || null,
length_mm: dims.l || null,
height_mm: dims.h || null,
self_weight_kg: null,
max_load_kg: null,
max_stack: null,
remarks: null,
created_date: "",
writer: null,
item_number: item.item_number,
} as LoadingUnit;
});
const orphans = existing.filter((l) => !items.some((i) => i.item_number === l.item_number || i.item_number === l.loading_code));
setLoadingUnits([...merged, ...orphans]);
} catch { /* ignore */ } finally { setLoadingLoading(false); }
}, []);
@@ -177,12 +237,21 @@ export default function PackagingPage() {
});
// --- 포장재 등록/수정 모달 ---
const openPkgModal = async (mode: "create" | "edit") => {
setPkgModalMode(mode);
if (mode === "edit" && selectedPkg) {
setPkgForm({ ...selectedPkg });
// row: 리스트에서 클릭한 행. id가 빈 문자열이면 미등록 상태(item_info만 있음) → 등록 모달
const openPkgModal = async (mode: "create" | "edit", row?: PkgUnit) => {
const target = row || selectedPkg;
const isRegistered = !!(target && target.id);
const actualMode: "create" | "edit" = isRegistered ? "edit" : "create";
setPkgModalMode(actualMode);
if (target) {
// 기존 데이터 or 품목정보 기반 초기값 세팅
setPkgForm({
...target,
status: target.status || "ACTIVE",
item_number: target.item_number || target.pkg_code || "",
});
} else {
setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "" });
setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "", item_number: "" });
}
setPkgItemPopoverOpen(false);
try {
@@ -232,12 +301,19 @@ export default function PackagingPage() {
};
// --- 적재함 등록/수정 모달 ---
const openLoadModal = async (mode: "create" | "edit") => {
setLoadModalMode(mode);
if (mode === "edit" && selectedLoading) {
setLoadForm({ ...selectedLoading });
const openLoadModal = async (mode: "create" | "edit", row?: LoadingUnit) => {
const target = row || selectedLoading;
const isRegistered = !!(target && target.id);
const actualMode: "create" | "edit" = isRegistered ? "edit" : "create";
setLoadModalMode(actualMode);
if (target) {
setLoadForm({
...target,
status: target.status || "ACTIVE",
item_number: target.item_number || target.loading_code || "",
});
} else {
setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "" });
setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "", item_number: "" });
}
setLoadItemPopoverOpen(false);
try {
@@ -442,12 +518,8 @@ export default function PackagingPage() {
<div className="h-4 w-px bg-border" />
</>
)}
<Button
size="sm"
onClick={() => activeTab === "packing" ? openPkgModal("create") : openLoadModal("create")}
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
{/* 메인 리스트가 품목정보 기반으로 자동 구성되므로 등록 버튼 제거.
미등록 상태의 품목을 직접 클릭하면 등록 모달이 열림. */}
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
@@ -466,22 +538,30 @@ export default function PackagingPage() {
size: { width: "w-[100px]", render: (_v: any, row: any) => fmtSize(row.width_mm, row.length_mm, row.height_mm) },
max_weight: { width: "w-[80px]", align: "right", render: (v: any) => Number(v || 0) > 0 ? `${v}kg` : "-" },
max_load_kg: { width: "w-[80px]", align: "right", render: (v: any) => Number(v || 0) > 0 ? `${v}kg` : "-" },
status: { width: "w-[60px]", align: "center", render: (v: any) => (
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(v))}>
{STATUS_LABEL[v] || v}
</span>
)},
status: { width: "w-[60px]", align: "center", render: (v: any, row: any) => {
const isUnreg = !row.id;
const cls = isUnreg ? "bg-warning/10 text-warning" : getStatusColor(v);
const label = isUnreg ? "미등록" : (STATUS_LABEL[v] || v);
return <span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", cls)}>{label}</span>;
}},
};
return { key: col.key, label: col.label, ...renderMap[col.key] };
})}
data={ts.groupData(filteredPkgUnits)}
rowKey={(row) => String(row.id)}
rowKey={(row) => row.id ? `reg-${row.id}` : `new-${row.pkg_code || row.item_number}`}
loading={pkgLoading}
emptyMessage="등록된 포장재가 없어요"
selectedId={selectedPkg ? String(selectedPkg.id) : null}
emptyMessage="포장재 품목이 없어요. 품목정보에서 관리품목을 '포장재'로 등록해주세요."
selectedId={selectedPkg ? (selectedPkg.id ? `reg-${selectedPkg.id}` : `new-${selectedPkg.pkg_code || selectedPkg.item_number}`) : null}
onSelect={(id) => {
const pkg = filteredPkgUnits.find((p) => String(p.id) === id);
if (pkg) selectPkg(pkg);
const pkg = filteredPkgUnits.find((p) => (p.id ? `reg-${p.id}` : `new-${p.pkg_code || p.item_number}`) === id);
if (pkg) {
if (!pkg.id) {
// 미등록 → 바로 등록 모달
openPkgModal("create", pkg);
} else {
selectPkg(pkg);
}
}
}}
showPagination={false}
draggableColumns
@@ -592,14 +672,24 @@ export default function PackagingPage() {
</div>
</TableCell>
</TableRow>
) : filteredLoadingUnits.map((l) => (
) : filteredLoadingUnits.map((l) => {
const isUnreg = !l.id;
const rowKey = l.id ? `reg-${l.id}` : `new-${l.loading_code || l.item_number}`;
const selKey = selectedLoading ? (selectedLoading.id ? `reg-${selectedLoading.id}` : `new-${selectedLoading.loading_code || selectedLoading.item_number}`) : null;
return (
<TableRow
key={l.id}
key={rowKey}
className={cn(
"cursor-pointer text-xs transition-colors",
selectedLoading?.id === l.id ? "bg-primary/5" : "hover:bg-muted/50"
selKey === rowKey ? "bg-primary/5" : "hover:bg-muted/50"
)}
onClick={() => selectLoading(l)}
onClick={() => {
if (isUnreg) {
openLoadModal("create", l);
} else {
selectLoading(l);
}
}}
>
<TableCell className="p-2 font-medium">{l.loading_code}</TableCell>
<TableCell className="p-2">{l.loading_name}</TableCell>
@@ -607,12 +697,13 @@ export default function PackagingPage() {
<TableCell className="p-2 text-[10px] tabular-nums">{fmtSize(l.width_mm, l.length_mm, l.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(l.max_load_kg || 0) > 0 ? `${l.max_load_kg}kg` : "-"}</TableCell>
<TableCell className="p-2 text-center">
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(l.status))}>
{STATUS_LABEL[l.status] || l.status}
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", isUnreg ? "bg-warning/10 text-warning" : getStatusColor(l.status))}>
{isUnreg ? "미등록" : (STATUS_LABEL[l.status] || l.status)}
</span>
</TableCell>
</TableRow>
))}
);
})}
</TableBody>
</Table>
</div>
@@ -699,35 +790,9 @@ export default function PackagingPage() {
{pkgModalMode === "create" && (
<div className="rounded-lg border bg-muted/50 p-4">
<Label className="mb-2 block text-xs font-semibold"> (구분: 포장재)</Label>
<Popover open={pkgItemPopoverOpen} onOpenChange={setPkgItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-9 w-full justify-between text-sm font-normal">
{pkgForm.pkg_code ? `${pkgForm.pkg_name} (${pkgForm.pkg_code})` : "품목정보에서 포장재를 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = pkgItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{pkgItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onPkgItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", pkgForm.pkg_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<div className="h-9 w-full rounded-md border border-input bg-muted px-3 py-2 text-sm text-muted-foreground flex items-center">
{pkgForm.pkg_code ? `${pkgForm.pkg_name} (${pkgForm.pkg_code})` : "-"}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
@@ -781,35 +846,9 @@ export default function PackagingPage() {
{loadModalMode === "create" && (
<div className="rounded-lg border bg-muted/50 p-4">
<Label className="mb-2 block text-xs font-semibold"> (구분: 적재함)</Label>
<Popover open={loadItemPopoverOpen} onOpenChange={setLoadItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-9 w-full justify-between text-sm font-normal">
{loadForm.loading_code ? `${loadForm.loading_name} (${loadForm.loading_code})` : "품목정보에서 적재함을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = loadItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{loadItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onLoadItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", loadForm.loading_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<div className="h-9 w-full rounded-md border border-input bg-muted px-3 py-2 text-sm text-muted-foreground flex items-center">
{loadForm.loading_code ? `${loadForm.loading_name} (${loadForm.loading_code})` : "-"}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
@@ -37,7 +37,7 @@ const GRID_COLUMNS = [
{ key: "pkg_name", label: "포장명" },
{ key: "pkg_type", label: "유형" },
{ key: "size", label: "크기(mm)" },
{ key: "max_weight", label: "최대중량" },
{ key: "max_load_kg", label: "최대중량" },
{ key: "status", label: "상태" },
];
@@ -51,7 +51,7 @@ const LOADING_TYPE_LABEL: Record<string, string> = {
ALU_PALLET: "알루미늄파렛트", CONTAINER: "컨테이너", STEEL_BOX: "철재함",
CAGE: "케이지", ETC: "기타",
};
const STATUS_LABEL: Record<string, string> = { ACTIVE: "사용", INACTIVE: "미사용" };
const STATUS_LABEL: Record<string, string> = { ACTIVE: "사용", INACTIVE: "미사용", UNREGISTERED: "미등록" };
const getStatusColor = (s: string) => s === "ACTIVE" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground";
const fmtSize = (w: any, l: any, h: any) => {
@@ -118,20 +118,80 @@ export default function PackagingPage() {
const [saving, setSaving] = useState(false);
// --- 데이터 로드 ---
// --- 데이터 로드 (item_info 기반 + pkg_unit/loading_unit LEFT JOIN 방식) ---
const fetchPkgUnits = useCallback(async () => {
setPkgLoading(true);
try {
const res = await getPkgUnits();
if (res.success) setPkgUnits(res.data);
const [itemRes, pkgRes] = await Promise.all([
getItemsByDivision("포장재"),
getPkgUnits(),
]);
const items = itemRes.success ? itemRes.data : [];
const existing = pkgRes.success ? pkgRes.data : [];
// item_info 기준으로 병합: item_number 매칭되는 pkg_unit이 있으면 그 데이터 사용, 없으면 미등록 placeholder
const merged: PkgUnit[] = items.map((item) => {
const match = existing.find((p) => p.item_number === item.item_number || p.pkg_code === item.item_number);
if (match) return { ...match, pkg_code: match.pkg_code || item.item_number, pkg_name: match.pkg_name || item.item_name };
const dims = parseSpecDimensions(item.size);
return {
id: "",
company_code: "",
pkg_code: item.item_number,
pkg_name: item.item_name,
pkg_type: "",
status: "INACTIVE",
width_mm: dims.w || null,
length_mm: dims.l || null,
height_mm: dims.h || null,
self_weight_kg: null,
max_load_kg: null,
volume_l: null,
remarks: null,
created_date: "",
writer: null,
item_number: item.item_number,
} as PkgUnit;
});
// 품목정보에 없지만 pkg_unit에만 있는 항목 (고아) 추가
const orphans = existing.filter((p) => !items.some((i) => i.item_number === p.item_number || i.item_number === p.pkg_code));
setPkgUnits([...merged, ...orphans]);
} catch { /* ignore */ } finally { setPkgLoading(false); }
}, []);
const fetchLoadingUnits = useCallback(async () => {
setLoadingLoading(true);
try {
const res = await getLoadingUnits();
if (res.success) setLoadingUnits(res.data);
const [itemRes, luRes] = await Promise.all([
getItemsByDivision("적재함"),
getLoadingUnits(),
]);
const items = itemRes.success ? itemRes.data : [];
const existing = luRes.success ? luRes.data : [];
const merged: LoadingUnit[] = items.map((item) => {
const match = existing.find((l) => l.item_number === item.item_number || l.loading_code === item.item_number);
if (match) return { ...match, loading_code: match.loading_code || item.item_number, loading_name: match.loading_name || item.item_name };
const dims = parseSpecDimensions(item.size);
return {
id: "",
company_code: "",
loading_code: item.item_number,
loading_name: item.item_name,
loading_type: "",
status: "INACTIVE",
width_mm: dims.w || null,
length_mm: dims.l || null,
height_mm: dims.h || null,
self_weight_kg: null,
max_load_kg: null,
max_stack: null,
remarks: null,
created_date: "",
writer: null,
item_number: item.item_number,
} as LoadingUnit;
});
const orphans = existing.filter((l) => !items.some((i) => i.item_number === l.item_number || i.item_number === l.loading_code));
setLoadingUnits([...merged, ...orphans]);
} catch { /* ignore */ } finally { setLoadingLoading(false); }
}, []);
@@ -177,12 +237,21 @@ export default function PackagingPage() {
});
// --- 포장재 등록/수정 모달 ---
const openPkgModal = async (mode: "create" | "edit") => {
setPkgModalMode(mode);
if (mode === "edit" && selectedPkg) {
setPkgForm({ ...selectedPkg });
// row: 리스트에서 클릭한 행. id가 빈 문자열이면 미등록 상태(item_info만 있음) → 등록 모달
const openPkgModal = async (mode: "create" | "edit", row?: PkgUnit) => {
const target = row || selectedPkg;
const isRegistered = !!(target && target.id);
const actualMode: "create" | "edit" = isRegistered ? "edit" : "create";
setPkgModalMode(actualMode);
if (target) {
// 기존 데이터 or 품목정보 기반 초기값 세팅
setPkgForm({
...target,
status: target.status || "ACTIVE",
item_number: target.item_number || target.pkg_code || "",
});
} else {
setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "" });
setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "", item_number: "" });
}
setPkgItemPopoverOpen(false);
try {
@@ -232,12 +301,19 @@ export default function PackagingPage() {
};
// --- 적재함 등록/수정 모달 ---
const openLoadModal = async (mode: "create" | "edit") => {
setLoadModalMode(mode);
if (mode === "edit" && selectedLoading) {
setLoadForm({ ...selectedLoading });
const openLoadModal = async (mode: "create" | "edit", row?: LoadingUnit) => {
const target = row || selectedLoading;
const isRegistered = !!(target && target.id);
const actualMode: "create" | "edit" = isRegistered ? "edit" : "create";
setLoadModalMode(actualMode);
if (target) {
setLoadForm({
...target,
status: target.status || "ACTIVE",
item_number: target.item_number || target.loading_code || "",
});
} else {
setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "" });
setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "", item_number: "" });
}
setLoadItemPopoverOpen(false);
try {
@@ -442,12 +518,8 @@ export default function PackagingPage() {
<div className="h-4 w-px bg-border" />
</>
)}
<Button
size="sm"
onClick={() => activeTab === "packing" ? openPkgModal("create") : openLoadModal("create")}
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
{/* 메인 리스트가 품목정보 기반으로 자동 구성되므로 등록 버튼 제거.
미등록 상태의 품목을 직접 클릭하면 등록 모달이 열림. */}
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
@@ -466,22 +538,30 @@ export default function PackagingPage() {
size: { width: "w-[100px]", render: (_v: any, row: any) => fmtSize(row.width_mm, row.length_mm, row.height_mm) },
max_weight: { width: "w-[80px]", align: "right", render: (v: any) => Number(v || 0) > 0 ? `${v}kg` : "-" },
max_load_kg: { width: "w-[80px]", align: "right", render: (v: any) => Number(v || 0) > 0 ? `${v}kg` : "-" },
status: { width: "w-[60px]", align: "center", render: (v: any) => (
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(v))}>
{STATUS_LABEL[v] || v}
</span>
)},
status: { width: "w-[60px]", align: "center", render: (v: any, row: any) => {
const isUnreg = !row.id;
const cls = isUnreg ? "bg-warning/10 text-warning" : getStatusColor(v);
const label = isUnreg ? "미등록" : (STATUS_LABEL[v] || v);
return <span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", cls)}>{label}</span>;
}},
};
return { key: col.key, label: col.label, ...renderMap[col.key] };
})}
data={ts.groupData(filteredPkgUnits)}
rowKey={(row) => String(row.id)}
rowKey={(row) => row.id ? `reg-${row.id}` : `new-${row.pkg_code || row.item_number}`}
loading={pkgLoading}
emptyMessage="등록된 포장재가 없어요"
selectedId={selectedPkg ? String(selectedPkg.id) : null}
emptyMessage="포장재 품목이 없어요. 품목정보에서 관리품목을 '포장재'로 등록해주세요."
selectedId={selectedPkg ? (selectedPkg.id ? `reg-${selectedPkg.id}` : `new-${selectedPkg.pkg_code || selectedPkg.item_number}`) : null}
onSelect={(id) => {
const pkg = filteredPkgUnits.find((p) => String(p.id) === id);
if (pkg) selectPkg(pkg);
const pkg = filteredPkgUnits.find((p) => (p.id ? `reg-${p.id}` : `new-${p.pkg_code || p.item_number}`) === id);
if (pkg) {
if (!pkg.id) {
// 미등록 → 바로 등록 모달
openPkgModal("create", pkg);
} else {
selectPkg(pkg);
}
}
}}
showPagination={false}
draggableColumns
@@ -592,14 +672,24 @@ export default function PackagingPage() {
</div>
</TableCell>
</TableRow>
) : filteredLoadingUnits.map((l) => (
) : filteredLoadingUnits.map((l) => {
const isUnreg = !l.id;
const rowKey = l.id ? `reg-${l.id}` : `new-${l.loading_code || l.item_number}`;
const selKey = selectedLoading ? (selectedLoading.id ? `reg-${selectedLoading.id}` : `new-${selectedLoading.loading_code || selectedLoading.item_number}`) : null;
return (
<TableRow
key={l.id}
key={rowKey}
className={cn(
"cursor-pointer text-xs transition-colors",
selectedLoading?.id === l.id ? "bg-primary/5" : "hover:bg-muted/50"
selKey === rowKey ? "bg-primary/5" : "hover:bg-muted/50"
)}
onClick={() => selectLoading(l)}
onClick={() => {
if (isUnreg) {
openLoadModal("create", l);
} else {
selectLoading(l);
}
}}
>
<TableCell className="p-2 font-medium">{l.loading_code}</TableCell>
<TableCell className="p-2">{l.loading_name}</TableCell>
@@ -607,12 +697,13 @@ export default function PackagingPage() {
<TableCell className="p-2 text-[10px] tabular-nums">{fmtSize(l.width_mm, l.length_mm, l.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(l.max_load_kg || 0) > 0 ? `${l.max_load_kg}kg` : "-"}</TableCell>
<TableCell className="p-2 text-center">
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(l.status))}>
{STATUS_LABEL[l.status] || l.status}
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", isUnreg ? "bg-warning/10 text-warning" : getStatusColor(l.status))}>
{isUnreg ? "미등록" : (STATUS_LABEL[l.status] || l.status)}
</span>
</TableCell>
</TableRow>
))}
);
})}
</TableBody>
</Table>
</div>
@@ -699,35 +790,9 @@ export default function PackagingPage() {
{pkgModalMode === "create" && (
<div className="rounded-lg border bg-muted/50 p-4">
<Label className="mb-2 block text-xs font-semibold"> (구분: 포장재)</Label>
<Popover open={pkgItemPopoverOpen} onOpenChange={setPkgItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-9 w-full justify-between text-sm font-normal">
{pkgForm.pkg_code ? `${pkgForm.pkg_name} (${pkgForm.pkg_code})` : "품목정보에서 포장재를 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = pkgItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{pkgItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onPkgItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", pkgForm.pkg_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<div className="h-9 w-full rounded-md border border-input bg-muted px-3 py-2 text-sm text-muted-foreground flex items-center">
{pkgForm.pkg_code ? `${pkgForm.pkg_name} (${pkgForm.pkg_code})` : "-"}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
@@ -781,35 +846,9 @@ export default function PackagingPage() {
{loadModalMode === "create" && (
<div className="rounded-lg border bg-muted/50 p-4">
<Label className="mb-2 block text-xs font-semibold"> (구분: 적재함)</Label>
<Popover open={loadItemPopoverOpen} onOpenChange={setLoadItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-9 w-full justify-between text-sm font-normal">
{loadForm.loading_code ? `${loadForm.loading_name} (${loadForm.loading_code})` : "품목정보에서 적재함을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = loadItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{loadItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onLoadItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", loadForm.loading_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<div className="h-9 w-full rounded-md border border-input bg-muted px-3 py-2 text-sm text-muted-foreground flex items-center">
{loadForm.loading_code ? `${loadForm.loading_name} (${loadForm.loading_code})` : "-"}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
@@ -37,7 +37,7 @@ const GRID_COLUMNS = [
{ key: "pkg_name", label: "포장명" },
{ key: "pkg_type", label: "유형" },
{ key: "size", label: "크기(mm)" },
{ key: "max_weight", label: "최대중량" },
{ key: "max_load_kg", label: "최대중량" },
{ key: "status", label: "상태" },
];
@@ -51,7 +51,7 @@ const LOADING_TYPE_LABEL: Record<string, string> = {
ALU_PALLET: "알루미늄파렛트", CONTAINER: "컨테이너", STEEL_BOX: "철재함",
CAGE: "케이지", ETC: "기타",
};
const STATUS_LABEL: Record<string, string> = { ACTIVE: "사용", INACTIVE: "미사용" };
const STATUS_LABEL: Record<string, string> = { ACTIVE: "사용", INACTIVE: "미사용", UNREGISTERED: "미등록" };
const getStatusColor = (s: string) => s === "ACTIVE" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground";
const fmtSize = (w: any, l: any, h: any) => {
@@ -118,20 +118,80 @@ export default function PackagingPage() {
const [saving, setSaving] = useState(false);
// --- 데이터 로드 ---
// --- 데이터 로드 (item_info 기반 + pkg_unit/loading_unit LEFT JOIN 방식) ---
const fetchPkgUnits = useCallback(async () => {
setPkgLoading(true);
try {
const res = await getPkgUnits();
if (res.success) setPkgUnits(res.data);
const [itemRes, pkgRes] = await Promise.all([
getItemsByDivision("포장재"),
getPkgUnits(),
]);
const items = itemRes.success ? itemRes.data : [];
const existing = pkgRes.success ? pkgRes.data : [];
// item_info 기준으로 병합: item_number 매칭되는 pkg_unit이 있으면 그 데이터 사용, 없으면 미등록 placeholder
const merged: PkgUnit[] = items.map((item) => {
const match = existing.find((p) => p.item_number === item.item_number || p.pkg_code === item.item_number);
if (match) return { ...match, pkg_code: match.pkg_code || item.item_number, pkg_name: match.pkg_name || item.item_name };
const dims = parseSpecDimensions(item.size);
return {
id: "",
company_code: "",
pkg_code: item.item_number,
pkg_name: item.item_name,
pkg_type: "",
status: "INACTIVE",
width_mm: dims.w || null,
length_mm: dims.l || null,
height_mm: dims.h || null,
self_weight_kg: null,
max_load_kg: null,
volume_l: null,
remarks: null,
created_date: "",
writer: null,
item_number: item.item_number,
} as PkgUnit;
});
// 품목정보에 없지만 pkg_unit에만 있는 항목 (고아) 추가
const orphans = existing.filter((p) => !items.some((i) => i.item_number === p.item_number || i.item_number === p.pkg_code));
setPkgUnits([...merged, ...orphans]);
} catch { /* ignore */ } finally { setPkgLoading(false); }
}, []);
const fetchLoadingUnits = useCallback(async () => {
setLoadingLoading(true);
try {
const res = await getLoadingUnits();
if (res.success) setLoadingUnits(res.data);
const [itemRes, luRes] = await Promise.all([
getItemsByDivision("적재함"),
getLoadingUnits(),
]);
const items = itemRes.success ? itemRes.data : [];
const existing = luRes.success ? luRes.data : [];
const merged: LoadingUnit[] = items.map((item) => {
const match = existing.find((l) => l.item_number === item.item_number || l.loading_code === item.item_number);
if (match) return { ...match, loading_code: match.loading_code || item.item_number, loading_name: match.loading_name || item.item_name };
const dims = parseSpecDimensions(item.size);
return {
id: "",
company_code: "",
loading_code: item.item_number,
loading_name: item.item_name,
loading_type: "",
status: "INACTIVE",
width_mm: dims.w || null,
length_mm: dims.l || null,
height_mm: dims.h || null,
self_weight_kg: null,
max_load_kg: null,
max_stack: null,
remarks: null,
created_date: "",
writer: null,
item_number: item.item_number,
} as LoadingUnit;
});
const orphans = existing.filter((l) => !items.some((i) => i.item_number === l.item_number || i.item_number === l.loading_code));
setLoadingUnits([...merged, ...orphans]);
} catch { /* ignore */ } finally { setLoadingLoading(false); }
}, []);
@@ -177,12 +237,21 @@ export default function PackagingPage() {
});
// --- 포장재 등록/수정 모달 ---
const openPkgModal = async (mode: "create" | "edit") => {
setPkgModalMode(mode);
if (mode === "edit" && selectedPkg) {
setPkgForm({ ...selectedPkg });
// row: 리스트에서 클릭한 행. id가 빈 문자열이면 미등록 상태(item_info만 있음) → 등록 모달
const openPkgModal = async (mode: "create" | "edit", row?: PkgUnit) => {
const target = row || selectedPkg;
const isRegistered = !!(target && target.id);
const actualMode: "create" | "edit" = isRegistered ? "edit" : "create";
setPkgModalMode(actualMode);
if (target) {
// 기존 데이터 or 품목정보 기반 초기값 세팅
setPkgForm({
...target,
status: target.status || "ACTIVE",
item_number: target.item_number || target.pkg_code || "",
});
} else {
setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "" });
setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "", item_number: "" });
}
setPkgItemPopoverOpen(false);
try {
@@ -232,12 +301,19 @@ export default function PackagingPage() {
};
// --- 적재함 등록/수정 모달 ---
const openLoadModal = async (mode: "create" | "edit") => {
setLoadModalMode(mode);
if (mode === "edit" && selectedLoading) {
setLoadForm({ ...selectedLoading });
const openLoadModal = async (mode: "create" | "edit", row?: LoadingUnit) => {
const target = row || selectedLoading;
const isRegistered = !!(target && target.id);
const actualMode: "create" | "edit" = isRegistered ? "edit" : "create";
setLoadModalMode(actualMode);
if (target) {
setLoadForm({
...target,
status: target.status || "ACTIVE",
item_number: target.item_number || target.loading_code || "",
});
} else {
setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "" });
setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "", item_number: "" });
}
setLoadItemPopoverOpen(false);
try {
@@ -442,12 +518,8 @@ export default function PackagingPage() {
<div className="h-4 w-px bg-border" />
</>
)}
<Button
size="sm"
onClick={() => activeTab === "packing" ? openPkgModal("create") : openLoadModal("create")}
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
{/* 메인 리스트가 품목정보 기반으로 자동 구성되므로 등록 버튼 제거.
미등록 상태의 품목을 직접 클릭하면 등록 모달이 열림. */}
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
@@ -466,22 +538,30 @@ export default function PackagingPage() {
size: { width: "w-[100px]", render: (_v: any, row: any) => fmtSize(row.width_mm, row.length_mm, row.height_mm) },
max_weight: { width: "w-[80px]", align: "right", render: (v: any) => Number(v || 0) > 0 ? `${v}kg` : "-" },
max_load_kg: { width: "w-[80px]", align: "right", render: (v: any) => Number(v || 0) > 0 ? `${v}kg` : "-" },
status: { width: "w-[60px]", align: "center", render: (v: any) => (
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(v))}>
{STATUS_LABEL[v] || v}
</span>
)},
status: { width: "w-[60px]", align: "center", render: (v: any, row: any) => {
const isUnreg = !row.id;
const cls = isUnreg ? "bg-warning/10 text-warning" : getStatusColor(v);
const label = isUnreg ? "미등록" : (STATUS_LABEL[v] || v);
return <span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", cls)}>{label}</span>;
}},
};
return { key: col.key, label: col.label, ...renderMap[col.key] };
})}
data={ts.groupData(filteredPkgUnits)}
rowKey={(row) => String(row.id)}
rowKey={(row) => row.id ? `reg-${row.id}` : `new-${row.pkg_code || row.item_number}`}
loading={pkgLoading}
emptyMessage="등록된 포장재가 없어요"
selectedId={selectedPkg ? String(selectedPkg.id) : null}
emptyMessage="포장재 품목이 없어요. 품목정보에서 관리품목을 '포장재'로 등록해주세요."
selectedId={selectedPkg ? (selectedPkg.id ? `reg-${selectedPkg.id}` : `new-${selectedPkg.pkg_code || selectedPkg.item_number}`) : null}
onSelect={(id) => {
const pkg = filteredPkgUnits.find((p) => String(p.id) === id);
if (pkg) selectPkg(pkg);
const pkg = filteredPkgUnits.find((p) => (p.id ? `reg-${p.id}` : `new-${p.pkg_code || p.item_number}`) === id);
if (pkg) {
if (!pkg.id) {
// 미등록 → 바로 등록 모달
openPkgModal("create", pkg);
} else {
selectPkg(pkg);
}
}
}}
showPagination={false}
draggableColumns
@@ -592,14 +672,24 @@ export default function PackagingPage() {
</div>
</TableCell>
</TableRow>
) : filteredLoadingUnits.map((l) => (
) : filteredLoadingUnits.map((l) => {
const isUnreg = !l.id;
const rowKey = l.id ? `reg-${l.id}` : `new-${l.loading_code || l.item_number}`;
const selKey = selectedLoading ? (selectedLoading.id ? `reg-${selectedLoading.id}` : `new-${selectedLoading.loading_code || selectedLoading.item_number}`) : null;
return (
<TableRow
key={l.id}
key={rowKey}
className={cn(
"cursor-pointer text-xs transition-colors",
selectedLoading?.id === l.id ? "bg-primary/5" : "hover:bg-muted/50"
selKey === rowKey ? "bg-primary/5" : "hover:bg-muted/50"
)}
onClick={() => selectLoading(l)}
onClick={() => {
if (isUnreg) {
openLoadModal("create", l);
} else {
selectLoading(l);
}
}}
>
<TableCell className="p-2 font-medium">{l.loading_code}</TableCell>
<TableCell className="p-2">{l.loading_name}</TableCell>
@@ -607,12 +697,13 @@ export default function PackagingPage() {
<TableCell className="p-2 text-[10px] tabular-nums">{fmtSize(l.width_mm, l.length_mm, l.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(l.max_load_kg || 0) > 0 ? `${l.max_load_kg}kg` : "-"}</TableCell>
<TableCell className="p-2 text-center">
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(l.status))}>
{STATUS_LABEL[l.status] || l.status}
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", isUnreg ? "bg-warning/10 text-warning" : getStatusColor(l.status))}>
{isUnreg ? "미등록" : (STATUS_LABEL[l.status] || l.status)}
</span>
</TableCell>
</TableRow>
))}
);
})}
</TableBody>
</Table>
</div>
@@ -699,35 +790,9 @@ export default function PackagingPage() {
{pkgModalMode === "create" && (
<div className="rounded-lg border bg-muted/50 p-4">
<Label className="mb-2 block text-xs font-semibold"> (구분: 포장재)</Label>
<Popover open={pkgItemPopoverOpen} onOpenChange={setPkgItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-9 w-full justify-between text-sm font-normal">
{pkgForm.pkg_code ? `${pkgForm.pkg_name} (${pkgForm.pkg_code})` : "품목정보에서 포장재를 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = pkgItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{pkgItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onPkgItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", pkgForm.pkg_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<div className="h-9 w-full rounded-md border border-input bg-muted px-3 py-2 text-sm text-muted-foreground flex items-center">
{pkgForm.pkg_code ? `${pkgForm.pkg_name} (${pkgForm.pkg_code})` : "-"}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
@@ -781,35 +846,9 @@ export default function PackagingPage() {
{loadModalMode === "create" && (
<div className="rounded-lg border bg-muted/50 p-4">
<Label className="mb-2 block text-xs font-semibold"> (구분: 적재함)</Label>
<Popover open={loadItemPopoverOpen} onOpenChange={setLoadItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-9 w-full justify-between text-sm font-normal">
{loadForm.loading_code ? `${loadForm.loading_name} (${loadForm.loading_code})` : "품목정보에서 적재함을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = loadItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{loadItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onLoadItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", loadForm.loading_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<div className="h-9 w-full rounded-md border border-input bg-muted px-3 py-2 text-sm text-muted-foreground flex items-center">
{loadForm.loading_code ? `${loadForm.loading_name} (${loadForm.loading_code})` : "-"}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
@@ -37,7 +37,7 @@ const GRID_COLUMNS = [
{ key: "pkg_name", label: "포장명" },
{ key: "pkg_type", label: "유형" },
{ key: "size", label: "크기(mm)" },
{ key: "max_weight", label: "최대중량" },
{ key: "max_load_kg", label: "최대중량" },
{ key: "status", label: "상태" },
];
@@ -51,7 +51,7 @@ const LOADING_TYPE_LABEL: Record<string, string> = {
ALU_PALLET: "알루미늄파렛트", CONTAINER: "컨테이너", STEEL_BOX: "철재함",
CAGE: "케이지", ETC: "기타",
};
const STATUS_LABEL: Record<string, string> = { ACTIVE: "사용", INACTIVE: "미사용" };
const STATUS_LABEL: Record<string, string> = { ACTIVE: "사용", INACTIVE: "미사용", UNREGISTERED: "미등록" };
const getStatusColor = (s: string) => s === "ACTIVE" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground";
const fmtSize = (w: any, l: any, h: any) => {
@@ -118,20 +118,80 @@ export default function PackagingPage() {
const [saving, setSaving] = useState(false);
// --- 데이터 로드 ---
// --- 데이터 로드 (item_info 기반 + pkg_unit/loading_unit LEFT JOIN 방식) ---
const fetchPkgUnits = useCallback(async () => {
setPkgLoading(true);
try {
const res = await getPkgUnits();
if (res.success) setPkgUnits(res.data);
const [itemRes, pkgRes] = await Promise.all([
getItemsByDivision("포장재"),
getPkgUnits(),
]);
const items = itemRes.success ? itemRes.data : [];
const existing = pkgRes.success ? pkgRes.data : [];
// item_info 기준으로 병합: item_number 매칭되는 pkg_unit이 있으면 그 데이터 사용, 없으면 미등록 placeholder
const merged: PkgUnit[] = items.map((item) => {
const match = existing.find((p) => p.item_number === item.item_number || p.pkg_code === item.item_number);
if (match) return { ...match, pkg_code: match.pkg_code || item.item_number, pkg_name: match.pkg_name || item.item_name };
const dims = parseSpecDimensions(item.size);
return {
id: "",
company_code: "",
pkg_code: item.item_number,
pkg_name: item.item_name,
pkg_type: "",
status: "INACTIVE",
width_mm: dims.w || null,
length_mm: dims.l || null,
height_mm: dims.h || null,
self_weight_kg: null,
max_load_kg: null,
volume_l: null,
remarks: null,
created_date: "",
writer: null,
item_number: item.item_number,
} as PkgUnit;
});
// 품목정보에 없지만 pkg_unit에만 있는 항목 (고아) 추가
const orphans = existing.filter((p) => !items.some((i) => i.item_number === p.item_number || i.item_number === p.pkg_code));
setPkgUnits([...merged, ...orphans]);
} catch { /* ignore */ } finally { setPkgLoading(false); }
}, []);
const fetchLoadingUnits = useCallback(async () => {
setLoadingLoading(true);
try {
const res = await getLoadingUnits();
if (res.success) setLoadingUnits(res.data);
const [itemRes, luRes] = await Promise.all([
getItemsByDivision("적재함"),
getLoadingUnits(),
]);
const items = itemRes.success ? itemRes.data : [];
const existing = luRes.success ? luRes.data : [];
const merged: LoadingUnit[] = items.map((item) => {
const match = existing.find((l) => l.item_number === item.item_number || l.loading_code === item.item_number);
if (match) return { ...match, loading_code: match.loading_code || item.item_number, loading_name: match.loading_name || item.item_name };
const dims = parseSpecDimensions(item.size);
return {
id: "",
company_code: "",
loading_code: item.item_number,
loading_name: item.item_name,
loading_type: "",
status: "INACTIVE",
width_mm: dims.w || null,
length_mm: dims.l || null,
height_mm: dims.h || null,
self_weight_kg: null,
max_load_kg: null,
max_stack: null,
remarks: null,
created_date: "",
writer: null,
item_number: item.item_number,
} as LoadingUnit;
});
const orphans = existing.filter((l) => !items.some((i) => i.item_number === l.item_number || i.item_number === l.loading_code));
setLoadingUnits([...merged, ...orphans]);
} catch { /* ignore */ } finally { setLoadingLoading(false); }
}, []);
@@ -177,12 +237,21 @@ export default function PackagingPage() {
});
// --- 포장재 등록/수정 모달 ---
const openPkgModal = async (mode: "create" | "edit") => {
setPkgModalMode(mode);
if (mode === "edit" && selectedPkg) {
setPkgForm({ ...selectedPkg });
// row: 리스트에서 클릭한 행. id가 빈 문자열이면 미등록 상태(item_info만 있음) → 등록 모달
const openPkgModal = async (mode: "create" | "edit", row?: PkgUnit) => {
const target = row || selectedPkg;
const isRegistered = !!(target && target.id);
const actualMode: "create" | "edit" = isRegistered ? "edit" : "create";
setPkgModalMode(actualMode);
if (target) {
// 기존 데이터 or 품목정보 기반 초기값 세팅
setPkgForm({
...target,
status: target.status || "ACTIVE",
item_number: target.item_number || target.pkg_code || "",
});
} else {
setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "" });
setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "", item_number: "" });
}
setPkgItemPopoverOpen(false);
try {
@@ -232,12 +301,19 @@ export default function PackagingPage() {
};
// --- 적재함 등록/수정 모달 ---
const openLoadModal = async (mode: "create" | "edit") => {
setLoadModalMode(mode);
if (mode === "edit" && selectedLoading) {
setLoadForm({ ...selectedLoading });
const openLoadModal = async (mode: "create" | "edit", row?: LoadingUnit) => {
const target = row || selectedLoading;
const isRegistered = !!(target && target.id);
const actualMode: "create" | "edit" = isRegistered ? "edit" : "create";
setLoadModalMode(actualMode);
if (target) {
setLoadForm({
...target,
status: target.status || "ACTIVE",
item_number: target.item_number || target.loading_code || "",
});
} else {
setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "" });
setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "", item_number: "" });
}
setLoadItemPopoverOpen(false);
try {
@@ -442,12 +518,8 @@ export default function PackagingPage() {
<div className="h-4 w-px bg-border" />
</>
)}
<Button
size="sm"
onClick={() => activeTab === "packing" ? openPkgModal("create") : openLoadModal("create")}
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
{/* 메인 리스트가 품목정보 기반으로 자동 구성되므로 등록 버튼 제거.
미등록 상태의 품목을 직접 클릭하면 등록 모달이 열림. */}
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
@@ -466,22 +538,30 @@ export default function PackagingPage() {
size: { width: "w-[100px]", render: (_v: any, row: any) => fmtSize(row.width_mm, row.length_mm, row.height_mm) },
max_weight: { width: "w-[80px]", align: "right", render: (v: any) => Number(v || 0) > 0 ? `${v}kg` : "-" },
max_load_kg: { width: "w-[80px]", align: "right", render: (v: any) => Number(v || 0) > 0 ? `${v}kg` : "-" },
status: { width: "w-[60px]", align: "center", render: (v: any) => (
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(v))}>
{STATUS_LABEL[v] || v}
</span>
)},
status: { width: "w-[60px]", align: "center", render: (v: any, row: any) => {
const isUnreg = !row.id;
const cls = isUnreg ? "bg-warning/10 text-warning" : getStatusColor(v);
const label = isUnreg ? "미등록" : (STATUS_LABEL[v] || v);
return <span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", cls)}>{label}</span>;
}},
};
return { key: col.key, label: col.label, ...renderMap[col.key] };
})}
data={ts.groupData(filteredPkgUnits)}
rowKey={(row) => String(row.id)}
rowKey={(row) => row.id ? `reg-${row.id}` : `new-${row.pkg_code || row.item_number}`}
loading={pkgLoading}
emptyMessage="등록된 포장재가 없어요"
selectedId={selectedPkg ? String(selectedPkg.id) : null}
emptyMessage="포장재 품목이 없어요. 품목정보에서 관리품목을 '포장재'로 등록해주세요."
selectedId={selectedPkg ? (selectedPkg.id ? `reg-${selectedPkg.id}` : `new-${selectedPkg.pkg_code || selectedPkg.item_number}`) : null}
onSelect={(id) => {
const pkg = filteredPkgUnits.find((p) => String(p.id) === id);
if (pkg) selectPkg(pkg);
const pkg = filteredPkgUnits.find((p) => (p.id ? `reg-${p.id}` : `new-${p.pkg_code || p.item_number}`) === id);
if (pkg) {
if (!pkg.id) {
// 미등록 → 바로 등록 모달
openPkgModal("create", pkg);
} else {
selectPkg(pkg);
}
}
}}
showPagination={false}
draggableColumns
@@ -592,14 +672,24 @@ export default function PackagingPage() {
</div>
</TableCell>
</TableRow>
) : filteredLoadingUnits.map((l) => (
) : filteredLoadingUnits.map((l) => {
const isUnreg = !l.id;
const rowKey = l.id ? `reg-${l.id}` : `new-${l.loading_code || l.item_number}`;
const selKey = selectedLoading ? (selectedLoading.id ? `reg-${selectedLoading.id}` : `new-${selectedLoading.loading_code || selectedLoading.item_number}`) : null;
return (
<TableRow
key={l.id}
key={rowKey}
className={cn(
"cursor-pointer text-xs transition-colors",
selectedLoading?.id === l.id ? "bg-primary/5" : "hover:bg-muted/50"
selKey === rowKey ? "bg-primary/5" : "hover:bg-muted/50"
)}
onClick={() => selectLoading(l)}
onClick={() => {
if (isUnreg) {
openLoadModal("create", l);
} else {
selectLoading(l);
}
}}
>
<TableCell className="p-2 font-medium">{l.loading_code}</TableCell>
<TableCell className="p-2">{l.loading_name}</TableCell>
@@ -607,12 +697,13 @@ export default function PackagingPage() {
<TableCell className="p-2 text-[10px] tabular-nums">{fmtSize(l.width_mm, l.length_mm, l.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(l.max_load_kg || 0) > 0 ? `${l.max_load_kg}kg` : "-"}</TableCell>
<TableCell className="p-2 text-center">
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(l.status))}>
{STATUS_LABEL[l.status] || l.status}
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", isUnreg ? "bg-warning/10 text-warning" : getStatusColor(l.status))}>
{isUnreg ? "미등록" : (STATUS_LABEL[l.status] || l.status)}
</span>
</TableCell>
</TableRow>
))}
);
})}
</TableBody>
</Table>
</div>
@@ -699,35 +790,9 @@ export default function PackagingPage() {
{pkgModalMode === "create" && (
<div className="rounded-lg border bg-muted/50 p-4">
<Label className="mb-2 block text-xs font-semibold"> (구분: 포장재)</Label>
<Popover open={pkgItemPopoverOpen} onOpenChange={setPkgItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-9 w-full justify-between text-sm font-normal">
{pkgForm.pkg_code ? `${pkgForm.pkg_name} (${pkgForm.pkg_code})` : "품목정보에서 포장재를 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = pkgItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{pkgItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onPkgItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", pkgForm.pkg_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<div className="h-9 w-full rounded-md border border-input bg-muted px-3 py-2 text-sm text-muted-foreground flex items-center">
{pkgForm.pkg_code ? `${pkgForm.pkg_name} (${pkgForm.pkg_code})` : "-"}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
@@ -781,35 +846,9 @@ export default function PackagingPage() {
{loadModalMode === "create" && (
<div className="rounded-lg border bg-muted/50 p-4">
<Label className="mb-2 block text-xs font-semibold"> (구분: 적재함)</Label>
<Popover open={loadItemPopoverOpen} onOpenChange={setLoadItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-9 w-full justify-between text-sm font-normal">
{loadForm.loading_code ? `${loadForm.loading_name} (${loadForm.loading_code})` : "품목정보에서 적재함을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = loadItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{loadItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onLoadItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", loadForm.loading_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<div className="h-9 w-full rounded-md border border-input bg-muted px-3 py-2 text-sm text-muted-foreground flex items-center">
{loadForm.loading_code ? `${loadForm.loading_name} (${loadForm.loading_code})` : "-"}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">