diff --git a/.gitignore b/.gitignore index 197ad216..2566257f 100644 --- a/.gitignore +++ b/.gitignore @@ -191,3 +191,7 @@ mcp-task-queue/ .cursor/rules/multi-agent-tester.mdc .cursor/rules/multi-agent-reviewer.mdc .cursor/rules/multi-agent-knowledge.mdc + +# 파이프라인 회고록 (자동 생성) +docs/retrospectives/ +mes-architecture-guide.md \ No newline at end of file diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 41926dd0..9de5f66c 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -143,6 +143,7 @@ import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스) import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력 import moldRoutes from "./routes/moldRoutes"; // 금형 관리 +import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -335,6 +336,7 @@ app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테 app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준 app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력 app.use("/api/mold", moldRoutes); // 금형 관리 +app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 diff --git a/backend-node/src/controllers/shippingPlanController.ts b/backend-node/src/controllers/shippingPlanController.ts new file mode 100644 index 00000000..e89e14c2 --- /dev/null +++ b/backend-node/src/controllers/shippingPlanController.ts @@ -0,0 +1,459 @@ +/** + * 출하계획 컨트롤러 + * + * 수주 마스터(sales_order_mng, INTEGER id) 또는 + * 수주 디테일(sales_order_detail, UUID id) 양쪽에서 호출 가능. + * + * ID 포맷으로 소스 테이블 자동 감지 → JOIN으로 완전한 정보 조합 + */ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +// UUID 포맷 감지 (하이픈 포함 36자) +const isUUID = (val: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + val + ); + +type SourceTable = "master" | "detail"; + +interface NormalizedOrder { + sourceId: string; // 원본 ID (master: 정수, detail: UUID) + masterId: number | null; + detailId: string | null; + orderNo: string; + partCode: string; + partName: string; + partnerCode: string; + partnerName: string; + dueDate: string; + orderQty: number; + shipQty: number; + balanceQty: number; +} + +// ─── 소스 테이블 감지 ─── + +function detectSource(ids: string[]): SourceTable { + if (ids.length === 0) return "detail"; + return ids.every((id) => isUUID(id)) ? "detail" : "master"; +} + +// ─── 수주 정보 정규화 (마스터/디테일 양쪽 JOIN) ─── + +async function getNormalizedOrders( + companyCode: string, + ids: string[], + source: SourceTable +): Promise { + const pool = getPool(); + + if (source === "detail") { + // 디테일 기준 → 마스터 JOIN (order_no), 거래처 JOIN (customer_mng) + // item_info는 LATERAL로 1건만 매칭 (item_number 중복 대비) + const res = await pool.query( + `SELECT + d.id AS detail_id, + m.id AS master_id, + d.order_no, + d.part_code, + COALESCE(d.part_name, i.item_name, d.part_code) AS part_name, + COALESCE(d.delivery_partner_code, m.partner_id, '') AS partner_code, + COALESCE(c.customer_name, d.delivery_partner_code, m.partner_id, '') AS partner_name, + COALESCE(d.due_date, m.due_date::text, '') AS due_date, + COALESCE(NULLIF(d.qty,'')::numeric, m.order_qty, 0) AS order_qty, + COALESCE(NULLIF(d.ship_qty,'')::numeric, m.ship_qty, 0) AS ship_qty, + COALESCE(NULLIF(d.balance_qty,'')::numeric, m.balance_qty, 0) AS balance_qty + FROM sales_order_detail d + LEFT JOIN sales_order_mng m + ON d.order_no = m.order_no AND d.company_code = m.company_code + LEFT JOIN LATERAL ( + SELECT item_name FROM item_info + WHERE item_number = d.part_code AND company_code = d.company_code + LIMIT 1 + ) i ON true + LEFT JOIN customer_mng c + ON COALESCE(d.delivery_partner_code, m.partner_id) = c.customer_code + AND d.company_code = c.company_code + WHERE d.company_code = $1 + AND d.id = ANY($2::text[])`, + [companyCode, ids] + ); + + return res.rows.map((r) => ({ + sourceId: r.detail_id, + masterId: r.master_id, + detailId: r.detail_id, + orderNo: r.order_no || "", + partCode: r.part_code || "", + partName: r.part_name || "", + partnerCode: r.partner_code || "", + partnerName: r.partner_name || "", + dueDate: r.due_date || "", + orderQty: Number(r.order_qty || 0), + shipQty: Number(r.ship_qty || 0), + balanceQty: Number(r.balance_qty || 0), + })); + } else { + // 마스터 기준 → 거래처 JOIN + const numericIds = ids.map(Number).filter((n) => !isNaN(n)); + // item_info는 LATERAL로 1건만 매칭 (item_number 중복 대비) + const res = await pool.query( + `SELECT + m.id AS master_id, + NULL AS detail_id, + m.order_no, + m.part_code, + COALESCE(m.part_name, i.item_name, m.part_code, '') AS part_name, + COALESCE(m.partner_id, '') AS partner_code, + COALESCE(c.customer_name, m.partner_id, '') AS partner_name, + COALESCE(m.due_date::text, '') AS due_date, + COALESCE(m.order_qty, 0) AS order_qty, + COALESCE(m.ship_qty, 0) AS ship_qty, + COALESCE(m.balance_qty, 0) AS balance_qty + FROM sales_order_mng m + LEFT JOIN LATERAL ( + SELECT item_name FROM item_info + WHERE item_number = m.part_code AND company_code = m.company_code + LIMIT 1 + ) i ON true + LEFT JOIN customer_mng c + ON m.partner_id = c.customer_code AND m.company_code = c.company_code + WHERE m.company_code = $1 + AND m.id = ANY($2::int[])`, + [companyCode, numericIds] + ); + + return res.rows.map((r) => ({ + sourceId: String(r.master_id), + masterId: r.master_id, + detailId: null, + orderNo: r.order_no || "", + partCode: r.part_code || "", + partName: r.part_name || "", + partnerCode: r.partner_code || "", + partnerName: r.partner_name || "", + dueDate: r.due_date || "", + orderQty: Number(r.order_qty || 0), + shipQty: Number(r.ship_qty || 0), + balanceQty: Number(r.balance_qty || 0), + })); + } +} + +// ─── 품목별 집계 + 기존 출하계획 조회 ─── + +export async function getAggregate(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { ids } = req.query; + + if (!ids) { + return res + .status(400) + .json({ success: false, message: "ids 파라미터가 필요합니다" }); + } + + const idList = (ids as string).split(",").filter(Boolean); + if (idList.length === 0) { + return res + .status(400) + .json({ success: false, message: "유효한 ID가 필요합니다" }); + } + + const source = detectSource(idList); + logger.info("출하계획 집계 조회", { + companyCode, + source, + idCount: idList.length, + }); + + // 1) 정규화된 수주 정보 조회 (JOIN 포함) + const orders = await getNormalizedOrders(companyCode, idList, source); + + if (orders.length === 0) { + return res + .status(404) + .json({ success: false, message: "해당 수주를 찾을 수 없습니다" }); + } + + // 2) 품목별 그룹핑 + const partCodeMap = new Map(); + for (const order of orders) { + const key = order.partCode || "UNKNOWN"; + if (!partCodeMap.has(key)) partCodeMap.set(key, []); + partCodeMap.get(key)!.push(order); + } + + const pool = getPool(); + const result: Record = {}; + + for (const [partCode, partOrders] of partCodeMap) { + // 총수주잔량: 선택된 수주들의 balance_qty 합 + const totalBalance = partOrders.reduce( + (s, o) => s + (o.balanceQty > 0 ? o.balanceQty : o.orderQty - o.shipQty), + 0 + ); + + // 기존 출하계획 조회 (detail_id 또는 sales_order_id 기준) + let existingPlans: any[] = []; + if (source === "detail") { + const planDetailIds = partOrders + .map((o) => o.detailId) + .filter(Boolean); + if (planDetailIds.length > 0) { + const planRes = await pool.query( + `SELECT id, detail_id, sales_order_id, plan_qty, plan_date, + shipment_plan_no, status + FROM shipment_plan + WHERE company_code = $1 AND detail_id = ANY($2::text[]) + ORDER BY created_date DESC`, + [companyCode, planDetailIds] + ); + existingPlans = planRes.rows.map((r) => ({ + id: r.id, + sourceId: r.detail_id, + planQty: Number(r.plan_qty || 0), + planDate: r.plan_date, + shipmentPlanNo: r.shipment_plan_no, + status: r.status, + })); + } + } else { + const planMasterIds = partOrders + .map((o) => o.masterId) + .filter((id): id is number => id != null); + if (planMasterIds.length > 0) { + const planRes = await pool.query( + `SELECT id, sales_order_id, detail_id, plan_qty, plan_date, + shipment_plan_no, status + FROM shipment_plan + WHERE company_code = $1 AND sales_order_id = ANY($2::int[]) + ORDER BY created_date DESC`, + [companyCode, planMasterIds] + ); + existingPlans = planRes.rows.map((r) => ({ + id: r.id, + sourceId: String(r.sales_order_id), + planQty: Number(r.plan_qty || 0), + planDate: r.plan_date, + shipmentPlanNo: r.shipment_plan_no, + status: r.status, + })); + } + } + + const totalPlanQty = existingPlans.reduce((s, p) => s + p.planQty, 0); + + // 현재고 + const stockRes = await pool.query( + `SELECT COALESCE(SUM(current_qty::numeric), 0) AS current_stock + FROM inventory_stock + WHERE company_code = $1 AND item_code = $2`, + [companyCode, partCode] + ); + const currentStock = Number(stockRes.rows[0]?.current_stock || 0); + + // 생산중수량 + const prodRes = await pool.query( + `SELECT COALESCE(SUM(plan_qty - COALESCE(completed_qty, 0)), 0) AS in_production + FROM production_plan_mng + WHERE company_code = $1 + AND item_code = $2 + AND status IN ('in_progress', 'planned')`, + [companyCode, partCode] + ); + const inProductionQty = Number(prodRes.rows[0]?.in_production || 0); + + result[partCode] = { + totalBalance, + totalPlanQty, + currentStock, + availableStock: currentStock - totalPlanQty, + inProductionQty, + existingPlans, + orders: partOrders.map((o) => ({ + sourceId: o.sourceId, + orderNo: o.orderNo, + partCode: o.partCode, + partName: o.partName, + partnerName: o.partnerName, + dueDate: o.dueDate, + orderQty: o.orderQty, + shipQty: o.shipQty, + balanceQty: o.balanceQty, + })), + }; + } + + logger.info("출하계획 집계 조회 완료", { + companyCode, + source, + partCodes: Array.from(partCodeMap.keys()), + orderCount: orders.length, + }); + + return res.json({ success: true, data: result, source }); + } catch (error: any) { + logger.error("출하계획 집계 조회 실패", { + error: error.message, + stack: error.stack, + }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 출하계획 일괄 저장 ─── + +export async function batchSave(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { plans, source } = req.body; + + if (!Array.isArray(plans) || plans.length === 0) { + return res.status(400).json({ + success: false, + message: "저장할 출하계획 데이터가 필요합니다", + }); + } + + // source 자동 감지 (프론트에서 전달, 또는 ID 포맷으로 추론) + const detectedSource: SourceTable = + source || detectSource(plans.map((p: any) => String(p.sourceId))); + + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + const savedPlans = []; + + for (const plan of plans) { + const { sourceId, planQty, planDate } = plan; + if (!sourceId || !planQty || planQty <= 0) continue; + const planDateValue = planDate || null; + + if (detectedSource === "detail") { + // 디테일 소스: detail_id로 저장 + const detailCheck = await client.query( + `SELECT d.id, d.order_no, d.part_code, d.qty, d.ship_qty, d.balance_qty, + m.id AS master_id + FROM sales_order_detail d + LEFT JOIN sales_order_mng m + ON d.order_no = m.order_no AND d.company_code = m.company_code + WHERE d.id = $1 AND d.company_code = $2`, + [sourceId, companyCode] + ); + + if (detailCheck.rowCount === 0) { + throw new Error(`수주상세 ${sourceId}을 찾을 수 없습니다`); + } + + const detail = detailCheck.rows[0]; + const qty = Number(detail.qty || 0); + const shipQty = Number(detail.ship_qty || 0); + const balanceQty = detail.balance_qty + ? Number(detail.balance_qty) + : qty - shipQty; + + if (balanceQty > 0 && planQty > balanceQty) { + throw new Error( + `수주번호 ${detail.order_no}: 출하계획량(${planQty})이 미출하량(${balanceQty})을 초과합니다` + ); + } + + const insertRes = await client.query( + `INSERT INTO shipment_plan + (company_code, detail_id, sales_order_id, plan_qty, plan_date, status, created_by) + VALUES ($1, $2, $3, $4, COALESCE($5::date, CURRENT_DATE), 'READY', $6) + RETURNING *`, + [companyCode, sourceId, detail.master_id, planQty, planDateValue, userId] + ); + savedPlans.push(insertRes.rows[0]); + + // detail ship_qty 업데이트 + await client.query( + `UPDATE sales_order_detail + SET ship_qty = (COALESCE(NULLIF(ship_qty,'')::numeric, 0) + $1)::text, + balance_qty = (COALESCE(NULLIF(qty,'')::numeric, 0) + - COALESCE(NULLIF(ship_qty,'')::numeric, 0) - $1)::text, + updated_date = NOW() + WHERE id = $2 AND company_code = $3`, + [planQty, sourceId, companyCode] + ); + } else { + // 마스터 소스: sales_order_id로 저장 + const masterId = Number(sourceId); + const masterCheck = await client.query( + `SELECT id, order_no, order_qty, ship_qty, balance_qty + FROM sales_order_mng + WHERE id = $1 AND company_code = $2`, + [masterId, companyCode] + ); + + if (masterCheck.rowCount === 0) { + throw new Error(`수주 ID ${masterId}을 찾을 수 없습니다`); + } + + const master = masterCheck.rows[0]; + const balanceQty = Number(master.balance_qty || 0); + + if (balanceQty > 0 && planQty > balanceQty) { + throw new Error( + `수주번호 ${master.order_no}: 출하계획량(${planQty})이 미출하량(${balanceQty})을 초과합니다` + ); + } + + const insertRes = await client.query( + `INSERT INTO shipment_plan + (company_code, sales_order_id, plan_qty, plan_date, status, created_by) + VALUES ($1, $2, $3, COALESCE($4::date, CURRENT_DATE), 'READY', $5) + RETURNING *`, + [companyCode, masterId, planQty, planDateValue, userId] + ); + savedPlans.push(insertRes.rows[0]); + + // 마스터 ship_qty 업데이트 + await client.query( + `UPDATE sales_order_mng + SET ship_qty = COALESCE(ship_qty, 0) + $1, + balance_qty = COALESCE(order_qty, 0) - COALESCE(ship_qty, 0) - $1, + updated_date = NOW() + WHERE id = $2 AND company_code = $3`, + [planQty, masterId, companyCode] + ); + } + } + + await client.query("COMMIT"); + + logger.info("출하계획 일괄 저장 완료", { + companyCode, + source: detectedSource, + savedCount: savedPlans.length, + userId, + }); + + return res.json({ + success: true, + message: `${savedPlans.length}건 저장 완료`, + data: savedPlans, + }); + } catch (txError) { + await client.query("ROLLBACK"); + throw txError; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("출하계획 일괄 저장 실패", { + error: error.message, + stack: error.stack, + }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/routes/shippingPlanRoutes.ts b/backend-node/src/routes/shippingPlanRoutes.ts new file mode 100644 index 00000000..16ff0050 --- /dev/null +++ b/backend-node/src/routes/shippingPlanRoutes.ts @@ -0,0 +1,19 @@ +/** + * 출하계획 라우트 + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as shippingPlanController from "../controllers/shippingPlanController"; + +const router = Router(); + +router.use(authenticateToken); + +// 품목별 집계 + 기존 출하계획 조회 +router.get("/aggregate", shippingPlanController.getAggregate); + +// 출하계획 일괄 저장 +router.post("/batch", shippingPlanController.batchSave); + +export default router; diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 6f0848e2..219159e0 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -952,13 +952,20 @@ export class NodeFlowExecutionService { } const schemaPrefix = schema ? `${schema}.` : ""; + + // WHERE 조건에서 field 값 조회를 위해 컨텍스트 데이터 전달 + // sourceData(저장된 폼 데이터) + buttonContext(인증 정보) 병합 + const contextForWhere = { + ...(context.buttonContext || {}), + ...(context.sourceData?.[0] || {}), + }; const whereResult = whereConditions - ? this.buildWhereClause(whereConditions) + ? this.buildWhereClause(whereConditions, contextForWhere) : { clause: "", values: [] }; const sql = `SELECT * FROM ${schemaPrefix}${tableName} ${whereResult.clause}`; - logger.info(`📊 테이블 전체 데이터 조회 SQL: ${sql}`); + logger.info(`📊 테이블 전체 데이터 조회 SQL: ${sql}`, { values: whereResult.values }); const result = await query(sql, whereResult.values); diff --git a/docs/shipping-plan-editor-plan.md b/docs/shipping-plan-editor-plan.md new file mode 100644 index 00000000..1f2f95d3 --- /dev/null +++ b/docs/shipping-plan-editor-plan.md @@ -0,0 +1,176 @@ +# 출하계획 동시 등록 컴포넌트 (v2-shipping-plan-editor) 설계서 + +## 개요 + +수주 목록에서 다건 선택 후 "출하계획" 버튼 클릭 시 모달로 열리는 출하계획 일괄 등록 화면. +기존 ScreenModal + modalScreenId 매커니즘을 활용하여, DB 기반 화면(screen_definitions)으로 구현한다. + +## 핵심 기능 + +1. 선택된 수주를 **품목(part_code) 기준으로 그룹핑** +2. 그룹별 **5칸 집계 카드**: 총수주잔량, 총 출하계획량, 현재고, 가용재고, 생산중수량 +3. 그룹별 상세 테이블: 기존 계획(기존) + 신규 입력(신규) 구분 표시 +4. 출하계획량만 입력 → 확인 시 shipment_plan에 일괄 INSERT + +## 테이블 관계 + +``` +sales_order_mng (수주) + ├─ id (PK) + ├─ part_code (품목코드) ← 그룹핑 기준 + ├─ part_name (품명) + ├─ order_qty (수주수량) + ├─ ship_qty (출하수량) + ├─ balance_qty (잔량) = order_qty - ship_qty + ├─ partner_id (거래처) + └─ due_date (납기일) + +shipment_plan (출하계획) + ├─ sales_order_id (FK → sales_order_mng.id) + ├─ plan_qty (출하계획수량) + ├─ plan_date (출하예정일) + ├─ shipment_plan_no (자동 채번) + └─ status (READY) + +inventory_stock (재고) + ├─ item_code (품목코드) + └─ current_qty (현재고) + +production_plan_mng (생산계획) + ├─ item_code (품목코드) + ├─ plan_qty (계획수량) + ├─ completed_qty (완료수량) + └─ status (진행중 = in_progress / planned) +``` + +## 집계 카드 데이터 소스 + +| 카드 | 계산 방법 | +|------|----------| +| 총수주잔량 | SUM(sales_order_mng.balance_qty) WHERE part_code = ? | +| 총 출하계획량 | SUM(shipment_plan.plan_qty) WHERE sales_order_id IN (해당 품목 수주들) | +| 현재고 | SUM(inventory_stock.current_qty) WHERE item_code = part_code | +| 가용재고 | 현재고 - 총 출하계획량 (기존 계획분) | +| 생산중수량 | SUM(production_plan_mng.plan_qty - completed_qty) WHERE item_code = part_code AND status IN ('in_progress', 'planned') | + +## 상세 테이블 컬럼 + +| 컬럼 | 소스 | 편집 | +|------|------|------| +| 구분 | "기존" or "신규" | 읽기 전용 (배지) | +| 수주번호 | sales_order_mng.order_no | 읽기 전용 | +| 거래처 | sales_order_mng.partner_id (엔티티 조인) | 읽기 전용 | +| 납기일 | sales_order_mng.due_date | 읽기 전용 | +| 미출하 | sales_order_mng.balance_qty | 읽기 전용 | +| 출하계획량 | 입력값 / shipment_plan.plan_qty | **입력 가능** | + +## 데이터 흐름 + +``` +1. 수주 목록에서 체크박스 선택 → "출하계획" 버튼 클릭 +2. openScreenModal 이벤트 발생 (selectedData = 선택된 수주 배열) +3. ScreenModal이 모달 화면 로드 (v2-shipping-plan-editor 컴포넌트) +4. 컴포넌트가 groupedData (= selectedData) 수신 +5. part_code 기준 그룹핑 +6. 백엔드 API 호출: GET /api/shipping-plan/aggregate + → 품목별 재고, 생산중수량, 기존 출하계획 조회 +7. UI 렌더링 (집계 카드 + 상세 테이블) +8. 사용자가 출하계획량 입력 +9. 확인 버튼 → POST /api/shipping-plan/batch + → shipment_plan INSERT + sales_order_mng.plan_ship_qty UPDATE +``` + +## 파일 구조 + +``` +frontend/lib/registry/components/v2-shipping-plan-editor/ + ├── index.ts # createComponentDefinition + ├── ShippingPlanEditorRenderer.tsx # AutoRegisteringComponentRenderer + ├── ShippingPlanEditorComponent.tsx # 메인 UI 컴포넌트 + └── types.ts # 타입 정의 + +frontend/lib/api/ + └── shipping.ts # API 클라이언트 함수 + +backend-node/src/ + ├── controllers/shippingPlanController.ts # API 핸들러 + └── routes/shippingPlanRoutes.ts # 라우터 +``` + +## 백엔드 API + +### GET /api/shipping-plan/aggregate +품목별 집계 + 기존 출하계획 조회 + +Request: `?partCodes=ITEM001,SEAL-100&orderIds=172,175,178` +Response: +```json +{ + "success": true, + "data": { + "ITEM001": { + "totalBalance": 1700, + "totalPlanQty": 500, + "currentStock": 1000, + "availableStock": 500, + "inProductionQty": 300, + "existingPlans": [ + { "id": 76, "salesOrderId": 172, "planQty": 500, "planDate": "2025-12-10", "shipmentPlanNo": "SPL-..." } + ] + } + } +} +``` + +### POST /api/shipping-plan/batch +출하계획 일괄 저장 + +Request: +```json +{ + "plans": [ + { "salesOrderId": 172, "planQty": 1000 }, + { "salesOrderId": 175, "planQty": 500 } + ] +} +``` + +## 구현 상태 + +### 완료 +- [x] types.ts (타입 정의) +- [x] index.ts (컴포넌트 정의) +- [x] ShippingPlanEditorRenderer.tsx (레지스트리 등록) +- [x] ShippingPlanEditorComponent.tsx (메인 UI) +- [x] frontend/lib/api/shipping.ts (API 클라이언트) +- [x] backend-node/src/controllers/shippingPlanController.ts (집계 + 일괄 저장) +- [x] backend-node/src/routes/shippingPlanRoutes.ts (라우터) +- [x] screen_definitions (screen_id: 4573, screen_code: *_SHIP_PLAN_EDITOR) +- [x] screen_layouts_v2 (layout_id: 11562) + +### 연동 정보 +| 항목 | 마스터(*) | 탑씰(COMPANY_7) | +|------|-----------|-----------------| +| screen_id | 4573 | 4574 | +| screen_code | *_SHIP_PLAN_EDITOR | TOPSEAL_SHIP_PLAN_EDITOR | +| layout_id | 11562 | 11563 | + +탑씰 수주관리 화면(screen_id: 156)의 "출하계획" 버튼(comp_33659)이 +targetScreenId: 4574로 연결되어, 체크박스 선택 → 버튼 클릭 → 모달 오픈. +선택된 수주 데이터는 `groupedData` prop으로 전달됨. + +## 테스트 계획 + +### 1단계: 기본 기능 +- [ ] 수주 선택 → 모달 열기 → groupedData 수신 확인 +- [ ] part_code 기준 그룹핑 확인 +- [ ] 집계 카드 데이터 표시 확인 + +### 2단계: CRUD +- [ ] 출하계획량 입력 → 집계 자동 재계산 +- [ ] 확인 버튼 → shipment_plan INSERT 확인 +- [ ] 기존 계획 "기존" 배지 표시 확인 + +### 3단계: 검증 +- [ ] 출하계획량 > 미출하 시 에러 처리 +- [ ] 멀티테넌시 (company_code) 필터링 확인 diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 356d55c3..bf7426e5 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -217,10 +217,16 @@ export default function TableManagementPage() { // 메모이제이션된 입력타입 옵션 const memoizedInputTypeOptions = useMemo(() => inputTypeOptions, []); - // 참조 테이블 옵션 (실제 테이블 목록에서 가져옴) + // 참조 테이블 옵션 (한글라벨 (영어명) 동시 표시) const referenceTableOptions = [ { value: "none", label: getTextFromUI(TABLE_MANAGEMENT_KEYS.LABEL_NONE, "선택 안함") }, - ...tables.map((table) => ({ value: table.tableName, label: table.displayName || table.tableName })), + ...tables.map((table) => ({ + value: table.tableName, + label: + table.displayName && table.displayName !== table.tableName + ? `${table.displayName} (${table.tableName})` + : table.tableName, + })), ]; // 공통 코드 카테고리 목록 상태 @@ -1586,6 +1592,20 @@ export default function TableManagementPage() { selectedColumn={selectedColumn} onSelectColumn={setSelectedColumn} onColumnChange={(columnName, field, value) => { + if (field === "isUnique") { + const currentColumn = columns.find((c) => c.columnName === columnName); + if (currentColumn) { + handleUniqueToggle(columnName, currentColumn.isUnique || "NO"); + } + return; + } + if (field === "isNullable") { + const currentColumn = columns.find((c) => c.columnName === columnName); + if (currentColumn) { + handleNullableToggle(columnName, currentColumn.isNullable || "YES"); + } + return; + } const idx = columns.findIndex((c) => c.columnName === columnName); if (idx >= 0) handleColumnChange(idx, field, value); }} @@ -1596,6 +1616,8 @@ export default function TableManagementPage() { onIndexToggle={(columnName, checked) => handleIndexToggle(columnName, "index", checked) } + tables={tables} + referenceTableColumns={referenceTableColumns} /> )} @@ -1795,11 +1817,16 @@ export default function TableManagementPage() {

