diff --git a/.gitignore b/.gitignore index 5e66bd12..552d1265 100644 --- a/.gitignore +++ b/.gitignore @@ -153,6 +153,7 @@ backend-node/uploads/ uploads/ *.jpg *.jpeg +*.png *.gif *.pdf *.doc diff --git a/.playwright-mcp/pivotgrid-demo.png b/.playwright-mcp/pivotgrid-demo.png deleted file mode 100644 index 0fad6fa6..00000000 Binary files a/.playwright-mcp/pivotgrid-demo.png and /dev/null differ diff --git a/.playwright-mcp/pivotgrid-table.png b/.playwright-mcp/pivotgrid-table.png deleted file mode 100644 index 79041f47..00000000 Binary files a/.playwright-mcp/pivotgrid-table.png and /dev/null differ diff --git a/.playwright-mcp/pop-page-initial.png b/.playwright-mcp/pop-page-initial.png deleted file mode 100644 index b14666b3..00000000 Binary files a/.playwright-mcp/pop-page-initial.png and /dev/null differ diff --git a/backend-node/scripts/btn-bulk-update-company7.ts b/backend-node/scripts/btn-bulk-update-company7.ts new file mode 100644 index 00000000..ee757a0c --- /dev/null +++ b/backend-node/scripts/btn-bulk-update-company7.ts @@ -0,0 +1,318 @@ +/** + * 탑씰(company_7) 버튼 스타일 일괄 변경 스크립트 + * + * 사용법: + * npx ts-node scripts/btn-bulk-update-company7.ts --test # 1건만 테스트 (ROLLBACK) + * npx ts-node scripts/btn-bulk-update-company7.ts --run # 전체 실행 (COMMIT) + * npx ts-node scripts/btn-bulk-update-company7.ts --backup # 백업 테이블만 생성 + * npx ts-node scripts/btn-bulk-update-company7.ts --restore # 백업에서 원복 + */ + +import { Pool } from "pg"; + +// ── 배포 DB 연결 ── +const pool = new Pool({ + connectionString: + "postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor", +}); + +const COMPANY_CODE = "COMPANY_7"; +const BACKUP_TABLE = "screen_layouts_v2_backup_20260313"; + +// ── 액션별 기본 아이콘 매핑 (frontend/lib/button-icon-map.tsx 기준) ── +const actionIconMap: Record = { + save: "Check", + delete: "Trash2", + edit: "Pencil", + navigate: "ArrowRight", + modal: "Maximize2", + transferData: "SendHorizontal", + excel_download: "Download", + excel_upload: "Upload", + quickInsert: "Zap", + control: "Settings", + barcode_scan: "ScanLine", + operation_control: "Truck", + event: "Send", + copy: "Copy", +}; +const FALLBACK_ICON = "SquareMousePointer"; + +function getIconForAction(actionType?: string): string { + if (actionType && actionIconMap[actionType]) { + return actionIconMap[actionType]; + } + return FALLBACK_ICON; +} + +// ── 버튼 컴포넌트인지 판별 (최상위 + 탭 내부 둘 다 지원) ── +function isTopLevelButton(comp: any): boolean { + return ( + comp.url?.includes("v2-button-primary") || + comp.overrides?.type === "v2-button-primary" + ); +} + +function isTabChildButton(comp: any): boolean { + return comp.componentType === "v2-button-primary"; +} + +function isButtonComponent(comp: any): boolean { + return isTopLevelButton(comp) || isTabChildButton(comp); +} + +// ── 탭 위젯인지 판별 ── +function isTabsWidget(comp: any): boolean { + return ( + comp.url?.includes("v2-tabs-widget") || + comp.overrides?.type === "v2-tabs-widget" + ); +} + +// ── 버튼 스타일 변경 (최상위 버튼용: overrides 사용) ── +function applyButtonStyle(config: any, actionType: string | undefined) { + const iconName = getIconForAction(actionType); + + config.displayMode = "icon-text"; + + config.icon = { + name: iconName, + type: "lucide", + size: "보통", + ...(config.icon?.color ? { color: config.icon.color } : {}), + }; + + config.iconTextPosition = "right"; + config.iconGap = 6; + + if (!config.style) config.style = {}; + delete config.style.width; // 레거시 하드코딩 너비 제거 (size.width만 사용) + config.style.borderRadius = "8px"; + config.style.labelColor = "#FFFFFF"; + config.style.fontSize = "12px"; + config.style.fontWeight = "normal"; + config.style.labelTextAlign = "left"; + + if (actionType === "delete") { + config.style.backgroundColor = "#F04544"; + } else if (actionType === "excel_upload" || actionType === "excel_download") { + config.style.backgroundColor = "#212121"; + } else { + config.style.backgroundColor = "#3B83F6"; + } +} + +function updateButtonStyle(comp: any): boolean { + if (isTopLevelButton(comp)) { + const overrides = comp.overrides || {}; + const actionType = overrides.action?.type; + + if (!comp.size) comp.size = {}; + comp.size.height = 40; + + applyButtonStyle(overrides, actionType); + comp.overrides = overrides; + return true; + } + + if (isTabChildButton(comp)) { + const config = comp.componentConfig || {}; + const actionType = config.action?.type; + + if (!comp.size) comp.size = {}; + comp.size.height = 40; + + applyButtonStyle(config, actionType); + comp.componentConfig = config; + + // 탭 내부 버튼은 렌더러가 comp.style (최상위)에서 스타일을 읽음 + if (!comp.style) comp.style = {}; + comp.style.borderRadius = "8px"; + comp.style.labelColor = "#FFFFFF"; + comp.style.fontSize = "12px"; + comp.style.fontWeight = "normal"; + comp.style.labelTextAlign = "left"; + comp.style.backgroundColor = config.style.backgroundColor; + + return true; + } + + return false; +} + +// ── 백업 테이블 생성 ── +async function createBackup() { + console.log(`\n=== 백업 테이블 생성: ${BACKUP_TABLE} ===`); + + const exists = await pool.query( + `SELECT to_regclass($1) AS tbl`, + [BACKUP_TABLE], + ); + if (exists.rows[0].tbl) { + console.log(`백업 테이블이 이미 존재합니다: ${BACKUP_TABLE}`); + const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`); + console.log(`기존 백업 레코드 수: ${count.rows[0].count}`); + return; + } + + await pool.query( + `CREATE TABLE ${BACKUP_TABLE} AS + SELECT * FROM screen_layouts_v2 + WHERE company_code = $1`, + [COMPANY_CODE], + ); + + const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`); + console.log(`백업 완료. 레코드 수: ${count.rows[0].count}`); +} + +// ── 백업에서 원복 ── +async function restoreFromBackup() { + console.log(`\n=== 백업에서 원복: ${BACKUP_TABLE} ===`); + + const result = await pool.query( + `UPDATE screen_layouts_v2 AS target + SET layout_data = backup.layout_data, + updated_at = backup.updated_at + FROM ${BACKUP_TABLE} AS backup + WHERE target.screen_id = backup.screen_id + AND target.company_code = backup.company_code + AND target.layer_id = backup.layer_id`, + ); + console.log(`원복 완료. 변경된 레코드 수: ${result.rowCount}`); +} + +// ── 메인: 버튼 일괄 변경 ── +async function updateButtons(testMode: boolean) { + const modeLabel = testMode ? "테스트 (1건, ROLLBACK)" : "전체 실행 (COMMIT)"; + console.log(`\n=== 버튼 일괄 변경 시작 [${modeLabel}] ===`); + + // company_7 레코드 조회 + const rows = await pool.query( + `SELECT screen_id, layer_id, company_code, layout_data + FROM screen_layouts_v2 + WHERE company_code = $1 + ORDER BY screen_id, layer_id`, + [COMPANY_CODE], + ); + console.log(`대상 레코드 수: ${rows.rowCount}`); + + if (!rows.rowCount) { + console.log("변경할 레코드가 없습니다."); + return; + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + let totalUpdated = 0; + let totalButtons = 0; + const targetRows = testMode ? [rows.rows[0]] : rows.rows; + + for (const row of targetRows) { + const layoutData = row.layout_data; + if (!layoutData?.components || !Array.isArray(layoutData.components)) { + continue; + } + + let buttonsInRow = 0; + for (const comp of layoutData.components) { + // 최상위 버튼 처리 + if (updateButtonStyle(comp)) { + buttonsInRow++; + } + + // 탭 위젯 내부 버튼 처리 + if (isTabsWidget(comp)) { + const tabs = comp.overrides?.tabs || []; + for (const tab of tabs) { + const tabComps = tab.components || []; + for (const tabComp of tabComps) { + if (updateButtonStyle(tabComp)) { + buttonsInRow++; + } + } + } + } + } + + if (buttonsInRow > 0) { + await client.query( + `UPDATE screen_layouts_v2 + SET layout_data = $1, updated_at = NOW() + WHERE screen_id = $2 AND company_code = $3 AND layer_id = $4`, + [JSON.stringify(layoutData), row.screen_id, row.company_code, row.layer_id], + ); + totalUpdated++; + totalButtons += buttonsInRow; + + console.log( + ` screen_id=${row.screen_id}, layer_id=${row.layer_id} → 버튼 ${buttonsInRow}개 변경`, + ); + + // 테스트 모드: 변경 전후 비교를 위해 첫 번째 버튼 출력 + if (testMode) { + const sampleBtn = layoutData.components.find(isButtonComponent); + if (sampleBtn) { + console.log("\n--- 변경 후 샘플 버튼 ---"); + console.log(JSON.stringify(sampleBtn, null, 2)); + } + } + } + } + + console.log(`\n--- 결과 ---`); + console.log(`변경된 레코드: ${totalUpdated}개`); + console.log(`변경된 버튼: ${totalButtons}개`); + + if (testMode) { + await client.query("ROLLBACK"); + console.log("\n[테스트 모드] ROLLBACK 완료. 실제 DB 변경 없음."); + } else { + await client.query("COMMIT"); + console.log("\nCOMMIT 완료."); + } + } catch (err) { + await client.query("ROLLBACK"); + console.error("\n에러 발생. ROLLBACK 완료.", err); + throw err; + } finally { + client.release(); + } +} + +// ── CLI 진입점 ── +async function main() { + const arg = process.argv[2]; + + if (!arg || !["--test", "--run", "--backup", "--restore"].includes(arg)) { + console.log("사용법:"); + console.log(" --test : 1건 테스트 (ROLLBACK, DB 변경 없음)"); + console.log(" --run : 전체 실행 (COMMIT)"); + console.log(" --backup : 백업 테이블 생성"); + console.log(" --restore : 백업에서 원복"); + process.exit(1); + } + + try { + if (arg === "--backup") { + await createBackup(); + } else if (arg === "--restore") { + await restoreFromBackup(); + } else if (arg === "--test") { + await createBackup(); + await updateButtons(true); + } else if (arg === "--run") { + await createBackup(); + await updateButtons(false); + } + } catch (err) { + console.error("스크립트 실행 실패:", err); + process.exit(1); + } finally { + await pool.end(); + } +} + +main(); diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index f45a88cd..0cd44741 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -113,6 +113,7 @@ import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성 import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회 import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리 +import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리 @@ -124,6 +125,7 @@ import entitySearchRoutes, { import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리 import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행 +import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머) import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 @@ -259,6 +261,7 @@ app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능 app.use("/api/screen-management", screenManagementRoutes); app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리 app.use("/api/pop", popActionRoutes); // POP 액션 실행 +app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리 app.use("/api/common-codes", commonCodeRoutes); app.use("/api/dynamic-form", dynamicFormRoutes); app.use("/api/files", fileRoutes); @@ -310,6 +313,7 @@ app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성 app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리 +app.use("/api/production", productionRoutes); // 생산계획 관리 app.use("/api/roles", roleRoutes); // 권한 그룹 관리 app.use("/api/departments", departmentRoutes); // 부서 관리 app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리 diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index 9d05a1b7..3764c3bc 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -314,13 +314,14 @@ router.post( async (req: AuthenticatedRequest, res: Response) => { const companyCode = req.user!.companyCode; const { ruleId } = req.params; - const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용) + const { formData, manualInputValue } = req.body; try { const previewCode = await numberingRuleService.previewCode( ruleId, companyCode, - formData + formData, + manualInputValue ); return res.json({ success: true, data: { generatedCode: previewCode } }); } catch (error: any) { diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts new file mode 100644 index 00000000..d575b07a --- /dev/null +++ b/backend-node/src/controllers/popProductionController.ts @@ -0,0 +1,291 @@ +import { Response } from "express"; +import { getPool } from "../database/db"; +import logger from "../utils/logger"; +import { AuthenticatedRequest } from "../middleware/authMiddleware"; + +/** + * D-BE1: 작업지시 공정 일괄 생성 + * PC에서 작업지시 생성 후 호출. 1 트랜잭션으로 work_order_process + process_work_result 일괄 생성. + */ +export const createWorkProcesses = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + const { work_instruction_id, item_code, routing_version_id, plan_qty } = + req.body; + + if (!work_instruction_id || !routing_version_id) { + return res.status(400).json({ + success: false, + message: + "work_instruction_id와 routing_version_id는 필수입니다.", + }); + } + + logger.info("[pop/production] create-work-processes 요청", { + companyCode, + userId, + work_instruction_id, + item_code, + routing_version_id, + plan_qty, + }); + + await client.query("BEGIN"); + + // 중복 호출 방지: 이미 생성된 공정이 있는지 확인 + const existCheck = await client.query( + `SELECT COUNT(*) as cnt FROM work_order_process + WHERE wo_id = $1 AND company_code = $2`, + [work_instruction_id, companyCode] + ); + if (parseInt(existCheck.rows[0].cnt, 10) > 0) { + await client.query("ROLLBACK"); + return res.status(409).json({ + success: false, + message: "이미 공정이 생성된 작업지시입니다.", + }); + } + + // 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명) + const routingDetails = await client.query( + `SELECT rd.id, rd.seq_no, rd.process_code, + COALESCE(pm.process_name, rd.process_code) as process_name, + rd.is_required, rd.is_fixed_order, rd.standard_time + FROM item_routing_detail rd + LEFT JOIN process_mng pm ON pm.process_code = rd.process_code + AND pm.company_code = rd.company_code + WHERE rd.routing_version_id = $1 AND rd.company_code = $2 + ORDER BY CAST(rd.seq_no AS int) NULLS LAST`, + [routing_version_id, companyCode] + ); + + if (routingDetails.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "라우팅 버전에 등록된 공정이 없습니다.", + }); + } + + const processes: Array<{ + id: string; + seq_no: string; + process_name: string; + checklist_count: number; + }> = []; + let totalChecklists = 0; + + for (const rd of routingDetails.rows) { + // 2. work_order_process INSERT + const wopResult = await client.query( + `INSERT INTO work_order_process ( + company_code, wo_id, seq_no, process_code, process_name, + is_required, is_fixed_order, standard_time, plan_qty, + status, routing_detail_id, writer + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING id`, + [ + companyCode, + work_instruction_id, + rd.seq_no, + rd.process_code, + rd.process_name, + rd.is_required, + rd.is_fixed_order, + rd.standard_time, + plan_qty || null, + "waiting", + rd.id, + userId, + ] + ); + const wopId = wopResult.rows[0].id; + + // 3. process_work_result INSERT (스냅샷 복사) + // process_work_item + process_work_item_detail에서 해당 routing_detail의 항목 조회 후 복사 + const snapshotResult = await client.query( + `INSERT INTO process_work_result ( + company_code, work_order_process_id, + source_work_item_id, source_detail_id, + work_phase, item_title, item_sort_order, + detail_content, detail_type, detail_sort_order, is_required, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + input_type, lookup_target, display_fields, duration_minutes, + status, writer + ) + SELECT + pwi.company_code, $1, + pwi.id, pwd.id, + pwi.work_phase, pwi.title, pwi.sort_order::text, + pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required, + pwd.inspection_code, pwd.inspection_method, pwd.unit, pwd.lower_limit, pwd.upper_limit, + pwd.input_type, pwd.lookup_target, pwd.display_fields, pwd.duration_minutes::text, + 'pending', $2 + FROM process_work_item pwi + JOIN process_work_item_detail pwd ON pwd.work_item_id = pwi.id + AND pwd.company_code = pwi.company_code + WHERE pwi.routing_detail_id = $3 + AND pwi.company_code = $4 + ORDER BY pwi.sort_order, pwd.sort_order`, + [wopId, userId, rd.id, companyCode] + ); + + const checklistCount = snapshotResult.rowCount ?? 0; + totalChecklists += checklistCount; + + processes.push({ + id: wopId, + seq_no: rd.seq_no, + process_name: rd.process_name, + checklist_count: checklistCount, + }); + + logger.info("[pop/production] 공정 생성 완료", { + wopId, + processName: rd.process_name, + checklistCount, + }); + } + + await client.query("COMMIT"); + + logger.info("[pop/production] create-work-processes 완료", { + companyCode, + work_instruction_id, + total_processes: processes.length, + total_checklists: totalChecklists, + }); + + return res.json({ + success: true, + data: { + processes, + total_processes: processes.length, + total_checklists: totalChecklists, + }, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("[pop/production] create-work-processes 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "공정 생성 중 오류가 발생했습니다.", + }); + } finally { + client.release(); + } +}; + +/** + * D-BE2: 타이머 API (시작/일시정지/재시작) + */ +export const controlTimer = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + const { work_order_process_id, action } = req.body; + + if (!work_order_process_id || !action) { + return res.status(400).json({ + success: false, + message: "work_order_process_id와 action은 필수입니다.", + }); + } + + if (!["start", "pause", "resume"].includes(action)) { + return res.status(400).json({ + success: false, + message: "action은 start, pause, resume 중 하나여야 합니다.", + }); + } + + logger.info("[pop/production] timer 요청", { + companyCode, + userId, + work_order_process_id, + action, + }); + + let result; + + switch (action) { + case "start": + // 최초 1회만 설정, 이미 있으면 무시 + result = await pool.query( + `UPDATE work_order_process + SET started_at = CASE WHEN started_at IS NULL THEN NOW()::text ELSE started_at END, + status = CASE WHEN status = 'waiting' THEN 'in_progress' ELSE status END, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 + RETURNING id, started_at, status`, + [work_order_process_id, companyCode] + ); + break; + + case "pause": + result = await pool.query( + `UPDATE work_order_process + SET paused_at = NOW()::text, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND paused_at IS NULL + RETURNING id, paused_at`, + [work_order_process_id, companyCode] + ); + break; + + case "resume": + // 일시정지 시간 누적 후 paused_at 초기화 + result = await pool.query( + `UPDATE work_order_process + SET total_paused_time = ( + COALESCE(total_paused_time::int, 0) + + EXTRACT(EPOCH FROM NOW() - paused_at::timestamp)::int + )::text, + paused_at = NULL, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND paused_at IS NOT NULL + RETURNING id, total_paused_time`, + [work_order_process_id, companyCode] + ); + break; + } + + if (!result || result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "대상 공정을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.", + }); + } + + logger.info("[pop/production] timer 완료", { + action, + work_order_process_id, + result: result.rows[0], + }); + + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("[pop/production] timer 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "타이머 처리 중 오류가 발생했습니다.", + }); + } +}; diff --git a/backend-node/src/controllers/productionController.ts b/backend-node/src/controllers/productionController.ts new file mode 100644 index 00000000..582188d6 --- /dev/null +++ b/backend-node/src/controllers/productionController.ts @@ -0,0 +1,233 @@ +/** + * 생산계획 컨트롤러 + */ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import * as productionService from "../services/productionPlanService"; +import { logger } from "../utils/logger"; + +// ─── 수주 데이터 조회 (품목별 그룹핑) ─── + +export async function getOrderSummary(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { excludePlanned, itemCode, itemName } = req.query; + + const data = await productionService.getOrderSummary(companyCode, { + excludePlanned: excludePlanned === "true", + itemCode: itemCode as string, + itemName: itemName as string, + }); + + 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 getStockShortage(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const data = await productionService.getStockShortage(companyCode); + 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 getPlanById(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const planId = parseInt(req.params.id, 10); + const data = await productionService.getPlanById(companyCode, planId); + + if (!data) { + return res.status(404).json({ success: false, message: "생산계획을 찾을 수 없습니다" }); + } + 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 updatePlan(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const planId = parseInt(req.params.id, 10); + const updatedBy = req.user!.userId; + + const data = await productionService.updatePlan(companyCode, planId, req.body, updatedBy); + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("생산계획 수정 실패", { error: error.message }); + return res.status(error.message.includes("찾을 수 없") ? 404 : 500).json({ + success: false, + message: error.message, + }); + } +} + +// ─── 생산계획 삭제 ─── + +export async function deletePlan(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const planId = parseInt(req.params.id, 10); + + await productionService.deletePlan(companyCode, planId); + return res.json({ success: true, message: "삭제되었습니다" }); + } catch (error: any) { + logger.error("생산계획 삭제 실패", { error: error.message }); + return res.status(error.message.includes("찾을 수 없") ? 404 : 500).json({ + success: false, + message: error.message, + }); + } +} + +// ─── 자동 스케줄 미리보기 (실제 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) { + try { + const companyCode = req.user!.companyCode; + const createdBy = req.user!.userId; + 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.generateSchedule(companyCode, items, options || {}, createdBy); + 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 mergeSchedules(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const mergedBy = req.user!.userId; + const { schedule_ids, product_type } = req.body; + + if (!schedule_ids || !Array.isArray(schedule_ids) || schedule_ids.length < 2) { + return res.status(400).json({ success: false, message: "2개 이상의 스케줄을 선택해주세요" }); + } + + const data = await productionService.mergeSchedules( + companyCode, + schedule_ids, + product_type || "완제품", + mergedBy + ); + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("스케줄 병합 실패", { error: error.message }); + const status = error.message.includes("동일 품목") || error.message.includes("찾을 수 없") ? 400 : 500; + return res.status(status).json({ success: false, message: error.message }); + } +} + +// ─── 반제품 계획 미리보기 (실제 변경 없이 예상 결과) ─── + +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) { + try { + const companyCode = req.user!.companyCode; + const createdBy = req.user!.userId; + 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.generateSemiSchedule( + companyCode, + plan_ids, + options || {}, + createdBy + ); + 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 splitSchedule(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const splitBy = req.user!.userId; + const planId = parseInt(req.params.id, 10); + const { split_qty } = req.body; + + if (!split_qty || split_qty <= 0) { + return res.status(400).json({ success: false, message: "분할 수량을 입력해주세요" }); + } + + const data = await productionService.splitSchedule(companyCode, planId, split_qty, splitBy); + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("스케줄 분할 실패", { error: error.message }); + return res.status(error.message.includes("찾을 수 없") ? 404 : 400).json({ + success: false, + message: error.message, + }); + } +} diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts index d25c6bdc..669cc960 100644 --- a/backend-node/src/routes/popActionRoutes.ts +++ b/backend-node/src/routes/popActionRoutes.ts @@ -104,6 +104,11 @@ interface TaskBody { manualItemField?: string; manualPkColumn?: string; cartScreenId?: string; + preCondition?: { + column: string; + expectedValue: string; + failMessage?: string; + }; } function resolveStatusValue( @@ -334,14 +339,30 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp const item = items[i] ?? {}; const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item); const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; - await client.query( - `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`, - [resolved, companyCode, lookupValues[i]], + let condWhere = `WHERE company_code = $2 AND "${pkColumn}" = $3`; + const condParams: unknown[] = [resolved, companyCode, lookupValues[i]]; + if (task.preCondition?.column && task.preCondition?.expectedValue) { + if (!isSafeIdentifier(task.preCondition.column)) throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`); + condWhere += ` AND "${task.preCondition.column}" = $4`; + condParams.push(task.preCondition.expectedValue); + } + const condResult = await client.query( + `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} ${condWhere}`, + condParams, ); + if (task.preCondition && condResult.rowCount === 0) { + const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다."); + (err as any).isPreConditionFail = true; + throw err; + } processedCount++; } } else if (opType === "db-conditional") { - // DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중') + if (task.preCondition) { + logger.warn("[pop/execute-action] db-conditional에는 preCondition 미지원, 무시됨", { + taskId: task.id, preCondition: task.preCondition, + }); + } if (!task.compareColumn || !task.compareOperator || !task.compareWith) break; if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break; @@ -392,10 +413,24 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; - await client.query( - `UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`, - [value, companyCode, lookupValues[i]], + let whereSql = `WHERE company_code = $2 AND "${pkColumn}" = $3`; + const queryParams: unknown[] = [value, companyCode, lookupValues[i]]; + if (task.preCondition?.column && task.preCondition?.expectedValue) { + if (!isSafeIdentifier(task.preCondition.column)) { + throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`); + } + whereSql += ` AND "${task.preCondition.column}" = $4`; + queryParams.push(task.preCondition.expectedValue); + } + const updateResult = await client.query( + `UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} ${whereSql}`, + queryParams, ); + if (task.preCondition && updateResult.rowCount === 0) { + const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다."); + (err as any).isPreConditionFail = true; + throw err; + } processedCount++; } } @@ -746,6 +781,16 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp }); } catch (error: any) { await client.query("ROLLBACK"); + + if (error.isPreConditionFail) { + logger.warn("[pop/execute-action] preCondition 실패", { message: error.message }); + return res.status(409).json({ + success: false, + message: error.message, + errorCode: "PRE_CONDITION_FAIL", + }); + } + logger.error("[pop/execute-action] 오류:", error); return res.status(500).json({ success: false, diff --git a/backend-node/src/routes/popProductionRoutes.ts b/backend-node/src/routes/popProductionRoutes.ts new file mode 100644 index 00000000..f20d470d --- /dev/null +++ b/backend-node/src/routes/popProductionRoutes.ts @@ -0,0 +1,15 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + createWorkProcesses, + controlTimer, +} from "../controllers/popProductionController"; + +const router = Router(); + +router.use(authenticateToken); + +router.post("/create-work-processes", createWorkProcesses); +router.post("/timer", controlTimer); + +export default router; diff --git a/backend-node/src/routes/productionRoutes.ts b/backend-node/src/routes/productionRoutes.ts new file mode 100644 index 00000000..572674aa --- /dev/null +++ b/backend-node/src/routes/productionRoutes.ts @@ -0,0 +1,42 @@ +/** + * 생산계획 라우트 + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as productionController from "../controllers/productionController"; + +const router = Router(); + +router.use(authenticateToken); + +// 수주 데이터 조회 (품목별 그룹핑) +router.get("/order-summary", productionController.getOrderSummary); + +// 안전재고 부족분 조회 +router.get("/stock-shortage", productionController.getStockShortage); + +// 생산계획 CRUD +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); + +// 스케줄 분할 +router.post("/plan/:id/split", productionController.splitSchedule); + +export default router; diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 97d27e6c..4a8be6bd 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -39,7 +39,9 @@ function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globa result += val; if (idx < partValues.length - 1) { const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator; - result += sep; + if (val || !result.endsWith(sep)) { + result += sep; + } } }); return result; @@ -74,16 +76,22 @@ class NumberingRuleService { */ private async buildPrefixKey( rule: NumberingRuleConfig, - formData?: Record + formData?: Record, + manualValues?: string[] ): Promise { const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order); const prefixParts: string[] = []; + let manualIndex = 0; for (const part of sortedParts) { if (part.partType === "sequence") continue; if (part.generationMethod === "manual") { - // 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로) + const manualValue = manualValues?.[manualIndex] || ""; + manualIndex++; + if (manualValue) { + prefixParts.push(manualValue); + } continue; } @@ -1384,19 +1392,28 @@ class NumberingRuleService { * @param ruleId 채번 규칙 ID * @param companyCode 회사 코드 * @param formData 폼 데이터 (카테고리 기반 채번 시 사용) + * @param manualInputValue 수동 입력 값 (접두어별 순번 조회용) */ async previewCode( ruleId: string, companyCode: string, - formData?: Record + formData?: Record, + manualInputValue?: string ): Promise { const rule = await this.getRuleById(ruleId, companyCode); if (!rule) throw new Error("규칙을 찾을 수 없습니다"); + // 수동 파트가 있는데 입력값이 없으면 레거시 공용 시퀀스 조회를 건너뜀 + const hasManualPart = rule.parts.some((p: any) => p.generationMethod === "manual"); + const skipSequenceLookup = hasManualPart && !manualInputValue; + // prefix_key 기반 순번 조회 + 테이블 내 최대값과 비교 - const prefixKey = await this.buildPrefixKey(rule, formData); + const manualValues = manualInputValue ? [manualInputValue] : undefined; + const prefixKey = await this.buildPrefixKey(rule, formData, manualValues); const pool = getPool(); - const currentSeq = await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey); + const currentSeq = skipSequenceLookup + ? 0 + : await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey); // 대상 테이블에서 실제 최대 시퀀스 조회 let baseSeq = currentSeq; @@ -1427,7 +1444,7 @@ class NumberingRuleService { } logger.info("미리보기: 순번 조회 완료", { - ruleId, prefixKey, currentSeq, baseSeq, + ruleId, prefixKey, currentSeq, baseSeq, skipSequenceLookup, }); const parts = await Promise.all(rule.parts @@ -1442,7 +1459,8 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { const length = autoConfig.sequenceLength || 3; - const nextSequence = baseSeq + 1; + const startFrom = autoConfig.startFrom || 1; + const nextSequence = baseSeq + startFrom; return String(nextSequence).padStart(length, "0"); } @@ -1484,110 +1502,8 @@ class NumberingRuleService { return autoConfig.textValue || "TEXT"; } - case "category": { - // 카테고리 기반 코드 생성 - const categoryKey = autoConfig.categoryKey; // 예: "item_info.material" - const categoryMappings = autoConfig.categoryMappings || []; - - if (!categoryKey || !formData) { - logger.warn("카테고리 키 또는 폼 데이터 없음", { - categoryKey, - hasFormData: !!formData, - }); - return ""; - } - - // categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material") - const columnName = categoryKey.includes(".") - ? categoryKey.split(".")[1] - : categoryKey; - - // 폼 데이터에서 해당 컬럼의 값 가져오기 - const selectedValue = formData[columnName]; - - logger.info("카테고리 파트 처리", { - categoryKey, - columnName, - selectedValue, - formDataKeys: Object.keys(formData), - mappingsCount: categoryMappings.length, - }); - - if (!selectedValue) { - logger.warn("카테고리 값이 선택되지 않음", { - columnName, - formDataKeys: Object.keys(formData), - }); - return ""; - } - - // 카테고리 매핑에서 해당 값에 대한 형식 찾기 - // selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용) - const selectedValueStr = String(selectedValue); - let mapping = categoryMappings.find((m: any) => { - // ID로 매칭 (기존 방식: V2Select가 valueId를 사용하던 경우) - if (m.categoryValueId?.toString() === selectedValueStr) - return true; - // valueCode로 매칭 (매핑에 categoryValueCode가 있는 경우) - if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) - return true; - // 라벨로 매칭 (폴백) - if (m.categoryValueLabel === selectedValueStr) return true; - return false; - }); - - // 매핑을 못 찾았으면 category_values 테이블에서 valueCode → valueId 역변환 시도 - if (!mapping) { - try { - const pool = getPool(); - const [catTableName, catColumnName] = categoryKey.includes(".") - ? categoryKey.split(".") - : [categoryKey, categoryKey]; - const cvResult = await pool.query( - `SELECT value_id, value_code, value_label FROM category_values - WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`, - [catTableName, catColumnName, selectedValueStr] - ); - if (cvResult.rows.length > 0) { - const resolvedId = cvResult.rows[0].value_id; - const resolvedLabel = cvResult.rows[0].value_label; - mapping = categoryMappings.find((m: any) => { - if (m.categoryValueId?.toString() === String(resolvedId)) return true; - if (m.categoryValueLabel === resolvedLabel) return true; - return false; - }); - if (mapping) { - logger.info("카테고리 매핑 역변환 성공 (valueCode→valueId)", { - valueCode: selectedValueStr, - resolvedId, - resolvedLabel, - format: mapping.format, - }); - } - } - } catch (lookupError: any) { - logger.warn("카테고리 값 역변환 조회 실패", { error: lookupError.message }); - } - } - - if (mapping) { - logger.info("카테고리 매핑 적용", { - selectedValue, - format: mapping.format, - categoryValueLabel: mapping.categoryValueLabel, - }); - return mapping.format || ""; - } - - logger.warn("카테고리 매핑을 찾을 수 없음", { - selectedValue, - availableMappings: categoryMappings.map((m: any) => ({ - id: m.categoryValueId, - label: m.categoryValueLabel, - })), - }); - return ""; - } + case "category": + return this.resolveCategoryFormat(autoConfig, formData); case "reference": { const refColumn = autoConfig.referenceColumnName; @@ -1636,11 +1552,29 @@ class NumberingRuleService { const rule = await this.getRuleById(ruleId, companyCode); if (!rule) throw new Error("규칙을 찾을 수 없습니다"); - // prefix_key 기반 순번: 순번 이외 파트 조합으로 prefix 생성 - const prefixKey = await this.buildPrefixKey(rule, formData); + // 1단계: 수동 값 추출 (buildPrefixKey 전에 수행해야 prefix_key에 포함 가능) + const manualParts = rule.parts.filter( + (p: any) => p.generationMethod === "manual" + ); + let extractedManualValues: string[] = []; + + if (manualParts.length > 0 && userInputCode) { + extractedManualValues = await this.extractManualValuesFromInput( + rule, userInputCode, formData + ); + + // 템플릿 파싱 실패 시 userInputCode 전체를 수동 값으로 사용 (수동 파트 1개인 경우만) + if (extractedManualValues.length === 0 && manualParts.length === 1) { + extractedManualValues = [userInputCode]; + logger.info("수동 값 추출 폴백: userInputCode 전체 사용", { userInputCode }); + } + } + + // 2단계: prefix_key 빌드 (수동 값 포함) + const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues); const hasSequence = rule.parts.some((p: any) => p.partType === "sequence"); - // 순번이 있으면 테이블 내 최대값과 카운터를 비교하여 다음 순번 결정 + // 3단계: 순번이 있으면 prefix_key 기반 UPSERT + 테이블 내 최대값 비교하여 다음 순번 결정 let allocatedSequence = 0; if (hasSequence) { allocatedSequence = await this.resolveNextSequence( @@ -1648,137 +1582,16 @@ class NumberingRuleService { ); } - logger.info("allocateCode: 테이블 기반 순번 할당", { - ruleId, prefixKey, allocatedSequence, + logger.info("allocateCode: prefix_key + 테이블 기반 순번 할당", { + ruleId, prefixKey, allocatedSequence, extractedManualValues, }); - // 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출 - const manualParts = rule.parts.filter( - (p: any) => p.generationMethod === "manual" - ); - let extractedManualValues: string[] = []; - - if (manualParts.length > 0 && userInputCode) { - const previewParts = await Promise.all(rule.parts - .sort((a: any, b: any) => a.order - b.order) - .map(async (part: any) => { - if (part.generationMethod === "manual") { - return "____"; - } - const autoConfig = part.autoConfig || {}; - switch (part.partType) { - case "sequence": { - const length = autoConfig.sequenceLength || 3; - return "X".repeat(length); - } - case "text": - return autoConfig.textValue || ""; - case "date": - return "DATEPART"; - case "category": { - const catKey2 = autoConfig.categoryKey; - const catMappings2 = autoConfig.categoryMappings || []; - - if (!catKey2 || !formData) { - return "CATEGORY"; - } - - const colName2 = catKey2.includes(".") - ? catKey2.split(".")[1] - : catKey2; - const selVal2 = formData[colName2]; - - if (!selVal2) { - return "CATEGORY"; - } - - const selValStr2 = String(selVal2); - let catMapping2 = catMappings2.find((m: any) => { - if (m.categoryValueId?.toString() === selValStr2) return true; - if (m.categoryValueCode && m.categoryValueCode === selValStr2) return true; - if (m.categoryValueLabel === selValStr2) return true; - return false; - }); - - if (!catMapping2) { - try { - const pool2 = getPool(); - const [ct2, cc2] = catKey2.includes(".") ? catKey2.split(".") : [catKey2, catKey2]; - const cvr2 = await pool2.query( - `SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`, - [ct2, cc2, selValStr2] - ); - if (cvr2.rows.length > 0) { - const rid2 = cvr2.rows[0].value_id; - const rlabel2 = cvr2.rows[0].value_label; - catMapping2 = catMappings2.find((m: any) => { - if (m.categoryValueId?.toString() === String(rid2)) return true; - if (m.categoryValueLabel === rlabel2) return true; - return false; - }); - } - } catch { /* ignore */ } - } - - return catMapping2?.format || "CATEGORY"; - } - case "reference": { - const refCol2 = autoConfig.referenceColumnName; - if (refCol2 && formData && formData[refCol2]) { - return String(formData[refCol2]); - } - return "REF"; - } - default: - return ""; - } - })); - - const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order); - const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || ""); - - const templateParts = previewTemplate.split("____"); - if (templateParts.length > 1) { - let remainingCode = userInputCode; - for (let i = 0; i < templateParts.length - 1; i++) { - const prefix = templateParts[i]; - const suffix = templateParts[i + 1]; - - if (prefix && remainingCode.startsWith(prefix)) { - remainingCode = remainingCode.slice(prefix.length); - } - - if (suffix) { - const suffixStart = suffix.replace(/X+|DATEPART/g, ""); - const manualEndIndex = suffixStart - ? remainingCode.indexOf(suffixStart) - : remainingCode.length; - if (manualEndIndex > 0) { - extractedManualValues.push( - remainingCode.slice(0, manualEndIndex) - ); - remainingCode = remainingCode.slice(manualEndIndex); - } - } else { - extractedManualValues.push(remainingCode); - } - } - } - - logger.info( - `수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedManualValues)}` - ); - } - let manualPartIndex = 0; const parts = await Promise.all(rule.parts .sort((a: any, b: any) => a.order - b.order) .map(async (part: any) => { if (part.generationMethod === "manual") { - const manualValue = - extractedManualValues[manualPartIndex] || - part.manualConfig?.value || - ""; + const manualValue = extractedManualValues[manualPartIndex] || ""; manualPartIndex++; return manualValue; } @@ -1788,7 +1601,9 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { const length = autoConfig.sequenceLength || 3; - return String(allocatedSequence).padStart(length, "0"); + const startFrom = autoConfig.startFrom || 1; + const actualSequence = allocatedSequence + startFrom - 1; + return String(actualSequence).padStart(length, "0"); } case "number": { @@ -1825,65 +1640,14 @@ class NumberingRuleService { return autoConfig.textValue || "TEXT"; } - case "category": { - const categoryKey = autoConfig.categoryKey; - const categoryMappings = autoConfig.categoryMappings || []; - - if (!categoryKey || !formData) { - return ""; - } - - const columnName = categoryKey.includes(".") - ? categoryKey.split(".")[1] - : categoryKey; - - const selectedValue = formData[columnName]; - - if (!selectedValue) { - return ""; - } - - const selectedValueStr = String(selectedValue); - let allocMapping = categoryMappings.find((m: any) => { - if (m.categoryValueId?.toString() === selectedValueStr) return true; - if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true; - if (m.categoryValueLabel === selectedValueStr) return true; - return false; - }); - - if (!allocMapping) { - try { - const pool3 = getPool(); - const [ct3, cc3] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey]; - const cvr3 = await pool3.query( - `SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`, - [ct3, cc3, selectedValueStr] - ); - if (cvr3.rows.length > 0) { - const rid3 = cvr3.rows[0].value_id; - const rlabel3 = cvr3.rows[0].value_label; - allocMapping = categoryMappings.find((m: any) => { - if (m.categoryValueId?.toString() === String(rid3)) return true; - if (m.categoryValueLabel === rlabel3) return true; - return false; - }); - } - } catch { /* ignore */ } - } - - if (allocMapping) { - return allocMapping.format || ""; - } - - return ""; - } + case "category": + return this.resolveCategoryFormat(autoConfig, formData); case "reference": { const refColumn = autoConfig.referenceColumnName; if (refColumn && formData && formData[refColumn]) { return String(formData[refColumn]); } - logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] }); return ""; } @@ -1922,6 +1686,139 @@ class NumberingRuleService { return this.allocateCode(ruleId, companyCode); } + /** + * 사용자 입력 코드에서 수동 파트 값을 추출 + * 템플릿 기반 파싱으로 수동 입력 위치("____")에 해당하는 값을 분리 + */ + private async extractManualValuesFromInput( + rule: NumberingRuleConfig, + userInputCode: string, + formData?: Record + ): Promise { + const extractedValues: string[] = []; + + const previewParts = await Promise.all(rule.parts + .sort((a: any, b: any) => a.order - b.order) + .map(async (part: any) => { + if (part.generationMethod === "manual") { + return "____"; + } + const autoConfig = part.autoConfig || {}; + switch (part.partType) { + case "sequence": { + const length = autoConfig.sequenceLength || 3; + return "X".repeat(length); + } + case "text": + return autoConfig.textValue || ""; + case "date": + return "DATEPART"; + case "category": + return this.resolveCategoryFormat(autoConfig, formData); + case "reference": { + const refColumn = autoConfig.referenceColumnName; + if (refColumn && formData && formData[refColumn]) { + return String(formData[refColumn]); + } + return ""; + } + default: + return ""; + } + })); + + const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order); + const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || ""); + + const templateParts = previewTemplate.split("____"); + if (templateParts.length > 1) { + let remainingCode = userInputCode; + for (let i = 0; i < templateParts.length - 1; i++) { + const prefix = templateParts[i]; + const suffix = templateParts[i + 1]; + + if (prefix && remainingCode.startsWith(prefix)) { + remainingCode = remainingCode.slice(prefix.length); + } + + if (suffix) { + const suffixStart = suffix.replace(/X+|DATEPART/g, ""); + const manualEndIndex = suffixStart + ? remainingCode.indexOf(suffixStart) + : remainingCode.length; + if (manualEndIndex > 0) { + extractedValues.push( + remainingCode.slice(0, manualEndIndex) + ); + remainingCode = remainingCode.slice(manualEndIndex); + } + } else { + extractedValues.push(remainingCode); + } + } + } + + logger.info( + `수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedValues)}` + ); + + return extractedValues; + } + + /** + * 카테고리 매핑에서 format 값을 해석 + * categoryKey + formData로 선택된 값을 찾고, 매핑 테이블에서 format 반환 + */ + private async resolveCategoryFormat( + autoConfig: Record, + formData?: Record + ): Promise { + const categoryKey = autoConfig.categoryKey; + const categoryMappings = autoConfig.categoryMappings || []; + + if (!categoryKey || !formData) return ""; + + const columnName = categoryKey.includes(".") + ? categoryKey.split(".")[1] + : categoryKey; + const selectedValue = formData[columnName]; + + if (!selectedValue) return ""; + + const selectedValueStr = String(selectedValue); + let mapping = categoryMappings.find((m: any) => { + if (m.categoryValueId?.toString() === selectedValueStr) return true; + if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true; + if (m.categoryValueLabel === selectedValueStr) return true; + return false; + }); + + // 매핑 못 찾으면 category_values에서 valueCode → valueId 역변환 + if (!mapping) { + try { + const pool = getPool(); + const [tableName, colName] = categoryKey.includes(".") + ? categoryKey.split(".") + : [categoryKey, categoryKey]; + const result = await pool.query( + `SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`, + [tableName, colName, selectedValueStr] + ); + if (result.rows.length > 0) { + const resolvedId = result.rows[0].value_id; + const resolvedLabel = result.rows[0].value_label; + mapping = categoryMappings.find((m: any) => { + if (m.categoryValueId?.toString() === String(resolvedId)) return true; + if (m.categoryValueLabel === resolvedLabel) return true; + return false; + }); + } + } catch { /* ignore */ } + } + + return mapping?.format || ""; + } + private formatDate(date: Date, format: string): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); diff --git a/backend-node/src/services/productionPlanService.ts b/backend-node/src/services/productionPlanService.ts new file mode 100644 index 00000000..f6b080a0 --- /dev/null +++ b/backend-node/src/services/productionPlanService.ts @@ -0,0 +1,879 @@ +/** + * 생산계획 서비스 + * - 수주 데이터 조회 (품목별 그룹핑) + * - 안전재고 부족분 조회 + * - 자동 스케줄 생성 + * - 스케줄 병합 + * - 반제품 계획 자동 생성 + * - 스케줄 분할 + */ + +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +// ─── 수주 데이터 조회 (품목별 그룹핑) ─── + +export async function getOrderSummary( + companyCode: string, + options?: { excludePlanned?: boolean; itemCode?: string; itemName?: string } +) { + const pool = getPool(); + const conditions: string[] = ["so.company_code = $1"]; + const params: any[] = [companyCode]; + let paramIdx = 2; + + if (options?.itemCode) { + conditions.push(`so.part_code ILIKE $${paramIdx}`); + params.push(`%${options.itemCode}%`); + paramIdx++; + } + if (options?.itemName) { + conditions.push(`so.part_name ILIKE $${paramIdx}`); + params.push(`%${options.itemName}%`); + paramIdx++; + } + + const whereClause = conditions.join(" AND "); + + const query = ` + WITH order_summary AS ( + SELECT + so.part_code AS item_code, + COALESCE(so.part_name, so.part_code) AS item_name, + SUM(COALESCE(so.order_qty::numeric, 0)) AS total_order_qty, + SUM(COALESCE(so.ship_qty::numeric, 0)) AS total_ship_qty, + SUM(COALESCE(so.balance_qty::numeric, 0)) AS total_balance_qty, + COUNT(*) AS order_count, + MIN(so.due_date) AS earliest_due_date + FROM sales_order_mng so + WHERE ${whereClause} + GROUP BY so.part_code, so.part_name + ), + stock_info AS ( + SELECT + item_code, + SUM(COALESCE(current_qty::numeric, 0)) AS current_stock, + MAX(COALESCE(safety_qty::numeric, 0)) AS safety_stock + FROM inventory_stock + WHERE company_code = $1 + GROUP BY item_code + ), + plan_info AS ( + SELECT + item_code, + SUM(CASE WHEN status = 'planned' THEN COALESCE(plan_qty, 0) ELSE 0 END) AS existing_plan_qty, + SUM(CASE WHEN status = 'in_progress' THEN COALESCE(plan_qty, 0) ELSE 0 END) AS in_progress_qty + FROM production_plan_mng + WHERE company_code = $1 + AND COALESCE(product_type, '완제품') = '완제품' + AND status NOT IN ('completed', 'cancelled') + GROUP BY item_code + ) + SELECT + os.item_code, + os.item_name, + os.total_order_qty, + os.total_ship_qty, + os.total_balance_qty, + os.order_count, + os.earliest_due_date, + COALESCE(si.current_stock, 0) AS current_stock, + COALESCE(si.safety_stock, 0) AS safety_stock, + COALESCE(pi.existing_plan_qty, 0) AS existing_plan_qty, + COALESCE(pi.in_progress_qty, 0) AS in_progress_qty, + GREATEST( + os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0) + - COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0), + 0 + ) AS required_plan_qty + FROM order_summary os + LEFT JOIN stock_info si ON os.item_code = si.item_code + LEFT JOIN plan_info pi ON os.item_code = pi.item_code + ${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""} + ORDER BY os.item_code; + `; + + const result = await pool.query(query, params); + + // 그룹별 상세 수주 데이터도 함께 조회 + const detailWhere = conditions.map(c => c.replace(/so\./g, "")).join(" AND "); + const detailQuery = ` + SELECT + id, order_no, part_code, part_name, + COALESCE(order_qty::numeric, 0) AS order_qty, + COALESCE(ship_qty::numeric, 0) AS ship_qty, + COALESCE(balance_qty::numeric, 0) AS balance_qty, + due_date, status, partner_id, manager_name + FROM sales_order_mng + WHERE ${detailWhere} + ORDER BY part_code, due_date; + `; + const detailResult = await pool.query(detailQuery, params); + + // 그룹별로 상세 데이터 매핑 + const ordersByItem: Record = {}; + for (const row of detailResult.rows) { + const key = row.part_code || "__null__"; + if (!ordersByItem[key]) ordersByItem[key] = []; + ordersByItem[key].push(row); + } + + const data = result.rows.map((group: any) => ({ + ...group, + orders: ordersByItem[group.item_code || "__null__"] || [], + })); + + logger.info("수주 데이터 조회", { companyCode, groupCount: data.length }); + return data; +} + +// ─── 안전재고 부족분 조회 ─── + +export async function getStockShortage(companyCode: string) { + const pool = getPool(); + + const query = ` + SELECT + ist.item_code, + ii.item_name, + COALESCE(ist.current_qty::numeric, 0) AS current_qty, + COALESCE(ist.safety_qty::numeric, 0) AS safety_qty, + (COALESCE(ist.current_qty::numeric, 0) - COALESCE(ist.safety_qty::numeric, 0)) AS shortage_qty, + GREATEST( + COALESCE(ist.safety_qty::numeric, 0) * 2 - COALESCE(ist.current_qty::numeric, 0), 0 + ) AS recommended_qty, + ist.last_in_date + FROM inventory_stock ist + LEFT JOIN item_info ii ON ist.item_code = ii.id AND ist.company_code = ii.company_code + WHERE ist.company_code = $1 + AND COALESCE(ist.current_qty::numeric, 0) < COALESCE(ist.safety_qty::numeric, 0) + ORDER BY shortage_qty ASC; + `; + + const result = await pool.query(query, [companyCode]); + logger.info("안전재고 부족분 조회", { companyCode, count: result.rowCount }); + return result.rows; +} + +// ─── 생산계획 CRUD ─── + +export async function getPlanById(companyCode: string, planId: number) { + const pool = getPool(); + const result = await pool.query( + `SELECT * FROM production_plan_mng WHERE id = $1 AND company_code = $2`, + [planId, companyCode] + ); + return result.rows[0] || null; +} + +export async function updatePlan( + companyCode: string, + planId: number, + data: Record, + updatedBy: string +) { + const pool = getPool(); + + const allowedFields = [ + "plan_qty", "start_date", "end_date", "due_date", + "equipment_id", "equipment_code", "equipment_name", + "manager_name", "work_shift", "priority", "remarks", "status", + "item_code", "item_name", "product_type", "order_no", + ]; + + const setClauses: string[] = []; + const params: any[] = []; + let paramIdx = 1; + + for (const field of allowedFields) { + if (data[field] !== undefined) { + setClauses.push(`${field} = $${paramIdx}`); + params.push(data[field]); + paramIdx++; + } + } + + if (setClauses.length === 0) { + throw new Error("수정할 필드가 없습니다"); + } + + setClauses.push(`updated_date = NOW()`); + setClauses.push(`updated_by = $${paramIdx}`); + params.push(updatedBy); + paramIdx++; + + params.push(planId); + params.push(companyCode); + + const query = ` + UPDATE production_plan_mng + SET ${setClauses.join(", ")} + WHERE id = $${paramIdx - 1} AND company_code = $${paramIdx} + RETURNING * + `; + + const result = await pool.query(query, params); + if (result.rowCount === 0) { + throw new Error("생산계획을 찾을 수 없거나 권한이 없습니다"); + } + logger.info("생산계획 수정", { companyCode, planId }); + return result.rows[0]; +} + +export async function deletePlan(companyCode: string, planId: number) { + const pool = getPool(); + const result = await pool.query( + `DELETE FROM production_plan_mng WHERE id = $1 AND company_code = $2 RETURNING id`, + [planId, companyCode] + ); + if (result.rowCount === 0) { + throw new Error("생산계획을 찾을 수 없거나 권한이 없습니다"); + } + logger.info("생산계획 삭제", { companyCode, planId }); + return { id: planId }; +} + +// ─── 자동 스케줄 생성 ─── + +interface GenerateScheduleItem { + item_code: string; + item_name: string; + required_qty: number; + earliest_due_date: string; + hourly_capacity?: number; + daily_capacity?: number; + lead_time?: number; +} + +interface GenerateScheduleOptions { + safety_lead_time?: number; + recalculate_unstarted?: boolean; + 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[], + options: GenerateScheduleOptions, + createdBy: string +) { + const pool = getPool(); + const client = await pool.connect(); + const productType = options.product_type || "완제품"; + const safetyLeadTime = options.safety_lead_time || 1; + + try { + await client.query("BEGIN"); + + let deletedCount = 0; + let keptCount = 0; + const newSchedules: any[] = []; + + for (const item of items) { + // 기존 미진행(planned) 스케줄 처리 + if (options.recalculate_unstarted) { + const deleteResult = await client.query( + `DELETE FROM production_plan_mng + WHERE company_code = $1 + AND item_code = $2 + AND COALESCE(product_type, '완제품') = $3 + AND status = 'planned' + RETURNING id`, + [companyCode, item.item_code, productType] + ); + deletedCount += deleteResult.rowCount || 0; + + const keptResult = await client.query( + `SELECT COUNT(*) AS cnt 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] + ); + keptCount += parseInt(keptResult.rows[0].cnt, 10); + } + + // 생산일수 계산 + 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); + } + + // 계획번호 생성 (YYYYMMDD-NNNN 형식) + const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, ""); + const planNoResult = await client.query( + `SELECT COUNT(*) + 1 AS next_no + FROM production_plan_mng + WHERE company_code = $1 AND plan_no LIKE $2`, + [companyCode, `PP-${todayStr}-%`] + ); + 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 ( + company_code, plan_no, plan_date, item_code, item_name, + product_type, plan_qty, start_date, end_date, due_date, + status, priority, hourly_capacity, daily_capacity, lead_time, + created_by, created_date, updated_date + ) VALUES ( + $1, $2, CURRENT_DATE, $3, $4, + $5, $6, $7, $8, $9, + 'planned', 'normal', $10, $11, $12, + $13, NOW(), NOW() + ) RETURNING *`, + [ + companyCode, planNo, item.item_code, item.item_name, + productType, requiredQty, + startDate.toISOString().split("T")[0], + endDate.toISOString().split("T")[0], + item.earliest_due_date, + item.hourly_capacity || 100, + dailyCapacity, + item.lead_time || 1, + createdBy, + ] + ); + newSchedules.push(insertResult.rows[0]); + } + + await client.query("COMMIT"); + + const summary = { + total: newSchedules.length + keptCount, + new_count: newSchedules.length, + kept_count: keptCount, + deleted_count: deletedCount, + }; + + logger.info("자동 스케줄 생성 완료", { companyCode, summary }); + return { summary, schedules: newSchedules }; + } catch (error) { + await client.query("ROLLBACK"); + logger.error("자동 스케줄 생성 실패", { companyCode, error }); + throw error; + } finally { + client.release(); + } +} + +// ─── 스케줄 병합 ─── + +export async function mergeSchedules( + companyCode: string, + scheduleIds: number[], + productType: string, + mergedBy: string +) { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // 대상 스케줄 조회 + const placeholders = scheduleIds.map((_, i) => `$${i + 2}`).join(", "); + const targetResult = await client.query( + `SELECT * FROM production_plan_mng + WHERE company_code = $1 AND id IN (${placeholders}) + ORDER BY start_date`, + [companyCode, ...scheduleIds] + ); + + if (targetResult.rowCount !== scheduleIds.length) { + throw new Error("일부 스케줄을 찾을 수 없습니다"); + } + + const rows = targetResult.rows; + + // 동일 품목 검증 + const itemCodes = [...new Set(rows.map((r: any) => r.item_code))]; + if (itemCodes.length > 1) { + throw new Error("동일 품목의 스케줄만 병합할 수 있습니다"); + } + + // 병합 값 계산 + const totalQty = rows.reduce((sum: number, r: any) => sum + (parseFloat(r.plan_qty) || 0), 0); + const earliestStart = rows.reduce( + (min: string, r: any) => (!min || r.start_date < min ? r.start_date : min), + "" + ); + const latestEnd = rows.reduce( + (max: string, r: any) => (!max || r.end_date > max ? r.end_date : max), + "" + ); + const earliestDue = rows.reduce( + (min: string, r: any) => (!min || (r.due_date && r.due_date < min) ? r.due_date : min), + "" + ); + const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))].join(", "); + + // 기존 삭제 + await client.query( + `DELETE FROM production_plan_mng WHERE company_code = $1 AND id IN (${placeholders})`, + [companyCode, ...scheduleIds] + ); + + // 병합된 스케줄 생성 + 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] + ); + const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`; + + const insertResult = await client.query( + `INSERT INTO production_plan_mng ( + company_code, plan_no, plan_date, item_code, item_name, + product_type, plan_qty, start_date, end_date, due_date, + status, order_no, created_by, created_date, updated_date + ) VALUES ( + $1, $2, CURRENT_DATE, $3, $4, + $5, $6, $7, $8, $9, + 'planned', $10, $11, NOW(), NOW() + ) RETURNING *`, + [ + companyCode, planNo, rows[0].item_code, rows[0].item_name, + productType, totalQty, + earliestStart, latestEnd, earliestDue || null, + orderNos || null, mergedBy, + ] + ); + + await client.query("COMMIT"); + logger.info("스케줄 병합 완료", { + companyCode, + mergedFrom: scheduleIds, + mergedTo: insertResult.rows[0].id, + }); + return insertResult.rows[0]; + } catch (error) { + await client.query("ROLLBACK"); + logger.error("스케줄 병합 실패", { companyCode, error }); + throw error; + } finally { + client.release(); + } +} + +// ─── 반제품 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( + companyCode: string, + planIds: number[], + options: { considerStock?: boolean; excludeUsed?: boolean }, + createdBy: string +) { + const pool = getPool(); + const client = await pool.connect(); + + 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}) + 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) { + const bomItems = await getBomChildItems(client, 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 client.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 semiEndDate = plan.start_date; + const semiStartDate = new Date(plan.start_date); + semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1)); + + // plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품) + const planNoResult = await client.query( + `SELECT COUNT(*) + 1 AS next_no + FROM production_plan_mng + WHERE company_code = $1 AND plan_no LIKE $2`, + [companyCode, `PP-${todayStr}-S%`] + ); + 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 ( + company_code, plan_no, plan_date, item_code, item_name, + product_type, plan_qty, start_date, end_date, due_date, + status, parent_plan_id, created_by, created_date, updated_date + ) VALUES ( + $1, $2, CURRENT_DATE, $3, $4, + '반제품', $5, $6, $7, $8, + 'planned', $9, $10, NOW(), NOW() + ) RETURNING *`, + [ + companyCode, planNo, + bomItem.child_item_code || bomItem.child_item_id, + bomItem.child_item_name || bomItem.child_item_id, + requiredQty, + semiStartDate.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, + ] + ); + newSemiPlans.push(insertResult.rows[0]); + } + } + + await client.query("COMMIT"); + logger.info("반제품 계획 생성 완료", { + companyCode, + parentPlanIds: planIds, + semiPlanCount: newSemiPlans.length, + }); + return { count: newSemiPlans.length, schedules: newSemiPlans }; + } catch (error) { + await client.query("ROLLBACK"); + logger.error("반제품 계획 생성 실패", { companyCode, error }); + throw error; + } finally { + client.release(); + } +} + +// ─── 스케줄 분할 ─── + +export async function splitSchedule( + companyCode: string, + planId: number, + splitQty: number, + splitBy: string +) { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const planResult = await client.query( + `SELECT * FROM production_plan_mng WHERE id = $1 AND company_code = $2`, + [planId, companyCode] + ); + if (planResult.rowCount === 0) { + throw new Error("생산계획을 찾을 수 없습니다"); + } + + const plan = planResult.rows[0]; + const originalQty = parseFloat(plan.plan_qty) || 0; + + if (splitQty >= originalQty || splitQty <= 0) { + throw new Error("분할 수량은 0보다 크고 원래 수량보다 작아야 합니다"); + } + + // 원본 수량 감소 + await client.query( + `UPDATE production_plan_mng SET plan_qty = $1, updated_date = NOW(), updated_by = $2 + WHERE id = $3 AND company_code = $4`, + [originalQty - splitQty, splitBy, planId, companyCode] + ); + + // 분할된 새 계획 생성 + 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] + ); + const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`; + + const insertResult = await client.query( + `INSERT INTO production_plan_mng ( + company_code, plan_no, plan_date, item_code, item_name, + product_type, plan_qty, start_date, end_date, due_date, + status, priority, equipment_id, equipment_code, equipment_name, + order_no, parent_plan_id, created_by, created_date, updated_date + ) VALUES ( + $1, $2, CURRENT_DATE, $3, $4, + $5, $6, $7, $8, $9, + $10, $11, $12, $13, $14, + $15, $16, $17, NOW(), NOW() + ) RETURNING *`, + [ + companyCode, planNo, plan.item_code, plan.item_name, + plan.product_type, splitQty, + plan.start_date, plan.end_date, plan.due_date, + plan.status, plan.priority, plan.equipment_id, plan.equipment_code, plan.equipment_name, + plan.order_no, plan.parent_plan_id, + splitBy, + ] + ); + + await client.query("COMMIT"); + logger.info("스케줄 분할 완료", { companyCode, planId, splitQty }); + return { + original: { id: planId, plan_qty: originalQty - splitQty }, + split: insertResult.rows[0], + }; + } catch (error) { + await client.query("ROLLBACK"); + logger.error("스케줄 분할 실패", { companyCode, error }); + throw error; + } finally { + client.release(); + } +} 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 58c8cd3f..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 @@ -531,7 +531,7 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul - [x] 레지스트리 등록 - [x] 문서화 (README.md) -#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30) +#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30, 업데이트: 2026-03-13) - [x] 타입 정의 완료 - [x] 기본 구조 생성 @@ -539,16 +539,20 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul - [x] TimelineGrid (배경) - [x] ResourceColumn (리소스) - [x] ScheduleBar 기본 렌더링 -- [x] 드래그 이동 (기본) -- [x] 리사이즈 (기본) +- [x] 드래그 이동 (실제 로직: deltaX → 날짜 계산 → API 저장 → toast) +- [x] 리사이즈 (실제 로직: 시작/종료 핸들 → 기간 변경 → API 저장 → toast) - [x] 줌 레벨 전환 - [x] 날짜 네비게이션 -- [ ] 충돌 감지 (향후) -- [ ] 가상 스크롤 (향후) +- [x] 충돌 감지 (같은 리소스 겹침 → ring-destructive + AlertTriangle) +- [x] 마일스톤 표시 (시작일 = 종료일 → 다이아몬드 마커) +- [x] 범례 표시 (TimelineLegend: 상태별 색상 + 마일스톤 + 충돌) +- [x] 반응형 공통 CSS 적용 (text-[10px] sm:text-sm 패턴) +- [x] staticFilters 지원 (커스텀 테이블 필터링) +- [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-implementation.md b/docs/screen-implementation-guide/03_production/production-plan-implementation.md new file mode 100644 index 00000000..c487e59f --- /dev/null +++ b/docs/screen-implementation-guide/03_production/production-plan-implementation.md @@ -0,0 +1,856 @@ +# 생산계획관리 화면 구현 설계서 + +> **Screen Code**: `TOPSEAL_PP_MAIN` (screen_id: 3985) +> **메뉴 경로**: 생산관리 > 생산계획관리 +> **HTML 예시**: `00_화면개발_html/Cursor 폴더/화면개발/PC브라우저/생산/생산계획관리.html` +> **작성일**: 2026-03-13 + +--- + +## 1. 화면 전체 구조 + +``` ++---------------------------------------------------------------------+ +| 검색 섹션 (상단) | +| [품목코드] [품명] [계획기간(daterange)] [상태] | +| [사용자옵션] [엑셀업로드] [엑셀다운로드] | ++----------------------------------+--+-------------------------------+ +| 좌측 패널 (50%, 리사이즈) | | 우측 패널 (50%) | +| +------------------------------+ |리| +---------------------------+ | +| | [수주데이터] [안전재고 부족분] | |사| | [완제품] [반제품] | | +| +------------------------------+ |이| +---------------------------+ | +| | 수주 목록 헤더 | |즈| | 완제품 생산 타임라인 헤더 | | +| | [계획에없는품목만] [불러오기] | |핸| | [새로고침] [자동스케줄] | | +| | +---------------------------+| |들| | [병합] [반제품계획] [저장] | | +| | | 품목 그룹 테이블 || | | | +------------------------+| | +| | | - 품목별 그룹 행 (13컬럼) || | | | | 옵션 패널 || | +| | | -> 수주 상세 행 (7컬럼) || | | | | [리드타임] [기간] [재계산]|| | +| | | - 접기/펼치기 토글 || | | | +------------------------+| | +| | | - 체크박스 (그룹/개별) || | | | | 범례 || | +| | +---------------------------+| | | | +------------------------+| | +| +------------------------------+ | | | | 타임라인 스케줄러 || | +| | | | | (간트차트 형태) || | +| -- 안전재고 부족분 탭 -- | | | +------------------------+| | +| | 부족 품목 테이블 (8컬럼) | | | +---------------------------+ | +| | - 체크박스, 품목코드, 품명 | | | | +| | - 현재고, 안전재고, 부족수량 | | | -- 반제품 탭 -- | +| | - 권장생산량, 최종입고일 | | | | 옵션 + 안내 패널 | | +| +------------------------------+ | | | 반제품 타임라인 스케줄러 | | ++----------------------------------+--+-------------------------------+ +``` + +--- + +## 2. 사용 테이블 및 컬럼 매핑 + +### 2.1 메인 테이블 + +| 테이블명 | 용도 | PK | +|----------|------|-----| +| `production_plan_mng` | 생산계획 마스터 | `id` (serial) | +| `sales_order_mng` | 수주 데이터 (좌측 패널 조회용) | `id` (serial) | +| `item_info` | 품목 마스터 (참조) | `id` (uuid text) | +| `inventory_stock` | 재고 현황 (안전재고 부족분 탭) | `id` (uuid text) | +| `equipment_info` | 설비 정보 (타임라인 리소스) | `id` (serial) | +| `bom` / `bom_detail` | BOM 정보 (반제품 계획 생성) | `id` (uuid text) | +| `work_instruction` | 작업지시 (타임라인 연동) | 별도 확인 필요 | + +### 2.2 핵심 컬럼 매핑 - production_plan_mng + +| 컬럼명 | 타입 | 용도 | HTML 매핑 | +|--------|------|------|-----------| +| `id` | serial PK | 고유 ID | `schedule.id` | +| `company_code` | varchar | 멀티테넌시 | - | +| `plan_no` | varchar NOT NULL | 계획번호 | `SCH-{timestamp}` | +| `plan_date` | date | 계획 등록일 | 자동 | +| `item_code` | varchar NOT NULL | 품목코드 | `schedule.itemCode` | +| `item_name` | varchar | 품목명 | `schedule.itemName` | +| `product_type` | varchar | 완제품/반제품 | `'완제품'` or `'반제품'` | +| `plan_qty` | numeric NOT NULL | 계획 수량 | `schedule.quantity` | +| `completed_qty` | numeric | 완료 수량 | `schedule.completedQty` | +| `progress_rate` | numeric | 진행률(%) | `schedule.progressRate` | +| `start_date` | date NOT NULL | 시작일 | `schedule.startDate` | +| `end_date` | date NOT NULL | 종료일 | `schedule.endDate` | +| `due_date` | date | 납기일 | `schedule.dueDate` | +| `equipment_id` | integer | 설비 ID | `schedule.equipmentId` | +| `equipment_code` | varchar | 설비 코드 | - | +| `equipment_name` | varchar | 설비명 | `schedule.productionLine` | +| `status` | varchar | 상태 | `planned/in_progress/completed/work-order` | +| `priority` | varchar | 우선순위 | `normal/high/urgent` | +| `hourly_capacity` | numeric | 시간당 생산능력 | `schedule.hourlyCapacity` | +| `daily_capacity` | numeric | 일일 생산능력 | `schedule.dailyCapacity` | +| `lead_time` | integer | 리드타임(일) | `schedule.leadTime` | +| `work_shift` | varchar | 작업조 | `DAY/NIGHT/BOTH` | +| `work_order_no` | varchar | 작업지시번호 | `schedule.workOrderNo` | +| `manager_name` | varchar | 담당자 | `schedule.manager` | +| `order_no` | varchar | 연관 수주번호 | `schedule.orderInfo[].orderNo` | +| `parent_plan_id` | integer | 모 계획 ID (반제품용) | `schedule.parentPlanId` | +| `remarks` | text | 비고 | `schedule.remarks` | + +### 2.3 수주 데이터 조회용 - sales_order_mng + +| 컬럼명 | 용도 | 좌측 테이블 컬럼 매핑 | +|--------|------|----------------------| +| `order_no` | 수주번호 | 수주 상세 행 - 수주번호 | +| `part_code` | 품목코드 | 그룹 행 - 품목코드 (그룹 기준) | +| `part_name` | 품명 | 그룹 행 - 품목명 | +| `order_qty` | 수주량 | 총수주량 (SUM) | +| `ship_qty` | 출고량 | 출고량 (SUM) | +| `balance_qty` | 잔량 | 잔량 (SUM) | +| `due_date` | 납기일 | 수주 상세 행 - 납기일 | +| `partner_id` | 거래처 | 수주 상세 행 - 거래처 | +| `status` | 상태 | 상태 배지 (일반/긴급) | + +### 2.4 안전재고 부족분 조회용 - inventory_stock + item_info + +| 컬럼명 | 출처 | 좌측 테이블 컬럼 매핑 | +|--------|------|----------------------| +| `item_code` | inventory_stock | 품목코드 | +| `item_name` | item_info (JOIN) | 품목명 | +| `current_qty` | inventory_stock | 현재고 | +| `safety_qty` | inventory_stock | 안전재고 | +| `부족수량` | 계산값 (`safety_qty - current_qty`) | 부족수량 (음수면 부족) | +| `권장생산량` | 계산값 (`safety_qty * 2 - current_qty`) | 권장생산량 | +| `last_in_date` | inventory_stock | 최종입고일 | + +--- + +## 3. V2 컴포넌트 구현 가능/불가능 분석 + +### 3.1 구현 가능 (기존 V2 컴포넌트) + +| 기능 | V2 컴포넌트 | 현재 상태 | +|------|-------------|-----------| +| 좌우 분할 레이아웃 | `v2-split-panel-layout` (`displayMode: "custom"`) | layout_data에 이미 존재 | +| 검색 필터 | `v2-table-search-widget` | layout_data에 이미 존재 | +| 좌측/우측 탭 전환 | `v2-tabs-widget` | layout_data에 이미 존재 | +| 체크박스 선택 | `v2-table-grouped` (`showCheckbox: true`) | layout_data에 이미 존재 | +| 단순 그룹핑 테이블 | `v2-table-grouped` (`groupByColumn`) | layout_data에 이미 존재 | +| 타임라인 스케줄러 | `v2-timeline-scheduler` | layout_data에 이미 존재 | +| 버튼 액션 | `v2-button-primary` | layout_data에 이미 존재 | +| 안전재고 부족분 테이블 | `v2-table-list` 또는 `v2-table-grouped` | 미구성 (탭2에 컴포넌트 없음) | + +### 3.2 부분 구현 가능 (개선/확장 필요) + +| 기능 | 문제점 | 필요 작업 | +|------|--------|-----------| +| 수주 그룹 테이블 (2레벨) | `v2-table-grouped`는 **동일 컬럼 기준 그룹핑**만 지원. HTML은 그룹 행(13컬럼)과 상세 행(7컬럼)이 완전히 다른 구조 | 컴포넌트 확장 or 백엔드에서 집계 데이터를 별도 API로 제공 | +| 스케줄러 옵션 패널 | HTML의 안전리드타임/표시기간/재계산 옵션을 위한 전용 UI 없음 | `v2-input` + `v2-select` 조합으로 구성 가능 | +| 범례 UI | `v2-timeline-scheduler`에 statusColors 설정은 있지만 범례 UI 자체는 없음 | `v2-text-display` 또는 커스텀 구성 | +| 부족수량 빨간색 강조 | 조건부 서식(conditional formatting) 미지원 | 컴포넌트 확장 필요 | +| "계획에 없는 품목만" 필터 | 단순 테이블 필터가 아닌 교차 테이블 비교 필터 | 백엔드 API 필요 | + +### 3.3 신규 개발 필요 (현재 V2 컴포넌트로 불가능) + +| 기능 | 설명 | 구현 방안 | +|------|------|-----------| +| **자동 스케줄 생성 API** | 선택 품목의 필요생산계획량, 납기일, 설비 생산능력 기반으로 타임라인 자동 배치 | 백엔드 전용 API | +| **선택 계획 병합 API** | 동일 품목 복수 스케줄을 하나로 합산 | 백엔드 전용 API | +| **반제품 계획 자동 생성 API** | BOM 기반으로 완제품 계획에서 필요 반제품 소요량 계산 | 백엔드 전용 API (BOM + 재고 연계) | +| **수주 잔량/현재고 연산 조회 API** | 여러 테이블 JOIN + 집계 연산으로 좌측 패널 데이터 제공 | 백엔드 전용 API | +| **스케줄 상세 모달** | 기본정보, 근거정보, 생산정보, 계획기간, 계획분할, 설비할당 | 모달 화면 (`TOPSEAL_PP_MODAL` screen_id: 3986) 보강 | +| **설비 선택 모달** | 설비별 수량 할당 및 일정 등록 | 신규 모달 화면 필요 | +| **변경사항 확인 모달** | 자동 스케줄 생성 전후 비교 (신규/유지/삭제 건수 요약) | 신규 모달 또는 확인 다이얼로그 | + +--- + +## 4. 백엔드 API 설계 + +### 4.1 수주 데이터 조회 API (좌측 패널 - 수주데이터 탭) + +``` +GET /api/production/order-summary +``` + +**목적**: 수주 데이터를 **품목별로 그룹핑**하여 반환. 그룹 헤더에 집계값(총수주량, 출고량, 잔량, 현재고, 안전재고, 기생산계획량 등) 포함. + +**응답 구조**: +```json +{ + "success": true, + "data": [ + { + "item_code": "ITEM-001", + "item_name": "탑씰 Type A", + "hourly_capacity": 100, + "daily_capacity": 800, + "lead_time": 1, + "total_order_qty": 1000, + "total_ship_qty": 300, + "total_balance_qty": 700, + "current_stock": 100, + "safety_stock": 150, + "plan_ship_qty": 0, + "existing_plan_qty": 0, + "in_progress_qty": 0, + "required_plan_qty": 750, + "orders": [ + { + "order_no": "SO-2025-101", + "partner_name": "ABC 상사", + "order_qty": 500, + "ship_qty": 200, + "balance_qty": 300, + "due_date": "2025-11-05", + "is_urgent": false + }, + { + "order_no": "SO-2025-102", + "partner_name": "XYZ 무역", + "order_qty": 500, + "ship_qty": 100, + "balance_qty": 400, + "due_date": "2025-11-10", + "is_urgent": false + } + ] + } + ] +} +``` + +**SQL 로직 (핵심)**: +```sql +WITH order_summary AS ( + SELECT + so.part_code AS item_code, + so.part_name AS item_name, + SUM(COALESCE(so.order_qty, 0)) AS total_order_qty, + SUM(COALESCE(so.ship_qty, 0)) AS total_ship_qty, + SUM(COALESCE(so.balance_qty, 0)) AS total_balance_qty + FROM sales_order_mng so + WHERE so.company_code = $1 + AND so.status NOT IN ('cancelled', 'completed') + AND so.balance_qty > 0 + GROUP BY so.part_code, so.part_name +), +stock_info AS ( + SELECT + item_code, + SUM(COALESCE(current_qty::numeric, 0)) AS current_stock, + MAX(COALESCE(safety_qty::numeric, 0)) AS safety_stock + FROM inventory_stock + WHERE company_code = $1 + GROUP BY item_code +), +plan_info AS ( + SELECT + item_code, + SUM(CASE WHEN status = 'planned' THEN plan_qty ELSE 0 END) AS existing_plan_qty, + SUM(CASE WHEN status = 'in_progress' THEN plan_qty ELSE 0 END) AS in_progress_qty + FROM production_plan_mng + WHERE company_code = $1 + AND product_type = '완제품' + AND status NOT IN ('completed', 'cancelled') + GROUP BY item_code +) +SELECT + os.*, + COALESCE(si.current_stock, 0) AS current_stock, + COALESCE(si.safety_stock, 0) AS safety_stock, + COALESCE(pi.existing_plan_qty, 0) AS existing_plan_qty, + COALESCE(pi.in_progress_qty, 0) AS in_progress_qty, + GREATEST( + os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0) + - COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0), + 0 + ) AS required_plan_qty +FROM order_summary os +LEFT JOIN stock_info si ON os.item_code = si.item_code +LEFT JOIN plan_info pi ON os.item_code = pi.item_code +ORDER BY os.item_code; +``` + +**파라미터**: +- `company_code`: req.user.companyCode (자동) +- `exclude_planned` (optional): `true`이면 기존 계획이 있는 품목 제외 + +--- + +### 4.2 안전재고 부족분 조회 API (좌측 패널 - 안전재고 탭) + +``` +GET /api/production/stock-shortage +``` + +**응답 구조**: +```json +{ + "success": true, + "data": [ + { + "item_code": "ITEM-001", + "item_name": "탑씰 Type A", + "current_qty": 50, + "safety_qty": 200, + "shortage_qty": -150, + "recommended_qty": 300, + "last_in_date": "2025-10-15" + } + ] +} +``` + +**SQL 로직**: +```sql +SELECT + ist.item_code, + ii.item_name, + COALESCE(ist.current_qty::numeric, 0) AS current_qty, + COALESCE(ist.safety_qty::numeric, 0) AS safety_qty, + (COALESCE(ist.current_qty::numeric, 0) - COALESCE(ist.safety_qty::numeric, 0)) AS shortage_qty, + GREATEST(COALESCE(ist.safety_qty::numeric, 0) * 2 - COALESCE(ist.current_qty::numeric, 0), 0) AS recommended_qty, + ist.last_in_date +FROM inventory_stock ist +JOIN item_info ii ON ist.item_code = ii.id AND ist.company_code = ii.company_code +WHERE ist.company_code = $1 + AND COALESCE(ist.current_qty::numeric, 0) < COALESCE(ist.safety_qty::numeric, 0) +ORDER BY shortage_qty ASC; +``` + +--- + +### 4.3 자동 스케줄 생성 API + +``` +POST /api/production/generate-schedule +``` + +**요청 body**: +```json +{ + "items": [ + { + "item_code": "ITEM-001", + "item_name": "탑씰 Type A", + "required_qty": 750, + "earliest_due_date": "2025-11-05", + "hourly_capacity": 100, + "daily_capacity": 800, + "lead_time": 1, + "orders": [ + { "order_no": "SO-2025-101", "balance_qty": 300, "due_date": "2025-11-05" }, + { "order_no": "SO-2025-102", "balance_qty": 400, "due_date": "2025-11-10" } + ] + } + ], + "options": { + "safety_lead_time": 1, + "recalculate_unstarted": true, + "product_type": "완제품" + } +} +``` + +**비즈니스 로직**: +1. 각 품목의 필요생산계획량, 납기일, 일일생산능력을 기반으로 생산일수 계산 +2. `생산일수 = ceil(필요생산계획량 / 일일생산능력)` +3. `시작일 = 납기일 - 생산일수 - 안전리드타임` +4. 시작일이 오늘 이전이면 오늘로 조정 +5. `recalculate_unstarted = true`면 기존 진행중/작업지시/완료 스케줄은 유지, 미진행(planned)만 제거 후 재계산 +6. 결과를 `production_plan_mng`에 INSERT +7. 변경사항 요약(신규/유지/삭제 건수) 반환 + +**응답 구조**: +```json +{ + "success": true, + "data": { + "summary": { + "total": 3, + "new_count": 2, + "kept_count": 1, + "deleted_count": 1 + }, + "schedules": [ + { + "id": 101, + "plan_no": "PP-2025-0001", + "item_code": "ITEM-001", + "item_name": "탑씰 Type A", + "plan_qty": 750, + "start_date": "2025-10-30", + "end_date": "2025-11-03", + "due_date": "2025-11-05", + "status": "planned" + } + ] + } +} +``` + +--- + +### 4.4 스케줄 병합 API + +``` +POST /api/production/merge-schedules +``` + +**요청 body**: +```json +{ + "schedule_ids": [101, 102, 103], + "product_type": "완제품" +} +``` + +**비즈니스 로직**: +1. 선택된 스케줄이 모두 동일 품목인지 검증 +2. 완제품/반제품이 섞여있지 않은지 검증 +3. 수량 합산, 가장 빠른 시작일/납기일, 가장 늦은 종료일 적용 +4. 원본 스케줄 DELETE, 병합된 스케줄 INSERT +5. 수주 정보(order_no)는 병합 (중복 제거) + +--- + +### 4.5 반제품 계획 자동 생성 API + +``` +POST /api/production/generate-semi-schedule +``` + +**요청 body**: +```json +{ + "plan_ids": [101, 102], + "options": { + "consider_stock": true, + "keep_in_progress": false, + "exclude_used": true + } +} +``` + +**비즈니스 로직**: +1. 선택된 완제품 계획의 품목코드로 BOM 조회 +2. `bom` 테이블에서 해당 품목의 `item_id` → `bom_detail`에서 하위 반제품(`child_item_id`) 조회 +3. 각 반제품의 필요 수량 = `완제품 계획수량 x BOM 소요량(quantity)` +4. `consider_stock = true`면 현재고/안전재고 감안하여 순 필요량 계산 +5. `exclude_used = true`면 이미 투입된 반제품 수량 차감 +6. 모품목 생산 시작일 고려하여 반제품 납기일 설정 (시작일 - 반제품 리드타임) +7. `production_plan_mng`에 `product_type = '반제품'`, `parent_plan_id` 설정하여 INSERT + +--- + +### 4.6 스케줄 상세 저장/수정 API + +``` +PUT /api/production/plan/:id +``` + +**요청 body**: +```json +{ + "plan_qty": 750, + "start_date": "2025-10-30", + "end_date": "2025-11-03", + "equipment_id": 1, + "equipment_code": "LINE-01", + "equipment_name": "1호기", + "manager_name": "홍길동", + "work_shift": "DAY", + "priority": "high", + "remarks": "긴급 생산" +} +``` + +--- + +### 4.7 스케줄 분할 API + +``` +POST /api/production/split-schedule +``` + +**요청 body**: +```json +{ + "plan_id": 101, + "splits": [ + { "qty": 500, "start_date": "2025-10-30", "end_date": "2025-11-01" }, + { "qty": 250, "start_date": "2025-11-02", "end_date": "2025-11-03" } + ] +} +``` + +**비즈니스 로직**: +1. 분할 수량 합산이 원본 수량과 일치하는지 검증 +2. 원본 스케줄 DELETE +3. 분할된 각 조각을 신규 INSERT (동일 `order_no`, `item_code` 유지) + +--- + +## 5. 모달 화면 설계 + +### 5.1 스케줄 상세 모달 (screen_id: 3986 보강) + +**섹션 구성**: + +| 섹션 | 필드 | 타입 | 비고 | +|------|------|------|------| +| **기본 정보** | 품목코드, 품목명 | text (readonly) | 자동 채움 | +| **근거 정보** | 수주번호/거래처/납기일 목록 | text (readonly) | 연관 수주 정보 표시 | +| **생산 정보** | 총 생산수량 | number | 수정 가능 | +| | 납기일 (수주 기준) | date (readonly) | 가장 빠른 납기일 | +| **계획 기간** | 계획 시작일, 종료일 | date | 수정 가능 | +| | 생산 기간 | text (readonly) | 자동 계산 표시 | +| **계획 분할** | 분할 개수, 분할 수량 입력 | select, number | 분할하기 기능 | +| **설비 할당** | 설비 선택 버튼 | button → 모달 | 설비 선택 모달 오픈 | +| **생산 상태** | 상태 | select (disabled) | `planned/work-order/in_progress/completed` | +| **추가 정보** | 담당자, 작업지시번호, 비고 | text | 수정 가능 | +| **하단 버튼** | 삭제, 취소, 저장 | buttons | - | + +### 5.2 수주 불러오기 모달 + +**구성**: +- 선택된 품목 목록 표시 +- 주의사항 안내 +- 라디오 버튼: "기존 계획에 추가" / "별도 계획으로 생성" +- 취소/불러오기 버튼 + +### 5.3 안전재고 불러오기 모달 + +**구성**: 수주 불러오기 모달과 동일한 패턴 + +### 5.4 설비 선택 모달 + +**구성**: +- 총 수량 / 할당 수량 / 미할당 수량 요약 +- 설비 카드 그리드 (설비명, 생산능력, 할당 수량 입력, 시작일/종료일) +- 취소/저장 버튼 + +### 5.5 변경사항 확인 모달 + +**구성**: +- 경고 메시지 +- 변경사항 요약 카드 (총 계획, 신규 생성, 유지됨, 삭제됨) +- 변경사항 상세 목록 (품목별 변경 전/후 비교) +- 취소/확인 및 적용 버튼 + +--- + +## 6. 현재 layout_data 수정 필요 사항 + +### 6.1 현재 layout_data 구조 (screen_id: 3985, layout_id: 9192) + +``` +comp_search (v2-table-search-widget) - 검색 필터 +comp_split_panel (v2-split-panel-layout) + ├── leftPanel (custom mode) + │ ├── left_tabs (v2-tabs-widget) - [수주데이터, 안전재고 부족분] + │ ├── order_table (v2-table-grouped) - 수주 테이블 + │ └── btn_import (v2-button-primary) - 선택 품목 불러오기 + ├── rightPanel (custom mode) + │ ├── right_tabs (v2-tabs-widget) - [완제품, 반제품] + │ │ └── finished_tab.components + │ │ ├── v2-timeline-scheduler - 타임라인 + │ │ └── v2-button-primary - 스케줄 생성 + │ ├── btn_save (v2-button-primary) - 자동 스케줄 생성 + │ └── btn_clear (v2-button-primary) - 초기화 +comp_q0iqzkpx (v2-button-primary) - 하단 저장 버튼 (무의미) +``` + +### 6.2 수정 필요 사항 + +| 항목 | 현재 상태 | 필요 상태 | +|------|-----------|-----------| +| **좌측 - 안전재고 탭** | 컴포넌트 없음 (`"컴포넌트가 없습니다"` 표시) | `v2-table-list` 또는 별도 조회 API 연결된 테이블 추가 | +| **좌측 - order_table** | `selectedTable: "sales_order_mng"` (범용 API) | 전용 API (`/api/production/order-summary`)로 변경 필요 | +| **좌측 - 체크박스 필터** | 없음 | "계획에 없는 품목만" 체크박스 UI 추가 | +| **우측 - 반제품 탭** | 컴포넌트 없음 | 반제품 타임라인 + 옵션 패널 추가 | +| **우측 - 타임라인** | `selectedTable: "work_instruction"` | `selectedTable: "production_plan_mng"` + 필터 `product_type='완제품'` | +| **우측 - 옵션 패널** | 없음 | 안전리드타임, 표시기간, 재계산 체크박스 → `v2-input` 조합 | +| **우측 - 범례** | 없음 | `v2-text-display` 또는 커스텀 범례 컴포넌트 | +| **우측 - 버튼들** | 일부만 존재 | 병합, 반제품계획, 저장, 초기화 추가 | +| **하단 저장 버튼** | 존재 (무의미) | 제거 | +| **우측 패널 렌더링 버그** | 타임라인 미렌더링 | SplitPanelLayout custom 모드 디버깅 필요 | + +--- + +## 7. 구현 단계별 계획 + +### Phase 1: 기존 버그 수정 + 기본 구조 안정화 + +**목표**: 현재 layout_data로 화면이 최소한 정상 렌더링되게 만들기 + +| 작업 | 상세 | 예상 난이도 | +|------|------|-------------| +| 1-1. 좌측 z-index 겹침 수정 | SplitPanelLayout의 custom 모드에서 내부 컴포넌트가 비대화형 div에 가려지는 이슈 | 중 | +| 1-2. 우측 타임라인 렌더링 수정 | tabs-widget 내부 timeline-scheduler가 렌더링되지 않는 이슈 | 중 | +| 1-3. 하단 저장 버튼 제거 | layout_data에서 `comp_q0iqzkpx` 제거 | 하 | +| 1-4. 타임라인 데이터 소스 수정 | `work_instruction` → `production_plan_mng`으로 변경 | 하 | + +### Phase 2: 백엔드 API 개발 + +**목표**: 화면에 필요한 데이터를 제공하는 전용 API 구축 + +| 작업 | 상세 | 예상 난이도 | +|------|------|-------------| +| 2-1. 수주 데이터 조회 API | `GET /api/production/order-summary` (4.1 참조) | 중 | +| 2-2. 안전재고 부족분 API | `GET /api/production/stock-shortage` (4.2 참조) | 하 | +| 2-3. 자동 스케줄 생성 API | `POST /api/production/generate-schedule` (4.3 참조) | 상 | +| 2-4. 스케줄 CRUD API | `PUT/DELETE /api/production/plan/:id` (4.6 참조) | 중 | +| 2-5. 스케줄 병합 API | `POST /api/production/merge-schedules` (4.4 참조) | 중 | +| 2-6. 반제품 계획 자동 생성 API | `POST /api/production/generate-semi-schedule` (4.5 참조) | 상 | +| 2-7. 스케줄 분할 API | `POST /api/production/split-schedule` (4.7 참조) | 중 | + +### Phase 3: layout_data 보강 + 모달 화면 + +**목표**: 안전재고 탭, 반제품 탭, 모달들 구성 + +| 작업 | 상세 | 예상 난이도 | +|------|------|-------------| +| 3-1. 안전재고 부족분 탭 구성 | `stock_tab`에 테이블 컴포넌트 + "선택 품목 불러오기" 버튼 추가 | 중 | +| 3-2. 반제품 탭 구성 | `semi_tab`에 타임라인 + 옵션 + 버튼 추가 | 중 | +| 3-3. 옵션 패널 구성 | v2-input 조합으로 안전리드타임, 표시기간, 체크박스 | 중 | +| 3-4. 버튼 액션 연결 | 자동 스케줄, 병합, 반제품계획, 저장, 초기화 → API 연결 | 중 | +| 3-5. 스케줄 상세 모달 보강 | screen_id: 3986 layout_data 수정 | 중 | +| 3-6. 수주/안전재고 불러오기 모달 | 신규 모달 screen 생성 | 중 | +| 3-7. 설비 선택 모달 | 신규 모달 screen 생성 | 중 | + +### Phase 4: v2-table-grouped 확장 (2레벨 트리 지원) + +**목표**: HTML 예시의 "품목 그룹 → 수주 상세" 2레벨 트리 테이블 구현 + +| 작업 | 상세 | 예상 난이도 | +|------|------|-------------| +| 4-1. 컴포넌트 확장 설계 | 그룹 행과 상세 행이 다른 컬럼 구조를 가질 수 있도록 설계 | 상 | +| 4-2. expandedRowRenderer 구현 | 그룹 행 펼침 시 별도 컬럼/데이터로 하위 행 렌더링 | 상 | +| 4-3. 그룹 행 집계 컬럼 설정 | 그룹 헤더에 SUM, 계산 필드 표시 (현재고, 안전재고, 필요생산계획 등) | 중 | +| 4-4. 조건부 서식 지원 | 부족수량 빨간색, 양수 초록색 등 | 중 | + +**대안**: Phase 4가 너무 복잡하면, 좌측 수주데이터를 2개 연동 테이블로 분리 (상단: 품목별 집계 테이블, 하단: 선택 품목의 수주 상세 테이블) 하는 방식도 검토 가능 + +--- + +## 8. 파일 생성/수정 목록 + +### 8.1 백엔드 + +| 파일 | 작업 | 비고 | +|------|------|------| +| `backend-node/src/routes/productionRoutes.ts` | 라우터 등록 | 신규 or 기존 확장 | +| `backend-node/src/controllers/productionController.ts` | API 핸들러 | 신규 or 기존 확장 | +| `backend-node/src/services/productionPlanService.ts` | 비즈니스 로직 서비스 | 신규 | + +### 8.2 DB (layout_data 수정) + +| 대상 | 작업 | +|------|------| +| `screen_layouts_v2` (screen_id: 3985) | layout_data JSON 수정 | +| `screen_layouts_v2` (screen_id: 3986) | 모달 layout_data 보강 | +| `screen_definitions` + `screen_layouts_v2` | 설비 선택 모달 신규 등록 | +| `screen_definitions` + `screen_layouts_v2` | 불러오기 모달 신규 등록 | + +### 8.3 프론트엔드 (API 클라이언트) + +| 파일 | 작업 | +|------|------| +| `frontend/lib/api/production.ts` | 생산계획 전용 API 클라이언트 함수 추가 | + +### 8.4 프론트엔드 (V2 컴포넌트 확장, Phase 4) + +| 파일 | 작업 | +|------|------| +| `frontend/lib/registry/components/v2-table-grouped/` | 2레벨 트리 지원 확장 | +| `frontend/lib/registry/components/v2-timeline-scheduler/` | 옵션 패널/범례 확장 (필요시) | + +--- + +## 9. 이벤트 흐름 (주요 시나리오) + +### 9.1 자동 스케줄 생성 흐름 + +``` +1. 사용자가 좌측 수주데이터에서 품목 체크박스 선택 +2. 우측 "자동 스케줄 생성" 버튼 클릭 +3. (옵션 확인) 안전리드타임, 재계산 모드 체크 +4. POST /api/production/generate-schedule 호출 +5. (응답) 변경사항 확인 모달 표시 (신규/유지/삭제 건수) +6. 사용자 "확인 및 적용" 클릭 +7. 타임라인 스케줄러 새로고침 +8. 좌측 수주 목록의 "기생산계획량" 컬럼 갱신 +``` + +### 9.2 수주 불러오기 흐름 + +``` +1. 사용자가 좌측 수주데이터에서 품목 체크박스 선택 +2. "선택 품목 불러오기" 버튼 클릭 +3. 불러오기 모달 표시 (선택 품목 목록 + 추가방식 선택) +4. "기존 계획에 추가" or "별도 계획으로 생성" 선택 +5. "불러오기" 버튼 클릭 +6. POST /api/production/generate-schedule 호출 (단건) +7. 타임라인 새로고침 +``` + +### 9.3 타임라인 스케줄 클릭 → 상세 모달 + +``` +1. 사용자가 타임라인의 스케줄 바 클릭 +2. 스케줄 상세 모달 오픈 (TOPSEAL_PP_MODAL) +3. 기본정보(readonly), 근거정보(readonly), 생산정보(수정가능) 표시 +4. 계획기간 수정, 설비할당, 분할 등 작업 +5. "저장" → PUT /api/production/plan/:id +6. "삭제" → DELETE /api/production/plan/:id +7. 모달 닫기 → 타임라인 새로고침 +``` + +### 9.4 반제품 계획 생성 흐름 + +``` +1. 우측 완제품 탭에서 스케줄 체크박스 선택 +2. "선택 품목 → 반제품 계획" 버튼 클릭 +3. POST /api/production/generate-semi-schedule 호출 + - BOM 조회 → 필요 반제품 목록 + 소요량 계산 + - 재고 감안 → 순 필요량 계산 + - 반제품 계획 INSERT (product_type='반제품', parent_plan_id 설정) +4. 반제품 탭으로 자동 전환 +5. 반제품 타임라인 새로고침 +``` + +--- + +## 10. 검색 필드 설정 + +| 필드명 | 타입 | 라벨 | 대상 컬럼 | +|--------|------|------|-----------| +| `item_code` | text | 품목코드 | `part_code` (수주) / `item_code` (계획) | +| `item_name` | text | 품명 | `part_name` / `item_name` | +| `plan_date` | daterange | 계획기간 | `start_date` ~ `end_date` | +| `status` | select | 상태 | 전체 / 계획 / 진행 / 완료 | + +--- + +## 11. 권한 및 멀티테넌시 + +### 11.1 모든 API에 적용 + +```typescript +const companyCode = req.user!.companyCode; + +if (companyCode === '*') { + // 최고관리자: 모든 회사 데이터 조회 가능 +} else { + // 일반 회사: WHERE company_code = $1 필수 +} +``` + +### 11.2 데이터 격리 + +- `production_plan_mng.company_code` 필터 필수 +- `sales_order_mng.company_code` 필터 필수 +- `inventory_stock.company_code` 필터 필수 +- JOIN 시 양쪽 테이블 모두 `company_code` 조건 포함 + +--- + +## 12. 우선순위 정리 + +| 우선순위 | 작업 | 이유 | +|----------|------|------| +| **1 (긴급)** | Phase 1: 기존 렌더링 버그 수정 | 현재 화면 자체가 정상 동작하지 않음 | +| **2 (높음)** | Phase 2-1, 2-2: 수주/재고 조회 API | 좌측 패널의 핵심 데이터 | +| **3 (높음)** | Phase 2-3: 자동 스케줄 생성 API | 우측 패널의 핵심 기능 | +| **4 (중간)** | Phase 3: layout_data 보강 | 안전재고 탭, 반제품 탭, 모달 | +| **5 (중간)** | Phase 2-4~2-7: 나머지 API | 병합, 분할, 반제품 계획 | +| **6 (낮음)** | Phase 4: 2레벨 트리 테이블 확장 | 현재 단순 그룹핑으로도 기본 동작 | + +--- + +## 부록 A: HTML 예시의 모달 목록 + +| 모달명 | HTML ID | 용도 | +|--------|---------|------| +| 스케줄 상세 모달 | `scheduleModal` | 스케줄 기본정보/근거정보/생산정보/계획기간/분할/설비할당/상태/추가정보 | +| 수주 불러오기 모달 | `orderImportModal` | 선택 품목 목록 + 추가방식 선택 (기존추가/별도생성) | +| 안전재고 불러오기 모달 | `stockImportModal` | 부족 품목 목록 + 추가방식 선택 | +| 설비 선택 모달 | `equipmentSelectModal` | 설비 카드 + 수량할당 + 일정등록 | +| 변경사항 확인 모달 | `changeConfirmModal` | 자동스케줄 생성 결과 요약 + 상세 비교 | + +## 부록 B: HTML 예시의 JS 핵심 함수 목록 + +| 함수명 | 기능 | 매핑 API | +|--------|------|----------| +| `generateSchedule()` | 자동 스케줄 생성 (품목별 합산) | POST /api/production/generate-schedule | +| `saveSchedule()` | 스케줄 저장 (localStorage → DB) | POST /api/production/plan (bulk) | +| `mergeSelectedSchedules()` | 선택 계획 병합 | POST /api/production/merge-schedules | +| `generateSemiFromSelected()` | 반제품 계획 자동 생성 | POST /api/production/generate-semi-schedule | +| `saveScheduleFromModal()` | 모달에서 스케줄 저장 | PUT /api/production/plan/:id | +| `deleteScheduleFromModal()` | 모달에서 스케줄 삭제 | DELETE /api/production/plan/:id | +| `openOrderImportModal()` | 수주 불러오기 모달 열기 | - (프론트엔드 UI) | +| `importOrderItems()` | 수주 품목 불러오기 실행 | POST /api/production/generate-schedule | +| `openStockImportModal()` | 안전재고 불러오기 모달 열기 | - (프론트엔드 UI) | +| `importStockItems()` | 안전재고 품목 불러오기 실행 | POST /api/production/generate-schedule | +| `refreshOrderList()` | 수주 목록 새로고침 | GET /api/production/order-summary | +| `refreshStockList()` | 재고 부족 목록 새로고침 | GET /api/production/stock-shortage | +| `switchTab(tabName)` | 좌측 탭 전환 | - (프론트엔드 UI) | +| `switchTimelineTab(tabName)` | 우측 탭 전환 | - (프론트엔드 UI) | +| `toggleOrderDetails(itemGroup)` | 품목 그룹 펼치기/접기 | - (프론트엔드 UI) | +| `renderTimeline()` | 완제품 타임라인 렌더링 | - (프론트엔드 UI) | +| `renderSemiTimeline()` | 반제품 타임라인 렌더링 | - (프론트엔드 UI) | +| `executeSplit()` | 계획 분할 실행 | POST /api/production/split-schedule | +| `openEquipmentSelectModal()` | 설비 선택 모달 열기 | GET /api/equipment (기존) | +| `saveEquipmentSelection()` | 설비 할당 저장 | PUT /api/production/plan/:id | +| `applyScheduleChanges()` | 변경사항 확인 후 적용 | - (프론트엔드 상태 관리) | + +## 부록 C: 수주 데이터 테이블 컬럼 상세 + +### 그룹 행 (품목별 집계) + +| # | 컬럼 | 데이터 소스 | 정렬 | +|---|------|-------------|------| +| 1 | 체크박스 | - | center | +| 2 | 토글 (펼치기/접기) | - | center | +| 3 | 품목코드 | `sales_order_mng.part_code` (GROUP BY) | left | +| 4 | 품목명 | `sales_order_mng.part_name` | left | +| 5 | 총수주량 | `SUM(order_qty)` | right | +| 6 | 출고량 | `SUM(ship_qty)` | right | +| 7 | 잔량 | `SUM(balance_qty)` | right | +| 8 | 현재고 | `inventory_stock.current_qty` (JOIN) | right | +| 9 | 안전재고 | `inventory_stock.safety_qty` (JOIN) | right | +| 10 | 출하계획량 | `SUM(plan_ship_qty)` | right | +| 11 | 기생산계획량 | `production_plan_mng` 조회 (JOIN) | right | +| 12 | 생산진행 | `production_plan_mng` (status='in_progress') 조회 | right | +| 13 | 필요생산계획 | 계산값 (잔량+안전재고-현재고-기생산계획량-생산진행) | right, 빨간색 강조 | + +### 상세 행 (개별 수주) + +| # | 컬럼 | 데이터 소스 | +|---|------|-------------| +| 1 | (빈 칸) | - | +| 2 | (빈 칸) | - | +| 3-4 | 수주번호, 거래처, 상태배지 | `order_no`, `partner_id` → partner_name, `status` | +| 5 | 수주량 | `order_qty` | +| 6 | 출고량 | `ship_qty` | +| 7 | 잔량 | `balance_qty` | +| 8-13 | 납기일 (colspan) | `due_date` | + +## 부록 D: 타임라인 스케줄러 필드 매핑 + +### 완제품 타임라인 + +| 타임라인 필드 | production_plan_mng 컬럼 | 비고 | +|--------------|--------------------------|------| +| `id` | `id` | PK | +| `resourceId` | `item_code` | 품목 기준 리소스 (설비 기준이 아님) | +| `title` | `item_name` + `plan_qty` | 표시 텍스트 | +| `startDate` | `start_date` | 시작일 | +| `endDate` | `end_date` | 종료일 | +| `status` | `status` | planned/in_progress/completed/work-order | +| `progress` | `progress_rate` | 진행률(%) | + +### 반제품 타임라인 + +동일 구조, 단 `product_type = '반제품'` 필터 적용 + +### statusColors 매핑 + +| 상태 | 색상 | 의미 | +|------|------|------| +| `planned` | `#3b82f6` (파란색) | 계획됨 | +| `work-order` | `#f59e0b` (노란색) | 작업지시 | +| `in_progress` | `#10b981` (초록색) | 진행중 | +| `completed` | `#6b7280` (회색, 반투명) | 완료 | +| `delayed` | `#ef4444` (빨간색) | 지연 | 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/docs/ycshin-node/BIC[계획]-버튼-아이콘화.md b/docs/ycshin-node/BIC[계획]-버튼-아이콘화.md index 816eaa1e..be3a3776 100644 --- a/docs/ycshin-node/BIC[계획]-버튼-아이콘화.md +++ b/docs/ycshin-node/BIC[계획]-버튼-아이콘화.md @@ -323,7 +323,7 @@ interface ButtonComponentConfig { | 파일 | 내용 | |------|------| -| `frontend/lib/button-icon-map.ts` | 버튼 액션별 추천 아이콘 매핑 + 아이콘 동적 렌더링 유틸 | +| `frontend/lib/button-icon-map.tsx` | 버튼 액션별 추천 아이콘 매핑 + 아이콘 동적 렌더링 유틸 | --- @@ -338,3 +338,52 @@ interface ButtonComponentConfig { - 외부 SVG 붙여넣기도 지원 → 관리자가 회사 로고 등 자체 아이콘을 등록 가능 - lucide 커스텀 아이콘은 `componentConfig.customIcons`에, SVG 아이콘은 `componentConfig.customSvgIcons`에 저장 - lucide 아이콘 렌더링: 아이콘 이름 → 컴포넌트 매핑, SVG 아이콘 렌더링: `dangerouslySetInnerHTML` + DOMPurify 정화 +- **동적 아이콘 로딩**: `iconMap`에 명시적으로 import되지 않은 lucide 아이콘도 `getLucideIcon()` 호출 시 `lucide-react`의 전체 아이콘(`icons`)에서 자동 조회 후 캐싱 → 화면 관리에서 선택한 모든 lucide 아이콘이 실제 화면에서도 렌더링됨 +- **커스텀 아이콘 전역 관리 (미구현)**: 커스텀 아이콘을 버튼별(`componentConfig`)이 아닌 시스템 전역(`custom_icon_registry` 테이블)으로 관리하여, 한번 추가한 커스텀 아이콘이 모든 화면의 모든 버튼에서 사용 가능하도록 확장 예정 + +--- + +## [미구현] 커스텀 아이콘 전역 관리 + +### 현재 문제 + +- 커스텀 아이콘이 `componentConfig.customIcons`에 저장 → **해당 버튼에서만** 보임 +- 저장1 버튼에 추가한 커스텀 아이콘이 저장2 버튼, 다른 화면에서는 안 보임 +- 같은 아이콘을 쓰려면 매번 검색해서 다시 추가해야 함 + +### 변경 후 동작 + +- 커스텀 아이콘을 **회사(company_code) 단위 전역**으로 관리 +- 어떤 화면의 어떤 버튼에서든 커스텀 아이콘 추가 → 모든 화면의 모든 버튼에서 커스텀란에 표시 +- 버튼 액션 종류와 무관하게 모든 커스텀 아이콘이 노출 + +### DB 테이블 (신규) + +```sql +CREATE TABLE custom_icon_registry ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + company_code VARCHAR(500) NOT NULL, + icon_name VARCHAR(500) NOT NULL, + icon_type VARCHAR(500) DEFAULT 'lucide', -- 'lucide' | 'svg' + svg_data TEXT, -- SVG일 경우 원본 데이터 + created_date TIMESTAMP DEFAULT now(), + updated_date TIMESTAMP DEFAULT now(), + writer VARCHAR(500) +); + +CREATE INDEX idx_custom_icon_registry_company ON custom_icon_registry(company_code); +``` + +### 백엔드 API (신규) + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| GET | `/api/custom-icons` | 커스텀 아이콘 목록 조회 (company_code 필터) | +| POST | `/api/custom-icons` | 커스텀 아이콘 추가 | +| DELETE | `/api/custom-icons/:id` | 커스텀 아이콘 삭제 | + +### 프론트엔드 변경 + +- `ButtonConfigPanel` — 커스텀 아이콘 조회/추가/삭제를 API 호출로 변경 +- 기존 `componentConfig.customIcons` 데이터는 하위 호환으로 병합 표시 (점진적 마이그레이션) +- `componentConfig.customSvgIcons`도 동일하게 전역 테이블로 이관 diff --git a/docs/ycshin-node/BIC[맥락]-버튼-아이콘화.md b/docs/ycshin-node/BIC[맥락]-버튼-아이콘화.md index f4b2b16d..ba19e386 100644 --- a/docs/ycshin-node/BIC[맥락]-버튼-아이콘화.md +++ b/docs/ycshin-node/BIC[맥락]-버튼-아이콘화.md @@ -145,8 +145,24 @@ - **결정**: lucide-react에서 export되는 전체 아이콘 이름 목록을 검색 가능 - **근거**: 관리자가 "어떤 아이콘이 있는지" 모르므로 검색 기능이 필수 -- **구현**: lucide 아이콘 이름 배열을 상수로 관리하고, CommandInput으로 필터링 -- **주의**: 전체 아이콘 컴포넌트를 import하지 않고, 이름 배열만 관리 → 선택 시에만 해당 아이콘을 매핑에 추가 +- **구현**: `lucide-react`의 `icons` 객체에서 `Object.keys()`로 전체 이름 목록을 가져오고, CommandInput으로 필터링 +- **주의**: `allLucideIcons`는 `button-icon-map.tsx`에서 re-export하여 import를 중앙화 + +### 18. 커스텀 아이콘 전역 관리 (미구현) + +- **결정**: 커스텀 아이콘을 버튼별(`componentConfig`) → 시스템 전역(`custom_icon_registry` 테이블)으로 변경 +- **근거**: 현재는 버튼 A에서 추가한 커스텀 아이콘이 버튼 B, 다른 화면에서 안 보여 매번 재등록 필요. 아이콘은 시각적 자원이므로 액션이나 화면에 종속될 이유가 없음 +- **범위 검토**: 버튼별 < 화면 단위 < **시스템 전역(채택)** — 같은 아이콘을 여러 화면에서 재사용하는 ERP 특성에 시스템 전역이 가장 적합 +- **저장**: `custom_icon_registry` 테이블 (company_code 멀티테넌시), lucide 이름 또는 SVG 데이터 저장 +- **하위 호환**: 기존 `componentConfig.customIcons` 데이터는 병합 표시 후 점진적 마이그레이션 + +### 19. 동적 아이콘 로딩 (getLucideIcon fallback) + +- **결정**: `getLucideIcon(name)`이 `iconMap`에 없는 아이콘을 `lucide-react`의 `icons` 전체 객체에서 동적으로 조회 후 캐싱 +- **근거**: 화면 관리에서 커스텀 lucide 아이콘을 선택하면 `componentConfig.customIcons`에 이름만 저장됨. 디자이너 세션에서는 `addToIconMap()`으로 런타임에 등록되지만, 실제 화면(뷰어) 로드 시에는 `iconMap`에 해당 아이콘이 없어 렌더링 실패. `icons` fallback을 추가하면 **어떤 lucide 아이콘이든 이름만으로 자동 렌더링** +- **구현**: `button-icon-map.tsx`에 `import { icons as allLucideIcons } from "lucide-react"` 추가, `getLucideIcon()`에서 `iconMap` miss 시 `allLucideIcons[name]` 조회 후 `iconMap`에 캐싱 +- **번들 영향**: `icons` 전체 객체 import로 번들 크기 증가 (~100-200KB). ERP 애플리케이션 특성상 수용 가능한 수준이며, 관리자가 선택한 모든 아이콘이 실제 화면에서 동작하는 것이 더 중요 +- **대안 검토**: 뷰어 로드 시 `customIcons`를 순회하여 개별 등록 → 기각 (모든 뷰어 컴포넌트에 로직 추가 필요, 누락 위험) --- @@ -159,7 +175,7 @@ | 뷰어 렌더링 (수정) | `frontend/components/screen/InteractiveScreenViewer.tsx` | 버튼 렌더링 분기 (2041~2059행) | | 위젯 (수정) | `frontend/components/screen/widgets/types/ButtonWidget.tsx` | 위젯 기반 버튼 렌더링 (67~86행) | | 최적화 버튼 (수정) | `frontend/components/screen/OptimizedButtonComponent.tsx` | 최적화된 버튼 렌더링 (643~674행) | -| 아이콘 매핑 (신규) | `frontend/lib/button-icon-map.ts` | 액션별 추천 아이콘 + 동적 렌더링 유틸 | +| 아이콘 매핑 (신규) | `frontend/lib/button-icon-map.tsx` | 액션별 추천 아이콘 + 동적 렌더링 유틸 + allLucideIcons fallback | | 타입 정의 (참고) | `frontend/types/screen.ts` | ComponentData, componentConfig 타입 | --- @@ -169,17 +185,21 @@ ### lucide-react 아이콘 동적 렌더링 ```typescript -// button-icon-map.ts -import { Check, Save, Trash2, Pencil, ... } from "lucide-react"; +// button-icon-map.tsx +import { Check, Save, ..., icons as allLucideIcons, type LucideIcon } from "lucide-react"; -const iconMap: Record> = { - Check, Save, Trash2, Pencil, ... -}; +// 추천 아이콘은 명시적 import, 나머지는 동적 조회 +const iconMap: Record = { Check, Save, ... }; -export function renderButtonIcon(name: string, size: string | number) { - const IconComponent = iconMap[name]; - if (!IconComponent) return null; - return ; +export function getLucideIcon(name: string): LucideIcon | undefined { + if (iconMap[name]) return iconMap[name]; + // iconMap에 없으면 lucide-react 전체에서 동적 조회 후 캐싱 + const found = allLucideIcons[name as keyof typeof allLucideIcons]; + if (found) { + iconMap[name] = found; + return found; + } + return undefined; } ``` diff --git a/docs/ycshin-node/BIC[체크]-버튼-아이콘화.md b/docs/ycshin-node/BIC[체크]-버튼-아이콘화.md index a02a15b1..1b20cab9 100644 --- a/docs/ycshin-node/BIC[체크]-버튼-아이콘화.md +++ b/docs/ycshin-node/BIC[체크]-버튼-아이콘화.md @@ -125,12 +125,30 @@ - [x] 커스텀 아이콘 삭제 시 디폴트 아이콘으로 복귀 → 아이콘 모드 유지 확인 - [x] deprecated 액션에서 디폴트 폴백 아이콘(SquareMousePointer) 표시 확인 -### 6단계: 정리 +### 6단계: 동적 아이콘 로딩 (뷰어 렌더링 누락 수정) -- [x] TypeScript 컴파일 에러 없음 확인 (우리 파일 6개 모두 0 에러) +- [x] `button-icon-map.tsx`에 `icons as allLucideIcons` import 추가 +- [x] `getLucideIcon()` — `iconMap` miss 시 `allLucideIcons` fallback 조회 + 캐싱 +- [x] `allLucideIcons`를 `button-icon-map.tsx`에서 re-export (import 중앙화) +- [x] `ButtonConfigPanel.tsx` — `lucide-react` 직접 import 제거, `button-icon-map`에서 import로 통합 +- [x] 화면 관리에서 선택한 커스텀 lucide 아이콘이 실제 화면(뷰어)에서도 렌더링됨 확인 + +### 7단계: 정리 + +- [x] TypeScript 컴파일 에러 없음 확인 - [x] 불필요한 import 없음 확인 +- [x] 문서 3개 최신화 (동적 로딩 반영) - [x] 이 체크리스트 완료 표시 업데이트 +### 8단계: 커스텀 아이콘 전역 관리 (미구현) + +- [ ] `custom_icon_registry` 테이블 마이그레이션 SQL 작성 및 실행 (개발섭 + 본섭) +- [ ] 백엔드 API 구현 (GET/POST/DELETE `/api/custom-icons`) +- [ ] 프론트엔드 API 클라이언트 함수 추가 (`lib/api/`) +- [ ] `ButtonConfigPanel` — 커스텀 아이콘 조회/추가/삭제를 전역 API로 변경 +- [ ] 기존 `componentConfig.customIcons` 하위 호환 병합 처리 +- [ ] 검증: 화면 A에서 추가한 커스텀 아이콘이 화면 B에서도 보이는지 확인 + --- ## 변경 이력 @@ -156,3 +174,6 @@ | 2026-03-04 | 텍스트 위치 4방향 설정 추가 (왼쪽/오른쪽/위쪽/아래쪽) | | 2026-03-04 | 버튼 테두리 이중 적용 수정 — position wrapper에서 border strip, border shorthand 제거 | | 2026-03-04 | 프리셋 라벨 한글화 (작게/보통/크게/매우 크게), 라벨 "아이콘 크기 비율"로 변경 | +| 2026-03-13 | 동적 아이콘 로딩 — `getLucideIcon()` fallback으로 `allLucideIcons` 조회+캐싱, import 중앙화 | +| 2026-03-13 | 문서 3개 최신화 (계획서 설계 원칙, 맥락노트 결정사항 #18, 체크리스트 6-7단계) | +| 2026-03-13 | 커스텀 아이콘 전역 관리 계획 추가 (8단계, 미구현) — DB 테이블 + API + 프론트 변경 예정 | diff --git a/docs/ycshin-node/BTN-일괄변경-탑씰-버튼스타일.md b/docs/ycshin-node/BTN-일괄변경-탑씰-버튼스타일.md new file mode 100644 index 00000000..83976b73 --- /dev/null +++ b/docs/ycshin-node/BTN-일괄변경-탑씰-버튼스타일.md @@ -0,0 +1,171 @@ +# BTN - 버튼 UI 스타일 기준정보 + +## 1. 스타일 기준 + +### 공통 스타일 + +| 항목 | 값 | +|---|---| +| 높이 | 40px | +| 표시모드 | 아이콘 + 텍스트 (icon-text) | +| 아이콘 | 액션별 첫 번째 기본 아이콘 (자동 선택) | +| 아이콘 크기 비율 | 보통 | +| 아이콘-텍스트 간격 | 6px | +| 텍스트 위치 | 오른쪽 (아이콘 왼쪽, 텍스트 오른쪽) | +| 테두리 모서리 | 8px | +| 테두리 색상/두께 | 없음 (투명, borderWidth: 0) | +| 텍스트 색상 | #FFFFFF (흰색) | +| 텍스트 크기 | 12px | +| 텍스트 굵기 | normal (보통) | +| 텍스트 정렬 | 왼쪽 | + +### 배경색 (액션별) + +| 액션 타입 | 배경색 | 비고 | +|---|---|---| +| `delete` | `#F04544` | 빨간색 | +| `excel_download`, `excel_upload`, `multi_table_excel_upload` | `#212121` | 검정색 | +| 그 외 모든 액션 | `#3B83F6` | 파란색 (기본값) | + +배경색은 디자이너에서 액션을 변경하면 자동으로 바뀐다. + +### 너비 (텍스트 글자수별) + +| 글자수 | 너비 | +|---|---| +| 6글자 이하 | 140px | +| 7글자 이상 | 160px | + +### 액션별 기본 아이콘 + +디자이너에서 표시모드를 "아이콘" 또는 "아이콘+텍스트"로 변경하면 액션에 맞는 첫 번째 아이콘이 자동 선택된다. + +소스: `frontend/lib/button-icon-map.tsx` > `actionIconMap` + +| action.type | 기본 아이콘 | +|---|---| +| `save` | Check | +| `delete` | Trash2 | +| `edit` | Pencil | +| `navigate` | ArrowRight | +| `modal` | Maximize2 | +| `transferData` | SendHorizontal | +| `excel_download` | Download | +| `excel_upload` | Upload | +| `quickInsert` | Zap | +| `control` | Settings | +| `barcode_scan` | ScanLine | +| `operation_control` | Truck | +| `event` | Send | +| `copy` | Copy | +| (그 외/없음) | SquareMousePointer | + +--- + +## 2. 코드 반영 현황 + +### 컴포넌트 기본값 (신규 버튼 생성 시 적용) + +| 파일 | 내용 | +|---|---| +| `frontend/lib/registry/components/v2-button-primary/index.ts` | defaultConfig, defaultSize (140x40) | +| `frontend/lib/registry/components/v2-button-primary/config.ts` | ButtonPrimaryDefaultConfig | + +### 액션 변경 시 배경색 자동 변경 + +| 파일 | 내용 | +|---|---| +| `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 액션 변경 시 배경색/텍스트색 자동 설정 | + +### 렌더링 배경색 우선순위 + +| 파일 | 내용 | +|---|---| +| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | 배경색 결정 우선순위 개선 | + +배경색 결정 순서: +1. `webTypeConfig.backgroundColor` +2. `componentConfig.backgroundColor` +3. `component.style.backgroundColor` +4. `componentConfig.style.backgroundColor` +5. `component.style.labelColor` (레거시 호환) +6. 액션별 기본 배경색 (`#F04544` / `#212121` / `#3B83F6`) + +### 미반영 (추후 작업) + +- split-panel 내부 버튼의 코드 기본값 (split-panel 컴포넌트가 자체 생성하는 버튼) + +--- + +## 3. DB 데이터 매핑 (layout_data JSON) + +버튼은 `layout_data.components[]` 배열 안에 `url`이 `v2-button-primary`인 컴포넌트로 저장된다. + +| 항목 | JSON 위치 | 값 | +|---|---|---| +| 높이 | `size.height` | `40` | +| 너비 | `size.width` | `140` 또는 `160` | +| 표시모드 | `overrides.displayMode` | `"icon-text"` | +| 아이콘 이름 | `overrides.icon.name` | 액션별 영문 이름 | +| 아이콘 타입 | `overrides.icon.type` | `"lucide"` | +| 아이콘 크기 | `overrides.icon.size` | `"보통"` | +| 텍스트 위치 | `overrides.iconTextPosition` | `"right"` | +| 아이콘-텍스트 간격 | `overrides.iconGap` | `6` | +| 테두리 모서리 | `overrides.style.borderRadius` | `"8px"` | +| 텍스트 색상 | `overrides.style.labelColor` | `"#FFFFFF"` | +| 텍스트 크기 | `overrides.style.fontSize` | `"12px"` | +| 텍스트 굵기 | `overrides.style.fontWeight` | `"normal"` | +| 텍스트 정렬 | `overrides.style.labelTextAlign` | `"left"` | +| 배경색 | `overrides.style.backgroundColor` | 액션별 색상 | + +버튼이 위치하는 구조별 경로: +- 일반 버튼: `layout_data.components[]` +- 탭 위젯 내부: `layout_data.components[].overrides.tabs[].components[]` +- split-panel 내부: `layout_data.components[].overrides.rightPanel.components[]` + +--- + +## 4. 탑씰(COMPANY_7) 일괄 변경 작업 기록 + +### 대상 +- **회사**: 탑씰 (company_code = 'COMPANY_7') +- **테이블**: screen_layouts_v2 (배포서버) +- **스크립트**: `backend-node/scripts/btn-bulk-update-company7.ts` +- **백업 테이블**: `screen_layouts_v2_backup_company7` + +### 작업 이력 + +| 날짜 | 작업 내용 | 비고 | +|---|---|---| +| 2026-03-13 | 백업 테이블 생성 | | +| 2026-03-13 | 전체 버튼 공통 스타일 일괄 적용 | 높이, 아이콘, 텍스트 스타일, 배경색, 모서리 | +| 2026-03-13 | 탭 위젯 내부 버튼 스타일 보정 | componentConfig + root style 양쪽 적용 | +| 2026-03-13 | fontWeight "400" → "normal" 보정 | | +| 2026-03-13 | overrides.style.width 제거 | size.width와 충돌 방지 | +| 2026-03-13 | save 액션 55개에 "저장" 텍스트 명시 | | +| 2026-03-13 | "엑셀다운로드" → "Excel" 텍스트 통일 | | +| 2026-03-13 | Excel 버튼 배경색 #212121 통일 | | +| 2026-03-13 | 전체 버튼 너비 140px 통일 | | +| 2026-03-13 | 7글자 이상 버튼 너비 160px 재조정 | | +| 2026-03-13 | split-panel 내부 버튼 스타일 적용 | BOM관리 등 7개 버튼 | + +### 스킵 항목 +- `transferData` 액션의 텍스트 없는 버튼 1개 (screen=5976) + +### 알려진 이슈 +- **반응형 너비 불일치**: 디자이너에서 설정한 `size.width`가 실제 화면(`ResponsiveGridRenderer`)에서 반영되지 않을 수 있음. 버튼 wrapper에 `width` 속성이 누락되어 flex shrink-to-fit 동작으로 너비가 줄어드는 현상. 세로(height)는 정상 반영됨. + +### 원복 (필요 시) + +```sql +UPDATE screen_layouts_v2 AS target +SET layout_data = backup.layout_data +FROM screen_layouts_v2_backup_company7 AS backup +WHERE target.layout_id = backup.layout_id; +``` + +### 백업 테이블 정리 + +```sql +DROP TABLE screen_layouts_v2_backup_company7; +``` diff --git a/docs/ycshin-node/MPN[계획]-품번-수동접두어채번.md b/docs/ycshin-node/MPN[계획]-품번-수동접두어채번.md new file mode 100644 index 00000000..0cac81c2 --- /dev/null +++ b/docs/ycshin-node/MPN[계획]-품번-수동접두어채번.md @@ -0,0 +1,420 @@ +# [계획서] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성 + +> 관련 문서: [맥락노트](./MPN[맥락]-품번-수동접두어채번.md) | [체크리스트](./MPN[체크]-품번-수동접두어채번.md) + +## 개요 + +기준정보 - 품목 정보 등록 모달에서 품번(`item_number`) 채번의 세 가지 문제를 해결합니다. + +1. **BULK1 덮어쓰기 문제**: 사용자가 "ㅁㅁㅁ"을 입력해도 수동 값 추출이 실패하여 DB 숨은 값 `manualConfig.value = "BULK1"`로 덮어씌워짐 +2. **순번 공유 문제**: `buildPrefixKey`가 수동 파트를 건너뛰어 모든 접두어가 같은 시퀀스 카운터를 공유함 +3. **연속 구분자(--) 문제**: 카테고리가 비었을 때 `joinPartsWithSeparators`가 빈 파트에도 구분자를 붙여 `--` 발생 + 템플릿 불일치로 수동 값 추출 실패 → `userInputCode` 전체(구분자 포함)가 수동 값이 됨 + +--- + +## 현재 동작 + +### 채번 규칙 구성 (옵션설정 > 코드설정) + +``` +규칙1(카테고리/재질, 자동) → "-" → 규칙2(문자, 직접입력) → "-" → 규칙3(순번, 자동, 3자리, 시작=5) +``` + +### 실제 저장 흐름 (사용자가 "ㅁㅁㅁ" 입력 시) + +1. 모달 열림 → `_numberingRuleId` 설정됨 (TextInputComponent L117-128) +2. 사용자가 "ㅁㅁㅁ" 입력 → `formData.item_number = "ㅁㅁㅁ"` +3. 저장 클릭 → `buttonActions.ts`가 `_numberingRuleId` 확인 → `allocateCode(ruleId, "ㅁㅁㅁ", formData)` 호출 +4. 백엔드: 템플릿 기반 수동 값 추출 시도 → **실패** (입력 "ㅁㅁㅁ"이 템플릿 "CATEGORY-____-XXX"와 불일치) +5. 폴백: `manualConfig.value = "BULK1"` 사용 → **사용자 입력 "ㅁㅁㅁ" 완전 무시됨** +6. `buildPrefixKey`가 수동 파트를 건너뜀 → prefix_key에 접두어 미포함 → 공유 카운터 사용 +7. 결과: **-BULK1-015** (사용자가 뭘 입력하든 항상 BULK1, 항상 공유 카운터) + +### 문제 1: 순번 공유 (buildPrefixKey) + +**위치**: `numberingRuleService.ts` L85-88 + +```typescript +if (part.generationMethod === "manual") { + // 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로) + continue; // ← 접두어별 순번 분리를 막는 원인 +} +``` + +이 `continue` 때문에 수동 입력값이 prefix_key에 포함되지 않습니다. +"ㅁㅁㅁ", "ㅇㅇㅇ", "BULK1" 전부 **같은 시퀀스 카운터를 공유**합니다. + +### 문제 2: BULK1 덮어쓰기 (추출 실패 + manualConfig.value 폴백) + +**발생 흐름**: + +1. 사용자가 "ㅁㅁㅁ" 입력 → `userInputCode = "ㅁㅁㅁ"` 으로 `allocateCode` 호출 +2. `allocateCode` 내부에서 **prefix_key를 먼저 빌드** (L1306) → 수동 값 추출은 그 이후 (L1332-1442) +3. 템플릿 기반 수동 값 추출 시도 (L1411-1436): + ``` + 템플릿: "카테고리값-____-XXX" (카테고리값-수동입력위치-순번) + 사용자 입력: "ㅁㅁㅁ" + ``` +4. "ㅁㅁㅁ"은 "카테고리값-"으로 시작하지 않음 → `startsWith` 불일치 → **추출 실패** → `extractedManualValues = []` +5. 코드 조합 단계 (L1448-1454)에서 폴백 체인 동작: + ```typescript + const manualValue = + extractedManualValues[0] || // undefined (추출 실패) + part.manualConfig?.value || // "BULK1" (DB 숨은 값) ← 여기서 덮어씌워짐 + ""; + ``` +6. 결과: `-BULK1-015` (사용자 입력 "ㅁㅁㅁ"이 완전히 무시됨) + +**DB 숨은 값 원인**: +- DB `numbering_rule_parts.manual_config` 컬럼에 `{"value": "BULK1", "placeholder": "..."}` 저장됨 +- `ManualConfigPanel.tsx`에는 `placeholder` 입력란만 있고 **`value` 입력란이 없음** +- 플레이스홀더 수정 시 `{ ...config, placeholder: ... }` 스프레드로 기존 `value: "BULK1"`이 계속 보존됨 + +### 문제 3: 연속 구분자(--) 문제 + +**발생 흐름**: + +1. 카테고리 미선택 → 카테고리 파트 값 = `""` (빈 문자열) +2. `joinPartsWithSeparators`가 빈 파트에도 구분자 `-`를 추가 → 연속 빈 파트 시 `--` 발생 +3. 사용자 입력 필드에 `-제발-015` 형태로 표시 (선행 `-`) +4. `extractManualValuesFromInput`에서 템플릿이 `CATEGORY-____-XXX`로 생성됨 (실제 값 `""` 대신 플레이스홀더 `"CATEGORY"` 사용) +5. 입력 `-제발-015`이 `CATEGORY-`로 시작하지 않음 → 추출 실패 +6. 폴백: `userInputCode` 전체 `-제발-015`가 수동 값이 됨 +7. 코드 조합: `""` + `-` + `-제발-015` + `-` + `003` = `--제발-015-003` + +### 정상 동작 확인된 부분 + +| 항목 | 상태 | 근거 | +|------|------|------| +| `_numberingRuleId` 유지 | 정상 | 사용자 입력해도 allocateCode가 호출됨 | +| 시퀀스 증가 | 정상 | 순번이 증가하고 있음 (015 등) | +| 코드 조합 | 정상 | 구분자, 파트 순서 등 올바르게 결합됨 | + +### 비정상 확인된 부분 + +| 항목 | 상태 | 근거 | +|------|------|------| +| 수동 값 추출 | **실패** | 사용자 입력 "ㅁㅁㅁ"이 템플릿과 불일치 → 추출 실패 → BULK1 폴백 | +| prefix_key 분리 | **실패** | `buildPrefixKey`가 수동 파트 skip → 모든 접두어가 같은 시퀀스 공유 | +| 연속 구분자 | **실패** | 빈 파트에 구분자 추가 + 템플릿 플레이스홀더 불일치 → `--` 발생 | + +--- + +## 변경 후 동작 + +### prefix_key에 수동 파트 값 포함 + +``` +현재: prefix_key = 카테고리값만 (수동 파트 무시) +변경: prefix_key = 카테고리값 + "|" + 수동입력값 +``` + +### allocateCode 실행 순서 변경 + +``` +현재: buildPrefixKey → 시퀀스 할당 → 수동 값 추출 → 코드 조합 +변경: 수동 값 추출 → buildPrefixKey(수동 값 포함) → 시퀀스 할당 → 코드 조합 +``` + +### 순번 동작 + +``` +"ㅁㅁㅁ" 첫 등록 → prefix_key="카테고리|ㅁㅁㅁ", sequence=1 → -ㅁㅁㅁ-001 +"ㅁㅁㅁ" 두번째 → prefix_key="카테고리|ㅁㅁㅁ", sequence=2 → -ㅁㅁㅁ-002 +"ㅇㅇㅇ" 첫 등록 → prefix_key="카테고리|ㅇㅇㅇ", sequence=1 → -ㅇㅇㅇ-001 +"ㅁㅁㅁ" 세번째 → prefix_key="카테고리|ㅁㅁㅁ", sequence=3 → -ㅁㅁㅁ-003 +``` + +### BULK1 폴백 제거 (코드 + DB 이중 조치) + +``` +코드: 폴백 체인에서 manualConfig.value 제거 → extractedManualValues만 사용 +DB: manual_config에서 "value": "BULK1" 키 제거 → 유령 기본값 정리 +``` + +### 연속 구분자 방지 + 템플릿 정합성 복원 + +``` +joinPartsWithSeparators: 빈 파트 뒤에 이미 구분자가 있으면 중복 추가하지 않음 +extractManualValuesFromInput: 카테고리/참조 빈 값 시 "" 반환 (플레이스홀더 "CATEGORY"/"REF" 대신) +→ 템플릿이 실제 코드 구조와 일치 → 추출 성공 → -- 방지 +``` + +--- + +## 시각적 예시 + +| 사용자 입력 | 현재 동작 | 원인 | 변경 후 동작 | +|------------|----------|------|-------------| +| `ㅁㅁㅁ` (첫번째) | `-BULK1-015` | 추출 실패 → BULK1 폴백 + 공유 카운터 | `카테고리값-ㅁㅁㅁ-001` | +| `ㅁㅁㅁ` (두번째) | `-BULK1-016` | 동일 | `카테고리값-ㅁㅁㅁ-002` | +| `ㅇㅇㅇ` (첫번째) | `-BULK1-017` | 동일 | `카테고리값-ㅇㅇㅇ-001` | +| (입력 안 함) | `-BULK1-018` | manualConfig.value 폴백 | 에러 반환 (수동 파트 필수 입력) | +| 카테고리 비었을 때 | `--제발-015-003` | 빈 파트 구분자 중복 + 템플릿 불일치 | `-제발-001` | + +--- + +## 아키텍처 + +```mermaid +sequenceDiagram + participant User as 사용자 + participant BA as buttonActions.ts + participant API as allocateNumberingCode API + participant NRS as numberingRuleService + participant DB as numbering_rule_sequences + + User->>BA: 저장 클릭 (item_number = "ㅁㅁㅁ") + BA->>API: allocateCode(ruleId, "ㅁㅁㅁ", formData) + API->>NRS: allocateCode() + + Note over NRS: 1단계: 수동 값 추출 (buildPrefixKey 전에 수행) + NRS->>NRS: extractManualValuesFromInput("ㅁㅁㅁ") + Note over NRS: 템플릿 파싱 실패 → 폴백: userInputCode 전체 사용 + NRS->>NRS: extractedManualValues = ["ㅁㅁㅁ"] + + Note over NRS: 2단계: prefix_key 빌드 (수동 값 포함) + NRS->>NRS: buildPrefixKey(rule, formData, ["ㅁㅁㅁ"]) + Note over NRS: prefix_key = "카테고리값|ㅁㅁㅁ" + + Note over NRS: 3단계: 시퀀스 할당 + NRS->>DB: UPSERT sequences (prefix_key="카테고리값|ㅁㅁㅁ") + DB-->>NRS: current_sequence = 1 + + Note over NRS: 4단계: 코드 조합 + NRS->>NRS: 카테고리값 + "-" + "ㅁㅁㅁ" + "-" + "001" + NRS-->>API: "카테고리값-ㅁㅁㅁ-001" + API-->>BA: generatedCode + BA->>BA: formData.item_number = "카테고리값-ㅁㅁㅁ-001" +``` + +--- + +## 변경 대상 파일 + +| 파일 | 변경 내용 | 규모 | +|------|----------|------| +| `backend-node/src/services/numberingRuleService.ts` | `buildPrefixKey`에 `manualValues` 파라미터 추가, `allocateCode`에서 수동 값 추출 순서 변경 + 폴백 체인 정리, `extractManualValuesFromInput` 헬퍼 분리, `joinPartsWithSeparators` 연속 구분자 방지, 템플릿 카테고리/참조 플레이스홀더를 실제값으로 변경, `previewCode`에 `manualInputValue` 파라미터 추가 + `startFrom` 적용 | ~80줄 | +| `backend-node/src/controllers/numberingRuleController.ts` | preview 엔드포인트에 `manualInputValue` body 파라미터 수신 추가 | ~2줄 | +| `frontend/lib/api/numberingRule.ts` | `previewNumberingCode`에 `manualInputValue` 파라미터 추가 | ~3줄 | +| `frontend/components/v2/V2Input.tsx` | 수동 입력값 변경 시 디바운스(300ms) preview API 호출 + suffix(순번) 실시간 갱신 | ~35줄 | +| `db/migrations/1053_remove_bulk1_manual_config_value.sql` | `numbering_rule_parts.manual_config`에서 `value: "BULK1"` 제거 | SQL 1건 | + +### buildPrefixKey 호출부 영향 분석 + +| 호출부 | 위치 | `manualValues` 전달 | 영향 | +|--------|------|---------------------|------| +| `previewCode` | L1091 | `manualInputValue` 전달 시 포함 | 접두어별 정확한 순번 조회 | +| `allocateCode` | L1332 | 전달 | prefix_key에 수동 값 포함됨 | + +### 멀티테넌시 체크 + +| 항목 | 상태 | 근거 | +|------|------|------| +| `buildPrefixKey` | 영향 없음 | 시그니처만 확장, company_code 관련 변경 없음 | +| `allocateCode` | 이미 준수 | L1302에서 `companyCode`로 규칙 조회, L1313에서 시퀀스 할당 시 `companyCode` 전달 | +| `joinPartsWithSeparators` | 영향 없음 | 순수 문자열 조합 함수, company_code 무관 | +| DB 마이그레이션 | 해당 없음 | JSONB 내부 값 정리, company_code 무관 | + +--- + +## 코드 설계 + +### 1. `joinPartsWithSeparators` 수정 - 연속 구분자 방지 + +**위치**: L36-48 +**변경**: 빈 파트 뒤에 이미 구분자가 있으면 중복 추가하지 않음 + +```typescript +function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globalSeparator: string): string { + let result = ""; + partValues.forEach((val, idx) => { + result += val; + if (idx < partValues.length - 1) { + const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator; + if (val || !result.endsWith(sep)) { + result += sep; + } + } + }); + return result; +} +``` + +### 2. `buildPrefixKey` 수정 - 수동 파트 값을 prefix에 포함 + +**위치**: L75-88 +**변경**: 세 번째 파라미터 `manualValues` 추가. 전달되면 prefix_key에 포함. + +```typescript +private async buildPrefixKey( + rule: NumberingRuleConfig, + formData?: Record, + manualValues?: string[] +): Promise { + const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order); + const prefixParts: string[] = []; + let manualIndex = 0; + + for (const part of sortedParts) { + if (part.partType === "sequence") continue; + + if (part.generationMethod === "manual") { + const manualValue = manualValues?.[manualIndex] || ""; + manualIndex++; + if (manualValue) { + prefixParts.push(manualValue); + } + continue; + } + + // ... 나머지 기존 로직 (text, date, category, reference 등) 그대로 유지 ... + } + + return prefixParts.join("|"); +} +``` + +**하위 호환성**: `manualValues`는 optional. `previewCode`(L1091)는 전달하지 않으므로 동작 변화 없음. + +### 3. `allocateCode` 수정 - 수동 값 추출 순서 변경 + 폴백 정리 + +**위치**: L1290-1584 +**핵심 변경 2가지**: + +(A) 기존에는 `buildPrefixKey`(L1306) → 수동 값 추출(L1332) 순서였으나, **수동 값 추출 → `buildPrefixKey`** 순서로 변경. + +(B) 코드 조합 단계(L1448-1454)에서 `manualConfig.value` 폴백 제거. + +```typescript +async allocateCode(ruleId, companyCode, formData?, userInputCode?) { + // ... 규칙 조회 ... + + // 1단계: 수동 파트 값 추출 (buildPrefixKey 호출 전에 수행) + const manualParts = rule.parts.filter(p => p.generationMethod === "manual"); + let extractedManualValues: string[] = []; + + if (manualParts.length > 0 && userInputCode) { + extractedManualValues = await this.extractManualValuesFromInput( + rule, userInputCode, formData + ); + + // 폴백: 추출 실패 시 userInputCode 전체를 수동 값으로 사용 + if (extractedManualValues.length === 0 && manualParts.length === 1) { + extractedManualValues = [userInputCode]; + } + } + + // 2단계: 수동 값을 포함하여 prefix_key 빌드 + const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues); + + // 3단계: 시퀀스 할당 (기존 로직 그대로) + + // 4단계: 코드 조합 (manualConfig.value 폴백 제거) + // 기존: extractedManualValues[i] || part.manualConfig?.value || "" + // 변경: extractedManualValues[i] || "" +} +``` + +### 4. `extractManualValuesFromInput` 헬퍼 분리 + 템플릿 정합성 복원 + +기존 `allocateCode` 내부의 수동 값 추출 로직(L1332-1442)을 별도 private 메서드로 추출. +로직 자체는 변경 없음, 위치만 이동. +카테고리/참조 파트의 빈 값 처리를 실제 코드 생성과 일치시킴. + +```typescript +private async extractManualValuesFromInput( + rule: NumberingRuleConfig, + userInputCode: string, + formData?: Record +): Promise { + // 기존 L1332-1442의 로직을 그대로 이동 + // 변경: 카테고리/참조 빈 값 시 "CATEGORY"/"REF" 대신 "" 반환 + // → 템플릿이 실제 코드 구조와 일치 → 추출 성공률 향상 +} +``` + +### 5. DB 마이그레이션 - BULK1 유령 기본값 제거 + +**파일**: `db/migrations/1053_remove_bulk1_manual_config_value.sql` + +`numbering_rule_parts.manual_config` 컬럼에서 `value` 키를 제거합니다. + +```sql +-- manual_config에서 "value" 키 제거 (BULK1 유령 기본값 정리) +UPDATE numbering_rule_parts +SET manual_config = manual_config - 'value' +WHERE generation_method = 'manual' + AND manual_config ? 'value' + AND manual_config->>'value' = 'BULK1'; +``` + +> PostgreSQL JSONB 연산자 `-`를 사용하여 특정 키만 제거. +> `manual_config`의 나머지 필드(`placeholder` 등)는 유지됨. +> "BULK1" 값을 가진 레코드만 대상으로 하여 안전성 확보. + +--- + +## 설계 원칙 + +- **변경 범위 최소화**: `numberingRuleService.ts` 코드 변경 + DB 마이그레이션 1건 +- **이중 조치**: 코드에서 `manualConfig.value` 폴백 제거 + DB에서 유령 값 정리 +- `buildPrefixKey`의 `manualValues`는 optional → 기존 호출부(`previewCode` 등)에 영향 없음 +- `allocateCode` 내부 로직 순서만 변경 (추출 → prefix_key 빌드), 새 로직 추가 아님 +- 수동 값 추출 로직은 기존 코드를 헬퍼로 분리할 뿐, 로직 자체는 변경 없음 +- DB 마이그레이션은 "BULK1" 값만 정확히 타겟팅하여 부작용 방지 +- `TextInputComponent.tsx` 변경 불필요 (현재 동작이 올바름) +- 프론트엔드 변경 없음 → 프론트엔드 테스트 불필요 +- `joinPartsWithSeparators`는 연속 구분자만 방지, 기존 구분자 구조 유지 +- 템플릿 카테고리/참조 빈 값을 실제 코드와 일치시켜 추출 성공률 향상 + +--- + +## 실시간 순번 미리보기 (추가 기능) + +### 배경 + +품목 등록 모달에서 수동 입력 세그먼트 우측에 표시되는 순번(suffix)이 입력값과 무관하게 고정되어 있었음. 사용자가 "ㅇㅇ"을 입력하면 해당 접두어로 이미 몇 개가 등록되었는지에 따라 순번이 달라져야 함. + +### 목표 동작 + +``` +모달 열림 : -[입력하시오]-005 (startFrom=5 기반 기본 순번) +"ㅇㅇ" 입력 : -[ㅇㅇ]-005 (기존 "ㅇㅇ" 등록 0건) +저장 후 재입력 "ㅇㅇ": -[ㅇㅇ]-006 (기존 "ㅇㅇ" 등록 1건) +``` + +### 아키텍처 + +```mermaid +sequenceDiagram + participant User as 사용자 + participant V2 as V2Input + participant API as previewNumberingCode + participant BE as numberingRuleService.previewCode + participant DB as numbering_rule_sequences + + User->>V2: 수동 입력 "ㅇㅇ" + Note over V2: 디바운스 300ms + V2->>API: preview(ruleId, formData, "ㅇㅇ") + API->>BE: previewCode(ruleId, companyCode, formData, "ㅇㅇ") + BE->>BE: buildPrefixKey(rule, formData, ["ㅇㅇ"]) + Note over BE: prefix_key = "카테고리|ㅇㅇ" + BE->>DB: getSequenceForPrefix(prefix_key) + DB-->>BE: currentSeq = 0 + Note over BE: nextSequence = 0 + startFrom(5) = 5 + BE-->>API: "-____-005" + API-->>V2: generatedCode + V2->>V2: suffix = "-005" 갱신 + Note over V2: 화면 표시: -[ㅇㅇ]-005 +``` + +### 변경 내용 + +1. **백엔드 컨트롤러**: preview 엔드포인트가 `req.body.manualInputValue` 수신 +2. **백엔드 서비스**: `previewCode`가 `manualInputValue`를 받아 `buildPrefixKey`에 전달 → 접두어별 정확한 시퀀스 조회 +3. **백엔드 서비스**: 수동 파트가 있는데 `manualInputValue`가 없는 초기 상태 → 레거시 공용 시퀀스 조회 건너뜀, `currentSeq = 0` 사용 → `startFrom` 기본값 표시 +4. **프론트엔드 API**: `previewNumberingCode`에 `manualInputValue` 파라미터 추가 +5. **V2Input**: `manualInputValue` 변경 시 디바운스(300ms) preview API 재호출 → `numberingTemplateRef` 갱신 → suffix 실시간 업데이트 +6. **V2Input**: 카테고리 변경 시 초기 useEffect에서도 현재 `manualInputValue`를 preview에 전달 → 카테고리 변경/삭제 시 순번 즉시 반영 +7. **코드 정리**: 카테고리 해석 로직 3곳 중복 → `resolveCategoryFormat` 헬퍼로 통합 (약 100줄 감소) diff --git a/docs/ycshin-node/MPN[맥락]-품번-수동접두어채번.md b/docs/ycshin-node/MPN[맥락]-품번-수동접두어채번.md new file mode 100644 index 00000000..1d895989 --- /dev/null +++ b/docs/ycshin-node/MPN[맥락]-품번-수동접두어채번.md @@ -0,0 +1,161 @@ +# [맥락노트] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성 + +> 관련 문서: [계획서](./MPN[계획]-품번-수동접두어채번.md) | [체크리스트](./MPN[체크]-품번-수동접두어채번.md) + +--- + +## 왜 이 작업을 하는가 + +- 기준정보 - 품목정보 등록 모달에서 품번 인풋에 사용자가 값을 입력해도 무시되고 "BULK1"로 저장됨 +- 서로 다른 접두어("ㅁㅁㅁ", "ㅇㅇㅇ")를 입력해도 전부 같은 시퀀스 카운터를 공유함 +- 카테고리 미선택 시 `--제발-015-003` 처럼 연속 구분자가 발생함 +- 사용자 입력이 반영되고, 접두어별로 독립된 순번이 부여되어야 함 + +--- + +## 핵심 결정 사항과 근거 + +### 1. 수동 값 추출을 buildPrefixKey 전으로 이동 + +- **결정**: `allocateCode` 내부에서 수동 값 추출 → buildPrefixKey 순서로 변경 +- **근거**: 기존에는 buildPrefixKey(L1306)가 먼저 실행된 후 수동 값 추출(L1332)이 진행됨. 수동 값이 prefix_key에 포함되려면 추출이 먼저 되어야 함 +- **대안 검토**: buildPrefixKey 내부에서 직접 추출 → 기각 (역할 분리 위반, previewCode 호출에도 영향) + +### 2. buildPrefixKey에 수동 파트 값 포함 + +- **결정**: `manualValues` optional 파라미터 추가, 전달되면 prefix_key에 포함 +- **근거**: 기존 `continue`(L85-87)로 수동 파트가 prefix_key에서 제외되어 모든 접두어가 같은 시퀀스를 공유함 +- **하위호환**: optional 파라미터이므로 `previewCode`(L1091) 등 기존 호출부는 영향 없음 + +### 3. 템플릿 파싱 실패 시 userInputCode 전체를 수동 값으로 사용 + +- **결정**: 수동 파트가 1개이고 템플릿 기반 추출이 실패하면 `userInputCode` 전체를 수동 값으로 사용 +- **근거**: 사용자가 "ㅁㅁㅁ"처럼 접두어 부분만 입력하면 템플릿 "카테고리값-____-XXX"와 불일치. `startsWith` 조건 실패로 추출이 안 됨. 이 경우 입력 전체가 수동 값임 +- **제한**: 수동 파트가 2개 이상이면 이 폴백 불가 (어디서 분리할지 알 수 없음) + +### 4. 코드 조합에서 manualConfig.value 폴백 제거 + +- **결정**: `extractedManualValues[i] || part.manualConfig?.value || ""` → `extractedManualValues[i] || ""` +- **근거**: `manualConfig.value`는 UI에서 입력/편집할 수 없는 유령 필드. `ManualConfigPanel.tsx`에 `value` 입력란이 없어 DB에 한번 저장되면 스프레드 연산자로 계속 보존됨 +- **이중 조치**: 코드에서 폴백 제거 + DB 마이그레이션으로 기존 "BULK1" 값 정리 + +### 5. DB 마이그레이션은 BULK1만 타겟팅 + +- **결정**: `manual_config->>'value' = 'BULK1'` 조건으로 한정 +- **근거**: 다른 value가 의도적으로 설정된 경우가 있을 수 있음. 확인된 문제("BULK1")만 정리하여 부작용 방지 +- **대안 검토**: 전체 `manual_config.value` 키 제거 → 보류 (운영 판단 필요) + +### 6. extractManualValuesFromInput 헬퍼 분리 + +- **결정**: 기존 `allocateCode` 내부의 수동 값 추출 로직(L1332-1442)을 별도 private 메서드로 추출 +- **근거**: 추출 로직이 약 110줄로 `allocateCode`가 과도하게 비대함. 헬퍼로 분리하면 순서 변경도 자연스러움 +- **원칙**: 로직 자체는 변경 없음, 위치만 이동 (구조적 변경과 행위적 변경 분리) + +### 7. 프론트엔드 변경 불필요 + +- **결정**: 프론트엔드 코드 수정 없음 +- **근거**: `_numberingRuleId`가 사용자 입력 시에도 유지되고 있음 확인. `buttonActions.ts`가 정상적으로 `allocateCode`를 호출함. 문제는 백엔드 로직에만 있음 + +### 8. joinPartsWithSeparators 연속 구분자 방지 + +- **결정**: 빈 파트 뒤에 이미 같은 구분자가 있으면 중복 추가하지 않음 +- **근거**: 카테고리가 비면 파트 값 `""` + 구분자 `-`가 반복되어 `--` 발생. 구분자 구조(`-ㅁㅁㅁ-001`)는 유지하되 연속(`--`)만 방지 +- **조건**: `if (val || !result.endsWith(sep))` — 값이 있으면 항상 추가, 값이 없으면 이미 같은 구분자로 끝나면 스킵 + +### 9. 템플릿 카테고리/참조 플레이스홀더를 실제값으로 변경 + +- **결정**: `extractManualValuesFromInput` 내부의 카테고리/참조 빈 값 반환을 `"CATEGORY"`/`"REF"` → `""`로 변경 +- **근거**: 실제 코드 생성에서 빈 카테고리는 `""`인데 템플릿에서 `"CATEGORY"`를 쓰면 구조 불일치로 추출 실패. 로그로 확인: `userInputCode=-제발-015, previewTemplate=CATEGORY-____-XXX, extractedManualValues=[]` +- **카테고리 있을 때**: `catMapping2?.format` 반환은 수정 전후 동일하여 영향 없음 + +--- + +## 관련 파일 위치 + +| 구분 | 파일 경로 | 설명 | +|------|----------|------| +| 수정 대상 | `backend-node/src/services/numberingRuleService.ts` | joinPartsWithSeparators(L36), buildPrefixKey(L75), extractManualValuesFromInput(신규), allocateCode(L1296) | +| 신규 생성 | `db/migrations/1053_remove_bulk1_manual_config_value.sql` | BULK1 유령 값 정리 마이그레이션 | +| 변경 없음 | `frontend/components/screen/widgets/TextInputComponent.tsx` | _numberingRuleId 유지 확인 완료 | +| 변경 없음 | `frontend/lib/registry/components/numbering-rule/config.ts` | 채번 설정 레지스트리 | +| 변경 없음 | `frontend/components/screen/config-panels/NumberConfigPanel.tsx` | 채번 규칙 설정 패널 | +| 참고 | `backend-node/src/controllers/numberingRuleController.ts` | allocateNumberingCode 컨트롤러 | + +--- + +## 기술 참고 + +### allocateCode 실행 순서 (변경 전 → 후) + +``` +변경 전: buildPrefixKey(L1306) → 시퀀스 할당 → 수동 값 추출(L1332) → 코드 조합 +변경 후: 수동 값 추출 → buildPrefixKey(수동 값 포함) → 시퀀스 할당 → 코드 조합 +``` + +### prefix_key 구성 (변경 전 → 후) + +``` +변경 전: "카테고리값" (수동 파트 무시, 모든 접두어가 같은 키) +변경 후: "카테고리값|ㅁㅁㅁ" (수동 파트 포함, 접두어별 독립 키) +``` + +### 폴백 체인 (변경 전 → 후) + +``` +변경 전: extractedManualValues[i] || manualConfig.value || "" +변경 후: extractedManualValues[i] || "" +``` + +### joinPartsWithSeparators 연속 구분자 방지 (변경 전 → 후) + +``` +변경 전: "" + "-" + "" + "-" + "ㅁㅁㅁ" → "--ㅁㅁㅁ" +변경 후: "" + "-" (이미 "-"로 끝남, 스킵) + "ㅁㅁㅁ" → "-ㅁㅁㅁ" +``` + +### 템플릿 정합성 (변경 전 → 후) + +``` +변경 전: 카테고리 비었을 때 템플릿 = "CATEGORY-____-XXX" / 입력 = "-제발-015" → 불일치 → 추출 실패 +변경 후: 카테고리 비었을 때 템플릿 = "-____-XXX" / 입력 = "-제발-015" → 일치 → 추출 성공 +``` + +### 10. 실시간 순번 미리보기 구현 방식 + +- **결정**: V2Input에서 `manualInputValue` 변경 시 디바운스(300ms)로 preview API를 재호출하여 suffix(순번)를 갱신 +- **근거**: 기존 preview API는 `manualInputValue` 없이 호출되어 모든 접두어가 같은 기본 순번을 표시함. 접두어별 정확한 순번을 보여주려면 preview 시점에도 수동 값을 전달하여 해당 prefix_key의 시퀀스를 조회해야 함 +- **대안 검토**: 프론트엔드에서 카운트 API를 별도 호출 → 기각 (기존 `previewCode` 흐름 재사용이 프로젝트 관행에 부합) +- **디바운스 300ms**: 사용자 타이핑 중 과도한 API 호출 방지. 프로젝트 기존 패턴(검색 디바운스 등)과 동일 + +### 11. previewCode에 manualInputValue 전달 + +- **결정**: `previewCode` 시그니처에 `manualInputValue?: string` 추가, `buildPrefixKey`에 `[manualInputValue]`로 전달 +- **근거**: `buildPrefixKey`가 이미 `manualValues` optional 파라미터를 지원하므로 자연스럽게 확장 가능. 순번 조회 시 접두어별 독립 시퀀스를 정확히 반영함 +- **하위호환**: optional 파라미터이므로 기존 호출(`formData`만 전달)에 영향 없음 + +### 12. 초기 상태에서 레거시 시퀀스 조회 방지 + +- **결정**: `previewCode`에서 수동 파트가 있는데 `manualInputValue`가 없으면 시퀀스 조회를 건너뛰고 `currentSeq = 0` 사용 +- **근거**: 수정 전에는 모든 할당이 수동 파트 없는 공용 prefix_key를 사용했으므로 레거시 시퀀스가 누적되어 있음(예: 16). 모달 초기 상태에서 이 공용 키를 조회하면 `-016`이 표시됨. 아직 어떤 접두어인지 모르는 상태이므로 `startFrom` 기본값을 보여주는 것이 정확함 +- **`currentSeq = 0` + `startFrom`**: `nextSequence = 0 + startFrom(5) = 5` → `-005` 표시. 사용자가 입력하면 디바운스 preview가 해당 접두어의 실제 시퀀스를 조회 + +### 13. 카테고리 변경 시 수동 입력값 포함하여 순번 재조회 + +- **결정**: 초기 useEffect(카테고리 변경 트리거)에서 `previewNumberingCode` 호출 시 현재 `manualInputValue`도 함께 전달 +- **근거**: 카테고리를 바꾸거나 삭제하면 prefix_key가 달라지므로 순번도 달라져야 함. 기존에는 입력값 변경과 카테고리 변경이 별도 트리거여서 카테고리 변경 시 수동 값이 누락됨 +- **빈 입력값 처리**: `manualInputValue || undefined`로 처리하여 빈 문자열일 때는 기존처럼 `skipSequenceLookup` 작동 + +### 14. 카테고리 해석 로직 resolveCategoryFormat 헬퍼 통합 + +- **결정**: `previewCode`, `allocateCode`, `extractManualValuesFromInput` 3곳에 복붙된 카테고리 매핑 해석 로직을 `resolveCategoryFormat` private 메서드로 추출 +- **근거**: 동일 로직 약 50줄이 3곳에 복사되어 있었음 (변수명만 pool2/ct2/cc2 등으로 다름). 한 곳을 수정하면 나머지도 동일하게 수정해야 하는 유지보수 위험 +- **원칙**: 구조적 변경만 수행 (로직 변경 없음) + +### BULK1이 DB에 남아있는 이유 + +``` +ManualConfigPanel.tsx: placeholder 입력란만 존재 (value 입력란 없음) +플레이스홀더 수정 시: { ...existingConfig, placeholder: newValue } +→ 기존 config에 value: "BULK1"이 있으면 스프레드로 계속 보존됨 +→ UI에서 제거 불가능한 유령 값 +``` diff --git a/docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md b/docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md new file mode 100644 index 00000000..cbcb5f27 --- /dev/null +++ b/docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md @@ -0,0 +1,100 @@ +# [체크리스트] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성 + +> 관련 문서: [계획서](./MPN[계획]-품번-수동접두어채번.md) | [맥락노트](./MPN[맥락]-품번-수동접두어채번.md) + +--- + +## 공정 상태 + +- 전체 진행률: **100%** (전체 완료) +- 현재 단계: 완료 + +--- + +## 구현 체크리스트 + +### 1단계: 구조적 변경 (행위 변경 없음) + +- [x] `numberingRuleService.ts`에서 수동 값 추출 로직을 `extractManualValuesFromInput` private 메서드로 분리 +- [x] 기존 `allocateCode` 내부에서 분리한 메서드 호출로 교체 +- [x] 기존 동작과 동일한지 확인 (구조적 변경만, 행위 변경 없음) + +### 2단계: buildPrefixKey 수정 + +- [x] `buildPrefixKey` 시그니처에 `manualValues?: string[]` 파라미터 추가 +- [x] 수동 파트 처리 로직 변경: `continue` → `manualValues`에서 값 꺼내 `prefixParts`에 추가 +- [x] `previewCode` 호출부에 영향 없음 확인 (optional 파라미터) + +### 3단계: allocateCode 순서 변경 + 폴백 정리 + +- [x] 수동 값 추출 로직을 `buildPrefixKey` 호출 전으로 이동 +- [x] 수동 파트 1개 + 추출 실패 시 `userInputCode` 전체를 수동 값으로 사용하는 폴백 추가 +- [x] `buildPrefixKey` 호출 시 `extractedManualValues`를 세 번째 인자로 전달 +- [x] 코드 조합 단계에서 `part.manualConfig?.value` 폴백 제거 + +### 4단계: DB 마이그레이션 + +- [x] `db/migrations/1053_remove_bulk1_manual_config_value.sql` 작성 +- [x] `manual_config->>'value' = 'BULK1'` 조건으로 JSONB에서 `value` 키 제거 +- [x] 마이그레이션 실행 (9건 정리 완료) + +### 5단계: 연속 구분자(--) 방지 + +- [x] `joinPartsWithSeparators`에서 빈 파트 뒤 연속 구분자 방지 로직 추가 +- [x] `extractManualValuesFromInput`에서 카테고리/참조 빈 값 시 `""` 반환 (템플릿 정합성) + +### 6단계: 검증 + +- [x] 카테고리 선택 + 수동입력 "ㅁㅁㅁ" → 카테고리값-ㅁㅁㅁ-001 생성 확인 +- [x] 카테고리 미선택 + 수동입력 "ㅁㅁㅁ" → -ㅁㅁㅁ-001 생성 확인 (-- 아님) +- [x] 같은 접두어 "ㅁㅁㅁ" 재등록 → -ㅁㅁㅁ-002 순번 증가 확인 +- [x] 다른 접두어 "ㅇㅇㅇ" 등록 → -ㅇㅇㅇ-001 독립 시퀀스 확인 +- [x] 수동 파트 없는 채번 규칙 동작 영향 없음 확인 +- [x] previewCode (미리보기) 동작 영향 없음 확인 +- [x] BULK1이 더 이상 생성되지 않음 확인 + +### 7단계: 실시간 순번 미리보기 + +- [x] 백엔드 컨트롤러: preview 엔드포인트에 `manualInputValue` body 파라미터 수신 추가 +- [x] 백엔드 서비스: `previewCode`에 `manualInputValue` 파라미터 추가, `buildPrefixKey`에 전달 +- [x] 프론트엔드 API: `previewNumberingCode`에 `manualInputValue` 파라미터 추가 +- [x] V2Input: `manualInputValue` 변경 시 디바운스(300ms) preview API 호출 + suffix 갱신 +- [x] 백엔드 서비스: 초기 상태(수동 입력 없음) 시 레거시 공용 시퀀스 조회 건너뜀 → startFrom 기본값 표시 +- [x] V2Input: 카테고리 변경 시 초기 useEffect에서도 `manualInputValue` 전달 → 순번 즉시 반영 +- [x] 린트 에러 없음 확인 + +### 8단계: 코드 정리 + +- [x] 카테고리 해석 로직 3곳 중복 → `resolveCategoryFormat` 헬퍼 추출 (약 100줄 감소) +- [x] 임시 변수명 정리 (pool2/ct2/cc2 등 복붙 흔적 제거) +- [x] 린트 에러 없음 확인 + +### 9단계: 정리 + +- [x] 계획서/맥락노트/체크리스트 최신화 + +--- + +## 알려진 이슈 (보류) + +| 이슈 | 설명 | 상태 | +|------|------|------| +| 저장 실패 시 순번 갭 | allocateCode와 saveFormData가 별도 트랜잭션이라 저장 실패해도 순번 소비됨 | 보류 | +| 유령 데이터 | 중복 품명으로 간헐적 저장 성공 + 리스트 미노출 | 보류 | + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 완료 | +| 2026-03-11 | 1-4단계 구현 완료 | +| 2026-03-11 | 5단계 추가 구현 (연속 구분자 방지 + 템플릿 정합성 복원) | +| 2026-03-11 | 계맥체 최신화 완료. 문제 4-5 보류 | +| 2026-03-12 | 7단계 실시간 순번 미리보기 구현 완료 (백엔드/프론트엔드 4파일) | +| 2026-03-12 | 계맥체 최신화 완료 | +| 2026-03-12 | 초기 상태 레거시 시퀀스 조회 방지 수정 + 계맥체 반영 | +| 2026-03-12 | 카테고리 변경 시 수동 입력값 포함 순번 재조회 수정 | +| 2026-03-12 | resolveCategoryFormat 헬퍼 추출 코드 정리 + 계맥체 최신화 | +| 2026-03-12 | 6단계 검증 완료. 전체 완료 | diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index 7fe11270..c7933033 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -17,14 +17,17 @@ import { ScreenContextProvider } from "@/contexts/ScreenContext"; import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; import { - PopLayoutDataV5, + PopLayoutData, GridMode, - isV5Layout, - createEmptyPopLayoutV5, + isPopLayout, + createEmptyLayout, GAP_PRESETS, GRID_BREAKPOINTS, + BLOCK_GAP, + BLOCK_PADDING, detectGridMode, } from "@/components/pop/designer/types/pop-layout"; +import { loadLegacyLayout } from "@/components/pop/designer/utils/legacyLoader"; // POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import) import "@/lib/registry/pop-components"; import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals"; @@ -79,7 +82,7 @@ function PopScreenViewPage() { const { user } = useAuth(); const [screen, setScreen] = useState(null); - const [layout, setLayout] = useState(createEmptyPopLayoutV5()); + const [layout, setLayout] = useState(createEmptyLayout()); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -116,22 +119,22 @@ function PopScreenViewPage() { try { const popLayout = await screenApi.getLayoutPop(screenId); - if (popLayout && isV5Layout(popLayout)) { - // v5 레이아웃 로드 - setLayout(popLayout); + if (popLayout && isPopLayout(popLayout)) { + const v6Layout = loadLegacyLayout(popLayout); + setLayout(v6Layout); const componentCount = Object.keys(popLayout.components).length; console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`); } else if (popLayout) { // 다른 버전 레이아웃은 빈 v5로 처리 console.log("[POP] 레거시 레이아웃 감지, 빈 레이아웃으로 시작합니다:", popLayout.version); - setLayout(createEmptyPopLayoutV5()); + setLayout(createEmptyLayout()); } else { console.log("[POP] 레이아웃 없음"); - setLayout(createEmptyPopLayoutV5()); + setLayout(createEmptyLayout()); } } catch (layoutError) { console.warn("[POP] 레이아웃 로드 실패:", layoutError); - setLayout(createEmptyPopLayoutV5()); + setLayout(createEmptyLayout()); } } catch (error) { console.error("[POP] 화면 로드 실패:", error); @@ -318,12 +321,8 @@ function PopScreenViewPage() { style={{ maxWidth: 1366 }} > {(() => { - // Gap 프리셋 계산 - const currentGapPreset = layout.settings.gapPreset || "medium"; - const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0; - const breakpoint = GRID_BREAKPOINTS[currentModeKey]; - const adjustedGap = Math.round(breakpoint.gap * gapMultiplier); - const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier)); + const adjustedGap = BLOCK_GAP; + const adjustedPadding = BLOCK_PADDING; return ( void; onSelectComponent: (id: string | null) => void; onDropComponent: (type: PopComponentType, position: PopGridPosition) => void; - onUpdateComponent: (componentId: string, updates: Partial) => void; + onUpdateComponent: (componentId: string, updates: Partial) => void; onDeleteComponent: (componentId: string) => void; onMoveComponent?: (componentId: string, newPosition: PopGridPosition) => void; onResizeComponent?: (componentId: string, newPosition: PopGridPosition) => void; @@ -168,7 +163,7 @@ export default function PopCanvas({ }, [layout.modals]); // activeCanvasId에 따라 렌더링할 layout 분기 - const activeLayout = useMemo((): PopLayoutDataV5 => { + const activeLayout = useMemo((): PopLayoutData => { if (activeCanvasId === "main") return layout; const modal = layout.modals?.find(m => m.id === activeCanvasId); if (!modal) return layout; // fallback @@ -202,15 +197,22 @@ export default function PopCanvas({ const containerRef = useRef(null); const canvasRef = useRef(null); - // 현재 뷰포트 해상도 + // V6: 뷰포트에서 동적 블록 칸 수 계산 const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!; - const breakpoint = GRID_BREAKPOINTS[currentMode]; + const dynamicColumns = getBlockColumns(customWidth); + const breakpoint = { + ...GRID_BREAKPOINTS[currentMode], + columns: dynamicColumns, + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + label: `${dynamicColumns}칸 블록`, + }; - // Gap 프리셋 적용 + // V6: 블록 간격 고정 (프리셋 무관) const currentGapPreset = layout.settings.gapPreset || "medium"; - const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0; - const adjustedGap = Math.round(breakpoint.gap * gapMultiplier); - const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier)); + const adjustedGap = BLOCK_GAP; + const adjustedPadding = BLOCK_PADDING; // 숨김 컴포넌트 ID 목록 (activeLayout 기반) const hiddenComponentIds = activeLayout.overrides?.[currentMode]?.hidden || []; @@ -399,9 +401,9 @@ export default function PopCanvas({ const effectivePositions = getAllEffectivePositions(activeLayout, currentMode); // 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기 - // 검토 필요(ReviewPanel에서 클릭)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용 + // 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용 const currentEffectivePos = effectivePositions.get(dragItem.componentId); - const componentData = layout.components[dragItem.componentId]; + const componentData = activeLayout.components[dragItem.componentId]; if (!currentEffectivePos && !componentData) return; @@ -470,22 +472,8 @@ export default function PopCanvas({ ); }, [activeLayout.components, hiddenComponentIds]); - // 검토 필요 컴포넌트 목록 - const reviewComponents = useMemo(() => { - return visibleComponents.filter(comp => { - const hasOverride = !!activeLayout.overrides?.[currentMode]?.positions?.[comp.id]; - return needsReview(currentMode, hasOverride); - }); - }, [visibleComponents, activeLayout.overrides, currentMode]); - - // 검토 패널 표시 여부 (12칸 모드가 아니고, 검토 필요 컴포넌트가 있을 때) - const showReviewPanel = currentMode !== "tablet_landscape" && reviewComponents.length > 0; - - // 12칸 모드가 아닐 때만 패널 표시 - // 숨김 패널: 숨김 컴포넌트가 있거나, 그리드에 컴포넌트가 있을 때 드롭 영역으로 표시 const hasGridComponents = Object.keys(activeLayout.components).length > 0; const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents); - const showRightPanel = showReviewPanel || showHiddenPanel; return (
@@ -666,7 +654,7 @@ export default function PopCanvas({
{/* 오른쪽 패널 영역 (초과 컴포넌트 + 숨김 컴포넌트) */} - {showRightPanel && ( + {showHiddenPanel && (
- {/* 검토 필요 패널 */} - {showReviewPanel && ( - - )} - {/* 숨김 컴포넌트 패널 */} {showHiddenPanel && (
- {breakpoint.label} - {breakpoint.columns}칸 그리드 (행 높이: {breakpoint.rowHeight}px) + V6 블록 그리드 - {dynamicColumns}칸 (블록: {BLOCK_SIZE}px, 간격: {BLOCK_GAP}px)
Space + 드래그: 패닝 | Ctrl + 휠: 줌 @@ -819,99 +798,12 @@ export default function PopCanvas({ // 검토 필요 영역 (오른쪽 패널) // ======================================== -interface ReviewPanelProps { - components: PopComponentDefinitionV5[]; - selectedComponentId: string | null; - onSelectComponent: (id: string | null) => void; -} - -function ReviewPanel({ - components, - selectedComponentId, - onSelectComponent, -}: ReviewPanelProps) { - return ( -
- {/* 헤더 */} -
- - - 검토 필요 ({components.length}개) - -
- - {/* 컴포넌트 목록 */} -
- {components.map((comp) => ( - onSelectComponent(comp.id)} - /> - ))} -
- - {/* 안내 문구 */} -
-

- 자동 배치됨. 클릭하여 확인 후 편집 가능 -

-
-
- ); -} - -// ======================================== -// 검토 필요 아이템 (ReviewPanel 내부) -// ======================================== - -interface ReviewItemProps { - component: PopComponentDefinitionV5; - isSelected: boolean; - onSelect: () => void; -} - -function ReviewItem({ - component, - isSelected, - onSelect, -}: ReviewItemProps) { - return ( -
{ - e.stopPropagation(); - onSelect(); - }} - > - - {component.label || component.id} - - - 자동 배치됨 - -
- ); -} - // ======================================== // 숨김 컴포넌트 영역 (오른쪽 패널) // ======================================== interface HiddenPanelProps { - components: PopComponentDefinitionV5[]; + components: PopComponentDefinition[]; selectedComponentId: string | null; onSelectComponent: (id: string | null) => void; onHideComponent?: (componentId: string) => void; @@ -997,7 +889,7 @@ function HiddenPanel({ // ======================================== interface HiddenItemProps { - component: PopComponentDefinitionV5; + component: PopComponentDefinition; isSelected: boolean; onSelect: () => void; } diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index 36241817..8e6df1a3 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -19,21 +19,22 @@ import PopCanvas from "./PopCanvas"; import ComponentEditorPanel from "./panels/ComponentEditorPanel"; import ComponentPalette from "./panels/ComponentPalette"; import { - PopLayoutDataV5, + PopLayoutData, PopComponentType, - PopComponentDefinitionV5, + PopComponentDefinition, PopGridPosition, GridMode, GapPreset, - createEmptyPopLayoutV5, - isV5Layout, - addComponentToV5Layout, - createComponentDefinitionV5, + createEmptyLayout, + isPopLayout, + addComponentToLayout, + createComponentDefinition, GRID_BREAKPOINTS, PopModalDefinition, PopDataConnection, } from "./types/pop-layout"; import { getAllEffectivePositions } from "./utils/gridUtils"; +import { loadLegacyLayout } from "./utils/legacyLoader"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; import { PopDesignerContext } from "./PopDesignerContext"; @@ -59,10 +60,10 @@ export default function PopDesigner({ // ======================================== // 레이아웃 상태 // ======================================== - const [layout, setLayout] = useState(createEmptyPopLayoutV5()); + const [layout, setLayout] = useState(createEmptyLayout()); // 히스토리 - const [history, setHistory] = useState([]); + const [history, setHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); // UI 상태 @@ -84,7 +85,7 @@ export default function PopDesigner({ const [activeCanvasId, setActiveCanvasId] = useState("main"); // 선택된 컴포넌트 (activeCanvasId에 따라 메인 또는 모달에서 조회) - const selectedComponent: PopComponentDefinitionV5 | null = (() => { + const selectedComponent: PopComponentDefinition | null = (() => { if (!selectedComponentId) return null; if (activeCanvasId === "main") { return layout.components[selectedComponentId] || null; @@ -96,7 +97,7 @@ export default function PopDesigner({ // ======================================== // 히스토리 관리 // ======================================== - const saveToHistory = useCallback((newLayout: PopLayoutDataV5) => { + const saveToHistory = useCallback((newLayout: PopLayoutData) => { setHistory((prev) => { const newHistory = prev.slice(0, historyIndex + 1); newHistory.push(JSON.parse(JSON.stringify(newLayout))); @@ -150,14 +151,13 @@ export default function PopDesigner({ try { const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId); - if (loadedLayout && isV5Layout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) { - // v5 레이아웃 로드 - // 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가 + if (loadedLayout && isPopLayout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) { if (!loadedLayout.settings.gapPreset) { loadedLayout.settings.gapPreset = "medium"; } - setLayout(loadedLayout); - setHistory([loadedLayout]); + const v6Layout = loadLegacyLayout(loadedLayout); + setLayout(v6Layout); + setHistory([v6Layout]); setHistoryIndex(0); // 기존 컴포넌트 ID에서 최대 숫자 추출하여 idCounter 설정 (중복 방지) @@ -175,7 +175,7 @@ export default function PopDesigner({ console.log(`POP 레이아웃 로드: ${existingIds.length}개 컴포넌트, idCounter: ${maxId + 1}`); } else { // 새 화면 또는 빈 레이아웃 - const emptyLayout = createEmptyPopLayoutV5(); + const emptyLayout = createEmptyLayout(); setLayout(emptyLayout); setHistory([emptyLayout]); setHistoryIndex(0); @@ -184,7 +184,7 @@ export default function PopDesigner({ } catch (error) { console.error("레이아웃 로드 실패:", error); toast.error("레이아웃을 불러오는데 실패했습니다"); - const emptyLayout = createEmptyPopLayoutV5(); + const emptyLayout = createEmptyLayout(); setLayout(emptyLayout); setHistory([emptyLayout]); setHistoryIndex(0); @@ -225,13 +225,13 @@ export default function PopDesigner({ if (activeCanvasId === "main") { // 메인 캔버스 - const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`); + const newLayout = addComponentToLayout(layout, componentId, type, position, `${type} ${idCounter}`); setLayout(newLayout); saveToHistory(newLayout); } else { // 모달 캔버스 setLayout(prev => { - const comp = createComponentDefinitionV5(componentId, type, position, `${type} ${idCounter}`); + const comp = createComponentDefinition(componentId, type, position, `${type} ${idCounter}`); const newLayout = { ...prev, modals: (prev.modals || []).map(m => { @@ -250,7 +250,7 @@ export default function PopDesigner({ ); const handleUpdateComponent = useCallback( - (componentId: string, updates: Partial) => { + (componentId: string, updates: Partial) => { // 함수적 업데이트로 stale closure 방지 setLayout((prev) => { if (activeCanvasId === "main") { @@ -303,7 +303,7 @@ export default function PopDesigner({ const newId = `conn_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; const newConnection: PopDataConnection = { ...conn, id: newId }; const prevConnections = prev.dataFlow?.connections || []; - const newLayout: PopLayoutDataV5 = { + const newLayout: PopLayoutData = { ...prev, dataFlow: { ...prev.dataFlow, @@ -322,7 +322,7 @@ export default function PopDesigner({ (connectionId: string, conn: Omit) => { setLayout((prev) => { const prevConnections = prev.dataFlow?.connections || []; - const newLayout: PopLayoutDataV5 = { + const newLayout: PopLayoutData = { ...prev, dataFlow: { ...prev.dataFlow, @@ -343,7 +343,7 @@ export default function PopDesigner({ (connectionId: string) => { setLayout((prev) => { const prevConnections = prev.dataFlow?.connections || []; - const newLayout: PopLayoutDataV5 = { + const newLayout: PopLayoutData = { ...prev, dataFlow: { ...prev.dataFlow, @@ -389,97 +389,156 @@ export default function PopDesigner({ const handleMoveComponent = useCallback( (componentId: string, newPosition: PopGridPosition) => { - const component = layout.components[componentId]; - if (!component) return; - - // 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정 - if (currentMode === "tablet_landscape") { - const newLayout = { - ...layout, - components: { - ...layout.components, - [componentId]: { - ...component, - position: newPosition, - }, - }, - }; - setLayout(newLayout); - saveToHistory(newLayout); - setHasChanges(true); - } else { - // 다른 모드인 경우: 오버라이드에 저장 - // 숨김 상태였던 컴포넌트를 이동하면 숨김 해제도 함께 처리 - const currentHidden = layout.overrides?.[currentMode]?.hidden || []; - const isHidden = currentHidden.includes(componentId); - const newHidden = isHidden - ? currentHidden.filter(id => id !== componentId) - : currentHidden; - - const newLayout = { - ...layout, - overrides: { - ...layout.overrides, - [currentMode]: { - ...layout.overrides?.[currentMode], - positions: { - ...layout.overrides?.[currentMode]?.positions, - [componentId]: newPosition, + setLayout((prev) => { + if (activeCanvasId === "main") { + const component = prev.components[componentId]; + if (!component) return prev; + + if (currentMode === "tablet_landscape") { + const newLayout = { + ...prev, + components: { + ...prev.components, + [componentId]: { ...component, position: newPosition }, }, - // 숨김 배열 업데이트 (빈 배열이면 undefined로) - hidden: newHidden.length > 0 ? newHidden : undefined, - }, - }, - }; - setLayout(newLayout); - saveToHistory(newLayout); - setHasChanges(true); - } + }; + saveToHistory(newLayout); + return newLayout; + } else { + const currentHidden = prev.overrides?.[currentMode]?.hidden || []; + const newHidden = currentHidden.filter(id => id !== componentId); + const newLayout = { + ...prev, + overrides: { + ...prev.overrides, + [currentMode]: { + ...prev.overrides?.[currentMode], + positions: { + ...prev.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + hidden: newHidden.length > 0 ? newHidden : undefined, + }, + }, + }; + saveToHistory(newLayout); + return newLayout; + } + } else { + // 모달 캔버스 + const newLayout = { + ...prev, + modals: (prev.modals || []).map(m => { + if (m.id !== activeCanvasId) return m; + const component = m.components[componentId]; + if (!component) return m; + + if (currentMode === "tablet_landscape") { + return { + ...m, + components: { + ...m.components, + [componentId]: { ...component, position: newPosition }, + }, + }; + } else { + const currentHidden = m.overrides?.[currentMode]?.hidden || []; + const newHidden = currentHidden.filter(id => id !== componentId); + return { + ...m, + overrides: { + ...m.overrides, + [currentMode]: { + ...m.overrides?.[currentMode], + positions: { + ...m.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + hidden: newHidden.length > 0 ? newHidden : undefined, + }, + }, + }; + } + }), + }; + saveToHistory(newLayout); + return newLayout; + } + }); + setHasChanges(true); }, - [layout, saveToHistory, currentMode] + [saveToHistory, currentMode, activeCanvasId] ); const handleResizeComponent = useCallback( (componentId: string, newPosition: PopGridPosition) => { - const component = layout.components[componentId]; - if (!component) return; - - // 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정 - if (currentMode === "tablet_landscape") { - const newLayout = { - ...layout, - components: { - ...layout.components, - [componentId]: { - ...component, - position: newPosition, - }, - }, - }; - setLayout(newLayout); - // 리사이즈는 드래그 중 계속 호출되므로 히스토리는 마우스업 시에만 저장 - // 현재는 간단히 매번 저장 (최적화 가능) - setHasChanges(true); - } else { - // 다른 모드인 경우: 오버라이드에 저장 - const newLayout = { - ...layout, - overrides: { - ...layout.overrides, - [currentMode]: { - ...layout.overrides?.[currentMode], - positions: { - ...layout.overrides?.[currentMode]?.positions, - [componentId]: newPosition, + setLayout((prev) => { + if (activeCanvasId === "main") { + const component = prev.components[componentId]; + if (!component) return prev; + + if (currentMode === "tablet_landscape") { + return { + ...prev, + components: { + ...prev.components, + [componentId]: { ...component, position: newPosition }, }, - }, - }, - }; - setLayout(newLayout); - setHasChanges(true); - } + }; + } else { + return { + ...prev, + overrides: { + ...prev.overrides, + [currentMode]: { + ...prev.overrides?.[currentMode], + positions: { + ...prev.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + }, + }, + }; + } + } else { + // 모달 캔버스 + return { + ...prev, + modals: (prev.modals || []).map(m => { + if (m.id !== activeCanvasId) return m; + const component = m.components[componentId]; + if (!component) return m; + + if (currentMode === "tablet_landscape") { + return { + ...m, + components: { + ...m.components, + [componentId]: { ...component, position: newPosition }, + }, + }; + } else { + return { + ...m, + overrides: { + ...m.overrides, + [currentMode]: { + ...m.overrides?.[currentMode], + positions: { + ...m.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + }, + }, + }; + } + }), + }; + } + }); + setHasChanges(true); }, - [layout, currentMode] + [currentMode, activeCanvasId] ); const handleResizeEnd = useCallback( @@ -493,51 +552,87 @@ export default function PopDesigner({ // 컴포넌트가 자신의 rowSpan/colSpan을 동적으로 변경 요청 (CardList 확장 등) const handleRequestResize = useCallback( (componentId: string, newRowSpan: number, newColSpan?: number) => { - const component = layout.components[componentId]; - if (!component) return; + setLayout((prev) => { + const buildPosition = (comp: PopComponentDefinition) => ({ + ...comp.position, + rowSpan: newRowSpan, + ...(newColSpan !== undefined ? { colSpan: newColSpan } : {}), + }); - const newPosition = { - ...component.position, - rowSpan: newRowSpan, - ...(newColSpan !== undefined ? { colSpan: newColSpan } : {}), - }; - - // 기본 모드(tablet_landscape)인 경우: 원본 position 직접 수정 - if (currentMode === "tablet_landscape") { - const newLayout = { - ...layout, - components: { - ...layout.components, - [componentId]: { - ...component, - position: newPosition, - }, - }, - }; - setLayout(newLayout); - saveToHistory(newLayout); - setHasChanges(true); - } else { - // 다른 모드인 경우: 오버라이드에 저장 - const newLayout = { - ...layout, - overrides: { - ...layout.overrides, - [currentMode]: { - ...layout.overrides?.[currentMode], - positions: { - ...layout.overrides?.[currentMode]?.positions, - [componentId]: newPosition, + if (activeCanvasId === "main") { + const component = prev.components[componentId]; + if (!component) return prev; + const newPosition = buildPosition(component); + + if (currentMode === "tablet_landscape") { + const newLayout = { + ...prev, + components: { + ...prev.components, + [componentId]: { ...component, position: newPosition }, }, - }, - }, - }; - setLayout(newLayout); - saveToHistory(newLayout); - setHasChanges(true); - } + }; + saveToHistory(newLayout); + return newLayout; + } else { + const newLayout = { + ...prev, + overrides: { + ...prev.overrides, + [currentMode]: { + ...prev.overrides?.[currentMode], + positions: { + ...prev.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + }, + }, + }; + saveToHistory(newLayout); + return newLayout; + } + } else { + // 모달 캔버스 + const newLayout = { + ...prev, + modals: (prev.modals || []).map(m => { + if (m.id !== activeCanvasId) return m; + const component = m.components[componentId]; + if (!component) return m; + const newPosition = buildPosition(component); + + if (currentMode === "tablet_landscape") { + return { + ...m, + components: { + ...m.components, + [componentId]: { ...component, position: newPosition }, + }, + }; + } else { + return { + ...m, + overrides: { + ...m.overrides, + [currentMode]: { + ...m.overrides?.[currentMode], + positions: { + ...m.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + }, + }, + }; + } + }), + }; + saveToHistory(newLayout); + return newLayout; + } + }); + setHasChanges(true); }, - [layout, currentMode, saveToHistory] + [currentMode, saveToHistory, activeCanvasId] ); // ======================================== @@ -605,9 +700,6 @@ export default function PopDesigner({ // ======================================== const handleHideComponent = useCallback((componentId: string) => { - // 12칸 모드에서는 숨기기 불가 - if (currentMode === "tablet_landscape") return; - const currentHidden = layout.overrides?.[currentMode]?.hidden || []; // 이미 숨겨져 있으면 무시 diff --git a/frontend/components/pop/designer/index.ts b/frontend/components/pop/designer/index.ts index 37d86aec..c58ec3db 100644 --- a/frontend/components/pop/designer/index.ts +++ b/frontend/components/pop/designer/index.ts @@ -1,4 +1,4 @@ -// POP 디자이너 컴포넌트 export (v5 그리드 시스템) +// POP 디자이너 컴포넌트 export (블록 그리드 시스템) // 타입 export * from "./types"; @@ -17,11 +17,12 @@ export { default as PopRenderer } from "./renderers/PopRenderer"; // 유틸리티 export * from "./utils/gridUtils"; +export * from "./utils/legacyLoader"; // 핵심 타입 재export (편의) export type { - PopLayoutDataV5, - PopComponentDefinitionV5, + PopLayoutData, + PopComponentDefinition, PopComponentType, PopGridPosition, GridMode, diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index 32ff5e06..d79883ad 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -3,10 +3,12 @@ import React from "react"; import { cn } from "@/lib/utils"; import { - PopComponentDefinitionV5, + PopComponentDefinition, PopGridPosition, GridMode, GRID_BREAKPOINTS, + BLOCK_SIZE, + getBlockColumns, } from "../types/pop-layout"; import { Settings, @@ -31,15 +33,15 @@ import ConnectionEditor from "./ConnectionEditor"; interface ComponentEditorPanelProps { /** 선택된 컴포넌트 */ - component: PopComponentDefinitionV5 | null; + component: PopComponentDefinition | null; /** 현재 모드 */ currentMode: GridMode; /** 컴포넌트 업데이트 */ - onUpdateComponent?: (updates: Partial) => void; + onUpdateComponent?: (updates: Partial) => void; /** 추가 className */ className?: string; /** 그리드에 배치된 모든 컴포넌트 */ - allComponents?: PopComponentDefinitionV5[]; + allComponents?: PopComponentDefinition[]; /** 컴포넌트 선택 콜백 */ onSelectComponent?: (componentId: string) => void; /** 현재 선택된 컴포넌트 ID */ @@ -247,11 +249,11 @@ export default function ComponentEditorPanel({ // ======================================== interface PositionFormProps { - component: PopComponentDefinitionV5; + component: PopComponentDefinition; currentMode: GridMode; isDefaultMode: boolean; columns: number; - onUpdate?: (updates: Partial) => void; + onUpdate?: (updates: Partial) => void; } function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate }: PositionFormProps) { @@ -378,7 +380,7 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate

- 높이: {position.rowSpan * GRID_BREAKPOINTS[currentMode].rowHeight}px + 높이: {position.rowSpan * BLOCK_SIZE + (position.rowSpan - 1) * 2}px

@@ -400,13 +402,13 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate // ======================================== interface ComponentSettingsFormProps { - component: PopComponentDefinitionV5; - onUpdate?: (updates: Partial) => void; + component: PopComponentDefinition; + onUpdate?: (updates: Partial) => void; currentMode?: GridMode; previewPageIndex?: number; onPreviewPage?: (pageIndex: number) => void; modals?: PopModalDefinition[]; - allComponents?: PopComponentDefinitionV5[]; + allComponents?: PopComponentDefinition[]; connections?: PopDataConnection[]; } @@ -464,16 +466,16 @@ function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIn // ======================================== interface VisibilityFormProps { - component: PopComponentDefinitionV5; - onUpdate?: (updates: Partial) => void; + component: PopComponentDefinition; + onUpdate?: (updates: Partial) => void; } function VisibilityForm({ component, onUpdate }: VisibilityFormProps) { const modes: Array<{ key: GridMode; label: string }> = [ - { key: "tablet_landscape", label: "태블릿 가로 (12칸)" }, - { key: "tablet_portrait", label: "태블릿 세로 (8칸)" }, - { key: "mobile_landscape", label: "모바일 가로 (6칸)" }, - { key: "mobile_portrait", label: "모바일 세로 (4칸)" }, + { key: "tablet_landscape", label: `태블릿 가로 (${getBlockColumns(1024)}칸)` }, + { key: "tablet_portrait", label: `태블릿 세로 (${getBlockColumns(820)}칸)` }, + { key: "mobile_landscape", label: `모바일 가로 (${getBlockColumns(600)}칸)` }, + { key: "mobile_portrait", label: `모바일 세로 (${getBlockColumns(375)}칸)` }, ]; const handleVisibilityChange = (mode: GridMode, visible: boolean) => { diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx index 3817b54d..ddedc7d0 100644 --- a/frontend/components/pop/designer/panels/ComponentPalette.tsx +++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx @@ -3,7 +3,7 @@ import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; import { PopComponentType } from "../types/pop-layout"; -import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2 } from "lucide-react"; +import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2, ClipboardCheck } from "lucide-react"; import { DND_ITEM_TYPES } from "../constants"; // 컴포넌트 정의 @@ -93,6 +93,12 @@ const PALETTE_ITEMS: PaletteItem[] = [ icon: UserCircle, description: "사용자 프로필 / PC 전환 / 로그아웃", }, + { + type: "pop-work-detail", + label: "작업 상세", + icon: ClipboardCheck, + description: "공정별 체크리스트/검사/실적 상세 작업 화면", + }, ]; // 드래그 가능한 컴포넌트 아이템 diff --git a/frontend/components/pop/designer/panels/ConnectionEditor.tsx b/frontend/components/pop/designer/panels/ConnectionEditor.tsx index 84b56935..0a64e82a 100644 --- a/frontend/components/pop/designer/panels/ConnectionEditor.tsx +++ b/frontend/components/pop/designer/panels/ConnectionEditor.tsx @@ -13,7 +13,7 @@ import { SelectValue, } from "@/components/ui/select"; import { - PopComponentDefinitionV5, + PopComponentDefinition, PopDataConnection, } from "../types/pop-layout"; import { @@ -26,8 +26,8 @@ import { getTableColumns } from "@/lib/api/tableManagement"; // ======================================== interface ConnectionEditorProps { - component: PopComponentDefinitionV5; - allComponents: PopComponentDefinitionV5[]; + component: PopComponentDefinition; + allComponents: PopComponentDefinition[]; connections: PopDataConnection[]; onAddConnection?: (conn: Omit) => void; onUpdateConnection?: (connectionId: string, conn: Omit) => void; @@ -102,8 +102,8 @@ export default function ConnectionEditor({ // ======================================== interface SendSectionProps { - component: PopComponentDefinitionV5; - allComponents: PopComponentDefinitionV5[]; + component: PopComponentDefinition; + allComponents: PopComponentDefinition[]; outgoing: PopDataConnection[]; onAddConnection?: (conn: Omit) => void; onUpdateConnection?: (connectionId: string, conn: Omit) => void; @@ -197,15 +197,15 @@ function SendSection({ // ======================================== interface SimpleConnectionFormProps { - component: PopComponentDefinitionV5; - allComponents: PopComponentDefinitionV5[]; + component: PopComponentDefinition; + allComponents: PopComponentDefinition[]; initial?: PopDataConnection; onSubmit: (data: Omit) => void; onCancel?: () => void; submitLabel: string; } -function extractSubTableName(comp: PopComponentDefinitionV5): string | null { +function extractSubTableName(comp: PopComponentDefinition): string | null { const cfg = comp.config as Record | undefined; if (!cfg) return null; @@ -423,8 +423,8 @@ function SimpleConnectionForm({ // ======================================== interface ReceiveSectionProps { - component: PopComponentDefinitionV5; - allComponents: PopComponentDefinitionV5[]; + component: PopComponentDefinition; + allComponents: PopComponentDefinition[]; incoming: PopDataConnection[]; } diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index a9c7db6e..3af031b4 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -5,14 +5,18 @@ import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; import { DND_ITEM_TYPES } from "../constants"; import { - PopLayoutDataV5, - PopComponentDefinitionV5, + PopLayoutData, + PopComponentDefinition, PopGridPosition, GridMode, GRID_BREAKPOINTS, GridBreakpoint, detectGridMode, PopComponentType, + BLOCK_SIZE, + BLOCK_GAP, + BLOCK_PADDING, + getBlockColumns, } from "../types/pop-layout"; import { convertAndResolvePositions, @@ -27,7 +31,7 @@ import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; interface PopRendererProps { /** v5 레이아웃 데이터 */ - layout: PopLayoutDataV5; + layout: PopLayoutData; /** 현재 뷰포트 너비 */ viewportWidth: number; /** 현재 모드 (자동 감지 또는 수동 지정) */ @@ -80,6 +84,7 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-field": "입력", "pop-scanner": "스캐너", "pop-profile": "프로필", + "pop-work-detail": "작업 상세", }; // ======================================== @@ -107,18 +112,27 @@ export default function PopRenderer({ }: PopRendererProps) { const { gridConfig, components, overrides } = layout; - // 현재 모드 (자동 감지 또는 지정) + // V6: 뷰포트 너비에서 블록 칸 수 동적 계산 const mode = currentMode || detectGridMode(viewportWidth); - const breakpoint = GRID_BREAKPOINTS[mode]; + const columns = getBlockColumns(viewportWidth); - // Gap/Padding: 오버라이드 우선, 없으면 기본값 사용 - const finalGap = overrideGap !== undefined ? overrideGap : breakpoint.gap; - const finalPadding = overridePadding !== undefined ? overridePadding : breakpoint.padding; + // V6: 블록 간격 고정 + const finalGap = overrideGap !== undefined ? overrideGap : BLOCK_GAP; + const finalPadding = overridePadding !== undefined ? overridePadding : BLOCK_PADDING; + + // 하위 호환: breakpoint 객체 (ResizeHandles 등에서 사용) + const breakpoint: GridBreakpoint = { + columns, + rowHeight: BLOCK_SIZE, + gap: finalGap, + padding: finalPadding, + label: `${columns}칸 블록`, + }; // 숨김 컴포넌트 ID 목록 const hiddenIds = overrides?.[mode]?.hidden || []; - // 동적 행 수 계산 (가이드 셀 + Grid 스타일 공유, 숨김 컴포넌트 제외) + // 동적 행 수 계산 const dynamicRowCount = useMemo(() => { const visibleComps = Object.values(components).filter( comp => !hiddenIds.includes(comp.id) @@ -131,19 +145,17 @@ export default function PopRenderer({ return Math.max(10, maxRowEnd + 3); }, [components, overrides, mode, hiddenIds]); - // CSS Grid 스타일 - // 디자인 모드: 행 높이 고정 (정밀한 레이아웃 편집) - // 뷰어 모드: minmax(rowHeight, auto) (컴포넌트가 컨텐츠에 맞게 확장 가능) + // V6: CSS Grid - 열은 1fr(뷰포트 꽉 채움), 행은 고정 BLOCK_SIZE const rowTemplate = isDesignMode - ? `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)` - : `repeat(${dynamicRowCount}, minmax(${breakpoint.rowHeight}px, auto))`; + ? `repeat(${dynamicRowCount}, ${BLOCK_SIZE}px)` + : `repeat(${dynamicRowCount}, minmax(${BLOCK_SIZE}px, auto))`; const autoRowHeight = isDesignMode - ? `${breakpoint.rowHeight}px` - : `minmax(${breakpoint.rowHeight}px, auto)`; + ? `${BLOCK_SIZE}px` + : `minmax(${BLOCK_SIZE}px, auto)`; const gridStyle = useMemo((): React.CSSProperties => ({ display: "grid", - gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`, + gridTemplateColumns: `repeat(${columns}, 1fr)`, gridTemplateRows: rowTemplate, gridAutoRows: autoRowHeight, gap: `${finalGap}px`, @@ -151,15 +163,15 @@ export default function PopRenderer({ minHeight: "100%", backgroundColor: "#ffffff", position: "relative", - }), [breakpoint, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]); + }), [columns, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]); - // 그리드 가이드 셀 생성 (동적 행 수) + // 그리드 가이드 셀 생성 const gridCells = useMemo(() => { if (!isDesignMode || !showGridGuide) return []; const cells = []; for (let row = 1; row <= dynamicRowCount; row++) { - for (let col = 1; col <= breakpoint.columns; col++) { + for (let col = 1; col <= columns; col++) { cells.push({ id: `cell-${col}-${row}`, col, @@ -168,10 +180,10 @@ export default function PopRenderer({ } } return cells; - }, [isDesignMode, showGridGuide, breakpoint.columns, dynamicRowCount]); + }, [isDesignMode, showGridGuide, columns, dynamicRowCount]); // visibility 체크 - const isVisible = (comp: PopComponentDefinitionV5): boolean => { + const isVisible = (comp: PopComponentDefinition): boolean => { if (!comp.visibility) return true; const modeVisibility = comp.visibility[mode]; return modeVisibility !== false; @@ -196,7 +208,7 @@ export default function PopRenderer({ }; // 오버라이드 적용 또는 자동 재배치 - const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => { + const getEffectivePosition = (comp: PopComponentDefinition): PopGridPosition => { // 1순위: 오버라이드가 있으면 사용 const override = overrides?.[mode]?.positions?.[comp.id]; if (override) { @@ -214,7 +226,7 @@ export default function PopRenderer({ }; // 오버라이드 숨김 체크 - const isHiddenByOverride = (comp: PopComponentDefinitionV5): boolean => { + const isHiddenByOverride = (comp: PopComponentDefinition): boolean => { return overrides?.[mode]?.hidden?.includes(comp.id) ?? false; }; @@ -311,7 +323,7 @@ export default function PopRenderer({ // ======================================== interface DraggableComponentProps { - component: PopComponentDefinitionV5; + component: PopComponentDefinition; position: PopGridPosition; positionStyle: React.CSSProperties; isSelected: boolean; @@ -412,7 +424,7 @@ function DraggableComponent({ // ======================================== interface ResizeHandlesProps { - component: PopComponentDefinitionV5; + component: PopComponentDefinition; position: PopGridPosition; breakpoint: GridBreakpoint; viewportWidth: number; @@ -533,7 +545,7 @@ function ResizeHandles({ // ======================================== interface ComponentContentProps { - component: PopComponentDefinitionV5; + component: PopComponentDefinition; effectivePosition: PopGridPosition; isDesignMode: boolean; isSelected: boolean; @@ -603,7 +615,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect // ======================================== function renderActualComponent( - component: PopComponentDefinitionV5, + component: PopComponentDefinition, effectivePosition?: PopGridPosition, onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void, screenId?: string, diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index 9fb9a847..f859cf5d 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -1,6 +1,4 @@ -// POP 디자이너 레이아웃 타입 정의 -// v5.0: CSS Grid 기반 그리드 시스템 -// 2024-02 버전 통합: v1~v4 제거, v5 단일 버전 +// POP 블록 그리드 레이아웃 타입 정의 // ======================================== // 공통 타입 @@ -9,7 +7,7 @@ /** * POP 컴포넌트 타입 */ -export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile"; +export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile" | "pop-work-detail"; /** * 데이터 흐름 정의 @@ -99,24 +97,39 @@ export interface PopLayoutMetadata { } // ======================================== -// v5 그리드 기반 레이아웃 +// v6 정사각형 블록 그리드 시스템 // ======================================== -// 핵심: CSS Grid로 정확한 위치 지정 -// - 열/행 좌표로 배치 (col, row) -// - 칸 단위 크기 (colSpan, rowSpan) -// - Material Design 브레이크포인트 기반 +// 핵심: 균일한 정사각형 블록 (24px x 24px) +// - 열/행 좌표로 배치 (col, row) - 블록 단위 +// - 뷰포트 너비에 따라 칸 수 동적 계산 +// - 단일 좌표계 (모드별 변환 불필요) /** - * 그리드 모드 (4가지) + * V6 블록 상수 + */ +export const BLOCK_SIZE = 24; // 블록 크기 (px, 정사각형) +export const BLOCK_GAP = 2; // 블록 간격 (px) +export const BLOCK_PADDING = 8; // 캔버스 패딩 (px) + +/** + * 뷰포트 너비에서 블록 칸 수 계산 + */ +export function getBlockColumns(viewportWidth: number): number { + const available = viewportWidth - BLOCK_PADDING * 2; + return Math.max(1, Math.floor((available + BLOCK_GAP) / (BLOCK_SIZE + BLOCK_GAP))); +} + +/** + * 뷰포트 프리셋 (디자이너 해상도 전환용) */ export type GridMode = - | "mobile_portrait" // 4칸 - | "mobile_landscape" // 6칸 - | "tablet_portrait" // 8칸 - | "tablet_landscape"; // 12칸 (기본) + | "mobile_portrait" + | "mobile_landscape" + | "tablet_portrait" + | "tablet_landscape"; /** - * 그리드 브레이크포인트 설정 + * 뷰포트 프리셋 설정 */ export interface GridBreakpoint { minWidth?: number; @@ -129,50 +142,43 @@ export interface GridBreakpoint { } /** - * 브레이크포인트 상수 - * 업계 표준 (768px, 1024px) + 실제 기기 커버리지 기반 + * V6 브레이크포인트 (블록 기반 동적 칸 수) + * columns는 각 뷰포트 너비에서의 블록 수 */ export const GRID_BREAKPOINTS: Record = { - // 스마트폰 세로 (iPhone SE ~ Galaxy S25 Ultra) mobile_portrait: { maxWidth: 479, - columns: 4, - rowHeight: 40, - gap: 8, - padding: 12, - label: "모바일 세로 (4칸)", + columns: getBlockColumns(375), + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + label: `모바일 세로 (${getBlockColumns(375)}칸)`, }, - - // 스마트폰 가로 + 소형 태블릿 mobile_landscape: { minWidth: 480, maxWidth: 767, - columns: 6, - rowHeight: 44, - gap: 8, - padding: 16, - label: "모바일 가로 (6칸)", + columns: getBlockColumns(600), + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + label: `모바일 가로 (${getBlockColumns(600)}칸)`, }, - - // 태블릿 세로 (iPad Mini ~ iPad Pro) tablet_portrait: { minWidth: 768, maxWidth: 1023, - columns: 8, - rowHeight: 48, - gap: 12, - padding: 16, - label: "태블릿 세로 (8칸)", + columns: getBlockColumns(820), + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + label: `태블릿 세로 (${getBlockColumns(820)}칸)`, }, - - // 태블릿 가로 + 데스크톱 (기본) tablet_landscape: { minWidth: 1024, - columns: 12, - rowHeight: 48, - gap: 16, - padding: 24, - label: "태블릿 가로 (12칸)", + columns: getBlockColumns(1024), + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + label: `태블릿 가로 (${getBlockColumns(1024)}칸)`, }, } as const; @@ -183,7 +189,6 @@ export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape"; /** * 뷰포트 너비로 모드 감지 - * GRID_BREAKPOINTS와 일치하는 브레이크포인트 사용 */ export function detectGridMode(viewportWidth: number): GridMode { if (viewportWidth < 480) return "mobile_portrait"; @@ -193,31 +198,31 @@ export function detectGridMode(viewportWidth: number): GridMode { } /** - * v5 레이아웃 (그리드 기반) + * POP 레이아웃 데이터 */ -export interface PopLayoutDataV5 { +export interface PopLayoutData { version: "pop-5.0"; // 그리드 설정 gridConfig: PopGridConfig; // 컴포넌트 정의 (ID → 정의) - components: Record; + components: Record; // 데이터 흐름 dataFlow: PopDataFlow; // 전역 설정 - settings: PopGlobalSettingsV5; + settings: PopGlobalSettings; // 메타데이터 metadata?: PopLayoutMetadata; // 모드별 오버라이드 (위치 변경용) overrides?: { - mobile_portrait?: PopModeOverrideV5; - mobile_landscape?: PopModeOverrideV5; - tablet_portrait?: PopModeOverrideV5; + mobile_portrait?: PopModeOverride; + mobile_landscape?: PopModeOverride; + tablet_portrait?: PopModeOverride; }; // 모달 캔버스 목록 (버튼의 "모달 열기" 액션으로 생성) @@ -225,17 +230,17 @@ export interface PopLayoutDataV5 { } /** - * 그리드 설정 + * 그리드 설정 (V6: 블록 단위) */ export interface PopGridConfig { - // 행 높이 (px) - 1행의 기본 높이 - rowHeight: number; // 기본 48px + // 행 높이 = 블록 크기 (px) + rowHeight: number; // V6 기본 24px (= BLOCK_SIZE) // 간격 (px) - gap: number; // 기본 8px + gap: number; // V6 기본 2px (= BLOCK_GAP) // 패딩 (px) - padding: number; // 기본 16px + padding: number; // V6 기본 8px (= BLOCK_PADDING) } /** @@ -249,9 +254,9 @@ export interface PopGridPosition { } /** - * v5 컴포넌트 정의 + * POP 컴포넌트 정의 */ -export interface PopComponentDefinitionV5 { +export interface PopComponentDefinition { id: string; type: PopComponentType; label?: string; @@ -274,7 +279,7 @@ export interface PopComponentDefinitionV5 { } /** - * Gap 프리셋 타입 + * Gap 프리셋 타입 (V6: 단일 간격이므로 medium만 유효, 하위 호환용 유지) */ export type GapPreset = "narrow" | "medium" | "wide"; @@ -287,18 +292,18 @@ export interface GapPresetConfig { } /** - * Gap 프리셋 상수 + * Gap 프리셋 상수 (V6: 모두 동일 - 블록 간격 고정) */ export const GAP_PRESETS: Record = { - narrow: { multiplier: 0.5, label: "좁게" }, - medium: { multiplier: 1.0, label: "보통" }, - wide: { multiplier: 1.5, label: "넓게" }, + narrow: { multiplier: 1.0, label: "기본" }, + medium: { multiplier: 1.0, label: "기본" }, + wide: { multiplier: 1.0, label: "기본" }, }; /** - * v5 전역 설정 + * POP 전역 설정 */ -export interface PopGlobalSettingsV5 { +export interface PopGlobalSettings { // 터치 최소 크기 (px) touchTargetMin: number; // 기본 48 @@ -310,9 +315,9 @@ export interface PopGlobalSettingsV5 { } /** - * v5 모드별 오버라이드 + * 모드별 오버라이드 (위치/숨김) */ -export interface PopModeOverrideV5 { +export interface PopModeOverride { // 컴포넌트별 위치 오버라이드 positions?: Record>; @@ -321,18 +326,18 @@ export interface PopModeOverrideV5 { } // ======================================== -// v5 유틸리티 함수 +// 레이아웃 유틸리티 함수 // ======================================== /** - * 빈 v5 레이아웃 생성 + * 빈 POP 레이아웃 생성 */ -export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({ +export const createEmptyLayout = (): PopLayoutData => ({ version: "pop-5.0", gridConfig: { - rowHeight: 48, - gap: 8, - padding: 16, + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, }, components: {}, dataFlow: { connections: [] }, @@ -344,40 +349,46 @@ export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({ }); /** - * v5 레이아웃 여부 확인 + * POP 레이아웃 데이터인지 확인 */ -export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => { +export const isPopLayout = (layout: any): layout is PopLayoutData => { return layout?.version === "pop-5.0"; }; /** - * 컴포넌트 타입별 기본 크기 (칸 단위) + * 컴포넌트 타입별 기본 크기 (블록 단위, V6) + * + * 소형 (2x2) : 최소 단위. 아이콘, 프로필, 스캐너 등 단일 요소 + * 중형 (8x4) : 검색, 버튼, 텍스트 등 한 줄 입력/표시 + * 대형 (8x6) : 샘플, 상태바, 필드 등 여러 줄 컨텐츠 + * 초대형 (19x8~) : 카드, 리스트, 대시보드 등 메인 영역 */ export const DEFAULT_COMPONENT_GRID_SIZE: Record = { - "pop-sample": { colSpan: 2, rowSpan: 1 }, - "pop-text": { colSpan: 3, rowSpan: 1 }, - "pop-icon": { colSpan: 1, rowSpan: 2 }, - "pop-dashboard": { colSpan: 6, rowSpan: 3 }, - "pop-card-list": { colSpan: 4, rowSpan: 3 }, - "pop-card-list-v2": { colSpan: 4, rowSpan: 3 }, - "pop-button": { colSpan: 2, rowSpan: 1 }, - "pop-string-list": { colSpan: 4, rowSpan: 3 }, - "pop-search": { colSpan: 2, rowSpan: 1 }, - "pop-status-bar": { colSpan: 6, rowSpan: 1 }, - "pop-field": { colSpan: 6, rowSpan: 2 }, - "pop-scanner": { colSpan: 1, rowSpan: 1 }, - "pop-profile": { colSpan: 1, rowSpan: 1 }, + "pop-sample": { colSpan: 8, rowSpan: 6 }, + "pop-text": { colSpan: 8, rowSpan: 4 }, + "pop-icon": { colSpan: 2, rowSpan: 2 }, + "pop-dashboard": { colSpan: 19, rowSpan: 10 }, + "pop-card-list": { colSpan: 19, rowSpan: 10 }, + "pop-card-list-v2": { colSpan: 19, rowSpan: 10 }, + "pop-button": { colSpan: 8, rowSpan: 4 }, + "pop-string-list": { colSpan: 19, rowSpan: 10 }, + "pop-search": { colSpan: 8, rowSpan: 4 }, + "pop-status-bar": { colSpan: 19, rowSpan: 4 }, + "pop-field": { colSpan: 19, rowSpan: 6 }, + "pop-scanner": { colSpan: 2, rowSpan: 2 }, + "pop-profile": { colSpan: 2, rowSpan: 2 }, + "pop-work-detail": { colSpan: 38, rowSpan: 26 }, }; /** - * v5 컴포넌트 정의 생성 + * POP 컴포넌트 정의 생성 */ -export const createComponentDefinitionV5 = ( +export const createComponentDefinition = ( id: string, type: PopComponentType, position: PopGridPosition, label?: string -): PopComponentDefinitionV5 => ({ +): PopComponentDefinition => ({ id, type, label, @@ -385,21 +396,21 @@ export const createComponentDefinitionV5 = ( }); /** - * v5 레이아웃에 컴포넌트 추가 + * POP 레이아웃에 컴포넌트 추가 */ -export const addComponentToV5Layout = ( - layout: PopLayoutDataV5, +export const addComponentToLayout = ( + layout: PopLayoutData, componentId: string, type: PopComponentType, position: PopGridPosition, label?: string -): PopLayoutDataV5 => { +): PopLayoutData => { const newLayout = { ...layout }; // 컴포넌트 정의 추가 newLayout.components = { ...newLayout.components, - [componentId]: createComponentDefinitionV5(componentId, type, position, label), + [componentId]: createComponentDefinition(componentId, type, position, label), }; return newLayout; @@ -474,12 +485,12 @@ export interface PopModalDefinition { /** 모달 내부 그리드 설정 */ gridConfig: PopGridConfig; /** 모달 내부 컴포넌트 */ - components: Record; + components: Record; /** 모드별 오버라이드 */ overrides?: { - mobile_portrait?: PopModeOverrideV5; - mobile_landscape?: PopModeOverrideV5; - tablet_portrait?: PopModeOverrideV5; + mobile_portrait?: PopModeOverride; + mobile_landscape?: PopModeOverride; + tablet_portrait?: PopModeOverride; }; /** 모달 프레임 설정 (닫기 방식) */ frameConfig?: { @@ -495,15 +506,29 @@ export interface PopModalDefinition { } // ======================================== -// 레거시 타입 별칭 (하위 호환 - 추후 제거) +// 레거시 타입 별칭 (이전 코드 호환용) // ======================================== -// 기존 코드에서 import 오류 방지용 -/** @deprecated v5에서는 PopLayoutDataV5 사용 */ -export type PopLayoutData = PopLayoutDataV5; +/** @deprecated PopLayoutData 사용 */ +export type PopLayoutDataV5 = PopLayoutData; -/** @deprecated v5에서는 PopComponentDefinitionV5 사용 */ -export type PopComponentDefinition = PopComponentDefinitionV5; +/** @deprecated PopComponentDefinition 사용 */ +export type PopComponentDefinitionV5 = PopComponentDefinition; -/** @deprecated v5에서는 PopGridPosition 사용 */ -export type GridPosition = PopGridPosition; +/** @deprecated PopGlobalSettings 사용 */ +export type PopGlobalSettingsV5 = PopGlobalSettings; + +/** @deprecated PopModeOverride 사용 */ +export type PopModeOverrideV5 = PopModeOverride; + +/** @deprecated createEmptyLayout 사용 */ +export const createEmptyPopLayoutV5 = createEmptyLayout; + +/** @deprecated isPopLayout 사용 */ +export const isV5Layout = isPopLayout; + +/** @deprecated addComponentToLayout 사용 */ +export const addComponentToV5Layout = addComponentToLayout; + +/** @deprecated createComponentDefinition 사용 */ +export const createComponentDefinitionV5 = createComponentDefinition; diff --git a/frontend/components/pop/designer/utils/gridUtils.ts b/frontend/components/pop/designer/utils/gridUtils.ts index 308ce730..5a8895d8 100644 --- a/frontend/components/pop/designer/utils/gridUtils.ts +++ b/frontend/components/pop/designer/utils/gridUtils.ts @@ -1,217 +1,106 @@ +// POP 그리드 유틸리티 (리플로우, 겹침 해결, 위치 계산) + import { PopGridPosition, GridMode, GRID_BREAKPOINTS, - GridBreakpoint, - GapPreset, - GAP_PRESETS, - PopLayoutDataV5, - PopComponentDefinitionV5, + PopLayoutData, } from "../types/pop-layout"; // ======================================== -// Gap/Padding 조정 +// 리플로우 (행 그룹 기반 자동 재배치) // ======================================== /** - * Gap 프리셋에 따라 breakpoint의 gap/padding 조정 - * - * @param base 기본 breakpoint 설정 - * @param preset Gap 프리셋 ("narrow" | "medium" | "wide") - * @returns 조정된 breakpoint (gap, padding 계산됨) - */ -export function getAdjustedBreakpoint( - base: GridBreakpoint, - preset: GapPreset -): GridBreakpoint { - const multiplier = GAP_PRESETS[preset]?.multiplier || 1.0; - - return { - ...base, - gap: Math.round(base.gap * multiplier), - padding: Math.max(8, Math.round(base.padding * multiplier)), // 최소 8px - }; -} - -// ======================================== -// 그리드 위치 변환 -// ======================================== - -/** - * 12칸 기준 위치를 다른 모드로 변환 - */ -export function convertPositionToMode( - position: PopGridPosition, - targetMode: GridMode -): PopGridPosition { - const sourceColumns = 12; - const targetColumns = GRID_BREAKPOINTS[targetMode].columns; - - // 같은 칸 수면 그대로 반환 - if (sourceColumns === targetColumns) { - return position; - } - - const ratio = targetColumns / sourceColumns; - - // 열 위치 변환 - let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1); - let newColSpan = Math.max(1, Math.round(position.colSpan * ratio)); - - // 범위 초과 방지 - if (newCol > targetColumns) { - newCol = 1; - } - if (newCol + newColSpan - 1 > targetColumns) { - newColSpan = targetColumns - newCol + 1; - } - - return { - col: newCol, - row: position.row, - colSpan: Math.max(1, newColSpan), - rowSpan: position.rowSpan, - }; -} - -/** - * 여러 컴포넌트를 모드별로 변환하고 겹침 해결 - * - * v5.1 자동 줄바꿈: - * - 원본 col > targetColumns인 컴포넌트는 자동으로 맨 아래에 배치 - * - 정보 손실 방지: 모든 컴포넌트가 그리드 안에 배치됨 + * 행 그룹 리플로우 + * + * CSS Flexbox wrap 원리로 자동 재배치한다. + * 1. 같은 행의 컴포넌트를 한 묶음으로 처리 + * 2. 최소 2x2칸 보장 (터치 가능한 최소 크기) + * 3. 한 줄에 안 들어가면 다음 줄로 줄바꿈 (숨김 없음) + * 4. 설계 너비의 50% 이상인 컴포넌트는 전체 너비 확장 + * 5. 리플로우 후 겹침 해결 */ export function convertAndResolvePositions( components: Array<{ id: string; position: PopGridPosition }>, targetMode: GridMode ): Array<{ id: string; position: PopGridPosition }> { - // 엣지 케이스: 빈 배열 - if (components.length === 0) { - return []; - } + if (components.length === 0) return []; const targetColumns = GRID_BREAKPOINTS[targetMode].columns; + const designColumns = GRID_BREAKPOINTS["tablet_landscape"].columns; - // 1단계: 각 컴포넌트를 비율로 변환 (원본 col 보존) - const converted = components.map(comp => ({ - id: comp.id, - position: convertPositionToMode(comp.position, targetMode), - originalCol: comp.position.col, // 원본 col 보존 - })); + if (targetColumns >= designColumns) { + return components.map(c => ({ id: c.id, position: { ...c.position } })); + } - // 2단계: 정상 컴포넌트 vs 초과 컴포넌트 분리 - const normalComponents = converted.filter(c => c.originalCol <= targetColumns); - const overflowComponents = converted.filter(c => c.originalCol > targetColumns); + const ratio = targetColumns / designColumns; + const MIN_COL_SPAN = 2; + const MIN_ROW_SPAN = 2; - // 3단계: 정상 컴포넌트의 최대 row 계산 - const maxRow = normalComponents.length > 0 - ? Math.max(...normalComponents.map(c => c.position.row + c.position.rowSpan - 1)) - : 0; - - // 4단계: 초과 컴포넌트들을 맨 아래에 순차 배치 - let currentRow = maxRow + 1; - const wrappedComponents = overflowComponents.map(comp => { - const wrappedPosition: PopGridPosition = { - col: 1, // 왼쪽 끝부터 시작 - row: currentRow, - colSpan: Math.min(comp.position.colSpan, targetColumns), // 최대 칸 수 제한 - rowSpan: comp.position.rowSpan, - }; - currentRow += comp.position.rowSpan; // 다음 행으로 이동 - - return { - id: comp.id, - position: wrappedPosition, - }; + const rowGroups: Record> = {}; + components.forEach(comp => { + const r = comp.position.row; + if (!rowGroups[r]) rowGroups[r] = []; + rowGroups[r].push(comp); }); - // 5단계: 정상 + 줄바꿈 컴포넌트 병합 - const adjusted = [ - ...normalComponents.map(c => ({ id: c.id, position: c.position })), - ...wrappedComponents, - ]; + const placed: Array<{ id: string; position: PopGridPosition }> = []; + let outputRow = 1; - // 6단계: 겹침 해결 (아래로 밀기) - return resolveOverlaps(adjusted, targetColumns); -} - -// ======================================== -// 검토 필요 판별 -// ======================================== - -/** - * 컴포넌트가 현재 모드에서 "검토 필요" 상태인지 확인 - * - * v5.1 검토 필요 기준: - * - 12칸 모드(기본 모드)가 아님 - * - 해당 모드에서 오버라이드가 없음 (아직 편집 안 함) - * - * @param currentMode 현재 그리드 모드 - * @param hasOverride 해당 모드에서 오버라이드 존재 여부 - * @returns true = 검토 필요, false = 검토 완료 또는 불필요 - */ -export function needsReview( - currentMode: GridMode, - hasOverride: boolean -): boolean { - const targetColumns = GRID_BREAKPOINTS[currentMode].columns; + const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b); - // 12칸 모드는 기본 모드이므로 검토 불필요 - if (targetColumns === 12) { - return false; + for (const rowKey of sortedRows) { + const group = rowGroups[rowKey].sort((a, b) => a.position.col - b.position.col); + let currentCol = 1; + let maxRowSpanInLine = 0; + + for (const comp of group) { + const pos = comp.position; + const isMainContent = pos.colSpan >= designColumns * 0.5; + + let scaledSpan = isMainContent + ? targetColumns + : Math.max(MIN_COL_SPAN, Math.round(pos.colSpan * ratio)); + scaledSpan = Math.min(scaledSpan, targetColumns); + + const scaledRowSpan = Math.max(MIN_ROW_SPAN, pos.rowSpan); + + if (currentCol + scaledSpan - 1 > targetColumns) { + outputRow += Math.max(1, maxRowSpanInLine); + currentCol = 1; + maxRowSpanInLine = 0; + } + + placed.push({ + id: comp.id, + position: { + col: currentCol, + row: outputRow, + colSpan: scaledSpan, + rowSpan: scaledRowSpan, + }, + }); + + maxRowSpanInLine = Math.max(maxRowSpanInLine, scaledRowSpan); + currentCol += scaledSpan; + } + + outputRow += Math.max(1, maxRowSpanInLine); } - // 오버라이드가 있으면 이미 편집함 → 검토 완료 - if (hasOverride) { - return false; - } - - // 오버라이드 없으면 → 검토 필요 - return true; -} - -/** - * @deprecated v5.1부터 needsReview() 사용 권장 - * - * 기존 isOutOfBounds는 "화면 밖" 개념이었으나, - * v5.1 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 배치됩니다. - * 대신 needsReview()로 "검토 필요" 여부를 판별하세요. - */ -export function isOutOfBounds( - originalPosition: PopGridPosition, - currentMode: GridMode, - overridePosition?: PopGridPosition | null -): boolean { - const targetColumns = GRID_BREAKPOINTS[currentMode].columns; - - // 12칸 모드면 초과 불가 - if (targetColumns === 12) { - return false; - } - - // 오버라이드가 있으면 오버라이드 위치로 판단 - if (overridePosition) { - return overridePosition.col > targetColumns; - } - - // 오버라이드 없으면 원본 col로 판단 - return originalPosition.col > targetColumns; + return resolveOverlaps(placed, targetColumns); } // ======================================== // 겹침 감지 및 해결 // ======================================== -/** - * 두 위치가 겹치는지 확인 - */ export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean { - // 열 겹침 체크 const aColEnd = a.col + a.colSpan - 1; const bColEnd = b.col + b.colSpan - 1; const colOverlap = !(aColEnd < b.col || bColEnd < a.col); - // 행 겹침 체크 const aRowEnd = a.row + a.rowSpan - 1; const bRowEnd = b.row + b.rowSpan - 1; const rowOverlap = !(aRowEnd < b.row || bRowEnd < a.row); @@ -219,14 +108,10 @@ export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean { return colOverlap && rowOverlap; } -/** - * 겹침 해결 (아래로 밀기) - */ export function resolveOverlaps( positions: Array<{ id: string; position: PopGridPosition }>, columns: number ): Array<{ id: string; position: PopGridPosition }> { - // row, col 순으로 정렬 const sorted = [...positions].sort((a, b) => a.position.row - b.position.row || a.position.col - b.position.col ); @@ -236,21 +121,15 @@ export function resolveOverlaps( sorted.forEach((item) => { let { row, col, colSpan, rowSpan } = item.position; - // 열이 범위를 초과하면 조정 if (col + colSpan - 1 > columns) { colSpan = columns - col + 1; } - // 기존 배치와 겹치면 아래로 이동 let attempts = 0; - const maxAttempts = 100; - - while (attempts < maxAttempts) { + while (attempts < 100) { const currentPos: PopGridPosition = { col, row, colSpan, rowSpan }; const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position)); - if (!hasOverlap) break; - row++; attempts++; } @@ -265,124 +144,9 @@ export function resolveOverlaps( } // ======================================== -// 좌표 변환 +// 자동 배치 (새 컴포넌트 드롭 시) // ======================================== -/** - * 마우스 좌표 → 그리드 좌표 변환 - * - * CSS Grid 계산 방식: - * - 사용 가능 너비 = 캔버스 너비 - 패딩*2 - gap*(columns-1) - * - 각 칸 너비 = 사용 가능 너비 / columns - * - 셀 N의 시작 X = padding + (N-1) * (칸너비 + gap) - */ -export function mouseToGridPosition( - mouseX: number, - mouseY: number, - canvasRect: DOMRect, - columns: number, - rowHeight: number, - gap: number, - padding: number -): { col: number; row: number } { - // 캔버스 내 상대 위치 (패딩 영역 포함) - const relX = mouseX - canvasRect.left - padding; - const relY = mouseY - canvasRect.top - padding; - - // CSS Grid 1fr 계산과 동일하게 - // 사용 가능 너비 = 전체 너비 - 양쪽 패딩 - (칸 사이 gap) - const availableWidth = canvasRect.width - padding * 2 - gap * (columns - 1); - const colWidth = availableWidth / columns; - - // 각 셀의 실제 간격 (셀 너비 + gap) - const cellStride = colWidth + gap; - - // 그리드 좌표 계산 (1부터 시작) - // relX를 cellStride로 나누면 몇 번째 칸인지 알 수 있음 - const col = Math.max(1, Math.min(columns, Math.floor(relX / cellStride) + 1)); - const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1); - - return { col, row }; -} - -/** - * 그리드 좌표 → 픽셀 좌표 변환 - */ -export function gridToPixelPosition( - col: number, - row: number, - colSpan: number, - rowSpan: number, - canvasWidth: number, - columns: number, - rowHeight: number, - gap: number, - padding: number -): { x: number; y: number; width: number; height: number } { - const totalGap = gap * (columns - 1); - const colWidth = (canvasWidth - padding * 2 - totalGap) / columns; - - return { - x: padding + (col - 1) * (colWidth + gap), - y: padding + (row - 1) * (rowHeight + gap), - width: colWidth * colSpan + gap * (colSpan - 1), - height: rowHeight * rowSpan + gap * (rowSpan - 1), - }; -} - -// ======================================== -// 위치 검증 -// ======================================== - -/** - * 위치가 그리드 범위 내에 있는지 확인 - */ -export function isValidPosition( - position: PopGridPosition, - columns: number -): boolean { - return ( - position.col >= 1 && - position.row >= 1 && - position.colSpan >= 1 && - position.rowSpan >= 1 && - position.col + position.colSpan - 1 <= columns - ); -} - -/** - * 위치를 그리드 범위 내로 조정 - */ -export function clampPosition( - position: PopGridPosition, - columns: number -): PopGridPosition { - let { col, row, colSpan, rowSpan } = position; - - // 최소값 보장 - col = Math.max(1, col); - row = Math.max(1, row); - colSpan = Math.max(1, colSpan); - rowSpan = Math.max(1, rowSpan); - - // 열 범위 초과 방지 - if (col + colSpan - 1 > columns) { - if (col > columns) { - col = 1; - } - colSpan = columns - col + 1; - } - - return { col, row, colSpan, rowSpan }; -} - -// ======================================== -// 자동 배치 -// ======================================== - -/** - * 다음 빈 위치 찾기 - */ export function findNextEmptyPosition( existingPositions: PopGridPosition[], colSpan: number, @@ -391,168 +155,94 @@ export function findNextEmptyPosition( ): PopGridPosition { let row = 1; let col = 1; - - const maxAttempts = 1000; let attempts = 0; - while (attempts < maxAttempts) { + while (attempts < 1000) { const candidatePos: PopGridPosition = { col, row, colSpan, rowSpan }; - // 범위 체크 if (col + colSpan - 1 > columns) { col = 1; row++; continue; } - // 겹침 체크 - const hasOverlap = existingPositions.some(pos => - isOverlapping(candidatePos, pos) - ); + const hasOverlap = existingPositions.some(pos => isOverlapping(candidatePos, pos)); + if (!hasOverlap) return candidatePos; - if (!hasOverlap) { - return candidatePos; - } - - // 다음 위치로 이동 col++; if (col + colSpan - 1 > columns) { col = 1; row++; } - attempts++; } - // 실패 시 마지막 행에 배치 return { col: 1, row: row + 1, colSpan, rowSpan }; } -/** - * 컴포넌트들을 자동으로 배치 - */ -export function autoLayoutComponents( - components: Array<{ id: string; colSpan: number; rowSpan: number }>, - columns: number -): Array<{ id: string; position: PopGridPosition }> { - const result: Array<{ id: string; position: PopGridPosition }> = []; - - let currentRow = 1; - let currentCol = 1; - - components.forEach(comp => { - // 현재 행에 공간이 부족하면 다음 행으로 - if (currentCol + comp.colSpan - 1 > columns) { - currentRow++; - currentCol = 1; - } - - result.push({ - id: comp.id, - position: { - col: currentCol, - row: currentRow, - colSpan: comp.colSpan, - rowSpan: comp.rowSpan, - }, - }); - - currentCol += comp.colSpan; - }); - - return result; -} - // ======================================== -// 유효 위치 계산 (통합 함수) +// 유효 위치 계산 // ======================================== /** - * 컴포넌트의 유효 위치를 계산합니다. + * 컴포넌트의 유효 위치를 계산한다. * 우선순위: 1. 오버라이드 → 2. 자동 재배치 → 3. 원본 위치 - * - * @param componentId 컴포넌트 ID - * @param layout 전체 레이아웃 데이터 - * @param mode 현재 그리드 모드 - * @param autoResolvedPositions 미리 계산된 자동 재배치 위치 (선택적) */ -export function getEffectiveComponentPosition( +function getEffectiveComponentPosition( componentId: string, - layout: PopLayoutDataV5, + layout: PopLayoutData, mode: GridMode, autoResolvedPositions?: Array<{ id: string; position: PopGridPosition }> ): PopGridPosition | null { const component = layout.components[componentId]; if (!component) return null; - // 1순위: 오버라이드가 있으면 사용 const override = layout.overrides?.[mode]?.positions?.[componentId]; if (override) { return { ...component.position, ...override }; } - // 2순위: 자동 재배치된 위치 사용 if (autoResolvedPositions) { const autoResolved = autoResolvedPositions.find(p => p.id === componentId); - if (autoResolved) { - return autoResolved.position; - } + if (autoResolved) return autoResolved.position; } else { - // 자동 재배치 직접 계산 const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({ id, position: comp.position, })); const resolved = convertAndResolvePositions(componentsArray, mode); const autoResolved = resolved.find(p => p.id === componentId); - if (autoResolved) { - return autoResolved.position; - } + if (autoResolved) return autoResolved.position; } - // 3순위: 원본 위치 (12칸 모드) return component.position; } /** - * 모든 컴포넌트의 유효 위치를 일괄 계산합니다. - * 숨김 처리된 컴포넌트는 제외됩니다. - * - * v5.1: 자동 줄바꿈 시스템으로 인해 모든 컴포넌트가 그리드 안에 배치되므로 - * "화면 밖" 개념이 제거되었습니다. + * 모든 컴포넌트의 유효 위치를 일괄 계산한다. + * 숨김 처리된 컴포넌트는 제외. */ export function getAllEffectivePositions( - layout: PopLayoutDataV5, + layout: PopLayoutData, mode: GridMode ): Map { const result = new Map(); - // 숨김 처리된 컴포넌트 ID 목록 const hiddenIds = layout.overrides?.[mode]?.hidden || []; - // 자동 재배치 위치 미리 계산 const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({ id, position: comp.position, })); const autoResolvedPositions = convertAndResolvePositions(componentsArray, mode); - // 각 컴포넌트의 유효 위치 계산 Object.keys(layout.components).forEach(componentId => { - // 숨김 처리된 컴포넌트는 제외 - if (hiddenIds.includes(componentId)) { - return; - } + if (hiddenIds.includes(componentId)) return; const position = getEffectiveComponentPosition( - componentId, - layout, - mode, - autoResolvedPositions + componentId, layout, mode, autoResolvedPositions ); - // v5.1: 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 있음 - // 따라서 추가 필터링 불필요 if (position) { result.set(componentId, position); } diff --git a/frontend/components/pop/designer/utils/legacyLoader.ts b/frontend/components/pop/designer/utils/legacyLoader.ts new file mode 100644 index 00000000..42cf20d7 --- /dev/null +++ b/frontend/components/pop/designer/utils/legacyLoader.ts @@ -0,0 +1,128 @@ +// 레거시 레이아웃 로더 +// DB에 저장된 V5(12칸) 좌표를 현재 블록 좌표로 변환한다. +// DB 데이터는 건드리지 않고, 로드 시 메모리에서만 변환. + +import { + PopGridPosition, + PopLayoutData, + BLOCK_SIZE, + BLOCK_GAP, + BLOCK_PADDING, + getBlockColumns, +} from "../types/pop-layout"; + +const LEGACY_COLUMNS = 12; +const LEGACY_ROW_HEIGHT = 48; +const LEGACY_GAP = 16; +const DESIGN_WIDTH = 1024; + +function isLegacyGridConfig(layout: PopLayoutData): boolean { + if (layout.gridConfig?.rowHeight === BLOCK_SIZE) return false; + + const maxCol = Object.values(layout.components).reduce((max, comp) => { + const end = comp.position.col + comp.position.colSpan - 1; + return Math.max(max, end); + }, 0); + + return maxCol <= LEGACY_COLUMNS; +} + +function convertLegacyPosition( + pos: PopGridPosition, + targetColumns: number, +): PopGridPosition { + const colRatio = targetColumns / LEGACY_COLUMNS; + const rowRatio = (LEGACY_ROW_HEIGHT + LEGACY_GAP) / (BLOCK_SIZE + BLOCK_GAP); + + const newCol = Math.max(1, Math.round((pos.col - 1) * colRatio) + 1); + let newColSpan = Math.max(1, Math.round(pos.colSpan * colRatio)); + const newRowSpan = Math.max(1, Math.round(pos.rowSpan * rowRatio)); + + if (newCol + newColSpan - 1 > targetColumns) { + newColSpan = targetColumns - newCol + 1; + } + + return { col: newCol, row: pos.row, colSpan: newColSpan, rowSpan: newRowSpan }; +} + +const BLOCK_GRID_CONFIG = { + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, +}; + +/** + * DB에서 로드한 레이아웃을 현재 블록 좌표로 변환한다. + * + * - 12칸 레거시 좌표 → 블록 좌표 변환 + * - 이미 블록 좌표인 경우 → gridConfig만 보정 + * - 구 모드별 overrides는 항상 제거 (리플로우가 대체) + */ +export function loadLegacyLayout(layout: PopLayoutData): PopLayoutData { + if (!isLegacyGridConfig(layout)) { + return { + ...layout, + gridConfig: BLOCK_GRID_CONFIG, + overrides: undefined, + }; + } + + const blockColumns = getBlockColumns(DESIGN_WIDTH); + + const rowGroups: Record = {}; + Object.entries(layout.components).forEach(([id, comp]) => { + const r = comp.position.row; + if (!rowGroups[r]) rowGroups[r] = []; + rowGroups[r].push(id); + }); + + const convertedPositions: Record = {}; + Object.entries(layout.components).forEach(([id, comp]) => { + convertedPositions[id] = convertLegacyPosition(comp.position, blockColumns); + }); + + const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b); + const rowMapping: Record = {}; + let currentRow = 1; + for (const legacyRow of sortedRows) { + rowMapping[legacyRow] = currentRow; + const maxSpan = Math.max( + ...rowGroups[legacyRow].map(id => convertedPositions[id].rowSpan) + ); + currentRow += maxSpan; + } + + const newComponents = { ...layout.components }; + Object.entries(newComponents).forEach(([id, comp]) => { + const converted = convertedPositions[id]; + const mappedRow = rowMapping[comp.position.row] ?? converted.row; + newComponents[id] = { + ...comp, + position: { ...converted, row: mappedRow }, + }; + }); + + const newModals = layout.modals?.map(modal => { + const modalComps = { ...modal.components }; + Object.entries(modalComps).forEach(([id, comp]) => { + modalComps[id] = { + ...comp, + position: convertLegacyPosition(comp.position, blockColumns), + }; + }); + return { + ...modal, + gridConfig: BLOCK_GRID_CONFIG, + components: modalComps, + overrides: undefined, + }; + }); + + return { + ...layout, + gridConfig: BLOCK_GRID_CONFIG, + components: newComponents, + overrides: undefined, + modals: newModals, + }; +} diff --git a/frontend/components/pop/viewer/PopViewerWithModals.tsx b/frontend/components/pop/viewer/PopViewerWithModals.tsx index f322d4c0..cc29697b 100644 --- a/frontend/components/pop/viewer/PopViewerWithModals.tsx +++ b/frontend/components/pop/viewer/PopViewerWithModals.tsx @@ -20,7 +20,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import PopRenderer from "../designer/renderers/PopRenderer"; -import type { PopLayoutDataV5, PopModalDefinition, GridMode } from "../designer/types/pop-layout"; +import type { PopLayoutData, PopModalDefinition, GridMode } from "../designer/types/pop-layout"; import { detectGridMode, resolveModalWidth } from "../designer/types/pop-layout"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver"; @@ -31,7 +31,7 @@ import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver"; interface PopViewerWithModalsProps { /** 전체 레이아웃 (모달 정의 포함) */ - layout: PopLayoutDataV5; + layout: PopLayoutData; /** 뷰포트 너비 */ viewportWidth: number; /** 화면 ID (이벤트 버스용) */ @@ -42,12 +42,15 @@ interface PopViewerWithModalsProps { overrideGap?: number; /** Padding 오버라이드 */ overridePadding?: number; + /** 부모 화면에서 선택된 행 데이터 (모달 내부 컴포넌트가 sharedData로 조회) */ + parentRow?: Record; } /** 열린 모달 상태 */ interface OpenModal { definition: PopModalDefinition; returnTo?: string; + fullscreen?: boolean; } // ======================================== @@ -61,10 +64,17 @@ export default function PopViewerWithModals({ currentMode, overrideGap, overridePadding, + parentRow, }: PopViewerWithModalsProps) { const router = useRouter(); const [modalStack, setModalStack] = useState([]); - const { subscribe, publish } = usePopEvent(screenId); + const { subscribe, publish, setSharedData } = usePopEvent(screenId); + + useEffect(() => { + if (parentRow) { + setSharedData("parentRow", parentRow); + } + }, [parentRow, setSharedData]); // 연결 해석기: layout에 정의된 connections를 이벤트 라우팅으로 변환 const stableConnections = useMemo( @@ -96,6 +106,7 @@ export default function PopViewerWithModals({ title?: string; mode?: string; returnTo?: string; + fullscreen?: boolean; }; if (data?.modalId) { @@ -104,6 +115,7 @@ export default function PopViewerWithModals({ setModalStack(prev => [...prev, { definition: modalDef, returnTo: data.returnTo, + fullscreen: data.fullscreen, }]); } } @@ -173,22 +185,27 @@ export default function PopViewerWithModals({ {/* 모달 스택 렌더링 */} {modalStack.map((modal, index) => { - const { definition } = modal; + const { definition, fullscreen } = modal; const isTopModal = index === modalStack.length - 1; const closeOnOverlay = definition.frameConfig?.closeOnOverlay !== false; const closeOnEsc = definition.frameConfig?.closeOnEsc !== false; - const modalLayout: PopLayoutDataV5 = { + const modalLayout: PopLayoutData = { ...layout, gridConfig: definition.gridConfig, components: definition.components, overrides: definition.overrides, }; - const detectedMode = currentMode || detectGridMode(viewportWidth); - const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth); - const isFull = modalWidth >= viewportWidth; - const rendererWidth = isFull ? viewportWidth : modalWidth - 32; + const isFull = fullscreen || (() => { + const detectedMode = currentMode || detectGridMode(viewportWidth); + const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth); + return modalWidth >= viewportWidth; + })(); + const rendererWidth = isFull + ? viewportWidth + : resolveModalWidth(definition.sizeConfig, currentMode || detectGridMode(viewportWidth), viewportWidth) - 32; + const modalWidth = isFull ? viewportWidth : resolveModalWidth(definition.sizeConfig, currentMode || detectGridMode(viewportWidth), viewportWidth); return ( { - // 최상위 모달이 아니면 overlay 클릭 무시 (하위 모달이 먼저 닫히는 것 방지) if (!isTopModal || !closeOnOverlay) e.preventDefault(); }} onEscapeKeyDown={(e) => { if (!isTopModal || !closeOnEsc) e.preventDefault(); }} > - + {definition.title} diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 4726cf39..d23337b5 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -583,7 +583,7 @@ const RealtimePreviewDynamicComponent: React.FC = ({ const needsStripBorder = isV2HorizLabel || isButtonComponent; const safeComponentStyle = needsStripBorder ? (() => { - const { borderWidth, borderColor, borderStyle, border, borderRadius, ...rest } = componentStyle as any; + const { borderWidth, borderColor, borderStyle, border, ...rest } = componentStyle as any; return rest; })() : componentStyle; diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index 626bbbe3..901ca667 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -764,7 +764,7 @@ export const V2Input = forwardRef((props, ref) => // 채번 코드 생성 (formDataRef.current 사용하여 최신 formData 전달) const currentFormData = formDataRef.current; - const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData); + const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData, manualInputValue || undefined); if (previewResponse.success && previewResponse.data?.generatedCode) { const generatedCode = previewResponse.data.generatedCode; @@ -852,6 +852,49 @@ export const V2Input = forwardRef((props, ref) => }; }, [columnName, manualInputValue, propsInputType, config.inputType, config.type]); + // 수동 입력값 변경 시 디바운스로 순번 미리보기 갱신 + useEffect(() => { + const inputType = propsInputType || config.inputType || config.type || "text"; + if (inputType !== "numbering") return; + if (!numberingTemplateRef.current?.includes("____")) return; + + const ruleId = numberingRuleIdRef.current; + if (!ruleId) return; + + // 사용자가 한 번도 입력하지 않은 초기 상태면 스킵 + if (!userEditedNumberingRef.current) return; + + const debounceTimer = setTimeout(async () => { + try { + const currentFormData = formDataRef.current; + const resp = await previewNumberingCode(ruleId, currentFormData, manualInputValue || undefined); + + if (resp.success && resp.data?.generatedCode) { + const newTemplate = resp.data.generatedCode; + if (newTemplate.includes("____")) { + numberingTemplateRef.current = newTemplate; + + const parts = newTemplate.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.length > 1 ? parts.slice(1).join("") : ""; + const combined = prefix + manualInputValue + suffix; + + setAutoGeneratedValue(combined); + onChange?.(combined); + if (onFormDataChange && columnName) { + onFormDataChange(columnName, combined); + } + } + } + } catch { + /* 미리보기 실패 시 기존 suffix 유지 */ + } + }, 300); + + return () => clearTimeout(debounceTimer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [manualInputValue]); + // 실제 표시할 값 (자동생성 값 또는 props value) const displayValue = autoGeneratedValue ?? value; diff --git a/frontend/components/v2/config-panels/V2TimelineSchedulerConfigPanel.tsx b/frontend/components/v2/config-panels/V2TimelineSchedulerConfigPanel.tsx index 11815db8..44065912 100644 --- a/frontend/components/v2/config-panels/V2TimelineSchedulerConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2TimelineSchedulerConfigPanel.tsx @@ -15,11 +15,11 @@ import { Badge } from "@/components/ui/badge"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; -import { Settings, ChevronDown, Check, ChevronsUpDown, Database, Users, Layers } from "lucide-react"; +import { Settings, ChevronDown, Check, ChevronsUpDown, Database, Users, Layers, Filter, Link, Zap, Trash2, Plus, GripVertical } from "lucide-react"; import { cn } from "@/lib/utils"; import { tableTypeApi } from "@/lib/api/screen"; -import type { TimelineSchedulerConfig, ScheduleType, SourceDataConfig, ResourceFieldMapping, FieldMapping, ZoomLevel } from "@/lib/registry/components/v2-timeline-scheduler/types"; -import { zoomLevelOptions, scheduleTypeOptions } from "@/lib/registry/components/v2-timeline-scheduler/config"; +import type { TimelineSchedulerConfig, ScheduleType, SourceDataConfig, ResourceFieldMapping, FieldMapping, ZoomLevel, ToolbarAction } from "@/lib/registry/components/v2-timeline-scheduler/types"; +import { zoomLevelOptions, scheduleTypeOptions, viewModeOptions, dataSourceOptions, toolbarIconOptions } from "@/lib/registry/components/v2-timeline-scheduler/config"; interface V2TimelineSchedulerConfigPanelProps { config: TimelineSchedulerConfig; @@ -49,10 +49,16 @@ export const V2TimelineSchedulerConfigPanel: React.FC(null); useEffect(() => { const loadTables = async () => { @@ -225,6 +231,31 @@ export const V2TimelineSchedulerConfigPanel: React.FC
+ {/* 뷰 모드 */} +
+
+

표시 모드

+

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

+
+ +
+ {/* 커스텀 테이블 사용 여부 */}
@@ -470,6 +501,210 @@ export const V2TimelineSchedulerConfigPanel: React.FC + {/* ─── 필터 & 연동 설정 ─── */} + + + + + +
+ {/* 정적 필터 */} +
+

정적 필터 (staticFilters)

+

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

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

+ + 연결 필터 (linkedFilter) +

+

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

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

선택 이벤트의 tableName 매칭

+
+ + + + + + value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0}> + + + 없음 + + {tables.map((table) => ( + { + updateConfig({ + linkedFilter: { ...config.linkedFilter!, sourceTableName: table.tableName }, + }); + setLinkedFilterTableOpen(false); + }} + className="text-xs" + > + + {table.displayName} + + ))} + + + + + +
+ +
+ 소스 필드 (sourceField) * + updateConfig({ linkedFilter: { ...config.linkedFilter!, sourceField: e.target.value } })} + placeholder="예: part_code" + className="h-7 w-[140px] text-xs" + /> +
+ +
+ 타겟 필드 (targetField) * + updateConfig({ linkedFilter: { ...config.linkedFilter!, targetField: e.target.value } })} + placeholder="예: item_code" + className="h-7 w-[140px] text-xs" + /> +
+ +
+ 빈 상태 메시지 + updateConfig({ linkedFilter: { ...config.linkedFilter!, emptyMessage: e.target.value } })} + placeholder="선택 안내 문구" + className="h-7 w-[180px] text-xs" + /> +
+ +
+ 선택 없을 때 빈 화면 + updateConfig({ linkedFilter: { ...config.linkedFilter!, showEmptyWhenNoSelection: v } })} + /> +
+
+ )} +
+
+ + + {/* ─── 2단계: 소스 데이터 설정 ─── */} @@ -1038,6 +1273,17 @@ export const V2TimelineSchedulerConfigPanel: React.FC updateConfig({ showAddButton: v })} />
+ +
+
+

범례 표시

+

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

+
+ updateConfig({ showLegend: v })} + /> +
@@ -1114,6 +1360,405 @@ export const V2TimelineSchedulerConfigPanel: React.FC + {/* ─── 6단계: 툴바 액션 설정 ─── */} + + + + + +
+

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

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

API 설정

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

다이얼로그

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

데이터 소스

+
+
+ 데이터 소스 유형 * + +
+ + {action.dataSource === "linkedSelection" && ( +
+
+
+ 그룹 필드 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, groupByField: e.target.value || undefined } }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="linkedFilter.sourceField 사용" + className="h-7 text-xs" + /> +
+
+ 수량 필드 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, quantityField: e.target.value || undefined } }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="balance_qty" + className="h-7 text-xs" + /> +
+
+
+
+ 기준일 필드 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, dueDateField: e.target.value || undefined } }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="due_date" + className="h-7 text-xs" + /> +
+
+ 표시명 필드 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, nameField: e.target.value || undefined } }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="part_name" + className="h-7 text-xs" + /> +
+
+
+ )} + + {action.dataSource === "currentSchedules" && ( +
+
+
+ 필터 필드 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, scheduleFilterField: e.target.value || undefined } }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="product_type" + className="h-7 text-xs" + /> +
+
+ 필터 값 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, scheduleFilterValue: e.target.value || undefined } }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="완제품" + className="h-7 text-xs" + /> +
+
+
+ )} +
+
+ + {/* 표시 조건 */} +
+

표시 조건 (showWhen)

+

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

+ {Object.entries(action.showWhen || {}).map(([key, value]) => ( +
+ + = + + +
+ ))} +
+ + = + + +
+
+
+
+
+
+ ))} + + {/* 액션 추가 버튼 */} + +
+ +
); }; diff --git a/frontend/lib/api/numberingRule.ts b/frontend/lib/api/numberingRule.ts index b0ec38e2..01f0a321 100644 --- a/frontend/lib/api/numberingRule.ts +++ b/frontend/lib/api/numberingRule.ts @@ -105,6 +105,7 @@ export async function deleteNumberingRule(ruleId: string): Promise, + manualInputValue?: string, ): Promise> { // ruleId 유효성 검사 if (!ruleId || ruleId === "undefined" || ruleId === "null") { @@ -114,6 +115,7 @@ export async function previewNumberingCode( try { const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`, { formData: formData || {}, + manualInputValue, }); if (!response.data) { return { success: false, error: "서버 응답이 비어있습니다" }; diff --git a/frontend/lib/api/production.ts b/frontend/lib/api/production.ts new file mode 100644 index 00000000..244ef426 --- /dev/null +++ b/frontend/lib/api/production.ts @@ -0,0 +1,178 @@ +/** + * 생산계획 API 클라이언트 + */ + +import apiClient from "./client"; + +// ─── 타입 정의 ─── + +export interface OrderSummaryItem { + item_code: string; + item_name: string; + total_order_qty: number; + total_ship_qty: number; + total_balance_qty: number; + order_count: number; + earliest_due_date: string | null; + current_stock: number; + safety_stock: number; + existing_plan_qty: number; + in_progress_qty: number; + required_plan_qty: number; + orders: OrderDetail[]; +} + +export interface OrderDetail { + id: string; + order_no: string; + part_code: string; + part_name: string; + order_qty: number; + ship_qty: number; + balance_qty: number; + due_date: string | null; + status: string; + customer_name: string | null; +} + +export interface StockShortageItem { + item_code: string; + item_name: string; + current_qty: number; + safety_qty: number; + shortage_qty: number; + recommended_qty: number; + last_in_date: string | null; +} + +export interface ProductionPlan { + id: number; + company_code: string; + plan_no: string; + plan_date: string; + item_code: string; + item_name: string; + product_type: string; + plan_qty: number; + completed_qty: number; + progress_rate: number; + start_date: string; + end_date: string; + due_date: string | null; + equipment_id: number | null; + equipment_code: string | null; + equipment_name: string | null; + status: string; + priority: string | null; + order_no: string | null; + parent_plan_id: number | null; + remarks: string | null; +} + +export interface GenerateScheduleRequest { + items: { + item_code: string; + item_name: string; + required_qty: number; + earliest_due_date: string; + hourly_capacity?: number; + daily_capacity?: number; + lead_time?: number; + }[]; + options?: { + safety_lead_time?: number; + recalculate_unstarted?: boolean; + product_type?: string; + }; +} + +export interface GenerateScheduleResponse { + summary: { + total: number; + new_count: number; + kept_count: number; + deleted_count: number; + }; + schedules: ProductionPlan[]; +} + +// ─── API 함수 ─── + +/** 수주 데이터 조회 (품목별 그룹핑) */ +export async function getOrderSummary(params?: { + excludePlanned?: boolean; + itemCode?: string; + itemName?: string; +}) { + const queryParams = new URLSearchParams(); + if (params?.excludePlanned) queryParams.set("excludePlanned", "true"); + if (params?.itemCode) queryParams.set("itemCode", params.itemCode); + if (params?.itemName) queryParams.set("itemName", params.itemName); + + const qs = queryParams.toString(); + const url = `/api/production/order-summary${qs ? `?${qs}` : ""}`; + const response = await apiClient.get(url); + return response.data as { success: boolean; data: OrderSummaryItem[] }; +} + +/** 안전재고 부족분 조회 */ +export async function getStockShortage() { + const response = await apiClient.get("/api/production/stock-shortage"); + return response.data as { success: boolean; data: StockShortageItem[] }; +} + +/** 생산계획 상세 조회 */ +export async function getPlanById(planId: number) { + const response = await apiClient.get(`/api/production/plan/${planId}`); + return response.data as { success: boolean; data: ProductionPlan }; +} + +/** 생산계획 수정 */ +export async function updatePlan(planId: number, data: Partial) { + const response = await apiClient.put(`/api/production/plan/${planId}`, data); + return response.data as { success: boolean; data: ProductionPlan }; +} + +/** 생산계획 삭제 */ +export async function deletePlan(planId: number) { + const response = await apiClient.delete(`/api/production/plan/${planId}`); + return response.data as { success: boolean; message: string }; +} + +/** 자동 스케줄 생성 */ +export async function generateSchedule(request: GenerateScheduleRequest) { + const response = await apiClient.post("/api/production/generate-schedule", request); + return response.data as { success: boolean; data: GenerateScheduleResponse }; +} + +/** 스케줄 병합 */ +export async function mergeSchedules(scheduleIds: number[], productType?: string) { + const response = await apiClient.post("/api/production/merge-schedules", { + schedule_ids: scheduleIds, + product_type: productType || "완제품", + }); + return response.data as { success: boolean; data: ProductionPlan }; +} + +/** 반제품 계획 자동 생성 */ +export async function generateSemiSchedule( + planIds: number[], + options?: { considerStock?: boolean; excludeUsed?: boolean } +) { + const response = await apiClient.post("/api/production/generate-semi-schedule", { + plan_ids: planIds, + options: options || {}, + }); + return response.data as { success: boolean; data: { count: number; schedules: ProductionPlan[] } }; +} + +/** 스케줄 분할 */ +export async function splitSchedule(planId: number, splitQty: number) { + const response = await apiClient.post(`/api/production/plan/${planId}/split`, { + split_qty: splitQty, + }); + return response.data as { + success: boolean; + data: { original: { id: number; plan_qty: number }; split: ProductionPlan }; + }; +} diff --git a/frontend/lib/button-icon-map.tsx b/frontend/lib/button-icon-map.tsx index 4ddd2f7d..d18e6297 100644 --- a/frontend/lib/button-icon-map.tsx +++ b/frontend/lib/button-icon-map.tsx @@ -19,11 +19,12 @@ import { Send, Radio, Megaphone, Podcast, BellRing, Copy, ClipboardCopy, Files, CopyPlus, ClipboardList, Clipboard, SquareMousePointer, + icons as allLucideIcons, type LucideIcon, } from "lucide-react"; // --------------------------------------------------------------------------- -// 아이콘 이름 → 컴포넌트 매핑 (추천 아이콘만 명시적 import) +// 아이콘 이름 → 컴포넌트 매핑 (추천 아이콘은 명시적 import, 나머지는 동적 조회) // --------------------------------------------------------------------------- export const iconMap: Record = { Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck, @@ -109,15 +110,27 @@ export function getIconSizeStyle(size: string | number): React.CSSProperties { // --------------------------------------------------------------------------- // 아이콘 조회 / 동적 등록 +// iconMap에 없으면 lucide-react 전체 아이콘에서 동적 조회 후 캐싱 // --------------------------------------------------------------------------- export function getLucideIcon(name: string): LucideIcon | undefined { - return iconMap[name]; + if (iconMap[name]) return iconMap[name]; + + const found = allLucideIcons[name as keyof typeof allLucideIcons]; + if (found) { + iconMap[name] = found; + return found; + } + + return undefined; } export function addToIconMap(name: string, component: LucideIcon): void { iconMap[name] = component; } +// ButtonConfigPanel 등에서 전체 아이콘 검색용으로 사용 +export { allLucideIcons }; + // --------------------------------------------------------------------------- // SVG 정화 // --------------------------------------------------------------------------- diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index b5e8852f..597c759a 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -2670,7 +2670,7 @@ export const SplitPanelLayoutComponent: React.FC {formatCellValue( col.name, @@ -2732,7 +2732,7 @@ export const SplitPanelLayoutComponent: React.FC {formatCellValue( col.name, @@ -3415,7 +3415,7 @@ export const SplitPanelLayoutComponent: React.FC {formatCellValue( col.name, diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index babec53c..119cca53 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -379,12 +379,33 @@ export const TableListComponent: React.FC = ({ } }, [tableConfig.selectedTable, currentUserId]); - // columnVisibility 변경 시 컬럼 순서 및 가시성 적용 + // columnVisibility 변경 시 컬럼 순서, 가시성, 너비 적용 useEffect(() => { if (columnVisibility.length > 0) { const newOrder = columnVisibility.map((cv) => cv.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 제외 setColumnOrder(newOrder); + // 너비 적용 + const newWidths: Record = {}; + columnVisibility.forEach((cv) => { + if (cv.width) { + newWidths[cv.columnName] = cv.width; + } + }); + if (Object.keys(newWidths).length > 0) { + setColumnWidths((prev) => ({ ...prev, ...newWidths })); + + // table_column_widths_* localStorage도 동기화 (초기 너비 로드 시 올바른 값 사용) + if (tableConfig.selectedTable && userId) { + const widthsKey = `table_column_widths_${tableConfig.selectedTable}_${userId}`; + try { + const existing = localStorage.getItem(widthsKey); + const merged = existing ? { ...JSON.parse(existing), ...newWidths } : newWidths; + localStorage.setItem(widthsKey, JSON.stringify(merged)); + } catch { /* ignore */ } + } + } + // localStorage에 저장 (사용자별) if (tableConfig.selectedTable && currentUserId) { const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`; diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index fa0cfaae..26a5d7c4 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -502,15 +502,22 @@ export const ButtonPrimaryComponent: React.FC = ({ if (component.style?.backgroundColor) { return component.style.backgroundColor; } - // 4순위: style.labelColor (레거시) + // 4순위: componentConfig.style.backgroundColor + if (componentConfig.style?.backgroundColor) { + return componentConfig.style.backgroundColor; + } + // 5순위: style.labelColor (레거시 호환) if (component.style?.labelColor) { return component.style.labelColor; } - // 기본값: 삭제 버튼이면 빨강, 아니면 파랑 - if (isDeleteAction()) { - return "#ef4444"; // 빨간색 (Tailwind red-500) - } - return "#3b82f6"; // 파란색 (Tailwind blue-500) + // 6순위: 액션별 기본 배경색 + const excelActions = ["excel_download", "excel_upload", "multi_table_excel_upload"]; + const actionType = typeof componentConfig.action === "string" + ? componentConfig.action + : componentConfig.action?.type || ""; + if (actionType === "delete") return "#F04544"; + if (excelActions.includes(actionType)) return "#212121"; + return "#3B83F6"; }; const getButtonTextColor = () => { @@ -570,6 +577,8 @@ export const ButtonPrimaryComponent: React.FC = ({ ...restComponentStyle, width: "100%", height: "100%", + borderRadius: _br || "0.5rem", + overflow: "hidden", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/v2-button-primary/config.ts b/frontend/lib/registry/components/v2-button-primary/config.ts index 06f73556..66ff9173 100644 --- a/frontend/lib/registry/components/v2-button-primary/config.ts +++ b/frontend/lib/registry/components/v2-button-primary/config.ts @@ -6,16 +6,29 @@ import { ButtonPrimaryConfig } from "./types"; * ButtonPrimary 컴포넌트 기본 설정 */ export const ButtonPrimaryDefaultConfig: ButtonPrimaryConfig = { - text: "버튼", + text: "저장", actionType: "button", - variant: "primary", - - // 공통 기본값 + variant: "default", + size: "md", disabled: false, required: false, readonly: false, - variant: "default", - size: "md", + displayMode: "icon-text", + icon: { + name: "Check", + type: "lucide", + size: "보통", + }, + iconTextPosition: "right", + iconGap: 6, + style: { + borderRadius: "8px", + labelColor: "#FFFFFF", + fontSize: "12px", + fontWeight: "normal", + labelTextAlign: "left", + backgroundColor: "#3B83F6", + }, }; /** diff --git a/frontend/lib/registry/components/v2-button-primary/index.ts b/frontend/lib/registry/components/v2-button-primary/index.ts index 44600ee5..2aa4844c 100644 --- a/frontend/lib/registry/components/v2-button-primary/index.ts +++ b/frontend/lib/registry/components/v2-button-primary/index.ts @@ -26,8 +26,24 @@ export const V2ButtonPrimaryDefinition = createComponentDefinition({ successMessage: "저장되었습니다.", errorMessage: "저장 중 오류가 발생했습니다.", }, + displayMode: "icon-text", + icon: { + name: "Check", + type: "lucide", + size: "보통", + }, + iconTextPosition: "right", + iconGap: 6, + style: { + borderRadius: "8px", + labelColor: "#FFFFFF", + fontSize: "12px", + fontWeight: "normal", + labelTextAlign: "left", + backgroundColor: "#3B83F6", + }, }, - defaultSize: { width: 120, height: 40 }, + defaultSize: { width: 100, height: 40 }, configPanel: V2ButtonConfigPanel, icon: "MousePointer", tags: ["버튼", "액션", "클릭"], diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 0f54b4f2..c89fb1d3 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -3607,7 +3607,7 @@ export const SplitPanelLayoutComponent: React.FC {formatCellValue( col.name, @@ -3704,7 +3704,7 @@ export const SplitPanelLayoutComponent: React.FC {formatCellValue( col.name, @@ -4201,7 +4201,7 @@ export const SplitPanelLayoutComponent: React.FC onClick={() => toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)} > {tabSummaryColumns.map((col: any) => ( - + {col.type === "progress" ? renderProgressCell(col, item, selectedLeftItem) : formatCellValue( @@ -4317,7 +4317,7 @@ export const SplitPanelLayoutComponent: React.FC onClick={() => toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)} > {listSummaryColumns.map((col: any) => ( - + {col.type === "progress" ? renderProgressCell(col, item, selectedLeftItem) : formatCellValue( @@ -4384,9 +4384,8 @@ export const SplitPanelLayoutComponent: React.FC } })() ) : componentConfig.rightPanel?.displayMode === "custom" ? ( - // 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 - // 실행 모드에서 좌측 미선택 시 안내 메시지 표시 - !isDesignMode && !selectedLeftItem ? ( + // 커스텀 모드: alwaysShow가 아닌 경우에만 좌측 선택 필요 + !isDesignMode && !selectedLeftItem && !componentConfig.rightPanel?.alwaysShow ? (

좌측에서 항목을 선택하세요

@@ -4710,8 +4709,8 @@ export const SplitPanelLayoutComponent: React.FC {columnsToShow.map((col, colIdx) => ( {col.type === "progress" ? renderProgressCell(col, item, selectedLeftItem) @@ -4857,7 +4856,7 @@ export const SplitPanelLayoutComponent: React.FC onClick={() => toggleRightItemExpansion(itemId)} > {columnsToDisplay.map((col) => ( - + {formatCellValue( col.name, getEntityJoinValue(item, col.name), diff --git a/frontend/lib/registry/components/v2-split-panel-layout/types.ts b/frontend/lib/registry/components/v2-split-panel-layout/types.ts index ed41f578..5b87a82e 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/types.ts +++ b/frontend/lib/registry/components/v2-split-panel-layout/types.ts @@ -237,6 +237,7 @@ export interface SplitPanelLayoutConfig { customTableName?: string; // 사용자 지정 테이블명 (useCustomTable이 true일 때) dataSource?: string; displayMode?: "list" | "table" | "custom"; // 표시 모드: 목록, 테이블, 또는 커스텀 + alwaysShow?: boolean; // true면 좌측 선택 없이도 우측 패널 항상 표시 // 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 (탭 컴포넌트와 동일 구조) components?: PanelInlineComponent[]; showSearch?: boolean; 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-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 65833fa9..15b7a13b 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -521,12 +521,33 @@ export const TableListComponent: React.FC = ({ } }, [tableConfig.selectedTable, currentUserId]); - // columnVisibility 변경 시 컬럼 순서 및 가시성 적용 + // columnVisibility 변경 시 컬럼 순서, 가시성, 너비 적용 useEffect(() => { if (columnVisibility.length > 0) { const newOrder = columnVisibility.map((cv) => cv.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 제외 setColumnOrder(newOrder); + // 너비 적용 + const newWidths: Record = {}; + columnVisibility.forEach((cv) => { + if (cv.width) { + newWidths[cv.columnName] = cv.width; + } + }); + if (Object.keys(newWidths).length > 0) { + setColumnWidths((prev) => ({ ...prev, ...newWidths })); + + // table_column_widths_* localStorage도 동기화 (초기 너비 로드 시 올바른 값 사용) + if (tableConfig.selectedTable && userId) { + const widthsKey = `table_column_widths_${tableConfig.selectedTable}_${userId}`; + try { + const existing = localStorage.getItem(widthsKey); + const merged = existing ? { ...JSON.parse(existing), ...newWidths } : newWidths; + localStorage.setItem(widthsKey, JSON.stringify(merged)); + } catch { /* ignore */ } + } + } + // localStorage에 저장 (사용자별) if (tableConfig.selectedTable && currentUserId) { const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`; diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx index f6fbaea2..075e8eca 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, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ChevronLeft, ChevronRight, @@ -9,19 +9,36 @@ import { Loader2, ZoomIn, ZoomOut, + Package, + Zap, + RefreshCw, + Download, + Upload, + Play, + FileText, + Send, + Sparkles, + Wand2, } from "lucide-react"; -import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { TimelineSchedulerComponentProps, ScheduleItem, ZoomLevel, - DragEvent, - ResizeEvent, + ToolbarAction, } from "./types"; import { useTimelineData } from "./hooks/useTimelineData"; -import { TimelineHeader, ResourceRow } 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 메인 컴포넌트 @@ -45,22 +62,59 @@ export function TimelineSchedulerComponent({ }: TimelineSchedulerComponentProps) { const containerRef = useRef(null); - // 드래그/리사이즈 상태 - const [dragState, setDragState] = useState<{ - schedule: ScheduleItem; - startX: number; - startY: number; + // ────────── 툴바 액션 다이얼로그 상태 (통합) ────────── + const [actionDialog, setActionDialog] = useState<{ + actionId: string; + action: ToolbarAction; + isLoading: boolean; + isApplying: boolean; + summary: any; + previews: any[]; + deletedSchedules: any[]; + keptSchedules: any[]; + preparedPayload: any; } | null>(null); + const linkedFilterValuesRef = useRef([]); - const [resizeState, setResizeState] = useState<{ - schedule: ScheduleItem; - direction: "start" | "end"; - startX: number; - } | null>(null); + // ────────── 아이콘 맵 ────────── + const TOOLBAR_ICONS: Record> = useMemo(() => ({ + Zap, Package, Plus, Download, Upload, RefreshCw, Play, FileText, Send, Sparkles, Wand2, + }), []); + + // ────────── 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, @@ -72,59 +126,62 @@ 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; // 설정값 - const rowHeight = config.rowHeight || defaultTimelineSchedulerConfig.rowHeight!; - const headerHeight = config.headerHeight || defaultTimelineSchedulerConfig.headerHeight!; + const rowHeight = + config.rowHeight || defaultTimelineSchedulerConfig.rowHeight!; + const headerHeight = + config.headerHeight || defaultTimelineSchedulerConfig.headerHeight!; const resourceColumnWidth = - config.resourceColumnWidth || defaultTimelineSchedulerConfig.resourceColumnWidth!; - const cellWidthConfig = config.cellWidth || defaultTimelineSchedulerConfig.cellWidth!; + config.resourceColumnWidth || + defaultTimelineSchedulerConfig.resourceColumnWidth!; + const cellWidthConfig = + config.cellWidth || defaultTimelineSchedulerConfig.cellWidth!; const cellWidth = cellWidthConfig[zoomLevel] || 60; - // 리소스가 없으면 스케줄의 resourceId로 자동 생성 + // 리소스 자동 생성 (리소스 테이블 미설정 시 스케줄 데이터에서 추출) const effectiveResources = useMemo(() => { - if (resources.length > 0) { - return resources; - } + if (resources.length > 0) return resources; - // 스케줄에서 고유한 resourceId 추출하여 자동 리소스 생성 const uniqueResourceIds = new Set(); - schedules.forEach((schedule) => { - if (schedule.resourceId) { - uniqueResourceIds.add(schedule.resourceId); - } + schedules.forEach((s) => { + if (s.resourceId) uniqueResourceIds.add(s.resourceId); }); - return Array.from(uniqueResourceIds).map((id) => ({ - id, - name: id, // resourceId를 이름으로 사용 - })); + return Array.from(uniqueResourceIds).map((id) => ({ id, name: id })); }, [resources, schedules]); // 리소스별 스케줄 그룹화 const schedulesByResource = useMemo(() => { const grouped = new Map(); - effectiveResources.forEach((resource) => { - grouped.set(resource.id, []); - }); + effectiveResources.forEach((r) => grouped.set(r.id, [])); schedules.forEach((schedule) => { const list = grouped.get(schedule.resourceId); if (list) { list.push(schedule); } else { - // 리소스가 없는 스케줄은 첫 번째 리소스에 할당 const firstResource = effectiveResources[0]; if (firstResource) { - const firstList = grouped.get(firstResource.id); - if (firstList) { - firstList.push(schedule); - } + grouped.get(firstResource.id)?.push(schedule); } } }); @@ -132,27 +189,31 @@ export function TimelineSchedulerComponent({ return grouped; }, [schedules, effectiveResources]); - // 줌 레벨 변경 + // ────────── 충돌 감지 ────────── + const conflictIds = useMemo(() => { + if (config.showConflicts === false) return new Set(); + return detectConflicts(schedules); + }, [schedules, config.showConflicts]); + + // ────────── 줌 레벨 변경 ────────── const handleZoomIn = useCallback(() => { const levels: ZoomLevel[] = ["month", "week", "day"]; - const currentIdx = levels.indexOf(zoomLevel); - if (currentIdx < levels.length - 1) { - setZoomLevel(levels[currentIdx + 1]); - } + const idx = levels.indexOf(zoomLevel); + if (idx < levels.length - 1) setZoomLevel(levels[idx + 1]); }, [zoomLevel, setZoomLevel]); const handleZoomOut = useCallback(() => { const levels: ZoomLevel[] = ["month", "week", "day"]; - const currentIdx = levels.indexOf(zoomLevel); - if (currentIdx > 0) { - setZoomLevel(levels[currentIdx - 1]); - } + const idx = levels.indexOf(zoomLevel); + if (idx > 0) setZoomLevel(levels[idx - 1]); }, [zoomLevel, setZoomLevel]); - // 스케줄 클릭 핸들러 + // ────────── 스케줄 클릭 ────────── const handleScheduleClick = useCallback( (schedule: ScheduleItem) => { - const resource = effectiveResources.find((r) => r.id === schedule.resourceId); + const resource = effectiveResources.find( + (r) => r.id === schedule.resourceId + ); if (resource && onScheduleClick) { onScheduleClick({ schedule, resource }); } @@ -160,7 +221,7 @@ export function TimelineSchedulerComponent({ [effectiveResources, onScheduleClick] ); - // 빈 셀 클릭 핸들러 + // ────────── 빈 셀 클릭 ────────── const handleCellClick = useCallback( (resourceId: string, date: Date) => { if (onCellClick) { @@ -173,47 +234,111 @@ export function TimelineSchedulerComponent({ [onCellClick] ); - // 드래그 시작 - const handleDragStart = useCallback( - (schedule: ScheduleItem, e: React.MouseEvent) => { - setDragState({ - schedule, - startX: e.clientX, - startY: e.clientY, - }); + // ────────── 드래그 완료 (핵심 로직) ────────── + const handleDragComplete = useCallback( + async (schedule: ScheduleItem, deltaX: number) => { + // 줌 레벨에 따라 1셀당 일수가 달라짐 + let daysPerCell = 1; + if (zoomLevel === "week") daysPerCell = 7; + if (zoomLevel === "month") daysPerCell = 30; + + const deltaDays = Math.round((deltaX / cellWidth) * daysPerCell); + if (deltaDays === 0) return; + + const newStartDate = addDaysToDateString(schedule.startDate, deltaDays); + const newEndDate = addDaysToDateString(schedule.endDate, deltaDays); + + try { + await updateSchedule(schedule.id, { + startDate: newStartDate, + endDate: newEndDate, + }); + + // 외부 이벤트 핸들러 호출 + onDragEnd?.({ + scheduleId: schedule.id, + newStartDate, + newEndDate, + }); + + toast.success("스케줄 이동 완료", { + description: `${schedule.title}: ${newStartDate} ~ ${newEndDate}`, + }); + } catch (err: any) { + toast.error("스케줄 이동 실패", { + description: err.message || "잠시 후 다시 시도해주세요", + }); + } }, - [] + [cellWidth, zoomLevel, updateSchedule, onDragEnd] ); - // 드래그 종료 - const handleDragEnd = useCallback(() => { - if (dragState) { - // TODO: 드래그 결과 계산 및 업데이트 - setDragState(null); - } - }, [dragState]); + // ────────── 리사이즈 완료 (핵심 로직) ────────── + const handleResizeComplete = useCallback( + async ( + schedule: ScheduleItem, + direction: "start" | "end", + deltaX: number + ) => { + let daysPerCell = 1; + if (zoomLevel === "week") daysPerCell = 7; + if (zoomLevel === "month") daysPerCell = 30; - // 리사이즈 시작 - const handleResizeStart = useCallback( - (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => { - setResizeState({ - schedule, - direction, - startX: e.clientX, - }); + const deltaDays = Math.round((deltaX / cellWidth) * daysPerCell); + if (deltaDays === 0) return; + + let newStartDate = schedule.startDate; + let newEndDate = schedule.endDate; + + if (direction === "start") { + newStartDate = addDaysToDateString(schedule.startDate, deltaDays); + // 시작일이 종료일을 넘지 않도록 + if (new Date(newStartDate) >= new Date(newEndDate)) { + toast.warning("시작일은 종료일보다 이전이어야 합니다"); + return; + } + } else { + newEndDate = addDaysToDateString(schedule.endDate, deltaDays); + // 종료일이 시작일보다 앞서지 않도록 + if (new Date(newEndDate) <= new Date(newStartDate)) { + toast.warning("종료일은 시작일보다 이후여야 합니다"); + return; + } + } + + try { + await updateSchedule(schedule.id, { + startDate: newStartDate, + endDate: newEndDate, + }); + + onResizeEnd?.({ + scheduleId: schedule.id, + newStartDate, + newEndDate, + direction, + }); + + const days = + Math.round( + (new Date(newEndDate).getTime() - + new Date(newStartDate).getTime()) / + (1000 * 60 * 60 * 24) + ) + 1; + + toast.success("기간 변경 완료", { + description: `${schedule.title}: ${days}일 (${newStartDate} ~ ${newEndDate})`, + }); + } catch (err: any) { + toast.error("기간 변경 실패", { + description: err.message || "잠시 후 다시 시도해주세요", + }); + } }, - [] + [cellWidth, zoomLevel, updateSchedule, onResizeEnd] ); - // 리사이즈 종료 - const handleResizeEnd = useCallback(() => { - if (resizeState) { - // TODO: 리사이즈 결과 계산 및 업데이트 - setResizeState(null); - } - }, [resizeState]); - - // 추가 버튼 클릭 + // ────────── 추가 버튼 클릭 ────────── const handleAddClick = useCallback(() => { if (onAddSchedule && effectiveResources.length > 0) { onAddSchedule( @@ -223,14 +348,177 @@ export function TimelineSchedulerComponent({ } }, [onAddSchedule, effectiveResources]); - // 디자인 모드 플레이스홀더 + // ────────── 유효 툴바 액션 (config 기반 또는 하위호환 자동생성) ────────── + const effectiveToolbarActions: ToolbarAction[] = useMemo(() => { + if (config.toolbarActions && config.toolbarActions.length > 0) { + return config.toolbarActions; + } + return []; + }, [config.toolbarActions]); + + // ────────── 범용 액션: 미리보기 요청 ────────── + const handleActionPreview = useCallback(async (action: ToolbarAction) => { + let payload: any; + + if (action.dataSource === "linkedSelection") { + const selectedRows = linkedFilterValuesRef.current; + if (!selectedRows || selectedRows.length === 0) { + toast.warning("좌측에서 항목을 선택해주세요"); + return; + } + + const groupField = action.payloadConfig?.groupByField || config.linkedFilter?.sourceField || "part_code"; + const qtyField = action.payloadConfig?.quantityField || config.sourceConfig?.quantityField || "balance_qty"; + const dateField = action.payloadConfig?.dueDateField || config.sourceConfig?.dueDateField || "due_date"; + const nameField = action.payloadConfig?.nameField || config.sourceConfig?.groupNameField || "part_name"; + + const grouped = new Map(); + selectedRows.forEach((row: any) => { + const key = row[groupField] || ""; + if (!key) return; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key)!.push(row); + }); + + const items = Array.from(grouped.entries()).map(([code, rows]) => { + const totalQty = rows.reduce((sum: number, r: any) => sum + (Number(r[qtyField]) || 0), 0); + const dates = rows.map((r: any) => r[dateField]).filter(Boolean).sort(); + const earliestDate = dates[0] || new Date().toISOString().split("T")[0]; + const first = rows[0]; + return { + item_code: code, + item_name: first[nameField] || first.item_name || code, + required_qty: totalQty, + earliest_due_date: typeof earliestDate === "string" ? earliestDate.split("T")[0] : earliestDate, + 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; + } + + payload = { + items, + options: { + ...(config.staticFilters || {}), + ...(action.payloadConfig?.extraOptions || {}), + }, + }; + } else if (action.dataSource === "currentSchedules") { + let targetSchedules = schedules; + const filterField = action.payloadConfig?.scheduleFilterField; + const filterValue = action.payloadConfig?.scheduleFilterValue; + + if (filterField && filterValue) { + targetSchedules = schedules.filter((s) => { + const val = (s.data as any)?.[filterField] || ""; + return val === filterValue; + }); + } + + if (targetSchedules.length === 0) { + toast.warning("대상 스케줄이 없습니다"); + return; + } + + const planIds = targetSchedules.map((s) => Number(s.id)).filter((id) => !isNaN(id)); + if (planIds.length === 0) { + toast.warning("유효한 스케줄 ID가 없습니다"); + return; + } + + payload = { + plan_ids: planIds, + options: action.payloadConfig?.extraOptions || {}, + }; + } + + setActionDialog({ + actionId: action.id, + action, + isLoading: true, + isApplying: false, + summary: null, + previews: [], + deletedSchedules: [], + keptSchedules: [], + preparedPayload: payload, + }); + + try { + const response = await apiClient.post(action.previewApi, payload); + if (response.data?.success) { + setActionDialog((prev) => prev ? { + ...prev, + isLoading: false, + summary: response.data.data.summary, + previews: response.data.data.previews || [], + deletedSchedules: response.data.data.deletedSchedules || [], + keptSchedules: response.data.data.keptSchedules || [], + } : null); + } else { + toast.error("미리보기 생성 실패", { description: response.data?.message }); + setActionDialog(null); + } + } catch (err: any) { + toast.error("미리보기 요청 실패", { description: err.message }); + setActionDialog(null); + } + }, [config.linkedFilter, config.staticFilters, config.sourceConfig, schedules]); + + // ────────── 범용 액션: 확인 및 적용 ────────── + const handleActionApply = useCallback(async () => { + if (!actionDialog) return; + const { action, preparedPayload } = actionDialog; + + setActionDialog((prev) => prev ? { ...prev, isApplying: true } : null); + + try { + const response = await apiClient.post(action.applyApi, preparedPayload); + if (response.data?.success) { + const data = response.data.data; + const summary = data.summary || data; + toast.success(action.dialogTitle || "완료", { + description: `신규: ${summary.new_count || summary.count || 0}건${summary.kept_count ? `, 유지: ${summary.kept_count}건` : ""}${summary.deleted_count ? `, 삭제: ${summary.deleted_count}건` : ""}`, + }); + setActionDialog(null); + refreshTimeline(); + } else { + toast.error("실행 실패", { description: response.data?.message }); + } + } catch (err: any) { + toast.error("실행 실패", { description: err.message }); + } finally { + setActionDialog((prev) => prev ? { ...prev, isApplying: false } : null); + } + }, [actionDialog, refreshTimeline]); + + // ────────── 하단 영역 높이 계산 (툴바 + 범례) ────────── + const showToolbar = config.showToolbar !== false; + const showLegend = config.showLegend !== false; + + // ────────── 가상 스크롤 ────────── + 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) { return ( -
+
- -

타임라인 스케줄러

-

+ +

타임라인 스케줄러

+

{config.selectedTable ? `테이블: ${config.selectedTable}` : "테이블을 선택하세요"} @@ -240,48 +528,92 @@ export function TimelineSchedulerComponent({ ); } - // 로딩 상태 + // ────────── 로딩 상태 ────────── if (isLoading) { return (

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

오류 발생

-

{error}

+

오류 발생

+

{error}

); } - // 스케줄 데이터 없음 - 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 (
- -

스케줄 데이터가 없습니다

-

- 좌측 테이블에서 품목을 선택하거나,
+ +

+ 스케줄 데이터가 없습니다 +

+

+ 좌측 테이블에서 품목을 선택하거나, +
스케줄 생성 버튼을 눌러 스케줄을 생성하세요

@@ -289,35 +621,204 @@ export function TimelineSchedulerComponent({ ); } + // ────────── 품목 그룹 모드 렌더링 ────────── + if (config.viewMode === "itemGrouped") { + const itemGroups = groupSchedulesByItem(schedules); + + return ( +
+ {/* 툴바: 액션 버튼 + 네비게이션 */} + {showToolbar && ( +
+ {/* 네비게이션 */} +
+ {config.showNavigation !== false && ( + <> + + + + + )} + + {viewStartDate.toLocaleDateString("ko-KR")} ~ {viewEndDate.toLocaleDateString("ko-KR")} + +
+ + {/* 줌 + 액션 버튼 */} +
+ {config.showZoomControls !== false && ( + <> + + + {zoomLevelOptions.find((o) => o.value === zoomLevel)?.label} + + +
+ + )} + + + {effectiveToolbarActions.map((action) => { + if (action.showWhen) { + const matches = Object.entries(action.showWhen).every( + ([key, value]) => config.staticFilters?.[key] === value + ); + if (!matches) return null; + } + const IconComp = TOOLBAR_ICONS[action.icon || "Zap"] || Zap; + return ( + + ); + })} +
+
+ )} + + {/* 범례 */} + {showLegend && ( +
+ 생산 상태: + {statusOptions.map((s) => ( +
+
+ {s.label} +
+ ))} + 납기: +
+
+ 납기일 +
+
+ )} + + {/* 품목별 카드 목록 또는 빈 상태 */} + {itemGroups.length > 0 ? ( +
+ {itemGroups.map((group) => ( + + ))} +
+ ) : ( +
+
+ {hasLinkedFilter && !hasReceivedSelection ? ( + <> + +

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

+

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

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

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

+

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

+ + ) : ( + <> + +

스케줄 데이터가 없습니다

+ + )} +
+
+ )} + + {/* 범용 액션 미리보기 다이얼로그 */} + {actionDialog && ( + { if (!open) setActionDialog(null); }} + isLoading={actionDialog.isLoading} + summary={actionDialog.summary} + previews={actionDialog.previews} + deletedSchedules={actionDialog.deletedSchedules} + keptSchedules={actionDialog.keptSchedules} + onConfirm={handleActionApply} + isApplying={actionDialog.isApplying} + title={actionDialog.action.dialogTitle} + description={actionDialog.action.dialogDescription} + /> + )} +
+ ); + } + + // ────────── 메인 렌더링 (리소스 기반) ────────── return (
{/* 툴바 */} - {config.showToolbar !== false && ( -
+ {showToolbar && ( +
{/* 네비게이션 */} -
+
{config.showNavigation !== false && ( <> @@ -325,46 +826,56 @@ export function TimelineSchedulerComponent({ variant="ghost" size="sm" onClick={goToNext} - className="h-7 px-2" + className="h-6 px-1.5 sm:h-7 sm:px-2" > - + )} - {/* 현재 날짜 범위 표시 */} - + {/* 날짜 범위 표시 */} + {viewStartDate.getFullYear()}년 {viewStartDate.getMonth() + 1}월{" "} - {viewStartDate.getDate()}일 ~{" "} - {viewEndDate.getMonth() + 1}월 {viewEndDate.getDate()}일 + {viewStartDate.getDate()}일 ~ {viewEndDate.getMonth() + 1}월{" "} + {viewEndDate.getDate()}일
{/* 오른쪽 컨트롤 */} -
+
+ {/* 충돌 카운트 표시 */} + {config.showConflicts !== false && conflictIds.size > 0 && ( + + 충돌 {conflictIds.size}건 + + )} + {/* 줌 컨트롤 */} {config.showZoomControls !== false && ( -
+
- - {zoomLevelOptions.find((o) => o.value === zoomLevel)?.label} + + { + zoomLevelOptions.find((o) => o.value === zoomLevel) + ?.label + }
)} @@ -375,9 +886,9 @@ export function TimelineSchedulerComponent({ variant="default" size="sm" onClick={handleAddClick} - className="h-7" + className="h-6 text-xs sm:h-7 sm:text-sm" > - + 추가 )} @@ -385,15 +896,8 @@ export function TimelineSchedulerComponent({
)} - {/* 타임라인 본문 */} -
+ {/* 타임라인 본문 (스크롤 영역) */} +
{/* 헤더 */} - {/* 리소스 행들 */} -
- {effectiveResources.map((resource) => ( - - ))} -
+ {/* 리소스 행들 - 30개 이상이면 가상 스크롤 */} + {useVirtual ? ( +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const resource = effectiveResources[virtualRow.index]; + return ( +
+ +
+ ); + })} +
+ ) : ( +
+ {effectiveResources.map((resource) => ( + + ))} +
+ )}
+ + {/* 범례 */} + {showLegend && ( +
+ +
+ )}
); } 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/ResourceRow.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/ResourceRow.tsx index 407bdd14..4e248cd6 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/components/ResourceRow.tsx +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/ResourceRow.tsx @@ -2,54 +2,44 @@ import React, { useMemo } from "react"; import { cn } from "@/lib/utils"; -import { Resource, ScheduleItem, ZoomLevel, TimelineSchedulerConfig } from "../types"; +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; - /** 스케줄 클릭 */ + /** 충돌 스케줄 ID 목록 */ + conflictIds?: Set; 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; + /** 드래그 완료: deltaX(픽셀) 전달 */ + onDragComplete?: (schedule: ScheduleItem, deltaX: number) => void; + /** 리사이즈 완료: direction + deltaX(픽셀) 전달 */ + onResizeComplete?: ( + schedule: ScheduleItem, + direction: "start" | "end", + deltaX: number + ) => 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; }; @@ -64,20 +54,18 @@ export function ResourceRow({ cellWidth, resourceColumnWidth, config, + conflictIds, onScheduleClick, onCellClick, - onDragStart, - onDragEnd, - onResizeStart, - onResizeEnd, + onDragComplete, + onResizeComplete, }: ResourceRowProps) { - // 총 셀 개수 - const totalCells = useMemo(() => getCellCount(startDate, endDate), [startDate, endDate]); - - // 총 그리드 너비 + 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); @@ -92,21 +80,26 @@ export function ResourceRow({ 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); + const width = Math.max( + cellWidth, + (visibleEndOffset - visibleStartOffset) * cellWidth + ); + + // 시작일 = 종료일이면 마일스톤 + const isMilestone = schedule.startDate === schedule.endDate; return { schedule, + isMilestone, position: { left: resourceColumnWidth + left, top: 0, @@ -115,9 +108,15 @@ export function ResourceRow({ }, }; }); - }, [schedules, startDate, cellWidth, resourceColumnWidth, rowHeight, totalCells]); + }, [ + schedules, + startDate, + cellWidth, + resourceColumnWidth, + rowHeight, + totalCells, + ]); - // 그리드 셀 클릭 핸들러 const handleGridClick = (e: React.MouseEvent) => { if (!onCellClick) return; @@ -138,13 +137,15 @@ export function ResourceRow({ > {/* 리소스 컬럼 */}
-
{resource.name}
+
+ {resource.name} +
{resource.group && ( -
+
{resource.group}
)} @@ -162,7 +163,8 @@ export function ResourceRow({ {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 isWeekend = + cellDate.getDay() === 0 || cellDate.getDay() === 6; const isToday = cellDate.getTime() === today.getTime(); const isMonthStart = cellDate.getDate() === 1; @@ -170,7 +172,7 @@ export function ResourceRow({
{/* 스케줄 바들 */} - {schedulePositions.map(({ schedule, position }) => ( + {schedulePositions.map(({ schedule, position, isMilestone }) => ( onScheduleClick?.(schedule)} - onDragStart={onDragStart} - onDragEnd={onDragEnd} - onResizeStart={onResizeStart} - onResizeEnd={onResizeEnd} + onDragComplete={onDragComplete} + onResizeComplete={onResizeComplete} /> ))}
diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/ScheduleBar.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/ScheduleBar.tsx index a85c457c..678dc3ac 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/components/ScheduleBar.tsx +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/ScheduleBar.tsx @@ -2,79 +2,99 @@ import React, { useState, useCallback, useRef } from "react"; import { cn } from "@/lib/utils"; -import { ScheduleItem, ScheduleBarPosition, TimelineSchedulerConfig } from "../types"; +import { AlertTriangle } from "lucide-react"; +import { + ScheduleItem, + ScheduleBarPosition, + TimelineSchedulerConfig, +} from "../types"; import { statusOptions } from "../config"; interface ScheduleBarProps { - /** 스케줄 항목 */ schedule: ScheduleItem; - /** 위치 정보 */ position: ScheduleBarPosition; - /** 설정 */ config: TimelineSchedulerConfig; - /** 드래그 가능 여부 */ draggable?: boolean; - /** 리사이즈 가능 여부 */ resizable?: boolean; - /** 클릭 이벤트 */ + hasConflict?: boolean; + isMilestone?: 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; + /** 드래그 완료 시 deltaX(픽셀) 전달 */ + onDragComplete?: (schedule: ScheduleItem, deltaX: number) => void; + /** 리사이즈 완료 시 direction과 deltaX(픽셀) 전달 */ + onResizeComplete?: ( + schedule: ScheduleItem, + direction: "start" | "end", + deltaX: number + ) => void; } +// 드래그/리사이즈 판정 최소 이동 거리 (px) +const MIN_MOVE_THRESHOLD = 5; + export function ScheduleBar({ schedule, position, config, draggable = true, resizable = true, + hasConflict = false, + isMilestone = false, onClick, - onDragStart, - onDragEnd, - onResizeStart, - onResizeEnd, + onDragComplete, + onResizeComplete, }: ScheduleBarProps) { const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); + const [dragOffset, setDragOffset] = useState(0); + const [resizeOffset, setResizeOffset] = useState(0); + const [resizeDir, setResizeDir] = useState<"start" | "end">("end"); const barRef = useRef(null); + const startXRef = useRef(0); + const movedRef = useRef(false); - // 상태에 따른 색상 - const statusColor = schedule.color || + 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 progressWidth = + config.showProgress && schedule.progress !== undefined + ? `${schedule.progress}%` + : "0%"; - // 드래그 시작 핸들러 + const isEditable = config.editable !== false; + + // ────────── 드래그 핸들러 ────────── const handleMouseDown = useCallback( (e: React.MouseEvent) => { - if (!draggable || isResizing) return; + if (!draggable || isResizing || !isEditable) return; e.preventDefault(); e.stopPropagation(); + + startXRef.current = e.clientX; + movedRef.current = false; setIsDragging(true); - onDragStart?.(schedule, e); + setDragOffset(0); const handleMouseMove = (moveEvent: MouseEvent) => { - // 드래그 중 로직은 부모에서 처리 + const delta = moveEvent.clientX - startXRef.current; + if (Math.abs(delta) > MIN_MOVE_THRESHOLD) { + movedRef.current = true; + } + setDragOffset(delta); }; - const handleMouseUp = () => { + const handleMouseUp = (upEvent: MouseEvent) => { + const finalDelta = upEvent.clientX - startXRef.current; setIsDragging(false); - onDragEnd?.(); + setDragOffset(0); + + if (movedRef.current && Math.abs(finalDelta) > MIN_MOVE_THRESHOLD) { + onDragComplete?.(schedule, finalDelta); + } + document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; @@ -82,25 +102,39 @@ export function ScheduleBar({ document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, - [draggable, isResizing, schedule, onDragStart, onDragEnd] + [draggable, isResizing, isEditable, schedule, onDragComplete] ); - // 리사이즈 시작 핸들러 - const handleResizeStart = useCallback( + // ────────── 리사이즈 핸들러 ────────── + const handleResizeMouseDown = useCallback( (direction: "start" | "end", e: React.MouseEvent) => { - if (!resizable) return; + if (!resizable || !isEditable) return; e.preventDefault(); e.stopPropagation(); + + startXRef.current = e.clientX; + movedRef.current = false; setIsResizing(true); - onResizeStart?.(schedule, direction, e); + setResizeOffset(0); + setResizeDir(direction); const handleMouseMove = (moveEvent: MouseEvent) => { - // 리사이즈 중 로직은 부모에서 처리 + const delta = moveEvent.clientX - startXRef.current; + if (Math.abs(delta) > MIN_MOVE_THRESHOLD) { + movedRef.current = true; + } + setResizeOffset(delta); }; - const handleMouseUp = () => { + const handleMouseUp = (upEvent: MouseEvent) => { + const finalDelta = upEvent.clientX - startXRef.current; setIsResizing(false); - onResizeEnd?.(); + setResizeOffset(0); + + if (movedRef.current && Math.abs(finalDelta) > MIN_MOVE_THRESHOLD) { + onResizeComplete?.(schedule, direction, finalDelta); + } + document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; @@ -108,73 +142,125 @@ export function ScheduleBar({ document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, - [resizable, schedule, onResizeStart, onResizeEnd] + [resizable, isEditable, schedule, onResizeComplete] ); - // 클릭 핸들러 + // ────────── 클릭 핸들러 ────────── const handleClick = useCallback( (e: React.MouseEvent) => { - if (isDragging || isResizing) return; + if (movedRef.current) return; e.stopPropagation(); onClick?.(schedule); }, - [isDragging, isResizing, onClick, schedule] + [onClick, schedule] ); + // ────────── 드래그/리사이즈 중 시각적 위치 계산 ────────── + let visualLeft = position.left; + let visualWidth = position.width; + + if (isDragging) { + visualLeft += dragOffset; + } + + if (isResizing) { + if (resizeDir === "start") { + visualLeft += resizeOffset; + visualWidth -= resizeOffset; + } else { + visualWidth += resizeOffset; + } + } + + visualWidth = Math.max(10, visualWidth); + + // ────────── 마일스톤 렌더링 (단일 날짜 마커) ────────── + if (isMilestone) { + return ( +
+
+
+ ); + } + + // ────────── 일반 스케줄 바 렌더링 ────────── return (
{/* 진행률 바 */} {config.showProgress && schedule.progress !== undefined && (
)} {/* 제목 */} -
+
{schedule.title}
{/* 진행률 텍스트 */} {config.showProgress && schedule.progress !== undefined && ( -
+
{schedule.progress}%
)} + {/* 충돌 인디케이터 */} + {hasConflict && ( +
+ +
+ )} + {/* 리사이즈 핸들 - 왼쪽 */} - {resizable && ( + {resizable && isEditable && (
handleResizeStart("start", e)} + className="absolute bottom-0 left-0 top-0 w-1.5 cursor-ew-resize rounded-l-md hover:bg-white/20 sm:w-2" + onMouseDown={(e) => handleResizeMouseDown("start", e)} /> )} {/* 리사이즈 핸들 - 오른쪽 */} - {resizable && ( + {resizable && isEditable && (
handleResizeStart("end", e)} + className="absolute bottom-0 right-0 top-0 w-1.5 cursor-ew-resize rounded-r-md hover:bg-white/20 sm:w-2" + onMouseDown={(e) => handleResizeMouseDown("end", e)} /> )}
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/TimelineHeader.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineHeader.tsx index 52afc2e2..b3f89cfc 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineHeader.tsx +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineHeader.tsx @@ -140,7 +140,7 @@ export function TimelineHeader({
{/* 리소스 컬럼 헤더 */}
리소스 @@ -150,7 +150,7 @@ export function TimelineHeader({ {monthGroups.map((group, idx) => (
{group.year}년 {group.month} @@ -162,7 +162,7 @@ export function TimelineHeader({
{/* 리소스 컬럼 (빈칸) */}
@@ -171,7 +171,7 @@ export function TimelineHeader({
)} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineLegend.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineLegend.tsx new file mode 100644 index 00000000..da70e1b7 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineLegend.tsx @@ -0,0 +1,55 @@ +"use client"; + +import React from "react"; +import { TimelineSchedulerConfig } from "../types"; +import { statusOptions } from "../config"; + +interface TimelineLegendProps { + config: TimelineSchedulerConfig; +} + +export function TimelineLegend({ config }: TimelineLegendProps) { + const colors = config.statusColors || {}; + + return ( +
+ + 범례: + + {statusOptions.map((status) => ( +
+
+ + {status.label} + +
+ ))} + + {/* 마일스톤 범례 */} +
+
+
+
+ + 마일스톤 + +
+ + {/* 충돌 범례 */} + {config.showConflicts && ( +
+
+ + 충돌 + +
+ )} +
+ ); +} 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 4da03f17..e9064267 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts @@ -1,3 +1,6 @@ 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/config.ts b/frontend/lib/registry/components/v2-timeline-scheduler/config.ts index 17c31991..57409191 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/config.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/config.ts @@ -1,6 +1,6 @@ "use client"; -import { TimelineSchedulerConfig, ZoomLevel, ScheduleType } from "./types"; +import { TimelineSchedulerConfig, ZoomLevel, ScheduleType, ToolbarAction } from "./types"; /** * 기본 타임라인 스케줄러 설정 @@ -94,6 +94,39 @@ export const scheduleTypeOptions: { value: ScheduleType; label: string }[] = [ { value: "WORK_ASSIGN", label: "작업배정" }, ]; +/** + * 뷰 모드 옵션 + */ +export const viewModeOptions: { value: string; label: string; description: string }[] = [ + { value: "resource", label: "리소스 기반", description: "설비/작업자 행 기반 간트차트" }, + { value: "itemGrouped", label: "품목별 그룹", description: "품목별 카드형 타임라인" }, +]; + +/** + * 데이터 소스 옵션 + */ +export const dataSourceOptions: { value: string; label: string; description: string }[] = [ + { value: "linkedSelection", label: "연결 필터 선택값", description: "좌측 테이블에서 선택된 행 데이터 사용" }, + { value: "currentSchedules", label: "현재 스케줄", description: "타임라인에 표시 중인 스케줄 ID 사용" }, +]; + +/** + * 아이콘 옵션 + */ +export const toolbarIconOptions: { value: string; label: string }[] = [ + { value: "Zap", label: "Zap (번개)" }, + { value: "Package", label: "Package (박스)" }, + { value: "Plus", label: "Plus (추가)" }, + { value: "Download", label: "Download (다운로드)" }, + { value: "Upload", label: "Upload (업로드)" }, + { value: "RefreshCw", label: "RefreshCw (새로고침)" }, + { value: "Play", label: "Play (재생)" }, + { value: "FileText", label: "FileText (문서)" }, + { value: "Send", label: "Send (전송)" }, + { value: "Sparkles", label: "Sparkles (반짝)" }, + { value: "Wand2", label: "Wand2 (마법봉)" }, +]; + /** * 줌 레벨별 표시 일수 */ diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts b/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts index 94c001d4..8e2e1b53 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts @@ -124,10 +124,16 @@ export function useTimelineData( sourceKeys: currentSourceKeys, }); + const searchParams: Record = {}; + if (!isScheduleMng && config.staticFilters) { + Object.assign(searchParams, config.staticFilters); + } + const response = await apiClient.post(`/table-management/tables/${tableName}/data`, { page: 1, size: 10000, autoFilter: true, + ...(Object.keys(searchParams).length > 0 ? { search: searchParams } : {}), }); const responseData = response.data?.data?.data || response.data?.data || []; @@ -195,7 +201,8 @@ export function useTimelineData( setIsLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tableName, externalSchedules, fieldMappingKey, config.scheduleType]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tableName, externalSchedules, fieldMappingKey, config.scheduleType, JSON.stringify(config.staticFilters)]); // 리소스 데이터 로드 const fetchResources = useCallback(async () => { diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/types.ts b/frontend/lib/registry/components/v2-timeline-scheduler/types.ts index baf59741..5c0ef953 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/types.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/types.ts @@ -128,6 +128,58 @@ export interface SourceDataConfig { groupNameField?: string; } +/** + * 툴바 액션 설정 (커스텀 버튼) + * 타임라인 툴바에 표시되는 커스텀 액션 버튼을 정의 + * preview -> confirm -> apply 워크플로우 지원 + */ +export interface ToolbarAction { + /** 고유 ID */ + id: string; + /** 버튼 텍스트 */ + label: string; + /** lucide-react 아이콘명 */ + icon?: "Zap" | "Package" | "Plus" | "Download" | "Upload" | "RefreshCw" | "Play" | "FileText" | "Send" | "Sparkles" | "Wand2"; + /** 버튼 색상 클래스 (예: "bg-emerald-600 hover:bg-emerald-700") */ + color?: string; + /** 미리보기 API 엔드포인트 (예: "/production/generate-schedule/preview") */ + previewApi: string; + /** 적용 API 엔드포인트 (예: "/production/generate-schedule") */ + applyApi: string; + /** 다이얼로그 제목 */ + dialogTitle?: string; + /** 다이얼로그 설명 */ + dialogDescription?: string; + /** + * 데이터 소스 유형 + * - linkedSelection: 연결 필터(좌측 테이블)에서 선택된 행 사용 + * - currentSchedules: 현재 타임라인의 스케줄 ID 사용 + */ + dataSource: "linkedSelection" | "currentSchedules"; + /** 페이로드 구성 설정 */ + payloadConfig?: { + /** linkedSelection: 선택된 행을 그룹화할 필드 (기본: linkedFilter.sourceField) */ + groupByField?: string; + /** linkedSelection: 수량 합계 필드 (예: "balance_qty") */ + quantityField?: string; + /** linkedSelection: 기준일 필드 (예: "due_date") */ + dueDateField?: string; + /** linkedSelection: 표시명 필드 (예: "part_name") */ + nameField?: string; + /** currentSchedules: 스케줄 필터 조건 필드명 (예: "product_type") */ + scheduleFilterField?: string; + /** currentSchedules: 스케줄 필터 값 (예: "완제품") */ + scheduleFilterValue?: string; + /** API 호출 시 추가 옵션 (예: { "safety_lead_time": 1 }) */ + extraOptions?: Record; + }; + /** + * 표시 조건: staticFilters와 비교하여 모든 조건이 일치할 때만 버튼 표시 + * 예: { "product_type": "완제품" } → staticFilters.product_type === "완제품"일 때만 표시 + */ + showWhen?: Record; +} + /** * 타임라인 스케줄러 설정 */ @@ -144,6 +196,9 @@ export interface TimelineSchedulerConfig extends ComponentConfig { /** 커스텀 테이블명 */ customTableName?: string; + /** 정적 필터 조건 (커스텀 테이블에서 특정 조건으로 필터링) */ + staticFilters?: Record; + /** 리소스 테이블명 (설비/작업자) */ resourceTable?: string; @@ -222,6 +277,38 @@ 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; + }; + + /** 툴바 커스텀 액션 버튼 설정 */ + toolbarActions?: ToolbarAction[]; } /** diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/utils/conflictDetection.ts b/frontend/lib/registry/components/v2-timeline-scheduler/utils/conflictDetection.ts new file mode 100644 index 00000000..98b9fbb1 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/utils/conflictDetection.ts @@ -0,0 +1,58 @@ +"use client"; + +import { ScheduleItem } from "../types"; + +/** + * 같은 리소스에서 시간이 겹치는 스케줄을 감지 + * @returns 충돌이 있는 스케줄 ID Set + */ +export function detectConflicts(schedules: ScheduleItem[]): Set { + const conflictIds = new Set(); + + // 리소스별로 그룹화 + const byResource = new Map(); + for (const schedule of schedules) { + if (!byResource.has(schedule.resourceId)) { + byResource.set(schedule.resourceId, []); + } + byResource.get(schedule.resourceId)!.push(schedule); + } + + // 리소스별 충돌 검사 + for (const [, resourceSchedules] of byResource) { + if (resourceSchedules.length < 2) continue; + + // 시작일 기준 정렬 + const sorted = [...resourceSchedules].sort( + (a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime() + ); + + for (let i = 0; i < sorted.length; i++) { + const aEnd = new Date(sorted[i].endDate).getTime(); + + for (let j = i + 1; j < sorted.length; j++) { + const bStart = new Date(sorted[j].startDate).getTime(); + + // 정렬되어 있으므로 aStart <= bStart + // 겹치는 조건: aEnd > bStart + if (aEnd > bStart) { + conflictIds.add(sorted[i].id); + conflictIds.add(sorted[j].id); + } else { + break; + } + } + } + } + + return conflictIds; +} + +/** + * 날짜를 일수만큼 이동 + */ +export function addDaysToDateString(dateStr: string, days: number): string { + const date = new Date(dateStr); + date.setDate(date.getDate() + days); + return date.toISOString().split("T")[0]; +} diff --git a/frontend/lib/registry/pop-components/index.ts b/frontend/lib/registry/pop-components/index.ts index 351d6700..28e6a746 100644 --- a/frontend/lib/registry/pop-components/index.ts +++ b/frontend/lib/registry/pop-components/index.ts @@ -26,3 +26,4 @@ import "./pop-status-bar"; import "./pop-field"; import "./pop-scanner"; import "./pop-profile"; +import "./pop-work-detail"; diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index 55829efb..8c3c6447 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -34,6 +34,7 @@ import type { TimelineDataSource, ActionButtonUpdate, ActionButtonClickAction, + QuantityInputConfig, StatusValueMapping, SelectModeConfig, SelectModeButtonConfig, @@ -47,15 +48,42 @@ import { screenApi } from "@/lib/api/screen"; import { apiClient } from "@/lib/api/client"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { useCartSync } from "@/hooks/pop/useCartSync"; +import { useAuth } from "@/hooks/useAuth"; import { NumberInputModal } from "../pop-card-list/NumberInputModal"; import { renderCellV2 } from "./cell-renderers"; -import type { PopLayoutDataV5 } from "@/components/pop/designer/types/pop-layout"; -import { isV5Layout, detectGridMode } from "@/components/pop/designer/types/pop-layout"; +import type { PopLayoutData } from "@/components/pop/designer/types/pop-layout"; +import { isPopLayout, detectGridMode } from "@/components/pop/designer/types/pop-layout"; import dynamic from "next/dynamic"; const PopViewerWithModals = dynamic(() => import("@/components/pop/viewer/PopViewerWithModals"), { ssr: false }); type RowData = Record; +function calculateMaxQty( + row: RowData, + processId: string | number | undefined, + cfg?: QuantityInputConfig, +): number { + if (!cfg) return 999999; + const maxVal = cfg.maxColumn ? Number(row[cfg.maxColumn]) || 999999 : 999999; + if (!cfg.currentColumn) return maxVal; + + const processFlow = row.__processFlow__ as Array<{ + isCurrent: boolean; + processId?: string | number; + rawData?: Record; + }> | undefined; + + const currentProcess = processId + ? processFlow?.find((p) => String(p.processId) === String(processId)) + : processFlow?.find((p) => p.isCurrent); + + if (currentProcess?.rawData) { + const currentVal = Number(currentProcess.rawData[cfg.currentColumn]) || 0; + return Math.max(0, maxVal - currentVal); + } + return maxVal; +} + // cart_items 행 파싱 (pop-card-list에서 그대로 차용) function parseCartRow(dbRow: Record): Record { let rowData: Record = {}; @@ -111,8 +139,9 @@ export function PopCardListV2Component({ currentColSpan, onRequestResize, }: PopCardListV2ComponentProps) { - const { subscribe, publish } = usePopEvent(screenId || "default"); + const { subscribe, publish, setSharedData } = usePopEvent(screenId || "default"); const router = useRouter(); + const { userId: currentUserId } = useAuth(); const isCartListMode = config?.cartListMode?.enabled === true; const [inheritedConfig, setInheritedConfig] = useState | null>(null); @@ -216,11 +245,18 @@ export function PopCardListV2Component({ // ===== 모달 열기 (POP 화면) ===== const [popModalOpen, setPopModalOpen] = useState(false); - const [popModalLayout, setPopModalLayout] = useState(null); + const [popModalLayout, setPopModalLayout] = useState(null); const [popModalScreenId, setPopModalScreenId] = useState(""); const [popModalRow, setPopModalRow] = useState(null); const openPopModal = useCallback(async (screenIdStr: string, row: RowData) => { + // 내부 모달 캔버스 (디자이너에서 생성한 modal-*)인 경우 이벤트 발행 + if (screenIdStr.startsWith("modal-")) { + setSharedData("parentRow", row); + publish("__pop_modal_open__", { modalId: screenIdStr, fullscreen: true }); + return; + } + // 외부 POP 화면 ID인 경우 기존 fetch 방식 try { const sid = parseInt(screenIdStr, 10); if (isNaN(sid)) { @@ -228,7 +264,7 @@ export function PopCardListV2Component({ return; } const popLayout = await screenApi.getLayoutPop(sid); - if (popLayout && isV5Layout(popLayout)) { + if (popLayout && isPopLayout(popLayout)) { setPopModalLayout(popLayout); setPopModalScreenId(String(sid)); setPopModalRow(row); @@ -239,7 +275,7 @@ export function PopCardListV2Component({ } catch { toast.error("POP 화면을 불러오는데 실패했습니다."); } - }, []); + }, [publish, setSharedData]); const handleCardSelect = useCallback((row: RowData) => { @@ -469,7 +505,7 @@ export function PopCardListV2Component({ type: "data-update" as const, targetTable: btnConfig.targetTable!, targetColumn: u.column, - operationType: "assign" as const, + operationType: (u.operationType || "assign") as "assign" | "add" | "subtract", valueSource: "fixed" as const, fixedValue: u.valueType === "static" ? (u.value ?? "") : u.valueType === "currentUser" ? "__CURRENT_USER__" : @@ -619,11 +655,28 @@ export function PopCardListV2Component({ const scrollAreaRef = useRef(null); + const ownerSortColumn = config?.ownerSortColumn; + const displayCards = useMemo(() => { - if (!isExpanded) return filteredRows.slice(0, visibleCardCount); + let source = filteredRows; + + if (ownerSortColumn && currentUserId) { + const mine: RowData[] = []; + const others: RowData[] = []; + for (const row of source) { + if (String(row[ownerSortColumn] ?? "") === currentUserId) { + mine.push(row); + } else { + others.push(row); + } + } + source = [...mine, ...others]; + } + + if (!isExpanded) return source.slice(0, visibleCardCount); const start = (currentPage - 1) * expandedCardsPerPage; - return filteredRows.slice(start, start + expandedCardsPerPage); - }, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]); + return source.slice(start, start + expandedCardsPerPage); + }, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage, ownerSortColumn, currentUserId]); const totalPages = isExpanded ? Math.ceil(filteredRows.length / expandedCardsPerPage) : 1; const needsPagination = isExpanded && totalPages > 1; @@ -756,10 +809,17 @@ export function PopCardListV2Component({ if (firstPending) { firstPending.isCurrent = true; } } - return fetchedRows.map((row) => ({ - ...row, - __processFlow__: processMap.get(String(row.id)) || [], - })); + return fetchedRows.map((row) => { + const steps = processMap.get(String(row.id)) || []; + const current = steps.find((s) => s.isCurrent); + const processFields: Record = {}; + if (current?.rawData) { + for (const [key, val] of Object.entries(current.rawData)) { + processFields[`__process_${key}`] = val; + } + } + return { ...row, __processFlow__: steps, ...processFields }; + }); }, []); const fetchData = useCallback(async () => { @@ -1014,35 +1074,42 @@ export function PopCardListV2Component({ className={`min-h-0 flex-1 grid ${scrollClassName}`} style={{ ...cardAreaStyle, alignContent: "start", justifyContent: isHorizontalMode ? "start" : "center" }} > - {displayCards.map((row, index) => ( - { - const cartId = row.__cart_id != null ? String(row.__cart_id) : ""; - if (!cartId) return; - setSelectedKeys((prev) => { const next = new Set(prev); if (next.has(cartId)) next.delete(cartId); else next.add(cartId); return next; }); - }} - onDeleteItem={handleDeleteItem} - onUpdateQuantity={handleUpdateQuantity} - onRefresh={fetchData} - selectMode={selectMode} - isSelectModeSelected={selectedRowIds.has(String(row.id ?? row.pk ?? ""))} - isSelectable={isRowSelectable(row)} - onToggleRowSelect={() => toggleRowSelection(row)} - onEnterSelectMode={enterSelectMode} - onOpenPopModal={openPopModal} - /> - ))} + {displayCards.map((row, index) => { + const locked = !!ownerSortColumn + && !!String(row[ownerSortColumn] ?? "") + && String(row[ownerSortColumn] ?? "") !== (currentUserId ?? ""); + return ( + { + const cartId = row.__cart_id != null ? String(row.__cart_id) : ""; + if (!cartId) return; + setSelectedKeys((prev) => { const next = new Set(prev); if (next.has(cartId)) next.delete(cartId); else next.add(cartId); return next; }); + }} + onDeleteItem={handleDeleteItem} + onUpdateQuantity={handleUpdateQuantity} + onRefresh={fetchData} + selectMode={selectMode} + isSelectModeSelected={selectedRowIds.has(String(row.id ?? row.pk ?? ""))} + isSelectable={isRowSelectable(row)} + onToggleRowSelect={() => toggleRowSelection(row)} + onEnterSelectMode={enterSelectMode} + onOpenPopModal={openPopModal} + currentUserId={currentUserId} + isLockedByOther={locked} + /> + ); + })}
{/* 선택 모드 하단 액션 바 */} @@ -1116,6 +1183,7 @@ export function PopCardListV2Component({ viewportWidth={typeof window !== "undefined" ? window.innerWidth : 1024} screenId={popModalScreenId} currentMode={detectGridMode(typeof window !== "undefined" ? window.innerWidth : 1024)} + parentRow={popModalRow ?? undefined} /> )}
@@ -1148,6 +1216,8 @@ interface CardV2Props { onToggleRowSelect?: () => void; onEnterSelectMode?: (whenStatus: string, buttonConfig: Record) => void; onOpenPopModal?: (screenId: string, row: RowData) => void; + currentUserId?: string; + isLockedByOther?: boolean; } function CardV2({ @@ -1155,7 +1225,7 @@ function CardV2({ parentComponentId, isCartListMode, isSelected, onToggleSelect, onDeleteItem, onUpdateQuantity, onRefresh, selectMode, isSelectModeSelected, isSelectable, onToggleRowSelect, onEnterSelectMode, - onOpenPopModal, + onOpenPopModal, currentUserId, isLockedByOther, }: CardV2Props) { const inputField = config?.inputField; const cartAction = config?.cartAction; @@ -1167,6 +1237,72 @@ function CardV2({ const [packageEntries, setPackageEntries] = useState([]); const [isModalOpen, setIsModalOpen] = useState(false); + const [qtyModalState, setQtyModalState] = useState<{ + open: boolean; + row: RowData; + processId?: string | number; + action: ActionButtonClickAction; + } | null>(null); + + const handleQtyConfirm = useCallback(async (value: number) => { + if (!qtyModalState) return; + const { row: actionRow, processId: qtyProcessId, action } = qtyModalState; + setQtyModalState(null); + if (!action.targetTable || !action.updates) return; + + const rowId = qtyProcessId ?? actionRow.id ?? actionRow.pk; + if (!rowId) { toast.error("대상 레코드 ID를 찾을 수 없습니다."); return; } + + const lookupValue = action.joinConfig + ? String(actionRow[action.joinConfig.sourceColumn] ?? rowId) + : rowId; + const lookupColumn = action.joinConfig?.targetColumn || "id"; + + const tasks = action.updates.map((u, idx) => ({ + id: `qty-update-${idx}`, + type: "data-update" as const, + targetTable: action.targetTable!, + targetColumn: u.column, + operationType: (u.operationType || "assign") as "assign" | "add" | "subtract", + valueSource: "fixed" as const, + fixedValue: u.valueType === "userInput" ? String(value) : + u.valueType === "static" ? (u.value ?? "") : + u.valueType === "currentUser" ? "__CURRENT_USER__" : + u.valueType === "currentTime" ? "__CURRENT_TIME__" : + u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") : + (u.value ?? ""), + lookupMode: "manual" as const, + manualItemField: lookupColumn, + manualPkColumn: lookupColumn, + ...(idx === 0 && action.preCondition ? { preCondition: action.preCondition } : {}), + })); + + const targetRow = action.joinConfig + ? { ...actionRow, [lookupColumn]: lookupValue } + : qtyProcessId ? { ...actionRow, id: qtyProcessId } : actionRow; + + try { + const result = await apiClient.post("/pop/execute-action", { + tasks, + data: { items: [targetRow], fieldValues: {} }, + mappings: {}, + }); + if (result.data?.success) { + toast.success(result.data.message || "처리 완료"); + onRefresh?.(); + } else { + toast.error(result.data?.message || "처리 실패"); + } + } catch (err: unknown) { + if ((err as any)?.response?.status === 409) { + toast.error((err as any).response?.data?.message || "이미 다른 사용자가 처리한 작업입니다."); + onRefresh?.(); + } else { + toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); + } + } + }, [qtyModalState, onRefresh]); + const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : ""; const isCarted = cart.isItemInCart(rowKey); const existingCartItem = cart.getCartItem(rowKey); @@ -1272,16 +1408,24 @@ function CardV2({ return (
{ + if (isLockedByOther) return; if (selectMode && isSelectable) { onToggleRowSelect?.(); return; } if (!selectMode) onSelect?.(row); }} role="button" - tabIndex={0} + tabIndex={isLockedByOther ? -1 : 0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { + if (isLockedByOther) return; if (selectMode && isSelectable) { onToggleRowSelect?.(); return; } if (!selectMode) onSelect?.(row); } @@ -1365,7 +1509,11 @@ function CardV2({ } for (const action of actionsToRun) { - if (action.type === "immediate" && action.updates && action.updates.length > 0 && action.targetTable) { + if (action.type === "quantity-input" && action.targetTable && action.updates) { + if (action.confirmMessage && !window.confirm(action.confirmMessage)) return; + setQtyModalState({ open: true, row: actionRow, processId, action }); + return; + } else if (action.type === "immediate" && action.updates && action.updates.length > 0 && action.targetTable) { if (action.confirmMessage) { if (!window.confirm(action.confirmMessage)) return; } @@ -1381,7 +1529,7 @@ function CardV2({ type: "data-update" as const, targetTable: action.targetTable!, targetColumn: u.column, - operationType: "assign" as const, + operationType: (u.operationType || "assign") as "assign" | "add" | "subtract", valueSource: "fixed" as const, fixedValue: u.valueType === "static" ? (u.value ?? "") : u.valueType === "currentUser" ? "__CURRENT_USER__" : @@ -1391,6 +1539,7 @@ function CardV2({ lookupMode: "manual" as const, manualItemField: lookupColumn, manualPkColumn: lookupColumn, + ...(idx === 0 && action.preCondition ? { preCondition: action.preCondition } : {}), })); const targetRow = action.joinConfig ? { ...actionRow, [lookupColumn]: lookupValue } @@ -1408,7 +1557,12 @@ function CardV2({ return; } } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); + if ((err as any)?.response?.status === 409) { + toast.error((err as any).response?.data?.message || "이미 다른 사용자가 처리한 작업입니다."); + onRefresh?.(); + } else { + toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); + } return; } } else if (action.type === "modal-open" && action.modalScreenId) { @@ -1418,6 +1572,7 @@ function CardV2({ }, packageEntries, inputUnit: inputField?.unit, + currentUserId, })}
))} @@ -1437,6 +1592,17 @@ function CardV2({ /> )} + {qtyModalState?.open && ( + { if (!open) setQtyModalState(null); }} + unit={qtyModalState.action.quantityInput?.unit || "EA"} + maxValue={calculateMaxQty(qtyModalState.row, qtyModalState.processId, qtyModalState.action.quantityInput)} + showPackageUnit={qtyModalState.action.quantityInput?.enablePackage ?? false} + onConfirm={(value) => handleQtyConfirm(value)} + /> + )} +
); } diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx index 79d8a31e..9fc1339a 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx @@ -12,6 +12,7 @@ import { useState, useEffect, useRef, useCallback, useMemo, Fragment } from "rea import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { usePopDesignerContext } from "@/components/pop/designer/PopDesignerContext"; import { Switch } from "@/components/ui/switch"; import { Select, @@ -65,6 +66,33 @@ import { type ColumnInfo, } from "../pop-dashboard/utils/dataFetcher"; +// ===== 컬럼 옵션 그룹 ===== + +interface ColumnOptionGroup { + groupLabel: string; + options: { value: string; label: string }[]; +} + +function renderColumnOptionGroups(groups: ColumnOptionGroup[]) { + if (groups.length <= 1) { + return groups.flatMap((g) => + g.options.map((o) => ( + {o.label} + )) + ); + } + return groups + .filter((g) => g.options.length > 0) + .map((g) => ( + + {g.groupLabel} + {g.options.map((o) => ( + {o.label} + ))} + + )); +} + // ===== Props ===== interface ConfigPanelProps { @@ -271,6 +299,7 @@ export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps) )}
@@ -759,10 +788,36 @@ function TabCardDesign({ sourceTable: j.targetTable, })) ); - const allColumnOptions = [ - ...availableColumns.map((c) => ({ value: c.name, label: c.name })), - ...joinedColumns.map((c) => ({ value: c.name, label: `${c.displayName} (${c.sourceTable})` })), + + const [processColumns, setProcessColumns] = useState([]); + const timelineCell = cfg.cardGrid.cells.find((c) => c.type === "timeline" && c.timelineSource?.processTable); + const processTableName = timelineCell?.timelineSource?.processTable || ""; + useEffect(() => { + if (!processTableName) { setProcessColumns([]); return; } + fetchTableColumns(processTableName) + .then(setProcessColumns) + .catch(() => setProcessColumns([])); + }, [processTableName]); + + const columnOptionGroups: ColumnOptionGroup[] = [ + { + groupLabel: `메인 (${cfg.dataSource.tableName || "테이블"})`, + options: availableColumns.map((c) => ({ value: c.name, label: c.name })), + }, + ...(joinedColumns.length > 0 + ? [{ + groupLabel: "조인", + options: joinedColumns.map((c) => ({ value: c.name, label: `${c.displayName} (${c.sourceTable})` })), + }] + : []), + ...(processColumns.length > 0 + ? [{ + groupLabel: `공정 (${processTableName})`, + options: processColumns.map((c) => ({ value: `__process_${c.name}`, label: c.name })), + }] + : []), ]; + const allColumnOptions = columnOptionGroups.flatMap((g) => g.options); const [selectedCellId, setSelectedCellId] = useState(null); const [mergeMode, setMergeMode] = useState(false); @@ -1273,6 +1328,7 @@ function TabCardDesign({ cell={selectedCell} allCells={grid.cells} allColumnOptions={allColumnOptions} + columnOptionGroups={columnOptionGroups} columns={columns} selectedColumns={selectedColumns} tables={tables} @@ -1291,6 +1347,7 @@ function CellDetailEditor({ cell, allCells, allColumnOptions, + columnOptionGroups, columns, selectedColumns, tables, @@ -1301,6 +1358,7 @@ function CellDetailEditor({ cell: CardCellDefinitionV2; allCells: CardCellDefinitionV2[]; allColumnOptions: { value: string; label: string }[]; + columnOptionGroups: ColumnOptionGroup[]; columns: ColumnInfo[]; selectedColumns: string[]; tables: TableInfo[]; @@ -1348,9 +1406,7 @@ function CellDetailEditor({ 미지정 - {allColumnOptions.map((o) => ( - {o.label} - ))} + {renderColumnOptionGroups(columnOptionGroups)} )} @@ -1417,9 +1473,9 @@ function CellDetailEditor({ {/* 타입별 상세 설정 */} {cell.type === "status-badge" && } {cell.type === "timeline" && } - {cell.type === "action-buttons" && } - {cell.type === "footer-status" && } - {cell.type === "field" && } + {cell.type === "action-buttons" && } + {cell.type === "footer-status" && } + {cell.type === "field" && } {cell.type === "number-input" && (
숫자 입력 설정 @@ -1429,7 +1485,7 @@ function CellDetailEditor({ 없음 - {allColumnOptions.map((o) => {o.label})} + {renderColumnOptionGroups(columnOptionGroups)}
@@ -1809,12 +1865,14 @@ function ActionButtonsEditor({ cell, allCells, allColumnOptions, + columnOptionGroups, availableTableOptions, onUpdate, }: { cell: CardCellDefinitionV2; allCells: CardCellDefinitionV2[]; allColumnOptions: { value: string; label: string }[]; + columnOptionGroups: ColumnOptionGroup[]; availableTableOptions: { value: string; label: string }[]; onUpdate: (partial: Partial) => void; }) { @@ -1975,7 +2033,7 @@ function ActionButtonsEditor({ const isSectionOpen = (key: string) => expandedSections[key] !== false; - const ACTION_TYPE_LABELS: Record = { immediate: "즉시 실행", "select-mode": "선택 후 실행", "modal-open": "모달 열기" }; + const ACTION_TYPE_LABELS: Record = { immediate: "즉시 실행", "select-mode": "선택 후 실행", "modal-open": "모달 열기", "quantity-input": "수량 입력" }; const getCondSummary = (btn: ActionButtonDef) => { const c = btn.showCondition; @@ -1985,6 +2043,7 @@ function ActionButtonsEditor({ return opt ? opt.label : (c.value || "미설정"); } if (c.type === "column-value") return `${c.column || "?"} = ${c.value || "?"}`; + if (c.type === "owner-match") return `소유자(${c.column || "?"})`; return "항상"; }; @@ -2081,8 +2140,21 @@ function ActionButtonsEditor({ 항상 타임라인 카드 컬럼 + 소유자 일치 + {condType === "owner-match" && ( + + )} {condType === "timeline-status" && ( 즉시 실행 + 수량 입력 선택 후 실행 모달 열기 @@ -2191,6 +2262,50 @@ function ActionButtonsEditor({ /> )} + {aType === "quantity-input" && ( +
+ addActionUpdate(bi, ai)} + onUpdateUpdate={(ui, p) => updateActionUpdate(bi, ai, ui, p)} + onRemoveUpdate={(ui) => removeActionUpdate(bi, ai, ui)} + onUpdateAction={(p) => updateAction(bi, ai, p)} + /> +
+ 수량 모달 설정 +
+ 최대값 컬럼 + updateAction(bi, ai, { quantityInput: { ...action.quantityInput, maxColumn: e.target.value } })} + placeholder="예: qty" + className="h-6 flex-1 text-[10px]" + /> +
+
+ 현재값 컬럼 + updateAction(bi, ai, { quantityInput: { ...action.quantityInput, currentColumn: e.target.value } })} + placeholder="예: input_qty" + className="h-6 flex-1 text-[10px]" + /> +
+
+ 단위 + updateAction(bi, ai, { quantityInput: { ...action.quantityInput, unit: e.target.value } })} + placeholder="예: EA" + className="h-6 w-20 text-[10px]" + /> +
+
+
+ )} + {aType === "select-mode" && (
@@ -2455,6 +2570,70 @@ function ImmediateActionEditor({ className="h-6 flex-1 text-[10px]" />
+ + {/* 사전 조건 (중복 방지) */} +
+
+ 사전 조건 (중복 방지) + { + if (checked) { + onUpdateAction({ preCondition: { column: "", expectedValue: "", failMessage: "" } }); + } else { + onUpdateAction({ preCondition: undefined }); + } + }} + className="h-3.5 w-7 [&>span]:h-2.5 [&>span]:w-2.5" + /> +
+ {action.preCondition && ( +
+
+ 검증 컬럼 + +
+
+ 기대값 + onUpdateAction({ preCondition: { ...action.preCondition!, expectedValue: e.target.value } })} + placeholder="예: waiting" + className="h-6 flex-1 text-[10px]" + /> +
+
+ 실패 메시지 + onUpdateAction({ preCondition: { ...action.preCondition!, failMessage: e.target.value } })} + placeholder="이미 다른 사용자가 처리했습니다" + className="h-6 flex-1 text-[10px]" + /> +
+

+ 실행 시 해당 컬럼의 현재 DB 값이 기대값과 일치할 때만 처리됩니다 +

+
+ )} +
+
변경할 컬럼{tableName ? ` (${tableName})` : ""} @@ -2491,11 +2670,22 @@ function ImmediateActionEditor({ 직접입력 + 사용자 입력 현재 사용자 현재 시간 컬럼 참조 + {u.valueType === "userInput" && ( + + )} {(u.valueType === "static" || u.valueType === "columnRef") && ( ) => void; }) { const footerStatusMap = cell.footerStatusMap || []; @@ -2644,7 +2836,7 @@ function FooterStatusEditor({ 없음 - {allColumnOptions.map((o) => {o.label})} + {renderColumnOptionGroups(columnOptionGroups)}
@@ -2680,10 +2872,12 @@ function FooterStatusEditor({ function FieldConfigEditor({ cell, allColumnOptions, + columnOptionGroups, onUpdate, }: { cell: CardCellDefinitionV2; allColumnOptions: { value: string; label: string }[]; + columnOptionGroups: ColumnOptionGroup[]; onUpdate: (partial: Partial) => void; }) { const valueType = cell.valueType || "column"; @@ -2706,7 +2900,7 @@ function FieldConfigEditor({ onUpdate({ formulaRight: v })}> - {allColumnOptions.map((o) => {o.label})} + {renderColumnOptionGroups(columnOptionGroups)} )} @@ -2741,16 +2935,62 @@ function FieldConfigEditor({ function TabActions({ cfg, onUpdate, + columns, }: { cfg: PopCardListV2Config; onUpdate: (partial: Partial) => void; + columns: ColumnInfo[]; }) { + const designerCtx = usePopDesignerContext(); const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 }; const clickAction = cfg.cardClickAction || "none"; const modalConfig = cfg.cardClickModalConfig || { screenId: "" }; + const [processColumns, setProcessColumns] = useState([]); + const timelineCell = cfg.cardGrid?.cells?.find((c) => c.type === "timeline" && c.timelineSource?.processTable); + const processTableName = timelineCell?.timelineSource?.processTable || ""; + useEffect(() => { + if (!processTableName) { setProcessColumns([]); return; } + fetchTableColumns(processTableName) + .then(setProcessColumns) + .catch(() => setProcessColumns([])); + }, [processTableName]); + + const ownerColumnGroups: ColumnOptionGroup[] = useMemo(() => [ + { + groupLabel: `메인 (${cfg.dataSource?.tableName || "테이블"})`, + options: columns.map((c) => ({ value: c.name, label: c.name })), + }, + ...(processColumns.length > 0 + ? [{ + groupLabel: `공정 (${processTableName})`, + options: processColumns.map((c) => ({ value: `__process_${c.name}`, label: c.name })), + }] + : []), + ], [columns, processColumns, processTableName, cfg.dataSource?.tableName]); + return (
+ {/* 소유자 우선 정렬 */} +
+ +
+ +
+

+ 선택한 컬럼 값이 현재 로그인 사용자와 일치하는 카드가 맨 위에 표시됩니다 +

+
+ {/* 카드 선택 시 */}
@@ -2775,15 +3015,52 @@ function TabActions({
{clickAction === "modal-open" && (
-
- POP 화면 ID - onUpdate({ cardClickModalConfig: { ...modalConfig, screenId: e.target.value } })} - placeholder="화면 ID (예: 4481)" - className="h-7 flex-1 text-[10px]" - /> -
+ {/* 모달 캔버스 (디자이너 모드) */} + {designerCtx && ( +
+ {modalConfig.screenId?.startsWith("modal-") ? ( + + ) : ( + + )} +
+ )} + {/* 뷰어 모드 또는 직접 입력 폴백 */} + {!designerCtx && ( +
+ 모달 ID + onUpdate({ cardClickModalConfig: { ...modalConfig, screenId: e.target.value } })} + placeholder="모달 ID" + className="h-7 flex-1 text-[10px]" + /> +
+ )}
모달 제목 ) => void; packageEntries?: PackageEntry[]; inputUnit?: string; + currentUserId?: string; } // ===== 메인 디스패치 ===== @@ -592,7 +593,7 @@ function TimelineCell({ cell, row }: CellRendererProps) { // ===== 11. action-buttons ===== -function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | "disabled" | "hidden" { +function evaluateShowCondition(btn: ActionButtonDef, row: RowData, currentUserId?: string): "visible" | "disabled" | "hidden" { const cond = btn.showCondition; if (!cond || cond.type === "always") return "visible"; @@ -603,6 +604,9 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | matched = subStatus !== undefined && String(subStatus) === cond.value; } else if (cond.type === "column-value" && cond.column) { matched = String(row[cond.column] ?? "") === (cond.value ?? ""); + } else if (cond.type === "owner-match" && cond.column) { + const ownerValue = String(row[cond.column] ?? ""); + matched = !!currentUserId && ownerValue === currentUserId; } else { return "visible"; } @@ -611,7 +615,7 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | return cond.unmatchBehavior === "disabled" ? "disabled" : "hidden"; } -function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode }: CellRendererProps) { +function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode, currentUserId }: CellRendererProps) { const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined; const currentProcess = processFlow?.find((s) => s.isCurrent); const currentProcessId = currentProcess?.processId; @@ -619,7 +623,7 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode } if (cell.actionButtons && cell.actionButtons.length > 0) { const evaluated = cell.actionButtons.map((btn) => ({ btn, - state: evaluateShowCondition(btn, row), + state: evaluateShowCondition(btn, row, currentUserId), })); const activeBtn = evaluated.find((e) => e.state === "visible"); diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx index 1c351cf2..f5d06036 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx @@ -295,8 +295,8 @@ function BasicSettingsTab({ const recommendation = useMemo(() => { if (!currentMode) return null; const cols = GRID_BREAKPOINTS[currentMode].columns; - if (cols >= 8) return { rows: 3, cols: 2 }; - if (cols >= 6) return { rows: 3, cols: 1 }; + if (cols >= 25) return { rows: 3, cols: 2 }; + if (cols >= 18) return { rows: 3, cols: 1 }; return { rows: 2, cols: 1 }; }, [currentMode]); diff --git a/frontend/lib/registry/pop-components/pop-scanner.tsx b/frontend/lib/registry/pop-components/pop-scanner.tsx index e2230170..4ce86cc8 100644 --- a/frontend/lib/registry/pop-components/pop-scanner.tsx +++ b/frontend/lib/registry/pop-components/pop-scanner.tsx @@ -19,7 +19,7 @@ import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { BarcodeScanModal } from "@/components/common/BarcodeScanModal"; import type { PopDataConnection, - PopComponentDefinitionV5, + PopComponentDefinition, } from "@/components/pop/designer/types/pop-layout"; // ======================================== @@ -99,7 +99,7 @@ function parseScanResult( function getConnectedFields( componentId?: string, connections?: PopDataConnection[], - allComponents?: PopComponentDefinitionV5[], + allComponents?: PopComponentDefinition[], ): ConnectedFieldInfo[] { if (!componentId || !connections || !allComponents) return []; @@ -308,7 +308,7 @@ const PARSE_MODE_LABELS: Record = { interface PopScannerConfigPanelProps { config: PopScannerConfig; onUpdate: (config: PopScannerConfig) => void; - allComponents?: PopComponentDefinitionV5[]; + allComponents?: PopComponentDefinition[]; connections?: PopDataConnection[]; componentId?: string; } diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx index 8c619429..b0752146 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx @@ -72,7 +72,7 @@ const DEFAULT_CONFIG: PopSearchConfig = { interface ConfigPanelProps { config: PopSearchConfig | undefined; onUpdate: (config: PopSearchConfig) => void; - allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[]; connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; componentId?: string; } @@ -151,7 +151,7 @@ export function PopSearchConfigPanel({ config, onUpdate, allComponents, connecti interface StepProps { cfg: PopSearchConfig; update: (partial: Partial) => void; - allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[]; connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; componentId?: string; } @@ -268,7 +268,7 @@ interface FilterConnectionSectionProps { update: (partial: Partial) => void; showFieldName: boolean; fixedFilterMode?: SearchFilterMode; - allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[]; connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; componentId?: string; } @@ -284,7 +284,7 @@ interface ConnectedComponentInfo { function getConnectedComponentInfo( componentId?: string, connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[], - allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[], + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[], ): ConnectedComponentInfo { const empty: ConnectedComponentInfo = { tableNames: [], displayedColumns: new Set() }; if (!componentId || !connections || !allComponents) return empty; diff --git a/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx index 3b0ce864..8118dfe2 100644 --- a/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx @@ -22,7 +22,7 @@ import { DEFAULT_STATUS_BAR_CONFIG, STATUS_CHIP_STYLE_LABELS } from "./types"; interface ConfigPanelProps { config: StatusBarConfig | undefined; onUpdate: (config: StatusBarConfig) => void; - allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[]; connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; componentId?: string; } diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx new file mode 100644 index 00000000..963d2148 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx @@ -0,0 +1,832 @@ +"use client"; + +import React, { useEffect, useState, useMemo, useCallback } from "react"; +import { + Loader2, Play, Pause, CheckCircle2, AlertCircle, Timer, Package, +} from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { dataApi } from "@/lib/api/data"; +import { apiClient } from "@/lib/api/client"; +import { usePopEvent } from "@/hooks/pop/usePopEvent"; +import { useAuth } from "@/hooks/useAuth"; +import type { PopWorkDetailConfig } from "../types"; +import type { TimelineProcessStep } from "../types"; + +// ======================================== +// 타입 +// ======================================== + +type RowData = Record; + +interface WorkResultRow { + id: string; + work_order_process_id: string; + source_work_item_id: string; + source_detail_id: string; + work_phase: string; + item_title: string; + item_sort_order: string; + detail_type: string; + detail_label: string; + detail_sort_order: string; + spec_value: string | null; + lower_limit: string | null; + upper_limit: string | null; + input_type: string | null; + result_value: string | null; + status: string; + is_passed: string | null; + recorded_by: string | null; + recorded_at: string | null; +} + +interface WorkGroup { + phase: string; + title: string; + itemId: string; + sortOrder: number; + total: number; + completed: number; +} + +type WorkPhase = "PRE" | "IN" | "POST"; +const PHASE_ORDER: Record = { PRE: 1, IN: 2, POST: 3 }; + +interface ProcessTimerData { + started_at: string | null; + paused_at: string | null; + total_paused_time: string | null; + status: string; + good_qty: string | null; + defect_qty: string | null; +} + +// ======================================== +// Props +// ======================================== + +interface PopWorkDetailComponentProps { + config?: PopWorkDetailConfig; + screenId?: string; + componentId?: string; + currentRowSpan?: number; + currentColSpan?: number; +} + +// ======================================== +// 메인 컴포넌트 +// ======================================== + +export function PopWorkDetailComponent({ + config, + screenId, + componentId, +}: PopWorkDetailComponentProps) { + const { getSharedData } = usePopEvent(screenId || "default"); + const { user } = useAuth(); + + const cfg: PopWorkDetailConfig = { + showTimer: config?.showTimer ?? true, + showQuantityInput: config?.showQuantityInput ?? true, + phaseLabels: config?.phaseLabels ?? { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }, + }; + + // parentRow에서 현재 공정 정보 추출 + const parentRow = getSharedData("parentRow"); + const processFlow = parentRow?.__processFlow__ as TimelineProcessStep[] | undefined; + const currentProcess = processFlow?.find((p) => p.isCurrent); + const workOrderProcessId = currentProcess?.processId + ? String(currentProcess.processId) + : undefined; + const processName = currentProcess?.processName ?? "공정 상세"; + + // ======================================== + // 상태 + // ======================================== + + const [allResults, setAllResults] = useState([]); + const [processData, setProcessData] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedGroupId, setSelectedGroupId] = useState(null); + const [tick, setTick] = useState(Date.now()); + const [savingIds, setSavingIds] = useState>(new Set()); + + // 수량 입력 로컬 상태 + const [goodQty, setGoodQty] = useState(""); + const [defectQty, setDefectQty] = useState(""); + + // ======================================== + // D-FE1: 데이터 로드 + // ======================================== + + const fetchData = useCallback(async () => { + if (!workOrderProcessId) { + setLoading(false); + return; + } + + try { + setLoading(true); + + const [resultRes, processRes] = await Promise.all([ + dataApi.getTableData("process_work_result", { + size: 500, + filters: { work_order_process_id: workOrderProcessId }, + }), + dataApi.getTableData("work_order_process", { + size: 1, + filters: { id: workOrderProcessId }, + }), + ]); + + setAllResults((resultRes.data ?? []) as unknown as WorkResultRow[]); + + const proc = (processRes.data?.[0] ?? null) as ProcessTimerData | null; + setProcessData(proc); + if (proc) { + setGoodQty(proc.good_qty ?? ""); + setDefectQty(proc.defect_qty ?? ""); + } + } catch { + toast.error("데이터를 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }, [workOrderProcessId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // ======================================== + // D-FE2: 좌측 사이드바 - 작업항목 그룹핑 + // ======================================== + + const groups = useMemo(() => { + const map = new Map(); + for (const row of allResults) { + const key = row.source_work_item_id; + if (!map.has(key)) { + map.set(key, { + phase: row.work_phase, + title: row.item_title, + itemId: key, + sortOrder: parseInt(row.item_sort_order || "0", 10), + total: 0, + completed: 0, + }); + } + const g = map.get(key)!; + g.total++; + if (row.status === "completed") g.completed++; + } + return Array.from(map.values()).sort( + (a, b) => + (PHASE_ORDER[a.phase] ?? 9) - (PHASE_ORDER[b.phase] ?? 9) || + a.sortOrder - b.sortOrder + ); + }, [allResults]); + + // phase별로 그룹핑 + const groupsByPhase = useMemo(() => { + const result: Record = {}; + for (const g of groups) { + if (!result[g.phase]) result[g.phase] = []; + result[g.phase].push(g); + } + return result; + }, [groups]); + + // 첫 그룹 자동 선택 + useEffect(() => { + if (groups.length > 0 && !selectedGroupId) { + setSelectedGroupId(groups[0].itemId); + } + }, [groups, selectedGroupId]); + + // ======================================== + // D-FE3: 우측 체크리스트 + // ======================================== + + const currentItems = useMemo( + () => + allResults + .filter((r) => r.source_work_item_id === selectedGroupId) + .sort((a, b) => parseInt(a.detail_sort_order || "0", 10) - parseInt(b.detail_sort_order || "0", 10)), + [allResults, selectedGroupId] + ); + + const saveResultValue = useCallback( + async ( + rowId: string, + resultValue: string, + isPassed: string | null, + newStatus: string + ) => { + setSavingIds((prev) => new Set(prev).add(rowId)); + try { + await apiClient.post("/pop/execute-action", { + tasks: [ + { type: "data-update", targetTable: "process_work_result", targetColumn: "result_value", value: resultValue, items: [{ id: rowId }] }, + { type: "data-update", targetTable: "process_work_result", targetColumn: "status", value: newStatus, items: [{ id: rowId }] }, + ...(isPassed !== null + ? [{ type: "data-update", targetTable: "process_work_result", targetColumn: "is_passed", value: isPassed, items: [{ id: rowId }] }] + : []), + { type: "data-update", targetTable: "process_work_result", targetColumn: "recorded_by", value: user?.userId ?? "", items: [{ id: rowId }] }, + { type: "data-update", targetTable: "process_work_result", targetColumn: "recorded_at", value: new Date().toISOString(), items: [{ id: rowId }] }, + ], + data: { items: [{ id: rowId }], fieldValues: {} }, + }); + + setAllResults((prev) => + prev.map((r) => + r.id === rowId + ? { + ...r, + result_value: resultValue, + status: newStatus, + is_passed: isPassed, + recorded_by: user?.userId ?? null, + recorded_at: new Date().toISOString(), + } + : r + ) + ); + } catch { + toast.error("저장에 실패했습니다."); + } finally { + setSavingIds((prev) => { + const next = new Set(prev); + next.delete(rowId); + return next; + }); + } + }, + [user?.userId] + ); + + // ======================================== + // D-FE4: 타이머 + // ======================================== + + useEffect(() => { + if (!cfg.showTimer || !processData?.started_at) return; + const id = setInterval(() => setTick(Date.now()), 1000); + return () => clearInterval(id); + }, [cfg.showTimer, processData?.started_at]); + + const elapsedMs = useMemo(() => { + if (!processData?.started_at) return 0; + const now = tick; + const totalMs = now - new Date(processData.started_at).getTime(); + const pausedSec = parseInt(processData.total_paused_time || "0", 10); + const currentPauseMs = processData.paused_at + ? now - new Date(processData.paused_at).getTime() + : 0; + return Math.max(0, totalMs - pausedSec * 1000 - currentPauseMs); + }, [processData?.started_at, processData?.paused_at, processData?.total_paused_time, tick]); + + const formattedTime = useMemo(() => { + const totalSec = Math.floor(elapsedMs / 1000); + const h = String(Math.floor(totalSec / 3600)).padStart(2, "0"); + const m = String(Math.floor((totalSec % 3600) / 60)).padStart(2, "0"); + const s = String(totalSec % 60).padStart(2, "0"); + return `${h}:${m}:${s}`; + }, [elapsedMs]); + + const isPaused = !!processData?.paused_at; + const isStarted = !!processData?.started_at; + + const handleTimerAction = useCallback( + async (action: "start" | "pause" | "resume") => { + if (!workOrderProcessId) return; + try { + await apiClient.post("/api/pop/production/timer", { + workOrderProcessId, + action, + }); + // 타이머 상태 새로고침 + const res = await dataApi.getTableData("work_order_process", { + size: 1, + filters: { id: workOrderProcessId }, + }); + const proc = (res.data?.[0] ?? null) as ProcessTimerData | null; + if (proc) setProcessData(proc); + } catch { + toast.error("타이머 제어에 실패했습니다."); + } + }, + [workOrderProcessId] + ); + + // ======================================== + // D-FE5: 수량 등록 + 완료 + // ======================================== + + const handleQuantityRegister = useCallback(async () => { + if (!workOrderProcessId) return; + try { + await apiClient.post("/pop/execute-action", { + tasks: [ + { type: "data-update", targetTable: "work_order_process", targetColumn: "good_qty", value: goodQty || "0", items: [{ id: workOrderProcessId }] }, + { type: "data-update", targetTable: "work_order_process", targetColumn: "defect_qty", value: defectQty || "0", items: [{ id: workOrderProcessId }] }, + ], + data: { items: [{ id: workOrderProcessId }], fieldValues: {} }, + }); + toast.success("수량이 등록되었습니다."); + } catch { + toast.error("수량 등록에 실패했습니다."); + } + }, [workOrderProcessId, goodQty, defectQty]); + + const handleProcessComplete = useCallback(async () => { + if (!workOrderProcessId) return; + try { + await apiClient.post("/pop/execute-action", { + tasks: [ + { type: "data-update", targetTable: "work_order_process", targetColumn: "status", value: "completed", items: [{ id: workOrderProcessId }] }, + { type: "data-update", targetTable: "work_order_process", targetColumn: "completed_at", value: new Date().toISOString(), items: [{ id: workOrderProcessId }] }, + ], + data: { items: [{ id: workOrderProcessId }], fieldValues: {} }, + }); + toast.success("공정이 완료되었습니다."); + setProcessData((prev) => + prev ? { ...prev, status: "completed" } : prev + ); + } catch { + toast.error("공정 완료 처리에 실패했습니다."); + } + }, [workOrderProcessId]); + + // ======================================== + // 안전 장치 + // ======================================== + + if (!parentRow) { + return ( +
+ + 카드를 선택해주세요 +
+ ); + } + + if (!workOrderProcessId) { + return ( +
+ + 공정 정보를 찾을 수 없습니다 +
+ ); + } + + if (loading) { + return ( +
+ +
+ ); + } + + if (allResults.length === 0) { + return ( +
+ + 작업기준이 등록되지 않았습니다 +
+ ); + } + + const isProcessCompleted = processData?.status === "completed"; + + // ======================================== + // 렌더링 + // ======================================== + + return ( +
+ {/* 헤더 */} +
+

{processName}

+ {cfg.showTimer && ( +
+ + + {formattedTime} + + {!isProcessCompleted && ( + <> + {!isStarted && ( + + )} + {isStarted && !isPaused && ( + + )} + {isStarted && isPaused && ( + + )} + + )} +
+ )} +
+ + {/* 본문: 좌측 사이드바 + 우측 체크리스트 */} +
+ {/* 좌측 사이드바 */} +
+ {(["PRE", "IN", "POST"] as WorkPhase[]).map((phase) => { + const phaseGroups = groupsByPhase[phase]; + if (!phaseGroups || phaseGroups.length === 0) return null; + return ( +
+
+ {cfg.phaseLabels[phase] ?? phase} +
+ {phaseGroups.map((g) => ( + + ))} +
+ ); + })} +
+ + {/* 우측 체크리스트 */} +
+ {selectedGroupId && ( +
+ {currentItems.map((item) => ( + + ))} +
+ )} +
+
+ + {/* 하단: 수량 입력 + 완료 */} + {cfg.showQuantityInput && ( +
+ +
+ 양품 + setGoodQty(e.target.value)} + disabled={isProcessCompleted} + /> +
+
+ 불량 + setDefectQty(e.target.value)} + disabled={isProcessCompleted} + /> +
+ +
+ {!isProcessCompleted && ( + + )} + {isProcessCompleted && ( + + 완료됨 + + )} +
+ )} +
+ ); +} + +// ======================================== +// 체크리스트 개별 항목 +// ======================================== + +interface ChecklistItemProps { + item: WorkResultRow; + saving: boolean; + disabled: boolean; + onSave: ( + rowId: string, + resultValue: string, + isPassed: string | null, + newStatus: string + ) => void; +} + +function ChecklistItem({ item, saving, disabled, onSave }: ChecklistItemProps) { + const isSaving = saving; + const isDisabled = disabled || isSaving; + + switch (item.detail_type) { + case "check": + return ; + case "inspect": + return ; + case "input": + return ; + case "procedure": + return ; + case "material": + return ; + default: + return ( +
+ 알 수 없는 유형: {item.detail_type} +
+ ); + } +} + +// ===== check: 체크박스 ===== + +function CheckItem({ + item, + disabled, + saving, + onSave, +}: { + item: WorkResultRow; + disabled: boolean; + saving: boolean; + onSave: ChecklistItemProps["onSave"]; +}) { + const checked = item.result_value === "Y"; + return ( +
+ { + const val = v ? "Y" : "N"; + onSave(item.id, val, v ? "Y" : "N", v ? "completed" : "pending"); + }} + /> + {item.detail_label} + {saving && } + {item.status === "completed" && !saving && ( + + 완료 + + )} +
+ ); +} + +// ===== inspect: 측정값 입력 (범위 판정) ===== + +function InspectItem({ + item, + disabled, + saving, + onSave, +}: { + item: WorkResultRow; + disabled: boolean; + saving: boolean; + onSave: ChecklistItemProps["onSave"]; +}) { + const [inputVal, setInputVal] = useState(item.result_value ?? ""); + const lower = parseFloat(item.lower_limit ?? ""); + const upper = parseFloat(item.upper_limit ?? ""); + const hasRange = !isNaN(lower) && !isNaN(upper); + + const handleBlur = () => { + if (!inputVal || disabled) return; + const numVal = parseFloat(inputVal); + let passed: string | null = null; + if (hasRange) { + passed = numVal >= lower && numVal <= upper ? "Y" : "N"; + } + onSave(item.id, inputVal, passed, "completed"); + }; + + const isPassed = item.is_passed; + + return ( +
+
+ {item.detail_label} + {hasRange && ( + + 기준: {item.lower_limit} ~ {item.upper_limit} + {item.spec_value ? ` (표준: ${item.spec_value})` : ""} + + )} +
+
+ setInputVal(e.target.value)} + onBlur={handleBlur} + disabled={disabled} + placeholder="측정값 입력" + /> + {saving && } + {isPassed === "Y" && !saving && ( + 합격 + )} + {isPassed === "N" && !saving && ( + 불합격 + )} +
+
+ ); +} + +// ===== input: 자유 입력 ===== + +function InputItem({ + item, + disabled, + saving, + onSave, +}: { + item: WorkResultRow; + disabled: boolean; + saving: boolean; + onSave: ChecklistItemProps["onSave"]; +}) { + const [inputVal, setInputVal] = useState(item.result_value ?? ""); + const inputType = item.input_type === "number" ? "number" : "text"; + + const handleBlur = () => { + if (!inputVal || disabled) return; + onSave(item.id, inputVal, null, "completed"); + }; + + return ( +
+
{item.detail_label}
+
+ setInputVal(e.target.value)} + onBlur={handleBlur} + disabled={disabled} + placeholder="값 입력" + /> + {saving && } +
+
+ ); +} + +// ===== procedure: 절차 확인 (읽기 전용 + 체크) ===== + +function ProcedureItem({ + item, + disabled, + saving, + onSave, +}: { + item: WorkResultRow; + disabled: boolean; + saving: boolean; + onSave: ChecklistItemProps["onSave"]; +}) { + const checked = item.result_value === "Y"; + return ( +
+
+ {item.spec_value || item.detail_label} +
+
+ { + onSave(item.id, v ? "Y" : "N", null, v ? "completed" : "pending"); + }} + /> + 확인 + {saving && } +
+
+ ); +} + +// ===== material: 자재/LOT 입력 ===== + +function MaterialItem({ + item, + disabled, + saving, + onSave, +}: { + item: WorkResultRow; + disabled: boolean; + saving: boolean; + onSave: ChecklistItemProps["onSave"]; +}) { + const [inputVal, setInputVal] = useState(item.result_value ?? ""); + + const handleBlur = () => { + if (!inputVal || disabled) return; + onSave(item.id, inputVal, null, "completed"); + }; + + return ( +
+
{item.detail_label}
+
+ setInputVal(e.target.value)} + onBlur={handleBlur} + disabled={disabled} + placeholder="LOT 번호 입력" + /> + {saving && } +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx new file mode 100644 index 00000000..7b75cf78 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Input } from "@/components/ui/input"; +import type { PopWorkDetailConfig } from "../types"; + +interface PopWorkDetailConfigPanelProps { + config?: PopWorkDetailConfig; + onChange?: (config: PopWorkDetailConfig) => void; +} + +const DEFAULT_PHASE_LABELS: Record = { + PRE: "작업 전", + IN: "작업 중", + POST: "작업 후", +}; + +export function PopWorkDetailConfigPanel({ + config, + onChange, +}: PopWorkDetailConfigPanelProps) { + const cfg: PopWorkDetailConfig = { + showTimer: config?.showTimer ?? true, + showQuantityInput: config?.showQuantityInput ?? true, + phaseLabels: config?.phaseLabels ?? { ...DEFAULT_PHASE_LABELS }, + }; + + const update = (partial: Partial) => { + onChange?.({ ...cfg, ...partial }); + }; + + return ( +
+
+ + update({ showTimer: v })} + /> +
+ +
+ + update({ showQuantityInput: v })} + /> +
+ +
+ + {(["PRE", "IN", "POST"] as const).map((phase) => ( +
+ + {phase} + + + update({ + phaseLabels: { ...cfg.phaseLabels, [phase]: e.target.value }, + }) + } + /> +
+ ))} +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailPreview.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailPreview.tsx new file mode 100644 index 00000000..d5eed206 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailPreview.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { ClipboardCheck } from "lucide-react"; +import type { PopWorkDetailConfig } from "../types"; + +interface PopWorkDetailPreviewProps { + config?: PopWorkDetailConfig; +} + +export function PopWorkDetailPreviewComponent({ config }: PopWorkDetailPreviewProps) { + const labels = config?.phaseLabels ?? { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }; + return ( +
+ + + 작업 상세 + +
+ {Object.values(labels).map((l) => ( + + {l} + + ))} +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-work-detail/index.tsx b/frontend/lib/registry/pop-components/pop-work-detail/index.tsx new file mode 100644 index 00000000..941db8d4 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-work-detail/index.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { PopComponentRegistry } from "../../PopComponentRegistry"; +import { PopWorkDetailComponent } from "./PopWorkDetailComponent"; +import { PopWorkDetailConfigPanel } from "./PopWorkDetailConfig"; +import { PopWorkDetailPreviewComponent } from "./PopWorkDetailPreview"; +import type { PopWorkDetailConfig } from "../types"; + +const defaultConfig: PopWorkDetailConfig = { + showTimer: true, + showQuantityInput: true, + phaseLabels: { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }, +}; + +PopComponentRegistry.registerComponent({ + id: "pop-work-detail", + name: "작업 상세", + description: "공정별 체크리스트/검사/실적 상세 작업 화면", + category: "display", + icon: "ClipboardCheck", + component: PopWorkDetailComponent, + configPanel: PopWorkDetailConfigPanel, + preview: PopWorkDetailPreviewComponent, + defaultProps: defaultConfig, + connectionMeta: { + sendable: [ + { + key: "process_completed", + label: "공정 완료", + type: "event", + category: "event", + description: "공정 작업 전체 완료 이벤트", + }, + ], + receivable: [], + }, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index 3b7ff73e..a32a53cd 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -851,7 +851,8 @@ export interface CardCellDefinitionV2 { export interface ActionButtonUpdate { column: string; value?: string; - valueType: "static" | "currentUser" | "currentTime" | "columnRef"; + valueType: "static" | "currentUser" | "currentTime" | "columnRef" | "userInput"; + operationType?: "assign" | "add" | "subtract"; } // 액션 버튼 클릭 시 동작 모드 @@ -881,34 +882,49 @@ export interface SelectModeConfig { export interface SelectModeButtonConfig { label: string; variant: ButtonVariant; - clickMode: "status-change" | "modal-open" | "cancel-select"; + clickMode: "status-change" | "modal-open" | "cancel-select" | "quantity-input"; targetTable?: string; updates?: ActionButtonUpdate[]; confirmMessage?: string; modalScreenId?: string; + quantityInput?: QuantityInputConfig; } // ===== 버튼 중심 구조 (신규) ===== export interface ActionButtonShowCondition { - type: "timeline-status" | "column-value" | "always"; + type: "timeline-status" | "column-value" | "always" | "owner-match"; value?: string; column?: string; unmatchBehavior?: "hidden" | "disabled"; } export interface ActionButtonClickAction { - type: "immediate" | "select-mode" | "modal-open"; + type: "immediate" | "select-mode" | "modal-open" | "quantity-input"; targetTable?: string; updates?: ActionButtonUpdate[]; confirmMessage?: string; selectModeButtons?: SelectModeButtonConfig[]; modalScreenId?: string; - // 외부 테이블 조인 설정 (DB 직접 선택 시) joinConfig?: { - sourceColumn: string; // 메인 테이블의 FK 컬럼 - targetColumn: string; // 외부 테이블의 매칭 컬럼 + sourceColumn: string; + targetColumn: string; }; + quantityInput?: QuantityInputConfig; + preCondition?: ActionPreCondition; +} + +export interface QuantityInputConfig { + maxColumn?: string; + currentColumn?: string; + unit?: string; + enablePackage?: boolean; +} + +export interface ActionPreCondition { + column: string; + expectedValue: string; + failMessage?: string; } export interface ActionButtonDef { @@ -976,6 +992,7 @@ export interface PopCardListV2Config { cartAction?: CardCartActionConfig; cartListMode?: CartListModeConfig; saveMapping?: CardListSaveMapping; + ownerSortColumn?: string; } /** 카드 컴포넌트가 하위 필터 적용 시 주입하는 가상 컬럼 키 */ @@ -983,3 +1000,14 @@ export const VIRTUAL_SUB_STATUS = "__subStatus__" as const; export const VIRTUAL_SUB_SEMANTIC = "__subSemantic__" as const; export const VIRTUAL_SUB_PROCESS = "__subProcessName__" as const; export const VIRTUAL_SUB_SEQ = "__subSeqNo__" as const; + + +// ============================================= +// pop-work-detail 전용 타입 +// ============================================= + +export interface PopWorkDetailConfig { + showTimer: boolean; + showQuantityInput: boolean; + phaseLabels: Record; +} diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index cc145262..1d8a3197 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -59,7 +59,8 @@ export type ButtonActionType = | "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간) | "quickInsert" // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT) | "event" // 이벤트 버스로 이벤트 발송 (스케줄 생성 등) - | "approval"; // 결재 요청 + | "approval" // 결재 요청 + | "apiCall"; // 범용 API 호출 (생산계획 자동 스케줄 등) /** * 버튼 액션 설정 @@ -286,6 +287,18 @@ export interface ButtonActionConfig { eventName: string; // 발송할 이벤트 이름 (V2_EVENTS 키) eventPayload?: Record; // 이벤트 페이로드 (requestId는 자동 생성) }; + + // 범용 API 호출 관련 (apiCall 액션용) + apiCallConfig?: { + method: "GET" | "POST" | "PUT" | "DELETE"; + endpoint: string; // 예: "/api/production/generate-schedule" + payloadMapping?: Record; // formData 필드 → API body 필드 매핑 + staticPayload?: Record; // 고정 페이로드 값 + useSelectedRows?: boolean; // true면 선택된 행 데이터를 body에 포함 + selectedRowsKey?: string; // 선택된 행 데이터의 key (기본: "items") + refreshAfterSuccess?: boolean; // 성공 후 테이블 새로고침 (기본: true) + confirmMessage?: string; // 실행 전 확인 메시지 + }; } /** @@ -457,6 +470,9 @@ export class ButtonActionExecutor { case "event": return await this.handleEvent(config, context); + case "apiCall": + return await this.handleApiCall(config, context); + case "approval": return this.handleApproval(config, context); @@ -7681,6 +7697,97 @@ export class ButtonActionExecutor { } } + /** + * 범용 API 호출 (생산계획 자동 스케줄 등) + */ + private static async handleApiCall(config: ButtonActionConfig, context: ButtonActionContext): Promise { + try { + const { apiCallConfig } = config; + + if (!apiCallConfig?.endpoint) { + toast.error("API 엔드포인트가 설정되지 않았습니다."); + return false; + } + + // 확인 메시지 + if (apiCallConfig.confirmMessage) { + const confirmed = window.confirm(apiCallConfig.confirmMessage); + if (!confirmed) return false; + } + + // 페이로드 구성 + let payload: Record = { ...(apiCallConfig.staticPayload || {}) }; + + // formData에서 매핑 + if (apiCallConfig.payloadMapping && context.formData) { + for (const [formField, apiField] of Object.entries(apiCallConfig.payloadMapping)) { + if (context.formData[formField] !== undefined) { + payload[apiField] = context.formData[formField]; + } + } + } + + // 선택된 행 데이터 포함 + if (apiCallConfig.useSelectedRows && context.selectedRowsData) { + const key = apiCallConfig.selectedRowsKey || "items"; + payload[key] = context.selectedRowsData; + } + + console.log("[handleApiCall] API 호출:", { + method: apiCallConfig.method, + endpoint: apiCallConfig.endpoint, + payload, + }); + + // API 호출 + const { apiClient } = await import("@/lib/api/client"); + let response: any; + + switch (apiCallConfig.method) { + case "GET": + response = await apiClient.get(apiCallConfig.endpoint, { params: payload }); + break; + case "POST": + response = await apiClient.post(apiCallConfig.endpoint, payload); + break; + case "PUT": + response = await apiClient.put(apiCallConfig.endpoint, payload); + break; + case "DELETE": + response = await apiClient.delete(apiCallConfig.endpoint, { data: payload }); + break; + } + + const result = response?.data; + + if (result?.success === false) { + toast.error(result.message || "API 호출에 실패했습니다."); + return false; + } + + // 성공 메시지 + if (config.successMessage) { + toast.success(config.successMessage); + } + + // 테이블 새로고침 + if (apiCallConfig.refreshAfterSuccess !== false) { + const { v2EventBus, V2_EVENTS } = await import("@/lib/v2-core"); + v2EventBus.emitSync(V2_EVENTS.TABLE_REFRESH, { + tableName: context.tableName, + target: "all", + }); + } + + return true; + } catch (error: any) { + console.error("[handleApiCall] API 호출 오류:", error); + const msg = error?.response?.data?.message || error?.message || "API 호출 중 오류가 발생했습니다."; + toast.error(msg); + return false; + } + } + /** * 결재 요청 모달 열기 */ @@ -7843,4 +7950,8 @@ export const DEFAULT_BUTTON_ACTIONS: Record=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",