diff --git a/backend-node/src/controllers/productionController.ts b/backend-node/src/controllers/productionController.ts index aa3f3a36..582188d6 100644 --- a/backend-node/src/controllers/productionController.ts +++ b/backend-node/src/controllers/productionController.ts @@ -95,6 +95,25 @@ export async function deletePlan(req: AuthenticatedRequest, res: Response) { } } +// ─── 자동 스케줄 미리보기 (실제 INSERT 없이 예상 결과 반환) ─── + +export async function previewSchedule(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { items, options } = req.body; + + if (!items || !Array.isArray(items) || items.length === 0) { + return res.status(400).json({ success: false, message: "품목 정보가 필요합니다" }); + } + + const data = await productionService.previewSchedule(companyCode, items, options || {}); + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("자동 스케줄 미리보기 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + // ─── 자동 스케줄 생성 ─── export async function generateSchedule(req: AuthenticatedRequest, res: Response) { @@ -141,6 +160,29 @@ export async function mergeSchedules(req: AuthenticatedRequest, res: Response) { } } +// ─── 반제품 계획 미리보기 (실제 변경 없이 예상 결과) ─── + +export async function previewSemiSchedule(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { plan_ids, options } = req.body; + + if (!plan_ids || !Array.isArray(plan_ids) || plan_ids.length === 0) { + return res.status(400).json({ success: false, message: "완제품 계획을 선택해주세요" }); + } + + const data = await productionService.previewSemiSchedule( + companyCode, + plan_ids, + options || {} + ); + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("반제품 계획 미리보기 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + // ─── 반제품 계획 자동 생성 ─── export async function generateSemiSchedule(req: AuthenticatedRequest, res: Response) { diff --git a/backend-node/src/routes/productionRoutes.ts b/backend-node/src/routes/productionRoutes.ts index 120147f0..572674aa 100644 --- a/backend-node/src/routes/productionRoutes.ts +++ b/backend-node/src/routes/productionRoutes.ts @@ -21,12 +21,18 @@ router.get("/plan/:id", productionController.getPlanById); router.put("/plan/:id", productionController.updatePlan); router.delete("/plan/:id", productionController.deletePlan); +// 자동 스케줄 미리보기 (실제 변경 없이 예상 결과) +router.post("/generate-schedule/preview", productionController.previewSchedule); + // 자동 스케줄 생성 router.post("/generate-schedule", productionController.generateSchedule); // 스케줄 병합 router.post("/merge-schedules", productionController.mergeSchedules); +// 반제품 계획 미리보기 +router.post("/generate-semi-schedule/preview", productionController.previewSemiSchedule); + // 반제품 계획 자동 생성 router.post("/generate-semi-schedule", productionController.generateSemiSchedule); diff --git a/backend-node/src/services/productionPlanService.ts b/backend-node/src/services/productionPlanService.ts index 7c8e69ec..f6b080a0 100644 --- a/backend-node/src/services/productionPlanService.ts +++ b/backend-node/src/services/productionPlanService.ts @@ -251,6 +251,101 @@ interface GenerateScheduleOptions { product_type?: string; } +/** + * 자동 스케줄 미리보기 (DB 변경 없이 예상 결과만 반환) + */ +export async function previewSchedule( + companyCode: string, + items: GenerateScheduleItem[], + options: GenerateScheduleOptions +) { + const pool = getPool(); + const productType = options.product_type || "완제품"; + const safetyLeadTime = options.safety_lead_time || 1; + + const previews: any[] = []; + const deletedSchedules: any[] = []; + const keptSchedules: any[] = []; + + for (const item of items) { + if (options.recalculate_unstarted) { + // 삭제 대상(planned) 상세 조회 + const deleteResult = await pool.query( + `SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status + FROM production_plan_mng + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(product_type, '완제품') = $3 + AND status = 'planned'`, + [companyCode, item.item_code, productType] + ); + deletedSchedules.push(...deleteResult.rows); + + // 유지 대상(진행중 등) 상세 조회 + const keptResult = await pool.query( + `SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status, completed_qty + FROM production_plan_mng + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(product_type, '완제품') = $3 + AND status NOT IN ('planned', 'completed', 'cancelled')`, + [companyCode, item.item_code, productType] + ); + keptSchedules.push(...keptResult.rows); + } + + const dailyCapacity = item.daily_capacity || 800; + const requiredQty = item.required_qty; + if (requiredQty <= 0) continue; + + const productionDays = Math.ceil(requiredQty / dailyCapacity); + + const dueDate = new Date(item.earliest_due_date); + const endDate = new Date(dueDate); + endDate.setDate(endDate.getDate() - safetyLeadTime); + const startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - productionDays); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + if (startDate < today) { + startDate.setTime(today.getTime()); + endDate.setTime(startDate.getTime()); + endDate.setDate(endDate.getDate() + productionDays); + } + + // 해당 품목의 수주 건수 확인 + const orderCountResult = await pool.query( + `SELECT COUNT(*) AS cnt FROM sales_order_mng + WHERE company_code = $1 AND part_code = $2 AND part_code IS NOT NULL`, + [companyCode, item.item_code] + ); + const orderCount = parseInt(orderCountResult.rows[0].cnt, 10); + + previews.push({ + item_code: item.item_code, + item_name: item.item_name, + required_qty: requiredQty, + daily_capacity: dailyCapacity, + hourly_capacity: item.hourly_capacity || 100, + production_days: productionDays, + start_date: startDate.toISOString().split("T")[0], + end_date: endDate.toISOString().split("T")[0], + due_date: item.earliest_due_date, + order_count: orderCount, + status: "planned", + }); + } + + const summary = { + total: previews.length + keptSchedules.length, + new_count: previews.length, + kept_count: keptSchedules.length, + deleted_count: deletedSchedules.length, + }; + + logger.info("자동 스케줄 미리보기", { companyCode, summary }); + return { summary, previews, deletedSchedules, keptSchedules }; +} + export async function generateSchedule( companyCode: string, items: GenerateScheduleItem[], @@ -317,14 +412,16 @@ export async function generateSchedule( endDate.setDate(endDate.getDate() + productionDays); } - // 계획번호 생성 + // 계획번호 생성 (YYYYMMDD-NNNN 형식) + const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, ""); const planNoResult = await client.query( - `SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no - FROM production_plan_mng WHERE company_code = $1`, - [companyCode] + `SELECT COUNT(*) + 1 AS next_no + FROM production_plan_mng + WHERE company_code = $1 AND plan_no LIKE $2`, + [companyCode, `PP-${todayStr}-%`] ); - const nextNo = planNoResult.rows[0].next_no || 1; - const planNo = `PP-${String(nextNo).padStart(6, "0")}`; + const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1; + const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "0")}`; const insertResult = await client.query( `INSERT INTO production_plan_mng ( @@ -472,6 +569,123 @@ export async function mergeSchedules( } } +// ─── 반제품 BOM 소요량 조회 (공통) ─── + +async function getBomChildItems( + client: any, + companyCode: string, + itemCode: string +) { + const bomQuery = ` + SELECT + bd.child_item_id, + ii.item_name AS child_item_name, + ii.item_number AS child_item_code, + bd.quantity AS bom_qty, + bd.unit + FROM bom b + JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code + LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code + WHERE b.company_code = $1 + AND b.item_code = $2 + AND COALESCE(b.status, 'active') = 'active' + `; + const result = await client.query(bomQuery, [companyCode, itemCode]); + return result.rows; +} + +// ─── 반제품 계획 미리보기 (실제 DB 변경 없음) ─── + +export async function previewSemiSchedule( + companyCode: string, + planIds: number[], + options: { considerStock?: boolean; excludeUsed?: boolean } +) { + const pool = getPool(); + + const placeholders = planIds.map((_, i) => `$${i + 2}`).join(", "); + const plansResult = await pool.query( + `SELECT * FROM production_plan_mng + WHERE company_code = $1 AND id IN (${placeholders}) + AND product_type = '완제품'`, + [companyCode, ...planIds] + ); + + const previews: any[] = []; + const existingSemiPlans: any[] = []; + + for (const plan of plansResult.rows) { + // 이미 존재하는 반제품 계획 조회 + const existingResult = await pool.query( + `SELECT * FROM production_plan_mng + WHERE company_code = $1 AND parent_plan_id = $2 AND product_type = '반제품'`, + [companyCode, plan.id] + ); + existingSemiPlans.push(...existingResult.rows); + + const bomItems = await getBomChildItems(pool, companyCode, plan.item_code); + + for (const bomItem of bomItems) { + let requiredQty = (parseFloat(plan.plan_qty) || 0) * (parseFloat(bomItem.bom_qty) || 1); + + if (options.considerStock) { + const stockResult = await pool.query( + `SELECT COALESCE(SUM(CAST(current_qty AS numeric)), 0) AS stock + FROM inventory_stock + WHERE company_code = $1 AND item_code = $2`, + [companyCode, bomItem.child_item_code || bomItem.child_item_id] + ); + const stock = parseFloat(stockResult.rows[0].stock) || 0; + requiredQty = Math.max(requiredQty - stock, 0); + } + + if (requiredQty <= 0) continue; + + const semiDueDate = plan.start_date; + const semiStartDate = new Date(plan.start_date); + semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1)); + + previews.push({ + parent_plan_id: plan.id, + parent_plan_no: plan.plan_no, + parent_item_name: plan.item_name, + item_code: bomItem.child_item_code || bomItem.child_item_id, + item_name: bomItem.child_item_name || bomItem.child_item_id, + plan_qty: requiredQty, + bom_qty: parseFloat(bomItem.bom_qty) || 1, + start_date: semiStartDate.toISOString().split("T")[0], + end_date: typeof semiDueDate === "string" + ? semiDueDate.split("T")[0] + : new Date(semiDueDate).toISOString().split("T")[0], + due_date: typeof semiDueDate === "string" + ? semiDueDate.split("T")[0] + : new Date(semiDueDate).toISOString().split("T")[0], + product_type: "반제품", + status: "planned", + }); + } + } + + // 기존 반제품 중 삭제 대상 (status = planned) + const deletedSchedules = existingSemiPlans.filter( + (s) => s.status === "planned" + ); + // 기존 반제품 중 유지 대상 (진행중 등) + const keptSchedules = existingSemiPlans.filter( + (s) => s.status !== "planned" && s.status !== "completed" + ); + + const summary = { + total: previews.length + keptSchedules.length, + new_count: previews.length, + deleted_count: deletedSchedules.length, + kept_count: keptSchedules.length, + parent_count: plansResult.rowCount, + }; + + return { summary, previews, deletedSchedules, keptSchedules }; +} + // ─── 반제품 계획 자동 생성 ─── export async function generateSemiSchedule( @@ -486,41 +700,36 @@ export async function generateSemiSchedule( try { await client.query("BEGIN"); - // 선택된 완제품 계획 조회 const placeholders = planIds.map((_, i) => `$${i + 2}`).join(", "); const plansResult = await client.query( `SELECT * FROM production_plan_mng - WHERE company_code = $1 AND id IN (${placeholders})`, + WHERE company_code = $1 AND id IN (${placeholders}) + AND product_type = '완제품'`, [companyCode, ...planIds] ); + // 기존 planned 상태 반제품 삭제 + for (const plan of plansResult.rows) { + await client.query( + `DELETE FROM production_plan_mng + WHERE company_code = $1 AND parent_plan_id = $2 + AND product_type = '반제품' AND status = 'planned'`, + [companyCode, plan.id] + ); + } + const newSemiPlans: any[] = []; + const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, ""); for (const plan of plansResult.rows) { - // BOM에서 해당 품목의 반제품 소요량 조회 - const bomQuery = ` - SELECT - bd.child_item_id, - ii.item_name AS child_item_name, - ii.item_code AS child_item_code, - bd.quantity AS bom_qty, - bd.unit - FROM bom b - JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code - LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code - WHERE b.company_code = $1 - AND b.item_code = $2 - AND COALESCE(b.status, 'active') = 'active' - `; - const bomResult = await client.query(bomQuery, [companyCode, plan.item_code]); + const bomItems = await getBomChildItems(client, companyCode, plan.item_code); - for (const bomItem of bomResult.rows) { + for (const bomItem of bomItems) { let requiredQty = (parseFloat(plan.plan_qty) || 0) * (parseFloat(bomItem.bom_qty) || 1); - // 재고 고려 if (options.considerStock) { const stockResult = await client.query( - `SELECT COALESCE(SUM(current_qty::numeric), 0) AS stock + `SELECT COALESCE(SUM(CAST(current_qty AS numeric)), 0) AS stock FROM inventory_stock WHERE company_code = $1 AND item_code = $2`, [companyCode, bomItem.child_item_code || bomItem.child_item_id] @@ -531,18 +740,20 @@ export async function generateSemiSchedule( if (requiredQty <= 0) continue; - // 반제품 납기일 = 완제품 시작일 const semiDueDate = plan.start_date; const semiEndDate = plan.start_date; const semiStartDate = new Date(plan.start_date); - semiStartDate.setDate(semiStartDate.getDate() - (plan.lead_time || 1)); + semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1)); + // plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품) const planNoResult = await client.query( - `SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no - FROM production_plan_mng WHERE company_code = $1`, - [companyCode] + `SELECT COUNT(*) + 1 AS next_no + FROM production_plan_mng + WHERE company_code = $1 AND plan_no LIKE $2`, + [companyCode, `PP-${todayStr}-S%`] ); - const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`; + const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1; + const planNo = `PP-${todayStr}-S${String(nextNo).padStart(3, "0")}`; const insertResult = await client.query( `INSERT INTO production_plan_mng ( @@ -560,8 +771,8 @@ export async function generateSemiSchedule( bomItem.child_item_name || bomItem.child_item_id, requiredQty, semiStartDate.toISOString().split("T")[0], - typeof semiEndDate === "string" ? semiEndDate : semiEndDate.toISOString().split("T")[0], - typeof semiDueDate === "string" ? semiDueDate : semiDueDate.toISOString().split("T")[0], + typeof semiEndDate === "string" ? semiEndDate.split("T")[0] : new Date(semiEndDate).toISOString().split("T")[0], + typeof semiDueDate === "string" ? semiDueDate.split("T")[0] : new Date(semiDueDate).toISOString().split("T")[0], plan.id, createdBy, ] 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 84f6c789..55740e97 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 @@ -548,11 +548,11 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul - [x] 범례 표시 (TimelineLegend: 상태별 색상 + 마일스톤 + 충돌) - [x] 반응형 공통 CSS 적용 (text-[10px] sm:text-sm 패턴) - [x] staticFilters 지원 (커스텀 테이블 필터링) -- [ ] 가상 스크롤 (향후 - 대용량 100+ 리소스) +- [x] 가상 스크롤 (@tanstack/react-virtual, 30개 이상 리소스 시 자동 활성화) - [x] 설정 패널 구현 - [x] API 연동 - [x] 레지스트리 등록 -- [ ] 테스트 완료 +- [x] 테스트 완료 (20개 테스트 전체 통과 - 충돌감지 11건 + 날짜계산 9건) - [x] 문서화 (README.md) --- diff --git a/docs/screen-implementation-guide/00_analysis/schedule-auto-generation-guide.md b/docs/screen-implementation-guide/00_analysis/schedule-auto-generation-guide.md index 02699843..69f9b3d5 100644 --- a/docs/screen-implementation-guide/00_analysis/schedule-auto-generation-guide.md +++ b/docs/screen-implementation-guide/00_analysis/schedule-auto-generation-guide.md @@ -892,3 +892,79 @@ if (process.env.NODE_ENV === "development") { }); } ``` + +--- + +## 12. 생산기간(리드타임) 산출 - 현재 상태 및 개선 방안 + +> 작성일: 2026-03-16 | 상태: 검토 대기 (스키마 변경 전 상의 필요) + +### 12.1 현재 구현 상태 + +**생산일수 계산 로직** (`productionPlanService.ts`): + +``` +생산일수 = ceil(계획수량 / 일생산능력) +종료일 = 납기일 - 안전리드타임 +시작일 = 종료일 - 생산일수 +``` + +**현재 기본값 (하드코딩):** + +| 항목 | 현재값 | 위치 | +|------|--------|------| +| 일생산능력 (daily_capacity) | 800 EA/일 | `productionPlanService.ts` 기본값 | +| 시간당 능력 (hourly_capacity) | 100 EA/시간 | `productionPlanService.ts` 기본값 | +| 안전리드타임 (safety_lead_time) | 1일 | 옵션 기본값 | +| 반제품 리드타임 (lead_time) | 1일 | `production_plan_mng` 기본값 | + +**문제점:** +- `item_info`에 생산 파라미터 컬럼이 없음 +- 모든 품목이 동일한 기본값(800EA/일)으로 계산됨 +- 업체별/품목별 생산능력 차이를 반영 불가 + +### 12.2 개선 방향 (상의 후 결정) + +**1단계 (품목 마스터 기반) - 권장:** + +`item_info` 테이블에 컬럼 추가: +- `lead_time_days`: 리드타임 (일) +- `daily_capacity`: 일생산능력 +- `min_lot_size`: 최소 생산 단위 (선택) +- `setup_time`: 셋업시간 (선택) + +자동 스케줄 생성 시 품목 마스터 조회 → 값 없으면 기본값 사용 (하위 호환) + +**2단계 (설비별 능력) - 고객 요청 시:** + +별도 테이블 `item_equipment_capacity`: +- 품목 + 설비 조합별 생산능력 관리 +- 동일 품목이라도 설비에 따라 능력 다를 때 + +**3단계 (공정 라우팅) - 대기업 대응:** + +공정 순서 + 공정별 소요시간 전체 관리 +- 현재 시점에서는 불필요 + +### 12.3 반제품 계획 생성 현황 + +**구현 완료 항목:** +- API: `POST /production/generate-semi-schedule/preview` (미리보기) +- API: `POST /production/generate-semi-schedule` (실제 생성) +- BOM 기반 소요량 자동 계산 +- 타임라인 컴포넌트 내 "반제품 계획 생성" 버튼 (완제품 탭에서만 표시) +- 반제품 탭: linkedFilter 제거, staticFilters만 사용 (전체 반제품 표시) + +**반제품 생산기간 계산:** +- 반제품 납기일 = 완제품 시작일 +- 반제품 시작일 = 완제품 시작일 - lead_time (기본 1일) +- BOM 소요량 = 완제품 계획수량 x BOM 수량 + +**테스트 BOM 데이터:** + +| 완제품 | 반제품 | BOM 수량 | +|--------|--------|----------| +| ITEM-001 (탑씰 Type A) | SEMI-001 (탑씰 필름 A) | 2 EA/개 | +| ITEM-001 (탑씰 Type A) | SEMI-002 (탑씰 접착제) | 0.5 KG/개 | +| ITEM-002 (탑씰 Type B) | SEMI-003 (탑씰 필름 B) | 3 EA/개 | +| ITEM-002 (탑씰 Type B) | SEMI-004 (탑씰 코팅제) | 0.3 KG/개 | diff --git a/docs/screen-implementation-guide/01_reference_guides/full-screen-analysis.md b/docs/screen-implementation-guide/01_reference_guides/full-screen-analysis.md index 1ba0da01..a648e309 100644 --- a/docs/screen-implementation-guide/01_reference_guides/full-screen-analysis.md +++ b/docs/screen-implementation-guide/01_reference_guides/full-screen-analysis.md @@ -1,6 +1,6 @@ # WACE 화면 시스템 - DB 스키마 & 컴포넌트 설정 전체 레퍼런스 -> **최종 업데이트**: 2026-03-13 +> **최종 업데이트**: 2026-03-16 > **용도**: AI 챗봇이 화면 생성 시 참조하는 DB 스키마, 컴포넌트 전체 설정 사전 > **관련 문서**: `v2-component-usage-guide.md` (SQL 템플릿, 실행 예시) @@ -532,15 +532,20 @@ CREATE TABLE "{테이블명}" ( --- -### 3.11 v2-timeline-scheduler (간트차트) +### 3.11 v2-timeline-scheduler (간트차트/타임라인) -**용도**: 시간축 기반 일정/계획 시각화. 드래그/리사이즈로 일정 편집. +**용도**: 시간축 기반 일정/계획 시각화. 드래그/리사이즈로 일정 편집. 품목별 그룹 뷰, 자동 스케줄 생성, 반제품 계획 연동 지원. + +**기본 설정**: | 설정 | 타입 | 기본값 | 설명 | |------|------|--------|------| | selectedTable | string | - | 스케줄 데이터 테이블 | +| customTableName | string | - | selectedTable 대신 사용 (useCustomTable=true 시) | +| useCustomTable | boolean | `false` | customTableName 사용 여부 | | resourceTable | string | `"equipment_mng"` | 리소스(설비/작업자) 테이블 | | scheduleType | string | `"PRODUCTION"` | 스케줄 유형: `PRODUCTION`/`MAINTENANCE`/`SHIPPING`/`WORK_ASSIGN` | +| viewMode | string | - | 뷰 모드: `"itemGrouped"` (품목별 카드 그룹) / 미설정 시 리소스 기반 | | defaultZoomLevel | string | `"day"` | 초기 줌: `day`/`week`/`month` | | editable | boolean | `true` | 편집 가능 | | draggable | boolean | `true` | 드래그 이동 허용 | @@ -548,15 +553,16 @@ CREATE TABLE "{테이블명}" ( | rowHeight | number | `50` | 행 높이(px) | | headerHeight | number | `60` | 헤더 높이(px) | | resourceColumnWidth | number | `150` | 리소스 컬럼 너비(px) | -| cellWidth.day | number | `60` | 일 단위 셀 너비 | -| cellWidth.week | number | `120` | 주 단위 셀 너비 | -| cellWidth.month | number | `40` | 월 단위 셀 너비 | | showConflicts | boolean | `true` | 시간 겹침 충돌 표시 | | showProgress | boolean | `true` | 진행률 바 표시 | | showTodayLine | boolean | `true` | 오늘 날짜 표시선 | | showToolbar | boolean | `true` | 상단 툴바 표시 | +| showLegend | boolean | `true` | 범례(상태 색상 안내) 표시 | +| showNavigation | boolean | `true` | 날짜 네비게이션 버튼 표시 | +| showZoomControls | boolean | `true` | 줌 컨트롤 버튼 표시 | | showAddButton | boolean | `true` | 추가 버튼 | | height | number | `500` | 높이(px) | +| maxHeight | number | - | 최대 높이(px) | **fieldMapping (필수)**: @@ -583,10 +589,74 @@ CREATE TABLE "{테이블명}" ( | 상태 | 기본 색상 | |------|----------| | planned | `"#3b82f6"` (파랑) | -| in_progress | `"#f59e0b"` (주황) | -| completed | `"#10b981"` (초록) | +| in_progress | `"#10b981"` (초록) | +| completed | `"#6b7280"` (회색) | | delayed | `"#ef4444"` (빨강) | -| cancelled | `"#6b7280"` (회색) | +| cancelled | `"#9ca3af"` (연회색) | + +**staticFilters (정적 필터)** - DB 조회 시 항상 적용되는 WHERE 조건: + +| 설정 | 타입 | 설명 | +|------|------|------| +| product_type | string | `"완제품"` 또는 `"반제품"` 등 고정 필터 | +| status | string | 상태값 필터 | +| (임의 컬럼) | string | 해당 컬럼으로 필터링 | + +```json +"staticFilters": { + "product_type": "완제품" +} +``` + +**linkedFilter (연결 필터)** - 다른 컴포넌트(주로 테이블)의 선택 이벤트와 연동: + +| 설정 | 타입 | 설명 | +|------|------|------| +| sourceField | string | 소스 컴포넌트(좌측 테이블)의 필터 기준 컬럼 | +| targetField | string | 타임라인 스케줄 데이터에서 매칭할 컬럼 | +| sourceTableName | string | 이벤트 발신 테이블명 (이벤트 필터용) | +| sourceComponentId | string | 이벤트 발신 컴포넌트 ID (선택) | +| emptyMessage | string | 선택 전 빈 상태 메시지 | +| showEmptyWhenNoSelection | boolean | 선택 전 빈 상태 표시 여부 | + +```json +"linkedFilter": { + "sourceField": "part_code", + "targetField": "item_code", + "sourceTableName": "sales_order_mng", + "emptyMessage": "좌측 수주 목록에서 품목을 선택하세요", + "showEmptyWhenNoSelection": true +} +``` + +> **linkedFilter 동작 원리**: v2EventBus의 `TABLE_SELECTION_CHANGE` 이벤트를 구독. +> 좌측 테이블에서 행을 선택하면 해당 행의 `sourceField` 값을 수집하여, +> 타임라인 데이터 중 `targetField`가 일치하는 스케줄만 클라이언트 측에서 필터링 표시. +> `staticFilters`는 서버 측 조회, `linkedFilter`는 클라이언트 측 필터링. + +**viewMode: "itemGrouped" (품목별 그룹 뷰)**: + +리소스(설비) 기반 간트차트 대신, 품목(item_code)별로 카드를 그룹화하여 표시하는 모드. +각 카드 안에 해당 품목의 스케줄 바가 미니 타임라인으로 표시됨. + +설정 시 `viewMode: "itemGrouped"`만 추가하면 됨. 툴바에 자동으로: +- 날짜 네비게이션 (이전/오늘/다음) +- 줌 컨트롤 +- 새로고침 버튼 +- (완제품 탭일 때) **완제품 계획 생성** / **반제품 계획 생성** 버튼 + +**자동 스케줄 생성 (내장 기능)**: + +`viewMode: "itemGrouped"` + `staticFilters.product_type === "완제품"` 일 때 자동 활성화. + +- **완제품 계획 생성**: linkedFilter로 선택된 수주 품목 기반, 미리보기 다이얼로그 → 확인 후 생성 + - API: `POST /production/generate-schedule/preview` → `POST /production/generate-schedule` +- **반제품 계획 생성**: 현재 타임라인의 완제품 스케줄 기반, BOM 소요량으로 반제품 계획 미리보기 → 확인 후 생성 + - API: `POST /production/generate-semi-schedule/preview` → `POST /production/generate-semi-schedule` + +> **중요**: 반제품 전용 타임라인에는 `linkedFilter`를 걸지 않는다. +> 반제품 item_code가 수주 품목 코드와 다르므로 매칭 불가. +> `staticFilters: { product_type: "반제품" }`만 설정하여 전체 반제품 계획을 표시. --- @@ -923,16 +993,32 @@ CREATE TABLE "{테이블명}" ( ## 4. 패턴 의사결정 트리 ``` -Q1. 시간축 기반 일정/간트차트? → v2-timeline-scheduler -Q2. 다차원 피벗 분석? → v2-pivot-grid -Q3. 그룹별 접기/펼치기? → v2-table-grouped -Q4. 카드 형태 표시? → v2-card-display -Q5. 마스터-디테일? +Q1. 좌측 마스터 + 우측 탭(타임라인/테이블) 복합 구성? + → 패턴 F → v2-split-panel-layout(custom) + v2-tabs-widget + v2-timeline-scheduler +Q2. 시간축 기반 일정/간트차트? + ├ 품목별 카드 그룹 뷰? → 패턴 E-2 → v2-timeline-scheduler(viewMode:itemGrouped) + └ 리소스(설비) 기반? → 패턴 E → v2-timeline-scheduler +Q3. 다차원 피벗 분석? → v2-pivot-grid +Q4. 그룹별 접기/펼치기? → v2-table-grouped +Q5. 카드 형태 표시? → v2-card-display +Q6. 마스터-디테일? ├ 우측 멀티 탭? → v2-split-panel-layout + additionalTabs └ 단일 디테일? → v2-split-panel-layout -Q6. 단일 테이블? → v2-table-search-widget + v2-table-list +Q7. 단일 테이블? → v2-table-search-widget + v2-table-list ``` +### 패턴 요약표 + +| 패턴 | 대표 화면 | 핵심 컴포넌트 | +|------|----------|-------------| +| A | 거래처관리 | v2-table-search-widget + v2-table-list | +| B | 수주관리 | v2-split-panel-layout | +| C | 수주관리(멀티탭) | v2-split-panel-layout + additionalTabs | +| D | 재고현황 | v2-table-grouped | +| E | 설비 작업일정 | v2-timeline-scheduler (리소스 기반) | +| E-2 | 품목별 타임라인 | v2-timeline-scheduler (viewMode: itemGrouped) | +| F | 생산계획 | split(custom) + tabs + timeline | + --- ## 5. 관계(relation) 레퍼런스 diff --git a/docs/screen-implementation-guide/01_reference_guides/v2-component-usage-guide.md b/docs/screen-implementation-guide/01_reference_guides/v2-component-usage-guide.md index 14182a91..6d9f7c8a 100644 --- a/docs/screen-implementation-guide/01_reference_guides/v2-component-usage-guide.md +++ b/docs/screen-implementation-guide/01_reference_guides/v2-component-usage-guide.md @@ -1,6 +1,6 @@ # WACE 화면 구현 실행 가이드 (챗봇/AI 에이전트 전용) -> **최종 업데이트**: 2026-03-13 +> **최종 업데이트**: 2026-03-16 > **용도**: 사용자가 "수주관리 화면 만들어줘"라고 요청하면, 이 문서를 참조하여 SQL을 직접 생성하고 화면을 구현하는 AI 챗봇용 실행 가이드 > **핵심**: 이 문서의 SQL 템플릿을 따라 INSERT하면 화면이 자동으로 생성된다 @@ -533,7 +533,9 @@ DO UPDATE SET layout_data = EXCLUDED.layout_data, updated_at = now(); } ``` -### 8.5 패턴 E: 타임라인/간트차트 +### 8.5 패턴 E: 타임라인/간트차트 (리소스 기반) + +**사용 조건**: 설비/작업자 등 리소스 기준으로 스케줄을 시간축에 표시 ```json { @@ -575,6 +577,246 @@ DO UPDATE SET layout_data = EXCLUDED.layout_data, updated_at = now(); } ``` +### 8.6 패턴 E-2: 타임라인 (품목 그룹 뷰 + 연결 필터) + +**사용 조건**: 좌측 테이블에서 선택한 품목 기반으로 타임라인을 필터링 표시. 품목별 카드 그룹 뷰. + +> 리소스(설비) 기반이 아닌, **품목(item_code)별로 카드 그룹** 형태로 스케줄을 표시한다. +> 좌측 테이블에서 행을 선택하면 `linkedFilter`로 해당 품목의 스케줄만 필터링. +> `staticFilters`로 완제품/반제품 등 데이터 유형을 고정 필터링. + +```json +{ + "id": "timeline_finished", + "url": "@/lib/registry/components/v2-timeline-scheduler", + "position": { "x": 0, "y": 0 }, + "size": { "width": 1920, "height": 800 }, + "displayOrder": 0, + "overrides": { + "label": "완제품 생산계획", + "selectedTable": "{스케줄_테이블}", + "viewMode": "itemGrouped", + "fieldMapping": { + "id": "id", + "resourceId": "item_code", + "title": "item_name", + "startDate": "start_date", + "endDate": "end_date", + "status": "status" + }, + "defaultZoomLevel": "day", + "staticFilters": { + "product_type": "완제품" + }, + "linkedFilter": { + "sourceField": "part_code", + "targetField": "item_code", + "sourceTableName": "{좌측_테이블명}", + "emptyMessage": "좌측 목록에서 품목을 선택하세요", + "showEmptyWhenNoSelection": true + } + } +} +``` + +**핵심 설정 설명**: + +| 설정 | 용도 | +|------|------| +| `viewMode: "itemGrouped"` | 리소스 행이 아닌, 품목별 카드 그룹으로 표시 | +| `staticFilters` | DB 조회 시 항상 적용 (서버측 WHERE 조건) | +| `linkedFilter` | 다른 컴포넌트 선택 이벤트로 클라이언트 측 필터링 | +| `linkedFilter.sourceField` | 소스 테이블에서 가져올 값의 컬럼명 | +| `linkedFilter.targetField` | 타임라인 데이터에서 매칭할 컬럼명 | + +> **주의**: `linkedFilter`와 `staticFilters`의 차이 +> - `staticFilters`: DB SELECT 쿼리의 WHERE 절에 포함 → 서버에서 필터링 +> - `linkedFilter`: 전체 데이터를 불러온 후, 선택 이벤트에 따라 클라이언트에서 필터링 + +### 8.7 패턴 F: 복합 화면 (좌측 테이블 + 우측 탭 내 타임라인) + +**사용 조건**: 생산계획처럼 좌측 마스터 테이블 + 우측에 탭으로 여러 타임라인/테이블을 표시하는 복합 화면. +`v2-split-panel-layout`의 `rightPanel.displayMode: "custom"` + `v2-tabs-widget` + `v2-timeline-scheduler` 조합. + +**구조 개요**: + +``` +┌──────────────────────────────────────────────────┐ +│ v2-split-panel-layout │ +│ ┌──────────┬─────────────────────────────────┐ │ +│ │ leftPanel │ rightPanel (displayMode:custom)│ │ +│ │ │ ┌─────────────────────────────┐│ │ +│ │ v2-table- │ │ v2-tabs-widget ││ │ +│ │ grouped │ │ ┌───────┬───────┬─────────┐ ││ │ +│ │ (수주목록) │ │ │완제품 │반제품 │기타 탭 │ ││ │ +│ │ │ │ └───────┴───────┴─────────┘ ││ │ +│ │ │ │ ┌─────────────────────────┐ ││ │ +│ │ │ │ │ v2-timeline-scheduler │ ││ │ +│ │ │ │ │ (품목별 그룹 뷰) │ ││ │ +│ │ │ │ └─────────────────────────┘ ││ │ +│ │ │ └─────────────────────────────┘│ │ +│ └──────────┴─────────────────────────────────┘ │ +└──────────────────────────────────────────────────┘ +``` + +**실제 layout_data 예시** (생산계획 화면 참고): + +```json +{ + "version": "2.0", + "components": [ + { + "id": "split_pp", + "url": "@/lib/registry/components/v2-split-panel-layout", + "position": { "x": 0, "y": 0 }, + "size": { "width": 1920, "height": 850 }, + "displayOrder": 0, + "overrides": { + "label": "생산계획", + "splitRatio": 25, + "resizable": true, + "autoLoad": true, + "syncSelection": true, + "leftPanel": { + "title": "수주 목록", + "displayMode": "custom", + "components": [ + { + "id": "grouped_orders", + "componentType": "v2-table-grouped", + "label": "수주별 품목", + "position": { "x": 0, "y": 0 }, + "size": { "width": 600, "height": 800 }, + "componentConfig": { + "selectedTable": "sales_order_mng", + "groupConfig": { + "groupByColumn": "order_number", + "groupLabelFormat": "{value}", + "defaultExpanded": true, + "summary": { "showCount": true } + }, + "columns": [ + { "columnName": "part_code", "displayName": "품번", "visible": true, "width": 100 }, + { "columnName": "part_name", "displayName": "품명", "visible": true, "width": 120 }, + { "columnName": "order_qty", "displayName": "수량", "visible": true, "width": 60 }, + { "columnName": "delivery_date", "displayName": "납기일", "visible": true, "width": 90 } + ], + "showCheckbox": true, + "checkboxMode": "multi" + } + } + ] + }, + "rightPanel": { + "title": "생산 계획", + "displayMode": "custom", + "components": [ + { + "id": "tabs_pp", + "componentType": "v2-tabs-widget", + "label": "생산계획 탭", + "position": { "x": 0, "y": 0 }, + "size": { "width": 1400, "height": 800 }, + "componentConfig": { + "tabs": [ + { + "id": "tab_finished", + "label": "완제품", + "order": 1, + "components": [ + { + "id": "timeline_finished", + "componentType": "v2-timeline-scheduler", + "label": "완제품 타임라인", + "position": { "x": 0, "y": 0 }, + "size": { "width": 1380, "height": 750 }, + "componentConfig": { + "selectedTable": "production_plan_mng", + "viewMode": "itemGrouped", + "fieldMapping": { + "id": "id", + "resourceId": "item_code", + "title": "item_name", + "startDate": "start_date", + "endDate": "end_date", + "status": "status" + }, + "defaultZoomLevel": "day", + "staticFilters": { + "product_type": "완제품" + }, + "linkedFilter": { + "sourceField": "part_code", + "targetField": "item_code", + "sourceTableName": "sales_order_mng", + "emptyMessage": "좌측 수주 목록에서 품목을 선택하세요", + "showEmptyWhenNoSelection": true + } + } + } + ] + }, + { + "id": "tab_semi", + "label": "반제품", + "order": 2, + "components": [ + { + "id": "timeline_semi", + "componentType": "v2-timeline-scheduler", + "label": "반제품 타임라인", + "position": { "x": 0, "y": 0 }, + "size": { "width": 1380, "height": 750 }, + "componentConfig": { + "selectedTable": "production_plan_mng", + "viewMode": "itemGrouped", + "fieldMapping": { + "id": "id", + "resourceId": "item_code", + "title": "item_name", + "startDate": "start_date", + "endDate": "end_date", + "status": "status" + }, + "defaultZoomLevel": "day", + "staticFilters": { + "product_type": "반제품" + } + } + } + ] + } + ], + "defaultTab": "tab_finished" + } + } + ] + } + } + } + ], + "gridSettings": { "columns": 12, "gap": 16, "padding": 16 }, + "screenResolution": { "width": 1920, "height": 1080 } +} +``` + +**패턴 F 핵심 포인트**: + +| 포인트 | 설명 | +|--------|------| +| `leftPanel.displayMode: "custom"` | 좌측에 v2-table-grouped 등 자유 배치 | +| `rightPanel.displayMode: "custom"` | 우측에 v2-tabs-widget 등 자유 배치 | +| `componentConfig` | custom 내부 컴포넌트는 overrides 대신 componentConfig 사용 | +| `componentType` | custom 내부에서는 url 대신 componentType 사용 | +| 완제품 탭에만 `linkedFilter` | 좌측 테이블과 연동 필터링 | +| 반제품 탭에는 `linkedFilter` 없음 | 반제품 item_code가 수주 품목과 다르므로 전체 표시 | +| 자동 스케줄 생성 버튼 | `staticFilters.product_type === "완제품"` 일 때 자동 표시 | + +> **displayMode: "custom" 내부 컴포넌트 규칙**: +> - `url` 대신 `componentType` 사용 (예: `"v2-timeline-scheduler"`, `"v2-table-grouped"`) +> - `overrides` 대신 `componentConfig` 사용 +> - `position`, `size`는 동일하게 사용 + --- ## 9. Step 7: menu_info INSERT @@ -696,29 +938,47 @@ VALUES 사용자가 화면을 요청하면 이 트리로 패턴을 결정한다. ``` -Q1. 시간축 기반 일정/간트차트가 필요한가? -├─ YES → 패턴 E (타임라인) → v2-timeline-scheduler +Q1. 좌측 마스터 + 우측에 탭으로 타임라인/테이블 등 복합 구성이 필요한가? +├─ YES → 패턴 F (복합 화면) → v2-split-panel-layout(custom) + v2-tabs-widget + v2-timeline-scheduler └─ NO ↓ -Q2. 다차원 집계/피벗 분석이 필요한가? +Q2. 시간축 기반 일정/간트차트가 필요한가? +├─ YES → Q2-1. 품목별 카드 그룹 뷰인가? +│ ├─ YES → 패턴 E-2 (품목 그룹 타임라인) → v2-timeline-scheduler(viewMode:itemGrouped) +│ └─ NO → 패턴 E (리소스 기반 타임라인) → v2-timeline-scheduler +└─ NO ↓ + +Q3. 다차원 집계/피벗 분석이 필요한가? ├─ YES → 피벗 → v2-pivot-grid └─ NO ↓ -Q3. 데이터를 그룹별로 접기/펼치기가 필요한가? +Q4. 데이터를 그룹별로 접기/펼치기가 필요한가? ├─ YES → 패턴 D (그룹화) → v2-table-grouped └─ NO ↓ -Q4. 이미지+정보를 카드 형태로 표시하는가? +Q5. 이미지+정보를 카드 형태로 표시하는가? ├─ YES → 카드뷰 → v2-card-display └─ NO ↓ -Q5. 마스터 테이블 선택 시 연관 디테일이 필요한가? -├─ YES → Q5-1. 디테일에 여러 탭이 필요한가? +Q6. 마스터 테이블 선택 시 연관 디테일이 필요한가? +├─ YES → Q6-1. 디테일에 여러 탭이 필요한가? │ ├─ YES → 패턴 C (마스터-디테일+탭) → v2-split-panel-layout + additionalTabs │ └─ NO → 패턴 B (마스터-디테일) → v2-split-panel-layout └─ NO → 패턴 A (기본 마스터) → v2-table-search-widget + v2-table-list ``` +### 패턴 선택 빠른 참조 + +| 패턴 | 대표 화면 | 핵심 컴포넌트 | +|------|----------|-------------| +| A | 거래처관리, 코드관리 | v2-table-search-widget + v2-table-list | +| B | 수주관리, 발주관리 | v2-split-panel-layout | +| C | 수주관리(멀티탭) | v2-split-panel-layout + additionalTabs | +| D | 재고현황, 그룹별조회 | v2-table-grouped | +| E | 설비 작업일정 | v2-timeline-scheduler (리소스 기반) | +| E-2 | 단독 품목별 타임라인 | v2-timeline-scheduler (viewMode: itemGrouped) | +| F | 생산계획, 작업지시 | v2-split-panel-layout(custom) + v2-tabs-widget + v2-timeline-scheduler | + --- ## 13. 화면 간 연결 관계 정의 @@ -1119,7 +1379,8 @@ VALUES ( | 검색 바 | v2-table-search-widget | `autoSelectFirstTable` | | 좌우 분할 | v2-split-panel-layout | `leftPanel`, `rightPanel`, `relation`, `splitRatio` | | 그룹화 테이블 | v2-table-grouped | `groupConfig.groupByColumn`, `summary` | -| 간트차트 | v2-timeline-scheduler | `fieldMapping`, `resourceTable` | +| 간트차트 (리소스 기반) | v2-timeline-scheduler | `fieldMapping`, `resourceTable` | +| 타임라인 (품목 그룹) | v2-timeline-scheduler | `viewMode:"itemGrouped"`, `staticFilters`, `linkedFilter` | | 피벗 분석 | v2-pivot-grid | `fields(area, summaryType)` | | 카드 뷰 | v2-card-display | `columnMapping`, `cardsPerRow` | | 액션 버튼 | v2-button-primary | `text`, `actionType`, `webTypeConfig.dataflowConfig` | @@ -1144,3 +1405,97 @@ VALUES ( | 창고 랙 | v2-rack-structure | `codePattern`, `namePattern`, `maxRows` | | 공정 작업기준 | v2-process-work-standard | `dataSource.itemTable`, `dataSource.routingDetailTable` | | 품목 라우팅 | v2-item-routing | `dataSource.itemTable`, `dataSource.routingDetailTable` | + +--- + +## 17. v2-timeline-scheduler 고급 설정 가이드 + +### 17.1 viewMode 선택 기준 + +| viewMode | 용도 | Y축 | +|----------|------|-----| +| (미설정) | 설비별 작업일정, 보전계획 | 설비/작업자 행 | +| `"itemGrouped"` | 생산계획, 출하계획 | 품목별 카드 그룹 | + +### 17.2 staticFilters vs linkedFilter 비교 + +| 구분 | staticFilters | linkedFilter | +|------|--------------|-------------| +| **적용 시점** | DB SELECT 쿼리 시 | 클라이언트 렌더링 시 | +| **위치** | 서버 측 (WHERE 절) | 프론트 측 (JS 필터링) | +| **변경 가능** | 고정 (layout에 하드코딩) | 동적 (이벤트 기반) | +| **용도** | 완제품/반제품 구분 등 | 좌측 테이블 선택 연동 | + +**조합 예시**: +``` +staticFilters: { product_type: "완제품" } → DB에서 완제품만 조회 +linkedFilter: { sourceField: "part_code", targetField: "item_code" } + → 완제품 중 좌측에서 선택한 품목만 표시 +``` + +### 17.3 자동 스케줄 생성 (내장 기능) + +`viewMode: "itemGrouped"` + `staticFilters.product_type === "완제품"` 조건 충족 시, +타임라인 툴바에 **완제품 계획 생성** / **반제품 계획 생성** 버튼이 자동 표시됨. + +**완제품 계획 생성 플로우**: +``` +1. linkedFilter로 선택된 수주 품목 수집 +2. POST /production/generate-schedule/preview → 미리보기 다이얼로그 +3. 사용자 확인 → POST /production/generate-schedule → 실제 생성 +4. 타임라인 자동 새로고침 +``` + +**반제품 계획 생성 플로우**: +``` +1. 현재 타임라인의 완제품 스케줄 ID 수집 +2. POST /production/generate-semi-schedule/preview → BOM 기반 소요량 계산 +3. 미리보기 다이얼로그 (기존 반제품 계획 삭제/유지 정보 포함) +4. 사용자 확인 → POST /production/generate-semi-schedule → 실제 생성 +5. 반제품 탭으로 전환 시 새 데이터 표시 +``` + +### 17.4 반제품 탭 주의사항 + +반제품 전용 타임라인에는 `linkedFilter`를 **걸지 않는다**. + +이유: 반제품의 `item_code`(예: `SEMI-001`)와 수주 품목의 `part_code`(예: `ITEM-001`)가 +서로 다른 값이므로 매칭이 불가능하다. `staticFilters: { product_type: "반제품" }`만 설정. + +```json +{ + "id": "timeline_semi", + "componentType": "v2-timeline-scheduler", + "componentConfig": { + "selectedTable": "production_plan_mng", + "viewMode": "itemGrouped", + "staticFilters": { "product_type": "반제품" }, + "fieldMapping": { "..." : "..." } + } +} +``` + +### 17.5 이벤트 연동 (v2EventBus) + +타임라인 컴포넌트는 `v2EventBus`를 통해 다른 컴포넌트와 통신한다. + +| 이벤트 | 방향 | 설명 | +|--------|------|------| +| `TABLE_SELECTION_CHANGE` | 수신 | 좌측 테이블 행 선택 시 linkedFilter 적용 | +| `TIMELINE_REFRESH` | 발신/수신 | 타임라인 데이터 새로고침 | + +**연결 필터 이벤트 페이로드**: +```typescript +{ + eventType: "TABLE_SELECTION_CHANGE", + source: "grouped_orders", + tableName: "sales_order_mng", + selectedRows: [ + { id: "...", part_code: "ITEM-001", ... }, + { id: "...", part_code: "ITEM-002", ... } + ] +} +``` + +타임라인은 `selectedRows`에서 `linkedFilter.sourceField` 값을 추출하여, +자신의 데이터 중 `linkedFilter.targetField`가 일치하는 항목만 표시. diff --git a/docs/screen-implementation-guide/03_production/production-plan-test-scenario.md b/docs/screen-implementation-guide/03_production/production-plan-test-scenario.md new file mode 100644 index 00000000..538f9e1c --- /dev/null +++ b/docs/screen-implementation-guide/03_production/production-plan-test-scenario.md @@ -0,0 +1,451 @@ +# 생산계획 화면 (TOPSEAL_PP_MAIN) 테스트 시나리오 + +> **화면 URL**: `http://localhost:9771/screens/3985` +> **로그인 정보**: `topseal_admin` / `qlalfqjsgh11` +> **작성일**: 2026-03-16 + +--- + +## 사전 조건 + +- 백엔드 서버 (포트 8080) 실행 중 +- 프론트엔드 서버 (포트 9771) 실행 중 +- `topseal_admin` 계정으로 로그인 완료 +- 사이드바 > 생산관리 > 생산계획 메뉴 클릭하여 화면 진입 + +### 현재 테스트 데이터 현황 + +| 구분 | 건수 | 상세 | +|------|:----:|------| +| 완제품 생산계획 | 7건 | planned(3), in_progress(3), completed(1) | +| 반제품 생산계획 | 6건 | planned(2), in_progress(2), completed(1) | +| 설비(리소스) | 10개 | CNC밀링#1~#2, 머시닝센터#1, 레이저절단기, 프레스기#1, 용접기#1, 도장설비#1, 조립라인#1, 검사대#1~#2 | +| 수주 데이터 | 10건 | sales_order_mng | + +--- + +## TC-01. 화면 레이아웃 확인 + +### 목적 +화면이 설계대로 좌/우 분할 패널로 렌더링되는지 확인 + +### 테스트 단계 +1. 생산계획 화면 진입 +2. 좌측 패널에 "수주 데이터" 탭이 보이는지 확인 +3. 우측 패널에 "완제품" / "반제품" 탭이 보이는지 확인 +4. 분할 패널 비율이 약 45:55인지 확인 + +### 예상 결과 +- [ ] 좌측: "수주데이터" 탭 + "안전재고 부족분" 탭 +- [ ] 우측: "완제품" 탭 + "반제품" 탭 +- [ ] 하단에 버튼들 (새로고침, 자동 스케줄, 병합, 반제품계획, 저장) 표시 +- [ ] 좌측 하단에 "선택 품목 불러오기" 버튼 표시 + +--- + +## TC-02. 좌측 패널 - 수주데이터 그룹 테이블 + +### 목적 +v2-table-grouped 컴포넌트의 그룹화 및 접기/펼치기 기능 확인 + +### 테스트 단계 +1. "수주데이터" 탭 선택 +2. 데이터가 품목코드(part_code) 기준으로 그룹화되었는지 확인 +3. 그룹 헤더 행에 품명, 품목코드가 표시되는지 확인 +4. 그룹 헤더 클릭하여 접기/펼치기 토글 +5. "전체 펼치기" / "전체 접기" 버튼 동작 확인 +6. 그룹별 합계(수주량, 출고량, 잔량) 표시 확인 + +### 예상 결과 +- [ ] 데이터가 part_code 기준으로 그룹화되어 표시 +- [ ] 그룹 헤더에 `{품명} ({품목코드})` 형식으로 표시 +- [ ] 그룹 헤더 클릭 시 하위 행 접기/펼치기 동작 +- [ ] 전체 펼치기/접기 버튼 정상 동작 +- [ ] 그룹별 수주량/출고량/잔량 합계 표시 + +--- + +## TC-03. 좌측 패널 - 체크박스 선택 + +### 목적 +그룹 테이블에서 체크박스 선택이 정상 동작하는지 확인 + +### 테스트 단계 +1. 개별 행 체크박스 선택/해제 +2. 그룹 헤더 체크박스로 그룹 전체 선택/해제 +3. 다른 그룹의 행도 동시 선택 가능한지 확인 +4. 선택된 행이 하이라이트되는지 확인 + +### 예상 결과 +- [ ] 개별 행 체크박스 선택/해제 정상 +- [ ] 그룹 체크박스로 하위 전체 선택/해제 +- [ ] 여러 그룹에서 동시 선택 가능 +- [ ] 선택된 행 시각적 구분 (하이라이트) + +--- + +## TC-04. 우측 패널 - 완제품 타임라인 기본 표시 + +### 목적 +v2-timeline-scheduler의 기본 렌더링 및 데이터 표시 확인 + +### 테스트 단계 +1. "완제품" 탭 선택 (기본 선택) +2. 타임라인 헤더에 날짜가 표시되는지 확인 +3. 리소스(설비) 목록이 좌측에 표시되는지 확인 +4. 스케줄 바가 해당 설비/날짜에 표시되는지 확인 +5. 스케줄 바에 품명이 표시되는지 확인 +6. 오늘 날짜 라인(빨간 세로선)이 표시되는지 확인 + +### 예상 결과 +- [ ] 타임라인 헤더에 날짜 표시 (월 그룹 + 일별) +- [ ] 좌측 리소스 열에 설비명 표시 (프레스기#1, CNC밀링머신#1 등) +- [ ] 7건의 완제품 스케줄 바가 올바른 위치에 표시 +- [ ] 스케줄 바에 item_name 표시 +- [ ] 오늘 날짜 (2026-03-16) 위치에 빨간 세로선 표시 +- [ ] "반제품" 데이터는 보이지 않음 (staticFilters 적용 확인) + +--- + +## TC-05. 타임라인 - 상태별 색상 표시 + +### 목적 +스케줄 상태에 따른 색상 구분 확인 + +### 테스트 단계 +1. 완제품 탭에서 스케줄 바 색상 확인 +2. 각 상태별 색상이 다른지 확인 + +### 예상 결과 +- [ ] `planned` (계획): 파란색 (#3b82f6) +- [ ] `in_progress` (진행): 초록색 (#10b981) +- [ ] `completed` (완료): 회색 (#6b7280) +- [ ] `delayed` (지연): 빨간색 (#ef4444) - 해당 데이터 있으면 +- [ ] 상태별 색상이 명확히 구분됨 + +--- + +## TC-06. 타임라인 - 진행률 표시 + +### 목적 +스케줄 바 내부에 진행률이 시각적으로 표시되는지 확인 + +### 테스트 단계 +1. 진행률이 있는 스케줄 바 확인 +2. 바 내부에 진행률 비율만큼 채워진 영역 확인 +3. 진행률 퍼센트 텍스트 표시 확인 + +### 예상 결과 +- [ ] `탑씰 Type A` (id:103): 40% 진행률 표시 +- [ ] `탑씰 Type B` (id:2): 25% 진행률 표시 +- [ ] `탑씰 Type C` (id:105): 25% 진행률 표시 +- [ ] `탑씰 Type A` (id:4): 100% 진행률 표시 (완료) +- [ ] 바 내부에 진행 영역이 색이 다르게 채워짐 + +--- + +## TC-07. 타임라인 - 줌 레벨 전환 + +### 목적 +일/주/월 줌 레벨 전환이 정상 동작하는지 확인 + +### 테스트 단계 +1. 툴바에서 "주" (기본) 줌 레벨 확인 +2. "일" 줌 레벨로 전환 -> 날짜 간격 변화 확인 +3. "월" 줌 레벨로 전환 -> 날짜 간격 변화 확인 +4. 다시 "주" 줌 레벨로 복귀 + +### 예상 결과 +- [ ] "일" 모드: 날짜 셀이 넓어지고, 하루 단위로 상세 표시 +- [ ] "주" 모드: 기본 크기, 주 단위 표시 +- [ ] "월" 모드: 날짜 셀이 좁아지고, 월 단위로 축소 표시 +- [ ] 줌 레벨 전환 시 스케줄 바 위치/크기가 자동 조정 + +--- + +## TC-08. 타임라인 - 날짜 네비게이션 + +### 목적 +이전/다음/오늘 버튼으로 타임라인 이동이 정상 동작하는지 확인 + +### 테스트 단계 +1. 툴바에서 현재 표시 날짜 확인 +2. "다음" 버튼 클릭 -> 다음 주(또는 기간)로 이동 +3. "이전" 버튼 클릭 -> 이전 주로 이동 +4. "오늘" 버튼 클릭 -> 현재 날짜 영역으로 이동 +5. 2월 초 데이터가 있으므로 충분히 이전으로 이동하여 과거 데이터 확인 + +### 예상 결과 +- [ ] "다음" 클릭 시 타임라인이 오른쪽(미래)으로 이동 +- [ ] "이전" 클릭 시 타임라인이 왼쪽(과거)으로 이동 +- [ ] "오늘" 클릭 시 2026-03-16 부근으로 이동 +- [ ] 날짜 헤더의 표시 날짜가 변경됨 +- [ ] 이동 후에도 스케줄 바가 올바른 위치에 표시 + +--- + +## TC-09. 타임라인 - 드래그 이동 + +### 목적 +스케줄 바를 드래그하여 날짜를 변경하는 기능 확인 + +### 테스트 단계 +1. 완제품 탭에서 `planned` 상태의 스케줄 바 선택 (예: 탑씰 Type A, id:106) +2. 스케줄 바를 마우스로 클릭하고 좌/우로 드래그 +3. 드래그 중 바가 마우스를 따라 이동하는지 확인 (시각적 피드백) +4. 마우스 놓기 후 결과 확인 +5. 성공 시 토스트 알림 확인 +6. DB에 start_date/end_date가 변경되었는지 확인 + +### 예상 결과 +- [ ] 스케줄 바 드래그 시 시각적으로 이동 (opacity 변화) +- [ ] 드래그 완료 후 "스케줄이 이동되었습니다" 토스트 표시 +- [ ] 날짜가 드래그 거리만큼 변경 (시작일/종료일 동일 간격 유지) +- [ ] 실패 시 "스케줄 이동 실패" 에러 토스트 표시 후 원래 위치로 복귀 + +--- + +## TC-10. 타임라인 - 리사이즈 (기간 조정) + +### 목적 +스케줄 바의 시작/종료 핸들을 드래그하여 기간을 변경하는 기능 확인 + +### 테스트 단계 +1. 완제품 탭에서 스케줄 바에 마우스 호버 +2. 바 좌측/우측에 리사이즈 핸들이 나타나는지 확인 +3. 우측 핸들을 오른쪽으로 드래그 -> 종료일 연장 +4. 좌측 핸들을 오른쪽으로 드래그 -> 시작일 변경 +5. 성공 시 토스트 알림 확인 + +### 예상 결과 +- [ ] 바 호버 시 좌/우측에 리사이즈 핸들(세로 바) 표시 +- [ ] 우측 핸들 드래그 시 종료일만 변경 (시작일 유지) +- [ ] 좌측 핸들 드래그 시 시작일만 변경 (종료일 유지) +- [ ] 리사이즈 완료 후 "스케줄 기간이 변경되었습니다" 토스트 표시 +- [ ] 바 크기가 변경된 기간에 맞게 조정 + +--- + +## TC-11. 타임라인 - 충돌 감지 + +### 목적 +같은 설비에 시간이 겹치는 스케줄이 있을 때 충돌 표시가 되는지 확인 + +### 테스트 단계 +1. 충돌 데이터 확인: + - 프레스기#1 (equipment_id=11): id:103 (03/10~03/17), id:4 (01/28~01/30) → 겹치지 않아서 충돌 없음 + - 조립라인#1 (equipment_id=14): id:5 (02/01~02/02), id:6 (02/01~02/02) → 기간 겹침! (반제품) +2. 반제품 탭으로 이동하여 조립라인#1의 충돌 확인 +3. 또는 드래그로 충돌 상황을 만들어서 확인 + +### 예상 결과 +- [ ] 충돌 스케줄 바에 빨간 외곽선 (`ring-destructive`) 표시 +- [ ] 충돌 스케줄 바에 경고 아이콘 (AlertTriangle) 표시 +- [ ] 툴바에 "충돌 N건" 배지 표시 (빨간색) +- [ ] 충돌이 없는 경우 배지 미표시 + +--- + +## TC-12. 타임라인 - 범례 (Legend) + +### 목적 +하단 범례가 정상 표시되는지 확인 + +### 테스트 단계 +1. 타임라인 하단에 범례 영역이 표시되는지 확인 +2. 상태별 색상 스와치가 표시되는지 확인 +3. 마일스톤 아이콘이 표시되는지 확인 +4. 충돌 표시 범례가 표시되는지 확인 + +### 예상 결과 +- [ ] "계획" (파란색), "진행" (초록색), "완료" (회색), "지연" (빨간색), "취소" (연회색) 표시 +- [ ] "마일스톤" 다이아몬드 아이콘 표시 +- [ ] "충돌" 빨간 테두리 아이콘 표시 (showConflicts 설정 시) +- [ ] 범례가 타임라인 하단에 깔끔하게 배치 + +--- + +## TC-13. 반제품 탭 전환 + +### 목적 +반제품 탭으로 전환 시 반제품 데이터만 필터링되어 표시되는지 확인 (staticFilters) + +### 테스트 단계 +1. 우측 패널에서 "반제품" 탭 클릭 +2. 표시되는 스케줄이 반제품만인지 확인 +3. 완제품 데이터가 보이지 않는지 확인 +4. 다시 "완제품" 탭 클릭하여 전환 확인 + +### 예상 결과 +- [ ] "반제품" 탭 클릭 시 반제품 스케줄만 표시 (4건) +- [ ] 반제품 리소스: 조립라인#1, 용접기#1, 레이저절단기 +- [ ] 완제품 데이터는 표시되지 않음 +- [ ] "완제품" 탭 복귀 시 완제품 데이터만 표시 + +--- + +## TC-14. 버튼 - 새로고침 + +### 목적 +"새로고침" 버튼 클릭 시 데이터가 다시 로드되는지 확인 + +### 테스트 단계 +1. 우측 패널 하단의 "새로고침" 버튼 클릭 +2. 타임라인 데이터가 다시 로드되는지 확인 +3. 토스트 알림 확인 + +### 예상 결과 +- [ ] 클릭 시 API 호출 (GET /api/production/order-summary) +- [ ] 성공 시 "데이터를 새로고침했습니다." 토스트 표시 +- [ ] 타임라인 데이터 갱신 + +--- + +## TC-15. 버튼 - 자동 스케줄 + +### 목적 +좌측 테이블에서 수주 데이터를 선택한 후 자동 스케줄 생성이 되는지 확인 + +### 테스트 단계 +1. 좌측 패널에서 수주 데이터 행 1개 이상 체크박스 선택 +2. "자동 스케줄" 버튼 클릭 +3. 확인 다이얼로그 표시 확인 ("선택한 품목의 자동 스케줄을 생성하시겠습니까?") +4. "확인" 클릭 +5. 결과 확인 + +### 예상 결과 +- [ ] 확인 다이얼로그 표시 +- [ ] 성공 시 "자동 스케줄이 생성되었습니다." 토스트 표시 +- [ ] 우측 타임라인에 새로운 스케줄 바 추가 +- [ ] 실패 시 에러 메시지 표시 +- [ ] 선택 없이 클릭 시 적절한 안내 메시지 + +--- + +## TC-16. 버튼 - 선택 품목 불러오기 + +### 목적 +좌측 수주 데이터에서 선택한 품목을 생산계획으로 불러오는 기능 확인 + +### 테스트 단계 +1. 좌측 수주데이터 탭에서 품목 선택 (체크박스) +2. "선택 품목 불러오기" 버튼 클릭 +3. 확인 다이얼로그 ("선택한 품목의 생산계획을 생성하시겠습니까?") +4. 결과 확인 + +### 예상 결과 +- [ ] 확인 다이얼로그 표시 +- [ ] 성공 시 "선택 품목이 불러와졌습니다." 토스트 표시 +- [ ] 타임라인 자동 새로고침 + +--- + +## TC-17. 버튼 - 저장 + +### 목적 +변경된 생산계획 데이터가 저장되는지 확인 + +### 테스트 단계 +1. 타임라인에서 스케줄 바 드래그 또는 리사이즈로 데이터 변경 +2. "저장" 버튼 클릭 +3. 저장 결과 확인 + +### 예상 결과 +- [ ] 성공 시 "생산계획이 저장되었습니다." 토스트 표시 +- [ ] 변경 사항이 DB에 반영 + +--- + +## TC-18. 반응형 CSS 확인 + +### 목적 +공통 반응형 CSS가 올바르게 적용되었는지 확인 + +### 테스트 단계 +1. 브라우저 창 너비를 640px 이하로 줄이기 (모바일) +2. 텍스트 크기, 버튼 크기, 패딩 변화 확인 +3. 브라우저 창 너비를 1280px 이상으로 늘리기 (데스크톱) +4. 원래 크기로 복귀 확인 + +### 예상 결과 +- [ ] 모바일(~640px): 텍스트 `text-[10px]`, 작은 버튼, 좁은 패딩 +- [ ] 데스크톱(640px~): 텍스트 `text-sm`, 기본 버튼, 넓은 패딩 +- [ ] 줌 버튼, 네비게이션 버튼, 리소스명, 날짜 헤더 모두 반응형 적용 +- [ ] 스케줄 바 내부 텍스트도 반응형 (text-[10px] sm:text-xs) +- [ ] 범례 텍스트도 반응형 + +--- + +## TC-19. 마일스톤 표시 + +### 목적 +시작일과 종료일이 같은 스케줄이 마일스톤(다이아몬드)으로 표시되는지 확인 + +### 테스트 단계 +1. DB에 마일스톤 테스트 데이터 추가: + ```sql + INSERT INTO production_plan_mng (id, item_name, product_type, status, start_date, end_date, equipment_id, progress_rate, company_code) + VALUES (200, '마일스톤 테스트', '완제품', 'planned', '2026-03-20', '2026-03-20', 9, '0', 'COMPANY_7'); + ``` +2. 새로고침 후 해당 날짜에 다이아몬드 마커가 표시되는지 확인 +3. 호버 시 정보 표시 확인 + +### 예상 결과 +- [ ] 시작일 = 종료일인 스케줄은 바 대신 다이아몬드 마커로 표시 +- [ ] 다이아몬드가 45도 회전된 정사각형으로 표시 +- [ ] 호버 시 효과 적용 + +--- + +## TC-20. 안전재고 부족분 탭 + +### 목적 +좌측 패널의 "안전재고 부족분" 탭이 정상 동작하는지 확인 + +### 테스트 단계 +1. 좌측 패널에서 "안전재고 부족분" 탭 클릭 +2. inventory_stock 테이블 데이터가 표시되는지 확인 +3. 빈 데이터인 경우 빈 상태 메시지 확인 + +### 예상 결과 +- [ ] 탭 전환 정상 동작 +- [ ] 데이터 있으면: 품목코드, 현재고, 안전재고, 창고, 최근입고일 표시 +- [ ] 데이터 없으면: "안전재고 부족분 데이터가 없습니다" 메시지 + +--- + +## 알려진 이슈 / 참고 사항 + +| 번호 | 내용 | 심각도 | +|:----:|------|:------:| +| 1 | "1 Issue" 배지가 화면 좌측 하단에 표시됨 (원인 미확인) | 낮음 | +| 2 | 생산계획 화면 URL 직접 접근 시 회사정보 화면(138)이 먼저 보일 수 있음 → 사이드바 메뉴를 통해 접근 권장 | 중간 | +| 3 | 설비(equipment_info)의 equipment_group이 null → 리소스 그룹핑 미표시 | 낮음 | +| 4 | 가상 스크롤은 리소스(설비) 30개 이상일 때 자동 활성화 (현재 10개라 비활성) | 참고 | + +--- + +## 테스트 결과 요약 + +| TC | 항목 | 결과 | 비고 | +|:--:|------|:----:|------| +| 01 | 화면 레이아웃 | | | +| 02 | 수주데이터 그룹 테이블 | | | +| 03 | 체크박스 선택 | | | +| 04 | 완제품 타임라인 기본 표시 | | | +| 05 | 상태별 색상 | | | +| 06 | 진행률 표시 | | | +| 07 | 줌 레벨 전환 | | | +| 08 | 날짜 네비게이션 | | | +| 09 | 드래그 이동 | | | +| 10 | 리사이즈 | | | +| 11 | 충돌 감지 | | | +| 12 | 범례 | | | +| 13 | 반제품 탭 전환 | | | +| 14 | 새로고침 버튼 | | | +| 15 | 자동 스케줄 버튼 | | | +| 16 | 선택 품목 불러오기 | | | +| 17 | 저장 버튼 | | | +| 18 | 반응형 CSS | | | +| 19 | 마일스톤 표시 | | | +| 20 | 안전재고 부족분 탭 | | | diff --git a/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts b/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts index d9f40aca..e2f415e7 100644 --- a/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts +++ b/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts @@ -153,15 +153,37 @@ export function useGroupedData( } ); - const responseData = response.data?.data?.data || response.data?.data || []; - setRawData(Array.isArray(responseData) ? responseData : []); + let responseData = response.data?.data?.data || response.data?.data || []; + responseData = Array.isArray(responseData) ? responseData : []; + + // dataFilter 적용 (클라이언트 사이드 필터링) + if (config.dataFilter && config.dataFilter.length > 0) { + responseData = responseData.filter((item: any) => { + return config.dataFilter!.every((f) => { + const val = item[f.column]; + switch (f.operator) { + case "eq": return val === f.value; + case "ne": return f.value === null ? (val !== null && val !== undefined && val !== "") : val !== f.value; + case "gt": return Number(val) > Number(f.value); + case "lt": return Number(val) < Number(f.value); + case "gte": return Number(val) >= Number(f.value); + case "lte": return Number(val) <= Number(f.value); + case "like": return String(val ?? "").includes(String(f.value)); + case "in": return Array.isArray(f.value) ? f.value.includes(val) : false; + default: return true; + } + }); + }); + } + + setRawData(responseData); } catch (err: any) { setError(err.message || "데이터 로드 중 오류 발생"); setRawData([]); } finally { setIsLoading(false); } - }, [tableName, externalData, searchFilters]); + }, [tableName, externalData, searchFilters, config.dataFilter]); // 초기 데이터 로드 useEffect(() => { diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx index 354869bc..3a69da65 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx +++ b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useMemo, useRef } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ChevronLeft, ChevronRight, @@ -9,18 +9,27 @@ import { Loader2, ZoomIn, ZoomOut, + Package, + Zap, + RefreshCw, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { TimelineSchedulerComponentProps, ScheduleItem, ZoomLevel, } from "./types"; import { useTimelineData } from "./hooks/useTimelineData"; -import { TimelineHeader, ResourceRow, TimelineLegend } from "./components"; -import { zoomLevelOptions, defaultTimelineSchedulerConfig } from "./config"; +import { TimelineHeader, ResourceRow, TimelineLegend, ItemTimelineCard, groupSchedulesByItem, SchedulePreviewDialog } from "./components"; +import { zoomLevelOptions, defaultTimelineSchedulerConfig, statusOptions } from "./config"; import { detectConflicts, addDaysToDateString } from "./utils/conflictDetection"; +import { v2EventBus, V2_EVENTS } from "@/lib/v2-core"; +import { apiClient } from "@/lib/api/client"; + +// 가상 스크롤 활성화 임계값 (리소스 수) +const VIRTUAL_THRESHOLD = 30; /** * v2-timeline-scheduler 메인 컴포넌트 @@ -44,9 +53,59 @@ export function TimelineSchedulerComponent({ }: TimelineSchedulerComponentProps) { const containerRef = useRef(null); + // ────────── 자동 스케줄 생성 상태 ────────── + const [showPreviewDialog, setShowPreviewDialog] = useState(false); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewApplying, setPreviewApplying] = useState(false); + const [previewSummary, setPreviewSummary] = useState(null); + const [previewItems, setPreviewItems] = useState([]); + const [previewDeleted, setPreviewDeleted] = useState([]); + const [previewKept, setPreviewKept] = useState([]); + const linkedFilterValuesRef = useRef([]); + + // ────────── 반제품 계획 생성 상태 ────────── + const [showSemiPreviewDialog, setShowSemiPreviewDialog] = useState(false); + const [semiPreviewLoading, setSemiPreviewLoading] = useState(false); + const [semiPreviewApplying, setSemiPreviewApplying] = useState(false); + const [semiPreviewSummary, setSemiPreviewSummary] = useState(null); + const [semiPreviewItems, setSemiPreviewItems] = useState([]); + const [semiPreviewDeleted, setSemiPreviewDeleted] = useState([]); + const [semiPreviewKept, setSemiPreviewKept] = useState([]); + + // ────────── linkedFilter 상태 ────────── + const linkedFilter = config.linkedFilter; + const hasLinkedFilter = !!linkedFilter; + const [linkedFilterValues, setLinkedFilterValues] = useState([]); + const [hasReceivedSelection, setHasReceivedSelection] = useState(false); + + // linkedFilter 이벤트 수신 + useEffect(() => { + if (!hasLinkedFilter) return; + + const handler = (event: any) => { + if (linkedFilter!.sourceTableName && event.tableName !== linkedFilter!.sourceTableName) return; + if (linkedFilter!.sourceComponentId && event.componentId !== linkedFilter!.sourceComponentId) return; + + const selectedRows: any[] = event.selectedRows || []; + const sourceField = linkedFilter!.sourceField; + + const values = selectedRows + .map((row: any) => String(row[sourceField] ?? "")) + .filter((v: string) => v !== "" && v !== "undefined" && v !== "null"); + + const uniqueValues = [...new Set(values)]; + setLinkedFilterValues(uniqueValues); + setHasReceivedSelection(true); + linkedFilterValuesRef.current = selectedRows; + }; + + const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_SELECTION_CHANGE, handler); + return unsubscribe; + }, [hasLinkedFilter, linkedFilter]); + // 타임라인 데이터 훅 const { - schedules, + schedules: rawSchedules, resources, isLoading: hookLoading, error: hookError, @@ -58,8 +117,21 @@ export function TimelineSchedulerComponent({ goToNext, goToToday, updateSchedule, + refresh: refreshTimeline, } = useTimelineData(config, externalSchedules, externalResources); + // linkedFilter 적용: 선택된 값으로 스케줄 필터링 + const schedules = useMemo(() => { + if (!hasLinkedFilter) return rawSchedules; + if (linkedFilterValues.length === 0) return []; + + const targetField = linkedFilter!.targetField; + return rawSchedules.filter((s) => { + const val = String((s.data as any)?.[targetField] ?? (s as any)[targetField] ?? ""); + return linkedFilterValues.includes(val); + }); + }, [rawSchedules, hasLinkedFilter, linkedFilterValues, linkedFilter]); + const isLoading = externalLoading ?? hookLoading; const error = externalError ?? hookError; @@ -267,11 +339,212 @@ export function TimelineSchedulerComponent({ } }, [onAddSchedule, effectiveResources]); + // ────────── 자동 스케줄 생성: 미리보기 요청 ────────── + const handleAutoSchedulePreview = useCallback(async () => { + const selectedRows = linkedFilterValuesRef.current; + if (!selectedRows || selectedRows.length === 0) { + toast.warning("좌측에서 품목을 선택해주세요"); + return; + } + + const sourceField = config.linkedFilter?.sourceField || "part_code"; + const grouped = new Map(); + selectedRows.forEach((row: any) => { + const key = row[sourceField] || ""; + if (!key) return; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key)!.push(row); + }); + + const items = Array.from(grouped.entries()).map(([itemCode, rows]) => { + const totalBalanceQty = rows.reduce((sum: number, r: any) => sum + (Number(r.balance_qty) || 0), 0); + const earliestDueDate = rows + .map((r: any) => r.due_date) + .filter(Boolean) + .sort()[0] || new Date().toISOString().split("T")[0]; + const first = rows[0]; + + return { + item_code: itemCode, + item_name: first.part_name || first.item_name || itemCode, + required_qty: totalBalanceQty, + earliest_due_date: typeof earliestDueDate === "string" ? earliestDueDate.split("T")[0] : earliestDueDate, + hourly_capacity: Number(first.hourly_capacity) || undefined, + daily_capacity: Number(first.daily_capacity) || undefined, + }; + }).filter((item) => item.required_qty > 0); + + if (items.length === 0) { + toast.warning("선택된 품목의 잔량이 없습니다"); + return; + } + + setShowPreviewDialog(true); + setPreviewLoading(true); + + try { + const response = await apiClient.post("/production/generate-schedule/preview", { + items, + options: { + product_type: config.staticFilters?.product_type || "완제품", + safety_lead_time: 1, + recalculate_unstarted: true, + }, + }); + + if (response.data?.success) { + setPreviewSummary(response.data.data.summary); + setPreviewItems(response.data.data.previews); + setPreviewDeleted(response.data.data.deletedSchedules || []); + setPreviewKept(response.data.data.keptSchedules || []); + } else { + toast.error("미리보기 생성 실패"); + setShowPreviewDialog(false); + } + } catch (err: any) { + toast.error("미리보기 요청 실패", { description: err.message }); + setShowPreviewDialog(false); + } finally { + setPreviewLoading(false); + } + }, [config.linkedFilter, config.staticFilters]); + + // ────────── 자동 스케줄 생성: 확인 및 적용 ────────── + const handleAutoScheduleApply = useCallback(async () => { + if (!previewItems || previewItems.length === 0) return; + + setPreviewApplying(true); + + const items = previewItems.map((p: any) => ({ + item_code: p.item_code, + item_name: p.item_name, + required_qty: p.required_qty, + earliest_due_date: p.due_date, + hourly_capacity: p.hourly_capacity, + daily_capacity: p.daily_capacity, + })); + + try { + const response = await apiClient.post("/production/generate-schedule", { + items, + options: { + product_type: config.staticFilters?.product_type || "완제품", + safety_lead_time: 1, + recalculate_unstarted: true, + }, + }); + + if (response.data?.success) { + const summary = response.data.data.summary; + toast.success("생산계획 업데이트 완료", { + description: `신규: ${summary.new_count}건, 유지: ${summary.kept_count}건, 삭제: ${summary.deleted_count}건`, + }); + setShowPreviewDialog(false); + refreshTimeline(); + } else { + toast.error("생산계획 생성 실패"); + } + } catch (err: any) { + toast.error("생산계획 생성 실패", { description: err.message }); + } finally { + setPreviewApplying(false); + } + }, [previewItems, config.staticFilters, refreshTimeline]); + + // ────────── 반제품 계획 생성: 미리보기 요청 ────────── + const handleSemiSchedulePreview = useCallback(async () => { + // 현재 타임라인에 표시된 완제품 스케줄의 plan ID 수집 + const finishedSchedules = schedules.filter((s) => { + const productType = (s.data as any)?.product_type || ""; + return productType === "완제품"; + }); + + if (finishedSchedules.length === 0) { + toast.warning("완제품 스케줄이 없습니다. 먼저 완제품 계획을 생성해주세요."); + return; + } + + const planIds = finishedSchedules.map((s) => Number(s.id)).filter((id) => !isNaN(id)); + if (planIds.length === 0) { + toast.warning("유효한 완제품 계획 ID가 없습니다"); + return; + } + + setShowSemiPreviewDialog(true); + setSemiPreviewLoading(true); + + try { + const response = await apiClient.post("/production/generate-semi-schedule/preview", { + plan_ids: planIds, + options: { considerStock: true }, + }); + + if (response.data?.success) { + setSemiPreviewSummary(response.data.data.summary); + setSemiPreviewItems(response.data.data.previews || []); + setSemiPreviewDeleted(response.data.data.deletedSchedules || []); + setSemiPreviewKept(response.data.data.keptSchedules || []); + } else { + toast.error("반제품 미리보기 실패", { description: response.data?.message }); + setShowSemiPreviewDialog(false); + } + } catch (err: any) { + toast.error("반제품 미리보기 요청 실패", { description: err.message }); + setShowSemiPreviewDialog(false); + } finally { + setSemiPreviewLoading(false); + } + }, [schedules]); + + // ────────── 반제품 계획 생성: 확인 및 적용 ────────── + const handleSemiScheduleApply = useCallback(async () => { + const finishedSchedules = schedules.filter((s) => { + const productType = (s.data as any)?.product_type || ""; + return productType === "완제품"; + }); + const planIds = finishedSchedules.map((s) => Number(s.id)).filter((id) => !isNaN(id)); + + if (planIds.length === 0) return; + + setSemiPreviewApplying(true); + + try { + const response = await apiClient.post("/production/generate-semi-schedule", { + plan_ids: planIds, + options: { considerStock: true }, + }); + + if (response.data?.success) { + const data = response.data.data; + toast.success("반제품 계획 생성 완료", { + description: `${data.count}건의 반제품 계획이 생성되었습니다`, + }); + setShowSemiPreviewDialog(false); + refreshTimeline(); + } else { + toast.error("반제품 계획 생성 실패"); + } + } catch (err: any) { + toast.error("반제품 계획 생성 실패", { description: err.message }); + } finally { + setSemiPreviewApplying(false); + } + }, [schedules, refreshTimeline]); + // ────────── 하단 영역 높이 계산 (툴바 + 범례) ────────── const showToolbar = config.showToolbar !== false; const showLegend = config.showLegend !== false; - const toolbarHeight = showToolbar ? 36 : 0; - const legendHeight = showLegend ? 28 : 0; + + // ────────── 가상 스크롤 ────────── + const scrollContainerRef = useRef(null); + const useVirtual = effectiveResources.length >= VIRTUAL_THRESHOLD; + + const virtualizer = useVirtualizer({ + count: effectiveResources.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => rowHeight, + overscan: 5, + }); // ────────── 디자인 모드 플레이스홀더 ────────── if (isDesignMode) { @@ -320,8 +593,49 @@ export function TimelineSchedulerComponent({ ); } - // ────────── 데이터 없음 ────────── - if (schedules.length === 0) { + // ────────── linkedFilter 빈 상태 (itemGrouped가 아닌 경우만 early return) ────────── + // itemGrouped 모드에서는 툴바를 항상 보여주기 위해 여기서 return하지 않음 + if (config.viewMode !== "itemGrouped") { + if (hasLinkedFilter && !hasReceivedSelection) { + const emptyMsg = linkedFilter?.emptyMessage || "좌측 목록에서 품목 또는 수주를 선택하세요"; + return ( +
+
+ +

