- Enhanced the `createMoldSerial` function to automatically generate serial numbers based on defined numbering rules when the serial number is not provided. - Integrated error handling for the automatic numbering process, ensuring robust logging for success and failure cases. - Updated the `NumberingRuleService` to support reference column handling, allowing for dynamic prefix generation based on related data. - Modified the frontend components to accommodate new reference configurations, improving user experience in managing numbering rules. These changes significantly enhance the mold management functionality by automating serial number generation and improving the flexibility of numbering rules.
509 lines
17 KiB
TypeScript
509 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useState } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Button } from "@/components/ui/button";
|
|
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 { StatusCountConfig, StatusCountItem, STATUS_COLOR_MAP } from "./types";
|
|
import { tableTypeApi } from "@/lib/api/screen";
|
|
import { entityJoinApi, EntityJoinConfig } from "@/lib/api/entityJoin";
|
|
import { Plus, Trash2, Loader2, Check, ChevronsUpDown } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export interface StatusCountConfigPanelProps {
|
|
config: StatusCountConfig;
|
|
onChange: (config: Partial<StatusCountConfig>) => void;
|
|
}
|
|
|
|
const COLOR_OPTIONS = Object.keys(STATUS_COLOR_MAP);
|
|
|
|
interface SearchableComboboxProps {
|
|
value: string;
|
|
onSelect: (value: string) => void;
|
|
items: Array<{ value: string; label: string; sublabel?: string }>;
|
|
placeholder: string;
|
|
searchPlaceholder: string;
|
|
emptyText: string;
|
|
disabled?: boolean;
|
|
loading?: boolean;
|
|
}
|
|
|
|
const SearchableCombobox: React.FC<SearchableComboboxProps> = ({
|
|
value,
|
|
onSelect,
|
|
items,
|
|
placeholder,
|
|
searchPlaceholder,
|
|
emptyText,
|
|
disabled,
|
|
loading,
|
|
}) => {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex h-8 items-center gap-1 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3 w-3 animate-spin" /> 로딩중...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const selectedItem = items.find((item) => item.value === value);
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
disabled={disabled}
|
|
className="h-8 w-full justify-between text-xs font-normal"
|
|
>
|
|
<span className="truncate">
|
|
{selectedItem
|
|
? selectedItem.sublabel
|
|
? `${selectedItem.label} (${selectedItem.sublabel})`
|
|
: selectedItem.label
|
|
: placeholder}
|
|
</span>
|
|
<ChevronsUpDown className="ml-1 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={searchPlaceholder} className="text-xs" />
|
|
<CommandList>
|
|
<CommandEmpty className="py-3 text-center text-xs">
|
|
{emptyText}
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{items.map((item) => (
|
|
<CommandItem
|
|
key={item.value}
|
|
value={`${item.label} ${item.sublabel || ""} ${item.value}`}
|
|
onSelect={() => {
|
|
onSelect(item.value === value ? "" : item.value);
|
|
setOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
value === item.value ? "opacity-100" : "opacity-0"
|
|
)}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span>{item.label}</span>
|
|
{item.sublabel && (
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{item.sublabel}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
};
|
|
|
|
export const StatusCountConfigPanel: React.FC<StatusCountConfigPanelProps> = ({
|
|
config,
|
|
onChange,
|
|
}) => {
|
|
const items = config.items || [];
|
|
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
|
const [columns, setColumns] = useState<Array<{ columnName: string; columnLabel: string }>>([]);
|
|
const [entityJoins, setEntityJoins] = useState<EntityJoinConfig[]>([]);
|
|
const [loadingTables, setLoadingTables] = useState(false);
|
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
|
const [loadingJoins, setLoadingJoins] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const loadTables = async () => {
|
|
setLoadingTables(true);
|
|
try {
|
|
const result = await tableTypeApi.getTables();
|
|
setTables(
|
|
(result || []).map((t: any) => ({
|
|
tableName: t.tableName || t.table_name,
|
|
displayName: t.displayName || t.tableName || t.table_name,
|
|
}))
|
|
);
|
|
} catch (err) {
|
|
console.error("테이블 목록 로드 실패:", err);
|
|
} finally {
|
|
setLoadingTables(false);
|
|
}
|
|
};
|
|
loadTables();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!config.tableName) {
|
|
setColumns([]);
|
|
setEntityJoins([]);
|
|
return;
|
|
}
|
|
|
|
const loadColumns = async () => {
|
|
setLoadingColumns(true);
|
|
try {
|
|
const result = await tableTypeApi.getColumns(config.tableName);
|
|
setColumns(
|
|
(result || []).map((c: any) => ({
|
|
columnName: c.columnName || c.column_name,
|
|
columnLabel: c.columnLabel || c.column_label || c.displayName || c.columnName || c.column_name,
|
|
}))
|
|
);
|
|
} catch (err) {
|
|
console.error("컬럼 목록 로드 실패:", err);
|
|
} finally {
|
|
setLoadingColumns(false);
|
|
}
|
|
};
|
|
|
|
const loadEntityJoins = async () => {
|
|
setLoadingJoins(true);
|
|
try {
|
|
const result = await entityJoinApi.getEntityJoinConfigs(config.tableName);
|
|
setEntityJoins(result?.joinConfigs || []);
|
|
} catch (err) {
|
|
console.error("엔티티 조인 설정 로드 실패:", err);
|
|
setEntityJoins([]);
|
|
} finally {
|
|
setLoadingJoins(false);
|
|
}
|
|
};
|
|
|
|
loadColumns();
|
|
loadEntityJoins();
|
|
}, [config.tableName]);
|
|
|
|
const handleChange = (key: keyof StatusCountConfig, value: any) => {
|
|
onChange({ [key]: value });
|
|
};
|
|
|
|
const handleItemChange = (index: number, key: keyof StatusCountItem, value: string) => {
|
|
const newItems = [...items];
|
|
newItems[index] = { ...newItems[index], [key]: value };
|
|
handleChange("items", newItems);
|
|
};
|
|
|
|
const addItem = () => {
|
|
handleChange("items", [
|
|
...items,
|
|
{ value: "", label: "새 상태", color: "gray" },
|
|
]);
|
|
};
|
|
|
|
const removeItem = (index: number) => {
|
|
handleChange(
|
|
"items",
|
|
items.filter((_: StatusCountItem, i: number) => i !== index)
|
|
);
|
|
};
|
|
|
|
// 상태 컬럼의 카테고리 값 로드
|
|
const [statusCategoryValues, setStatusCategoryValues] = useState<Array<{ value: string; label: string }>>([]);
|
|
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!config.tableName || !config.statusColumn) {
|
|
setStatusCategoryValues([]);
|
|
return;
|
|
}
|
|
|
|
const loadCategoryValues = async () => {
|
|
setLoadingCategoryValues(true);
|
|
try {
|
|
const { apiClient } = await import("@/lib/api/client");
|
|
const response = await apiClient.get(
|
|
`/table-categories/${config.tableName}/${config.statusColumn}/values`
|
|
);
|
|
if (response.data?.success && response.data?.data) {
|
|
const flatValues: Array<{ value: string; label: string }> = [];
|
|
const flatten = (items: any[]) => {
|
|
for (const item of items) {
|
|
flatValues.push({
|
|
value: item.valueCode || item.value_code,
|
|
label: item.valueLabel || item.value_label,
|
|
});
|
|
if (item.children?.length > 0) flatten(item.children);
|
|
}
|
|
};
|
|
flatten(response.data.data);
|
|
setStatusCategoryValues(flatValues);
|
|
}
|
|
} catch {
|
|
setStatusCategoryValues([]);
|
|
} finally {
|
|
setLoadingCategoryValues(false);
|
|
}
|
|
};
|
|
|
|
loadCategoryValues();
|
|
}, [config.tableName, config.statusColumn]);
|
|
|
|
const tableComboItems = tables.map((t) => ({
|
|
value: t.tableName,
|
|
label: t.displayName,
|
|
sublabel: t.displayName !== t.tableName ? t.tableName : undefined,
|
|
}));
|
|
|
|
const columnComboItems = columns.map((c) => ({
|
|
value: c.columnName,
|
|
label: c.columnLabel,
|
|
sublabel: c.columnLabel !== c.columnName ? c.columnName : undefined,
|
|
}));
|
|
|
|
const relationComboItems = entityJoins.map((ej) => {
|
|
const refTableLabel = tables.find((t) => t.tableName === ej.referenceTable)?.displayName || ej.referenceTable;
|
|
return {
|
|
value: `${ej.sourceColumn}::${ej.referenceTable}.${ej.referenceColumn}`,
|
|
label: `${ej.sourceColumn} -> ${refTableLabel}`,
|
|
sublabel: `${ej.referenceTable}.${ej.referenceColumn}`,
|
|
};
|
|
});
|
|
|
|
const currentRelationValue = config.relationColumn && config.parentColumn
|
|
? relationComboItems.find((item) => {
|
|
const [srcCol] = item.value.split("::");
|
|
return srcCol === config.relationColumn;
|
|
})?.value || ""
|
|
: "";
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="text-sm font-medium">상태별 카운트 설정</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">제목</Label>
|
|
<Input
|
|
value={config.title || ""}
|
|
onChange={(e) => handleChange("title", e.target.value)}
|
|
placeholder="일련번호 현황"
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">테이블</Label>
|
|
<SearchableCombobox
|
|
value={config.tableName || ""}
|
|
onSelect={(v) => {
|
|
onChange({ tableName: v, statusColumn: "", relationColumn: "", parentColumn: "" });
|
|
}}
|
|
items={tableComboItems}
|
|
placeholder="테이블 선택"
|
|
searchPlaceholder="테이블명 또는 라벨 검색..."
|
|
emptyText="테이블을 찾을 수 없습니다"
|
|
loading={loadingTables}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">상태 컬럼</Label>
|
|
<SearchableCombobox
|
|
value={config.statusColumn || ""}
|
|
onSelect={(v) => handleChange("statusColumn", v)}
|
|
items={columnComboItems}
|
|
placeholder={config.tableName ? "상태 컬럼 선택" : "테이블을 먼저 선택"}
|
|
searchPlaceholder="컬럼명 또는 라벨 검색..."
|
|
emptyText="컬럼을 찾을 수 없습니다"
|
|
disabled={!config.tableName}
|
|
loading={loadingColumns}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">엔티티 관계</Label>
|
|
{loadingJoins ? (
|
|
<div className="flex h-8 items-center gap-1 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3 w-3 animate-spin" /> 로딩중...
|
|
</div>
|
|
) : entityJoins.length > 0 ? (
|
|
<SearchableCombobox
|
|
value={currentRelationValue}
|
|
onSelect={(v) => {
|
|
if (!v) {
|
|
onChange({ relationColumn: "", parentColumn: "" });
|
|
return;
|
|
}
|
|
const [sourceCol, refPart] = v.split("::");
|
|
const [refTable, refCol] = refPart.split(".");
|
|
onChange({ relationColumn: sourceCol, parentColumn: refCol });
|
|
}}
|
|
items={relationComboItems}
|
|
placeholder="엔티티 관계 선택"
|
|
searchPlaceholder="관계 검색..."
|
|
emptyText="엔티티 관계가 없습니다"
|
|
disabled={!config.tableName}
|
|
/>
|
|
) : (
|
|
<div className="rounded-md border border-dashed p-2">
|
|
<p className="text-center text-[10px] text-muted-foreground">
|
|
{config.tableName ? "설정된 엔티티 관계가 없습니다" : "테이블을 먼저 선택하세요"}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{config.relationColumn && config.parentColumn && (
|
|
<p className="text-[10px] text-muted-foreground">
|
|
자식 FK: <span className="font-medium">{config.relationColumn}</span>
|
|
{" -> "}
|
|
부모 매칭: <span className="font-medium">{config.parentColumn}</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">카드 크기</Label>
|
|
<Select
|
|
value={config.cardSize || "md"}
|
|
onValueChange={(v) => handleChange("cardSize", v)}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="sm" className="text-xs">작게</SelectItem>
|
|
<SelectItem value="md" className="text-xs">보통</SelectItem>
|
|
<SelectItem value="lg" className="text-xs">크게</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">상태 항목</Label>
|
|
<Button variant="ghost" size="sm" onClick={addItem} className="h-6 px-2 text-xs">
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
|
|
{loadingCategoryValues && (
|
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3 w-3 animate-spin" /> 카테고리 값 로딩...
|
|
</div>
|
|
)}
|
|
|
|
{items.map((item: StatusCountItem, i: number) => (
|
|
<div key={i} className="space-y-1 rounded-md border p-2">
|
|
<div className="flex items-center gap-1">
|
|
{statusCategoryValues.length > 0 ? (
|
|
<Select
|
|
value={item.value || ""}
|
|
onValueChange={(v) => {
|
|
handleItemChange(i, "value", v);
|
|
if (v === "__ALL__" && !item.label) {
|
|
handleItemChange(i, "label", "전체");
|
|
} else {
|
|
const catVal = statusCategoryValues.find((cv) => cv.value === v);
|
|
if (catVal && !item.label) {
|
|
handleItemChange(i, "label", catVal.label);
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-7 flex-1 text-xs">
|
|
<SelectValue placeholder="카테고리 값 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__ALL__" className="text-xs font-medium">
|
|
전체
|
|
</SelectItem>
|
|
{statusCategoryValues.map((cv) => (
|
|
<SelectItem key={cv.value} value={cv.value} className="text-xs">
|
|
{cv.label} ({cv.value})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Input
|
|
value={item.value}
|
|
onChange={(e) => handleItemChange(i, "value", e.target.value)}
|
|
placeholder="상태값 (예: IN_USE)"
|
|
className="h-7 text-xs"
|
|
/>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeItem(i)}
|
|
className="h-7 w-7 shrink-0 p-0"
|
|
>
|
|
<Trash2 className="h-3 w-3 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
<Input
|
|
value={item.label}
|
|
onChange={(e) => handleItemChange(i, "label", e.target.value)}
|
|
placeholder="표시 라벨"
|
|
className="h-7 text-xs"
|
|
/>
|
|
<Select
|
|
value={item.color}
|
|
onValueChange={(v) => handleItemChange(i, "color", v)}
|
|
>
|
|
<SelectTrigger className="h-7 w-24 shrink-0 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{COLOR_OPTIONS.map((c) => (
|
|
<SelectItem key={c} value={c} className="text-xs">
|
|
<div className="flex items-center gap-1">
|
|
<div
|
|
className={`h-3 w-3 rounded-full ${STATUS_COLOR_MAP[c].bg} border ${STATUS_COLOR_MAP[c].border}`}
|
|
/>
|
|
{c}
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{!loadingCategoryValues && statusCategoryValues.length === 0 && config.tableName && config.statusColumn && (
|
|
<p className="text-[10px] text-amber-600">
|
|
카테고리 값이 없습니다. 옵션설정 > 카테고리설정에서 값을 추가하거나 직접 입력하세요.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|