ERP-node/frontend/components/v2/config-panels/V2CardDisplayConfigPanel.tsx

790 lines
29 KiB
TypeScript

"use client";
/**
* V2CardDisplay 설정 패널
* 토스식 단계별 UX: 테이블 선택 -> 컬럼 매핑 -> 카드 스타일 -> 고급 설정(접힘)
*/
import React, { useState, useEffect, useMemo } from "react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Badge } from "@/components/ui/badge";
import {
Settings,
ChevronDown,
Database,
ChevronsUpDown,
Check,
Plus,
Trash2,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { tableManagementApi } from "@/lib/api/tableManagement";
// ─── 한 행당 카드 수 카드 정의 ───
const CARDS_PER_ROW_OPTIONS = [
{ value: 1, label: "1개" },
{ value: 2, label: "2개" },
{ value: 3, label: "3개" },
{ value: 4, label: "4개" },
{ value: 5, label: "5개" },
{ value: 6, label: "6개" },
] as const;
interface EntityJoinColumn {
tableName: string;
columnName: string;
columnLabel: string;
dataType: string;
joinAlias: string;
suggestedLabel: string;
}
interface JoinTable {
tableName: string;
currentDisplayColumn: string;
joinConfig?: { sourceColumn: string };
availableColumns: Array<{
columnName: string;
columnLabel: string;
dataType: string;
description?: string;
}>;
}
interface V2CardDisplayConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
screenTableName?: string;
tableColumns?: any[];
}
export const V2CardDisplayConfigPanel: React.FC<V2CardDisplayConfigPanelProps> = ({
config,
onChange,
screenTableName,
tableColumns = [],
}) => {
const [advancedOpen, setAdvancedOpen] = useState(false);
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [availableColumns, setAvailableColumns] = useState<any[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [entityJoinColumns, setEntityJoinColumns] = useState<{
availableColumns: EntityJoinColumn[];
joinTables: JoinTable[];
}>({ availableColumns: [], joinTables: [] });
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
const targetTableName = useMemo(() => {
if (config.useCustomTable && config.customTableName) {
return config.customTableName;
}
return config.tableName || screenTableName;
}, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]);
const updateConfig = (field: string, value: any) => {
const newConfig = { ...config, [field]: value };
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", { detail: { config: newConfig } })
);
}
};
const updateNestedConfig = (path: string, value: any) => {
const keys = path.split(".");
const newConfig = { ...config };
let current = newConfig;
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) current[keys[i]] = {};
current[keys[i]] = { ...current[keys[i]] };
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value;
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", { detail: { config: newConfig } })
);
}
};
// 테이블 목록 로드
useEffect(() => {
const loadAllTables = async () => {
setLoadingTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(
response.data.map((t: any) => ({
tableName: t.tableName || t.table_name,
displayName: t.tableLabel || t.displayName || t.tableName || t.table_name,
}))
);
}
} catch {
/* 무시 */
} finally {
setLoadingTables(false);
}
};
loadAllTables();
}, []);
// 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!targetTableName) {
setAvailableColumns([]);
return;
}
if (!config.useCustomTable && tableColumns && tableColumns.length > 0) {
setAvailableColumns(tableColumns);
return;
}
setLoadingColumns(true);
try {
const result = await tableManagementApi.getColumnList(targetTableName);
if (result.success && result.data?.columns) {
setAvailableColumns(
result.data.columns.map((col: any) => ({
columnName: col.columnName,
columnLabel: col.displayName || col.columnLabel || col.columnName,
dataType: col.dataType,
}))
);
}
} catch {
setAvailableColumns([]);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [targetTableName, config.useCustomTable, tableColumns]);
// 엔티티 조인 컬럼 로드
useEffect(() => {
const fetchEntityJoinColumns = async () => {
if (!targetTableName) {
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
return;
}
setLoadingEntityJoins(true);
try {
const result = await entityJoinApi.getEntityJoinColumns(targetTableName);
setEntityJoinColumns({
availableColumns: result.availableColumns || [],
joinTables: result.joinTables || [],
});
} catch {
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
} finally {
setLoadingEntityJoins(false);
}
};
fetchEntityJoinColumns();
}, [targetTableName]);
const handleTableSelect = (selectedTable: string, isScreenTable: boolean) => {
const newConfig = isScreenTable
? { ...config, useCustomTable: false, customTableName: undefined, tableName: selectedTable, columnMapping: { displayColumns: [] } }
: { ...config, useCustomTable: true, customTableName: selectedTable, tableName: selectedTable, columnMapping: { displayColumns: [] } };
onChange(newConfig);
setTableComboboxOpen(false);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", { detail: { config: newConfig } })
);
}
};
const getSelectedTableDisplay = () => {
if (!targetTableName) return "테이블을 선택하세요";
const found = allTables.find((t) => t.tableName === targetTableName);
return found?.displayName || targetTableName;
};
const handleColumnSelect = (path: string, columnName: string) => {
const joinColumn = entityJoinColumns.availableColumns.find(
(col) => col.joinAlias === columnName
);
if (joinColumn) {
const joinColumnsConfig = config.joinColumns || [];
const exists = joinColumnsConfig.find((jc: any) => jc.columnName === columnName);
if (!exists) {
const joinTableInfo = entityJoinColumns.joinTables?.find(
(jt) => jt.tableName === joinColumn.tableName
);
const newJoinColumnConfig = {
columnName: joinColumn.joinAlias,
label: joinColumn.suggestedLabel || joinColumn.columnLabel,
sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "",
referenceTable: joinColumn.tableName,
referenceColumn: joinColumn.columnName,
isJoinColumn: true,
};
const newConfig = {
...config,
columnMapping: { ...config.columnMapping, [path.split(".")[1]]: columnName },
joinColumns: [...joinColumnsConfig, newJoinColumnConfig],
};
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", { detail: { config: newConfig } })
);
}
return;
}
}
updateNestedConfig(path, columnName);
};
// 표시 컬럼 관리
const addDisplayColumn = () => {
const current = config.columnMapping?.displayColumns || [];
updateNestedConfig("columnMapping.displayColumns", [...current, ""]);
};
const removeDisplayColumn = (index: number) => {
const current = [...(config.columnMapping?.displayColumns || [])];
current.splice(index, 1);
updateNestedConfig("columnMapping.displayColumns", current);
};
const updateDisplayColumn = (index: number, value: string) => {
const current = [...(config.columnMapping?.displayColumns || [])];
current[index] = value;
const joinColumn = entityJoinColumns.availableColumns.find(
(col) => col.joinAlias === value
);
if (joinColumn) {
const joinColumnsConfig = config.joinColumns || [];
const exists = joinColumnsConfig.find((jc: any) => jc.columnName === value);
if (!exists) {
const joinTableInfo = entityJoinColumns.joinTables?.find(
(jt) => jt.tableName === joinColumn.tableName
);
const newConfig = {
...config,
columnMapping: { ...config.columnMapping, displayColumns: current },
joinColumns: [
...joinColumnsConfig,
{
columnName: joinColumn.joinAlias,
label: joinColumn.suggestedLabel || joinColumn.columnLabel,
sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "",
referenceTable: joinColumn.tableName,
referenceColumn: joinColumn.columnName,
isJoinColumn: true,
},
],
};
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", { detail: { config: newConfig } })
);
}
return;
}
}
updateNestedConfig("columnMapping.displayColumns", current);
};
// 테이블별 조인 컬럼 그룹화
const joinColumnsByTable: Record<string, EntityJoinColumn[]> = {};
entityJoinColumns.availableColumns.forEach((col) => {
if (!joinColumnsByTable[col.tableName]) joinColumnsByTable[col.tableName] = [];
joinColumnsByTable[col.tableName].push(col);
});
const currentTableColumns = config.useCustomTable
? availableColumns
: tableColumns.length > 0
? tableColumns
: availableColumns;
// 컬럼 선택 Select 렌더링
const renderColumnSelect = (
value: string,
onChangeHandler: (value: string) => void,
placeholder: string = "컬럼 선택"
) => (
<Select
value={value || "__none__"}
onValueChange={(val) => onChangeHandler(val === "__none__" ? "" : val)}
>
<SelectTrigger className="h-7 text-[11px]">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-[11px] text-muted-foreground">
</SelectItem>
{currentTableColumns.length > 0 && (
<SelectGroup>
<SelectLabel className="text-[10px] font-semibold text-muted-foreground">
</SelectLabel>
{currentTableColumns.map((column: any) => (
<SelectItem key={column.columnName} value={column.columnName} className="text-[11px]">
{column.columnLabel || column.columnName}
</SelectItem>
))}
</SelectGroup>
)}
{Object.entries(joinColumnsByTable).map(([tableName, columns]) => (
<SelectGroup key={tableName}>
<SelectLabel className="text-[10px] font-semibold text-primary">
{tableName} ()
</SelectLabel>
{columns.map((col) => (
<SelectItem key={col.joinAlias} value={col.joinAlias} className="text-[11px]">
{col.suggestedLabel || col.columnLabel}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
);
return (
<div className="space-y-4">
{/* ─── 1단계: 테이블 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboboxOpen}
className="h-8 w-full justify-between text-xs"
disabled={loadingTables}
>
<div className="flex items-center gap-2 truncate">
<Database className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">
{loadingTables ? "로딩 중..." : getSelectedTableDisplay()}
</span>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-3 text-center text-xs text-muted-foreground">
</CommandEmpty>
{screenTableName && (
<CommandGroup heading="기본 (화면 테이블)">
<CommandItem
value={screenTableName}
onSelect={() => handleTableSelect(screenTableName, true)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
targetTableName === screenTableName && !config.useCustomTable
? "opacity-100"
: "opacity-0"
)}
/>
<Database className="mr-2 h-3.5 w-3.5 text-primary" />
{allTables.find((t) => t.tableName === screenTableName)?.displayName ||
screenTableName}
</CommandItem>
</CommandGroup>
)}
<CommandGroup heading="전체 테이블">
{allTables
.filter((t) => t.tableName !== screenTableName)
.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={() => handleTableSelect(table.tableName, false)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.useCustomTable && targetTableName === table.tableName
? "opacity-100"
: "opacity-0"
)}
/>
<Database className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate">{table.displayName}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{config.useCustomTable && (
<p className="text-[10px] text-muted-foreground">
</p>
)}
</div>
{/* ─── 2단계: 컬럼 매핑 ─── */}
{(currentTableColumns.length > 0 || loadingColumns) && (
<div className="space-y-3">
<p className="text-sm font-medium truncate"> </p>
{(loadingEntityJoins || loadingColumns) && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
{loadingColumns ? "컬럼 로딩 중..." : "조인 컬럼 로딩 중..."}
</div>
)}
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"></span>
<div className="w-[180px]">
{renderColumnSelect(
config.columnMapping?.titleColumn || "",
(value) => handleColumnSelect("columnMapping.titleColumn", value)
)}
</div>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"></span>
<div className="w-[180px]">
{renderColumnSelect(
config.columnMapping?.subtitleColumn || "",
(value) => handleColumnSelect("columnMapping.subtitleColumn", value)
)}
</div>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"></span>
<div className="w-[180px]">
{renderColumnSelect(
config.columnMapping?.descriptionColumn || "",
(value) => handleColumnSelect("columnMapping.descriptionColumn", value)
)}
</div>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"></span>
<div className="w-[180px]">
{renderColumnSelect(
config.columnMapping?.imageColumn || "",
(value) => handleColumnSelect("columnMapping.imageColumn", value)
)}
</div>
</div>
</div>
{/* 표시 컬럼 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium truncate"> </span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={addDisplayColumn}
className="h-6 px-2 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(config.columnMapping?.displayColumns || []).length > 0 ? (
<div className="space-y-1.5">
{(config.columnMapping?.displayColumns || []).map(
(column: string, index: number) => (
<div key={index} className="flex items-center gap-2">
<div className="flex-1">
{renderColumnSelect(column, (value) =>
updateDisplayColumn(index, value)
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeDisplayColumn(index)}
className="h-7 w-7 shrink-0 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)
)}
</div>
) : (
<div className="rounded-md border border-dashed border-muted-foreground/30 py-3 text-center text-xs text-muted-foreground">
</div>
)}
</div>
</div>
)}
{/* ─── 3단계: 카드 레이아웃 ─── */}
<div className="space-y-3">
<p className="text-sm font-medium truncate"> </p>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> </span>
<Select
value={String(config.cardsPerRow || 3)}
onValueChange={(v) => updateConfig("cardsPerRow", parseInt(v))}
>
<SelectTrigger className="h-7 w-[100px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CARDS_PER_ROW_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={String(opt.value)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> (px)</span>
<Input
type="number"
min={0}
max={50}
value={config.cardSpacing ?? 16}
onChange={(e) => updateConfig("cardSpacing", parseInt(e.target.value))}
className="h-7 w-[100px] text-xs"
/>
</div>
</div>
</div>
{/* ─── 4단계: 표시 요소 토글 ─── */}
<div className="space-y-3">
<p className="text-sm font-medium truncate"> </p>
<div className="rounded-lg border bg-muted/30 p-4 space-y-2">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"></p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.cardStyle?.showTitle ?? true}
onCheckedChange={(checked) => updateNestedConfig("cardStyle.showTitle", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"></p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.cardStyle?.showSubtitle ?? true}
onCheckedChange={(checked) =>
updateNestedConfig("cardStyle.showSubtitle", checked)
}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"></p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.cardStyle?.showDescription ?? true}
onCheckedChange={(checked) =>
updateNestedConfig("cardStyle.showDescription", checked)
}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"></p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.cardStyle?.showImage ?? false}
onCheckedChange={(checked) =>
updateNestedConfig("cardStyle.showImage", checked)
}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
, ,
</p>
</div>
<Switch
checked={config.cardStyle?.showActions ?? true}
onCheckedChange={(checked) =>
updateNestedConfig("cardStyle.showActions", checked)
}
/>
</div>
{(config.cardStyle?.showActions ?? true) && (
<div className="ml-4 border-l-2 border-primary/20 pl-3 space-y-2">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"></span>
<Switch
checked={config.cardStyle?.showViewButton ?? true}
onCheckedChange={(checked) =>
updateNestedConfig("cardStyle.showViewButton", checked)
}
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"></span>
<Switch
checked={config.cardStyle?.showEditButton ?? true}
onCheckedChange={(checked) =>
updateNestedConfig("cardStyle.showEditButton", checked)
}
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"></span>
<Switch
checked={config.cardStyle?.showDeleteButton ?? false}
onCheckedChange={(checked) =>
updateNestedConfig("cardStyle.showDeleteButton", checked)
}
/>
</div>
</div>
)}
</div>
</div>
{/* ─── 5단계: 고급 설정 (기본 접혀있음) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium truncate"> </span>
<Badge variant="secondary" className="text-[10px] h-5">3</Badge>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> </span>
<Input
type="number"
min={10}
max={500}
value={config.cardStyle?.maxDescriptionLength ?? 100}
onChange={(e) =>
updateNestedConfig(
"cardStyle.maxDescriptionLength",
parseInt(e.target.value)
)
}
className="h-7 w-[100px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"></p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.disabled || false}
onCheckedChange={(checked) => updateConfig("disabled", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.readonly || false}
onCheckedChange={(checked) => updateConfig("readonly", checked)}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2CardDisplayConfigPanel.displayName = "V2CardDisplayConfigPanel";
export default V2CardDisplayConfigPanel;