2026-03-12 00:45:49 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* V2StatusCount 설정 패널
|
2026-03-12 02:55:20 +09:00
|
|
|
* 토스식 단계별 UX: 데이터 소스 -> 컬럼 매핑 -> 상태 항목 관리 -> 표시 설정(접힘)
|
|
|
|
|
* 기존 StatusCountConfigPanel의 모든 기능을 자체 UI로 완전 구현
|
2026-03-12 00:45:49 +09:00
|
|
|
*/
|
|
|
|
|
|
2026-03-12 02:55:20 +09:00
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2026-03-12 08:27:47 +09:00
|
|
|
import { Badge } from "@/components/ui/badge";
|
2026-03-12 02:55:20 +09:00
|
|
|
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 { Separator } from "@/components/ui/separator";
|
2026-03-12 00:45:49 +09:00
|
|
|
import {
|
2026-03-12 02:55:20 +09:00
|
|
|
Table2,
|
|
|
|
|
Columns3,
|
|
|
|
|
Check,
|
|
|
|
|
ChevronsUpDown,
|
|
|
|
|
Loader2,
|
|
|
|
|
Link2,
|
|
|
|
|
Plus,
|
|
|
|
|
Trash2,
|
|
|
|
|
BarChart3,
|
|
|
|
|
Type,
|
|
|
|
|
Maximize2,
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import { tableTypeApi } from "@/lib/api/screen";
|
|
|
|
|
import { entityJoinApi, type EntityJoinConfig } from "@/lib/api/entityJoin";
|
|
|
|
|
import { apiClient } from "@/lib/api/client";
|
|
|
|
|
import type { StatusCountConfig, StatusCountItem } from "@/lib/registry/components/v2-status-count/types";
|
|
|
|
|
import { STATUS_COLOR_MAP } from "@/lib/registry/components/v2-status-count/types";
|
|
|
|
|
|
|
|
|
|
const COLOR_OPTIONS = Object.keys(STATUS_COLOR_MAP);
|
|
|
|
|
|
|
|
|
|
// ─── 카드 크기 선택 카드 ───
|
|
|
|
|
const SIZE_CARDS = [
|
|
|
|
|
{ value: "sm", title: "작게", description: "컴팩트" },
|
|
|
|
|
{ value: "md", title: "보통", description: "기본 크기" },
|
|
|
|
|
{ value: "lg", title: "크게", description: "넓은 카드" },
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
// ─── 섹션 헤더 컴포넌트 ───
|
|
|
|
|
function SectionHeader({ icon: Icon, title, description }: {
|
|
|
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
|
|
|
title: string;
|
|
|
|
|
description?: string;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
<h3 className="text-sm font-semibold">{title}</h3>
|
|
|
|
|
</div>
|
|
|
|
|
{description && <p className="text-muted-foreground text-[10px]">{description}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── 수평 라벨 + 컨트롤 Row ───
|
|
|
|
|
function LabeledRow({ label, description, children }: {
|
|
|
|
|
label: string;
|
|
|
|
|
description?: string;
|
|
|
|
|
children: React.ReactNode;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center justify-between py-1">
|
|
|
|
|
<div className="space-y-0.5">
|
|
|
|
|
<p className="text-xs text-muted-foreground">{label}</p>
|
|
|
|
|
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
{children}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-12 00:45:49 +09:00
|
|
|
|
|
|
|
|
interface V2StatusCountConfigPanelProps {
|
|
|
|
|
config: StatusCountConfig;
|
|
|
|
|
onChange: (config: Partial<StatusCountConfig>) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const V2StatusCountConfigPanel: React.FC<V2StatusCountConfigPanelProps> = ({
|
|
|
|
|
config,
|
|
|
|
|
onChange,
|
|
|
|
|
}) => {
|
2026-03-12 02:55:20 +09:00
|
|
|
// componentConfigChanged 이벤트 발행 래퍼
|
|
|
|
|
const handleChange = useCallback((newConfig: Partial<StatusCountConfig>) => {
|
2026-03-12 00:45:49 +09:00
|
|
|
onChange(newConfig);
|
|
|
|
|
if (typeof window !== "undefined") {
|
|
|
|
|
window.dispatchEvent(
|
|
|
|
|
new CustomEvent("componentConfigChanged", {
|
|
|
|
|
detail: { config: { ...config, ...newConfig } },
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-12 02:55:20 +09:00
|
|
|
}, [onChange, config]);
|
|
|
|
|
|
|
|
|
|
const updateField = useCallback((key: keyof StatusCountConfig, value: any) => {
|
|
|
|
|
handleChange({ [key]: value });
|
|
|
|
|
}, [handleChange]);
|
|
|
|
|
|
|
|
|
|
// ─── 상태 ───
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
const [statusCategoryValues, setStatusCategoryValues] = useState<Array<{ value: string; label: string }>>([]);
|
|
|
|
|
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
|
|
|
|
|
|
|
|
|
|
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
|
|
|
|
|
const [statusColumnOpen, setStatusColumnOpen] = useState(false);
|
|
|
|
|
const [relationOpen, setRelationOpen] = useState(false);
|
|
|
|
|
const items = config.items || [];
|
|
|
|
|
|
|
|
|
|
// ─── 테이블 목록 로드 ───
|
|
|
|
|
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]);
|
|
|
|
|
|
|
|
|
|
// ─── 상태 컬럼의 카테고리 값 로드 ───
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!config.tableName || !config.statusColumn) {
|
|
|
|
|
setStatusCategoryValues([]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const loadCategoryValues = async () => {
|
|
|
|
|
setLoadingCategoryValues(true);
|
|
|
|
|
try {
|
|
|
|
|
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 = (categoryItems: any[]) => {
|
|
|
|
|
for (const item of categoryItems) {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-03-12 00:45:49 +09:00
|
|
|
|
2026-03-12 02:55:20 +09:00
|
|
|
loadCategoryValues();
|
|
|
|
|
}, [config.tableName, config.statusColumn]);
|
|
|
|
|
|
|
|
|
|
// ─── 엔티티 관계 Combobox 아이템 ───
|
|
|
|
|
const relationComboItems = useMemo(() => {
|
|
|
|
|
return 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}`,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}, [entityJoins, tables]);
|
|
|
|
|
|
|
|
|
|
const currentRelationValue = useMemo(() => {
|
|
|
|
|
if (!config.relationColumn) return "";
|
|
|
|
|
return relationComboItems.find((item) => {
|
|
|
|
|
const [srcCol] = item.value.split("::");
|
|
|
|
|
return srcCol === config.relationColumn;
|
|
|
|
|
})?.value || "";
|
|
|
|
|
}, [config.relationColumn, relationComboItems]);
|
|
|
|
|
|
|
|
|
|
// ─── 상태 항목 관리 ───
|
|
|
|
|
const addItem = useCallback(() => {
|
|
|
|
|
updateField("items", [...items, { value: "", label: "새 상태", color: "gray" }]);
|
|
|
|
|
}, [items, updateField]);
|
|
|
|
|
|
|
|
|
|
const removeItem = useCallback((index: number) => {
|
|
|
|
|
updateField("items", items.filter((_: StatusCountItem, i: number) => i !== index));
|
|
|
|
|
}, [items, updateField]);
|
|
|
|
|
|
|
|
|
|
const updateItem = useCallback((index: number, key: keyof StatusCountItem, value: string) => {
|
|
|
|
|
const newItems = [...items];
|
|
|
|
|
newItems[index] = { ...newItems[index], [key]: value };
|
|
|
|
|
updateField("items", newItems);
|
|
|
|
|
}, [items, updateField]);
|
|
|
|
|
|
|
|
|
|
// ─── 테이블 변경 핸들러 ───
|
|
|
|
|
const handleTableChange = useCallback((newTableName: string) => {
|
|
|
|
|
handleChange({ tableName: newTableName, statusColumn: "", relationColumn: "", parentColumn: "" });
|
|
|
|
|
setTableComboboxOpen(false);
|
|
|
|
|
}, [handleChange]);
|
|
|
|
|
|
|
|
|
|
// ─── 렌더링 ───
|
2026-03-12 00:45:49 +09:00
|
|
|
return (
|
2026-03-12 02:55:20 +09:00
|
|
|
<div className="space-y-4">
|
|
|
|
|
{/* ═══════════════════════════════════════ */}
|
|
|
|
|
{/* 1단계: 데이터 소스 (테이블 선택) */}
|
|
|
|
|
{/* ═══════════════════════════════════════ */}
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<SectionHeader icon={Table2} title="데이터 소스" description="상태를 집계할 테이블을 선택하세요" />
|
|
|
|
|
<Separator />
|
|
|
|
|
|
|
|
|
|
{/* 제목 */}
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Type className="h-3.5 w-3.5 text-muted-foreground" />
|
2026-03-12 08:27:47 +09:00
|
|
|
<span className="text-xs font-medium truncate">제목</span>
|
2026-03-12 02:55:20 +09:00
|
|
|
</div>
|
|
|
|
|
<Input
|
|
|
|
|
value={config.title || ""}
|
|
|
|
|
onChange={(e) => updateField("title", e.target.value)}
|
|
|
|
|
placeholder="예: 일련번호 현황"
|
|
|
|
|
className="h-7 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 테이블 선택 */}
|
|
|
|
|
<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">
|
|
|
|
|
<Table2 className="h-3 w-3 shrink-0" />
|
|
|
|
|
<span className="truncate">
|
|
|
|
|
{loadingTables
|
|
|
|
|
? "테이블 로딩 중..."
|
|
|
|
|
: config.tableName
|
|
|
|
|
? tables.find((t) => t.tableName === config.tableName)?.displayName || config.tableName
|
|
|
|
|
: "테이블 선택"}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<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="text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{tables.map((table) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={table.tableName}
|
|
|
|
|
value={`${table.displayName} ${table.tableName}`}
|
|
|
|
|
onSelect={() => handleTableChange(table.tableName)}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn("mr-2 h-3 w-3", config.tableName === table.tableName ? "opacity-100" : "opacity-0")}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span>{table.displayName}</span>
|
|
|
|
|
{table.displayName !== table.tableName && (
|
|
|
|
|
<span className="text-[10px] text-muted-foreground/70">{table.tableName}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ═══════════════════════════════════════ */}
|
|
|
|
|
{/* 2단계: 컬럼 매핑 */}
|
|
|
|
|
{/* ═══════════════════════════════════════ */}
|
|
|
|
|
{config.tableName && (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<SectionHeader icon={Columns3} title="컬럼 매핑" description="상태 컬럼과 부모 관계를 설정하세요" />
|
|
|
|
|
<Separator />
|
|
|
|
|
|
|
|
|
|
{/* 상태 컬럼 */}
|
|
|
|
|
<div className="space-y-1">
|
2026-03-12 08:27:47 +09:00
|
|
|
<span className="text-xs font-medium truncate">상태 컬럼 *</span>
|
2026-03-12 02:55:20 +09:00
|
|
|
<Popover open={statusColumnOpen} onOpenChange={setStatusColumnOpen}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={statusColumnOpen}
|
|
|
|
|
className="h-8 w-full justify-between text-xs"
|
|
|
|
|
disabled={loadingColumns}
|
|
|
|
|
>
|
|
|
|
|
<span className="truncate">
|
|
|
|
|
{loadingColumns
|
|
|
|
|
? "컬럼 로딩 중..."
|
|
|
|
|
: config.statusColumn
|
|
|
|
|
? columns.find((c) => c.columnName === config.statusColumn)?.columnLabel || config.statusColumn
|
|
|
|
|
: "상태 컬럼 선택"}
|
|
|
|
|
</span>
|
|
|
|
|
<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="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{columns.map((col) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={col.columnName}
|
|
|
|
|
value={`${col.columnLabel} ${col.columnName}`}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
updateField("statusColumn", col.columnName);
|
|
|
|
|
setStatusColumnOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn("mr-2 h-3 w-3", config.statusColumn === col.columnName ? "opacity-100" : "opacity-0")}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span>{col.columnLabel}</span>
|
|
|
|
|
{col.columnLabel !== col.columnName && (
|
|
|
|
|
<span className="text-[10px] text-muted-foreground/70">{col.columnName}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 엔티티 관계 */}
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
2026-03-12 08:27:47 +09:00
|
|
|
<span className="text-xs font-medium truncate">엔티티 관계</span>
|
2026-03-12 02:55:20 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{loadingJoins ? (
|
|
|
|
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
|
|
|
<Loader2 className="h-3 w-3 animate-spin" /> 로딩중...
|
|
|
|
|
</div>
|
|
|
|
|
) : entityJoins.length > 0 ? (
|
|
|
|
|
<Popover open={relationOpen} onOpenChange={setRelationOpen}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={relationOpen}
|
|
|
|
|
className="h-8 w-full justify-between text-xs"
|
|
|
|
|
>
|
|
|
|
|
<span className="truncate">
|
|
|
|
|
{currentRelationValue
|
|
|
|
|
? relationComboItems.find((r) => r.value === currentRelationValue)?.label || "관계 선택"
|
|
|
|
|
: "엔티티 관계 선택"}
|
|
|
|
|
</span>
|
|
|
|
|
<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="text-xs">엔티티 관계가 없습니다.</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{relationComboItems.map((item) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={item.value}
|
|
|
|
|
value={`${item.label} ${item.sublabel}`}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
if (item.value === currentRelationValue) {
|
|
|
|
|
handleChange({ relationColumn: "", parentColumn: "" });
|
|
|
|
|
} else {
|
|
|
|
|
const [sourceCol, refPart] = item.value.split("::");
|
|
|
|
|
const [, refCol] = refPart.split(".");
|
|
|
|
|
handleChange({ relationColumn: sourceCol, parentColumn: refCol });
|
|
|
|
|
}
|
|
|
|
|
setRelationOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn("mr-2 h-3 w-3", currentRelationValue === item.value ? "opacity-100" : "opacity-0")}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span>{item.label}</span>
|
|
|
|
|
<span className="text-[10px] text-muted-foreground/70">{item.sublabel}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="rounded-lg border-2 border-dashed py-3 text-center">
|
|
|
|
|
<p className="text-[10px] text-muted-foreground">설정된 엔티티 관계가 없습니다</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{config.relationColumn && config.parentColumn && (
|
|
|
|
|
<div className="rounded bg-muted/50 px-2 py-1.5 text-[10px] text-muted-foreground">
|
|
|
|
|
자식 FK: <span className="font-medium text-foreground">{config.relationColumn}</span>
|
|
|
|
|
{" -> "}
|
|
|
|
|
부모 매칭: <span className="font-medium text-foreground">{config.parentColumn}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 테이블 미선택 안내 */}
|
|
|
|
|
{!config.tableName && (
|
|
|
|
|
<div className="rounded-lg border-2 border-dashed p-6 text-center">
|
|
|
|
|
<Table2 className="mx-auto mb-2 h-8 w-8 text-muted-foreground opacity-30" />
|
|
|
|
|
<p className="text-sm text-muted-foreground">테이블이 선택되지 않았습니다</p>
|
|
|
|
|
<p className="text-xs text-muted-foreground">위 데이터 소스에서 테이블을 선택하세요</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ═══════════════════════════════════════ */}
|
|
|
|
|
{/* 3단계: 카드 크기 (카드 선택 UI) */}
|
|
|
|
|
{/* ═══════════════════════════════════════ */}
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<SectionHeader icon={Maximize2} title="카드 크기" description="상태 카드의 크기를 선택하세요" />
|
|
|
|
|
<Separator />
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-3 gap-2">
|
|
|
|
|
{SIZE_CARDS.map((card) => {
|
|
|
|
|
const isSelected = (config.cardSize || "md") === card.value;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={card.value}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => updateField("cardSize", card.value)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex min-h-[60px] flex-col items-center justify-center rounded-lg border p-2 text-center transition-all",
|
|
|
|
|
isSelected
|
|
|
|
|
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
|
|
|
|
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span className="text-xs font-medium leading-tight">{card.title}</span>
|
|
|
|
|
<span className="mt-0.5 text-[10px] leading-tight text-muted-foreground">{card.description}</span>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ═══════════════════════════════════════ */}
|
|
|
|
|
{/* 4단계: 상태 항목 관리 */}
|
|
|
|
|
{/* ═══════════════════════════════════════ */}
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div className="flex items-center justify-between">
|
2026-03-12 08:27:47 +09:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<SectionHeader icon={BarChart3} title="상태 항목" description="집계할 상태 값과 표시 스타일을 설정하세요" />
|
|
|
|
|
<Badge variant="secondary" className="text-[10px] h-5">{items.length}개</Badge>
|
|
|
|
|
</div>
|
2026-03-12 02:55:20 +09:00
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={addItem}
|
|
|
|
|
className="h-6 shrink-0 px-2 text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
|
|
|
추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<Separator />
|
|
|
|
|
|
|
|
|
|
{loadingCategoryValues && (
|
|
|
|
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
|
|
|
<Loader2 className="h-3 w-3 animate-spin" /> 카테고리 값 로딩...
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{items.length === 0 ? (
|
|
|
|
|
<div className="rounded-lg border-2 border-dashed py-6 text-center">
|
|
|
|
|
<BarChart3 className="mx-auto mb-2 h-8 w-8 text-muted-foreground opacity-30" />
|
|
|
|
|
<p className="text-sm text-muted-foreground">아직 상태 항목이 없어요</p>
|
|
|
|
|
<p className="text-xs text-muted-foreground">위의 추가 버튼으로 항목을 만들어보세요</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-03-12 10:04:26 +09:00
|
|
|
<div className="space-y-2">
|
2026-03-12 02:55:20 +09:00
|
|
|
{items.map((item: StatusCountItem, i: number) => (
|
|
|
|
|
<div key={i} className="space-y-1.5 rounded-md border p-2.5">
|
|
|
|
|
{/* 첫 번째 줄: 상태값 + 삭제 */}
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
{statusCategoryValues.length > 0 ? (
|
|
|
|
|
<Select
|
|
|
|
|
value={item.value || ""}
|
|
|
|
|
onValueChange={(v) => {
|
|
|
|
|
updateItem(i, "value", v);
|
|
|
|
|
if (v === "__ALL__" && !item.label) {
|
|
|
|
|
updateItem(i, "label", "전체");
|
|
|
|
|
} else {
|
|
|
|
|
const catVal = statusCategoryValues.find((cv) => cv.value === v);
|
|
|
|
|
if (catVal && !item.label) {
|
|
|
|
|
updateItem(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) => updateItem(i, "value", e.target.value)}
|
|
|
|
|
placeholder="상태값 (예: IN_USE)"
|
|
|
|
|
className="h-7 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => removeItem(i)}
|
|
|
|
|
className="h-6 w-6 shrink-0 p-0 text-muted-foreground hover:text-destructive"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 두 번째 줄: 라벨 + 색상 */}
|
|
|
|
|
<div className="flex gap-1">
|
|
|
|
|
<Input
|
|
|
|
|
value={item.label}
|
|
|
|
|
onChange={(e) => updateItem(i, "label", e.target.value)}
|
|
|
|
|
placeholder="표시 라벨"
|
|
|
|
|
className="h-7 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
<Select
|
|
|
|
|
value={item.color}
|
|
|
|
|
onValueChange={(v) => updateItem(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.5">
|
|
|
|
|
<div
|
|
|
|
|
className={cn("h-3 w-3 rounded-full border", STATUS_COLOR_MAP[c].bg, STATUS_COLOR_MAP[c].border)}
|
|
|
|
|
/>
|
|
|
|
|
{c}
|
|
|
|
|
</div>
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{!loadingCategoryValues && statusCategoryValues.length === 0 && config.tableName && config.statusColumn && (
|
|
|
|
|
<div className="rounded bg-amber-50 px-2 py-1.5 text-[10px] text-amber-700 dark:bg-amber-950/30 dark:text-amber-400">
|
|
|
|
|
카테고리 값이 없습니다. 옵션설정 > 카테고리설정에서 값을 추가하거나 직접 입력하세요.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 미리보기 */}
|
|
|
|
|
{items.length > 0 && (
|
|
|
|
|
<div className="space-y-1.5">
|
2026-03-12 08:27:47 +09:00
|
|
|
<span className="text-xs text-muted-foreground truncate">미리보기</span>
|
2026-03-12 02:55:20 +09:00
|
|
|
<div className="flex gap-1.5 rounded-md bg-muted/30 p-2">
|
|
|
|
|
{items.map((item, i) => {
|
|
|
|
|
const colors = STATUS_COLOR_MAP[item.color] || STATUS_COLOR_MAP.gray;
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={i}
|
|
|
|
|
className={cn("flex flex-1 flex-col items-center rounded-md border p-1.5", colors.bg, colors.border)}
|
|
|
|
|
>
|
|
|
|
|
<span className={cn("text-sm font-bold", colors.text)}>0</span>
|
|
|
|
|
<span className={cn("text-[10px]", colors.text)}>{item.label || "라벨"}</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-12 00:45:49 +09:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
V2StatusCountConfigPanel.displayName = "V2StatusCountConfigPanel";
|
|
|
|
|
|
|
|
|
|
export default V2StatusCountConfigPanel;
|