변경될 PK 컬럼:

{pendingPkColumns.length > 0 ? (
- {pendingPkColumns.map((col) => ( - - {col} - - ))} + {pendingPkColumns.map((col) => { + const colInfo = columns.find((c) => c.columnName === col); + return ( + + {colInfo?.displayName && colInfo.displayName !== col + ? `${colInfo.displayName} (${col})` + : col} + + ); + })}
) : (

PK가 모두 제거됩니다

diff --git a/frontend/components/admin/CompanySwitcher.tsx b/frontend/components/admin/CompanySwitcher.tsx index c37d82a5..23445780 100644 --- a/frontend/components/admin/CompanySwitcher.tsx +++ b/frontend/components/admin/CompanySwitcher.tsx @@ -174,6 +174,8 @@ export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProp ? "bg-accent/50 font-semibold" : "" }`} + role="button" + aria-label={`${company.company_name} ${company.company_code}`} onClick={() => handleCompanySwitch(company.company_code)} >
diff --git a/frontend/components/admin/table-type/ColumnDetailPanel.tsx b/frontend/components/admin/table-type/ColumnDetailPanel.tsx index 1d053775..0d770dc9 100644 --- a/frontend/components/admin/table-type/ColumnDetailPanel.tsx +++ b/frontend/components/admin/table-type/ColumnDetailPanel.tsx @@ -76,9 +76,34 @@ export function ColumnDetailPanel({ if (!column) return null; - const refTableOpts = referenceTableOptions.length - ? referenceTableOptions - : [{ value: "none", label: "선택 안함" }, ...tables.map((t) => ({ value: t.tableName, label: t.displayName || t.tableName }))]; + const refTableOpts = useMemo(() => { + const hasKorean = (s: string) => /[가-힣]/.test(s); + const raw = referenceTableOptions.length + ? [...referenceTableOptions] + : [ + { value: "none", label: "없음" }, + ...tables.map((t) => ({ + value: t.tableName, + label: + t.displayName && t.displayName !== t.tableName + ? `${t.displayName} (${t.tableName})` + : t.tableName, + })), + ]; + + const noneOpt = raw.find((o) => o.value === "none"); + const rest = raw.filter((o) => o.value !== "none"); + + rest.sort((a, b) => { + const aK = hasKorean(a.label); + const bK = hasKorean(b.label); + if (aK && !bK) return -1; + if (!aK && bK) return 1; + return a.label.localeCompare(b.label, "ko"); + }); + + return noneOpt ? [noneOpt, ...rest] : rest; + }, [referenceTableOptions, tables]); return (
@@ -90,7 +115,11 @@ export function ColumnDetailPanel({ {typeConf.label} )} - {column.columnName} + + {column.displayName && column.displayName !== column.columnName + ? `${column.displayName} (${column.columnName})` + : column.columnName} +
@@ -245,7 +289,14 @@ export function ColumnDetailPanel({ column.referenceColumn === refCol.columnName ? "opacity-100" : "opacity-0", )} /> - {refCol.columnName} + {refCol.displayName && refCol.displayName !== refCol.columnName ? ( +
+ {refCol.displayName} + {refCol.columnName} +
+ ) : ( + {refCol.columnName} + )} ))} @@ -259,12 +310,20 @@ export function ColumnDetailPanel({ {/* 참조 요약 미니맵 */} {column.referenceTable && column.referenceTable !== "none" && column.referenceColumn && (
- - {column.referenceTable} + + {(() => { + const tbl = refTableOpts.find((o) => o.value === column.referenceTable); + return tbl?.label ?? column.referenceTable; + })()} - - {column.referenceColumn} + + {(() => { + const col = refColumns.find((c) => c.columnName === column.referenceColumn); + return col?.displayName && col.displayName !== column.referenceColumn + ? `${col.displayName} (${column.referenceColumn})` + : column.referenceColumn; + })()}
)} diff --git a/frontend/components/admin/table-type/ColumnGrid.tsx b/frontend/components/admin/table-type/ColumnGrid.tsx index c03c7516..825dbd36 100644 --- a/frontend/components/admin/table-type/ColumnGrid.tsx +++ b/frontend/components/admin/table-type/ColumnGrid.tsx @@ -5,8 +5,9 @@ import { MoreHorizontal, Database, Layers, FileStack } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; -import type { ColumnTypeInfo } from "./types"; +import type { ColumnTypeInfo, TableInfo } from "./types"; import { INPUT_TYPE_COLORS, getColumnGroup } from "./types"; +import type { ReferenceTableColumn } from "@/lib/api/entityJoin"; export interface ColumnGridConstraints { primaryKey: { columns: string[] }; @@ -23,6 +24,9 @@ export interface ColumnGridProps { getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean }; onPkToggle?: (columnName: string, checked: boolean) => void; onIndexToggle?: (columnName: string, checked: boolean) => void; + /** 호버 시 한글 라벨 표시용 (Badge title) */ + tables?: TableInfo[]; + referenceTableColumns?: Record; } function getIndexState( @@ -53,6 +57,8 @@ export function ColumnGrid({ getColumnIndexState: externalGetIndexState, onPkToggle, onIndexToggle, + tables, + referenceTableColumns, }: ColumnGridProps) { const getIdxState = useMemo( () => externalGetIndexState ?? ((name: string) => getIndexState(name, constraints)), @@ -136,13 +142,12 @@ export function ColumnGrid({ {/* 4px 색상바 (타입별 진한 색) */}
- {/* 라벨 + 컬럼명 */} + {/* 라벨 + 컬럼명 (한글라벨 (영어명) 동시 표시) */}
- {column.displayName || column.columnName} -
-
- {column.columnName} + {column.displayName && column.displayName !== column.columnName + ? `${column.displayName} (${column.columnName})` + : column.columnName}
@@ -150,11 +155,38 @@ export function ColumnGrid({
{column.inputType === "entity" && column.referenceTable && column.referenceTable !== "none" && ( <> - + { + const t = tables.find((tb) => tb.tableName === column.referenceTable); + return t?.displayName && t.displayName !== t.tableName + ? `${t.displayName} (${column.referenceTable})` + : column.referenceTable; + })() + : column.referenceTable + } + > {column.referenceTable} - + { + const refCols = referenceTableColumns[column.referenceTable]; + const c = refCols.find((rc) => rc.columnName === (column.referenceColumn ?? "")); + return c?.displayName && c.displayName !== c.columnName + ? `${c.displayName} (${column.referenceColumn})` + : column.referenceColumn ?? "—"; + })() + : column.referenceColumn ?? "—" + } + > {column.referenceColumn || "—"} diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index d3a4b187..8698b270 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -29,7 +29,11 @@ import { Zap, Copy, Loader2, + Check, + ChevronsUpDown, } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Checkbox } from "@/components/ui/checkbox"; import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport"; import { DynamicFormApi } from "@/lib/api/dynamicForm"; @@ -97,10 +101,37 @@ export interface ExcelUploadModalProps { interface ColumnMapping { excelColumn: string; systemColumn: string | null; - // 중복 체크 설정 (해당 컬럼을 중복 체크 키로 사용할지) checkDuplicate?: boolean; } +interface FlatCategoryValue { + valueCode: string; + valueLabel: string; + depth: number; + ancestors: string[]; +} + +function flattenCategoryValues( + values: Array<{ valueCode: string; valueLabel: string; children?: any[] }> +): FlatCategoryValue[] { + const result: FlatCategoryValue[] = []; + const traverse = (items: any[], depth: number, ancestors: string[]) => { + for (const item of items) { + result.push({ + valueCode: item.valueCode, + valueLabel: item.valueLabel, + depth, + ancestors, + }); + if (item.children?.length > 0) { + traverse(item.children, depth + 1, [...ancestors, item.valueLabel]); + } + } + }; + traverse(values, 0, []); + return result; +} + export const ExcelUploadModal: React.FC = ({ open, onOpenChange, @@ -137,6 +168,9 @@ export const ExcelUploadModal: React.FC = ({ // 중복 처리 방법 (전역 설정) const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip"); + // 검증 화면에서 DB 중복 처리 방법 (null이면 미선택 = 업로드 차단) + const [dbDuplicateAction, setDbDuplicateAction] = useState<"overwrite" | "skip" | null>(null); + // 엑셀 데이터 사전 검증 결과 const [isDataValidating, setIsDataValidating] = useState(false); const [validationResult, setValidationResult] = useState(null); @@ -149,7 +183,7 @@ export const ExcelUploadModal: React.FC = ({ Record; + validOptions: Array<{ code: string; label: string; depth: number; ancestors: string[] }>; rowIndices: number[]; }>> >({}); @@ -681,12 +715,8 @@ export const ExcelUploadModal: React.FC = ({ const valuesResponse = await getCategoryValues(targetTableName, catCol.systemCol); if (!valuesResponse.success || !valuesResponse.data) continue; - const validValues = valuesResponse.data as Array<{ - valueCode: string; - valueLabel: string; - }>; + const validValues = flattenCategoryValues(valuesResponse.data as any[]); - // 유효한 코드와 라벨 Set 생성 const validCodes = new Set(validValues.map((v) => v.valueCode)); const validLabels = new Set(validValues.map((v) => v.valueLabel)); const validLabelsLower = new Set(validValues.map((v) => v.valueLabel.toLowerCase())); @@ -714,6 +744,8 @@ export const ExcelUploadModal: React.FC = ({ const options = validValues.map((v) => ({ code: v.valueCode, label: v.valueLabel, + depth: v.depth, + ancestors: v.ancestors, })); mismatches[`${catCol.systemCol}|||${catCol.displayName}`] = Array.from(invalidMap.entries()).map( @@ -786,8 +818,7 @@ export const ExcelUploadModal: React.FC = ({ setDisplayData(newData); setShowCategoryValidation(false); setCategoryMismatches({}); - toast.success("카테고리 값이 대체되었습니다."); - setCurrentStep(3); + toast.success("카테고리 값이 대체되었습니다. '다음'을 눌러 진행해주세요."); return true; }; @@ -881,6 +912,7 @@ export const ExcelUploadModal: React.FC = ({ } // 데이터 사전 검증 (NOT NULL 값 누락, UNIQUE 중복) + setDbDuplicateAction(null); setIsDataValidating(true); try { const { validateExcelData: validateExcel } = await import("@/lib/api/tableManagement"); @@ -1096,9 +1128,33 @@ export const ExcelUploadModal: React.FC = ({ const hasNumbering = !!numberingInfo; // 중복 체크 설정 확인 - const duplicateCheckMappings = columnMappings.filter( + let duplicateCheckMappings = columnMappings.filter( (m) => m.checkDuplicate && m.systemColumn ); + let effectiveDuplicateAction = duplicateAction; + + // 검증 화면에서 DB 중복 처리 방법을 선택한 경우, 유니크 컬럼을 자동으로 중복 체크에 추가 + if (dbDuplicateAction && validationResult?.uniqueInDbErrors && validationResult.uniqueInDbErrors.length > 0) { + effectiveDuplicateAction = dbDuplicateAction; + const uniqueColumns = new Set(validationResult.uniqueInDbErrors.map((e) => e.column)); + for (const colName of uniqueColumns) { + const alreadyAdded = duplicateCheckMappings.some((m) => { + const mapped = m.systemColumn?.includes(".") ? m.systemColumn.split(".")[1] : m.systemColumn; + return mapped === colName; + }); + if (!alreadyAdded) { + const mapping = columnMappings.find((m) => { + const mapped = m.systemColumn?.includes(".") ? m.systemColumn.split(".")[1] : m.systemColumn; + return mapped === colName; + }); + if (mapping) { + duplicateCheckMappings = [...duplicateCheckMappings, { ...mapping, checkDuplicate: true }]; + } + } + } + console.log(`📊 검증 화면 DB 중복 처리: ${dbDuplicateAction}, 체크 컬럼: ${[...uniqueColumns].join(", ")}`); + } + const hasDuplicateCheck = duplicateCheckMappings.length > 0; // 중복 체크를 위한 기존 데이터 조회 (중복 체크가 설정된 경우에만) @@ -1161,7 +1217,7 @@ export const ExcelUploadModal: React.FC = ({ if (existingDataMap.has(key)) { existingRow = existingDataMap.get(key); - if (duplicateAction === "skip") { + if (effectiveDuplicateAction === "skip") { shouldSkip = true; skipCount++; console.log(`⏭️ [행 ${rowIdx + 1}] 중복으로 건너뛰기: ${key}`); @@ -1343,6 +1399,7 @@ export const ExcelUploadModal: React.FC = ({ setSystemColumns([]); setColumnMappings([]); setDuplicateAction("skip"); + setDbDuplicateAction(null); // 검증 상태 초기화 setValidationResult(null); setIsDataValidating(false); @@ -1357,7 +1414,7 @@ export const ExcelUploadModal: React.FC = ({ return ( <> - + { if (!showCategoryValidation) onOpenChange(v); }}> = ({ {/* DB 기존 데이터 중복 */} {validationResult.uniqueInDbErrors.length > 0 && ( -
-

- - DB 기존 데이터와 중복 ({validationResult.uniqueInDbErrors.length}건) -

-
+
+
+

+ {dbDuplicateAction ? : } + DB 기존 데이터와 중복 ({validationResult.uniqueInDbErrors.length}건) +

+
+ + 중복 시: + + +
+
+
{(() => { const grouped = new Map(); for (const err of validationResult.uniqueInDbErrors) { @@ -1984,7 +2079,7 @@ export const ExcelUploadModal: React.FC = ({
{items.slice(0, 5).map((item, i) => (

- {label} "{item.value}": 행 {item.rows.join(", ")} + {label} "{item.value}": 행 {item.rows.join(", ")}

))} {items.length > 5 &&

...외 {items.length - 5}건

} @@ -1992,6 +2087,13 @@ export const ExcelUploadModal: React.FC = ({ )); })()}
+ {dbDuplicateAction && ( +

+ {dbDuplicateAction === "skip" + ? "중복 데이터는 건너뛰고 신규 데이터만 업로드합니다." + : "중복 데이터는 새 값으로 덮어씁니다."} +

+ )}
)}
@@ -2105,11 +2207,24 @@ export const ExcelUploadModal: React.FC = ({ disabled={ isUploading || columnMappings.filter((m) => m.systemColumn).length === 0 || - (validationResult !== null && !validationResult.isValid) + (validationResult !== null && !validationResult.isValid && !( + validationResult.notNullErrors.length === 0 && + validationResult.uniqueInExcelErrors.length === 0 && + validationResult.uniqueInDbErrors.length > 0 && + dbDuplicateAction !== null + )) } className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" > - {isUploading ? "업로드 중..." : validationResult && !validationResult.isValid ? "검증 실패 - 이전으로 돌아가 수정" : "업로드"} + {isUploading ? "업로드 중..." : + validationResult && !validationResult.isValid && !( + validationResult.notNullErrors.length === 0 && + validationResult.uniqueInExcelErrors.length === 0 && + validationResult.uniqueInDbErrors.length > 0 && + dbDuplicateAction !== null + ) ? "검증 실패 - 이전으로 돌아가 수정" : + dbDuplicateAction === "skip" ? "업로드 (중복 건너뛰기)" : + dbDuplicateAction === "overwrite" ? "업로드 (중복 덮어쓰기)" : "업로드"} )} @@ -2156,33 +2271,63 @@ export const ExcelUploadModal: React.FC = ({
- + + + + + + { + const opt = item.validOptions.find((o) => o.code === value); + if (!opt) return 0; + const s = search.toLowerCase(); + if (opt.label.toLowerCase().includes(s)) return 1; + if (opt.ancestors.some((a) => a.toLowerCase().includes(s))) return 1; + return 0; + }} + > + + + 찾을 수 없습니다 + + {item.validOptions.map((opt) => ( + { + setCategoryMismatches((prev) => { + const updated = { ...prev }; + updated[key] = updated[key].map((it, i) => + i === idx ? { ...it, replacement: val } : it + ); + return updated; + }); + }} + className="text-xs sm:text-sm" + > + + + {opt.depth > 0 && } + {opt.label} + + + ))} + + + + +
))}
@@ -2201,17 +2346,6 @@ export const ExcelUploadModal: React.FC = ({ > 취소 -
- + + + + + + { + const opt = item.validOptions.find((o) => o.code === value); + if (!opt) return 0; + const s = search.toLowerCase(); + if (opt.label.toLowerCase().includes(s)) return 1; + if (opt.ancestors.some((a) => a.toLowerCase().includes(s))) return 1; + return 0; + }} + > + + + 찾을 수 없습니다 + + {item.validOptions.map((opt) => ( + { + setCategoryMismatches((prev) => { + const updated = { ...prev }; + updated[key] = updated[key].map((it, i) => + i === idx ? { ...it, replacement: val } : it + ); + return updated; + }); + }} + className="text-xs sm:text-sm" + > + + + {opt.depth > 0 && } + {opt.label} + + + ))} + + + + +
))} @@ -1054,17 +1114,6 @@ export const MultiTableExcelUploadModal: React.FC 취소 - - - +
+
+ + 규칙 {part.order} + + +
- +
setColumnSearch(e.target.value)} - placeholder="검색..." - className="h-8 text-xs" - /> - -
- {loading && numberingColumns.length === 0 ? ( -
-

로딩 중...

+
+ {/* 좌측: 규칙 리스트 (code-nav, 220px) */} +
+
+
+ + 채번 규칙 ({rulesList.length}) +
+ +
+
+ {loading && rulesList.length === 0 ? ( +
+ 로딩 중...
- ) : filteredGroups.length === 0 ? ( -
-

- {numberingColumns.length === 0 - ? "채번 타입 컬럼이 없습니다" - : "검색 결과가 없습니다"} -

+ ) : rulesList.length === 0 ? ( +
+ 규칙이 없습니다
) : ( - filteredGroups.map(([tableName, group]) => ( -
-
- - {group.tableLabel} - ({group.columns.length}) -
- {group.columns.map((col) => { - const isSelected = - selectedColumn?.tableName === col.tableName && - selectedColumn?.columnName === col.columnName; - return ( -
handleSelectColumn(col.tableName, col.columnName)} - > - {col.columnLabel} -
- ); - })} -
- )) + rulesList.map((rule) => { + const isSelected = selectedRuleId === rule.ruleId; + return ( + + ); + }) )}
- {/* 구분선 */} -
- - {/* 우측: 편집 영역 */} -
+ {/* 우측: 미리보기 + 파이프라인 + 설정 + 저장 바 (code-main) */} +
{!currentRule ? ( -
-
- -

컬럼을 선택해주세요

-

좌측에서 채번 컬럼을 선택하면 규칙을 편집할 수 있습니다

-
+
+ +

규칙을 선택하세요

+

+ 좌측에서 채번 규칙을 선택하거나 "추가"로 새 규칙을 만드세요 +

) : ( <> -
- {editingRightTitle ? ( - setRightTitle(e.target.value)} - onBlur={() => setEditingRightTitle(false)} - onKeyDown={(e) => e.key === "Enter" && setEditingRightTitle(false)} - className="h-8 text-sm font-semibold" - autoFocus - /> - ) : ( -

{rightTitle}

- )} - +
+ + setCurrentRule((prev) => (prev ? { ...prev, ruleName: e.target.value } : null))} + placeholder="예: 프로젝트 코드" + className="h-9 text-sm" + />
-
- {/* 첫 번째 줄: 규칙명 + 미리보기 */} -
-
- - setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value }))} - className="h-9" - placeholder="예: 프로젝트 코드" - /> -
-
- - -
-
- - + {/* 큰 미리보기 스트립 (code-preview-strip) */} +
+
-
-
-

코드 구성

- + {/* 파이프라인 영역 (code-pipeline-area) */} +
+
+ 코드 구성 + {currentRule.parts.length}/{maxRules}
- - {currentRule.parts.length === 0 ? ( -
-

규칙을 추가하여 코드를 구성하세요

-
- ) : ( -
- {currentRule.parts.map((part, index) => ( - -
- handleUpdatePart(part.order, updates)} - onDelete={() => handleDeletePart(part.order)} - isPreview={isPreview} - tableName={selectedColumn?.tableName} - /> - {/* 카드 하단에 구분자 설정 (마지막 파트 제외) */} - {index < currentRule.parts.length - 1 && ( -
- 뒤 구분자 - - {separatorTypes[part.order] === "custom" && ( - handlePartCustomSeparatorChange(part.order, e.target.value)} - className="h-6 w-14 text-center text-[10px]" - placeholder="2자" - maxLength={2} - /> +
+ {currentRule.parts.length === 0 ? ( +
+ 규칙을 추가하여 코드를 구성하세요 +
+ ) : ( + <> + {currentRule.parts.map((part, index) => { + const item = partItems.find((i) => i.order === part.order); + const sep = part.separatorAfter ?? globalSep; + const isSelected = selectedPartOrder === part.order; + const typeLabel = CODE_PART_TYPE_OPTIONS.find((o) => o.value === part.partType)?.label ?? part.partType; + return ( + +
- - ))} -
- )} + onClick={() => setSelectedPartOrder(part.order)} + > +
+ {typeLabel} +
+
+ {item?.displayValue ?? "-"} +
+ + {index < currentRule.parts.length - 1 && ( +
+ + + {sep || "-"} + +
+ )} + + ); + })} + + + )} +
-
+ {/* 설정 패널 (선택된 세그먼트 상세, code-config-panel) */} + {selectedPart && ( +
+
+ handleUpdatePart(selectedPart.order, updates)} + onDelete={() => handleDeletePart(selectedPart.order)} + isPreview={isPreview} + tableName={currentRule.tableName ?? currentTableName} + /> +
+ {currentRule.parts.some((p) => p.order === selectedPart.order) && ( +
+ 뒤 구분자 + + {separatorTypes[selectedPart.order] === "custom" && ( + handlePartCustomSeparatorChange(selectedPart.order, e.target.value)} + className="h-7 w-14 text-center text-[10px]" + placeholder="2자" + maxLength={2} + /> + )} +
+ )} +
+ )} + + {/* 저장 바 (code-save-bar) */} +
+
+ {currentRule.tableName && ( + 테이블: {currentRule.tableName} + )} + {currentRule.columnName && ( + 컬럼: {currentRule.columnName} + )} + 구분자: {globalSep || "-"} + {currentRule.resetPeriod && currentRule.resetPeriod !== "none" && ( + 리셋: {currentRule.resetPeriod} + )} +
-
diff --git a/frontend/components/numbering-rule/NumberingRulePreview.tsx b/frontend/components/numbering-rule/NumberingRulePreview.tsx index eff551a1..6a7f9732 100644 --- a/frontend/components/numbering-rule/NumberingRulePreview.tsx +++ b/frontend/components/numbering-rule/NumberingRulePreview.tsx @@ -1,88 +1,163 @@ "use client"; import React, { useMemo } from "react"; -import { NumberingRuleConfig } from "@/types/numbering-rule"; +import { cn } from "@/lib/utils"; +import { NumberingRuleConfig, NumberingRulePart, CodePartType } from "@/types/numbering-rule"; +import { CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule"; + +/** 파트별 표시값 + 타입 (미리보기 스트립/세그먼트용) */ +export interface PartDisplayItem { + partType: CodePartType; + displayValue: string; + order: number; +} + +/** config에서 파트별 표시값 배열 계산 (정렬된 parts 기준) */ +export function computePartDisplayItems(config: NumberingRuleConfig): PartDisplayItem[] { + if (!config.parts || config.parts.length === 0) return []; + const sorted = [...config.parts].sort((a, b) => a.order - b.order); + const globalSep = config.separator ?? "-"; + return sorted.map((part) => ({ + order: part.order, + partType: part.partType, + displayValue: getPartDisplayValue(part), + })); +} + +function getPartDisplayValue(part: NumberingRulePart): string { + if (part.generationMethod === "manual") { + return part.manualConfig?.value || "XXX"; + } + const c = part.autoConfig || {}; + switch (part.partType) { + case "sequence": + return String(c.startFrom ?? 1).padStart(c.sequenceLength ?? 3, "0"); + case "number": + return String(c.numberValue ?? 0).padStart(c.numberLength ?? 4, "0"); + case "date": { + const format = c.dateFormat || "YYYYMMDD"; + if (c.useColumnValue && c.sourceColumnName) { + return format === "YYYY" ? "[YYYY]" : format === "YY" ? "[YY]" : format === "YYYYMM" ? "[YYYYMM]" : format === "YYMM" ? "[YYMM]" : format === "YYMMDD" ? "[YYMMDD]" : "[DATE]"; + } + const now = new Date(); + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, "0"); + const d = String(now.getDate()).padStart(2, "0"); + if (format === "YYYY") return String(y); + if (format === "YY") return String(y).slice(-2); + if (format === "YYYYMM") return `${y}${m}`; + if (format === "YYMM") return `${String(y).slice(-2)}${m}`; + if (format === "YYYYMMDD") return `${y}${m}${d}`; + if (format === "YYMMDD") return `${String(y).slice(-2)}${m}${d}`; + return `${y}${m}${d}`; + } + case "text": + return c.textValue || "TEXT"; + default: + return "XXX"; + } +} + +/** 파트 타입별 미리보기용 텍스트 색상 클래스 (CSS 변수 기반) */ +export function getPartTypeColorClass(partType: CodePartType): string { + switch (partType) { + case "date": + return "text-warning"; + case "text": + return "text-primary"; + case "sequence": + return "text-primary"; + case "number": + return "text-muted-foreground"; + case "category": + case "reference": + return "text-muted-foreground"; + default: + return "text-foreground"; + } +} + +/** 파트 타입별 점(dot) 배경 색상 (범례용) */ +export function getPartTypeDotClass(partType: CodePartType): string { + switch (partType) { + case "date": + return "bg-warning"; + case "text": + case "sequence": + return "bg-primary"; + case "number": + case "category": + case "reference": + return "bg-muted-foreground"; + default: + return "bg-foreground"; + } +} interface NumberingRulePreviewProps { config: NumberingRuleConfig; compact?: boolean; + /** 큰 미리보기 스트립: 28px, 파트별 색상, 하단 범례 */ + variant?: "default" | "strip"; } export const NumberingRulePreview: React.FC = ({ config, - compact = false + compact = false, + variant = "default", }) => { + const partItems = useMemo(() => computePartDisplayItems(config), [config]); + const sortedParts = useMemo( + () => (config.parts ? [...config.parts].sort((a, b) => a.order - b.order) : []), + [config.parts] + ); const generatedCode = useMemo(() => { - if (!config.parts || config.parts.length === 0) { - return "규칙을 추가해주세요"; - } - - const sortedParts = config.parts.sort((a, b) => a.order - b.order); - - const partValues = sortedParts.map((part) => { - if (part.generationMethod === "manual") { - return part.manualConfig?.value || "XXX"; - } - - const autoConfig = part.autoConfig || {}; - - switch (part.partType) { - case "sequence": { - const length = autoConfig.sequenceLength || 3; - const startFrom = autoConfig.startFrom || 1; - return String(startFrom).padStart(length, "0"); - } - case "number": { - const length = autoConfig.numberLength || 4; - const value = autoConfig.numberValue || 0; - return String(value).padStart(length, "0"); - } - case "date": { - const format = autoConfig.dateFormat || "YYYYMMDD"; - if (autoConfig.useColumnValue && autoConfig.sourceColumnName) { - switch (format) { - case "YYYY": return "[YYYY]"; - case "YY": return "[YY]"; - case "YYYYMM": return "[YYYYMM]"; - case "YYMM": return "[YYMM]"; - case "YYYYMMDD": return "[YYYYMMDD]"; - case "YYMMDD": return "[YYMMDD]"; - default: return "[DATE]"; - } - } - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - switch (format) { - case "YYYY": return String(year); - case "YY": return String(year).slice(-2); - case "YYYYMM": return `${year}${month}`; - case "YYMM": return `${String(year).slice(-2)}${month}`; - case "YYYYMMDD": return `${year}${month}${day}`; - case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`; - default: return `${year}${month}${day}`; - } - } - case "text": - return autoConfig.textValue || "TEXT"; - default: - return "XXX"; - } - }); - - // 파트별 개별 구분자로 결합 + if (partItems.length === 0) return "규칙을 추가해주세요"; const globalSep = config.separator ?? "-"; let result = ""; - partValues.forEach((val, idx) => { - result += val; - if (idx < partValues.length - 1) { - const sep = sortedParts[idx].separatorAfter ?? globalSep; - result += sep; + partItems.forEach((item, idx) => { + result += item.displayValue; + if (idx < partItems.length - 1) { + const part = sortedParts.find((p) => p.order === item.order); + result += part?.separatorAfter ?? globalSep; } }); return result; - }, [config]); + }, [config.separator, partItems, sortedParts]); + + if (variant === "strip") { + const globalSep = config.separator ?? "-"; + return ( +
+
+ {partItems.length === 0 ? ( + 규칙을 추가해주세요 + ) : ( + partItems.map((item, idx) => ( + + {item.displayValue} + {idx < partItems.length - 1 && ( + + {sortedParts.find((p) => p.order === item.order)?.separatorAfter ?? globalSep} + + )} + + )) + )} +
+ {partItems.length > 0 && ( +
+ {CODE_PART_TYPE_OPTIONS.filter((opt) => partItems.some((p) => p.partType === opt.value)).map((opt) => ( + + + {opt.label} + + ))} +
+ )} +
+ ); + } if (compact) { return ( diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 51a9e34e..87579061 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -362,15 +362,15 @@ const RealtimePreviewDynamicComponent: React.FC = ({ // 런타임 모드에서 컴포넌트 타입별 높이 처리 if (!isDesignMode) { const compType = (component as any).componentType || component.componentConfig?.type || ""; - // 테이블: 부모 flex 컨테이너가 높이 관리 (flex: 1) - const flexGrowTypes = [ + // 레이아웃 계열: 부모 래퍼를 꽉 채움 (ResponsiveGridRenderer가 % 높이 관리) + const fillParentTypes = [ "table-list", "v2-table-list", "split-panel-layout", "split-panel-layout2", "v2-split-panel-layout", "screen-split-panel", "v2-tab-container", "tab-container", "tabs-widget", "v2-tabs-widget", ]; - if (flexGrowTypes.some(t => compType === t)) { + if (fillParentTypes.some(t => compType === t)) { return "100%"; } const autoHeightTypes = [ diff --git a/frontend/components/screen/ResponsiveGridRenderer.tsx b/frontend/components/screen/ResponsiveGridRenderer.tsx index 1322ee99..47a2cd52 100644 --- a/frontend/components/screen/ResponsiveGridRenderer.tsx +++ b/frontend/components/screen/ResponsiveGridRenderer.tsx @@ -23,8 +23,9 @@ function getComponentTypeId(component: ComponentData): string { } /** - * 디자이너 절대좌표를 캔버스 대비 비율로 변환하여 렌더링. - * 화면이 줄어들면 비율에 맞게 축소, 늘어나면 확대. + * 디자이너 절대좌표를 캔버스 대비 비율(%)로 변환하여 렌더링. + * 가로: 컨테이너 너비 대비 % → 반응형 스케일 + * 세로: 컨테이너 높이 대비 % → 뷰포트에 맞게 자동 조절 */ function ProportionalRenderer({ components, @@ -47,19 +48,12 @@ function ProportionalRenderer({ }, []); const topLevel = components.filter((c) => !c.parentId); - const ratio = containerW > 0 ? containerW / canvasWidth : 1; - - const maxBottom = topLevel.reduce((max, c) => { - const bottom = c.position.y + (c.size?.height || 40); - return Math.max(max, bottom); - }, 0); return (
0 ? `${maxBottom * ratio}px` : "200px" }} + className="bg-background relative h-full w-full overflow-hidden" > {containerW > 0 && topLevel.map((component) => { @@ -72,9 +66,9 @@ function ProportionalRenderer({ style={{ position: "absolute", left: `${(component.position.x / canvasWidth) * 100}%`, - top: `${component.position.y * ratio}px`, + top: `${(component.position.y / canvasHeight) * 100}%`, width: `${((component.size?.width || 100) / canvasWidth) * 100}%`, - height: `${(component.size?.height || 40) * ratio}px`, + height: `${((component.size?.height || 40) / canvasHeight) * 100}%`, zIndex: component.position.z || 1, }} > diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index ef739b27..a18f3bda 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -17,7 +17,6 @@ import { GroupComponent, DataTableComponent, TableInfo, - LayoutComponent, FileComponent, AreaComponent, } from "@/types/screen"; @@ -47,7 +46,7 @@ import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent // ComponentRegistry import (동적 ConfigPanel 가져오기용) import { ComponentRegistry } from "@/lib/registry/ComponentRegistry"; -import { columnMetaCache } from "@/lib/registry/DynamicComponentRenderer"; +import { columnMetaCache, loadColumnMeta } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel"; import StyleEditor from "../StyleEditor"; import { Slider } from "@/components/ui/slider"; @@ -98,6 +97,24 @@ export const V2PropertiesPanel: React.FC = ({ // 🆕 전체 테이블 목록 (selected-items-detail-input 등에서 사용) const [allTables, setAllTables] = useState>([]); + // 🆕 선택된 컴포넌트의 테이블에 대한 columnMeta 캐시가 비어 있으면 로드 후 재렌더 + const [columnMetaVersion, setColumnMetaVersion] = useState(0); + useEffect(() => { + if (!selectedComponent) return; + const tblName = + (selectedComponent as any).tableName || + currentTable?.tableName || + tables?.[0]?.tableName; + if (!tblName) return; + if (columnMetaCache[tblName]) return; + loadColumnMeta(tblName).then(() => setColumnMetaVersion((v) => v + 1)); + }, [ + selectedComponent?.id, + (selectedComponent as any)?.tableName, + currentTable?.tableName, + tables?.[0]?.tableName, + ]); + // 🆕 전체 테이블 목록 로드 useEffect(() => { const loadAllTables = async () => { @@ -211,20 +228,20 @@ export const V2PropertiesPanel: React.FC = ({ // 현재 화면의 테이블명 가져오기 const currentTableName = tables?.[0]?.tableName; - // DB input_type 가져오기 (columnMetaCache에서 최신값 조회) - const colName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName; - const tblName = selectedComponent.tableName || currentTable?.tableName || currentTableName; + // DB input_type만 조회 (saved config와 분리하여 전달) + const colName = (selectedComponent as any).columnName || currentConfig.fieldKey || currentConfig.columnName; + const tblName = (selectedComponent as any).tableName || currentTable?.tableName || currentTableName; const dbMeta = colName && tblName && !colName.includes(".") ? columnMetaCache[tblName]?.[colName] : undefined; const dbInputType = dbMeta ? (() => { const raw = dbMeta.input_type || dbMeta.inputType; return raw === "direct" || raw === "auto" ? undefined : raw; })() : undefined; - const inputType = dbInputType || currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType; // 컴포넌트별 추가 props const extraProps: Record = {}; - const resolvedTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; - const resolvedColumnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName; + const resolvedTableName = (selectedComponent as any).tableName || currentTable?.tableName || currentTableName; + const resolvedColumnName = (selectedComponent as any).columnName || currentConfig.fieldKey || currentConfig.columnName; if (componentId === "v2-input" || componentId === "v2-select") { - extraProps.inputType = inputType; + extraProps.componentType = componentId; + extraProps.inputType = dbInputType; extraProps.tableName = resolvedTableName; extraProps.columnName = resolvedColumnName; extraProps.screenTableName = resolvedTableName; @@ -256,7 +273,7 @@ export const V2PropertiesPanel: React.FC = ({ const currentConfig = selectedComponent.componentConfig || {}; // 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지) - const config = currentConfig || definition.defaultProps?.componentConfig || {}; + const config = currentConfig || (definition as any).defaultProps?.componentConfig || {}; const handlePanelConfigChange = (newConfig: any) => { // 🔧 Partial 업데이트: 기존 componentConfig를 유지하면서 새 설정만 병합 @@ -282,14 +299,14 @@ export const V2PropertiesPanel: React.FC = ({ onConfigChange={handlePanelConfigChange} tables={tables} allTables={allTables} - screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} - tableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} + screenTableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName} + tableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName} columnName={ (selectedComponent as any).columnName || currentConfig?.columnName || currentConfig?.fieldName } inputType={(selectedComponent as any).inputType || currentConfig?.inputType} componentType={componentType} - tableColumns={currentTable?.columns || []} + tableColumns={(currentTable as any)?.columns || []} allComponents={allComponents} currentComponent={selectedComponent} menuObjid={menuObjid} @@ -323,8 +340,8 @@ export const V2PropertiesPanel: React.FC = ({ componentType={componentType} config={selectedComponent.componentConfig || {}} onChange={handleDynamicConfigChange} - screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} - tableColumns={currentTable?.columns || []} + screenTableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName} + tableColumns={(currentTable as any)?.columns || []} tables={tables} menuObjid={menuObjid} allComponents={allComponents} @@ -491,7 +508,7 @@ export const V2PropertiesPanel: React.FC = ({ 제목
handleUpdate("title", e.target.value)} placeholder="제목" className="h-7 text-xs" @@ -503,7 +520,7 @@ export const V2PropertiesPanel: React.FC = ({ 설명
handleUpdate("description", e.target.value)} placeholder="설명" className="h-7 text-xs" @@ -519,9 +536,9 @@ export const V2PropertiesPanel: React.FC = ({

OPTIONS

{(isInputField || widget.required !== undefined) && (() => { - const colName = widget.columnName || selectedComponent?.columnName; + const colName = widget.columnName || (selectedComponent as any)?.columnName; const colMeta = colName - ? currentTable?.columns?.find( + ? (currentTable as any)?.columns?.find( (c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase(), ) : null; @@ -568,7 +585,7 @@ export const V2PropertiesPanel: React.FC = ({
숨김 { handleUpdate("hidden", checked); handleUpdate("componentConfig.hidden", checked); @@ -689,7 +706,7 @@ export const V2PropertiesPanel: React.FC = ({
표시 { const boolValue = checked === true; handleUpdate("style.labelDisplay", boolValue); @@ -785,7 +802,7 @@ export const V2PropertiesPanel: React.FC = ({ const webType = selectedComponent.componentConfig?.webType; // 테이블 패널에서 드래그한 컴포넌트인지 확인 - const isFromTablePanel = !!(selectedComponent.tableName && selectedComponent.columnName); + const isFromTablePanel = !!((selectedComponent as any).tableName && (selectedComponent as any).columnName); if (!componentId) { return ( @@ -845,8 +862,8 @@ export const V2PropertiesPanel: React.FC = ({ = ({ = ({ return (
{/* WebType 선택 (있는 경우만) */} - {widget.webType && ( + {(widget as any).webType && (
- handleUpdate("webType", value)}> {webTypes.map((wt) => ( - {wt.web_type_name_kor || wt.web_type} + {(wt as any).web_type_name_kor || wt.web_type} ))} diff --git a/frontend/components/table-category/CategoryColumnList.tsx b/frontend/components/table-category/CategoryColumnList.tsx index 872e7d57..1b1bf0a5 100644 --- a/frontend/components/table-category/CategoryColumnList.tsx +++ b/frontend/components/table-category/CategoryColumnList.tsx @@ -5,10 +5,11 @@ import { apiClient } from "@/lib/api/client"; import { getCategoryValues } from "@/lib/api/tableCategoryValue"; import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react"; import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; -interface CategoryColumn { +export interface CategoryColumn { tableName: string; - tableLabel?: string; // 테이블 라벨 추가 + tableLabel?: string; columnName: string; columnLabel: string; inputType: string; @@ -16,17 +17,30 @@ interface CategoryColumn { } interface CategoryColumnListProps { - tableName: string; // 현재 화면의 테이블 (사용하지 않음 - 형제 메뉴 전체 표시) + tableName: string; selectedColumn: string | null; - onColumnSelect: (columnName: string, columnLabel: string, tableName: string) => void; - menuObjid?: number; // 현재 메뉴 OBJID (필수) + onColumnSelect: (uniqueKeyOrColumnName: string, columnLabel: string, tableName: string) => void; + menuObjid?: number; + /** 대시보드 모드: 테이블 단위 네비만 표시, 선택 시 onTableSelect 호출 */ + selectedTable?: string | null; + onTableSelect?: (tableName: string) => void; + /** 컬럼 로드 완료 시 부모에 전달 (Stat Strip 등 계산용) */ + onColumnsLoaded?: (columns: CategoryColumn[]) => void; } /** * 카테고리 컬럼 목록 (좌측 패널) * - 형제 메뉴들의 모든 카테고리 타입 컬럼을 표시 (메뉴 스코프) */ -export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, menuObjid }: CategoryColumnListProps) { +export function CategoryColumnList({ + tableName, + selectedColumn, + onColumnSelect, + menuObjid, + selectedTable = null, + onTableSelect, + onColumnsLoaded, +}: CategoryColumnListProps) { const [columns, setColumns] = useState([]); const [isLoading, setIsLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -151,8 +165,8 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, ); setColumns(columnsWithCount); + onColumnsLoaded?.(columnsWithCount); - // 첫 번째 컬럼 자동 선택 if (columnsWithCount.length > 0 && !selectedColumn) { const firstCol = columnsWithCount[0]; onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName); @@ -160,6 +174,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, } catch (error) { console.error("❌ 테이블 기반 카테고리 컬럼 조회 실패:", error); setColumns([]); + onColumnsLoaded?.([]); } finally { setIsLoading(false); } @@ -248,21 +263,20 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, } setColumns(columnsWithCount); + onColumnsLoaded?.(columnsWithCount); - // 첫 번째 컬럼 자동 선택 if (columnsWithCount.length > 0 && !selectedColumn) { const firstCol = columnsWithCount[0]; onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName); } } catch (error) { console.error("❌ 카테고리 컬럼 조회 실패:", error); - // 에러 시에도 tableName 기반으로 fallback if (tableName) { - console.log("⚠️ menuObjid API 에러, tableName 기반으로 fallback:", tableName); await loadCategoryColumnsByTable(); return; } else { setColumns([]); + onColumnsLoaded?.([]); } } setIsLoading(false); @@ -291,6 +305,72 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, ); } + // 대시보드 모드: 테이블 단위 네비만 표시 + if (onTableSelect != null) { + return ( +
+
+
+ + setSearchQuery(e.target.value)} + className="h-8 border-0 bg-transparent pl-8 pr-8 text-xs shadow-none focus-visible:ring-0" + /> + {searchQuery && ( + + )} +
+
+
+ {filteredColumns.length === 0 && searchQuery ? ( +
+ '{searchQuery}'에 대한 검색 결과가 없습니다 +
+ ) : null} + {groupedColumns.map((group) => { + const totalValues = group.columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0); + const isActive = selectedTable === group.tableName; + return ( + + ); + })} +
+
+ ); + } + return (
@@ -298,7 +378,6 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,

관리할 카테고리 컬럼을 선택하세요

- {/* 검색 입력 필드 */}
{searchQuery && ( + {headerRight != null ?
{headerRight}
: null}
@@ -405,7 +408,6 @@ export const CategoryValueManager: React.FC = ({ value.isActive !== false ) } - className="data-[state=checked]:bg-emerald-500" /> - {/* 아이콘 */} {getIcon()} - {/* 라벨 */} -
- {node.valueLabel} - {getDepthLabel()} +
+ + {node.valueLabel} + + + {getDepthLabel()} +
- {/* 비활성 표시 */} {!node.isActive && ( - 비활성 + + 비활성 + )} - {/* 액션 버튼 */} -
+
{canAddChild && ( + {headerRight != null ?
{headerRight}
: null}
@@ -720,7 +727,7 @@ export const CategoryValueManagerTree: React.FC =

상단의 대분류 추가 버튼을 클릭하여 시작하세요

) : ( -
+
{tree.map((node) => ( = ({ if (!tableName || currentData.length === 0) { console.warn("🔴 [V2Repeater] 저장 스킵:", { tableName, dataLength: currentData.length }); toast.warning(`[디버그] V2Repeater 저장 스킵: data=${currentData.length}, table=${tableName}`); - window.dispatchEvent(new CustomEvent("repeaterSaveComplete")); return; } @@ -356,7 +355,6 @@ export const V2Repeater: React.FC = ({ const hasFkSource = sourceCol && mainFormData && mainFormData[sourceCol] !== undefined; if (!hasFkSource && !masterRecordId) { console.warn("🔴 [V2Repeater] FK 소스 값/masterRecordId 모두 없어 저장 스킵"); - window.dispatchEvent(new CustomEvent("repeaterSaveComplete")); return; } } diff --git a/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx b/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx index 4df68117..5405b24e 100644 --- a/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx @@ -433,9 +433,9 @@ export const V2ButtonConfigPanel: React.FC = ({ loadAll(); }, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, availableTables, loadTableColumns]); - // 화면 목록 로드 (모달 액션용) + // 화면 목록 로드 (모달/편집/네비게이트 액션용) useEffect(() => { - if (actionType !== "modal" && actionType !== "navigate") return; + if (actionType !== "modal" && actionType !== "navigate" && actionType !== "edit") return; if (screens.length > 0) return; const loadScreens = async () => { @@ -870,7 +870,6 @@ const ActionDetailSection: React.FC<{ switch (actionType) { case "save": case "delete": - case "edit": case "quickInsert": return (
@@ -879,7 +878,6 @@ const ActionDetailSection: React.FC<{ {actionType === "save" && "저장 설정"} {actionType === "delete" && "삭제 설정"} - {actionType === "edit" && "편집 설정"} {actionType === "quickInsert" && "즉시 저장 설정"}
@@ -900,6 +898,147 @@ const ActionDetailSection: React.FC<{
); + case "edit": + return ( +
+
+ + 편집 설정 +
+ + {/* 대상 화면 선택 */} +
+ + + + + + + + + + 화면을 찾을 수 없습니다. + + {screens + .filter((s) => + !modalSearchTerm || + s.name.toLowerCase().includes(modalSearchTerm.toLowerCase()) || + s.description?.toLowerCase().includes(modalSearchTerm.toLowerCase()) || + String(s.id).includes(modalSearchTerm) + ) + .map((screen) => ( + { + updateActionConfig("targetScreenId", screen.id); + setModalScreenOpen(false); + setModalSearchTerm(""); + }} + className="text-xs" + > + +
+ {screen.name} + {screen.description && ( + {screen.description} + )} +
+
+ ))} +
+
+
+
+
+
+ + {/* 편집 모드 선택 */} +
+ + +
+ + {/* 모달 모드일 때 추가 설정 */} + {(action.editMode || "modal") === "modal" && ( + <> +
+ + updateActionConfig("editModalTitle", e.target.value)} + placeholder="데이터 수정" + className="h-7 text-xs" + /> +
+
+ + updateActionConfig("editModalDescription", e.target.value)} + placeholder="모달 설명" + className="h-7 text-xs" + /> +
+
+ + +
+ + )} + + {commonMessageSection} +
+ ); + case "modal": return (
diff --git a/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx b/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx index 2f2b8011..3ce85266 100644 --- a/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx @@ -77,9 +77,9 @@ interface CategoryValueOption { valueLabel: string; } -// ─── 하위 호환: 기존 config에서 fieldType 추론 ─── +// ─── 하위 호환: 기존 config에서 fieldType 추론 (우선순위: DB값 > 사용자 fieldType > 컴포넌트구조 > saved config > 기본값) ─── function resolveFieldType(config: Record, componentType?: string, metaInputType?: string): FieldType { - // DB input_type이 전달된 경우 (데이터타입관리에서 변경 시) 우선 적용 + // (a) metaInputType: DB 전용 (undefined면 스킵, V2PropertiesPanel에서 dbInputType만 전달) if (metaInputType && metaInputType !== "direct" && metaInputType !== "auto") { const dbType = metaInputType as FieldType; if (["text", "number", "textarea", "numbering", "select", "category", "entity"].includes(dbType)) { @@ -87,9 +87,10 @@ function resolveFieldType(config: Record, componentType?: string, m } } + // (b) 사용자가 설정 패널에서 직접 선택한 fieldType if (config.fieldType) return config.fieldType as FieldType; - // v2-select 계열 + // (c) v2-select 계열: componentType 또는 config.source 기반 if (componentType === "v2-select" || config.source) { const source = config.source === "code" ? "category" : config.source; if (source === "entity") return "entity"; @@ -97,11 +98,13 @@ function resolveFieldType(config: Record, componentType?: string, m return "select"; } - // v2-input 계열 + // (d) saved config fallback (config.inputType / config.type) const it = config.inputType || config.type; if (it === "number") return "number"; if (it === "textarea") return "textarea"; if (it === "numbering") return "numbering"; + + // (e) 최종 기본값 return "text"; } diff --git a/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx b/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx index c788612e..325656d1 100644 --- a/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx @@ -14,31 +14,10 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { Slider } from "@/components/ui/slider"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +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 { Separator } from "@/components/ui/separator"; import { Database, @@ -70,17 +49,8 @@ import { entityJoinApi } from "@/lib/api/entityJoin"; import { tableTypeApi } from "@/lib/api/screen"; import { tableManagementApi } from "@/lib/api/tableManagement"; import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel"; -import { - DndContext, - closestCenter, - type DragEndEvent, -} from "@dnd-kit/core"; -import { - SortableContext, - useSortable, - verticalListSortingStrategy, - arrayMove, -} from "@dnd-kit/sortable"; +import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { MAX_LOAD_ALL_SIZE, @@ -116,14 +86,7 @@ function SortableColumnRow({ onFormatChange: (checked: boolean) => void; onRemove: () => void; }) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id }); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const style = { transform: CSS.Transform.toString(transform), transition }; return ( @@ -133,7 +96,7 @@ function SortableColumnRow({ className={cn( "bg-card flex items-center gap-1.5 rounded-md border px-2 py-1.5", isDragging && "z-50 opacity-50 shadow-md", - isEntityJoin && "border-primary/20 bg-primary/5" + isEntityJoin && "border-primary/20 bg-primary/5", )} >
) : ( - - #{index + 1} - + #{index + 1} )} % {isNumeric && ( -
); } @@ -231,9 +187,7 @@ function SwitchRow({

{label}

- {description && ( -

{description}

- )} + {description &&

{description}

}
@@ -306,17 +260,8 @@ const PanelColumnSection: React.FC<{ }; loadingEntityJoins: boolean; tableName: string; - onColumnsChange: ( - columns: SplitPanelLayoutConfig["leftPanel"]["columns"] - ) => void; -}> = ({ - columns, - availableColumns, - entityJoinData, - loadingEntityJoins, - tableName, - onColumnsChange, -}) => { + onColumnsChange: (columns: SplitPanelLayoutConfig["leftPanel"]["columns"]) => void; +}> = ({ columns, availableColumns, entityJoinData, loadingEntityJoins, tableName, onColumnsChange }) => { const currentColumns = columns || []; const addColumn = (colInfo: ColumnInfo) => { @@ -325,8 +270,7 @@ const PanelColumnSection: React.FC<{ ...currentColumns, { name: colInfo.columnName, - label: - colInfo.displayName || colInfo.columnName, + label: colInfo.displayName || colInfo.columnName, width: 120, }, ]); @@ -336,18 +280,11 @@ const PanelColumnSection: React.FC<{ onColumnsChange(currentColumns.filter((c) => c.name !== name)); }; - const updateColumn = ( - name: string, - updates: Partial<(typeof currentColumns)[0]> - ) => { - onColumnsChange( - currentColumns.map((c) => (c.name === name ? { ...c, ...updates } : c)) - ); + const updateColumn = (name: string, updates: Partial<(typeof currentColumns)[0]>) => { + onColumnsChange(currentColumns.map((c) => (c.name === name ? { ...c, ...updates } : c))); }; - const addEntityColumn = ( - joinCol: (typeof entityJoinData.availableColumns)[0] - ) => { + const addEntityColumn = (joinCol: (typeof entityJoinData.availableColumns)[0]) => { if (currentColumns.some((c) => c.name === joinCol.joinAlias)) return; onColumnsChange([ ...currentColumns, @@ -385,37 +322,28 @@ const PanelColumnSection: React.FC<{ {availableColumns.length > 0 && (
- + 컬럼 선택
{availableColumns.map((col) => { - const isAdded = currentColumns.some( - (c) => c.name === col.columnName - ); + const isAdded = currentColumns.some((c) => c.name === col.columnName); return (
{ if (isAdded) removeColumn(col.columnName); else addColumn(col); }} > - + - - {col.displayName || col.columnName} - - - {col.input_type || col.dataType} - + {col.displayName || col.columnName} + {col.input_type || col.dataType}
); })} @@ -427,58 +355,43 @@ const PanelColumnSection: React.FC<{ {entityJoinData.joinTables.length > 0 && (
- + Entity 조인 컬럼 - {loadingEntityJoins && ( - - )} + {loadingEntityJoins && }
{entityJoinData.joinTables.map((joinTable, idx) => (
-
+
{joinTable.tableName} {joinTable.currentDisplayColumn}
-
+
{joinTable.availableColumns.map((jCol, jIdx) => { - const matchingJoinColumn = - entityJoinData.availableColumns.find( - (jc) => - jc.tableName === joinTable.tableName && - jc.columnName === jCol.columnName - ); - if (!matchingJoinColumn) return null; - const isAdded = currentColumns.some( - (c) => c.name === matchingJoinColumn.joinAlias + const matchingJoinColumn = entityJoinData.availableColumns.find( + (jc) => jc.tableName === joinTable.tableName && jc.columnName === jCol.columnName, ); + if (!matchingJoinColumn) return null; + const isAdded = currentColumns.some((c) => c.name === matchingJoinColumn.joinAlias); return (
{ - if (isAdded) - removeColumn(matchingJoinColumn.joinAlias); + if (isAdded) removeColumn(matchingJoinColumn.joinAlias); else addEntityColumn(matchingJoinColumn); }} > - - - - {jCol.columnLabel} - - - {jCol.inputType || jCol.dataType} - + + + {jCol.columnLabel} + {jCol.inputType || jCol.dataType}
); })} @@ -493,10 +406,8 @@ const PanelColumnSection: React.FC<{ {currentColumns.length > 0 && (
- - - 선택된 컬럼 ({currentColumns.length}개) - + + 선택된 컬럼 ({currentColumns.length}개)
- c.name)} - strategy={verticalListSortingStrategy} - > + c.name)} strategy={verticalListSortingStrategy}>
{currentColumns.map((col, idx) => ( t.tableName === value)?.displayName || - value + ? allTables.find((t) => t.tableName === value)?.displayName || value : "테이블 선택"}
- + - - 테이블을 찾을 수 없습니다. - + 테이블을 찾을 수 없습니다. {screenTableName && ( - + - {allTables.find((t) => t.tableName === screenTableName) - ?.displayName || screenTableName} + {allTables.find((t) => t.tableName === screenTableName)?.displayName || screenTableName} )} @@ -626,18 +521,11 @@ const TableCombobox: React.FC<{ }} className="text-xs" > - +
{table.displayName} {table.displayName !== table.tableName && ( - - {table.tableName} - + {table.tableName} )}
@@ -660,20 +548,18 @@ interface V2SplitPanelLayoutConfigPanelProps { menuObjid?: number; } -export const V2SplitPanelLayoutConfigPanel: React.FC< - V2SplitPanelLayoutConfigPanelProps -> = ({ config, onChange, tables, screenTableName, menuObjid }) => { +export const V2SplitPanelLayoutConfigPanel: React.FC = ({ + config, + onChange, + tables, + screenTableName, + menuObjid, +}) => { // ─── 상태 ─── - const [allTables, setAllTables] = useState< - Array<{ tableName: string; displayName: string }> - >([]); + const [allTables, setAllTables] = useState>([]); const [loadingTables, setLoadingTables] = useState(false); - const [loadedTableColumns, setLoadedTableColumns] = useState< - Record - >({}); - const [loadingColumns, setLoadingColumns] = useState< - Record - >({}); + const [loadedTableColumns, setLoadedTableColumns] = useState>({}); + const [loadingColumns, setLoadingColumns] = useState>({}); const [entityJoinColumns, setEntityJoinColumns] = useState< Record< string, @@ -700,9 +586,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< } > >({}); - const [loadingEntityJoins, setLoadingEntityJoins] = useState< - Record - >({}); + const [loadingEntityJoins, setLoadingEntityJoins] = useState>({}); // Collapsible 상태 const [leftPanelOpen, setLeftPanelOpen] = useState(false); @@ -721,11 +605,11 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< const leftTableColumns = useMemo( () => (leftTableName ? loadedTableColumns[leftTableName] || [] : []), - [loadedTableColumns, leftTableName] + [loadedTableColumns, leftTableName], ); const rightTableColumns = useMemo( () => (rightTableName ? loadedTableColumns[rightTableName] || [] : []), - [loadedTableColumns, rightTableName] + [loadedTableColumns, rightTableName], ); const leftEntityJoins = useMemo( @@ -734,7 +618,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< availableColumns: [], joinTables: [], }, - [entityJoinColumns, leftTableName] + [entityJoinColumns, leftTableName], ); const rightEntityJoins = useMemo( () => @@ -742,7 +626,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< availableColumns: [], joinTables: [], }, - [entityJoinColumns, rightTableName] + [entityJoinColumns, rightTableName], ); // ─── 이벤트 발행 래퍼 ─── @@ -753,18 +637,18 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< window.dispatchEvent( new CustomEvent("componentConfigChanged", { detail: { config: newConfig }, - }) + }), ); } }, - [onChange] + [onChange], ); const updateConfig = useCallback( (updates: Partial) => { handleChange({ ...config, ...updates }); }, - [handleChange, config] + [handleChange, config], ); const updateLeftPanel = useCallback( @@ -774,7 +658,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< leftPanel: { ...config.leftPanel, ...updates }, }); }, - [handleChange, config] + [handleChange, config], ); const updateRightPanel = useCallback( @@ -784,7 +668,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< rightPanel: { ...config.rightPanel, ...updates }, }); }, - [handleChange, config] + [handleChange, config], ); // ─── 테이블 목록 로드 ─── @@ -797,9 +681,8 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< setAllTables( response.data.map((t: any) => ({ tableName: t.tableName || t.table_name, - displayName: - t.tableLabel || t.displayName || t.tableName || t.table_name, - })) + displayName: t.tableLabel || t.displayName || t.tableName || t.table_name, + })), ); } } catch (error) { @@ -829,12 +712,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< const cols = (columnsResponse || []).map((col: any) => ({ tableName: col.tableName || tableName, columnName: col.columnName || col.column_name, - displayName: - col.displayName || - col.columnLabel || - col.column_label || - col.columnName || - col.column_name, + displayName: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, dataType: col.dataType || col.data_type || col.dbType || "", dbType: col.dbType || col.dataType || col.data_type || "", webType: col.webType || col.web_type || "text", @@ -853,7 +731,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< setLoadingColumns((prev) => ({ ...prev, [tableName]: false })); } }, - [loadedTableColumns, loadingColumns] + [loadedTableColumns, loadingColumns], ); const loadEntityJoinColumnsForTable = useCallback( @@ -879,7 +757,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< setLoadingEntityJoins((prev) => ({ ...prev, [tableName]: false })); } }, - [entityJoinColumns, loadingEntityJoins] + [entityJoinColumns, loadingEntityJoins], ); // 좌측/우측 테이블 변경 시 컬럼 로드 @@ -910,17 +788,15 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< newTabs[tabIndex] = { ...newTabs[tabIndex], ...updates }; updateRightPanel({ additionalTabs: newTabs }); }, - [config.rightPanel?.additionalTabs, updateRightPanel] + [config.rightPanel?.additionalTabs, updateRightPanel], ); const removeTab = useCallback( (tabIndex: number) => { - const newTabs = - config.rightPanel?.additionalTabs?.filter((_, i) => i !== tabIndex) || - []; + const newTabs = config.rightPanel?.additionalTabs?.filter((_, i) => i !== tabIndex) || []; updateRightPanel({ additionalTabs: newTabs }); }, - [config.rightPanel?.additionalTabs, updateRightPanel] + [config.rightPanel?.additionalTabs, updateRightPanel], ); // ─── 렌더링 ─── @@ -950,19 +826,15 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< }) } className={cn( - "flex flex-col items-center justify-center rounded-lg border p-3 text-center transition-all min-h-[80px]", + "flex min-h-[80px] flex-col items-center justify-center rounded-lg border p-3 text-center transition-all", isSelected - ? "border-primary bg-primary/5 ring-1 ring-primary/20" - : "border-border hover:border-primary/50 hover:bg-muted/50" + ? "border-primary bg-primary/5 ring-primary/20 ring-1" + : "border-border hover:border-primary/50 hover:bg-muted/50", )} > - - - {card.title} - - - {card.description} - + + {card.title} + {card.description} ); })} @@ -973,21 +845,13 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< {/* 2단계: 레이아웃 설정 */} {/* ═══════════════════════════════════════ */}
- +
- - 좌측 패널 너비 - - - {config.splitRatio || 30}% - + 좌측 패널 너비 + {config.splitRatio || 30}%
@@ -1044,7 +908,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
{/* 좌측 패널 제목 */}
- + updateLeftPanel({ title: e.target.value })} @@ -1055,66 +919,58 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< {/* 좌측 테이블 선택 */}
- + - updateLeftPanel({ tableName, columns: [] }) - } + onChange={(tableName) => updateLeftPanel({ tableName, columns: [] })} /> - {screenTableName && - leftTableName !== screenTableName && ( -
- - 기본 테이블({screenTableName})과 다름 - - -
- )} + {screenTableName && leftTableName !== screenTableName && ( +
+ + 기본 테이블({screenTableName})과 다름 + + +
+ )}
{/* 표시 모드 */}
- +
{DISPLAY_MODE_CARDS.map((card) => { const Icon = card.icon; - const currentMode = - config.leftPanel?.displayMode || "list"; + const currentMode = config.leftPanel?.displayMode || "list"; const isSelected = currentMode === card.value; return ( ); })} @@ -1126,38 +982,28 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< - updateLeftPanel({ showSearch: checked }) - } + onCheckedChange={(checked) => updateLeftPanel({ showSearch: checked })} /> - updateLeftPanel({ showAdd: checked }) - } + onCheckedChange={(checked) => updateLeftPanel({ showAdd: checked })} /> - updateLeftPanel({ showEdit: checked }) - } + onCheckedChange={(checked) => updateLeftPanel({ showEdit: checked })} /> - updateLeftPanel({ showDelete: checked }) - } + onCheckedChange={(checked) => updateLeftPanel({ showDelete: checked })} /> - updateLeftPanel({ showItemAddButton: checked }) - } + onCheckedChange={(checked) => updateLeftPanel({ showItemAddButton: checked })} /> + @@ -1224,12 +1067,12 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
{loadingColumns[leftTableName] ? ( -
+
컬럼 로딩 중...
) : leftTableColumns.length === 0 ? ( -

+

테이블을 선택하면 컬럼이 표시됩니다

) : ( @@ -1238,13 +1081,9 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< columns={config.leftPanel?.columns} availableColumns={leftTableColumns} entityJoinData={leftEntityJoins} - loadingEntityJoins={ - loadingEntityJoins[leftTableName] || false - } + loadingEntityJoins={loadingEntityJoins[leftTableName] || false} tableName={leftTableName} - onColumnsChange={(columns) => - updateLeftPanel({ columns }) - } + onColumnsChange={(columns) => updateLeftPanel({ columns })} /> )}
@@ -1253,23 +1092,20 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< )} {/* 좌측 패널 데이터 필터 (접이식) */} - + @@ -1280,9 +1116,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< tableName={leftTableName} columns={leftTableColumns} config={config.leftPanel?.dataFilter} - onConfigChange={(dataFilter) => - updateLeftPanel({ dataFilter }) - } + onConfigChange={(dataFilter) => updateLeftPanel({ dataFilter })} />
@@ -1298,24 +1132,22 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< @@ -1324,7 +1156,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
{/* 우측 패널 제목 */}
- + updateRightPanel({ title: e.target.value })} @@ -1335,45 +1167,38 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< {/* 우측 테이블 선택 */}
- + - updateRightPanel({ tableName, columns: [] }) - } + onChange={(tableName) => updateRightPanel({ tableName, columns: [] })} />
{/* 표시 모드 */}
- +
{DISPLAY_MODE_CARDS.map((card) => { const Icon = card.icon; - const currentMode = - config.rightPanel?.displayMode || "list"; + const currentMode = config.rightPanel?.displayMode || "list"; const isSelected = currentMode === card.value; return ( ); })} @@ -1384,111 +1209,91 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< {rightTableName && (
- + 테이블 연결 키
-

- 좌측 패널과 우측 패널을 연결할 컬럼을 설정합니다 -

+

좌측 패널과 우측 패널을 연결할 컬럼을 설정합니다

{/* 기존 키 목록 */} - {(config.rightPanel?.relation?.keys || []).map( - (key, idx) => ( -
- + {(config.rightPanel?.relation?.keys || []).map((key, idx) => ( +
+ - + - + - -
- ) - )} + +
+ ))} {/* 키가 없을 때 단일키 호환 */} - {(!config.rightPanel?.relation?.keys || - config.rightPanel.relation.keys.length === 0) && ( + {(!config.rightPanel?.relation?.keys || config.rightPanel.relation.keys.length === 0) && (
- + updateRightPanel({ deduplication: { @@ -1796,10 +1565,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< {rightTableColumns.map((col) => ( - + {col.displayName || col.columnName} ))} @@ -1807,14 +1573,9 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
- - 유지 전략 - + 유지 전략
@@ -1852,15 +1605,12 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< updateRightPanel({ editButton: { ...config.rightPanel?.editButton, - enabled: - config.rightPanel?.editButton?.enabled ?? true, + enabled: config.rightPanel?.editButton?.enabled ?? true, mode: checked ? "modal" : "auto", }, }) @@ -1868,23 +1618,17 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< /> {config.rightPanel?.editButton?.mode === "modal" && ( -
+
- - 모달 화면 ID - + 모달 화면 ID updateRightPanel({ editButton: { ...config.rightPanel?.editButton!, - modalScreenId: - parseInt(e.target.value) || undefined, + modalScreenId: parseInt(e.target.value) || undefined, }, }) } @@ -1899,15 +1643,12 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< updateRightPanel({ addButton: { ...config.rightPanel?.addButton, - enabled: - config.rightPanel?.addButton?.enabled ?? true, + enabled: config.rightPanel?.addButton?.enabled ?? true, mode: checked ? "modal" : "auto", }, }) @@ -1915,22 +1656,17 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< /> {config.rightPanel?.addButton?.mode === "modal" && ( -
+
- - 모달 화면 ID - + 모달 화면 ID updateRightPanel({ addButton: { ...config.rightPanel?.addButton!, - modalScreenId: - parseInt(e.target.value) || undefined, + modalScreenId: parseInt(e.target.value) || undefined, }, }) } @@ -1949,22 +1685,17 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< updateRightPanel({ deleteButton: { ...config.rightPanel?.deleteButton, - enabled: - config.rightPanel?.deleteButton?.enabled ?? true, - confirmMessage: checked - ? "정말 삭제하시겠습니까?" - : undefined, + enabled: config.rightPanel?.deleteButton?.enabled ?? true, + confirmMessage: checked ? "정말 삭제하시겠습니까?" : undefined, }, }) } /> {config.rightPanel?.deleteButton?.confirmMessage && ( -
+
updateRightPanel({ deleteButton: { @@ -1983,21 +1714,15 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< {/* 추가 시 대상 테이블 (N:M 관계) */}
- - 추가 대상 설정 (N:M) - -

+ 추가 대상 설정 (N:M) +

추가 버튼 클릭 시 실제 INSERT할 테이블을 지정합니다

- - 대상 테이블 - + 대상 테이블 updateRightPanel({ addConfig: { @@ -2011,14 +1736,9 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< />
- - 좌측값 컬럼 - + 좌측값 컬럼 updateRightPanel({ addConfig: { @@ -2032,13 +1752,9 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< />
- - 대상 컬럼 - + 대상 컬럼 updateRightPanel({ addConfig: { @@ -2059,15 +1775,10 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< <>
- - 테이블 옵션 - + 테이블 옵션 updateRightPanel({ tableConfig: { @@ -2079,10 +1790,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< /> updateRightPanel({ tableConfig: { @@ -2094,9 +1802,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< /> updateRightPanel({ tableConfig: { @@ -2108,10 +1814,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< /> updateRightPanel({ tableConfig: { @@ -2138,17 +1841,19 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< @@ -2156,193 +1861,159 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
{/* 탭 목록 */} - {(config.rightPanel?.additionalTabs || []).map( - (tab, tabIndex) => ( -
-
- - {tab.label || `탭 ${tabIndex + 1}`} - - -
+ {(config.rightPanel?.additionalTabs || []).map((tab, tabIndex) => ( +
+
+ {tab.label || `탭 ${tabIndex + 1}`} + +
-
-
- - - updateTab(tabIndex, { label: e.target.value }) - } - placeholder="탭 이름" - className="h-7 text-xs" - /> -
-
- - - updateTab(tabIndex, { title: e.target.value }) - } - placeholder="패널 제목" - className="h-7 text-xs" - /> -
-
- - {/* 탭 테이블 선택 */} +
- - { - updateTab(tabIndex, { - tableName, - columns: [], - }); - if (tableName) loadTableColumns(tableName); - }} + + updateTab(tabIndex, { label: e.target.value })} + placeholder="탭 이름" + className="h-7 text-xs" />
- - {/* 탭 표시 모드 */} -
- - 표시 모드 - - -
- - {/* 탭 연결 키 */} - {tab.tableName && ( -
- 연결 키 -
- - - -
-
- )} - - {/* 탭 기능 토글 */} -
- - updateTab(tabIndex, { showSearch: checked }) - } - /> - - updateTab(tabIndex, { showAdd: checked }) - } - /> - - updateTab(tabIndex, { showDelete: checked }) - } +
+ + updateTab(tabIndex, { title: e.target.value })} + placeholder="패널 제목" + className="h-7 text-xs" />
- ) - )} + + {/* 탭 테이블 선택 */} +
+ + { + updateTab(tabIndex, { + tableName, + columns: [], + }); + if (tableName) loadTableColumns(tableName); + }} + /> +
+ + {/* 탭 표시 모드 */} +
+ 표시 모드 + +
+ + {/* 탭 연결 키 */} + {tab.tableName && ( +
+ 연결 키 +
+ + + +
+
+ )} + + {/* 탭 기능 토글 */} +
+ updateTab(tabIndex, { showSearch: checked })} + /> + updateTab(tabIndex, { showAdd: checked })} + /> + updateTab(tabIndex, { showDelete: checked })} + /> +
+
+ ))} {/* 탭 추가 버튼 */} -
@@ -2355,17 +2026,19 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< @@ -2376,9 +2049,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< label="선택 동기화" description="좌우 패널 간 선택 항목 동기화" checked={config.syncSelection ?? false} - onCheckedChange={(checked) => - updateConfig({ syncSelection: checked }) - } + onCheckedChange={(checked) => updateConfig({ syncSelection: checked })} /> @@ -2421,26 +2092,18 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< {/* 좌측 패널 하위 항목 추가 설정 */} {config.leftPanel?.showItemAddButton && (
- - 하위 항목 추가 설정 - + 하위 항목 추가 설정
- - 부모 컬럼 - + 부모 컬럼 updateLeftPanel({ itemAddConfig: { ...config.leftPanel?.itemAddConfig, parentColumn: e.target.value, - sourceColumn: - config.leftPanel?.itemAddConfig?.sourceColumn || - "", + sourceColumn: config.leftPanel?.itemAddConfig?.sourceColumn || "", }, }) } @@ -2449,13 +2112,9 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< />
- - 소스 컬럼 - + 소스 컬럼 updateLeftPanel({ itemAddConfig: { @@ -2478,9 +2137,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< 좌측 테이블 옵션 updateLeftPanel({ tableConfig: { @@ -2492,9 +2149,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< /> updateLeftPanel({ tableConfig: { @@ -2518,9 +2173,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< /> updateLeftPanel({ tableConfig: { @@ -2544,30 +2197,24 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< updateLeftPanel({ editButton: { ...config.leftPanel?.editButton, - enabled: - config.leftPanel?.editButton?.enabled ?? true, + enabled: config.leftPanel?.editButton?.enabled ?? true, mode: checked ? "modal" : "auto", }, }) } /> {config.leftPanel?.editButton?.mode === "modal" && ( -
+
- - 모달 화면 ID - + 모달 화면 ID updateLeftPanel({ editButton: { ...config.leftPanel?.editButton!, - modalScreenId: - parseInt(e.target.value) || undefined, + modalScreenId: parseInt(e.target.value) || undefined, }, }) } @@ -2585,30 +2232,24 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< updateLeftPanel({ addButton: { ...config.leftPanel?.addButton, - enabled: - config.leftPanel?.addButton?.enabled ?? true, + enabled: config.leftPanel?.addButton?.enabled ?? true, mode: checked ? "modal" : "auto", }, }) } /> {config.leftPanel?.addButton?.mode === "modal" && ( -
+
- - 모달 화면 ID - + 모달 화면 ID updateLeftPanel({ addButton: { ...config.leftPanel?.addButton!, - modalScreenId: - parseInt(e.target.value) || undefined, + modalScreenId: parseInt(e.target.value) || undefined, }, }) } @@ -2632,8 +2273,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< value={config.leftPanel?.panelHeaderHeight || ""} onChange={(e) => updateLeftPanel({ - panelHeaderHeight: - parseInt(e.target.value) || undefined, + panelHeaderHeight: parseInt(e.target.value) || undefined, }) } placeholder="자동" @@ -2647,8 +2287,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< value={config.rightPanel?.panelHeaderHeight || ""} onChange={(e) => updateRightPanel({ - panelHeaderHeight: - parseInt(e.target.value) || undefined, + panelHeaderHeight: parseInt(e.target.value) || undefined, }) } placeholder="자동" diff --git a/frontend/hooks/useLogin.ts b/frontend/hooks/useLogin.ts index bd0cf9a2..01231441 100644 --- a/frontend/hooks/useLogin.ts +++ b/frontend/hooks/useLogin.ts @@ -2,12 +2,13 @@ import { useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; -import { LoginFormData, LoginResponse } from "@/types/auth"; +import { LoginFormData } from "@/types/auth"; import { AUTH_CONFIG, FORM_VALIDATION } from "@/constants/auth"; -import { API_BASE_URL } from "@/lib/api/client"; +import { apiCall } from "@/lib/api/client"; /** * 로그인 관련 비즈니스 로직을 관리하는 커스텀 훅 + * API 호출은 lib/api/client의 apiCall(Axios) 사용 (fetch 직접 사용 금지) */ export const useLogin = () => { const router = useRouter(); @@ -73,67 +74,34 @@ export const useLogin = () => { }, [formData]); /** - * API 호출 공통 함수 - */ - const apiCall = useCallback(async (endpoint: string, options: RequestInit = {}): Promise => { - // 로컬 스토리지에서 토큰 가져오기 - const token = localStorage.getItem("authToken"); - - const response = await fetch(`${API_BASE_URL}${endpoint}`, { - credentials: "include", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - ...(token && { Authorization: `Bearer ${token}` }), - ...options.headers, - }, - ...options, - }); - - const result = await response.json(); - return result; - }, []); - - /** - * 기존 인증 상태 확인 + * 기존 인증 상태 확인 (apiCall 사용) */ const checkExistingAuth = useCallback(async () => { try { - // 로컬 스토리지에서 토큰 확인 const token = localStorage.getItem("authToken"); - if (!token) { - // 토큰이 없으면 로그인 페이지 유지 - return; - } + if (!token) return; - // 토큰이 있으면 API 호출로 유효성 확인 - const result = await apiCall(AUTH_CONFIG.ENDPOINTS.STATUS); + const result = await apiCall<{ isAuthenticated?: boolean }>("GET", AUTH_CONFIG.ENDPOINTS.STATUS); - // 백엔드가 isAuthenticated 필드를 반환함 if (result.success && result.data?.isAuthenticated) { - // 이미 로그인된 경우 메인으로 리다이렉트 router.push(AUTH_CONFIG.ROUTES.MAIN); } else { - // 토큰이 유효하지 않으면 제거 localStorage.removeItem("authToken"); document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax"; } - } catch (error) { - // 에러가 발생하면 토큰 제거 + } catch { localStorage.removeItem("authToken"); document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax"; - console.debug("기존 인증 체크 중 오류 (정상):", error); } - }, [apiCall, router]); + }, [router]); /** - * 로그인 처리 + * 로그인 처리 (apiCall 사용 - Axios 기반, fetch 미사용) */ const handleLogin = useCallback( async (e: React.FormEvent) => { e.preventDefault(); - // 입력값 검증 const validationError = validateForm(); if (validationError) { setError(validationError); @@ -144,9 +112,13 @@ export const useLogin = () => { setError(""); try { - const result = await apiCall(AUTH_CONFIG.ENDPOINTS.LOGIN, { - method: "POST", - body: JSON.stringify(formData), + const result = await apiCall<{ + token?: string; + firstMenuPath?: string; + popLandingPath?: string; + }>("POST", AUTH_CONFIG.ENDPOINTS.LOGIN, { + userId: formData.userId, + password: formData.password, }); if (result.success && result.data?.token) { @@ -185,7 +157,7 @@ export const useLogin = () => { setIsLoading(false); } }, - [formData, validateForm, apiCall, router, isPopMode], + [formData, validateForm, router, isPopMode], ); // 컴포넌트 마운트 시 기존 인증 상태 확인 diff --git a/frontend/lib/api/shipping.ts b/frontend/lib/api/shipping.ts new file mode 100644 index 00000000..b24394f2 --- /dev/null +++ b/frontend/lib/api/shipping.ts @@ -0,0 +1,60 @@ +import { apiClient } from "./client"; + +export interface EnrichedOrder { + sourceId: string; + orderNo: string; + partCode: string; + partName: string; + partnerName: string; + dueDate: string; + orderQty: number; + shipQty: number; + balanceQty: number; +} + +export interface ExistingPlan { + id: number; + sourceId: string; + planQty: number; + planDate: string; + shipmentPlanNo: string; + status: string; +} + +export interface AggregateResponse { + [partCode: string]: { + totalBalance: number; + totalPlanQty: number; + currentStock: number; + availableStock: number; + inProductionQty: number; + existingPlans: ExistingPlan[]; + orders: EnrichedOrder[]; + }; +} + +export interface BatchSavePlan { + sourceId: string; + planQty: number; + planDate?: string; +} + +// ID만 전달 → 백엔드에서 소스 테이블 자동 감지 + JOIN +export async function getShippingPlanAggregate(ids: string[]) { + const res = await apiClient.get("/shipping-plan/aggregate", { + params: { ids: ids.join(",") }, + }); + return res.data as { + success: boolean; + data: AggregateResponse; + source: "master" | "detail"; + }; +} + +export async function batchSaveShippingPlans( + plans: BatchSavePlan[], + source?: string +) { + const res = await apiClient.post("/shipping-plan/batch", { plans, source }); + return res.data as { success: boolean; message?: string; data?: any }; +} diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index f06a43fe..fb77df4b 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -30,18 +30,20 @@ export function invalidateColumnMetaCache(tableName?: string): void { } } -async function loadColumnMeta(tableName: string, forceReload = false): Promise { +export async function loadColumnMeta(tableName: string, forceReload = false): Promise { const now = Date.now(); - const isStale = columnMetaTimestamp[tableName] && (now - columnMetaTimestamp[tableName] > CACHE_TTL_MS); + const cachedAt = columnMetaTimestamp[tableName]; + const isStale = + typeof cachedAt === "number" && now - cachedAt > CACHE_TTL_MS; - if (!forceReload && !isStale && columnMetaCache[tableName]) return; + if (!forceReload && !isStale && tableName in columnMetaCache && columnMetaCache[tableName]) return; if (forceReload || isStale) { delete columnMetaCache[tableName]; delete columnMetaLoading[tableName]; } - if (columnMetaLoading[tableName]) { + if (tableName in columnMetaLoading) { await columnMetaLoading[tableName]; return; } @@ -663,7 +665,8 @@ export const DynamicComponentRenderer: React.FC = } // 1. 새 컴포넌트 시스템에서 먼저 조회 - const newComponent = ComponentRegistry.getComponent(componentType); + const newComponent = + componentType != null ? ComponentRegistry.getComponent(componentType) : null; if (newComponent) { // 새 컴포넌트 시스템으로 렌더링 @@ -775,7 +778,7 @@ export const DynamicComponentRenderer: React.FC = // 렌더러 props 구성 // 숨김 값 추출 - const hiddenValue = component.hidden || component.componentConfig?.hidden; + const hiddenValue = (component as any).hidden || component.componentConfig?.hidden; // 숨김 처리: 인터랙티브 모드(실제 뷰)에서만 숨김, 디자인 모드에서는 표시 if (hiddenValue && isInteractive) { @@ -892,7 +895,7 @@ export const DynamicComponentRenderer: React.FC = // 새로운 기능들 전달 // 🆕 webTypeConfig.numberingRuleId가 있으면 autoGeneration으로 변환 autoGeneration: - component.autoGeneration || + (component as any).autoGeneration || component.componentConfig?.autoGeneration || ((component as any).webTypeConfig?.numberingRuleId ? { @@ -992,10 +995,15 @@ export const DynamicComponentRenderer: React.FC = let renderedElement: React.ReactElement; if (isClass) { - const rendererInstance = new NewComponentRenderer(rendererProps); + const RendererClass = NewComponentRenderer as new (props: any) => { render: () => React.ReactElement }; + const rendererInstance = new RendererClass(rendererProps); renderedElement = rendererInstance.render(); } else { - renderedElement = ; + const needsKeyRefresh = + componentType === "v2-table-list" || + componentType === "table-list" || + componentType === "v2-repeater"; + renderedElement = ; } // 수평 라벨 → 라벨을 컴포넌트 영역 바깥에 absolute 배치, 입력은 100% 채움 @@ -1004,7 +1012,9 @@ export const DynamicComponentRenderer: React.FC = const labelFontSize = component.style?.labelFontSize || "14px"; const labelColor = getAdaptiveLabelColor(component.style?.labelColor); const labelFontWeight = component.style?.labelFontWeight || "500"; - const isRequired = effectiveComponent.required || isColumnRequiredByMeta(screenTableName, baseColumnName); + const isRequired = + effectiveComponent.required || + isColumnRequiredByMeta(screenTableName ?? "", baseColumnName ?? ""); const isLeft = labelPosition === "left"; return ( @@ -1038,7 +1048,8 @@ export const DynamicComponentRenderer: React.FC = } // 2. 레거시 시스템에서 조회 - const renderer = legacyComponentRegistry.get(componentType); + const renderer = + componentType != null ? legacyComponentRegistry.get(componentType) : undefined; if (!renderer) { console.error(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`, { diff --git a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx index f0ee3594..73317dce 100644 --- a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx @@ -106,6 +106,7 @@ const FileUploadComponent: React.FC = ({ const [forceUpdate, setForceUpdate] = useState(0); const [representativeImageUrl, setRepresentativeImageUrl] = useState(null); const fileInputRef = useRef(null); + const containerRef = useRef(null); // 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리 const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_')); @@ -217,18 +218,17 @@ const FileUploadComponent: React.FC = ({ } }, [component.id, getUniqueKey, recordId, isRecordMode]); - // 🆕 모달 닫힘/저장 성공 시 localStorage 파일 캐시 정리 (등록 후 재등록 시 이전 파일 잔존 방지) + // 모달 닫힘/저장 성공 시 localStorage 파일 캐시 정리 (등록 후 재등록 시 이전 파일 잔존 방지) + // 모달(Dialog) 내부의 컴포넌트만 초기화 대상 - 일반 화면의 파일 업로드는 초기화하지 않음 useEffect(() => { const handleClearFileCache = (event: Event) => { + // 모달 내부 컴포넌트만 초기화 (일반 화면에서는 스킵) + const isInModal = containerRef.current ? !!containerRef.current.closest('[role="dialog"]') : false; + if (!isInModal) { + return; + } + const backupKey = getUniqueKey(); - const eventType = event.type; - console.log("🧹 [DEBUG-CLEAR] 파일 캐시 정리 이벤트 수신:", { - eventType, - backupKey, - componentId: component.id, - currentFiles: uploadedFiles.length, - hasLocalStorage: !!localStorage.getItem(backupKey), - }); try { localStorage.removeItem(backupKey); setUploadedFiles([]); @@ -238,22 +238,15 @@ const FileUploadComponent: React.FC = ({ delete globalFileState[backupKey]; (window as any).globalFileState = globalFileState; } - console.log("🧹 [DEBUG-CLEAR] 정리 완료:", backupKey); } catch (e) { console.warn("파일 캐시 정리 실패:", e); } }; - // EditModal 닫힘, ScreenModal 연속 등록 저장 성공, 일반 저장 성공 모두 처리 window.addEventListener("closeEditModal", handleClearFileCache); window.addEventListener("saveSuccess", handleClearFileCache); window.addEventListener("saveSuccessInModal", handleClearFileCache); - console.log("🔎 [DEBUG-CLEAR] 이벤트 리스너 등록 완료:", { - componentId: component.id, - backupKey: getUniqueKey(), - }); - return () => { window.removeEventListener("closeEditModal", handleClearFileCache); window.removeEventListener("saveSuccess", handleClearFileCache); @@ -1190,10 +1183,11 @@ const FileUploadComponent: React.FC = ({ return (
= ({ toast.dismiss(); // UI 전환 액션 및 모달 액션은 로딩 토스트 표시하지 않음 - const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "approval"]; + const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "approval", "event"]; if (!silentActions.includes(actionConfig.type)) { currentLoadingToastRef.current = toast.loading( actionConfig.type === "save" @@ -631,7 +631,7 @@ export const ButtonPrimaryComponent: React.FC = ({ // 실패한 경우 오류 처리 if (!success) { // UI 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시) - const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "save", "delete", "quickInsert"]; + const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "save", "delete", "quickInsert", "event"]; if (silentErrorActions.includes(actionConfig.type)) { return; } @@ -691,25 +691,27 @@ export const ButtonPrimaryComponent: React.FC = ({ target: "all", }); - // 2. 모달 닫기 (약간의 딜레이) - setTimeout(() => { - // EditModal 내부인지 확인 (isInModal prop 사용) - const isInEditModal = (props as any).isInModal; + // 2. 모달 닫기 (약간의 딜레이, 모달 내부에서만) + const isInEditModal = (props as any).isInModal; + const isInScreenModal = !!(props as any).isScreenModal || !!context.onClose; - if (isInEditModal) { - v2EventBus.emitSync(V2_EVENTS.MODAL_CLOSE, { - modalId: "edit-modal", - reason: "save", + if (isInEditModal || isInScreenModal) { + setTimeout(() => { + if (isInEditModal) { + v2EventBus.emitSync(V2_EVENTS.MODAL_CLOSE, { + modalId: "edit-modal", + reason: "save", + }); + } + + // ScreenModal 연속 등록 모드 지원 + v2EventBus.emitSync(V2_EVENTS.MODAL_SAVE_SUCCESS, { + modalId: "screen-modal", + savedData: context.formData || {}, + tableName: context.tableName || "", }); - } - - // ScreenModal은 연속 등록 모드를 지원하므로 saveSuccessInModal 이벤트 발생 - v2EventBus.emitSync(V2_EVENTS.MODAL_SAVE_SUCCESS, { - modalId: "screen-modal", - savedData: context.formData || {}, - tableName: context.tableName || "", - }); - }, 100); + }, 100); + } } } catch (error) { // 로딩 토스트 제거 @@ -938,11 +940,28 @@ export const ButtonPrimaryComponent: React.FC = ({ effectiveMappingRules = multiTableMappings[0]?.mappingRules || []; } + // 소스 DataProvider에서 엔티티 조인 메타데이터 가져오기 + const entityJoinColumns = sourceProvider?.getEntityJoinColumns?.() || []; + if (entityJoinColumns.length > 0) { + console.log(`🔗 [ButtonPrimary] 엔티티 조인 메타데이터 ${entityJoinColumns.length}개 감지`, entityJoinColumns); + } + const mappedData = sourceData.map((row) => { - const mappedRow = applyMappingRules(row, effectiveMappingRules); + const mappedRow = applyMappingRules(row, effectiveMappingRules, entityJoinColumns); + + // 소스 출처 추적: source_table과 source_id를 자동 주입 + // 타겟 테이블에 해당 컬럼이 있으면 저장되고, 없으면 자동 무시됨 + const sourceTracking: Record = {}; + if (sourceTableName) { + sourceTracking.source_table = sourceTableName; + } + if (row.id) { + sourceTracking.source_id = row.id; + } return { ...mappedRow, + ...sourceTracking, ...additionalData, }; }); diff --git a/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx b/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx index 2e654c7a..22485ec9 100644 --- a/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx +++ b/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx @@ -2,18 +2,19 @@ /** * V2 카테고리 관리 컴포넌트 - * - 트리 구조 기반 카테고리 값 관리 + * - 대시보드 레이아웃: Stat Strip + 좌측 테이블 nav + 칩 바 + 트리/목록 편집기 * - 3단계 계층 구조 지원 (대분류/중분류/소분류) */ -import React, { useState, useCallback, useEffect } from "react"; +import React, { useState, useCallback, useMemo } from "react"; import { CategoryColumnList } from "@/components/table-category/CategoryColumnList"; +import type { CategoryColumn } from "@/components/table-category/CategoryColumnList"; import { CategoryValueManager } from "@/components/table-category/CategoryValueManager"; import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree"; import { LayoutList, TreeDeciduous } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; -import { ResponsiveSplitPanel } from "@/components/common/ResponsiveSplitPanel"; import { V2CategoryManagerConfig, defaultV2CategoryManagerConfig, ViewMode } from "./types"; interface V2CategoryManagerComponentProps { @@ -33,80 +34,87 @@ export function V2CategoryManagerComponent({ componentConfig, ...props }: V2CategoryManagerComponentProps) { - // 설정 병합 (componentConfig도 포함) const config: V2CategoryManagerConfig = { ...defaultV2CategoryManagerConfig, ...externalConfig, ...componentConfig, }; - // tableName 우선순위: props > selectedScreen > componentConfig - const effectiveTableName = tableName || selectedScreen?.tableName || (componentConfig as any)?.tableName || ""; - - // menuObjid 우선순위: props > selectedScreen + const effectiveTableName = + tableName || selectedScreen?.tableName || (componentConfig as any)?.tableName || ""; const propsMenuObjid = typeof props.menuObjid === "number" ? props.menuObjid : undefined; const effectiveMenuObjid = menuObjid || propsMenuObjid || selectedScreen?.menuObjid; - // 디버그 로그 - useEffect(() => { - console.log("🔍 V2CategoryManagerComponent props:", { - tableName, - menuObjid, - selectedScreen, - effectiveTableName, - effectiveMenuObjid, - config, - }); - }, [tableName, menuObjid, selectedScreen, effectiveTableName, effectiveMenuObjid, config]); - - // 선택된 컬럼 상태 + const [columns, setColumns] = useState([]); + const [selectedTable, setSelectedTable] = useState(null); const [selectedColumn, setSelectedColumn] = useState<{ uniqueKey: string; columnName: string; columnLabel: string; tableName: string; } | null>(null); - - // 뷰 모드 상태 const [viewMode, setViewMode] = useState(config.viewMode); - // 컬럼 선택 핸들러 - const handleColumnSelect = useCallback((uniqueKey: string, columnLabel: string, tableName: string) => { - const columnName = uniqueKey.split(".")[1]; - setSelectedColumn({ uniqueKey, columnName, columnLabel, tableName }); + const handleColumnsLoaded = useCallback((loaded: CategoryColumn[]) => { + setColumns(loaded); + if (loaded.length > 0) { + setSelectedTable((prev) => prev ?? loaded[0].tableName); + } }, []); - // 우측 패널 콘텐츠 + const handleTableSelect = useCallback((tableName: string) => { + setSelectedTable(tableName); + setSelectedColumn(null); + }, []); + + const handleColumnSelect = useCallback( + (uniqueKey: string, columnLabel: string, colTableName: string) => { + const columnName = uniqueKey.includes(".") ? uniqueKey.split(".")[1] : uniqueKey; + setSelectedColumn({ uniqueKey: uniqueKey.includes(".") ? uniqueKey : `${colTableName}.${uniqueKey}`, columnName, columnLabel, tableName: colTableName }); + }, + [], + ); + + const stats = useMemo(() => { + const columnCount = columns.length; + const totalValues = columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0); + const tableCount = new Set(columns.map((c) => c.tableName)).size; + const inactiveCount = 0; + return { columnCount, totalValues, tableCount, inactiveCount }; + }, [columns]); + + const columnsForSelectedTable = useMemo( + () => (selectedTable ? columns.filter((c) => c.tableName === selectedTable) : []), + [columns, selectedTable], + ); + + /** 편집기 헤더에 표시할 트리/목록 세그먼트 (보기 방식 토글) */ + const viewModeSegment = + config.showViewModeToggle ? ( +
+ + +
+ ) : null; + const rightContent = ( <> - {/* 뷰 모드 토글 */} - {config.showViewModeToggle && ( -
- 보기 방식: -
- - -
-
- )} - - {/* 카테고리 값 관리 */}
{selectedColumn ? ( viewMode === "tree" ? ( @@ -115,6 +123,7 @@ export function V2CategoryManagerComponent({ tableName={selectedColumn.tableName} columnName={selectedColumn.columnName} columnLabel={selectedColumn.columnLabel} + headerRight={viewModeSegment} /> ) : ( ) ) : ( @@ -130,7 +140,9 @@ export function V2CategoryManagerComponent({

- {config.showColumnList ? "좌측에서 관리할 카테고리 컬럼을 선택하세요" : "카테고리 컬럼이 설정되지 않았습니다"} + {config.showColumnList + ? "칩에서 카테고리 컬럼을 선택하세요" + : "카테고리 컬럼이 설정되지 않았습니다"}

@@ -148,24 +160,107 @@ export function V2CategoryManagerComponent({ } return ( - - } - right={rightContent} - leftTitle="카테고리 컬럼" - leftWidth={config.leftPanelWidth} - minLeftWidth={10} - maxLeftWidth={40} - height={config.height} - /> +
+ {/* Stat Strip: 카테고리 컬럼(primary) | 전체 값(success) | 테이블(primary) | 비활성(warning) */} +
+
+
+ {stats.columnCount} +
+
+ 카테고리 컬럼 +
+
+
+
+ {stats.totalValues} +
+
+ 전체 값 +
+
+
+
+ {stats.tableCount} +
+
+ 테이블 +
+
+
+
+ {stats.inactiveCount} +
+
+ 비활성 +
+
+
+ +
+ {/* 좌측 테이블 nav: 240px */} +
+ +
+ + {/* 우측: 칩 바 + 편집기 */} +
+ {/* 칩 바 */} +
+ {columnsForSelectedTable.map((col) => { + const uniqueKey = `${col.tableName}.${col.columnName}`; + const isActive = selectedColumn?.uniqueKey === uniqueKey; + return ( + + ); + })} + {selectedTable && columnsForSelectedTable.length === 0 && ( + 이 테이블에 카테고리 컬럼이 없습니다 + )} +
+ + {/* 편집기 영역 */} +
+ {rightContent} +
+
+
+
); } export default V2CategoryManagerComponent; - diff --git a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx index 58db0ad2..2baa6887 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx @@ -105,6 +105,7 @@ const FileUploadComponent: React.FC = ({ const [forceUpdate, setForceUpdate] = useState(0); const [representativeImageUrl, setRepresentativeImageUrl] = useState(null); const fileInputRef = useRef(null); + const containerRef = useRef(null); // objid 기반으로 파일이 로드되었는지 추적 (다른 이펙트가 덮어쓰지 않도록 방지) const filesLoadedFromObjidRef = useRef(false); @@ -197,7 +198,9 @@ const FileUploadComponent: React.FC = ({ useEffect(() => { if (!imageObjidFromFormData) { // formData에서 값이 사라지면 파일 목록도 초기화 (새 등록 시) - if (uploadedFiles.length > 0 && !isRecordMode) { + // 단, 모달 내부의 컴포넌트만 초기화 - 일반 화면에서는 저장 후 리셋으로 인한 초기화 방지 + const isInModal = containerRef.current ? !!containerRef.current.closest('[role="dialog"]') : false; + if (uploadedFiles.length > 0 && !isRecordMode && isInModal) { setUploadedFiles([]); filesLoadedFromObjidRef.current = false; } @@ -1058,11 +1061,11 @@ const FileUploadComponent: React.FC = ({ return (
= ({ component, isDesignMode = false, groupedData, formData, onFormDataChange, onClose, ...props }) => { + const config = (component?.componentConfig || + {}) as ShippingPlanEditorConfig; + const [itemGroups, setItemGroups] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [source, setSource] = useState<"master" | "detail">("detail"); + const itemGroupsRef = useRef([]); + const sourceRef = useRef<"master" | "detail">("detail"); + + // groupedData에서 선택된 행 추출 (마스터든 디테일이든 그대로) + const selectedRows = useMemo(() => { + if (!groupedData) return []; + if (Array.isArray(groupedData)) return groupedData; + if (groupedData.selectedRows) return groupedData.selectedRows; + if (groupedData.data) return groupedData.data; + return []; + }, [groupedData]); + + // 선택된 행의 ID 목록 추출 (문자열) + const selectedIds = useMemo(() => { + return selectedRows + .map((row: any) => String(row.id)) + .filter((id: string) => id && id !== "undefined" && id !== "null"); + }, [selectedRows]); + + const loadData = useCallback(async () => { + if (selectedIds.length === 0 || isDesignMode) return; + + setLoading(true); + try { + // ID만 보내면 백엔드에서 소스 감지 + JOIN + 정규화 + const res = await getShippingPlanAggregate(selectedIds); + + if (!res.success) { + toast.error("집계 데이터 조회 실패"); + return; + } + + setSource(res.source); + const aggregateData = res.data || {}; + + const groups: ItemGroup[] = Object.entries(aggregateData).map( + ([partCode, data]) => { + const details: PlanDetailRow[] = []; + + // 수주별로 기존 계획 합산량 계산 + const existingPlansBySource = new Map(); + for (const plan of data.existingPlans || []) { + const prev = existingPlansBySource.get(plan.sourceId) || 0; + existingPlansBySource.set(plan.sourceId, prev + plan.planQty); + } + + // 신규 행 먼저: 모든 수주에 대해 항상 추가 (분할출하 대응) + for (const order of data.orders || []) { + const alreadyPlanned = existingPlansBySource.get(order.sourceId) || 0; + const remainingBalance = Math.max(0, order.balanceQty - alreadyPlanned); + details.push({ + type: "new", + sourceId: order.sourceId, + orderNo: order.orderNo, + partnerName: order.partnerName, + dueDate: order.dueDate, + balanceQty: remainingBalance, + planQty: 0, + planDate: new Date().toISOString().split("T")[0], + splitKey: `${order.sourceId}_0`, + }); + } + + // 기존 출하계획 아래에 표시 + for (const plan of data.existingPlans || []) { + const matchOrder = data.orders?.find( + (o) => o.sourceId === plan.sourceId + ); + details.push({ + type: "existing", + sourceId: plan.sourceId, + orderNo: matchOrder?.orderNo || "-", + partnerName: matchOrder?.partnerName || "-", + dueDate: matchOrder?.dueDate || "-", + balanceQty: matchOrder?.balanceQty || 0, + planQty: plan.planQty, + existingPlanId: plan.id, + }); + } + + // partName: orders에서 가져오기 + const partName = + data.orders?.[0]?.partName || partCode; + + return { + partCode, + partName, + aggregation: { + totalBalance: data.totalBalance, + totalPlanQty: data.totalPlanQty, + currentStock: data.currentStock, + availableStock: data.availableStock, + inProductionQty: data.inProductionQty, + }, + details, + }; + } + ); + + setItemGroups(groups); + } catch (err) { + console.error("[v2-shipping-plan-editor] 데이터 로드 실패:", err); + toast.error("데이터를 불러오는데 실패했습니다"); + } finally { + setLoading(false); + } + }, [selectedIds, isDesignMode]); + + useEffect(() => { + loadData(); + }, [loadData]); + + // ref 동기화 (이벤트 핸들러에서 최신 state 접근용) + useEffect(() => { + itemGroupsRef.current = itemGroups; + }, [itemGroups]); + + useEffect(() => { + sourceRef.current = source; + }, [source]); + + // 저장 로직 (ref 기반으로 최신 state 접근, 재구독 방지) + const savingRef = useRef(false); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + const configRef = useRef(config); + configRef.current = config; + + const handleSave = useCallback(async () => { + if (savingRef.current) return; + + const currentGroups = itemGroupsRef.current; + const currentSource = sourceRef.current; + const currentConfig = configRef.current; + + const plans = currentGroups.flatMap((g) => + g.details + .filter((d) => d.type === "new" && d.planQty > 0) + .map((d) => ({ + sourceId: d.sourceId, + planQty: d.planQty, + planDate: d.planDate || new Date().toISOString().split("T")[0], + balanceQty: d.balanceQty, + })) + ); + + if (plans.length === 0) { + toast.warning("저장할 출하계획이 없습니다. 수량을 입력해주세요."); + return; + } + + // 같은 sourceId별 합산 → 미출하량 초과 검증 + if (!currentConfig.allowOverPlan) { + const sumBySource = new Map(); + for (const p of plans) { + const prev = sumBySource.get(p.sourceId) || { total: 0, balance: p.balanceQty }; + sumBySource.set(p.sourceId, { + total: prev.total + p.planQty, + balance: prev.balance, + }); + } + for (const [, val] of sumBySource) { + if (val.balance > 0 && val.total > val.balance) { + toast.error("분할 출하계획의 합산이 미출하량을 초과합니다."); + return; + } + } + } + + // 저장 전 확인 (confirmBeforeSave = true일 때) + if (currentConfig.confirmBeforeSave) { + const msg = currentConfig.confirmMessage || "출하계획을 저장하시겠습니까?"; + if (!window.confirm(msg)) return; + } + + savingRef.current = true; + setSaving(true); + try { + const savePlans = plans.map((p) => ({ sourceId: p.sourceId, planQty: p.planQty, planDate: p.planDate })); + const res = await batchSaveShippingPlans(savePlans, currentSource); + if (res.success) { + toast.success(`${plans.length}건의 출하계획이 저장되었습니다.`); + if (currentConfig.autoCloseOnSave !== false && onCloseRef.current) { + onCloseRef.current(); + } + } else { + toast.error(res.error || "출하계획 저장에 실패했습니다."); + } + } catch (err) { + console.error("[v2-shipping-plan-editor] 저장 실패:", err); + toast.error("출하계획 저장 중 오류가 발생했습니다."); + } finally { + savingRef.current = false; + setSaving(false); + } + }, []); + + // V2 이벤트 버스 구독 (마운트 1회만, ref로 최신 핸들러 참조) + const handleSaveRef = useRef(handleSave); + handleSaveRef.current = handleSave; + + useEffect(() => { + let unsubscribe: (() => void) | null = null; + let mounted = true; + + (async () => { + const { v2EventBus, V2_EVENTS } = await import("@/lib/v2-core"); + if (!mounted) return; + unsubscribe = v2EventBus.subscribe(V2_EVENTS.SHIPPING_PLAN_SAVE, () => { + handleSaveRef.current(); + }); + })(); + + return () => { + mounted = false; + if (unsubscribe) unsubscribe(); + }; + }, []); + + // 집계 재계산 헬퍼 + const recalcAggregation = (group: ItemGroup): ItemGroup => { + const newPlanTotal = group.details + .filter((d) => d.type === "new") + .reduce((sum, d) => sum + d.planQty, 0); + const existingPlanTotal = group.details + .filter((d) => d.type === "existing") + .reduce((sum, d) => sum + d.planQty, 0); + return { + ...group, + aggregation: { + ...group.aggregation, + totalPlanQty: existingPlanTotal + newPlanTotal, + availableStock: + group.aggregation.currentStock - (existingPlanTotal + newPlanTotal), + }, + }; + }; + + const handlePlanQtyChange = useCallback( + (groupIdx: number, detailIdx: number, value: string) => { + setItemGroups((prev) => { + const next = [...prev]; + const group = { ...next[groupIdx] }; + const details = [...group.details]; + const target = details[detailIdx]; + let qty = Number(value) || 0; + + // 같은 sourceId의 다른 신규 행 합산 + const otherSum = details + .filter((d, i) => d.type === "new" && d.sourceId === target.sourceId && i !== detailIdx) + .reduce((sum, d) => sum + d.planQty, 0); + + // 잔여 가능량 = 미출하량 - 다른 분할 행 합산 + const maxAllowed = Math.max(0, target.balanceQty - otherSum); + qty = Math.min(qty, maxAllowed); + qty = Math.max(0, qty); + + details[detailIdx] = { ...details[detailIdx], planQty: qty }; + group.details = details; + next[groupIdx] = recalcAggregation(group); + return next; + }); + }, + [] + ); + + // 출하계획일 변경 + const handlePlanDateChange = useCallback( + (groupIdx: number, detailIdx: number, value: string) => { + setItemGroups((prev) => { + const next = [...prev]; + const group = { ...next[groupIdx] }; + const details = [...group.details]; + details[detailIdx] = { ...details[detailIdx], planDate: value }; + group.details = details; + next[groupIdx] = group; + return next; + }); + }, + [] + ); + + // 분할 행 추가 (같은 수주에 새 행) + const handleAddSplitRow = useCallback( + (groupIdx: number, sourceId: string) => { + setItemGroups((prev) => { + const next = [...prev]; + const group = { ...next[groupIdx] }; + const details = [...group.details]; + + // 같은 sourceId의 신규 행 중 마지막 찾기 + const existingNewRows = details.filter( + (d) => d.type === "new" && d.sourceId === sourceId + ); + const baseRow = existingNewRows[0]; + if (!baseRow) return prev; + + const splitCount = existingNewRows.length; + const newRow: PlanDetailRow = { + type: "new", + sourceId, + orderNo: baseRow.orderNo, + partnerName: baseRow.partnerName, + dueDate: baseRow.dueDate, + balanceQty: baseRow.balanceQty, + planQty: 0, + planDate: new Date().toISOString().split("T")[0], + splitKey: `${sourceId}_${splitCount}`, + }; + + // 같은 sourceId 신규 행 바로 뒤에 삽입 + const lastNewIdx = details.reduce( + (last, d, i) => + d.type === "new" && d.sourceId === sourceId ? i : last, + -1 + ); + details.splice(lastNewIdx + 1, 0, newRow); + + group.details = details; + next[groupIdx] = group; + return next; + }); + }, + [] + ); + + // 분할 행 삭제 + const handleRemoveSplitRow = useCallback( + (groupIdx: number, detailIdx: number) => { + setItemGroups((prev) => { + const next = [...prev]; + const group = { ...next[groupIdx] }; + const details = [...group.details]; + const target = details[detailIdx]; + + // 같은 sourceId의 신규 행이 1개뿐이면 삭제 안 함 (최소 1행 유지) + const sameSourceNewCount = details.filter( + (d) => d.type === "new" && d.sourceId === target.sourceId + ).length; + if (sameSourceNewCount <= 1) { + toast.warning("최소 1개의 출하계획 행이 필요합니다."); + return prev; + } + + details.splice(detailIdx, 1); + group.details = details; + next[groupIdx] = recalcAggregation(group); + return next; + }); + }, + [] + ); + + if (isDesignMode) { + return ( +
+
+ + + {config.title || "출하계획 등록"} + +
+
+ {[ + "총수주잔량", + "총출하계획량", + "현재고", + "가용재고", + "생산중수량", + ].map((label) => ( +
+ 0 + {label} +
+ ))} +
+
+ 상세 테이블 영역 +
+
+ ); + } + + if (loading || saving) { + return ( +
+
+ + + {saving ? "출하계획 저장 중..." : "데이터 로딩 중..."} + +
+
+ ); + } + + if (selectedIds.length === 0) { + return ( +
+ + 선택된 수주가 없습니다 + +
+ ); + } + + const showSummary = config.showSummaryCards !== false; + const showExisting = config.showExistingPlans !== false; + + return ( +
+ {itemGroups.map((group, groupIdx) => ( +
+
+ + + {group.partName} ({group.partCode}) + +
+ + {showSummary && ( + + )} + + +
+ ))} + +
+ ); +}; + +interface VisibleCards { + totalBalance?: boolean; + totalPlanQty?: boolean; + currentStock?: boolean; + availableStock?: boolean; + inProductionQty?: boolean; +} + +const SummaryCards: React.FC<{ + aggregation: ItemAggregation; + visibleCards?: VisibleCards; +}> = ({ aggregation, visibleCards }) => { + const allCards = [ + { + key: "totalBalance" as const, + label: "총수주잔량", + value: aggregation.totalBalance, + icon: TrendingUp, + color: { + bg: "bg-blue-50", + text: "text-blue-600", + border: "border-blue-200", + }, + }, + { + key: "totalPlanQty" as const, + label: "총출하계획량", + value: aggregation.totalPlanQty, + icon: Truck, + color: { + bg: "bg-indigo-50", + text: "text-indigo-600", + border: "border-indigo-200", + }, + }, + { + key: "currentStock" as const, + label: "현재고", + value: aggregation.currentStock, + icon: Warehouse, + color: { + bg: "bg-emerald-50", + text: "text-emerald-600", + border: "border-emerald-200", + }, + }, + { + key: "availableStock" as const, + label: "가용재고", + value: aggregation.availableStock, + icon: CheckCircle, + color: { + bg: aggregation.availableStock < 0 ? "bg-red-50" : "bg-amber-50", + text: + aggregation.availableStock < 0 + ? "text-red-600" + : "text-amber-600", + border: + aggregation.availableStock < 0 + ? "border-red-200" + : "border-amber-200", + }, + }, + { + key: "inProductionQty" as const, + label: "생산중수량", + value: aggregation.inProductionQty, + icon: Factory, + color: { + bg: "bg-purple-50", + text: "text-purple-600", + border: "border-purple-200", + }, + }, + ]; + + const cards = allCards.filter( + (c) => !visibleCards || visibleCards[c.key] !== false + ); + + return ( +
+ {cards.map((card) => { + const Icon = card.icon; + return ( +
+
+ + + {card.value.toLocaleString()} + +
+ + {card.label} + +
+ ); + })} +
+ ); +}; + +// sourceId별 그룹 (셀병합용) +interface SourceGroup { + sourceId: string; + orderNo: string; + partnerName: string; + dueDate: string; + balanceQty: number; + rows: (PlanDetailRow & { _origIdx: number })[]; +} + +const DetailTable: React.FC<{ + details: PlanDetailRow[]; + groupIdx: number; + onPlanQtyChange: (groupIdx: number, detailIdx: number, value: string) => void; + onPlanDateChange: (groupIdx: number, detailIdx: number, value: string) => void; + onAddSplit: (groupIdx: number, sourceId: string) => void; + onRemoveSplit: (groupIdx: number, detailIdx: number) => void; + showExisting?: boolean; +}> = ({ details, groupIdx, onPlanQtyChange, onPlanDateChange, onAddSplit, onRemoveSplit, showExisting = true }) => { + const visibleDetails = details + .map((d, idx) => ({ ...d, _origIdx: idx })) + .filter((d) => showExisting || d.type === "new"); + + // sourceId별 그룹핑 (순서 유지) + const sourceGroups = useMemo(() => { + const map = new Map(); + const order: string[] = []; + for (const d of visibleDetails) { + if (!map.has(d.sourceId)) { + map.set(d.sourceId, { + sourceId: d.sourceId, + orderNo: d.orderNo, + partnerName: d.partnerName, + dueDate: d.dueDate, + balanceQty: d.balanceQty, + rows: [], + }); + order.push(d.sourceId); + } + map.get(d.sourceId)!.rows.push(d); + } + return order.map((id) => map.get(id)!); + }, [visibleDetails]); + + // 같은 sourceId 신규 행들의 planQty 합산 (잔여량 계산용) + const getRemaining = (sourceId: string, excludeOrigIdx: number) => { + const group = sourceGroups.find((g) => g.sourceId === sourceId); + if (!group) return 0; + const otherSum = group.rows + .filter((r) => r.type === "new" && r._origIdx !== excludeOrigIdx) + .reduce((sum, r) => sum + r.planQty, 0); + return Math.max(0, group.balanceQty - otherSum); + }; + + return ( +
+ + + + + + + + + + + + + + + {sourceGroups.map((sg) => { + const totalRows = sg.rows.length; + const newCount = sg.rows.filter((r) => r.type === "new").length; + return sg.rows.map((row, rowIdx) => { + const isFirst = rowIdx === 0; + const remaining = row.type === "new" ? getRemaining(row.sourceId, row._origIdx) : 0; + const isDisabled = row.type === "new" && sg.balanceQty <= 0; + + return ( + + {/* 구분 - 매 행마다 표시 */} + + + {/* 수주번호, 거래처, 납기일, 미출하 - 첫 행만 rowSpan */} + {isFirst && ( + <> + + + + + + )} + + {/* 출하계획량 */} + + + {/* 출하계획일 */} + + + {/* 분할 버튼 */} + + + ); + }); + })} + {sourceGroups.length === 0 && ( + + + + )} + +
+ 구분 + + 수주번호 + + 거래처 + + 납기일 + + 미출하 + + 출하계획량 + + 출하계획일 + + 분할 +
+ {row.type === "existing" ? ( + 기존 + ) : ( + 신규 + )} + + {sg.orderNo} + + {sg.partnerName} + + {sg.dueDate || "-"} + + {sg.balanceQty.toLocaleString()} + + {row.type === "existing" ? ( + + {row.planQty.toLocaleString()} + + ) : ( + onPlanQtyChange(groupIdx, row._origIdx, e.target.value)} + disabled={isDisabled} + className="mx-auto h-7 w-24 text-center text-xs disabled:opacity-40" + placeholder="0" + /> + )} + + {row.type === "existing" ? ( + - + ) : ( + onPlanDateChange(groupIdx, row._origIdx, e.target.value)} + disabled={isDisabled} + className="mx-auto h-7 w-32 text-xs disabled:opacity-40" + /> + )} + + {row.type === "new" && isFirst && ( + + )} + {row.type === "new" && !isFirst && newCount > 1 && ( + + )} +
+ 데이터가 없습니다 +
+
+ ); +}; + +export const ShippingPlanEditorWrapper: React.FC< + ShippingPlanEditorComponentProps +> = (props) => { + return ; +}; diff --git a/frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorConfigPanel.tsx b/frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorConfigPanel.tsx new file mode 100644 index 00000000..de21012a --- /dev/null +++ b/frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorConfigPanel.tsx @@ -0,0 +1,166 @@ +"use client"; + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Separator } from "@/components/ui/separator"; +import { Textarea } from "@/components/ui/textarea"; + +interface ShippingPlanEditorConfigPanelProps { + config: any; + onChange: (config: any) => void; +} + +export const ShippingPlanEditorConfigPanel: React.FC< + ShippingPlanEditorConfigPanelProps +> = ({ config, onChange }) => { + const handleChange = (key: string, value: any) => { + onChange({ ...config, [key]: value }); + }; + + const handleSummaryCardToggle = (cardKey: string, checked: boolean) => { + onChange({ + ...config, + visibleSummaryCards: { + ...(config.visibleSummaryCards || defaultSummaryCards), + [cardKey]: checked, + }, + }); + }; + + const defaultSummaryCards = { + totalBalance: true, + totalPlanQty: true, + currentStock: true, + availableStock: true, + inProductionQty: true, + }; + + const summaryCards = config.visibleSummaryCards || defaultSummaryCards; + + const summaryCardLabels: Record = { + totalBalance: "총수주잔량", + totalPlanQty: "총출하계획량", + currentStock: "현재고", + availableStock: "가용재고", + inProductionQty: "생산중수량", + }; + + return ( +
+ {/* 기본 설정 */} +
+ 기본 설정 +
+ +
+ + handleChange("title", e.target.value)} + placeholder="출하계획 등록" + className="h-8 text-xs" + /> +
+ + + + {/* 표시 설정 */} +
+ 표시 설정 +
+ +
+ + + handleChange("showSummaryCards", checked) + } + /> +
+ +
+ + + handleChange("showExistingPlans", checked) + } + /> +
+ + {config.showSummaryCards !== false && ( + <> + +
+ 집계 카드 항목 +
+
+ {Object.entries(summaryCardLabels).map(([key, label]) => ( +
+ + + handleSummaryCardToggle(key, checked) + } + /> +
+ ))} +
+ + )} + + + + {/* 저장 설정 */} +
+ 저장 설정 +
+ +
+ + + handleChange("allowOverPlan", checked) + } + /> +
+ +
+ + + handleChange("autoCloseOnSave", checked) + } + /> +
+ +
+ + + handleChange("confirmBeforeSave", checked) + } + /> +
+ + {config.confirmBeforeSave && ( +
+ +