Files
vexplor/frontend/lib/registry/components/v2-status-count/StatusCountConfigPanel.tsx
kjs 4d6783e508 feat: Implement automatic serial number generation and reference handling in mold management
- 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.
2026-03-09 15:34:31 +09:00

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">
. &gt; .
</p>
)}
</div>
</div>
);
};