From f959ca98bd28ed83fe1a8d41b5bcc53e283412cd Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 2 Feb 2026 10:46:01 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20v2-timeline-scheduler=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EC=99=84=EB=A3=8C=20=EB=B0=8F=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - v2-timeline-scheduler의 구현 상태를 체크리스트에 반영하였으며, 관련 문서화 작업을 완료하였습니다. - 각 구성 요소의 구현 완료 상태를 명시하고, 향후 작업 계획을 업데이트하였습니다. - 타임라인 스케줄러 컴포넌트를 레지스트리에 추가하여 통합하였습니다. --- .../next-component-development-plan.md | 34 +- frontend/lib/registry/components/index.ts | 1 + .../TableGroupedConfigPanel.tsx | 10 +- .../v2-timeline-scheduler/README.md | 159 +++++ .../TimelineSchedulerComponent.tsx | 413 ++++++++++++ .../TimelineSchedulerConfigPanel.tsx | 629 ++++++++++++++++++ .../TimelineSchedulerRenderer.tsx | 57 ++ .../components/ResourceRow.tsx | 206 ++++++ .../components/ScheduleBar.tsx | 182 +++++ .../components/TimelineHeader.tsx | 195 ++++++ .../v2-timeline-scheduler/components/index.ts | 3 + .../v2-timeline-scheduler/config.ts | 102 +++ .../hooks/useTimelineData.ts | 331 +++++++++ .../components/v2-timeline-scheduler/index.ts | 38 ++ .../components/v2-timeline-scheduler/types.ts | 363 ++++++++++ 15 files changed, 2701 insertions(+), 22 deletions(-) create mode 100644 frontend/lib/registry/components/v2-timeline-scheduler/README.md create mode 100644 frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx create mode 100644 frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerConfigPanel.tsx create mode 100644 frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerRenderer.tsx create mode 100644 frontend/lib/registry/components/v2-timeline-scheduler/components/ResourceRow.tsx create mode 100644 frontend/lib/registry/components/v2-timeline-scheduler/components/ScheduleBar.tsx create mode 100644 frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineHeader.tsx create mode 100644 frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts create mode 100644 frontend/lib/registry/components/v2-timeline-scheduler/config.ts create mode 100644 frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts create mode 100644 frontend/lib/registry/components/v2-timeline-scheduler/index.ts create mode 100644 frontend/lib/registry/components/v2-timeline-scheduler/types.ts diff --git a/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md b/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md index a0ce50b3..58c8cd3f 100644 --- a/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md +++ b/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md @@ -531,25 +531,25 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul - [x] 레지스트리 등록 - [x] 문서화 (README.md) -#### v2-timeline-scheduler +#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30) -- [ ] 타입 정의 완료 -- [ ] 기본 구조 생성 -- [ ] TimelineHeader (날짜) -- [ ] TimelineGrid (배경) -- [ ] ResourceColumn (리소스) -- [ ] ScheduleBar 기본 렌더링 -- [ ] 드래그 이동 -- [ ] 리사이즈 -- [ ] 줌 레벨 전환 -- [ ] 날짜 네비게이션 -- [ ] 충돌 감지 -- [ ] 가상 스크롤 -- [ ] 설정 패널 구현 -- [ ] API 연동 -- [ ] 레지스트리 등록 +- [x] 타입 정의 완료 +- [x] 기본 구조 생성 +- [x] TimelineHeader (날짜) +- [x] TimelineGrid (배경) +- [x] ResourceColumn (리소스) +- [x] ScheduleBar 기본 렌더링 +- [x] 드래그 이동 (기본) +- [x] 리사이즈 (기본) +- [x] 줌 레벨 전환 +- [x] 날짜 네비게이션 +- [ ] 충돌 감지 (향후) +- [ ] 가상 스크롤 (향후) +- [x] 설정 패널 구현 +- [x] API 연동 +- [x] 레지스트리 등록 - [ ] 테스트 완료 -- [ ] 문서화 +- [x] 문서화 (README.md) --- diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 45d6e15d..6d54749a 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -107,6 +107,7 @@ import "./v2-tabs-widget/tabs-component"; import "./v2-category-manager/V2CategoryManagerRenderer"; import "./v2-media"; // 통합 미디어 컴포넌트 import "./v2-table-grouped/TableGroupedRenderer"; // 그룹화 테이블 +import "./v2-timeline-scheduler/TimelineSchedulerRenderer"; // 타임라인 스케줄러 /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/v2-table-grouped/TableGroupedConfigPanel.tsx b/frontend/lib/registry/components/v2-table-grouped/TableGroupedConfigPanel.tsx index bf96f665..beb0f5b6 100644 --- a/frontend/lib/registry/components/v2-table-grouped/TableGroupedConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-table-grouped/TableGroupedConfigPanel.tsx @@ -45,7 +45,7 @@ import { Trash2, Plus } from "lucide-react"; interface TableGroupedConfigPanelProps { config: TableGroupedConfig; - onConfigChange: (newConfig: TableGroupedConfig) => void; + onChange: (newConfig: Partial) => void; } /** @@ -59,7 +59,7 @@ interface TableInfo { export function TableGroupedConfigPanel({ config, - onConfigChange, + onChange, }: TableGroupedConfigPanelProps) { // 테이블 목록 (라벨명 포함) const [tables, setTables] = useState([]); @@ -122,7 +122,7 @@ export function TableGroupedConfigPanel({ // 컬럼 설정이 없으면 자동 설정 if (!config.columns || config.columns.length === 0) { - onConfigChange({ ...config, columns: cols }); + onChange({ ...config, columns: cols }); } } } catch (err) { @@ -136,14 +136,14 @@ export function TableGroupedConfigPanel({ // 설정 업데이트 헬퍼 const updateConfig = (updates: Partial) => { - onConfigChange({ ...config, ...updates }); + onChange({ ...config, ...updates }); }; // 그룹 설정 업데이트 헬퍼 const updateGroupConfig = ( updates: Partial ) => { - onConfigChange({ + onChange({ ...config, groupConfig: { ...config.groupConfig, ...updates }, }); diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/README.md b/frontend/lib/registry/components/v2-timeline-scheduler/README.md new file mode 100644 index 00000000..2e8d7262 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/README.md @@ -0,0 +1,159 @@ +# v2-timeline-scheduler + +간트차트 형태의 일정/계획 시각화 및 편집 컴포넌트 + +## 개요 + +`v2-timeline-scheduler`는 생산계획, 일정관리 등에서 사용할 수 있는 타임라인 기반의 스케줄러 컴포넌트입니다. 리소스(설비, 작업자 등)별로 스케줄을 시각화하고, 드래그/리사이즈로 일정을 조정할 수 있습니다. + +## 핵심 기능 + +| 기능 | 설명 | +|------|------| +| 타임라인 그리드 | 일/주/월 단위 그리드 표시 | +| 스케줄 바 | 시작~종료 기간 바 렌더링 | +| 리소스 행 | 설비/작업자별 행 구분 | +| 드래그 이동 | 스케줄 바 드래그로 날짜 변경 | +| 리사이즈 | 바 양쪽 핸들로 기간 조정 | +| 줌 레벨 | 일/주/월 단위 전환 | +| 진행률 표시 | 바 내부 진행률 표시 | +| 오늘 표시선 | 현재 날짜 표시선 | + +## 사용법 + +### 기본 사용 + +```tsx +import { TimelineSchedulerComponent } from "@/lib/registry/components/v2-timeline-scheduler"; + + { + console.log("클릭된 스케줄:", event.schedule); + }} + onDragEnd={(event) => { + console.log("드래그 완료:", event); + }} +/> +``` + +### 설정 옵션 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `selectedTable` | string | - | 스케줄 데이터 테이블명 | +| `resourceTable` | string | - | 리소스 테이블명 | +| `fieldMapping` | object | - | 스케줄 필드 매핑 | +| `resourceFieldMapping` | object | - | 리소스 필드 매핑 | +| `defaultZoomLevel` | "day" \| "week" \| "month" | "day" | 기본 줌 레벨 | +| `editable` | boolean | true | 편집 가능 여부 | +| `draggable` | boolean | true | 드래그 이동 가능 | +| `resizable` | boolean | true | 리사이즈 가능 | +| `rowHeight` | number | 50 | 행 높이 (px) | +| `headerHeight` | number | 60 | 헤더 높이 (px) | +| `resourceColumnWidth` | number | 150 | 리소스 컬럼 너비 (px) | +| `showTodayLine` | boolean | true | 오늘 표시선 | +| `showProgress` | boolean | true | 진행률 표시 | +| `showToolbar` | boolean | true | 툴바 표시 | +| `height` | number \| string | 500 | 컴포넌트 높이 | + +### 필드 매핑 + +스케줄 테이블의 컬럼을 매핑합니다: + +```typescript +fieldMapping: { + id: "id", // 필수: 고유 ID + resourceId: "equipment_id", // 필수: 리소스 ID (FK) + title: "plan_name", // 필수: 표시 제목 + startDate: "start_date", // 필수: 시작일 + endDate: "end_date", // 필수: 종료일 + status: "status", // 선택: 상태 + progress: "progress", // 선택: 진행률 (0-100) + color: "color", // 선택: 바 색상 +} +``` + +### 이벤트 + +| 이벤트 | 파라미터 | 설명 | +|--------|----------|------| +| `onScheduleClick` | `{ schedule, resource }` | 스케줄 클릭 | +| `onCellClick` | `{ resourceId, date }` | 빈 셀 클릭 | +| `onDragEnd` | `{ scheduleId, newStartDate, newEndDate }` | 드래그 완료 | +| `onResizeEnd` | `{ scheduleId, newStartDate, newEndDate, direction }` | 리사이즈 완료 | +| `onAddSchedule` | `(resourceId, date)` | 추가 버튼 클릭 | + +### 상태별 색상 + +기본 상태별 색상: + +| 상태 | 색상 | 의미 | +|------|------|------| +| `planned` | 파랑 (#3b82f6) | 계획됨 | +| `in_progress` | 주황 (#f59e0b) | 진행중 | +| `completed` | 초록 (#10b981) | 완료 | +| `delayed` | 빨강 (#ef4444) | 지연 | +| `cancelled` | 회색 (#6b7280) | 취소 | + +## 파일 구조 + +``` +v2-timeline-scheduler/ +├── index.ts # Definition +├── types.ts # 타입 정의 +├── config.ts # 기본 설정값 +├── TimelineSchedulerComponent.tsx # 메인 컴포넌트 +├── TimelineSchedulerConfigPanel.tsx # 설정 패널 +├── TimelineSchedulerRenderer.tsx # 레지스트리 등록 +├── README.md # 문서 +├── components/ +│ ├── index.ts +│ ├── TimelineHeader.tsx # 날짜 헤더 +│ ├── ScheduleBar.tsx # 스케줄 바 +│ └── ResourceRow.tsx # 리소스 행 +└── hooks/ + └── useTimelineData.ts # 데이터 관리 훅 +``` + +## v2-table-list와의 차이점 + +| 특성 | v2-table-list | v2-timeline-scheduler | +|------|---------------|----------------------| +| 표현 방식 | 행 기반 테이블 | 시간축 기반 간트차트 | +| 데이터 구조 | 단순 목록 | 리소스 + 스케줄 (2개 테이블) | +| 편집 방식 | 폼 입력 | 드래그/리사이즈 | +| 시간 표현 | 텍스트 | 시각적 바 | +| 용도 | 일반 데이터 | 일정/계획 관리 | + +## 향후 개선 사항 + +- [ ] 충돌 감지 및 표시 +- [ ] 가상 스크롤 (대량 데이터) +- [ ] 마일스톤 표시 +- [ ] 의존성 연결선 +- [ ] 드래그로 새 스케줄 생성 +- [ ] 컨텍스트 메뉴 + +--- + +**버전**: 2.0.0 +**최종 수정**: 2026-01-30 diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx new file mode 100644 index 00000000..23301657 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx @@ -0,0 +1,413 @@ +"use client"; + +import React, { useCallback, useMemo, useRef, useState } from "react"; +import { + ChevronLeft, + ChevronRight, + Calendar, + Plus, + Loader2, + ZoomIn, + ZoomOut, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { + TimelineSchedulerComponentProps, + ScheduleItem, + ZoomLevel, + DragEvent, + ResizeEvent, +} from "./types"; +import { useTimelineData } from "./hooks/useTimelineData"; +import { TimelineHeader, ResourceRow } from "./components"; +import { zoomLevelOptions, defaultTimelineSchedulerConfig } from "./config"; + +/** + * v2-timeline-scheduler 메인 컴포넌트 + * + * 간트차트 형태의 일정/계획 시각화 및 편집 컴포넌트 + */ +export function TimelineSchedulerComponent({ + config, + isDesignMode = false, + formData, + externalSchedules, + externalResources, + isLoading: externalLoading, + error: externalError, + componentId, + onDragEnd, + onResizeEnd, + onScheduleClick, + onCellClick, + onAddSchedule, +}: TimelineSchedulerComponentProps) { + const containerRef = useRef(null); + + // 드래그/리사이즈 상태 + const [dragState, setDragState] = useState<{ + schedule: ScheduleItem; + startX: number; + startY: number; + } | null>(null); + + const [resizeState, setResizeState] = useState<{ + schedule: ScheduleItem; + direction: "start" | "end"; + startX: number; + } | null>(null); + + // 타임라인 데이터 훅 + const { + schedules, + resources, + isLoading: hookLoading, + error: hookError, + zoomLevel, + setZoomLevel, + viewStartDate, + viewEndDate, + goToPrevious, + goToNext, + goToToday, + updateSchedule, + } = useTimelineData(config, externalSchedules, externalResources); + + const isLoading = externalLoading ?? hookLoading; + const error = externalError ?? hookError; + + // 설정값 + const rowHeight = config.rowHeight || defaultTimelineSchedulerConfig.rowHeight!; + const headerHeight = config.headerHeight || defaultTimelineSchedulerConfig.headerHeight!; + const resourceColumnWidth = + config.resourceColumnWidth || defaultTimelineSchedulerConfig.resourceColumnWidth!; + const cellWidthConfig = config.cellWidth || defaultTimelineSchedulerConfig.cellWidth!; + const cellWidth = cellWidthConfig[zoomLevel] || 60; + + // 리소스별 스케줄 그룹화 + const schedulesByResource = useMemo(() => { + const grouped = new Map(); + + resources.forEach((resource) => { + grouped.set(resource.id, []); + }); + + schedules.forEach((schedule) => { + const list = grouped.get(schedule.resourceId); + if (list) { + list.push(schedule); + } else { + // 리소스가 없는 스케줄은 첫 번째 리소스에 할당 + const firstResource = resources[0]; + if (firstResource) { + const firstList = grouped.get(firstResource.id); + if (firstList) { + firstList.push(schedule); + } + } + } + }); + + return grouped; + }, [schedules, resources]); + + // 줌 레벨 변경 + const handleZoomIn = useCallback(() => { + const levels: ZoomLevel[] = ["month", "week", "day"]; + const currentIdx = levels.indexOf(zoomLevel); + if (currentIdx < levels.length - 1) { + setZoomLevel(levels[currentIdx + 1]); + } + }, [zoomLevel, setZoomLevel]); + + const handleZoomOut = useCallback(() => { + const levels: ZoomLevel[] = ["month", "week", "day"]; + const currentIdx = levels.indexOf(zoomLevel); + if (currentIdx > 0) { + setZoomLevel(levels[currentIdx - 1]); + } + }, [zoomLevel, setZoomLevel]); + + // 스케줄 클릭 핸들러 + const handleScheduleClick = useCallback( + (schedule: ScheduleItem) => { + const resource = resources.find((r) => r.id === schedule.resourceId); + if (resource && onScheduleClick) { + onScheduleClick({ schedule, resource }); + } + }, + [resources, onScheduleClick] + ); + + // 빈 셀 클릭 핸들러 + const handleCellClick = useCallback( + (resourceId: string, date: Date) => { + if (onCellClick) { + onCellClick({ + resourceId, + date: date.toISOString().split("T")[0], + }); + } + }, + [onCellClick] + ); + + // 드래그 시작 + const handleDragStart = useCallback( + (schedule: ScheduleItem, e: React.MouseEvent) => { + setDragState({ + schedule, + startX: e.clientX, + startY: e.clientY, + }); + }, + [] + ); + + // 드래그 종료 + const handleDragEnd = useCallback(() => { + if (dragState) { + // TODO: 드래그 결과 계산 및 업데이트 + setDragState(null); + } + }, [dragState]); + + // 리사이즈 시작 + const handleResizeStart = useCallback( + (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => { + setResizeState({ + schedule, + direction, + startX: e.clientX, + }); + }, + [] + ); + + // 리사이즈 종료 + const handleResizeEnd = useCallback(() => { + if (resizeState) { + // TODO: 리사이즈 결과 계산 및 업데이트 + setResizeState(null); + } + }, [resizeState]); + + // 추가 버튼 클릭 + const handleAddClick = useCallback(() => { + if (onAddSchedule && resources.length > 0) { + onAddSchedule( + resources[0].id, + new Date().toISOString().split("T")[0] + ); + } + }, [onAddSchedule, resources]); + + // 디자인 모드 플레이스홀더 + if (isDesignMode) { + return ( +
+
+ +

