504 lines
19 KiB
TypeScript
504 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { Search, Plus, Trash2, Edit, ListOrdered, Package } 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 { cn } from "@/lib/utils";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { ItemRoutingConfig, ItemRoutingComponentProps } from "./types";
|
|
import { defaultConfig } from "./config";
|
|
import { useItemRouting } from "./hooks/useItemRouting";
|
|
|
|
export function ItemRoutingComponent({
|
|
config: configProp,
|
|
isPreview,
|
|
}: ItemRoutingComponentProps) {
|
|
const { toast } = useToast();
|
|
|
|
const {
|
|
config,
|
|
items,
|
|
versions,
|
|
details,
|
|
loading,
|
|
selectedItemCode,
|
|
selectedItemName,
|
|
selectedVersionId,
|
|
fetchItems,
|
|
selectItem,
|
|
selectVersion,
|
|
refreshVersions,
|
|
refreshDetails,
|
|
deleteDetail,
|
|
deleteVersion,
|
|
} = useItemRouting(configProp || {});
|
|
|
|
const [searchText, setSearchText] = useState("");
|
|
const [deleteTarget, setDeleteTarget] = useState<{
|
|
type: "version" | "detail";
|
|
id: string;
|
|
name: string;
|
|
} | null>(null);
|
|
|
|
// 초기 로딩 (마운트 시 1회만)
|
|
const mountedRef = React.useRef(false);
|
|
useEffect(() => {
|
|
if (!mountedRef.current) {
|
|
mountedRef.current = true;
|
|
fetchItems();
|
|
}
|
|
}, [fetchItems]);
|
|
|
|
// 모달 저장 성공 감지 -> 데이터 새로고침
|
|
useEffect(() => {
|
|
const handleSaveSuccess = () => {
|
|
refreshVersions();
|
|
refreshDetails();
|
|
};
|
|
window.addEventListener("saveSuccessInModal", handleSaveSuccess);
|
|
return () => {
|
|
window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
|
|
};
|
|
}, [refreshVersions, refreshDetails]);
|
|
|
|
// 품목 검색
|
|
const handleSearch = useCallback(() => {
|
|
fetchItems(searchText || undefined);
|
|
}, [fetchItems, searchText]);
|
|
|
|
const handleSearchKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter") handleSearch();
|
|
},
|
|
[handleSearch]
|
|
);
|
|
|
|
// 버전 추가 모달
|
|
const handleAddVersion = useCallback(() => {
|
|
if (!selectedItemCode) {
|
|
toast({ title: "품목을 먼저 선택해주세요", variant: "destructive" });
|
|
return;
|
|
}
|
|
const screenId = config.modals.versionAddScreenId;
|
|
if (!screenId) return;
|
|
|
|
window.dispatchEvent(
|
|
new CustomEvent("openScreenModal", {
|
|
detail: {
|
|
screenId,
|
|
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 screenId = config.modals.processAddScreenId;
|
|
if (!screenId) return;
|
|
|
|
window.dispatchEvent(
|
|
new CustomEvent("openScreenModal", {
|
|
detail: {
|
|
screenId,
|
|
urlParams: { mode: "add", tableName: config.dataSource.routingDetailTable },
|
|
splitPanelParentData: {
|
|
[config.dataSource.routingDetailFkColumn]: selectedVersionId,
|
|
},
|
|
},
|
|
})
|
|
);
|
|
}, [selectedVersionId, config, toast]);
|
|
|
|
// 공정 수정 모달
|
|
const handleEditProcess = useCallback(
|
|
(detail: Record<string, any>) => {
|
|
const screenId = config.modals.processEditScreenId;
|
|
if (!screenId) return;
|
|
|
|
window.dispatchEvent(
|
|
new CustomEvent("openScreenModal", {
|
|
detail: {
|
|
screenId,
|
|
urlParams: { mode: "edit", tableName: config.dataSource.routingDetailTable },
|
|
editData: detail,
|
|
},
|
|
})
|
|
);
|
|
},
|
|
[config]
|
|
);
|
|
|
|
// 삭제 확인
|
|
const handleConfirmDelete = useCallback(async () => {
|
|
if (!deleteTarget) return;
|
|
|
|
let success = false;
|
|
if (deleteTarget.type === "version") {
|
|
success = await deleteVersion(deleteTarget.id);
|
|
} else {
|
|
success = await deleteDetail(deleteTarget.id);
|
|
}
|
|
|
|
if (success) {
|
|
toast({ title: `${deleteTarget.name} 삭제 완료` });
|
|
} else {
|
|
toast({ title: "삭제 실패", variant: "destructive" });
|
|
}
|
|
setDeleteTarget(null);
|
|
}, [deleteTarget, deleteVersion, deleteDetail, toast]);
|
|
|
|
// entity join으로 가져온 공정명 컬럼 이름 추정
|
|
const processNameKey = useMemo(() => {
|
|
const ds = config.dataSource;
|
|
return `${ds.processTable}_${ds.processNameColumn}`;
|
|
}, [config.dataSource]);
|
|
|
|
const splitRatio = config.splitRatio || 40;
|
|
|
|
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">
|
|
품목 선택 - 라우팅 버전 - 공정 순서
|
|
</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="border-b px-3 py-2">
|
|
<h3 className="text-sm font-semibold">
|
|
{config.leftPanelTitle || "품목 목록"}
|
|
</h3>
|
|
</div>
|
|
|
|
{/* 검색 */}
|
|
<div className="flex gap-1.5 border-b px-3 py-2">
|
|
<Input
|
|
value={searchText}
|
|
onChange={(e) => setSearchText(e.target.value)}
|
|
onKeyDown={handleSearchKeyDown}
|
|
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-y-auto">
|
|
{items.length === 0 ? (
|
|
<div className="flex h-full items-center justify-center p-4">
|
|
<p className="text-xs text-muted-foreground">
|
|
{loading ? "로딩 중..." : "품목이 없습니다"}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y">
|
|
{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 (
|
|
<button
|
|
key={item.id}
|
|
className={cn(
|
|
"flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted/50",
|
|
isSelected && "bg-primary/10 font-medium"
|
|
)}
|
|
onClick={() => selectItem(itemCode, itemName)}
|
|
>
|
|
<Package className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate font-medium">{itemName}</p>
|
|
<p className="truncate text-muted-foreground">{itemCode}</p>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</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;
|
|
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"
|
|
)}
|
|
onClick={() => selectVersion(ver.id)}
|
|
>
|
|
{ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id}
|
|
</Badge>
|
|
{!config.readonly && (
|
|
<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 cellValue = detail[col.name];
|
|
if (
|
|
col.name === "process_code" &&
|
|
detail[processNameKey]
|
|
) {
|
|
cellValue = `${detail[col.name]} (${detail[processNameKey]})`;
|
|
}
|
|
return (
|
|
<TableCell
|
|
key={col.name}
|
|
className={cn(
|
|
"text-xs",
|
|
col.align === "center" && "text-center",
|
|
col.align === "right" && "text-right"
|
|
)}
|
|
>
|
|
{cellValue ?? "-"}
|
|
</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>
|
|
</div>
|
|
);
|
|
}
|