- Introduced a new TableCombobox component for selecting tables, improving user experience by allowing table searches and selections. - Added a ColumnCombobox component to facilitate column selection based on the chosen table, enhancing the configurability of the process work standard settings. - Updated the V2ProcessWorkStandardConfigPanel to utilize the new combobox components, streamlining the configuration process for item tables and columns. - Removed the deprecated mcp.json file and updated .gitignore to reflect recent changes. These enhancements aim to improve the usability and flexibility of the configuration panel, making it easier for users to manage their process work standards.
391 lines
16 KiB
TypeScript
391 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
import { Plus, Trash2, GripVertical, 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
import { cn } from "@/lib/utils";
|
|
import { ProcessWorkStandardConfig, WorkPhaseDefinition, DetailTypeDefinition } from "./types";
|
|
import { defaultConfig } from "./config";
|
|
|
|
interface TableInfo { tableName: string; displayName?: string; }
|
|
interface ColumnInfo { columnName: string; displayName?: string; dataType?: string; }
|
|
|
|
function TableCombobox({ 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="mt-1 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="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} 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>
|
|
);
|
|
}
|
|
|
|
function ColumnCombobox({ value, onChange, tableName, placeholder }: {
|
|
value: string; onChange: (v: string) => void; tableName: string; placeholder?: 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="mt-1 h-8 w-full justify-between text-xs" disabled={loading || !tableName}>
|
|
<span className="truncate">
|
|
{loading ? "로딩..." : !tableName ? "테이블 먼저 선택" : selected ? selected.displayName || selected.columnName : placeholder || "컬럼 선택"}
|
|
</span>
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[240px] 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>
|
|
);
|
|
}
|
|
|
|
interface ConfigPanelProps {
|
|
config: Partial<ProcessWorkStandardConfig>;
|
|
onChange: (config: Partial<ProcessWorkStandardConfig>) => void;
|
|
}
|
|
|
|
export function ProcessWorkStandardConfigPanel({
|
|
config: configProp,
|
|
onChange,
|
|
}: ConfigPanelProps) {
|
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
|
const [loadingTables, setLoadingTables] = useState(false);
|
|
|
|
const config: ProcessWorkStandardConfig = {
|
|
...defaultConfig,
|
|
...configProp,
|
|
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
|
|
phases: configProp?.phases?.length ? configProp.phases : defaultConfig.phases,
|
|
detailTypes: configProp?.detailTypes?.length ? configProp.detailTypes : defaultConfig.detailTypes,
|
|
};
|
|
|
|
useEffect(() => {
|
|
const loadTables = async () => {
|
|
setLoadingTables(true);
|
|
try {
|
|
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
|
const res = await tableManagementApi.getTableList();
|
|
if (res.success && res.data) {
|
|
setTables(res.data.map((t: any) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName })));
|
|
}
|
|
} catch { /* ignore */ } finally { setLoadingTables(false); }
|
|
};
|
|
loadTables();
|
|
}, []);
|
|
|
|
const update = (partial: Partial<ProcessWorkStandardConfig>) => {
|
|
onChange({ ...configProp, ...partial });
|
|
};
|
|
|
|
const updateDataSource = (field: string, value: string) => {
|
|
update({
|
|
dataSource: { ...config.dataSource, [field]: value },
|
|
});
|
|
};
|
|
|
|
// 작업 단계 관리
|
|
const addPhase = () => {
|
|
const nextOrder = config.phases.length + 1;
|
|
update({
|
|
phases: [
|
|
...config.phases,
|
|
{ key: `PHASE_${nextOrder}`, label: `단계 ${nextOrder}`, sortOrder: nextOrder },
|
|
],
|
|
});
|
|
};
|
|
|
|
const removePhase = (idx: number) => {
|
|
update({ phases: config.phases.filter((_, i) => i !== idx) });
|
|
};
|
|
|
|
const updatePhase = (idx: number, field: keyof WorkPhaseDefinition, value: string | number) => {
|
|
const next = [...config.phases];
|
|
next[idx] = { ...next[idx], [field]: value };
|
|
update({ phases: next });
|
|
};
|
|
|
|
// 상세 유형 관리
|
|
const addDetailType = () => {
|
|
update({
|
|
detailTypes: [
|
|
...config.detailTypes,
|
|
{ value: `TYPE_${config.detailTypes.length + 1}`, label: "신규 유형" },
|
|
],
|
|
});
|
|
};
|
|
|
|
const removeDetailType = (idx: number) => {
|
|
update({ detailTypes: config.detailTypes.filter((_, i) => i !== idx) });
|
|
};
|
|
|
|
const updateDetailType = (idx: number, field: keyof DetailTypeDefinition, value: string) => {
|
|
const next = [...config.detailTypes];
|
|
next[idx] = { ...next[idx], [field]: value };
|
|
update({ detailTypes: 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>
|
|
<Select
|
|
value={config.itemListMode || "all"}
|
|
onValueChange={(v) => update({ itemListMode: v as "all" | "registered" })}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">전체 품목</SelectItem>
|
|
<SelectItem value="registered">등록 품목만</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="mt-1 text-[10px] text-muted-foreground">
|
|
{config.itemListMode === "registered"
|
|
? "품목별 라우팅 탭에서 등록한 품목만 표시됩니다. screenCode는 화면 ID 기준으로 자동 설정됩니다."
|
|
: "모든 품목을 표시합니다."}
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
{/* 데이터 소스 설정 */}
|
|
<section className="space-y-3">
|
|
<p className="text-xs font-medium text-muted-foreground">데이터 소스 설정</p>
|
|
|
|
<div>
|
|
<Label className="text-xs">품목 테이블</Label>
|
|
<TableCombobox value={config.dataSource.itemTable} onChange={(v) => updateDataSource("itemTable", v)} tables={tables} loading={loadingTables} />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label className="text-xs">품목명 컬럼</Label>
|
|
<ColumnCombobox value={config.dataSource.itemNameColumn} onChange={(v) => updateDataSource("itemNameColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목명" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">품목코드 컬럼</Label>
|
|
<ColumnCombobox value={config.dataSource.itemCodeColumn} onChange={(v) => updateDataSource("itemCodeColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목코드" />
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs">라우팅 버전 테이블</Label>
|
|
<TableCombobox value={config.dataSource.routingVersionTable} onChange={(v) => updateDataSource("routingVersionTable", v)} tables={tables} loading={loadingTables} />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">품목 연결 FK 컬럼</Label>
|
|
<ColumnCombobox value={config.dataSource.routingFkColumn} onChange={(v) => updateDataSource("routingFkColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="FK 컬럼" />
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs">공정 마스터 테이블</Label>
|
|
<TableCombobox value={config.dataSource.processTable} onChange={(v) => updateDataSource("processTable", v)} tables={tables} loading={loadingTables} />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label className="text-xs">공정명 컬럼</Label>
|
|
<ColumnCombobox value={config.dataSource.processNameColumn} onChange={(v) => updateDataSource("processNameColumn", v)} tableName={config.dataSource.processTable} placeholder="공정명" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">공정코드 컬럼</Label>
|
|
<ColumnCombobox value={config.dataSource.processCodeColumn} onChange={(v) => updateDataSource("processCodeColumn", v)} tableName={config.dataSource.processTable} placeholder="공정코드" />
|
|
</div>
|
|
</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={addPhase}>
|
|
<Plus className="h-3 w-3" />
|
|
단계 추가
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
{config.phases.map((phase, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="flex items-center gap-1.5 rounded border bg-muted/30 p-1.5"
|
|
>
|
|
<GripVertical className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
|
|
<Input
|
|
value={phase.key}
|
|
onChange={(e) => updatePhase(idx, "key", e.target.value)}
|
|
className="h-7 w-20 text-[10px]"
|
|
placeholder="키"
|
|
/>
|
|
<Input
|
|
value={phase.label}
|
|
onChange={(e) => updatePhase(idx, "label", e.target.value)}
|
|
className="h-7 flex-1 text-[10px]"
|
|
placeholder="표시명"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 shrink-0 text-destructive hover:text-destructive"
|
|
onClick={() => removePhase(idx)}
|
|
disabled={config.phases.length <= 1}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</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={addDetailType}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
유형 추가
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
{config.detailTypes.map((dt, idx) => (
|
|
<div key={idx} className="flex items-center gap-1.5 rounded border bg-muted/30 p-1.5">
|
|
<Input
|
|
value={dt.value}
|
|
onChange={(e) => updateDetailType(idx, "value", e.target.value)}
|
|
className="h-7 w-24 text-[10px]"
|
|
placeholder="값"
|
|
/>
|
|
<Input
|
|
value={dt.label}
|
|
onChange={(e) => updateDetailType(idx, "label", e.target.value)}
|
|
className="h-7 flex-1 text-[10px]"
|
|
placeholder="표시명"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 shrink-0 text-destructive hover:text-destructive"
|
|
onClick={() => removeDetailType(idx)}
|
|
disabled={config.detailTypes.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 || 30}
|
|
onChange={(e) => update({ splitRatio: Number(e.target.value) })}
|
|
min={15}
|
|
max={50}
|
|
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 className="flex items-center gap-2">
|
|
<Switch
|
|
checked={config.readonly || false}
|
|
onCheckedChange={(v) => update({ readonly: v })}
|
|
/>
|
|
<Label className="text-xs">읽기 전용 모드</Label>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|