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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user