[agent-pipeline] pipe-20260311174249-88g7 round-3
This commit is contained in:
parent
38cf617226
commit
0a0a175fcd
|
|
@ -2,16 +2,79 @@
|
|||
|
||||
/**
|
||||
* V2StatusCount 설정 패널
|
||||
* 기존 StatusCountConfigPanel의 모든 로직(테이블/컬럼 Combobox, 동적 아이템 관리,
|
||||
* 카테고리 값 자동 로드 등)을 유지하면서 componentConfigChanged 이벤트를 추가하여
|
||||
* 실시간 업데이트 지원
|
||||
* 토스식 단계별 UX: 데이터 소스 -> 컬럼 매핑 -> 상태 항목 관리 -> 표시 설정(접힘)
|
||||
* 기존 StatusCountConfigPanel의 모든 기능을 자체 UI로 완전 구현
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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 { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
StatusCountConfigPanel,
|
||||
} from "@/lib/registry/components/v2-status-count/StatusCountConfigPanel";
|
||||
import type { StatusCountConfig } from "@/lib/registry/components/v2-status-count/types";
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
interface V2StatusCountConfigPanelProps {
|
||||
config: StatusCountConfig;
|
||||
|
|
@ -22,9 +85,9 @@ export const V2StatusCountConfigPanel: React.FC<V2StatusCountConfigPanelProps> =
|
|||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const handleChange = (newConfig: Partial<StatusCountConfig>) => {
|
||||
// componentConfigChanged 이벤트 발행 래퍼
|
||||
const handleChange = useCallback((newConfig: Partial<StatusCountConfig>) => {
|
||||
onChange(newConfig);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("componentConfigChanged", {
|
||||
|
|
@ -32,13 +95,578 @@ export const V2StatusCountConfigPanel: React.FC<V2StatusCountConfigPanelProps> =
|
|||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [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);
|
||||
}
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
// ─── 렌더링 ───
|
||||
return (
|
||||
<StatusCountConfigPanel
|
||||
config={config}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<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" />
|
||||
<span className="text-xs font-medium">제목</span>
|
||||
</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">
|
||||
<span className="text-xs font-medium">상태 컬럼 *</span>
|
||||
<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" />
|
||||
<span className="text-xs font-medium">엔티티 관계</span>
|
||||
</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">
|
||||
<SectionHeader icon={BarChart3} title="상태 항목" description="집계할 상태 값과 표시 스타일을 설정하세요" />
|
||||
<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>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{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">
|
||||
<span className="text-xs text-muted-foreground">미리보기</span>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue