"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) => void; } interface TableInfo { tableName: string; displayName: string; } interface ColumnInfo { columnName: string; displayName: string; } export const V2TimelineSchedulerConfigPanel: React.FC = ({ config, onChange, }) => { const [tables, setTables] = useState([]); const [sourceColumns, setSourceColumns] = useState([]); const [resourceColumns, setResourceColumns] = useState([]); const [scheduleColumns, setScheduleColumns] = useState([]); 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(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) => { onChange({ ...config, ...updates }); }; const updateSourceConfig = (updates: Partial) => { 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 (
{/* ─── 1단계: 스케줄 데이터 테이블 설정 ─── */}
{/* 스케줄 타입 */}
스케줄 타입
{/* 뷰 모드 */}

표시 모드

{viewModeOptions.find((o) => o.value === (config.viewMode || "resource"))?.description}

{/* 커스텀 테이블 사용 여부 */}

커스텀 테이블 사용

OFF: schedule_mng 사용

updateConfig({ useCustomTable: v })} />
{/* 커스텀 테이블 선택 (Combobox) */} {config.useCustomTable && (
커스텀 테이블명 { return value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0; }} > 테이블을 찾을 수 없습니다. {tables.map((table) => ( { updateConfig({ customTableName: table.tableName, selectedTable: table.tableName }); setCustomTableOpen(false); }} className="text-xs" >
{table.displayName} {table.tableName}
))}
)} {/* 디자인 모드 표시용 테이블명 (selectedTable) */} {!config.useCustomTable && (
디자인 모드 표시 테이블 updateConfig({ selectedTable: e.target.value })} placeholder="schedule_mng" className="h-7 w-[140px] text-xs" />
)} {/* 스케줄 필드 매핑 */}

스케줄 필드 매핑

ID 필드 *
리소스 ID 필드 *
제목 필드 *
시작일 필드 *
종료일 필드 *
상태 필드
진행률 필드
색상 필드
{/* ─── 필터 & 연동 설정 ─── */}
{/* 정적 필터 */}

정적 필터 (staticFilters)

데이터 조회 시 항상 적용되는 고정 필터 조건

{Object.entries(config.staticFilters || {}).map(([key, value]) => (
=
))}
setNewFilterKey(e.target.value)} placeholder="필드명 (예: product_type)" className="h-7 flex-1 text-xs" /> = setNewFilterValue(e.target.value)} placeholder="값 (예: 완제품)" className="h-7 flex-1 text-xs" />
{/* 구분선 */}
{/* 연결 필터 */}

연결 필터 (linkedFilter)

다른 컴포넌트 선택에 따라 데이터를 필터링

{ if (v) { updateConfig({ linkedFilter: { sourceField: "", targetField: "", showEmptyWhenNoSelection: true, emptyMessage: "좌측 목록에서 항목을 선택하세요", }, }); } else { updateConfig({ linkedFilter: undefined }); } }} />
{config.linkedFilter && (
소스 테이블명

선택 이벤트의 tableName 매칭

value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0}> 없음 {tables.map((table) => ( { updateConfig({ linkedFilter: { ...config.linkedFilter!, sourceTableName: table.tableName }, }); setLinkedFilterTableOpen(false); }} className="text-xs" > {table.displayName} ))}
소스 필드 (sourceField) * updateConfig({ linkedFilter: { ...config.linkedFilter!, sourceField: e.target.value } })} placeholder="예: part_code" className="h-7 w-[140px] text-xs" />
타겟 필드 (targetField) * updateConfig({ linkedFilter: { ...config.linkedFilter!, targetField: e.target.value } })} placeholder="예: item_code" className="h-7 w-[140px] text-xs" />
빈 상태 메시지 updateConfig({ linkedFilter: { ...config.linkedFilter!, emptyMessage: e.target.value } })} placeholder="선택 안내 문구" className="h-7 w-[180px] text-xs" />
선택 없을 때 빈 화면 updateConfig({ linkedFilter: { ...config.linkedFilter!, showEmptyWhenNoSelection: v } })} />
)}
{/* ─── 2단계: 소스 데이터 설정 ─── */}
{/* 소스 테이블 Combobox */}
소스 테이블 (수주/작업요청 등) { return value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0; }} > 테이블을 찾을 수 없습니다. {tables.map((table) => ( { updateSourceConfig({ tableName: table.tableName }); setSourceTableOpen(false); }} className="text-xs" >
{table.displayName} {table.tableName}
))}
{/* 소스 필드 매핑 (테이블 선택 시) */} {config.sourceConfig?.tableName && (

필드 매핑

기준일 (마감일/납기일) *

스케줄 종료일로 사용돼요

수량 필드
그룹 필드 (품목코드)
그룹명 필드 (품목명)
)}
{/* ─── 3단계: 리소스 설정 ─── */}
{/* 리소스 테이블 Combobox */}
리소스 테이블 { return value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0; }} > 테이블을 찾을 수 없습니다. {tables.map((table) => ( { updateConfig({ resourceTable: table.tableName }); setResourceTableOpen(false); }} className="text-xs" >
{table.displayName} {table.tableName}
))}
{/* 리소스 필드 매핑 */} {config.resourceTable && (

리소스 필드

ID 필드
이름 필드
그룹 필드
)}
{/* ─── 4단계: 표시 설정 (Collapsible) ─── */}
{/* 줌 레벨 */}
기본 줌 레벨
{/* 초기 표시 날짜 */}
초기 표시 날짜

비워두면 오늘 기준

