feat: Enhance SplitPanelLayoutComponent with improved filtering and modal handling
- Updated search conditions to use an object structure with an "equals" operator for better filtering logic. - Added validation to ensure an item is selected in the left panel before opening the modal, providing user feedback through a toast notification. - Extracted foreign key data from the selected left item for improved data handling when opening the modal. - Cleaned up the code by removing unnecessary comments and consolidating logic for clarity and maintainability.
This commit is contained in:
parent
076184aad2
commit
a6f37fd3dc
|
|
@ -3,10 +3,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import * as ctrl from "../controllers/processWorkStandardController";
|
import * as ctrl from "../controllers/processWorkStandardController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
// 품목/라우팅/공정 조회 (좌측 트리)
|
// 품목/라우팅/공정 조회 (좌측 트리)
|
||||||
router.get("/items", ctrl.getItemsWithRouting);
|
router.get("/items", ctrl.getItemsWithRouting);
|
||||||
router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses);
|
router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses);
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
|
||||||
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
|
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
|
||||||
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
|
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
|
||||||
import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준
|
import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준
|
||||||
|
import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,503 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,780 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Plus, Trash2, Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ItemRoutingConfig, ProcessColumnDef } from "./types";
|
||||||
|
import { defaultConfig } from "./config";
|
||||||
|
|
||||||
|
interface TableInfo {
|
||||||
|
tableName: string;
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnInfo {
|
||||||
|
columnName: string;
|
||||||
|
displayName?: string;
|
||||||
|
dataType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScreenInfo {
|
||||||
|
screenId: number;
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블 셀렉터 Combobox
|
||||||
|
function TableSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
tables,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
tables: TableInfo[];
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const selected = tables.find((t) => t.tableName === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? "로딩 중..."
|
||||||
|
: selected
|
||||||
|
? selected.displayName || selected.tableName
|
||||||
|
: "테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[280px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-4 text-center text-xs">
|
||||||
|
테이블을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
{tables.map((t) => (
|
||||||
|
<CommandItem
|
||||||
|
key={t.tableName}
|
||||||
|
value={`${t.displayName || ""} ${t.tableName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(t.tableName);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
value === t.tableName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">
|
||||||
|
{t.displayName || t.tableName}
|
||||||
|
</span>
|
||||||
|
{t.displayName && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{t.tableName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 셀렉터 Combobox
|
||||||
|
function ColumnSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
tableName,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
tableName: string;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tableName) {
|
||||||
|
setColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { tableManagementApi } = await import(
|
||||||
|
"@/lib/api/tableManagement"
|
||||||
|
);
|
||||||
|
const res = await tableManagementApi.getColumnList(tableName);
|
||||||
|
if (res.success && res.data?.columns) {
|
||||||
|
setColumns(res.data.columns);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, [tableName]);
|
||||||
|
|
||||||
|
const selected = columns.find((c) => c.columnName === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={loading || !tableName}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? "로딩..."
|
||||||
|
: !tableName
|
||||||
|
? "테이블 먼저 선택"
|
||||||
|
: selected
|
||||||
|
? selected.displayName || selected.columnName
|
||||||
|
: label || "컬럼 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[260px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-4 text-center text-xs">
|
||||||
|
컬럼을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
{columns.map((c) => (
|
||||||
|
<CommandItem
|
||||||
|
key={c.columnName}
|
||||||
|
value={`${c.displayName || ""} ${c.columnName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(c.columnName);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
value === c.columnName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</span>
|
||||||
|
{c.displayName && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{c.columnName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 화면 셀렉터 Combobox
|
||||||
|
function ScreenSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value?: number;
|
||||||
|
onChange: (v?: number) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [screens, setScreens] = useState<ScreenInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { screenApi } = await import("@/lib/api/screen");
|
||||||
|
const res = await screenApi.getScreens({ page: 1, size: 1000 });
|
||||||
|
setScreens(
|
||||||
|
res.data.map((s: any) => ({
|
||||||
|
screenId: s.screenId,
|
||||||
|
screenName: s.screenName,
|
||||||
|
screenCode: s.screenCode,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selected = screens.find((s) => s.screenId === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? "로딩 중..."
|
||||||
|
: selected
|
||||||
|
? `${selected.screenName} (${selected.screenId})`
|
||||||
|
: "화면 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[350px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="화면 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-4 text-center text-xs">
|
||||||
|
화면을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[300px] overflow-auto">
|
||||||
|
{screens.map((s) => (
|
||||||
|
<CommandItem
|
||||||
|
key={s.screenId}
|
||||||
|
value={`${s.screenName.toLowerCase()} ${s.screenCode.toLowerCase()} ${s.screenId}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(s.screenId === value ? undefined : s.screenId);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
value === s.screenId ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{s.screenName}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{s.screenCode} (ID: {s.screenId})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공정 테이블 컬럼 셀렉터 (routingDetailTable의 컬럼 목록에서 선택)
|
||||||
|
function ProcessColumnSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
tableName,
|
||||||
|
processTable,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
tableName: string;
|
||||||
|
processTable: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAll = async () => {
|
||||||
|
if (!tableName) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { tableManagementApi } = await import(
|
||||||
|
"@/lib/api/tableManagement"
|
||||||
|
);
|
||||||
|
const res = await tableManagementApi.getColumnList(tableName);
|
||||||
|
const cols: ColumnInfo[] = [];
|
||||||
|
if (res.success && res.data?.columns) {
|
||||||
|
cols.push(...res.data.columns);
|
||||||
|
}
|
||||||
|
if (processTable && processTable !== tableName) {
|
||||||
|
const res2 = await tableManagementApi.getColumnList(processTable);
|
||||||
|
if (res2.success && res2.data?.columns) {
|
||||||
|
cols.push(
|
||||||
|
...res2.data.columns.map((c: any) => ({
|
||||||
|
...c,
|
||||||
|
columnName: c.columnName,
|
||||||
|
displayName: `[${processTable}] ${c.displayName || c.columnName}`,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setColumns(cols);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadAll();
|
||||||
|
}, [tableName, processTable]);
|
||||||
|
|
||||||
|
const selected = columns.find((c) => c.columnName === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="h-7 w-24 justify-between text-[10px]"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{selected ? selected.displayName || selected.columnName : value || "선택"}
|
||||||
|
<ChevronsUpDown className="ml-1 h-2.5 w-2.5 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[250px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-3 text-center text-xs">
|
||||||
|
없음
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
{columns.map((c) => (
|
||||||
|
<CommandItem
|
||||||
|
key={c.columnName}
|
||||||
|
value={`${c.displayName || ""} ${c.columnName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(c.columnName);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-1 h-3 w-3",
|
||||||
|
value === c.columnName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigPanelProps {
|
||||||
|
config: Partial<ItemRoutingConfig>;
|
||||||
|
onChange: (config: Partial<ItemRoutingConfig>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemRoutingConfigPanel({
|
||||||
|
config: configProp,
|
||||||
|
onChange,
|
||||||
|
}: ConfigPanelProps) {
|
||||||
|
const config: ItemRoutingConfig = {
|
||||||
|
...defaultConfig,
|
||||||
|
...configProp,
|
||||||
|
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
|
||||||
|
modals: { ...defaultConfig.modals, ...configProp?.modals },
|
||||||
|
processColumns: configProp?.processColumns?.length
|
||||||
|
? configProp.processColumns
|
||||||
|
: defaultConfig.processColumns,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [allTables, setAllTables] = useState<TableInfo[]>([]);
|
||||||
|
const [tablesLoading, setTablesLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
setTablesLoading(true);
|
||||||
|
try {
|
||||||
|
const { tableManagementApi } = await import(
|
||||||
|
"@/lib/api/tableManagement"
|
||||||
|
);
|
||||||
|
const res = await tableManagementApi.getTableList();
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setAllTables(res.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setTablesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const update = (partial: Partial<ItemRoutingConfig>) => {
|
||||||
|
onChange({ ...configProp, ...partial });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDataSource = (field: string, value: string) => {
|
||||||
|
update({ dataSource: { ...config.dataSource, [field]: value } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateModals = (field: string, value: number | undefined) => {
|
||||||
|
update({ modals: { ...config.modals, [field]: value } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컬럼 관리
|
||||||
|
const addColumn = () => {
|
||||||
|
update({
|
||||||
|
processColumns: [
|
||||||
|
...config.processColumns,
|
||||||
|
{ name: "", label: "새 컬럼", width: 100 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeColumn = (idx: number) => {
|
||||||
|
update({
|
||||||
|
processColumns: config.processColumns.filter((_, i) => i !== idx),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateColumn = (
|
||||||
|
idx: number,
|
||||||
|
field: keyof ProcessColumnDef,
|
||||||
|
value: any
|
||||||
|
) => {
|
||||||
|
const next = [...config.processColumns];
|
||||||
|
next[idx] = { ...next[idx], [field]: value };
|
||||||
|
update({ processColumns: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 p-4">
|
||||||
|
<h3 className="text-sm font-semibold">품목별 라우팅 설정</h3>
|
||||||
|
|
||||||
|
{/* 데이터 소스 설정 */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
데이터 소스
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">품목 테이블</Label>
|
||||||
|
<TableSelector
|
||||||
|
value={config.dataSource.itemTable}
|
||||||
|
onChange={(v) => updateDataSource("itemTable", v)}
|
||||||
|
tables={allTables}
|
||||||
|
loading={tablesLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">품목명 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.itemNameColumn}
|
||||||
|
onChange={(v) => updateDataSource("itemNameColumn", v)}
|
||||||
|
tableName={config.dataSource.itemTable}
|
||||||
|
label="품목명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">품목코드 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.itemCodeColumn}
|
||||||
|
onChange={(v) => updateDataSource("itemCodeColumn", v)}
|
||||||
|
tableName={config.dataSource.itemTable}
|
||||||
|
label="품목코드"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">라우팅 버전 테이블</Label>
|
||||||
|
<TableSelector
|
||||||
|
value={config.dataSource.routingVersionTable}
|
||||||
|
onChange={(v) => updateDataSource("routingVersionTable", v)}
|
||||||
|
tables={allTables}
|
||||||
|
loading={tablesLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">버전 FK 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.routingVersionFkColumn}
|
||||||
|
onChange={(v) => updateDataSource("routingVersionFkColumn", v)}
|
||||||
|
tableName={config.dataSource.routingVersionTable}
|
||||||
|
label="FK 컬럼"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">버전명 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.routingVersionNameColumn}
|
||||||
|
onChange={(v) =>
|
||||||
|
updateDataSource("routingVersionNameColumn", v)
|
||||||
|
}
|
||||||
|
tableName={config.dataSource.routingVersionTable}
|
||||||
|
label="버전명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정 상세 테이블</Label>
|
||||||
|
<TableSelector
|
||||||
|
value={config.dataSource.routingDetailTable}
|
||||||
|
onChange={(v) => updateDataSource("routingDetailTable", v)}
|
||||||
|
tables={allTables}
|
||||||
|
loading={tablesLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정 상세 FK 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.routingDetailFkColumn}
|
||||||
|
onChange={(v) => updateDataSource("routingDetailFkColumn", v)}
|
||||||
|
tableName={config.dataSource.routingDetailTable}
|
||||||
|
label="FK 컬럼"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정 마스터 테이블</Label>
|
||||||
|
<TableSelector
|
||||||
|
value={config.dataSource.processTable}
|
||||||
|
onChange={(v) => updateDataSource("processTable", v)}
|
||||||
|
tables={allTables}
|
||||||
|
loading={tablesLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정명 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.processNameColumn}
|
||||||
|
onChange={(v) => updateDataSource("processNameColumn", v)}
|
||||||
|
tableName={config.dataSource.processTable}
|
||||||
|
label="공정명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정코드 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.processCodeColumn}
|
||||||
|
onChange={(v) => updateDataSource("processCodeColumn", v)}
|
||||||
|
tableName={config.dataSource.processTable}
|
||||||
|
label="공정코드"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 모달 설정 */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">모달 연동</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">버전 추가 모달</Label>
|
||||||
|
<ScreenSelector
|
||||||
|
value={config.modals.versionAddScreenId}
|
||||||
|
onChange={(v) => updateModals("versionAddScreenId", v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정 추가 모달</Label>
|
||||||
|
<ScreenSelector
|
||||||
|
value={config.modals.processAddScreenId}
|
||||||
|
onChange={(v) => updateModals("processAddScreenId", v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정 수정 모달</Label>
|
||||||
|
<ScreenSelector
|
||||||
|
value={config.modals.processEditScreenId}
|
||||||
|
onChange={(v) => updateModals("processEditScreenId", v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 공정 테이블 컬럼 설정 */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
공정 테이블 컬럼
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 gap-1 text-[10px]"
|
||||||
|
onClick={addColumn}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
컬럼 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{config.processColumns.map((col, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex items-center gap-1.5 rounded border bg-muted/30 p-1.5"
|
||||||
|
>
|
||||||
|
<ProcessColumnSelector
|
||||||
|
value={col.name}
|
||||||
|
onChange={(v) => updateColumn(idx, "name", v)}
|
||||||
|
tableName={config.dataSource.routingDetailTable}
|
||||||
|
processTable={config.dataSource.processTable}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={col.label}
|
||||||
|
onChange={(e) => updateColumn(idx, "label", e.target.value)}
|
||||||
|
className="h-7 flex-1 text-[10px]"
|
||||||
|
placeholder="표시명"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={col.width || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateColumn(
|
||||||
|
idx,
|
||||||
|
"width",
|
||||||
|
e.target.value ? Number(e.target.value) : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-7 w-14 text-[10px]"
|
||||||
|
placeholder="너비"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => removeColumn(idx)}
|
||||||
|
disabled={config.processColumns.length <= 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* UI 설정 */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">UI 설정</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">좌우 분할 비율 (%)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.splitRatio || 40}
|
||||||
|
onChange={(e) => update({ splitRatio: Number(e.target.value) })}
|
||||||
|
min={20}
|
||||||
|
max={60}
|
||||||
|
className="mt-1 h-8 w-20 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">좌측 패널 제목</Label>
|
||||||
|
<Input
|
||||||
|
value={config.leftPanelTitle || ""}
|
||||||
|
onChange={(e) => update({ leftPanelTitle: e.target.value })}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">우측 패널 제목</Label>
|
||||||
|
<Input
|
||||||
|
value={config.rightPanelTitle || ""}
|
||||||
|
onChange={(e) => update({ rightPanelTitle: e.target.value })}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">버전 추가 버튼 텍스트</Label>
|
||||||
|
<Input
|
||||||
|
value={config.versionAddButtonText || ""}
|
||||||
|
onChange={(e) => update({ versionAddButtonText: e.target.value })}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정 추가 버튼 텍스트</Label>
|
||||||
|
<Input
|
||||||
|
value={config.processAddButtonText || ""}
|
||||||
|
onChange={(e) => update({ processAddButtonText: e.target.value })}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={config.autoSelectFirstVersion ?? true}
|
||||||
|
onCheckedChange={(v) => update({ autoSelectFirstVersion: v })}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs">첫 번째 버전 자동 선택</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={config.readonly || false}
|
||||||
|
onCheckedChange={(v) => update({ readonly: v })}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs">읽기 전용 모드</Label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||||
|
import { V2ItemRoutingDefinition } from "./index";
|
||||||
|
import { ItemRoutingComponent } from "./ItemRoutingComponent";
|
||||||
|
|
||||||
|
export class ItemRoutingRenderer extends AutoRegisteringComponentRenderer {
|
||||||
|
static componentDefinition = V2ItemRoutingDefinition;
|
||||||
|
|
||||||
|
render(): React.ReactElement {
|
||||||
|
const { formData, isPreview, config, tableName } = this.props as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ItemRoutingComponent
|
||||||
|
config={(config as object) || {}}
|
||||||
|
formData={formData as Record<string, unknown>}
|
||||||
|
tableName={tableName as string}
|
||||||
|
isPreview={isPreview as boolean}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ItemRoutingRenderer.registerSelf();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
ItemRoutingRenderer.enableHotReload();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { ItemRoutingConfig } from "./types";
|
||||||
|
|
||||||
|
export const defaultConfig: ItemRoutingConfig = {
|
||||||
|
dataSource: {
|
||||||
|
itemTable: "item_info",
|
||||||
|
itemNameColumn: "item_name",
|
||||||
|
itemCodeColumn: "item_number",
|
||||||
|
routingVersionTable: "item_routing_version",
|
||||||
|
routingVersionFkColumn: "item_code",
|
||||||
|
routingVersionNameColumn: "version_name",
|
||||||
|
routingDetailTable: "item_routing_detail",
|
||||||
|
routingDetailFkColumn: "routing_version_id",
|
||||||
|
processTable: "process_mng",
|
||||||
|
processNameColumn: "process_name",
|
||||||
|
processCodeColumn: "process_code",
|
||||||
|
},
|
||||||
|
modals: {
|
||||||
|
versionAddScreenId: 1613,
|
||||||
|
processAddScreenId: 1614,
|
||||||
|
processEditScreenId: 1615,
|
||||||
|
},
|
||||||
|
processColumns: [
|
||||||
|
{ name: "seq_no", label: "순서", width: 60, align: "center" },
|
||||||
|
{ name: "process_code", label: "공정코드", width: 120 },
|
||||||
|
{ name: "work_type", label: "작업유형", width: 100 },
|
||||||
|
{ name: "standard_time", label: "표준시간(분)", width: 100, align: "right" },
|
||||||
|
{ name: "is_required", label: "필수여부", width: 80, align: "center" },
|
||||||
|
{ name: "is_fixed_order", label: "순서고정", width: 80, align: "center" },
|
||||||
|
{ name: "outsource_supplier", label: "외주업체", width: 120 },
|
||||||
|
],
|
||||||
|
splitRatio: 40,
|
||||||
|
leftPanelTitle: "품목 목록",
|
||||||
|
rightPanelTitle: "공정 순서",
|
||||||
|
readonly: false,
|
||||||
|
autoSelectFirstVersion: true,
|
||||||
|
versionAddButtonText: "+ 라우팅 버전 추가",
|
||||||
|
processAddButtonText: "+ 공정 추가",
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,239 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { ItemRoutingConfig, ItemData, RoutingVersionData, RoutingDetailData } from "../types";
|
||||||
|
import { defaultConfig } from "../config";
|
||||||
|
|
||||||
|
const API_BASE = "/process-work-standard";
|
||||||
|
|
||||||
|
export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||||
|
const configKey = useMemo(
|
||||||
|
() => JSON.stringify(configPartial),
|
||||||
|
[configPartial]
|
||||||
|
);
|
||||||
|
|
||||||
|
const config: ItemRoutingConfig = useMemo(() => ({
|
||||||
|
...defaultConfig,
|
||||||
|
...configPartial,
|
||||||
|
dataSource: { ...defaultConfig.dataSource, ...configPartial?.dataSource },
|
||||||
|
modals: { ...defaultConfig.modals, ...configPartial?.modals },
|
||||||
|
processColumns: configPartial?.processColumns?.length
|
||||||
|
? configPartial.processColumns
|
||||||
|
: defaultConfig.processColumns,
|
||||||
|
}), [configKey]);
|
||||||
|
|
||||||
|
const configRef = useRef(config);
|
||||||
|
configRef.current = config;
|
||||||
|
|
||||||
|
const [items, setItems] = useState<ItemData[]>([]);
|
||||||
|
const [versions, setVersions] = useState<RoutingVersionData[]>([]);
|
||||||
|
const [details, setDetails] = useState<RoutingDetailData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 선택 상태
|
||||||
|
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
|
||||||
|
const [selectedItemName, setSelectedItemName] = useState<string | null>(null);
|
||||||
|
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 품목 목록 조회
|
||||||
|
const fetchItems = useCallback(
|
||||||
|
async (search?: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
tableName: ds.itemTable,
|
||||||
|
nameColumn: ds.itemNameColumn,
|
||||||
|
codeColumn: ds.itemCodeColumn,
|
||||||
|
routingTable: ds.routingVersionTable,
|
||||||
|
routingFkColumn: ds.routingVersionFkColumn,
|
||||||
|
...(search ? { search } : {}),
|
||||||
|
});
|
||||||
|
const res = await apiClient.get(`${API_BASE}/items?${params}`);
|
||||||
|
if (res.data?.success) {
|
||||||
|
setItems(res.data.data || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("품목 조회 실패", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[configKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 라우팅 버전 목록 조회
|
||||||
|
const fetchVersions = useCallback(
|
||||||
|
async (itemCode: string) => {
|
||||||
|
try {
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
routingVersionTable: ds.routingVersionTable,
|
||||||
|
routingDetailTable: ds.routingDetailTable,
|
||||||
|
routingFkColumn: ds.routingVersionFkColumn,
|
||||||
|
processTable: ds.processTable,
|
||||||
|
processNameColumn: ds.processNameColumn,
|
||||||
|
processCodeColumn: ds.processCodeColumn,
|
||||||
|
});
|
||||||
|
const res = await apiClient.get(
|
||||||
|
`${API_BASE}/items/${encodeURIComponent(itemCode)}/routings?${params}`
|
||||||
|
);
|
||||||
|
if (res.data?.success) {
|
||||||
|
const routingData = res.data.data || [];
|
||||||
|
setVersions(routingData);
|
||||||
|
return routingData;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("라우팅 버전 조회 실패", err);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[configKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 공정 상세 목록 조회 (특정 버전의 공정들)
|
||||||
|
const fetchDetails = useCallback(
|
||||||
|
async (versionId: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const res = await apiClient.get("/table-data/entity-join-api/data-with-joins", {
|
||||||
|
params: {
|
||||||
|
tableName: ds.routingDetailTable,
|
||||||
|
searchConditions: JSON.stringify({
|
||||||
|
[ds.routingDetailFkColumn]: {
|
||||||
|
value: versionId,
|
||||||
|
operator: "equals",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sortColumn: "seq_no",
|
||||||
|
sortDirection: "ASC",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.data?.success) {
|
||||||
|
setDetails(res.data.data || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("공정 상세 조회 실패", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[configKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 품목 선택
|
||||||
|
const selectItem = useCallback(
|
||||||
|
async (itemCode: string, itemName: string) => {
|
||||||
|
setSelectedItemCode(itemCode);
|
||||||
|
setSelectedItemName(itemName);
|
||||||
|
setSelectedVersionId(null);
|
||||||
|
setDetails([]);
|
||||||
|
|
||||||
|
const versionList = await fetchVersions(itemCode);
|
||||||
|
|
||||||
|
// 첫번째 버전 자동 선택
|
||||||
|
if (config.autoSelectFirstVersion && versionList.length > 0) {
|
||||||
|
const firstVersion = versionList[0];
|
||||||
|
setSelectedVersionId(firstVersion.id);
|
||||||
|
await fetchDetails(firstVersion.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchVersions, fetchDetails, config.autoSelectFirstVersion]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 버전 선택
|
||||||
|
const selectVersion = useCallback(
|
||||||
|
async (versionId: string) => {
|
||||||
|
setSelectedVersionId(versionId);
|
||||||
|
await fetchDetails(versionId);
|
||||||
|
},
|
||||||
|
[fetchDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모달에서 데이터 변경 후 새로고침
|
||||||
|
const refreshVersions = useCallback(async () => {
|
||||||
|
if (selectedItemCode) {
|
||||||
|
const versionList = await fetchVersions(selectedItemCode);
|
||||||
|
if (selectedVersionId) {
|
||||||
|
await fetchDetails(selectedVersionId);
|
||||||
|
} else if (versionList.length > 0) {
|
||||||
|
const lastVersion = versionList[versionList.length - 1];
|
||||||
|
setSelectedVersionId(lastVersion.id);
|
||||||
|
await fetchDetails(lastVersion.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedItemCode, selectedVersionId, fetchVersions, fetchDetails]);
|
||||||
|
|
||||||
|
const refreshDetails = useCallback(async () => {
|
||||||
|
if (selectedVersionId) {
|
||||||
|
await fetchDetails(selectedVersionId);
|
||||||
|
}
|
||||||
|
}, [selectedVersionId, fetchDetails]);
|
||||||
|
|
||||||
|
// 공정 삭제
|
||||||
|
const deleteDetail = useCallback(
|
||||||
|
async (detailId: string) => {
|
||||||
|
try {
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const res = await apiClient.delete(
|
||||||
|
`/table-data/${ds.routingDetailTable}/${detailId}`
|
||||||
|
);
|
||||||
|
if (res.data?.success) {
|
||||||
|
await refreshDetails();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("공정 삭제 실패", err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[refreshDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 버전 삭제
|
||||||
|
const deleteVersion = useCallback(
|
||||||
|
async (versionId: string) => {
|
||||||
|
try {
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const res = await apiClient.delete(
|
||||||
|
`/table-data/${ds.routingVersionTable}/${versionId}`
|
||||||
|
);
|
||||||
|
if (res.data?.success) {
|
||||||
|
if (selectedVersionId === versionId) {
|
||||||
|
setSelectedVersionId(null);
|
||||||
|
setDetails([]);
|
||||||
|
}
|
||||||
|
await refreshVersions();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("버전 삭제 실패", err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[selectedVersionId, refreshVersions]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
items,
|
||||||
|
versions,
|
||||||
|
details,
|
||||||
|
loading,
|
||||||
|
selectedItemCode,
|
||||||
|
selectedItemName,
|
||||||
|
selectedVersionId,
|
||||||
|
fetchItems,
|
||||||
|
selectItem,
|
||||||
|
selectVersion,
|
||||||
|
refreshVersions,
|
||||||
|
refreshDetails,
|
||||||
|
deleteDetail,
|
||||||
|
deleteVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
|
import { ComponentCategory } from "@/types/component";
|
||||||
|
import { ItemRoutingComponent } from "./ItemRoutingComponent";
|
||||||
|
import { ItemRoutingConfigPanel } from "./ItemRoutingConfigPanel";
|
||||||
|
import { defaultConfig } from "./config";
|
||||||
|
|
||||||
|
export const V2ItemRoutingDefinition = createComponentDefinition({
|
||||||
|
id: "v2-item-routing",
|
||||||
|
name: "품목별 라우팅",
|
||||||
|
nameEng: "Item Routing",
|
||||||
|
description: "품목별 라우팅 버전과 공정 순서를 관리하는 3단계 계층 컴포넌트",
|
||||||
|
category: ComponentCategory.INPUT,
|
||||||
|
webType: "component",
|
||||||
|
component: ItemRoutingComponent,
|
||||||
|
defaultConfig: defaultConfig,
|
||||||
|
defaultSize: {
|
||||||
|
width: 1400,
|
||||||
|
height: 800,
|
||||||
|
gridColumnSpan: "12",
|
||||||
|
},
|
||||||
|
configPanel: ItemRoutingConfigPanel,
|
||||||
|
icon: "ListOrdered",
|
||||||
|
tags: ["라우팅", "공정", "품목", "버전", "제조", "생산"],
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "개발팀",
|
||||||
|
documentation: `
|
||||||
|
품목별 라우팅 버전과 공정 순서를 관리하는 전용 컴포넌트입니다.
|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
- 좌측: 품목 목록 검색 및 선택
|
||||||
|
- 우측 상단: 라우팅 버전 선택 (Badge 버튼) 및 추가/삭제
|
||||||
|
- 우측 하단: 선택된 버전의 공정 순서 테이블 (추가/수정/삭제)
|
||||||
|
- 기존 모달 화면 재활용 (1613, 1614, 1615)
|
||||||
|
|
||||||
|
## 커스터마이징
|
||||||
|
- 데이터 소스 테이블/컬럼 변경 가능
|
||||||
|
- 모달 화면 ID 변경 가능
|
||||||
|
- 공정 테이블 컬럼 추가/삭제 가능
|
||||||
|
- 좌우 분할 비율, 패널 제목, 버튼 텍스트 변경 가능
|
||||||
|
- 읽기 전용 모드 지원
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ItemRoutingConfig,
|
||||||
|
ItemRoutingComponentProps,
|
||||||
|
ItemRoutingDataSource,
|
||||||
|
ItemRoutingModals,
|
||||||
|
ProcessColumnDef,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export { ItemRoutingComponent } from "./ItemRoutingComponent";
|
||||||
|
export { ItemRoutingRenderer } from "./ItemRoutingRenderer";
|
||||||
|
export { ItemRoutingConfigPanel } from "./ItemRoutingConfigPanel";
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
/**
|
||||||
|
* 품목별 라우팅 관리 컴포넌트 타입 정의
|
||||||
|
*
|
||||||
|
* 3단계 계층: item_info → item_routing_version → item_routing_detail
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 데이터 소스 설정
|
||||||
|
export interface ItemRoutingDataSource {
|
||||||
|
itemTable: string;
|
||||||
|
itemNameColumn: string;
|
||||||
|
itemCodeColumn: string;
|
||||||
|
routingVersionTable: string;
|
||||||
|
routingVersionFkColumn: string; // item_routing_version에서 item_code를 가리키는 FK
|
||||||
|
routingVersionNameColumn: string;
|
||||||
|
routingDetailTable: string;
|
||||||
|
routingDetailFkColumn: string; // item_routing_detail에서 routing_version_id를 가리키는 FK
|
||||||
|
processTable: string;
|
||||||
|
processNameColumn: string;
|
||||||
|
processCodeColumn: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모달 연동 설정
|
||||||
|
export interface ItemRoutingModals {
|
||||||
|
versionAddScreenId?: number;
|
||||||
|
processAddScreenId?: number;
|
||||||
|
processEditScreenId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공정 테이블 컬럼 정의
|
||||||
|
export interface ProcessColumnDef {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
width?: number;
|
||||||
|
align?: "left" | "center" | "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전체 Config
|
||||||
|
export interface ItemRoutingConfig {
|
||||||
|
dataSource: ItemRoutingDataSource;
|
||||||
|
modals: ItemRoutingModals;
|
||||||
|
processColumns: ProcessColumnDef[];
|
||||||
|
splitRatio?: number;
|
||||||
|
leftPanelTitle?: string;
|
||||||
|
rightPanelTitle?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
autoSelectFirstVersion?: boolean;
|
||||||
|
versionAddButtonText?: string;
|
||||||
|
processAddButtonText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 Props
|
||||||
|
export interface ItemRoutingComponentProps {
|
||||||
|
config: Partial<ItemRoutingConfig>;
|
||||||
|
formData?: Record<string, any>;
|
||||||
|
isPreview?: boolean;
|
||||||
|
tableName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 모델
|
||||||
|
export interface ItemData {
|
||||||
|
id: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingVersionData {
|
||||||
|
id: string;
|
||||||
|
version_name: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingDetailData {
|
||||||
|
id: string;
|
||||||
|
routing_version_id: string;
|
||||||
|
seq_no: string;
|
||||||
|
process_code: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
@ -1241,7 +1241,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const searchConditions: Record<string, any> = {};
|
const searchConditions: Record<string, any> = {};
|
||||||
keys?.forEach((key: any) => {
|
keys?.forEach((key: any) => {
|
||||||
if (key.leftColumn && key.rightColumn && originalItem[key.leftColumn] !== undefined) {
|
if (key.leftColumn && key.rightColumn && originalItem[key.leftColumn] !== undefined) {
|
||||||
searchConditions[key.rightColumn] = originalItem[key.leftColumn];
|
searchConditions[key.rightColumn] = { value: originalItem[key.leftColumn], operator: "equals" };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1271,11 +1271,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// 복합키: 여러 조건으로 필터링
|
// 복합키: 여러 조건으로 필터링
|
||||||
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
|
|
||||||
// 복합키 조건 생성
|
// 복합키 조건 생성 (FK 필터링이므로 equals 연산자 사용)
|
||||||
const searchConditions: Record<string, any> = {};
|
const searchConditions: Record<string, any> = {};
|
||||||
keys.forEach((key) => {
|
keys.forEach((key) => {
|
||||||
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
|
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
|
||||||
searchConditions[key.rightColumn] = leftItem[key.leftColumn];
|
searchConditions[key.rightColumn] = { value: leftItem[key.leftColumn], operator: "equals" };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2035,20 +2035,47 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.addButton;
|
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.addButton;
|
||||||
|
|
||||||
if (addButtonConfig?.mode === "modal" && addButtonConfig?.modalScreenId) {
|
if (addButtonConfig?.mode === "modal" && addButtonConfig?.modalScreenId) {
|
||||||
// 커스텀 모달 화면 열기
|
if (!selectedLeftItem) {
|
||||||
|
toast({
|
||||||
|
title: "항목을 선택해주세요",
|
||||||
|
description: "좌측 패널에서 항목을 먼저 선택한 후 추가해주세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const currentTableName =
|
const currentTableName =
|
||||||
activeTabIndex === 0
|
activeTabIndex === 0
|
||||||
? componentConfig.rightPanel?.tableName || ""
|
? componentConfig.rightPanel?.tableName || ""
|
||||||
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.tableName || "";
|
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.tableName || "";
|
||||||
|
|
||||||
// 좌측 선택 데이터를 modalDataStore에 저장 (추가 화면에서 참조 가능)
|
// 좌측 선택 데이터를 modalDataStore에 저장
|
||||||
if (selectedLeftItem && componentConfig.leftPanel?.tableName) {
|
if (selectedLeftItem && componentConfig.leftPanel?.tableName) {
|
||||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||||
useModalDataStore.getState().setData(componentConfig.leftPanel!.tableName!, [selectedLeftItem]);
|
useModalDataStore.getState().setData(componentConfig.leftPanel!.tableName!, [selectedLeftItem]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScreenModal 열기 이벤트 발생
|
// relation.keys에서 FK 데이터 추출
|
||||||
|
const parentData: Record<string, any> = {};
|
||||||
|
const relation = activeTabIndex === 0
|
||||||
|
? componentConfig.rightPanel?.relation
|
||||||
|
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.relation;
|
||||||
|
|
||||||
|
if (relation?.keys && Array.isArray(relation.keys)) {
|
||||||
|
for (const key of relation.keys) {
|
||||||
|
if (key.leftColumn && key.rightColumn && selectedLeftItem[key.leftColumn] != null) {
|
||||||
|
parentData[key.rightColumn] = selectedLeftItem[key.leftColumn];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (relation) {
|
||||||
|
const leftColumn = relation.leftColumn;
|
||||||
|
const rightColumn = relation.foreignKey || relation.rightColumn;
|
||||||
|
if (leftColumn && rightColumn && selectedLeftItem[leftColumn] != null) {
|
||||||
|
parentData[rightColumn] = selectedLeftItem[leftColumn];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("openScreenModal", {
|
new CustomEvent("openScreenModal", {
|
||||||
detail: {
|
detail: {
|
||||||
|
|
@ -2056,19 +2083,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
urlParams: {
|
urlParams: {
|
||||||
mode: "add",
|
mode: "add",
|
||||||
tableName: currentTableName,
|
tableName: currentTableName,
|
||||||
// 좌측 선택 항목의 연결 키 값 전달
|
|
||||||
...(selectedLeftItem && (() => {
|
|
||||||
const relation = activeTabIndex === 0
|
|
||||||
? componentConfig.rightPanel?.relation
|
|
||||||
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.relation;
|
|
||||||
const leftColumn = relation?.keys?.[0]?.leftColumn || relation?.leftColumn;
|
|
||||||
const rightColumn = relation?.keys?.[0]?.rightColumn || relation?.foreignKey;
|
|
||||||
if (leftColumn && rightColumn && selectedLeftItem[leftColumn] !== undefined) {
|
|
||||||
return { [rightColumn]: selectedLeftItem[leftColumn] };
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
})()),
|
|
||||||
},
|
},
|
||||||
|
splitPanelParentData: parentData,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -2076,6 +2092,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
console.log("✅ [SplitPanel] 추가 모달 화면 열기:", {
|
console.log("✅ [SplitPanel] 추가 모달 화면 열기:", {
|
||||||
screenId: addButtonConfig.modalScreenId,
|
screenId: addButtonConfig.modalScreenId,
|
||||||
tableName: currentTableName,
|
tableName: currentTableName,
|
||||||
|
parentData,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue