Files
vexplor_dev/frontend/components/v2/config-panels/V2TimelineSchedulerConfigPanel.tsx
kjs 1a319d1785 feat: enhance V2TimelineSchedulerConfigPanel with filter and view mode options
- Added new filter and linking settings section to the V2TimelineSchedulerConfigPanel, allowing users to manage static filters and linked filters more effectively.
- Introduced view mode options to switch between different display modes in the timeline scheduler.
- Updated the configuration types and added new toolbar action settings to support custom actions in the timeline toolbar.
- Enhanced the overall user experience by providing more flexible filtering and display options.

These updates aim to improve the functionality and usability of the timeline scheduler within the ERP system, enabling better data management and visualization.

Made-with: Cursor
2026-03-16 14:51:34 +09:00

1769 lines
81 KiB
TypeScript

"use client";
/**
* V2 타임라인 스케줄러 설정 패널
* 토스식 단계별 UX: 스케줄 데이터 설정 -> 소스 데이터 설정 -> 리소스 설정 -> 표시 설정(접힘) -> 고급 설정(접힘)
*/
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
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 { Settings, ChevronDown, Check, ChevronsUpDown, Database, Users, Layers, Filter, Link, Zap, Trash2, Plus, GripVertical } from "lucide-react";
import { cn } from "@/lib/utils";
import { tableTypeApi } from "@/lib/api/screen";
import type { TimelineSchedulerConfig, ScheduleType, SourceDataConfig, ResourceFieldMapping, FieldMapping, ZoomLevel, ToolbarAction } from "@/lib/registry/components/v2-timeline-scheduler/types";
import { zoomLevelOptions, scheduleTypeOptions, viewModeOptions, dataSourceOptions, toolbarIconOptions } from "@/lib/registry/components/v2-timeline-scheduler/config";
interface V2TimelineSchedulerConfigPanelProps {
config: TimelineSchedulerConfig;
onChange: (config: Partial<TimelineSchedulerConfig>) => void;
}
interface TableInfo {
tableName: string;
displayName: string;
}
interface ColumnInfo {
columnName: string;
displayName: string;
}
export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigPanelProps> = ({
config,
onChange,
}) => {
const [tables, setTables] = useState<TableInfo[]>([]);
const [sourceColumns, setSourceColumns] = useState<ColumnInfo[]>([]);
const [resourceColumns, setResourceColumns] = useState<ColumnInfo[]>([]);
const [scheduleColumns, setScheduleColumns] = useState<ColumnInfo[]>([]);
const [loading, setLoading] = useState(false);
const [sourceTableOpen, setSourceTableOpen] = useState(false);
const [resourceTableOpen, setResourceTableOpen] = useState(false);
const [customTableOpen, setCustomTableOpen] = useState(false);
const [scheduleDataOpen, setScheduleDataOpen] = useState(true);
const [filterLinkOpen, setFilterLinkOpen] = useState(false);
const [sourceDataOpen, setSourceDataOpen] = useState(true);
const [resourceOpen, setResourceOpen] = useState(true);
const [displayOpen, setDisplayOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [actionsOpen, setActionsOpen] = useState(false);
const [newFilterKey, setNewFilterKey] = useState("");
const [newFilterValue, setNewFilterValue] = useState("");
const [linkedFilterTableOpen, setLinkedFilterTableOpen] = useState(false);
const [expandedActionId, setExpandedActionId] = useState<string | null>(null);
useEffect(() => {
const loadTables = async () => {
setLoading(true);
try {
const tableList = await tableTypeApi.getTables();
if (Array.isArray(tableList)) {
setTables(
tableList.map((t: any) => ({
tableName: t.table_name || t.tableName,
displayName: t.display_name || t.displayName || t.table_name || t.tableName,
}))
);
}
} catch (err) {
console.error("테이블 목록 로드 오류:", err);
} finally {
setLoading(false);
}
};
loadTables();
}, []);
useEffect(() => {
const loadSourceColumns = async () => {
if (!config.sourceConfig?.tableName) {
setSourceColumns([]);
return;
}
try {
const columns = await tableTypeApi.getColumns(config.sourceConfig.tableName);
if (Array.isArray(columns)) {
setSourceColumns(
columns.map((col: any) => ({
columnName: col.column_name || col.columnName,
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
}))
);
}
} catch (err) {
console.error("소스 컬럼 로드 오류:", err);
setSourceColumns([]);
}
};
loadSourceColumns();
}, [config.sourceConfig?.tableName]);
useEffect(() => {
const loadResourceColumns = async () => {
if (!config.resourceTable) {
setResourceColumns([]);
return;
}
try {
const columns = await tableTypeApi.getColumns(config.resourceTable);
if (Array.isArray(columns)) {
setResourceColumns(
columns.map((col: any) => ({
columnName: col.column_name || col.columnName,
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
}))
);
}
} catch (err) {
console.error("리소스 컬럼 로드 오류:", err);
setResourceColumns([]);
}
};
loadResourceColumns();
}, [config.resourceTable]);
// 커스텀 테이블 또는 schedule_mng 컬럼 로드
useEffect(() => {
const loadScheduleColumns = async () => {
const tableName = config.useCustomTable && config.customTableName
? config.customTableName
: "schedule_mng";
try {
const columns = await tableTypeApi.getColumns(tableName);
if (Array.isArray(columns)) {
setScheduleColumns(
columns.map((col: any) => ({
columnName: col.column_name || col.columnName,
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
}))
);
}
} catch (err) {
console.error("스케줄 테이블 컬럼 로드 오류:", err);
setScheduleColumns([]);
}
};
loadScheduleColumns();
}, [config.useCustomTable, config.customTableName]);
const updateConfig = (updates: Partial<TimelineSchedulerConfig>) => {
onChange({ ...config, ...updates });
};
const updateSourceConfig = (updates: Partial<SourceDataConfig>) => {
updateConfig({
sourceConfig: {
...config.sourceConfig,
...updates,
},
});
};
const updateFieldMapping = (field: string, value: string) => {
updateConfig({
fieldMapping: {
...config.fieldMapping,
id: config.fieldMapping?.id || "id",
resourceId: config.fieldMapping?.resourceId || "resource_id",
title: config.fieldMapping?.title || "title",
startDate: config.fieldMapping?.startDate || "start_date",
endDate: config.fieldMapping?.endDate || "end_date",
[field]: value,
},
});
};
const updateResourceFieldMapping = (field: string, value: string) => {
updateConfig({
resourceFieldMapping: {
...config.resourceFieldMapping,
id: config.resourceFieldMapping?.id || "id",
name: config.resourceFieldMapping?.name || "name",
[field]: value,
},
});
};
return (
<div className="space-y-4">
{/* ─── 1단계: 스케줄 데이터 테이블 설정 ─── */}
<Collapsible open={scheduleDataOpen} onOpenChange={setScheduleDataOpen}>
<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">
<Layers className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="text-[10px] h-5">8 </Badge>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", scheduleDataOpen && "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>
<Select
value={config.scheduleType || "PRODUCTION"}
onValueChange={(v) => updateConfig({ scheduleType: v as ScheduleType })}
>
<SelectTrigger className="h-7 w-[140px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{scheduleTypeOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 뷰 모드 */}
<div className="flex items-center justify-between py-1">
<div>
<p className="text-xs text-muted-foreground truncate"> </p>
<p className="text-[10px] text-muted-foreground mt-0.5">
{viewModeOptions.find((o) => o.value === (config.viewMode || "resource"))?.description}
</p>
</div>
<Select
value={config.viewMode || "resource"}
onValueChange={(v) => updateConfig({ viewMode: v as any })}
>
<SelectTrigger className="h-7 w-[140px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{viewModeOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 커스텀 테이블 사용 여부 */}
<div className="flex items-center justify-between py-1">
<div>
<p className="text-xs text-muted-foreground"> </p>
<p className="text-[10px] text-muted-foreground mt-0.5">OFF: schedule_mng </p>
</div>
<Switch
checked={config.useCustomTable ?? false}
onCheckedChange={(v) => updateConfig({ useCustomTable: v })}
/>
</div>
{/* 커스텀 테이블 선택 (Combobox) */}
{config.useCustomTable && (
<div className="space-y-1">
<span className="text-xs text-muted-foreground"> </span>
<Popover open={customTableOpen} onOpenChange={setCustomTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={customTableOpen}
className="h-8 w-full justify-between text-xs"
disabled={loading}
>
{config.customTableName
? tables.find((t) => t.tableName === config.customTableName)?.displayName ||
config.customTableName
: "커스텀 테이블 선택..."}
<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
filter={(value, search) => {
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0;
}}
>
<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={() => {
updateConfig({ customTableName: table.tableName, selectedTable: table.tableName });
setCustomTableOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.customTableName === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{table.displayName}</span>
<span className="text-muted-foreground text-[10px]">{table.tableName}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* 디자인 모드 표시용 테이블명 (selectedTable) */}
{!config.useCustomTable && (
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> </span>
<Input
value={config.selectedTable || "schedule_mng"}
onChange={(e) => updateConfig({ selectedTable: e.target.value })}
placeholder="schedule_mng"
className="h-7 w-[140px] text-xs"
/>
</div>
)}
{/* 스케줄 필드 매핑 */}
<div className="ml-1 border-l-2 border-primary/20 pl-3 space-y-2 pt-1">
<p className="text-xs font-medium text-primary truncate"> </p>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate">ID *</span>
<Select
value={config.fieldMapping?.id || "schedule_id"}
onValueChange={(v) => updateFieldMapping("id", v)}
>
<SelectTrigger className="h-7 w-[130px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{scheduleColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> ID *</span>
<Select
value={config.fieldMapping?.resourceId || "resource_id"}
onValueChange={(v) => updateFieldMapping("resourceId", v)}
>
<SelectTrigger className="h-7 w-[130px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{scheduleColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> *</span>
<Select
value={config.fieldMapping?.title || "schedule_name"}
onValueChange={(v) => updateFieldMapping("title", v)}
>
<SelectTrigger className="h-7 w-[130px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{scheduleColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> *</span>
<Select
value={config.fieldMapping?.startDate || "start_date"}
onValueChange={(v) => updateFieldMapping("startDate", v)}
>
<SelectTrigger className="h-7 w-[130px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{scheduleColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> *</span>
<Select
value={config.fieldMapping?.endDate || "end_date"}
onValueChange={(v) => updateFieldMapping("endDate", v)}
>
<SelectTrigger className="h-7 w-[130px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{scheduleColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> </span>
<Select
value={config.fieldMapping?.status || ""}
onValueChange={(v) => updateFieldMapping("status", v)}
>
<SelectTrigger className="h-7 w-[130px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{scheduleColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> </span>
<Select
value={config.fieldMapping?.progress || ""}
onValueChange={(v) => updateFieldMapping("progress", v)}
>
<SelectTrigger className="h-7 w-[130px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{scheduleColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> </span>
<Select
value={config.fieldMapping?.color || ""}
onValueChange={(v) => updateFieldMapping("color", v)}
>
<SelectTrigger className="h-7 w-[130px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{scheduleColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 필터 & 연동 설정 ─── */}
<Collapsible open={filterLinkOpen} onOpenChange={setFilterLinkOpen}>
<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">
<Filter className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> & </span>
<Badge variant="secondary" className="text-[10px] h-5">
{Object.keys(config.staticFilters || {}).length + (config.linkedFilter ? 1 : 0)}
</Badge>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", filterLinkOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-4">
{/* 정적 필터 */}
<div className="space-y-2">
<p className="text-xs font-medium text-primary"> (staticFilters)</p>
<p className="text-[10px] text-muted-foreground"> </p>
{Object.entries(config.staticFilters || {}).map(([key, value]) => (
<div key={key} className="flex items-center gap-2">
<Input value={key} disabled className="h-7 flex-1 text-xs bg-muted/30" />
<span className="text-xs text-muted-foreground">=</span>
<Input value={value} disabled className="h-7 flex-1 text-xs bg-muted/30" />
<Button
variant="ghost"
size="sm"
onClick={() => {
const updated = { ...config.staticFilters };
delete updated[key];
updateConfig({ staticFilters: Object.keys(updated).length > 0 ? updated : undefined });
}}
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<div className="flex items-center gap-2">
<Input
value={newFilterKey}
onChange={(e) => setNewFilterKey(e.target.value)}
placeholder="필드명 (예: product_type)"
className="h-7 flex-1 text-xs"
/>
<span className="text-xs text-muted-foreground">=</span>
<Input
value={newFilterValue}
onChange={(e) => setNewFilterValue(e.target.value)}
placeholder="값 (예: 완제품)"
className="h-7 flex-1 text-xs"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
if (!newFilterKey.trim()) return;
updateConfig({
staticFilters: {
...(config.staticFilters || {}),
[newFilterKey.trim()]: newFilterValue.trim(),
},
});
setNewFilterKey("");
setNewFilterValue("");
}}
disabled={!newFilterKey.trim()}
className="h-7 w-7 p-0"
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
{/* 구분선 */}
<div className="border-t" />
{/* 연결 필터 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-primary flex items-center gap-1">
<Link className="h-3 w-3" />
(linkedFilter)
</p>
<p className="text-[10px] text-muted-foreground mt-0.5"> </p>
</div>
<Switch
checked={!!config.linkedFilter}
onCheckedChange={(v) => {
if (v) {
updateConfig({
linkedFilter: {
sourceField: "",
targetField: "",
showEmptyWhenNoSelection: true,
emptyMessage: "좌측 목록에서 항목을 선택하세요",
},
});
} else {
updateConfig({ linkedFilter: undefined });
}
}}
/>
</div>
{config.linkedFilter && (
<div className="ml-1 border-l-2 border-primary/20 pl-3 space-y-2 pt-1">
<div className="flex items-center justify-between py-1">
<div>
<span className="text-xs text-muted-foreground"> </span>
<p className="text-[10px] text-muted-foreground mt-0.5"> tableName </p>
</div>
<Popover open={linkedFilterTableOpen} onOpenChange={setLinkedFilterTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-[140px] justify-between text-xs"
disabled={loading}
>
{config.linkedFilter.sourceTableName || "선택..."}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[200px]" align="end">
<Command filter={(value, search) => value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0}>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs p-2"></CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName} ${table.tableName}`}
onSelect={() => {
updateConfig({
linkedFilter: { ...config.linkedFilter!, sourceTableName: table.tableName },
});
setLinkedFilterTableOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", config.linkedFilter?.sourceTableName === table.tableName ? "opacity-100" : "opacity-0")} />
{table.displayName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> (sourceField) *</span>
<Input
value={config.linkedFilter.sourceField || ""}
onChange={(e) => updateConfig({ linkedFilter: { ...config.linkedFilter!, sourceField: e.target.value } })}
placeholder="예: part_code"
className="h-7 w-[140px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> (targetField) *</span>
<Input
value={config.linkedFilter.targetField || ""}
onChange={(e) => updateConfig({ linkedFilter: { ...config.linkedFilter!, targetField: e.target.value } })}
placeholder="예: item_code"
className="h-7 w-[140px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input
value={config.linkedFilter.emptyMessage || ""}
onChange={(e) => updateConfig({ linkedFilter: { ...config.linkedFilter!, emptyMessage: e.target.value } })}
placeholder="선택 안내 문구"
className="h-7 w-[180px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Switch
checked={config.linkedFilter.showEmptyWhenNoSelection ?? true}
onCheckedChange={(v) => updateConfig({ linkedFilter: { ...config.linkedFilter!, showEmptyWhenNoSelection: v } })}
/>
</div>
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 2단계: 소스 데이터 설정 ─── */}
<Collapsible open={sourceDataOpen} onOpenChange={setSourceDataOpen}>
<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">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="text-[10px] h-5">
{config.sourceConfig?.tableName ? "4개 필드" : "미설정"}
</Badge>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", sourceDataOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
{/* 소스 테이블 Combobox */}
<div className="space-y-1">
<span className="text-xs text-muted-foreground truncate"> (/ )</span>
<Popover open={sourceTableOpen} onOpenChange={setSourceTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={sourceTableOpen}
className="h-8 w-full justify-between text-xs"
disabled={loading}
>
{config.sourceConfig?.tableName
? tables.find((t) => t.tableName === config.sourceConfig?.tableName)?.displayName ||
config.sourceConfig.tableName
: "소스 테이블 선택..."}
<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
filter={(value, search) => {
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0;
}}
>
<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={() => {
updateSourceConfig({ tableName: table.tableName });
setSourceTableOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.sourceConfig?.tableName === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{table.displayName}</span>
<span className="text-muted-foreground text-[10px]">{table.tableName}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 소스 필드 매핑 (테이블 선택 시) */}
{config.sourceConfig?.tableName && (
<div className="ml-1 border-l-2 border-primary/20 pl-3 space-y-2 pt-1">
<p className="text-xs font-medium text-primary truncate"> </p>
<div className="flex items-center justify-between py-1">
<div>
<span className="text-xs text-muted-foreground truncate"> (/) *</span>
<p className="text-[10px] text-muted-foreground mt-0.5"> </p>
</div>
<Select
value={config.sourceConfig?.dueDateField || ""}
onValueChange={(v) => updateSourceConfig({ dueDateField: v })}
>
<SelectTrigger className="h-7 w-[130px] text-xs">
<SelectValue placeholder="필수 선택" />
</SelectTrigger>
<SelectContent>
{sourceColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> </span>
<Select
value={config.sourceConfig?.quantityField || ""}
onValueChange={(v) => updateSourceConfig({ quantityField: v })}
>
<SelectTrigger className="h-7 w-[130px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{sourceColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> ()</span>
<Select
value={config.sourceConfig?.groupByField || ""}
onValueChange={(v) => updateSourceConfig({ groupByField: v })}
>
<SelectTrigger className="h-7 w-[130px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{sourceColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> ()</span>
<Select
value={config.sourceConfig?.groupNameField || ""}
onValueChange={(v) => updateSourceConfig({ groupNameField: v })}
>
<SelectTrigger className="h-7 w-[130px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{sourceColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 3단계: 리소스 설정 ─── */}
<Collapsible open={resourceOpen} onOpenChange={setResourceOpen}>
<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">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> (/)</span>
<Badge variant="secondary" className="text-[10px] h-5">
{config.resourceTable ? "3개 필드" : "미설정"}
</Badge>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", resourceOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
{/* 리소스 테이블 Combobox */}
<div className="space-y-1">
<span className="text-xs text-muted-foreground truncate"> </span>
<Popover open={resourceTableOpen} onOpenChange={setResourceTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={resourceTableOpen}
className="h-8 w-full justify-between text-xs"
disabled={loading}
>
{config.resourceTable
? tables.find((t) => t.tableName === config.resourceTable)?.displayName || config.resourceTable
: "리소스 테이블 선택..."}
<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
filter={(value, search) => {
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0;
}}
>
<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={() => {
updateConfig({ resourceTable: table.tableName });
setResourceTableOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.resourceTable === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{table.displayName}</span>
<span className="text-muted-foreground text-[10px]">{table.tableName}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 리소스 필드 매핑 */}
{config.resourceTable && (
<div className="ml-1 border-l-2 border-primary/20 pl-3 space-y-2 pt-1">
<p className="text-xs font-medium text-primary truncate"> </p>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate">ID </span>
<Select
value={config.resourceFieldMapping?.id || ""}
onValueChange={(v) => updateResourceFieldMapping("id", v)}
>
<SelectTrigger className="h-7 w-[130px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{resourceColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> </span>
<Select
value={config.resourceFieldMapping?.name || ""}
onValueChange={(v) => updateResourceFieldMapping("name", v)}
>
<SelectTrigger className="h-7 w-[130px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{resourceColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> </span>
<Select
value={config.resourceFieldMapping?.group || ""}
onValueChange={(v) => updateResourceFieldMapping("group", v)}
>
<SelectTrigger className="h-7 w-[130px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{resourceColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 4단계: 표시 설정 (Collapsible) ─── */}
<Collapsible open={displayOpen} onOpenChange={setDisplayOpen}>
<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"> </span>
<Badge variant="secondary" className="text-[10px] h-5">18</Badge>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
displayOpen && "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>
<Select
value={config.defaultZoomLevel || "day"}
onValueChange={(v) => updateConfig({ defaultZoomLevel: v as ZoomLevel })}
>
<SelectTrigger className="h-7 w-[100px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{zoomLevelOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 초기 표시 날짜 */}
<div className="flex items-center justify-between py-1">
<div>
<span className="text-xs text-muted-foreground truncate"> </span>
<p className="text-[10px] text-muted-foreground mt-0.5"> </p>
</div>
<Input
type="date"
value={config.initialDate || ""}
onChange={(e) => updateConfig({ initialDate: e.target.value || undefined })}
className="h-7 w-[140px] text-xs"
/>
</div>
{/* 높이 */}
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> (px)</span>
<Input
type="number"
value={config.height || 500}
onChange={(e) => updateConfig({ height: parseInt(e.target.value) || 500 })}
className="h-7 w-[100px] text-xs"
/>
</div>
{/* 최대 높이 */}
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> (px)</span>
<Input
type="number"
value={typeof config.maxHeight === "number" ? config.maxHeight : ""}
onChange={(e) => {
const val = e.target.value ? parseInt(e.target.value) : undefined;
updateConfig({ maxHeight: val });
}}
placeholder="제한 없음"
className="h-7 w-[100px] text-xs"
/>
</div>
{/* 행 높이 */}
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> (px)</span>
<Input
type="number"
value={config.rowHeight || 50}
onChange={(e) => updateConfig({ rowHeight: parseInt(e.target.value) || 50 })}
className="h-7 w-[100px] text-xs"
/>
</div>
{/* 헤더 높이 */}
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> (px)</span>
<Input
type="number"
value={config.headerHeight || 60}
onChange={(e) => updateConfig({ headerHeight: parseInt(e.target.value) || 60 })}
className="h-7 w-[100px] text-xs"
/>
</div>
{/* 리소스 컬럼 너비 */}
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> (px)</span>
<Input
type="number"
value={config.resourceColumnWidth || 150}
onChange={(e) => updateConfig({ resourceColumnWidth: parseInt(e.target.value) || 150 })}
className="h-7 w-[100px] text-xs"
/>
</div>
{/* 셀 너비 (줌 레벨별) */}
<div className="space-y-2 pt-1">
<span className="text-xs text-muted-foreground truncate"> ( , px)</span>
<div className="flex items-center gap-2">
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"></span>
<Input
type="number"
value={config.cellWidth?.day || 60}
onChange={(e) => updateConfig({ cellWidth: { ...config.cellWidth, day: parseInt(e.target.value) || 60 } })}
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"></span>
<Input
type="number"
value={config.cellWidth?.week || 120}
onChange={(e) => updateConfig({ cellWidth: { ...config.cellWidth, week: parseInt(e.target.value) || 120 } })}
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"></span>
<Input
type="number"
value={config.cellWidth?.month || 40}
onChange={(e) => updateConfig({ cellWidth: { ...config.cellWidth, month: parseInt(e.target.value) || 40 } })}
className="h-7 text-xs"
/>
</div>
</div>
</div>
{/* Switch 토글들 */}
<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.editable ?? true}
onCheckedChange={(v) => updateConfig({ editable: v })}
/>
</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.draggable ?? true}
onCheckedChange={(v) => updateConfig({ draggable: v })}
/>
</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.resizable ?? true}
onCheckedChange={(v) => updateConfig({ resizable: v })}
/>
</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.showTodayLine ?? true}
onCheckedChange={(v) => updateConfig({ showTodayLine: v })}
/>
</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.showProgress ?? true}
onCheckedChange={(v) => updateConfig({ showProgress: v })}
/>
</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.showConflicts ?? true}
onCheckedChange={(v) => updateConfig({ showConflicts: v })}
/>
</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.showToolbar ?? true}
onCheckedChange={(v) => updateConfig({ showToolbar: v })}
/>
</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.showZoomControls ?? true}
onCheckedChange={(v) => updateConfig({ showZoomControls: v })}
/>
</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.showNavigation ?? true}
onCheckedChange={(v) => updateConfig({ showNavigation: v })}
/>
</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.showAddButton ?? true}
onCheckedChange={(v) => updateConfig({ showAddButton: v })}
/>
</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.showLegend ?? true}
onCheckedChange={(v) => updateConfig({ showLegend: v })}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 5단계: 고급 설정 - 상태별 색상 (Collapsible) ─── */}
<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"> </span>
<Badge variant="secondary" className="text-[10px] h-5">5</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">
{[
{ key: "planned", label: "계획됨", defaultColor: "#3b82f6" },
{ key: "in_progress", label: "진행중", defaultColor: "#f59e0b" },
{ key: "completed", label: "완료", defaultColor: "#10b981" },
{ key: "delayed", label: "지연", defaultColor: "#ef4444" },
{ key: "cancelled", label: "취소", defaultColor: "#6b7280" },
].map((status) => (
<div key={status.key} className="flex items-center justify-between py-1">
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
style={{
backgroundColor:
(config.statusColors as any)?.[status.key] || status.defaultColor,
}}
/>
<span className="text-xs text-muted-foreground">{status.label}</span>
</div>
<div className="flex items-center gap-1.5">
<input
type="color"
value={(config.statusColors as any)?.[status.key] || status.defaultColor}
onChange={(e) =>
updateConfig({
statusColors: {
...config.statusColors,
[status.key]: e.target.value,
},
})
}
className="h-7 w-7 cursor-pointer rounded border"
/>
<Input
value={(config.statusColors as any)?.[status.key] || status.defaultColor}
onChange={(e) =>
updateConfig({
statusColors: {
...config.statusColors,
[status.key]: e.target.value,
},
})
}
className="h-7 w-[80px] text-xs"
/>
</div>
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 6단계: 툴바 액션 설정 ─── */}
<Collapsible open={actionsOpen} onOpenChange={setActionsOpen}>
<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">
<Zap className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="text-[10px] h-5">
{(config.toolbarActions || []).length}
</Badge>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", actionsOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<p className="text-[10px] text-muted-foreground">
API ( )
</p>
{/* 기존 액션 목록 */}
{(config.toolbarActions || []).map((action, index) => (
<Collapsible
key={action.id}
open={expandedActionId === action.id}
onOpenChange={(open) => setExpandedActionId(open ? action.id : null)}
>
<div className="rounded-lg border">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left hover:bg-muted/30"
>
<div className="flex items-center gap-2">
<GripVertical className="h-3 w-3 text-muted-foreground/50" />
<div className={cn("h-3 w-3 rounded-sm", action.color?.split(" ")[0] || "bg-primary")} />
<span className="text-xs font-medium">{action.label || "새 액션"}</span>
<Badge variant="outline" className="text-[9px] h-4">
{action.dataSource === "linkedSelection" ? "연결선택" : "스케줄"}
</Badge>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
const updated = (config.toolbarActions || []).filter((_, i) => i !== index);
updateConfig({ toolbarActions: updated.length > 0 ? updated : undefined });
}}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
<ChevronDown className={cn("h-3 w-3 text-muted-foreground transition-transform", expandedActionId === action.id && "rotate-180")} />
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t px-3 py-3 space-y-2.5">
{/* 기본 설정 */}
<div className="flex items-center gap-2">
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"></span>
<Input
value={action.label}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], label: e.target.value };
updateConfig({ toolbarActions: updated });
}}
className="h-7 text-xs"
/>
</div>
<div className="w-[110px]">
<span className="text-[10px] text-muted-foreground"></span>
<Select
value={action.icon || "Zap"}
onValueChange={(v) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], icon: v as any };
updateConfig({ toolbarActions: updated });
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{toolbarIconOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<span className="text-[10px] text-muted-foreground"> (Tailwind )</span>
<Input
value={action.color || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], color: e.target.value };
updateConfig({ toolbarActions: updated });
}}
placeholder="예: bg-emerald-600 hover:bg-emerald-700"
className="h-7 text-xs"
/>
</div>
{/* API 설정 */}
<div className="border-t pt-2">
<p className="text-[10px] font-medium text-primary mb-1.5">API </p>
<div className="space-y-1.5">
<div>
<span className="text-[10px] text-muted-foreground"> API *</span>
<Input
value={action.previewApi}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], previewApi: e.target.value };
updateConfig({ toolbarActions: updated });
}}
placeholder="/production/generate-schedule/preview"
className="h-7 text-xs"
/>
</div>
<div>
<span className="text-[10px] text-muted-foreground"> API *</span>
<Input
value={action.applyApi}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], applyApi: e.target.value };
updateConfig({ toolbarActions: updated });
}}
placeholder="/production/generate-schedule"
className="h-7 text-xs"
/>
</div>
</div>
</div>
{/* 다이얼로그 설정 */}
<div className="border-t pt-2">
<p className="text-[10px] font-medium text-primary mb-1.5"></p>
<div className="space-y-1.5">
<div>
<span className="text-[10px] text-muted-foreground"></span>
<Input
value={action.dialogTitle || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], dialogTitle: e.target.value };
updateConfig({ toolbarActions: updated });
}}
placeholder="자동 생성"
className="h-7 text-xs"
/>
</div>
<div>
<span className="text-[10px] text-muted-foreground"></span>
<Input
value={action.dialogDescription || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], dialogDescription: e.target.value };
updateConfig({ toolbarActions: updated });
}}
placeholder="미리보기 후 확인하여 적용합니다"
className="h-7 text-xs"
/>
</div>
</div>
</div>
{/* 데이터 소스 설정 */}
<div className="border-t pt-2">
<p className="text-[10px] font-medium text-primary mb-1.5"> </p>
<div className="space-y-1.5">
<div>
<span className="text-[10px] text-muted-foreground"> *</span>
<Select
value={action.dataSource}
onValueChange={(v) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], dataSource: v as any };
updateConfig({ toolbarActions: updated });
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{dataSourceOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
<div>
<span>{opt.label}</span>
<span className="ml-1 text-[10px] text-muted-foreground">({opt.description})</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{action.dataSource === "linkedSelection" && (
<div className="ml-2 border-l-2 border-blue-200 pl-2 space-y-1.5">
<div className="flex items-center gap-2">
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.groupByField || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, groupByField: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="linkedFilter.sourceField 사용"
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.quantityField || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, quantityField: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="balance_qty"
className="h-7 text-xs"
/>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.dueDateField || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, dueDateField: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="due_date"
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.nameField || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, nameField: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="part_name"
className="h-7 text-xs"
/>
</div>
</div>
</div>
)}
{action.dataSource === "currentSchedules" && (
<div className="ml-2 border-l-2 border-amber-200 pl-2 space-y-1.5">
<div className="flex items-center gap-2">
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.scheduleFilterField || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, scheduleFilterField: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="product_type"
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.scheduleFilterValue || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, scheduleFilterValue: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="완제품"
className="h-7 text-xs"
/>
</div>
</div>
</div>
)}
</div>
</div>
{/* 표시 조건 */}
<div className="border-t pt-2">
<p className="text-[10px] font-medium text-primary mb-1.5"> (showWhen)</p>
<p className="text-[9px] text-muted-foreground mb-1">staticFilters </p>
{Object.entries(action.showWhen || {}).map(([key, value]) => (
<div key={key} className="flex items-center gap-1 mb-1">
<Input value={key} disabled className="h-6 flex-1 text-[10px] bg-muted/30" />
<span className="text-[10px]">=</span>
<Input value={value} disabled className="h-6 flex-1 text-[10px] bg-muted/30" />
<Button
variant="ghost"
size="sm"
onClick={() => {
const updated = [...(config.toolbarActions || [])];
const newShowWhen = { ...updated[index].showWhen };
delete newShowWhen[key];
updated[index] = { ...updated[index], showWhen: Object.keys(newShowWhen).length > 0 ? newShowWhen : undefined };
updateConfig({ toolbarActions: updated });
}}
className="h-6 w-6 p-0 text-destructive"
>
<Trash2 className="h-2.5 w-2.5" />
</Button>
</div>
))}
<div className="flex items-center gap-1">
<Input
id={`showWhen-key-${index}`}
placeholder="필드명"
className="h-6 flex-1 text-[10px]"
/>
<span className="text-[10px]">=</span>
<Input
id={`showWhen-val-${index}`}
placeholder="값"
className="h-6 flex-1 text-[10px]"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
const keyEl = document.getElementById(`showWhen-key-${index}`) as HTMLInputElement;
const valEl = document.getElementById(`showWhen-val-${index}`) as HTMLInputElement;
if (!keyEl?.value?.trim()) return;
const updated = [...(config.toolbarActions || [])];
updated[index] = {
...updated[index],
showWhen: { ...(updated[index].showWhen || {}), [keyEl.value.trim()]: valEl?.value?.trim() || "" },
};
updateConfig({ toolbarActions: updated });
keyEl.value = "";
if (valEl) valEl.value = "";
}}
className="h-6 w-6 p-0"
>
<Plus className="h-2.5 w-2.5" />
</Button>
</div>
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
))}
{/* 액션 추가 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => {
const newAction: ToolbarAction = {
id: `action_${Date.now()}`,
label: "새 액션",
icon: "Zap",
color: "bg-primary hover:bg-primary/90",
previewApi: "",
applyApi: "",
dataSource: "linkedSelection",
};
updateConfig({
toolbarActions: [...(config.toolbarActions || []), newAction],
});
setExpandedActionId(newAction.id);
}}
className="w-full h-8 text-xs gap-1"
>
<Plus className="h-3 w-3" />
</Button>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2TimelineSchedulerConfigPanel.displayName = "V2TimelineSchedulerConfigPanel";
export default V2TimelineSchedulerConfigPanel;