updateConfig({ initialDate: e.target.value || undefined })} className="h-7 w-[140px] text-xs" />
{/* 높이 */}
높이 (px) updateConfig({ height: parseInt(e.target.value) || 500 })} className="h-7 w-[100px] text-xs" />
{/* 최대 높이 */}
최대 높이 (px) { const val = e.target.value ? parseInt(e.target.value) : undefined; updateConfig({ maxHeight: val }); }} placeholder="제한 없음" className="h-7 w-[100px] text-xs" />
{/* 행 높이 */}
행 높이 (px) updateConfig({ rowHeight: parseInt(e.target.value) || 50 })} className="h-7 w-[100px] text-xs" />
{/* 헤더 높이 */}
헤더 높이 (px) updateConfig({ headerHeight: parseInt(e.target.value) || 60 })} className="h-7 w-[100px] text-xs" />
{/* 리소스 컬럼 너비 */}
리소스 컬럼 너비 (px) updateConfig({ resourceColumnWidth: parseInt(e.target.value) || 150 })} className="h-7 w-[100px] text-xs" />
{/* 셀 너비 (줌 레벨별) */}
셀 너비 (줌 레벨별, px)
updateConfig({ cellWidth: { ...config.cellWidth, day: parseInt(e.target.value) || 60 } })} className="h-7 text-xs" />
updateConfig({ cellWidth: { ...config.cellWidth, week: parseInt(e.target.value) || 120 } })} className="h-7 text-xs" />
updateConfig({ cellWidth: { ...config.cellWidth, month: parseInt(e.target.value) || 40 } })} className="h-7 text-xs" />
{/* Switch 토글들 */}

편집 가능

스케줄을 직접 수정할 수 있어요

updateConfig({ editable: v })} />

드래그 이동

스케줄을 드래그해서 날짜를 변경해요

updateConfig({ draggable: v })} />

리사이즈

스케줄의 기간을 늘이거나 줄여요

updateConfig({ resizable: v })} />

오늘 표시선

오늘 날짜에 빨간 세로선을 표시해요

updateConfig({ showTodayLine: v })} />

진행률 표시

각 스케줄의 진행률 바를 보여줘요

updateConfig({ showProgress: v })} />

충돌 표시

스케줄 겹침 시 경고를 표시해요

updateConfig({ showConflicts: v })} />

툴바 표시

상단의 네비게이션/줌 컨트롤을 보여줘요

updateConfig({ showToolbar: v })} />

줌 컨트롤 표시

줌 레벨 변경 버튼을 보여줘요

updateConfig({ showZoomControls: v })} />

네비게이션 표시

이전/다음/오늘 버튼을 보여줘요

updateConfig({ showNavigation: v })} />

추가 버튼 표시

스케줄 추가 버튼을 보여줘요

updateConfig({ showAddButton: v })} />

범례 표시

상태별 색상 범례를 보여줘요

updateConfig({ showLegend: v })} />
{/* ─── 5단계: 고급 설정 - 상태별 색상 (Collapsible) ─── */}
{[ { 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) => (
{status.label}
updateConfig({ statusColors: { ...config.statusColors, [status.key]: e.target.value, }, }) } className="h-7 w-7 cursor-pointer rounded border" /> updateConfig({ statusColors: { ...config.statusColors, [status.key]: e.target.value, }, }) } className="h-7 w-[80px] text-xs" />
))}
{/* ─── 6단계: 툴바 액션 설정 ─── */}

툴바에 커스텀 버튼을 추가하여 API 호출 (미리보기 → 확인 → 적용) 워크플로우를 구성해요

{/* 기존 액션 목록 */} {(config.toolbarActions || []).map((action, index) => ( setExpandedActionId(open ? action.id : null)} >
{/* 기본 설정 */}
버튼명 { const updated = [...(config.toolbarActions || [])]; updated[index] = { ...updated[index], label: e.target.value }; updateConfig({ toolbarActions: updated }); }} className="h-7 text-xs" />
아이콘
버튼 색상 (Tailwind 클래스) { 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" />
{/* API 설정 */}

API 설정

미리보기 API * { 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" />
적용 API * { const updated = [...(config.toolbarActions || [])]; updated[index] = { ...updated[index], applyApi: e.target.value }; updateConfig({ toolbarActions: updated }); }} placeholder="/production/generate-schedule" className="h-7 text-xs" />
{/* 다이얼로그 설정 */}

다이얼로그

제목 { const updated = [...(config.toolbarActions || [])]; updated[index] = { ...updated[index], dialogTitle: e.target.value }; updateConfig({ toolbarActions: updated }); }} placeholder="자동 생성" className="h-7 text-xs" />
설명 { const updated = [...(config.toolbarActions || [])]; updated[index] = { ...updated[index], dialogDescription: e.target.value }; updateConfig({ toolbarActions: updated }); }} placeholder="미리보기 후 확인하여 적용합니다" className="h-7 text-xs" />
{/* 데이터 소스 설정 */}

데이터 소스

데이터 소스 유형 *
{action.dataSource === "linkedSelection" && (
그룹 필드 { 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" />
수량 필드 { 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" />
기준일 필드 { 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" />
표시명 필드 { 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" />
)} {action.dataSource === "currentSchedules" && (
필터 필드 { 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" />
필터 값 { 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" />
)}
{/* 표시 조건 */}

표시 조건 (showWhen)

staticFilters 값과 비교하여 일치할 때만 버튼 표시

{Object.entries(action.showWhen || {}).map(([key, value]) => (
=
))}
=
))} {/* 액션 추가 버튼 */}
); }; V2TimelineSchedulerConfigPanel.displayName = "V2TimelineSchedulerConfigPanel"; export default V2TimelineSchedulerConfigPanel;