Files
vexplor_dev/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx
kjs 3df9a39ebe feat: implement registered items management in process work standard
- Added new endpoints for managing registered items, including retrieval, registration, and batch registration.
- Enhanced the existing processWorkStandardController to support filtering and additional columns in item queries.
- Updated the processWorkStandardRoutes to include routes for registered items management.
- Introduced a new documentation file detailing the design and structure of the POP 작업진행 관리 system.

These changes aim to improve the management of registered items within the process work standard, enhancing usability and functionality.

Made-with: Cursor
2026-03-13 11:26:59 +09:00

551 lines
27 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Search, Plus, Trash2, Edit, ListOrdered, Package, Star, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { useToast } from "@/hooks/use-toast";
import { ItemRoutingComponentProps, ColumnDef } from "./types";
import { useItemRouting } from "./hooks/useItemRouting";
const DEFAULT_ITEM_COLS: ColumnDef[] = [
{ name: "item_name", label: "품명" },
{ name: "item_code", label: "품번", width: 100 },
];
export function ItemRoutingComponent({
config: configProp,
isPreview,
screenId,
}: ItemRoutingComponentProps) {
const { toast } = useToast();
const resolvedConfig = React.useMemo(() => {
if (configProp?.itemListMode === "registered" && !configProp?.screenCode && screenId) {
return { ...configProp, screenCode: `screen_${screenId}` };
}
return configProp;
}, [configProp, screenId]);
const {
config, items, allItems, versions, details, loading,
selectedItemCode, selectedItemName, selectedVersionId, isRegisteredMode,
fetchItems, fetchRegisteredItems, fetchAllItems,
registerItemsBatch, unregisterItem,
selectItem, selectVersion, refreshVersions, refreshDetails,
deleteDetail, deleteVersion, setDefaultVersion, unsetDefaultVersion,
} = useItemRouting(resolvedConfig || {});
const [searchText, setSearchText] = useState("");
const [deleteTarget, setDeleteTarget] = useState<{
type: "version" | "detail"; id: string; name: string;
} | null>(null);
// 품목 추가 다이얼로그
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [addSearchText, setAddSearchText] = useState("");
const [selectedAddItems, setSelectedAddItems] = useState<Set<string>>(new Set());
const [addLoading, setAddLoading] = useState(false);
const itemDisplayCols = config.itemDisplayColumns?.length
? config.itemDisplayColumns : DEFAULT_ITEM_COLS;
const modalDisplayCols = config.modalDisplayColumns?.length
? config.modalDisplayColumns : DEFAULT_ITEM_COLS;
// 초기 로딩
const mountedRef = React.useRef(false);
useEffect(() => {
if (!mountedRef.current) {
mountedRef.current = true;
if (isRegisteredMode) fetchRegisteredItems();
else fetchItems();
}
}, [fetchItems, fetchRegisteredItems, isRegisteredMode]);
// 모달 저장 성공 감지
const refreshVersionsRef = React.useRef(refreshVersions);
const refreshDetailsRef = React.useRef(refreshDetails);
refreshVersionsRef.current = refreshVersions;
refreshDetailsRef.current = refreshDetails;
useEffect(() => {
const h = () => { refreshVersionsRef.current(); refreshDetailsRef.current(); };
window.addEventListener("saveSuccessInModal", h);
return () => window.removeEventListener("saveSuccessInModal", h);
}, []);
// 검색
const handleSearch = useCallback(() => {
if (isRegisteredMode) fetchRegisteredItems(searchText || undefined);
else fetchItems(searchText || undefined);
}, [fetchItems, fetchRegisteredItems, isRegisteredMode, searchText]);
// ──── 품목 추가 모달 ────
const handleOpenAddDialog = useCallback(() => {
setAddSearchText(""); setSelectedAddItems(new Set()); setAddDialogOpen(true);
fetchAllItems();
}, [fetchAllItems]);
const handleToggleAddItem = useCallback((itemId: string) => {
setSelectedAddItems((prev) => {
const next = new Set(prev);
next.has(itemId) ? next.delete(itemId) : next.add(itemId);
return next;
});
}, []);
const handleConfirmAdd = useCallback(async () => {
if (selectedAddItems.size === 0) return;
setAddLoading(true);
const itemList = allItems
.filter((item) => selectedAddItems.has(item.id))
.map((item) => ({
itemId: item.id,
itemCode: item.item_code || item[config.dataSource.itemCodeColumn] || "",
}));
const success = await registerItemsBatch(itemList);
setAddLoading(false);
if (success) {
toast({ title: `${itemList.length}개 품목이 등록되었습니다` });
setAddDialogOpen(false);
} else {
toast({ title: "품목 등록 실패", variant: "destructive" });
}
}, [selectedAddItems, allItems, config.dataSource.itemCodeColumn, registerItemsBatch, toast]);
const handleUnregisterItem = useCallback(
async (registeredId: string, itemName: string) => {
const success = await unregisterItem(registeredId);
if (success) toast({ title: `${itemName} 등록 해제됨` });
else toast({ title: "등록 해제 실패", variant: "destructive" });
},
[unregisterItem, toast]
);
// ──── 기존 핸들러 ────
const handleAddVersion = useCallback(() => {
if (!selectedItemCode) { toast({ title: "품목을 먼저 선택해주세요", variant: "destructive" }); return; }
const sid = config.modals.versionAddScreenId;
if (!sid) return;
window.dispatchEvent(new CustomEvent("openScreenModal", {
detail: { screenId: sid, urlParams: { mode: "add", tableName: config.dataSource.routingVersionTable },
splitPanelParentData: { [config.dataSource.routingVersionFkColumn]: selectedItemCode } },
}));
}, [selectedItemCode, config, toast]);
const handleAddProcess = useCallback(() => {
if (!selectedVersionId) { toast({ title: "라우팅 버전을 먼저 선택해주세요", variant: "destructive" }); return; }
const sid = config.modals.processAddScreenId;
if (!sid) return;
window.dispatchEvent(new CustomEvent("openScreenModal", {
detail: { screenId: sid, urlParams: { mode: "add", tableName: config.dataSource.routingDetailTable },
splitPanelParentData: { [config.dataSource.routingDetailFkColumn]: selectedVersionId } },
}));
}, [selectedVersionId, config, toast]);
const handleEditProcess = useCallback(
(detail: Record<string, any>) => {
const sid = config.modals.processEditScreenId;
if (!sid) return;
window.dispatchEvent(new CustomEvent("openScreenModal", {
detail: { screenId: sid, urlParams: { mode: "edit", tableName: config.dataSource.routingDetailTable }, editData: detail },
}));
}, [config]
);
const handleToggleDefault = useCallback(
async (versionId: string, currentIsDefault: boolean) => {
const success = currentIsDefault ? await unsetDefaultVersion(versionId) : await setDefaultVersion(versionId);
if (success) toast({ title: currentIsDefault ? "기본 버전이 해제되었습니다" : "기본 버전으로 설정되었습니다" });
else toast({ title: "기본 버전 변경 실패", variant: "destructive" });
},
[setDefaultVersion, unsetDefaultVersion, toast]
);
const handleConfirmDelete = useCallback(async () => {
if (!deleteTarget) return;
const success = deleteTarget.type === "version"
? await deleteVersion(deleteTarget.id) : await deleteDetail(deleteTarget.id);
toast({ title: success ? `${deleteTarget.name} 삭제 완료` : "삭제 실패", variant: success ? undefined : "destructive" });
setDeleteTarget(null);
}, [deleteTarget, deleteVersion, deleteDetail, toast]);
const splitRatio = config.splitRatio || 40;
const registeredItemIds = React.useMemo(() => new Set(items.map((i) => i.id)), [items]);
// ──── 셀 값 추출 헬퍼 ────
const getCellValue = (item: Record<string, any>, colName: string) => {
return item[colName] ?? item[`item_${colName}`] ?? "-";
};
if (isPreview) {
return (
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/10 p-4">
<div className="text-center">
<ListOrdered className="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
<p className="text-sm font-medium text-muted-foreground"> </p>
<p className="mt-1 text-xs text-muted-foreground/70">
{isRegisteredMode ? "등록 품목 모드" : "전체 품목 모드"}
</p>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-background">
<div className="flex flex-1 overflow-hidden">
{/* ════ 좌측 패널: 품목 목록 (테이블) ════ */}
<div style={{ width: `${splitRatio}%` }} className="flex shrink-0 flex-col overflow-hidden border-r">
<div className="flex items-center justify-between border-b px-3 py-2">
<h3 className="text-sm font-semibold">
{config.leftPanelTitle || "품목 목록"}
{isRegisteredMode && (
<span className="ml-1.5 text-[10px] font-normal text-muted-foreground">( )</span>
)}
</h3>
{isRegisteredMode && !config.readonly && (
<Button variant="outline" size="sm" className="h-6 gap-1 text-[10px]" onClick={handleOpenAddDialog}>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
<div className="flex gap-1.5 border-b px-3 py-2">
<Input value={searchText} onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
placeholder="품목명/품번 검색" className="h-8 text-xs" />
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0" onClick={handleSearch}>
<Search className="h-3.5 w-3.5" />
</Button>
</div>
{/* 품목 테이블 */}
<div className="flex-1 overflow-auto">
{items.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-4">
<p className="text-xs text-muted-foreground">
{loading ? "로딩 중..." : isRegisteredMode ? "등록된 품목이 없습니다" : "품목이 없습니다"}
</p>
{isRegisteredMode && !loading && !config.readonly && (
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs" onClick={handleOpenAddDialog}>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
{itemDisplayCols.map((col) => (
<TableHead key={col.name}
style={{ width: col.width ? `${col.width}px` : undefined }}
className={cn("text-[11px] py-1.5", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
{col.label}
</TableHead>
))}
{isRegisteredMode && !config.readonly && (
<TableHead className="w-[36px] py-1.5" />
)}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => {
const itemCode = item[config.dataSource.itemCodeColumn] || item.item_code || item.item_number;
const itemName = item[config.dataSource.itemNameColumn] || item.item_name;
const isSelected = selectedItemCode === itemCode;
return (
<TableRow key={item.registered_id || item.id}
className={cn("cursor-pointer group", isSelected && "bg-primary/10")}
onClick={() => selectItem(itemCode, itemName)}>
{itemDisplayCols.map((col) => (
<TableCell key={col.name}
className={cn("text-xs py-1.5", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
{getCellValue(item, col.name)}
</TableCell>
))}
{isRegisteredMode && !config.readonly && item.registered_id && (
<TableCell className="py-1.5 text-center">
<Button variant="ghost" size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => { e.stopPropagation(); handleUnregisterItem(item.registered_id, itemName); }}
title="등록 해제">
<X className="h-3 w-3 text-muted-foreground hover:text-destructive" />
</Button>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</div>
{/* ════ 우측 패널: 버전 + 공정 ════ */}
<div className="flex flex-1 flex-col overflow-hidden">
{selectedItemCode ? (
<>
<div className="flex items-center justify-between border-b px-4 py-2">
<div>
<h3 className="text-sm font-semibold">{selectedItemName}</h3>
<p className="text-xs text-muted-foreground">{selectedItemCode}</p>
</div>
{!config.readonly && (
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs" onClick={handleAddVersion}>
<Plus className="h-3 w-3" /> {config.versionAddButtonText || "+ 라우팅 버전 추가"}
</Button>
)}
</div>
{versions.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 border-b px-4 py-2">
<span className="mr-1 text-xs text-muted-foreground">:</span>
{versions.map((ver) => {
const isActive = selectedVersionId === ver.id;
const isDefault = ver.is_default === true;
return (
<div key={ver.id} className="flex items-center gap-0.5">
<Badge variant={isActive ? "default" : "outline"}
className={cn("cursor-pointer px-2.5 py-0.5 text-xs transition-colors",
isActive && "bg-primary text-primary-foreground",
isDefault && !isActive && "border-amber-400 bg-amber-50 text-amber-700")}
onClick={() => selectVersion(ver.id)}>
{isDefault && <Star className="mr-1 h-3 w-3 fill-current" />}
{ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id}
</Badge>
{!config.readonly && (
<>
<Button variant="ghost" size="icon"
className={cn("h-5 w-5", isDefault ? "text-amber-500 hover:text-amber-600" : "text-muted-foreground hover:text-amber-500")}
onClick={(e) => { e.stopPropagation(); handleToggleDefault(ver.id, isDefault); }}
title={isDefault ? "기본 버전 해제" : "기본 버전으로 설정"}>
<Star className={cn("h-3 w-3", isDefault && "fill-current")} />
</Button>
<Button variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:text-destructive"
onClick={(e) => { e.stopPropagation(); setDeleteTarget({ type: "version", id: ver.id, name: ver.version_name || ver.id }); }}>
<Trash2 className="h-3 w-3" />
</Button>
</>
)}
</div>
);
})}
</div>
) : (
<div className="border-b px-4 py-3 text-center">
<p className="text-xs text-muted-foreground"> . .</p>
</div>
)}
{selectedVersionId ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center justify-between px-4 py-2">
<h4 className="text-xs font-medium text-muted-foreground">
{config.rightPanelTitle || "공정 순서"} ({details.length})
</h4>
{!config.readonly && (
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs" onClick={handleAddProcess}>
<Plus className="h-3 w-3" /> {config.processAddButtonText || "+ 공정 추가"}
</Button>
)}
</div>
<div className="flex-1 overflow-auto px-4 pb-4">
{details.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-xs text-muted-foreground">{loading ? "로딩 중..." : "등록된 공정이 없습니다"}</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
{config.processColumns.map((col) => (
<TableHead key={col.name}
style={{ width: col.width ? `${col.width}px` : undefined }}
className={cn("text-xs", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
{col.label}
</TableHead>
))}
{!config.readonly && <TableHead className="w-[80px] text-center text-xs"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{details.map((detail) => (
<TableRow key={detail.id}>
{config.processColumns.map((col) => {
let v = detail[col.name];
if (v == null) {
const ak = Object.keys(detail).find((k) => k.endsWith(`_${col.name}`));
if (ak) v = detail[ak];
}
return (
<TableCell key={col.name}
className={cn("text-xs", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
{v ?? "-"}
</TableCell>
);
})}
{!config.readonly && (
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleEditProcess(detail)}>
<Edit className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() => setDeleteTarget({ type: "detail", id: detail.id, name: `공정 ${detail.seq_no || detail.id}` })}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
) : (
versions.length > 0 && (
<div className="flex flex-1 items-center justify-center">
<p className="text-xs text-muted-foreground"> </p>
</div>
)
)}
</>
) : (
<div className="flex flex-1 flex-col items-center justify-center text-center">
<ListOrdered className="mb-3 h-12 w-12 text-muted-foreground/30" />
<p className="text-sm font-medium text-muted-foreground"> </p>
<p className="mt-1 text-xs text-muted-foreground/70"> </p>
</div>
)}
</div>
</div>
{/* ════ 삭제 확인 ════ */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-base"> </AlertDialogTitle>
<AlertDialogDescription className="text-sm">
{deleteTarget?.name}() ?
{deleteTarget?.type === "version" && (<><br /> .</>)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ════ 품목 추가 다이얼로그 (테이블 형태 + 검색) ════ */}
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{(config.itemFilterConditions?.length ?? 0) > 0 && (
<span className="ml-1 text-[10px] text-muted-foreground">
( {config.itemFilterConditions!.length} )
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="flex gap-1.5">
<Input value={addSearchText} onChange={(e) => setAddSearchText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") fetchAllItems(addSearchText || undefined); }}
placeholder="품목명/품번 검색" className="h-8 text-xs sm:h-10 sm:text-sm" />
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0 sm:h-10 sm:w-10"
onClick={() => fetchAllItems(addSearchText || undefined)}>
<Search className="h-3.5 w-3.5" />
</Button>
</div>
<div className="max-h-[340px] overflow-auto rounded-md border">
{allItems.length === 0 ? (
<div className="flex items-center justify-center py-8">
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px] text-center text-[11px] py-1.5" />
{modalDisplayCols.map((col) => (
<TableHead key={col.name}
style={{ width: col.width ? `${col.width}px` : undefined }}
className={cn("text-[11px] py-1.5", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
{col.label}
</TableHead>
))}
<TableHead className="w-[60px] text-center text-[11px] py-1.5"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allItems.map((item) => {
const isAlreadyRegistered = registeredItemIds.has(item.id);
const isChecked = selectedAddItems.has(item.id);
return (
<TableRow key={item.id}
className={cn("cursor-pointer", isAlreadyRegistered && "opacity-50", isChecked && "bg-primary/5")}
onClick={() => { if (!isAlreadyRegistered) handleToggleAddItem(item.id); }}>
<TableCell className="text-center py-1.5">
<Checkbox checked={isChecked || isAlreadyRegistered} disabled={isAlreadyRegistered} className="h-4 w-4" />
</TableCell>
{modalDisplayCols.map((col) => (
<TableCell key={col.name}
className={cn("text-xs py-1.5", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
{getCellValue(item, col.name)}
</TableCell>
))}
<TableCell className="text-center py-1.5">
{isAlreadyRegistered && (
<Badge variant="secondary" className="h-5 text-[10px]"></Badge>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
{selectedAddItems.size > 0 && (
<p className="text-xs text-muted-foreground">{selectedAddItems.size} </p>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setAddDialogOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"></Button>
<Button onClick={handleConfirmAdd} disabled={selectedAddItems.size === 0 || addLoading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
{addLoading ? "등록 중..." : `${selectedAddItems.size}개 추가`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}