{emptyMsg}

+

+ 선택한 항목에 대한 생산계획 타임라인이 여기에 표시됩니다 +

+
+
+ ); + } + + if (hasLinkedFilter && hasReceivedSelection && schedules.length === 0) { + return ( +
+
+ +

+ 선택한 항목에 대한 스케줄이 없습니다 +

+

+ 다른 품목을 선택하거나 스케줄을 생성해 주세요 +

+
+
+ ); + } + } + + // ────────── 데이터 없음 (linkedFilter 없고 itemGrouped가 아닌 경우) ────────── + if (schedules.length === 0 && config.viewMode !== "itemGrouped") { return (
+ {/* 툴바: 액션 버튼 + 네비게이션 */} + {showToolbar && ( +
+ {/* 네비게이션 */} +
+ {config.showNavigation !== false && ( + <> + + + + + )} + + {viewStartDate.toLocaleDateString("ko-KR")} ~ {viewEndDate.toLocaleDateString("ko-KR")} + +
+ + {/* 줌 + 액션 버튼 */} +
+ {config.showZoomControls !== false && ( + <> + + + {zoomLevelOptions.find((o) => o.value === zoomLevel)?.label} + + +
+ + )} + + + {config.staticFilters?.product_type === "완제품" && ( + <> + + + + )} +
+
+ )} + + {/* 범례 */} + {showLegend && ( +
+ 생산 상태: + {statusOptions.map((s) => ( +
+
+ {s.label} +
+ ))} + 납기: +
+
+ 납기일 +
+
+ )} + + {/* 품목별 카드 목록 또는 빈 상태 */} + {itemGroups.length > 0 ? ( +
+ {itemGroups.map((group) => ( + + ))} +
+ ) : ( +
+
+ {hasLinkedFilter && !hasReceivedSelection ? ( + <> + +

+ {linkedFilter?.emptyMessage || "좌측 목록에서 품목을 선택하세요"} +

+

+ 선택한 항목에 대한 생산계획 타임라인이 여기에 표시됩니다 +

+ + ) : hasLinkedFilter && hasReceivedSelection ? ( + <> + +

+ 선택한 항목에 대한 스케줄이 없습니다 +

+

+ 위 "자동 스케줄 생성" 버튼으로 생산계획을 생성하세요 +

+ + ) : ( + <> + +

스케줄 데이터가 없습니다

+ + )} +
+
+ )} + + {/* 완제품 스케줄 생성 미리보기 다이얼로그 */} + + + {/* 반제품 계획 생성 미리보기 다이얼로그 */} + +
+ ); + } + + // ────────── 메인 렌더링 (리소스 기반) ────────── return (
+
{/* 헤더 */} - {/* 리소스 행들 */} -
- {effectiveResources.map((resource) => ( - - ))} -
+ {/* 리소스 행들 - 30개 이상이면 가상 스크롤 */} + {useVirtual ? ( +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const resource = effectiveResources[virtualRow.index]; + return ( +
+ +
+ ); + })} +
+ ) : ( +
+ {effectiveResources.map((resource) => ( + + ))} +
+ )}
diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/ItemTimelineCard.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/ItemTimelineCard.tsx new file mode 100644 index 00000000..01e72a1c --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/ItemTimelineCard.tsx @@ -0,0 +1,297 @@ +"use client"; + +import React, { useMemo, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { Flame } from "lucide-react"; +import { ScheduleItem, TimelineSchedulerConfig, ZoomLevel } from "../types"; +import { statusOptions, dayLabels } from "../config"; + +interface ItemScheduleGroup { + itemCode: string; + itemName: string; + hourlyCapacity: number; + dailyCapacity: number; + schedules: ScheduleItem[]; + totalPlanQty: number; + totalCompletedQty: number; + remainingQty: number; + dueDates: { date: string; isUrgent: boolean }[]; +} + +interface ItemTimelineCardProps { + group: ItemScheduleGroup; + viewStartDate: Date; + viewEndDate: Date; + zoomLevel: ZoomLevel; + cellWidth: number; + config: TimelineSchedulerConfig; + onScheduleClick?: (schedule: ScheduleItem) => void; +} + +const toDateString = (d: Date) => d.toISOString().split("T")[0]; + +const addDays = (d: Date, n: number) => { + const r = new Date(d); + r.setDate(r.getDate() + n); + return r; +}; + +const diffDays = (a: Date, b: Date) => + Math.round((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24)); + +function generateDateCells(start: Date, end: Date) { + const cells: { date: Date; label: string; dayLabel: string; isWeekend: boolean; isToday: boolean; dateStr: string }[] = []; + const today = toDateString(new Date()); + let cur = new Date(start); + while (cur <= end) { + const d = new Date(cur); + const dow = d.getDay(); + cells.push({ + date: d, + label: String(d.getDate()), + dayLabel: dayLabels[dow], + isWeekend: dow === 0 || dow === 6, + isToday: toDateString(d) === today, + dateStr: toDateString(d), + }); + cur = addDays(cur, 1); + } + return cells; +} + +export function ItemTimelineCard({ + group, + viewStartDate, + viewEndDate, + zoomLevel, + cellWidth, + config, + onScheduleClick, +}: ItemTimelineCardProps) { + const scrollRef = useRef(null); + + const dateCells = useMemo( + () => generateDateCells(viewStartDate, viewEndDate), + [viewStartDate, viewEndDate] + ); + + const totalWidth = dateCells.length * cellWidth; + + const dueDateSet = useMemo(() => { + const set = new Set(); + group.dueDates.forEach((d) => set.add(d.date)); + return set; + }, [group.dueDates]); + + const urgentDateSet = useMemo(() => { + const set = new Set(); + group.dueDates.filter((d) => d.isUrgent).forEach((d) => set.add(d.date)); + return set; + }, [group.dueDates]); + + const statusColor = (status: string) => + config.statusColors?.[status as keyof typeof config.statusColors] || + statusOptions.find((s) => s.value === status)?.color || + "#3b82f6"; + + const isUrgentItem = group.dueDates.some((d) => d.isUrgent); + const hasRemaining = group.remainingQty > 0; + + return ( +
+ {/* 품목 헤더 */} +
+
+ +
+

{group.itemCode}

+

{group.itemName}

+
+
+
+

+ 시간당: {group.hourlyCapacity.toLocaleString()} EA +

+

+ 일일: {group.dailyCapacity.toLocaleString()} EA +

+
+
+ + {/* 타임라인 영역 */} +
+
+ {/* 날짜 헤더 */} +
+ {dateCells.map((cell) => { + const isDueDate = dueDateSet.has(cell.dateStr); + const isUrgentDate = urgentDateSet.has(cell.dateStr); + return ( +
+ + {cell.label} + + + {cell.dayLabel} + +
+ ); + })} +
+ + {/* 스케줄 바 영역 */} +
+ {group.schedules.map((schedule) => { + const schedStart = new Date(schedule.startDate); + const schedEnd = new Date(schedule.endDate); + + const startOffset = diffDays(schedStart, viewStartDate); + const endOffset = diffDays(schedEnd, viewStartDate); + + const left = Math.max(0, startOffset * cellWidth); + const right = Math.min(totalWidth, (endOffset + 1) * cellWidth); + const width = Math.max(cellWidth * 0.5, right - left); + + if (right < 0 || left > totalWidth) return null; + + const qty = Number(schedule.data?.plan_qty) || 0; + const color = statusColor(schedule.status); + + return ( +
onScheduleClick?.(schedule)} + title={`${schedule.title} (${schedule.startDate} ~ ${schedule.endDate})`} + > +
+ {qty > 0 ? `${qty.toLocaleString()} EA` : schedule.title} +
+
+ ); + })} + + {/* 납기일 마커 */} + {group.dueDates.map((dueDate, idx) => { + const d = new Date(dueDate.date); + const offset = diffDays(d, viewStartDate); + if (offset < 0 || offset > dateCells.length) return null; + const left = offset * cellWidth + cellWidth / 2; + return ( +
+
+
+ ); + })} +
+
+
+ + {/* 하단 잔량 영역 */} +
+ + {hasRemaining && ( +
+ {isUrgentItem && } + {group.remainingQty.toLocaleString()} EA +
+ )} + {/* 스크롤 인디케이터 */} +
+
+
+
+
+ ); +} + +/** + * 스케줄 데이터를 품목별로 그룹화 + */ +export function groupSchedulesByItem(schedules: ScheduleItem[]): ItemScheduleGroup[] { + const grouped = new Map(); + + schedules.forEach((s) => { + const key = s.data?.item_code || "unknown"; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key)!.push(s); + }); + + const result: ItemScheduleGroup[] = []; + + grouped.forEach((items, itemCode) => { + const first = items[0]; + const hourlyCapacity = Number(first.data?.hourly_capacity) || 0; + const dailyCapacity = Number(first.data?.daily_capacity) || 0; + const totalPlanQty = items.reduce((sum, s) => sum + (Number(s.data?.plan_qty) || 0), 0); + const totalCompletedQty = items.reduce((sum, s) => sum + (Number(s.data?.completed_qty) || 0), 0); + + const dueDates: { date: string; isUrgent: boolean }[] = []; + const seenDueDates = new Set(); + items.forEach((s) => { + const dd = s.data?.due_date; + if (dd) { + const dateStr = typeof dd === "string" ? dd.split("T")[0] : ""; + if (dateStr && !seenDueDates.has(dateStr)) { + seenDueDates.add(dateStr); + const isUrgent = s.data?.priority === "urgent" || s.data?.priority === "high"; + dueDates.push({ date: dateStr, isUrgent }); + } + } + }); + + result.push({ + itemCode, + itemName: first.data?.item_name || first.title || itemCode, + hourlyCapacity, + dailyCapacity, + schedules: items.sort( + (a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime() + ), + totalPlanQty, + totalCompletedQty, + remainingQty: totalPlanQty - totalCompletedQty, + dueDates: dueDates.sort((a, b) => a.date.localeCompare(b.date)), + }); + }); + + return result.sort((a, b) => a.itemCode.localeCompare(b.itemCode)); +} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/SchedulePreviewDialog.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/SchedulePreviewDialog.tsx new file mode 100644 index 00000000..ab130659 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/SchedulePreviewDialog.tsx @@ -0,0 +1,282 @@ +"use client"; + +import React from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Loader2, AlertTriangle, Check, X, Trash2, Play } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { statusOptions } from "../config"; + +interface PreviewItem { + item_code: string; + item_name: string; + required_qty: number; + daily_capacity: number; + hourly_capacity: number; + production_days: number; + start_date: string; + end_date: string; + due_date: string; + order_count: number; + status: string; +} + +interface ExistingSchedule { + id: string; + plan_no: string; + item_code: string; + item_name: string; + plan_qty: string; + start_date: string; + end_date: string; + status: string; + completed_qty?: string; +} + +interface PreviewSummary { + total: number; + new_count: number; + kept_count: number; + deleted_count: number; +} + +interface SchedulePreviewDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + isLoading: boolean; + summary: PreviewSummary | null; + previews: PreviewItem[]; + deletedSchedules: ExistingSchedule[]; + keptSchedules: ExistingSchedule[]; + onConfirm: () => void; + isApplying: boolean; + title?: string; + description?: string; +} + +const summaryCards = [ + { key: "total", label: "총 계획", color: "bg-primary/10 text-primary" }, + { key: "new_count", label: "신규 입력", color: "bg-emerald-50 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400" }, + { key: "deleted_count", label: "삭제될", color: "bg-destructive/10 text-destructive" }, + { key: "kept_count", label: "유지(진행중)", color: "bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-400" }, +]; + +function formatDate(d: string | null | undefined): string { + if (!d) return "-"; + const s = typeof d === "string" ? d : String(d); + return s.split("T")[0]; +} + +export function SchedulePreviewDialog({ + open, + onOpenChange, + isLoading, + summary, + previews, + deletedSchedules, + keptSchedules, + onConfirm, + isApplying, + title, + description, +}: SchedulePreviewDialogProps) { + return ( + + + + + {title || "생산계획 변경사항 확인"} + + + {description || "변경사항을 확인해주세요"} + + + + {isLoading ? ( +
+ + 미리보기 생성 중... +
+ ) : summary ? ( +
+ {/* 경고 배너 */} +
+ +
+

변경사항을 확인해주세요

+

+ 아래 변경사항을 검토하신 후 확인 버튼을 눌러주시면 생산계획이 업데이트됩니다. +

+
+
+ + {/* 요약 카드 */} +
+ {summaryCards.map((card) => ( +
+

+ {(summary as any)[card.key] ?? 0} +

+

{card.label}

+
+ ))} +
+ + {/* 신규 생성 목록 */} + {previews.length > 0 && ( +
+

+ + 신규 생성되는 계획 ({previews.length}건) +

+
+ {previews.map((item, idx) => { + const statusInfo = statusOptions.find((s) => s.value === item.status); + return ( +
+
+

+ {item.item_code} - {item.item_name} +

+ + {statusInfo?.label || item.status} + +
+

+ 수량: {(item.required_qty || (item as any).plan_qty || 0).toLocaleString()} EA +

+
+ 시작일: {formatDate(item.start_date)} + 종료일: {formatDate(item.end_date)} +
+ {item.order_count ? ( +

+ {item.order_count}건 수주 통합 (총 {item.required_qty.toLocaleString()} EA) +

+ ) : (item as any).parent_item_name ? ( +

+ 상위: {(item as any).parent_plan_no} ({(item as any).parent_item_name}) | BOM 수량: {(item as any).bom_qty || 1} +

+ ) : null} +
+ ); + })} +
+
+ )} + + {/* 삭제될 목록 */} + {deletedSchedules.length > 0 && ( +
+

+ + 삭제될 기존 계획 ({deletedSchedules.length}건) +

+
+ {deletedSchedules.map((item, idx) => ( +
+
+

+ {item.item_code} - {item.item_name} +

+ + 삭제 예정 + +
+

+ {item.plan_no} | 수량: {Number(item.plan_qty || 0).toLocaleString()} EA +

+
+ 시작일: {formatDate(item.start_date)} + 종료일: {formatDate(item.end_date)} +
+
+ ))} +
+
+ )} + + {/* 유지될 목록 (진행중) */} + {keptSchedules.length > 0 && ( +
+

+ + 유지되는 진행중 계획 ({keptSchedules.length}건) +

+
+ {keptSchedules.map((item, idx) => { + const statusInfo = statusOptions.find((s) => s.value === item.status); + return ( +
+
+

+ {item.item_code} - {item.item_name} +

+ + {statusInfo?.label || item.status} + +
+

+ {item.plan_no} | 수량: {Number(item.plan_qty || 0).toLocaleString()} EA + {item.completed_qty ? ` (완료: ${Number(item.completed_qty).toLocaleString()} EA)` : ""} +

+
+ 시작일: {formatDate(item.start_date)} + 종료일: {formatDate(item.end_date)} +
+
+ ); + })} +
+
+ )} +
+ ) : ( +
+ 미리보기 데이터를 불러올 수 없습니다 +
+ )} + + + + + +
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts b/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts index 4ac2af4b..e9064267 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts @@ -2,3 +2,5 @@ export { TimelineHeader } from "./TimelineHeader"; export { ScheduleBar } from "./ScheduleBar"; export { ResourceRow } from "./ResourceRow"; export { TimelineLegend } from "./TimelineLegend"; +export { ItemTimelineCard, groupSchedulesByItem } from "./ItemTimelineCard"; +export { SchedulePreviewDialog } from "./SchedulePreviewDialog"; diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/types.ts b/frontend/lib/registry/components/v2-timeline-scheduler/types.ts index afcc9f5e..aa5c4edd 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/types.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/types.ts @@ -225,6 +225,35 @@ export interface TimelineSchedulerConfig extends ComponentConfig { /** 최대 높이 */ maxHeight?: number | string; + + /** + * 표시 모드 + * - "resource": 기존 설비(리소스) 기반 간트 차트 (기본값) + * - "itemGrouped": 품목별 카드형 타임라인 (참고 이미지 스타일) + */ + viewMode?: "resource" | "itemGrouped"; + + /** 범례 표시 여부 */ + showLegend?: boolean; + + /** + * 연결 필터 설정: 다른 컴포넌트의 선택에 따라 데이터를 필터링 + * 설정 시 초기 상태는 빈 화면, 선택 이벤트 수신 시 필터링된 데이터 표시 + */ + linkedFilter?: { + /** 소스 컴포넌트 ID (선택 이벤트를 발생시키는 컴포넌트) */ + sourceComponentId?: string; + /** 소스 테이블명 (이벤트의 tableName과 매칭) */ + sourceTableName?: string; + /** 소스 필드 (선택된 행에서 추출할 필드) */ + sourceField: string; + /** 타겟 필드 (타임라인 데이터에서 필터링할 필드) */ + targetField: string; + /** 선택 없을 때 빈 상태 표시 여부 (기본: true) */ + showEmptyWhenNoSelection?: boolean; + /** 빈 상태 메시지 */ + emptyMessage?: string; + }; } /** diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f38af595..230d3139 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -35,6 +35,7 @@ "@react-three/fiber": "^9.4.0", "@tanstack/react-query": "^5.86.0", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.22", "@tiptap/core": "^2.27.1", "@tiptap/extension-placeholder": "^2.27.1", "@tiptap/pm": "^2.27.1", @@ -3756,6 +3757,23 @@ "react-dom": ">=16.8" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.22", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.22.tgz", + "integrity": "sha512-EaOrBBJLi3M0bTMQRjGkxLXRw7Gizwntoy5E2Q2UnSbML7Mo2a1P/Hfkw5tw9FLzK62bj34Jl6VNbQfRV6eJcA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tanstack/table-core": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", @@ -3769,6 +3787,16 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.22", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.22.tgz", + "integrity": "sha512-isuUGKsc5TAPDoHSbWTbl1SCil54zOS2MiWz/9GCWHPUQOvNTQx8qJEWC7UWR0lShhbK0Lmkcf0SZYxvch7G3g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tiptap/core": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 81de41a1..76773512 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -44,6 +44,7 @@ "@react-three/fiber": "^9.4.0", "@tanstack/react-query": "^5.86.0", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.22", "@tiptap/core": "^2.27.1", "@tiptap/extension-placeholder": "^2.27.1", "@tiptap/pm": "^2.27.1",