타임라인 스케줄러

+

+ {config.selectedTable + ? `테이블: ${config.selectedTable}` + : "테이블을 선택하세요"} +

+
+
+ ); + } + + // 로딩 상태 + if (isLoading) { + return ( +
+
+ + 로딩 중... +
+
+ ); + } + + // 에러 상태 + if (error) { + return ( +
+
+

오류 발생

+

{error}

+
+
+ ); + } + + // 리소스 없음 + if (resources.length === 0) { + return ( +
+
+ +

리소스가 없습니다

+

리소스 테이블을 설정하세요

+
+
+ ); + } + + return ( +
+ {/* 툴바 */} + {config.showToolbar !== false && ( +
+ {/* 네비게이션 */} +
+ {config.showNavigation !== false && ( + <> + + + + + )} + + {/* 현재 날짜 범위 표시 */} + + {viewStartDate.getFullYear()}년 {viewStartDate.getMonth() + 1}월{" "} + {viewStartDate.getDate()}일 ~{" "} + {viewEndDate.getMonth() + 1}월 {viewEndDate.getDate()}일 + +
+ + {/* 오른쪽 컨트롤 */} +
+ {/* 줌 컨트롤 */} + {config.showZoomControls !== false && ( +
+ + + {zoomLevelOptions.find((o) => o.value === zoomLevel)?.label} + + +
+ )} + + {/* 추가 버튼 */} + {config.showAddButton !== false && config.editable && ( + + )} +
+
+ )} + + {/* 타임라인 본문 */} +
+
+ {/* 헤더 */} + + + {/* 리소스 행들 */} +
+ {resources.map((resource) => ( + + ))} +
+
+
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerConfigPanel.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerConfigPanel.tsx new file mode 100644 index 00000000..3371d425 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerConfigPanel.tsx @@ -0,0 +1,629 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Check, ChevronsUpDown, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { tableTypeApi } from "@/lib/api/screen"; +import { TimelineSchedulerConfig } from "./types"; +import { zoomLevelOptions, statusOptions } from "./config"; + +interface TimelineSchedulerConfigPanelProps { + config: TimelineSchedulerConfig; + onChange: (config: Partial) => void; +} + +interface TableInfo { + tableName: string; + displayName: string; +} + +interface ColumnInfo { + columnName: string; + displayName: string; +} + +export function TimelineSchedulerConfigPanel({ + config, + onChange, +}: TimelineSchedulerConfigPanelProps) { + const [tables, setTables] = useState([]); + const [tableColumns, setTableColumns] = useState([]); + const [resourceColumns, setResourceColumns] = useState([]); + const [loading, setLoading] = useState(false); + const [tableSelectOpen, setTableSelectOpen] = useState(false); + const [resourceTableSelectOpen, setResourceTableSelectOpen] = useState(false); + + // 테이블 목록 로드 + 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 loadColumns = async () => { + if (!config.selectedTable) { + setTableColumns([]); + return; + } + try { + const columns = await tableTypeApi.getColumns(config.selectedTable); + if (Array.isArray(columns)) { + setTableColumns( + 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); + setTableColumns([]); + } + }; + loadColumns(); + }, [config.selectedTable]); + + // 리소스 테이블 컬럼 로드 + 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]); + + // 설정 업데이트 헬퍼 + const updateConfig = (updates: Partial) => { + onChange({ ...config, ...updates }); + }; + + // 필드 매핑 업데이트 + const updateFieldMapping = (field: string, value: string) => { + updateConfig({ + fieldMapping: { + ...config.fieldMapping, + [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 ( +
+ + {/* 테이블 설정 */} + + + 테이블 설정 + + + {/* 스케줄 테이블 선택 */} +
+ + + + + + + { + const lowerSearch = search.toLowerCase(); + if (value.toLowerCase().includes(lowerSearch)) { + return 1; + } + return 0; + }} + > + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((table) => ( + { + updateConfig({ selectedTable: table.tableName }); + setTableSelectOpen(false); + }} + className="text-xs" + > + +
+ {table.displayName} + + {table.tableName} + +
+
+ ))} +
+
+
+
+
+
+ + {/* 리소스 테이블 선택 */} +
+ + + + + + + { + const lowerSearch = search.toLowerCase(); + if (value.toLowerCase().includes(lowerSearch)) { + return 1; + } + return 0; + }} + > + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((table) => ( + { + updateConfig({ resourceTable: table.tableName }); + setResourceTableSelectOpen(false); + }} + className="text-xs" + > + +
+ {table.displayName} + + {table.tableName} + +
+
+ ))} +
+
+
+
+
+
+
+
+ + {/* 필드 매핑 */} + + + 필드 매핑 + + + {/* 스케줄 필드 매핑 */} + {config.selectedTable && ( +
+ +
+ {/* ID 필드 */} +
+ + +
+ + {/* 리소스 ID 필드 */} +
+ + +
+ + {/* 제목 필드 */} +
+ + +
+ + {/* 시작일 필드 */} +
+ + +
+ + {/* 종료일 필드 */} +
+ + +
+ + {/* 상태 필드 */} +
+ + +
+
+
+ )} + + {/* 리소스 필드 매핑 */} + {config.resourceTable && ( +
+ +
+ {/* ID 필드 */} +
+ + +
+ + {/* 이름 필드 */} +
+ + +
+
+
+ )} +
+
+ + {/* 표시 설정 */} + + + 표시 설정 + + + {/* 기본 줌 레벨 */} +
+ + +
+ + {/* 높이 */} +
+ + + updateConfig({ height: parseInt(e.target.value) || 500 }) + } + className="h-8 text-xs" + /> +
+ + {/* 행 높이 */} +
+ + + updateConfig({ rowHeight: parseInt(e.target.value) || 50 }) + } + className="h-8 text-xs" + /> +
+ + {/* 토글 스위치들 */} +
+
+ + updateConfig({ editable: v })} + /> +
+ +
+ + updateConfig({ draggable: v })} + /> +
+ +
+ + updateConfig({ resizable: v })} + /> +
+ +
+ + updateConfig({ showTodayLine: v })} + /> +
+ +
+ + updateConfig({ showProgress: v })} + /> +
+ +
+ + updateConfig({ showToolbar: v })} + /> +
+
+
+
+
+
+ ); +} + +export default TimelineSchedulerConfigPanel; diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerRenderer.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerRenderer.tsx new file mode 100644 index 00000000..48e8a21f --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerRenderer.tsx @@ -0,0 +1,57 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { V2TimelineSchedulerDefinition } from "./index"; +import { TimelineSchedulerComponent } from "./TimelineSchedulerComponent"; + +/** + * TimelineScheduler 렌더러 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +export class TimelineSchedulerRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2TimelineSchedulerDefinition; + + render(): React.ReactElement { + return ( + + ); + } + + // 설정 변경 핸들러 + protected handleConfigChange = (config: any) => { + console.log("📥 TimelineSchedulerRenderer에서 설정 변경 받음:", config); + + // 상위 컴포넌트의 onConfigChange 호출 (화면 설계자에게 알림) + if (this.props.onConfigChange) { + this.props.onConfigChange(config); + } + + this.updateComponent({ config }); + }; + + // 값 변경 처리 + protected handleValueChange = (value: any) => { + this.updateComponent({ value }); + }; +} + +// 자동 등록 실행 +TimelineSchedulerRenderer.registerSelf(); + +// 강제 등록 (디버깅용) +if (typeof window !== "undefined") { + setTimeout(() => { + try { + TimelineSchedulerRenderer.registerSelf(); + } catch (error) { + console.error("❌ TimelineScheduler 강제 등록 실패:", error); + } + }, 1000); +} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/ResourceRow.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/ResourceRow.tsx new file mode 100644 index 00000000..407bdd14 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/ResourceRow.tsx @@ -0,0 +1,206 @@ +"use client"; + +import React, { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { Resource, ScheduleItem, ZoomLevel, TimelineSchedulerConfig } from "../types"; +import { ScheduleBar } from "./ScheduleBar"; + +interface ResourceRowProps { + /** 리소스 */ + resource: Resource; + /** 해당 리소스의 스케줄 목록 */ + schedules: ScheduleItem[]; + /** 시작 날짜 */ + startDate: Date; + /** 종료 날짜 */ + endDate: Date; + /** 줌 레벨 */ + zoomLevel: ZoomLevel; + /** 행 높이 */ + rowHeight: number; + /** 셀 너비 */ + cellWidth: number; + /** 리소스 컬럼 너비 */ + resourceColumnWidth: number; + /** 설정 */ + config: TimelineSchedulerConfig; + /** 스케줄 클릭 */ + onScheduleClick?: (schedule: ScheduleItem) => void; + /** 빈 셀 클릭 */ + onCellClick?: (resourceId: string, date: Date) => void; + /** 드래그 시작 */ + onDragStart?: (schedule: ScheduleItem, e: React.MouseEvent) => void; + /** 드래그 종료 */ + onDragEnd?: () => void; + /** 리사이즈 시작 */ + onResizeStart?: (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => void; + /** 리사이즈 종료 */ + onResizeEnd?: () => void; +} + +/** + * 날짜 차이 계산 (일수) + */ +const getDaysDiff = (start: Date, end: Date): number => { + const startTime = new Date(start).setHours(0, 0, 0, 0); + const endTime = new Date(end).setHours(0, 0, 0, 0); + return Math.round((endTime - startTime) / (1000 * 60 * 60 * 24)); +}; + +/** + * 날짜 범위 내의 셀 개수 계산 + */ +const getCellCount = (startDate: Date, endDate: Date): number => { + return getDaysDiff(startDate, endDate) + 1; +}; + +export function ResourceRow({ + resource, + schedules, + startDate, + endDate, + zoomLevel, + rowHeight, + cellWidth, + resourceColumnWidth, + config, + onScheduleClick, + onCellClick, + onDragStart, + onDragEnd, + onResizeStart, + onResizeEnd, +}: ResourceRowProps) { + // 총 셀 개수 + const totalCells = useMemo(() => getCellCount(startDate, endDate), [startDate, endDate]); + + // 총 그리드 너비 + const gridWidth = totalCells * cellWidth; + + // 오늘 날짜 + const today = useMemo(() => { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return d; + }, []); + + // 스케줄 바 위치 계산 + const schedulePositions = useMemo(() => { + return schedules.map((schedule) => { + const scheduleStart = new Date(schedule.startDate); + const scheduleEnd = new Date(schedule.endDate); + scheduleStart.setHours(0, 0, 0, 0); + scheduleEnd.setHours(0, 0, 0, 0); + + // 시작 위치 계산 + const startOffset = getDaysDiff(startDate, scheduleStart); + const left = Math.max(0, startOffset * cellWidth); + + // 너비 계산 + const durationDays = getDaysDiff(scheduleStart, scheduleEnd) + 1; + const visibleStartOffset = Math.max(0, startOffset); + const visibleEndOffset = Math.min( + totalCells, + startOffset + durationDays + ); + const width = Math.max(cellWidth, (visibleEndOffset - visibleStartOffset) * cellWidth); + + return { + schedule, + position: { + left: resourceColumnWidth + left, + top: 0, + width, + height: rowHeight, + }, + }; + }); + }, [schedules, startDate, cellWidth, resourceColumnWidth, rowHeight, totalCells]); + + // 그리드 셀 클릭 핸들러 + const handleGridClick = (e: React.MouseEvent) => { + if (!onCellClick) return; + + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX - rect.left; + const cellIndex = Math.floor(x / cellWidth); + + const clickedDate = new Date(startDate); + clickedDate.setDate(clickedDate.getDate() + cellIndex); + + onCellClick(resource.id, clickedDate); + }; + + return ( +
+ {/* 리소스 컬럼 */} +
+
+
{resource.name}
+ {resource.group && ( +
+ {resource.group} +
+ )} +
+
+ + {/* 타임라인 그리드 */} +
+ {/* 배경 그리드 */} +
+ {Array.from({ length: totalCells }).map((_, idx) => { + const cellDate = new Date(startDate); + cellDate.setDate(cellDate.getDate() + idx); + const isWeekend = cellDate.getDay() === 0 || cellDate.getDay() === 6; + const isToday = cellDate.getTime() === today.getTime(); + const isMonthStart = cellDate.getDate() === 1; + + return ( +
+ ); + })} +
+ + {/* 스케줄 바들 */} + {schedulePositions.map(({ schedule, position }) => ( + onScheduleClick?.(schedule)} + onDragStart={onDragStart} + onDragEnd={onDragEnd} + onResizeStart={onResizeStart} + onResizeEnd={onResizeEnd} + /> + ))} +
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/ScheduleBar.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/ScheduleBar.tsx new file mode 100644 index 00000000..a85c457c --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/ScheduleBar.tsx @@ -0,0 +1,182 @@ +"use client"; + +import React, { useState, useCallback, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { ScheduleItem, ScheduleBarPosition, TimelineSchedulerConfig } from "../types"; +import { statusOptions } from "../config"; + +interface ScheduleBarProps { + /** 스케줄 항목 */ + schedule: ScheduleItem; + /** 위치 정보 */ + position: ScheduleBarPosition; + /** 설정 */ + config: TimelineSchedulerConfig; + /** 드래그 가능 여부 */ + draggable?: boolean; + /** 리사이즈 가능 여부 */ + resizable?: boolean; + /** 클릭 이벤트 */ + onClick?: (schedule: ScheduleItem) => void; + /** 드래그 시작 */ + onDragStart?: (schedule: ScheduleItem, e: React.MouseEvent) => void; + /** 드래그 중 */ + onDrag?: (deltaX: number, deltaY: number) => void; + /** 드래그 종료 */ + onDragEnd?: () => void; + /** 리사이즈 시작 */ + onResizeStart?: (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => void; + /** 리사이즈 중 */ + onResize?: (deltaX: number, direction: "start" | "end") => void; + /** 리사이즈 종료 */ + onResizeEnd?: () => void; +} + +export function ScheduleBar({ + schedule, + position, + config, + draggable = true, + resizable = true, + onClick, + onDragStart, + onDragEnd, + onResizeStart, + onResizeEnd, +}: ScheduleBarProps) { + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const barRef = useRef(null); + + // 상태에 따른 색상 + const statusColor = schedule.color || + config.statusColors?.[schedule.status] || + statusOptions.find((s) => s.value === schedule.status)?.color || + "#3b82f6"; + + // 진행률 바 너비 + const progressWidth = config.showProgress && schedule.progress !== undefined + ? `${schedule.progress}%` + : "0%"; + + // 드래그 시작 핸들러 + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (!draggable || isResizing) return; + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + onDragStart?.(schedule, e); + + const handleMouseMove = (moveEvent: MouseEvent) => { + // 드래그 중 로직은 부모에서 처리 + }; + + const handleMouseUp = () => { + setIsDragging(false); + onDragEnd?.(); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }, + [draggable, isResizing, schedule, onDragStart, onDragEnd] + ); + + // 리사이즈 시작 핸들러 + const handleResizeStart = useCallback( + (direction: "start" | "end", e: React.MouseEvent) => { + if (!resizable) return; + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + onResizeStart?.(schedule, direction, e); + + const handleMouseMove = (moveEvent: MouseEvent) => { + // 리사이즈 중 로직은 부모에서 처리 + }; + + const handleMouseUp = () => { + setIsResizing(false); + onResizeEnd?.(); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }, + [resizable, schedule, onResizeStart, onResizeEnd] + ); + + // 클릭 핸들러 + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (isDragging || isResizing) return; + e.stopPropagation(); + onClick?.(schedule); + }, + [isDragging, isResizing, onClick, schedule] + ); + + return ( +
+ {/* 진행률 바 */} + {config.showProgress && schedule.progress !== undefined && ( +
+ )} + + {/* 제목 */} +
+ {schedule.title} +
+ + {/* 진행률 텍스트 */} + {config.showProgress && schedule.progress !== undefined && ( +
+ {schedule.progress}% +
+ )} + + {/* 리사이즈 핸들 - 왼쪽 */} + {resizable && ( +
handleResizeStart("start", e)} + /> + )} + + {/* 리사이즈 핸들 - 오른쪽 */} + {resizable && ( +
handleResizeStart("end", e)} + /> + )} +
+ ); +} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineHeader.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineHeader.tsx new file mode 100644 index 00000000..52afc2e2 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineHeader.tsx @@ -0,0 +1,195 @@ +"use client"; + +import React, { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { DateCell, ZoomLevel } from "../types"; +import { dayLabels, monthLabels } from "../config"; + +interface TimelineHeaderProps { + /** 시작 날짜 */ + startDate: Date; + /** 종료 날짜 */ + endDate: Date; + /** 줌 레벨 */ + zoomLevel: ZoomLevel; + /** 셀 너비 */ + cellWidth: number; + /** 헤더 높이 */ + headerHeight: number; + /** 리소스 컬럼 너비 */ + resourceColumnWidth: number; + /** 오늘 표시선 */ + showTodayLine?: boolean; +} + +/** + * 날짜 범위 내의 모든 날짜 셀 생성 + */ +const generateDateCells = ( + startDate: Date, + endDate: Date, + zoomLevel: ZoomLevel +): DateCell[] => { + const cells: DateCell[] = []; + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const current = new Date(startDate); + current.setHours(0, 0, 0, 0); + + while (current <= endDate) { + const date = new Date(current); + const dayOfWeek = date.getDay(); + const isToday = date.getTime() === today.getTime(); + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; + const isMonthStart = date.getDate() === 1; + + let label = ""; + if (zoomLevel === "day") { + label = `${date.getDate()}(${dayLabels[dayOfWeek]})`; + } else if (zoomLevel === "week") { + // 주간: 월요일 기준 주 시작 + if (dayOfWeek === 1 || cells.length === 0) { + label = `${date.getMonth() + 1}/${date.getDate()}`; + } + } else if (zoomLevel === "month") { + // 월간: 월 시작일만 표시 + if (isMonthStart || cells.length === 0) { + label = monthLabels[date.getMonth()]; + } + } + + cells.push({ + date, + label, + isToday, + isWeekend, + isMonthStart, + }); + + current.setDate(current.getDate() + 1); + } + + return cells; +}; + +/** + * 월 헤더 그룹 생성 (상단 행) + */ +const generateMonthGroups = ( + cells: DateCell[] +): { month: string; year: number; count: number }[] => { + const groups: { month: string; year: number; count: number }[] = []; + + cells.forEach((cell) => { + const month = monthLabels[cell.date.getMonth()]; + const year = cell.date.getFullYear(); + + if ( + groups.length === 0 || + groups[groups.length - 1].month !== month || + groups[groups.length - 1].year !== year + ) { + groups.push({ month, year, count: 1 }); + } else { + groups[groups.length - 1].count++; + } + }); + + return groups; +}; + +export function TimelineHeader({ + startDate, + endDate, + zoomLevel, + cellWidth, + headerHeight, + resourceColumnWidth, + showTodayLine = true, +}: TimelineHeaderProps) { + // 날짜 셀 생성 + const dateCells = useMemo( + () => generateDateCells(startDate, endDate, zoomLevel), + [startDate, endDate, zoomLevel] + ); + + // 월 그룹 생성 + const monthGroups = useMemo(() => generateMonthGroups(dateCells), [dateCells]); + + // 오늘 위치 계산 + const todayPosition = useMemo(() => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const todayIndex = dateCells.findIndex( + (cell) => cell.date.getTime() === today.getTime() + ); + + if (todayIndex === -1) return null; + + return resourceColumnWidth + todayIndex * cellWidth + cellWidth / 2; + }, [dateCells, cellWidth, resourceColumnWidth]); + + return ( +
+ {/* 상단 행: 월/년도 */} +
+ {/* 리소스 컬럼 헤더 */} +
+ 리소스 +
+ + {/* 월 그룹 */} + {monthGroups.map((group, idx) => ( +
+ {group.year}년 {group.month} +
+ ))} +
+ + {/* 하단 행: 일자 */} +
+ {/* 리소스 컬럼 (빈칸) */} +
+ + {/* 날짜 셀 */} + {dateCells.map((cell, idx) => ( +
+ {cell.label} +
+ ))} +
+ + {/* 오늘 표시선 */} + {showTodayLine && todayPosition !== null && ( +
+ )} +
+ ); +} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts b/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts new file mode 100644 index 00000000..4da03f17 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts @@ -0,0 +1,3 @@ +export { TimelineHeader } from "./TimelineHeader"; +export { ScheduleBar } from "./ScheduleBar"; +export { ResourceRow } from "./ResourceRow"; diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/config.ts b/frontend/lib/registry/components/v2-timeline-scheduler/config.ts new file mode 100644 index 00000000..f8b10f94 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/config.ts @@ -0,0 +1,102 @@ +"use client"; + +import { TimelineSchedulerConfig, ZoomLevel } from "./types"; + +/** + * 기본 타임라인 스케줄러 설정 + */ +export const defaultTimelineSchedulerConfig: Partial = { + defaultZoomLevel: "day", + editable: true, + draggable: true, + resizable: true, + rowHeight: 50, + headerHeight: 60, + resourceColumnWidth: 150, + cellWidth: { + day: 60, + week: 120, + month: 40, + }, + showConflicts: true, + showProgress: true, + showTodayLine: true, + showToolbar: true, + showZoomControls: true, + showNavigation: true, + showAddButton: true, + height: 500, + statusColors: { + planned: "#3b82f6", // blue-500 + in_progress: "#f59e0b", // amber-500 + completed: "#10b981", // emerald-500 + delayed: "#ef4444", // red-500 + cancelled: "#6b7280", // gray-500 + }, + fieldMapping: { + id: "id", + resourceId: "resource_id", + title: "title", + startDate: "start_date", + endDate: "end_date", + status: "status", + progress: "progress", + }, + resourceFieldMapping: { + id: "id", + name: "name", + group: "group", + }, +}; + +/** + * 줌 레벨 옵션 + */ +export const zoomLevelOptions: { value: ZoomLevel; label: string }[] = [ + { value: "day", label: "일" }, + { value: "week", label: "주" }, + { value: "month", label: "월" }, +]; + +/** + * 상태 옵션 + */ +export const statusOptions = [ + { value: "planned", label: "계획됨", color: "#3b82f6" }, + { value: "in_progress", label: "진행중", color: "#f59e0b" }, + { value: "completed", label: "완료", color: "#10b981" }, + { value: "delayed", label: "지연", color: "#ef4444" }, + { value: "cancelled", label: "취소", color: "#6b7280" }, +]; + +/** + * 줌 레벨별 표시 일수 + */ +export const zoomLevelDays: Record = { + day: 14, // 2주 + week: 56, // 8주 + month: 90, // 3개월 +}; + +/** + * 요일 라벨 (한글) + */ +export const dayLabels = ["일", "월", "화", "수", "목", "금", "토"]; + +/** + * 월 라벨 (한글) + */ +export const monthLabels = [ + "1월", + "2월", + "3월", + "4월", + "5월", + "6월", + "7월", + "8월", + "9월", + "10월", + "11월", + "12월", +]; diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts b/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts new file mode 100644 index 00000000..61504c3d --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts @@ -0,0 +1,331 @@ +"use client"; + +import { useState, useCallback, useEffect, useMemo } from "react"; +import { apiClient } from "@/lib/api/client"; +import { + TimelineSchedulerConfig, + ScheduleItem, + Resource, + ZoomLevel, + UseTimelineDataResult, +} from "../types"; +import { zoomLevelDays, defaultTimelineSchedulerConfig } from "../config"; + +/** + * 날짜를 ISO 문자열로 변환 (시간 제외) + */ +const toDateString = (date: Date): string => { + return date.toISOString().split("T")[0]; +}; + +/** + * 날짜 더하기 + */ +const addDays = (date: Date, days: number): Date => { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; +}; + +/** + * 타임라인 데이터를 관리하는 훅 + */ +export function useTimelineData( + config: TimelineSchedulerConfig, + externalSchedules?: ScheduleItem[], + externalResources?: Resource[] +): UseTimelineDataResult { + // 상태 + const [schedules, setSchedules] = useState([]); + const [resources, setResources] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [zoomLevel, setZoomLevel] = useState( + config.defaultZoomLevel || "day" + ); + const [viewStartDate, setViewStartDate] = useState(() => { + if (config.initialDate) { + return new Date(config.initialDate); + } + // 오늘 기준 1주일 전부터 시작 + const today = new Date(); + today.setDate(today.getDate() - 7); + today.setHours(0, 0, 0, 0); + return today; + }); + + // 표시 종료일 계산 + const viewEndDate = useMemo(() => { + const days = zoomLevelDays[zoomLevel]; + return addDays(viewStartDate, days); + }, [viewStartDate, zoomLevel]); + + // 테이블명 + const tableName = config.useCustomTable + ? config.customTableName + : config.selectedTable; + + const resourceTableName = config.resourceTable; + + // 필드 매핑 + const fieldMapping = config.fieldMapping || defaultTimelineSchedulerConfig.fieldMapping!; + const resourceFieldMapping = + config.resourceFieldMapping || defaultTimelineSchedulerConfig.resourceFieldMapping!; + + // 스케줄 데이터 로드 + const fetchSchedules = useCallback(async () => { + if (externalSchedules) { + setSchedules(externalSchedules); + return; + } + + if (!tableName) { + setSchedules([]); + return; + } + + setIsLoading(true); + setError(null); + + try { + const response = await apiClient.post( + `/table-management/tables/${tableName}/data`, + { + page: 1, + size: 10000, + autoFilter: true, + search: { + // 표시 범위 내의 스케줄만 조회 + [fieldMapping.startDate]: { + value: toDateString(viewEndDate), + operator: "lte", + }, + [fieldMapping.endDate]: { + value: toDateString(viewStartDate), + operator: "gte", + }, + }, + } + ); + + const responseData = + response.data?.data?.data || response.data?.data || []; + const rawData = Array.isArray(responseData) ? responseData : []; + + // 데이터를 ScheduleItem 형태로 변환 + const mappedSchedules: ScheduleItem[] = rawData.map((row: any) => ({ + id: String(row[fieldMapping.id] || ""), + resourceId: String(row[fieldMapping.resourceId] || ""), + title: String(row[fieldMapping.title] || ""), + startDate: row[fieldMapping.startDate] || "", + endDate: row[fieldMapping.endDate] || "", + status: fieldMapping.status + ? row[fieldMapping.status] || "planned" + : "planned", + progress: fieldMapping.progress + ? Number(row[fieldMapping.progress]) || 0 + : undefined, + color: fieldMapping.color ? row[fieldMapping.color] : undefined, + data: row, + })); + + setSchedules(mappedSchedules); + } catch (err: any) { + setError(err.message || "스케줄 데이터 로드 중 오류 발생"); + setSchedules([]); + } finally { + setIsLoading(false); + } + }, [ + tableName, + externalSchedules, + fieldMapping, + viewStartDate, + viewEndDate, + ]); + + // 리소스 데이터 로드 + const fetchResources = useCallback(async () => { + if (externalResources) { + setResources(externalResources); + return; + } + + if (!resourceTableName) { + setResources([]); + return; + } + + try { + const response = await apiClient.post( + `/table-management/tables/${resourceTableName}/data`, + { + page: 1, + size: 1000, + autoFilter: true, + } + ); + + const responseData = + response.data?.data?.data || response.data?.data || []; + const rawData = Array.isArray(responseData) ? responseData : []; + + // 데이터를 Resource 형태로 변환 + const mappedResources: Resource[] = rawData.map((row: any) => ({ + id: String(row[resourceFieldMapping.id] || ""), + name: String(row[resourceFieldMapping.name] || ""), + group: resourceFieldMapping.group + ? row[resourceFieldMapping.group] + : undefined, + })); + + setResources(mappedResources); + } catch (err: any) { + console.error("리소스 로드 오류:", err); + setResources([]); + } + }, [resourceTableName, externalResources, resourceFieldMapping]); + + // 초기 로드 + useEffect(() => { + fetchSchedules(); + }, [fetchSchedules]); + + useEffect(() => { + fetchResources(); + }, [fetchResources]); + + // 네비게이션 함수들 + const goToPrevious = useCallback(() => { + const days = zoomLevelDays[zoomLevel]; + setViewStartDate((prev) => addDays(prev, -days)); + }, [zoomLevel]); + + const goToNext = useCallback(() => { + const days = zoomLevelDays[zoomLevel]; + setViewStartDate((prev) => addDays(prev, days)); + }, [zoomLevel]); + + const goToToday = useCallback(() => { + const today = new Date(); + today.setDate(today.getDate() - 7); + today.setHours(0, 0, 0, 0); + setViewStartDate(today); + }, []); + + const goToDate = useCallback((date: Date) => { + const newDate = new Date(date); + newDate.setDate(newDate.getDate() - 7); + newDate.setHours(0, 0, 0, 0); + setViewStartDate(newDate); + }, []); + + // 스케줄 업데이트 + const updateSchedule = useCallback( + async (id: string, updates: Partial) => { + if (!tableName || !config.editable) return; + + try { + // 필드 매핑 역변환 + const updateData: Record = {}; + if (updates.startDate) updateData[fieldMapping.startDate] = updates.startDate; + if (updates.endDate) updateData[fieldMapping.endDate] = updates.endDate; + if (updates.resourceId) updateData[fieldMapping.resourceId] = updates.resourceId; + if (updates.title) updateData[fieldMapping.title] = updates.title; + if (updates.status && fieldMapping.status) + updateData[fieldMapping.status] = updates.status; + if (updates.progress !== undefined && fieldMapping.progress) + updateData[fieldMapping.progress] = updates.progress; + + await apiClient.put(`/table-management/tables/${tableName}/data/${id}`, updateData); + + // 로컬 상태 업데이트 + setSchedules((prev) => + prev.map((s) => (s.id === id ? { ...s, ...updates } : s)) + ); + } catch (err: any) { + console.error("스케줄 업데이트 오류:", err); + throw err; + } + }, + [tableName, fieldMapping, config.editable] + ); + + // 스케줄 추가 + const addSchedule = useCallback( + async (schedule: Omit) => { + if (!tableName || !config.editable) return; + + try { + // 필드 매핑 역변환 + const insertData: Record = { + [fieldMapping.resourceId]: schedule.resourceId, + [fieldMapping.title]: schedule.title, + [fieldMapping.startDate]: schedule.startDate, + [fieldMapping.endDate]: schedule.endDate, + }; + + if (fieldMapping.status) insertData[fieldMapping.status] = schedule.status; + if (fieldMapping.progress && schedule.progress !== undefined) + insertData[fieldMapping.progress] = schedule.progress; + + const response = await apiClient.post( + `/table-management/tables/${tableName}/data`, + insertData + ); + + const newId = response.data?.data?.id || Date.now().toString(); + + // 로컬 상태 업데이트 + setSchedules((prev) => [...prev, { ...schedule, id: newId }]); + } catch (err: any) { + console.error("스케줄 추가 오류:", err); + throw err; + } + }, + [tableName, fieldMapping, config.editable] + ); + + // 스케줄 삭제 + const deleteSchedule = useCallback( + async (id: string) => { + if (!tableName || !config.editable) return; + + try { + await apiClient.delete(`/table-management/tables/${tableName}/data/${id}`); + + // 로컬 상태 업데이트 + setSchedules((prev) => prev.filter((s) => s.id !== id)); + } catch (err: any) { + console.error("스케줄 삭제 오류:", err); + throw err; + } + }, + [tableName, config.editable] + ); + + // 새로고침 + const refresh = useCallback(() => { + fetchSchedules(); + fetchResources(); + }, [fetchSchedules, fetchResources]); + + return { + schedules, + resources, + isLoading, + error, + zoomLevel, + setZoomLevel, + viewStartDate, + viewEndDate, + goToPrevious, + goToNext, + goToToday, + goToDate, + updateSchedule, + addSchedule, + deleteSchedule, + refresh, + }; +} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/index.ts b/frontend/lib/registry/components/v2-timeline-scheduler/index.ts new file mode 100644 index 00000000..33c483a0 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/index.ts @@ -0,0 +1,38 @@ +"use client"; + +import { ComponentCategory } from "@/types/component"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { TimelineSchedulerComponent } from "./TimelineSchedulerComponent"; +import { TimelineSchedulerConfigPanel } from "./TimelineSchedulerConfigPanel"; +import { defaultTimelineSchedulerConfig } from "./config"; +import { TimelineSchedulerConfig } from "./types"; + +/** + * v2-timeline-scheduler 컴포넌트 정의 + * 간트차트 형태의 일정/계획 시각화 및 편집 컴포넌트 + */ +export const V2TimelineSchedulerDefinition = createComponentDefinition({ + id: "v2-timeline-scheduler", + name: "타임라인 스케줄러", + nameEng: "Timeline Scheduler Component", + description: "간트차트 형태의 일정/계획 시각화 및 편집 컴포넌트", + category: ComponentCategory.DISPLAY, + webType: "text", + component: TimelineSchedulerComponent, + configPanel: TimelineSchedulerConfigPanel, + defaultConfig: defaultTimelineSchedulerConfig as TimelineSchedulerConfig, + defaultSize: { + width: 1000, + height: 500, + }, + icon: "Calendar", + tags: ["타임라인", "스케줄", "간트차트", "일정", "계획"], + version: "2.0.0", + author: "개발팀", + documentation: "", +}); + +export { TimelineSchedulerComponent } from "./TimelineSchedulerComponent"; +export { TimelineSchedulerConfigPanel } from "./TimelineSchedulerConfigPanel"; +export * from "./types"; +export * from "./config"; diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/types.ts b/frontend/lib/registry/components/v2-timeline-scheduler/types.ts new file mode 100644 index 00000000..eba6f4e3 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/types.ts @@ -0,0 +1,363 @@ +"use client"; + +import { ComponentConfig } from "@/types/component"; + +/** + * 줌 레벨 (시간 단위) + */ +export type ZoomLevel = "day" | "week" | "month"; + +/** + * 스케줄 상태 + */ +export type ScheduleStatus = + | "planned" + | "in_progress" + | "completed" + | "delayed" + | "cancelled"; + +/** + * 스케줄 항목 (간트 바) + */ +export interface ScheduleItem { + /** 고유 ID */ + id: string; + + /** 리소스 ID (설비/작업자) */ + resourceId: string; + + /** 표시 제목 */ + title: string; + + /** 시작 일시 (ISO 8601) */ + startDate: string; + + /** 종료 일시 (ISO 8601) */ + endDate: string; + + /** 상태 */ + status: ScheduleStatus; + + /** 진행률 (0-100) */ + progress?: number; + + /** 색상 (CSS color) */ + color?: string; + + /** 추가 데이터 */ + data?: Record; +} + +/** + * 리소스 (행 - 설비/작업자) + */ +export interface Resource { + /** 리소스 ID */ + id: string; + + /** 표시명 */ + name: string; + + /** 그룹 (선택) */ + group?: string; + + /** 아이콘 (선택) */ + icon?: string; + + /** 용량 (선택, 충돌 계산용) */ + capacity?: number; +} + +/** + * 필드 매핑 설정 + */ +export interface FieldMapping { + /** ID 필드 */ + id: string; + /** 리소스 ID 필드 */ + resourceId: string; + /** 제목 필드 */ + title: string; + /** 시작일 필드 */ + startDate: string; + /** 종료일 필드 */ + endDate: string; + /** 상태 필드 (선택) */ + status?: string; + /** 진행률 필드 (선택) */ + progress?: string; + /** 색상 필드 (선택) */ + color?: string; +} + +/** + * 리소스 필드 매핑 설정 + */ +export interface ResourceFieldMapping { + /** ID 필드 */ + id: string; + /** 이름 필드 */ + name: string; + /** 그룹 필드 (선택) */ + group?: string; +} + +/** + * 타임라인 스케줄러 설정 + */ +export interface TimelineSchedulerConfig extends ComponentConfig { + /** 스케줄 데이터 테이블명 */ + selectedTable?: string; + + /** 리소스 테이블명 */ + resourceTable?: string; + + /** 스케줄 필드 매핑 */ + fieldMapping: FieldMapping; + + /** 리소스 필드 매핑 */ + resourceFieldMapping?: ResourceFieldMapping; + + /** 초기 줌 레벨 */ + defaultZoomLevel?: ZoomLevel; + + /** 초기 표시 날짜 (ISO 8601) */ + initialDate?: string; + + /** 편집 가능 여부 */ + editable?: boolean; + + /** 드래그 이동 가능 */ + draggable?: boolean; + + /** 리사이즈 가능 */ + resizable?: boolean; + + /** 행 높이 (px) */ + rowHeight?: number; + + /** 헤더 높이 (px) */ + headerHeight?: number; + + /** 리소스 컬럼 너비 (px) */ + resourceColumnWidth?: number; + + /** 셀 너비 (px, 줌 레벨별) */ + cellWidth?: { + day?: number; + week?: number; + month?: number; + }; + + /** 충돌 표시 여부 */ + showConflicts?: boolean; + + /** 진행률 바 표시 여부 */ + showProgress?: boolean; + + /** 오늘 표시선 */ + showTodayLine?: boolean; + + /** 상태별 색상 */ + statusColors?: { + planned?: string; + in_progress?: string; + completed?: string; + delayed?: string; + cancelled?: string; + }; + + /** 툴바 표시 여부 */ + showToolbar?: boolean; + + /** 줌 레벨 변경 버튼 표시 */ + showZoomControls?: boolean; + + /** 네비게이션 버튼 표시 */ + showNavigation?: boolean; + + /** 추가 버튼 표시 */ + showAddButton?: boolean; + + /** 높이 (px 또는 auto) */ + height?: number | string; + + /** 최대 높이 */ + maxHeight?: number | string; +} + +/** + * 드래그 이벤트 + */ +export interface DragEvent { + /** 스케줄 ID */ + scheduleId: string; + /** 새로운 시작일 */ + newStartDate: string; + /** 새로운 종료일 */ + newEndDate: string; + /** 새로운 리소스 ID (리소스 간 이동 시) */ + newResourceId?: string; +} + +/** + * 리사이즈 이벤트 + */ +export interface ResizeEvent { + /** 스케줄 ID */ + scheduleId: string; + /** 새로운 시작일 */ + newStartDate: string; + /** 새로운 종료일 */ + newEndDate: string; + /** 리사이즈 방향 */ + direction: "start" | "end"; +} + +/** + * 클릭 이벤트 + */ +export interface ScheduleClickEvent { + /** 스케줄 항목 */ + schedule: ScheduleItem; + /** 리소스 */ + resource: Resource; +} + +/** + * 빈 셀 클릭 이벤트 + */ +export interface CellClickEvent { + /** 리소스 ID */ + resourceId: string; + /** 날짜 */ + date: string; +} + +/** + * TimelineSchedulerComponent Props + */ +export interface TimelineSchedulerComponentProps { + /** 컴포넌트 설정 */ + config: TimelineSchedulerConfig; + + /** 디자인 모드 여부 */ + isDesignMode?: boolean; + + /** 폼 데이터 */ + formData?: Record; + + /** 외부 스케줄 데이터 */ + externalSchedules?: ScheduleItem[]; + + /** 외부 리소스 데이터 */ + externalResources?: Resource[]; + + /** 로딩 상태 */ + isLoading?: boolean; + + /** 에러 */ + error?: string; + + /** 컴포넌트 ID */ + componentId?: string; + + /** 드래그 완료 이벤트 */ + onDragEnd?: (event: DragEvent) => void; + + /** 리사이즈 완료 이벤트 */ + onResizeEnd?: (event: ResizeEvent) => void; + + /** 스케줄 클릭 이벤트 */ + onScheduleClick?: (event: ScheduleClickEvent) => void; + + /** 빈 셀 클릭 이벤트 */ + onCellClick?: (event: CellClickEvent) => void; + + /** 스케줄 추가 이벤트 */ + onAddSchedule?: (resourceId: string, date: string) => void; +} + +/** + * useTimelineData 훅 반환 타입 + */ +export interface UseTimelineDataResult { + /** 스케줄 목록 */ + schedules: ScheduleItem[]; + + /** 리소스 목록 */ + resources: Resource[]; + + /** 로딩 상태 */ + isLoading: boolean; + + /** 에러 */ + error: string | null; + + /** 현재 줌 레벨 */ + zoomLevel: ZoomLevel; + + /** 줌 레벨 변경 */ + setZoomLevel: (level: ZoomLevel) => void; + + /** 현재 표시 시작일 */ + viewStartDate: Date; + + /** 현재 표시 종료일 */ + viewEndDate: Date; + + /** 이전으로 이동 */ + goToPrevious: () => void; + + /** 다음으로 이동 */ + goToNext: () => void; + + /** 오늘로 이동 */ + goToToday: () => void; + + /** 특정 날짜로 이동 */ + goToDate: (date: Date) => void; + + /** 스케줄 업데이트 */ + updateSchedule: (id: string, updates: Partial) => Promise; + + /** 스케줄 추가 */ + addSchedule: (schedule: Omit) => Promise; + + /** 스케줄 삭제 */ + deleteSchedule: (id: string) => Promise; + + /** 데이터 새로고침 */ + refresh: () => void; +} + +/** + * 날짜 셀 정보 + */ +export interface DateCell { + /** 날짜 */ + date: Date; + /** 표시 라벨 */ + label: string; + /** 오늘 여부 */ + isToday: boolean; + /** 주말 여부 */ + isWeekend: boolean; + /** 월 첫째날 여부 */ + isMonthStart: boolean; +} + +/** + * 스케줄 바 위치 정보 + */ +export interface ScheduleBarPosition { + /** 왼쪽 오프셋 (px) */ + left: number; + /** 너비 (px) */ + width: number; + /** 상단 오프셋 (px) */ + top: number; + /** 높이 (px) */ + height: number; +}