Compare commits

..

34 Commits

Author SHA1 Message Date
kjs 8e228bbf12 Merge pull request 'jskim-node' (#421) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/421
2026-03-18 17:43:49 +09:00
kjs 7fb4fbc7f1 Merge branch 'main' into jskim-node 2026-03-18 17:43:38 +09:00
kjs c634e1e054 fix: update file handling and improve query logging
- Added mes-architecture-guide.md to .gitignore to prevent unnecessary tracking.
- Enhanced NodeFlowExecutionService to merge context data for WHERE clause, improving query accuracy.
- Updated logging to include values in SQL query logs for better debugging.
- Removed redundant event dispatches in V2Repeater to streamline save operations.
- Adjusted DynamicComponentRenderer to conditionally refresh keys based on component type.
- Improved FileUploadComponent to clear localStorage only for modal components, preventing unintended resets in non-modal contexts.

These changes aim to enhance the overall functionality and maintainability of the application, ensuring better data handling and user experience.
2026-03-18 17:43:03 +09:00
kjs 359bf0e614 feat: enhance shipping plan editor and batch save functionality
- Added planDate support in batch save operations for shipping plans, allowing for more accurate scheduling.
- Updated the ShippingPlanEditorComponent to handle planDate and splitKey for better management of shipping plans.
- Enhanced validation logic to ensure that the total planned quantity does not exceed available stock during the editing process.
- Introduced functionality to add and remove split rows dynamically, improving user experience in managing shipping plans.

These changes aim to provide a more robust and flexible shipping plan management experience, facilitating better tracking and scheduling of shipping operations.
2026-03-18 16:04:55 +09:00
kjs 9decf13068 feat: implement shipping plan management features
- Added shipping plan routes and controller to handle aggregate and batch save operations.
- Introduced a new shipping plan editor component for bulk registration of shipping plans based on selected orders.
- Enhanced API client functions for fetching aggregated shipping plan data and saving plans in bulk.
- Updated the registry to include the new shipping plan editor component, improving the overall shipping management workflow.

These changes aim to streamline the shipping plan process, allowing for efficient management and registration of shipping plans in the application.
2026-03-18 14:42:47 +09:00
kjs 579461a6cb Merge branch 'gbpark-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-18 10:31:04 +09:00
kjs fb57bd4eaf feat: enhance ButtonPrimaryComponent for source tracking
- Updated ButtonPrimaryComponent to automatically inject source_table and source_id for better data tracking.
- Implemented logic to conditionally include source tracking information based on the presence of sourceTableName and row.id.
- This enhancement aims to improve data integrity and traceability during data mapping processes, facilitating better integration of source information in the application.
2026-03-18 10:23:08 +09:00
DDD1542 8630d82a69 Refactor RealtimePreviewDynamic and ResponsiveGridRenderer components to improve layout handling and responsiveness. Update SingleTableWithSticky and TableListComponent for better checkbox handling and styling consistency. Enhance overall user experience with refined component structures and styles. 2026-03-18 10:16:59 +09:00
DDD1542 b2a569f908 123 2026-03-18 00:05:40 +09:00
kjs a6aa57fece feat: enhance data mapping and entity join handling in components
- Updated ButtonPrimaryComponent to utilize entity join metadata for improved data mapping.
- Introduced getEntityJoinColumns method in TableListComponent to retrieve entity join column metadata.
- Enhanced applyMappingRules function to support optional entity join columns, allowing for more flexible data resolution.
- Added utility functions to build join alias maps and resolve values from entity joins, improving data handling capabilities.

These enhancements aim to provide a more robust and dynamic data mapping experience, facilitating better integration of entity relationships in the application.
2026-03-17 23:27:39 +09:00
DDD1542 c63eaf8434 123123 2026-03-17 22:49:42 +09:00
kjs 2772c2296c feat: enhance TableManagementPage and ExcelUploadModal for improved functionality
- Added handling for unique and nullable column toggles in TableManagementPage, allowing for better column configuration.
- Updated ExcelUploadModal to include depth and ancestors in valid options for category values, enhancing the categorization process.
- Improved user feedback in ExcelUploadModal by clarifying success messages and ensuring proper handling of duplicate actions.
- Refactored category value flattening logic to maintain depth and ancestor information, improving data structure for better usability.

These enhancements aim to provide users with a more flexible and intuitive experience when managing table configurations and uploading Excel data.
2026-03-17 22:37:13 +09:00
DDD1542 ba8a2fec2b Refactor RealtimePreviewDynamic and ResponsiveGridRenderer components to enhance rendering logic using CSS scale for improved layout consistency. Update SplitPanelLayoutComponent to handle drag events more effectively by passing the event object. This ensures better user interaction during column dragging. 2026-03-17 22:24:47 +09:00
DDD1542 f36638e53e 22 2026-03-17 22:13:15 +09:00
DDD1542 12d4d2a8b1 11 2026-03-17 22:06:13 +09:00
DDD1542 13b2ebaf1f Refactor ColumnDetailPanel and AppLayout for improved loading state handling and UI consistency. Enhance TabBar and TableListComponent styles for better user experience. Update V2SplitPanelLayoutConfigPanel to manage button visibility based on configuration. Introduce filter chips in TableListComponent for better filter management. 2026-03-17 22:02:52 +09:00
DDD1542 b293d184bb 11 2026-03-17 21:50:37 +09:00
kjs be0e63e577 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-17 21:41:46 +09:00
kjs 7e34b7bf35 Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-17 21:41:44 +09:00
DDD1542 cfd7ee9fce [agent-pipeline] pipe-20260317084014-ydap round-3 2026-03-17 18:25:36 +09:00
DDD1542 ad48b22770 [agent-pipeline] rollback to d3acf391 2026-03-17 18:17:51 +09:00
DDD1542 9dc2959601 [agent-pipeline] pipe-20260317084014-ydap round-2 2026-03-17 18:17:51 +09:00
DDD1542 d3acf391a4 [agent-pipeline] pipe-20260317084014-ydap round-1 2026-03-17 18:05:10 +09:00
DDD1542 9409f1308f [agent-pipeline] pipe-20260317063830-0nfs round-3 2026-03-17 17:12:54 +09:00
DDD1542 265f46f8d4 [agent-pipeline] pipe-20260317063830-0nfs round-2 2026-03-17 16:47:12 +09:00
DDD1542 128872b766 [agent-pipeline] pipe-20260317063830-0nfs round-1 2026-03-17 16:20:24 +09:00
DDD1542 80cd95e683 [agent-pipeline] pipe-20260317054958-cypk round-6 2026-03-17 15:18:41 +09:00
DDD1542 d8a542b253 [agent-pipeline] pipe-20260317054958-cypk round-5 2026-03-17 15:13:42 +09:00
DDD1542 c55520f01c [agent-pipeline] rollback to 87a7431e 2026-03-17 15:09:53 +09:00
DDD1542 c3fae741ae [agent-pipeline] pipe-20260317054958-cypk round-4 2026-03-17 15:09:53 +09:00
DDD1542 cc51ad71da [agent-pipeline] rollback to 2b4500a9 2026-03-17 15:04:26 +09:00
DDD1542 87a7431e53 [agent-pipeline] pipe-20260317054958-cypk round-3 2026-03-17 15:04:26 +09:00
DDD1542 2b4500a999 [agent-pipeline] pipe-20260317054958-cypk round-2 2026-03-17 15:00:41 +09:00
DDD1542 4db5d73817 [agent-pipeline] pipe-20260317054958-cypk round-1 2026-03-17 14:54:45 +09:00
52 changed files with 5728 additions and 2870 deletions

4
.gitignore vendored
View File

@ -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

View File

@ -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); // 차량 운행 이력 관리

View File

@ -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<NormalizedOrder[]> {
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<string, NormalizedOrder[]>();
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<string, any> = {};
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 });
}
}

View File

@ -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;

View File

@ -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);

View File

@ -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) 필터링 확인

View File

@ -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() {
<p className="text-sm font-medium"> PK :</p>
{pendingPkColumns.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{pendingPkColumns.map((col) => (
<Badge key={col} variant="secondary" className="font-mono text-xs">
{col}
</Badge>
))}
{pendingPkColumns.map((col) => {
const colInfo = columns.find((c) => c.columnName === col);
return (
<Badge key={col} variant="secondary" className="text-xs">
{colInfo?.displayName && colInfo.displayName !== col
? `${colInfo.displayName} (${col})`
: col}
</Badge>
);
})}
</div>
) : (
<p className="text-destructive mt-2 text-sm">PK가 </p>

View File

@ -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)}
>
<div className="flex flex-col">

View File

@ -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 (
<div className="flex h-full w-full flex-col border-l bg-card">
@ -90,7 +115,11 @@ export function ColumnDetailPanel({
{typeConf.label}
</span>
)}
<span className="truncate font-mono text-sm font-medium">{column.columnName}</span>
<span className="truncate text-sm font-medium">
{column.displayName && column.displayName !== column.columnName
? `${column.displayName} (${column.columnName})`
: column.columnName}
</span>
</div>
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={onClose} aria-label="닫기">
<X className="h-4 w-4" />
@ -170,23 +199,33 @@ export function ColumnDetailPanel({
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
{refTableOpts.map((opt) => (
<CommandItem
key={opt.value}
value={`${opt.label} ${opt.value}`}
onSelect={() => {
onColumnChange("referenceTable", opt.value === "none" ? undefined : opt.value);
if (opt.value !== "none") onLoadReferenceColumns?.(opt.value);
setEntityTableOpen(false);
}}
className="text-xs"
>
<Check
className={cn("mr-2 h-3 w-3", column.referenceTable === opt.value ? "opacity-100" : "opacity-0")}
/>
{opt.label}
</CommandItem>
))}
{refTableOpts.map((opt) => {
const hasKorean = opt.value !== "none" && opt.label !== opt.value && !opt.label.startsWith(opt.value);
return (
<CommandItem
key={opt.value}
value={`${opt.label} ${opt.value}`}
onSelect={() => {
onColumnChange("referenceTable", opt.value === "none" ? undefined : opt.value);
if (opt.value !== "none") onLoadReferenceColumns?.(opt.value);
setEntityTableOpen(false);
}}
className="text-xs"
>
<Check
className={cn("mr-2 h-3 w-3", column.referenceTable === opt.value ? "opacity-100" : "opacity-0")}
/>
{hasKorean ? (
<div className="flex flex-col">
<span className="font-medium">{opt.label.replace(` (${opt.value})`, "")}</span>
<span className="text-[10px] text-muted-foreground">{opt.value}</span>
</div>
) : (
opt.label
)}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
@ -207,7 +246,12 @@ export function ColumnDetailPanel({
className="h-9 w-full justify-between text-xs"
>
{column.referenceColumn && column.referenceColumn !== "none"
? column.referenceColumn
? (() => {
const matched = refColumns.find((c) => c.columnName === column.referenceColumn);
return matched?.displayName && matched.displayName !== column.referenceColumn
? `${matched.displayName} (${column.referenceColumn})`
: column.referenceColumn;
})()
: "컬럼 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 opacity-50" />
</Button>
@ -245,7 +289,14 @@ export function ColumnDetailPanel({
column.referenceColumn === refCol.columnName ? "opacity-100" : "opacity-0",
)}
/>
{refCol.columnName}
{refCol.displayName && refCol.displayName !== refCol.columnName ? (
<div className="flex flex-col">
<span className="font-medium">{refCol.displayName}</span>
<span className="text-[10px] text-muted-foreground">{refCol.columnName}</span>
</div>
) : (
<span>{refCol.columnName}</span>
)}
</CommandItem>
))}
</CommandGroup>
@ -259,12 +310,20 @@ export function ColumnDetailPanel({
{/* 참조 요약 미니맵 */}
{column.referenceTable && column.referenceTable !== "none" && column.referenceColumn && (
<div className="flex items-center gap-2 rounded-md bg-violet-50 px-3 py-2">
<span className="font-mono text-[11px] font-semibold text-violet-600">
{column.referenceTable}
<span className="text-[11px] font-semibold text-violet-600">
{(() => {
const tbl = refTableOpts.find((o) => o.value === column.referenceTable);
return tbl?.label ?? column.referenceTable;
})()}
</span>
<span className="text-muted-foreground text-[10px]"></span>
<span className="font-mono text-[11px] font-semibold text-violet-600">
{column.referenceColumn}
<span className="text-[11px] font-semibold text-violet-600">
{(() => {
const col = refColumns.find((c) => c.columnName === column.referenceColumn);
return col?.displayName && col.displayName !== column.referenceColumn
? `${col.displayName} (${column.referenceColumn})`
: column.referenceColumn;
})()}
</span>
</div>
)}

View File

@ -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<string, ReferenceTableColumn[]>;
}
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 색상바 (타입별 진한 색) */}
<div className={cn("h-full min-h-8 w-1 rounded-full", typeConf.barColor)} />
{/* 라벨 + 컬럼명 */}
{/* 라벨 + 컬럼명 (한글라벨 (영어명) 동시 표시) */}
<div className="min-w-0">
<div className="truncate text-sm font-medium">
{column.displayName || column.columnName}
</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{column.columnName}
{column.displayName && column.displayName !== column.columnName
? `${column.displayName} (${column.columnName})`
: column.columnName}
</div>
</div>
@ -150,11 +155,38 @@ export function ColumnGrid({
<div className="flex min-w-0 flex-wrap gap-1">
{column.inputType === "entity" && column.referenceTable && column.referenceTable !== "none" && (
<>
<Badge variant="outline" className="text-xs font-normal">
<Badge
variant="outline"
className="text-xs font-normal"
title={
tables
? (() => {
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}
</Badge>
<span className="text-muted-foreground text-xs"></span>
<Badge variant="outline" className="text-xs font-normal">
<Badge
variant="outline"
className="text-xs font-normal"
title={
referenceTableColumns?.[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 || "—"}
</Badge>
</>

View File

@ -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<ExcelUploadModalProps> = ({
open,
onOpenChange,
@ -137,6 +168,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
// 중복 처리 방법 (전역 설정)
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<import("@/lib/api/tableManagement").ExcelValidationResult | null>(null);
@ -149,7 +183,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
Record<string, Array<{
invalidValue: string;
replacement: string | null;
validOptions: Array<{ code: string; label: string }>;
validOptions: Array<{ code: string; label: string; depth: number; ancestors: string[] }>;
rowIndices: number[];
}>>
>({});
@ -681,12 +715,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
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<ExcelUploadModalProps> = ({
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<ExcelUploadModalProps> = ({
setDisplayData(newData);
setShowCategoryValidation(false);
setCategoryMismatches({});
toast.success("카테고리 값이 대체되었습니다.");
setCurrentStep(3);
toast.success("카테고리 값이 대체되었습니다. '다음'을 눌러 진행해주세요.");
return true;
};
@ -881,6 +912,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
// 데이터 사전 검증 (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<ExcelUploadModalProps> = ({
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<ExcelUploadModalProps> = ({
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<ExcelUploadModalProps> = ({
setSystemColumns([]);
setColumnMappings([]);
setDuplicateAction("skip");
setDbDuplicateAction(null);
// 검증 상태 초기화
setValidationResult(null);
setIsDataValidating(false);
@ -1357,7 +1414,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={(v) => { if (!showCategoryValidation) onOpenChange(v); }}>
<DialogContent
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
style={{
@ -1965,12 +2022,50 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
{/* DB 기존 데이터 중복 */}
{validationResult.uniqueInDbErrors.length > 0 && (
<div className="rounded-md border border-destructive bg-destructive/10 p-4">
<h3 className="flex items-center gap-2 text-sm font-medium text-destructive sm:text-base">
<XCircle className="h-4 w-4" />
DB ({validationResult.uniqueInDbErrors.length})
</h3>
<div className="mt-2 max-h-[150px] space-y-0.5 overflow-y-auto text-[10px] text-destructive sm:text-xs">
<div className={cn(
"rounded-md border p-4",
dbDuplicateAction
? "border-primary/30 bg-primary/5"
: "border-destructive bg-destructive/10"
)}>
<div className="flex items-start justify-between gap-3">
<h3 className={cn(
"flex items-center gap-2 text-sm font-medium sm:text-base",
dbDuplicateAction ? "text-primary" : "text-destructive"
)}>
{dbDuplicateAction ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
DB ({validationResult.uniqueInDbErrors.length})
</h3>
<div className="flex items-center gap-2 shrink-0">
<span className={cn(
"text-[10px] sm:text-xs",
dbDuplicateAction ? "text-primary" : "text-destructive"
)}>
:
</span>
<Select
value={dbDuplicateAction || ""}
onValueChange={(value) => setDbDuplicateAction(value as "overwrite" | "skip")}
>
<SelectTrigger className={cn(
"h-7 w-[100px] text-[10px] sm:text-xs",
dbDuplicateAction
? "border-primary/40 bg-white"
: "border-destructive/40 bg-white animate-pulse"
)}>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip" className="text-xs"></SelectItem>
<SelectItem value="overwrite" className="text-xs"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className={cn(
"mt-2 max-h-[150px] space-y-0.5 overflow-y-auto text-[10px] sm:text-xs",
dbDuplicateAction ? "text-primary/80" : "text-destructive"
)}>
{(() => {
const grouped = new Map<string, { value: string; rows: number[] }[]>();
for (const err of validationResult.uniqueInDbErrors) {
@ -1984,7 +2079,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
<div key={label}>
{items.slice(0, 5).map((item, i) => (
<p key={i}>
<span className="font-medium">{label}</span> "{item.value}": {item.rows.join(", ")}
<span className="font-medium">{label}</span> &quot;{item.value}&quot;: {item.rows.join(", ")}
</p>
))}
{items.length > 5 && <p className="font-medium">... {items.length - 5}</p>}
@ -1992,6 +2087,13 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
));
})()}
</div>
{dbDuplicateAction && (
<p className="mt-2 text-[10px] text-primary sm:text-xs font-medium">
{dbDuplicateAction === "skip"
? "중복 데이터는 건너뛰고 신규 데이터만 업로드합니다."
: "중복 데이터는 새 값으로 덮어씁니다."}
</p>
)}
</div>
)}
</div>
@ -2105,11 +2207,24 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
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" ? "업로드 (중복 덮어쓰기)" : "업로드"}
</Button>
)}
</DialogFooter>
@ -2156,33 +2271,63 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
</span>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<Select
value={item.replacement || ""}
onValueChange={(val) => {
setCategoryMismatches((prev) => {
const updated = { ...prev };
updated[key] = updated[key].map((it, i) =>
i === idx ? { ...it, replacement: val } : it
);
return updated;
});
}}
>
<SelectTrigger className="h-8 text-xs sm:h-9 sm:text-sm">
<SelectValue placeholder="대체 값 선택" />
</SelectTrigger>
<SelectContent>
{item.validOptions.map((opt) => (
<SelectItem
key={opt.code}
value={opt.code}
className="text-xs sm:text-sm"
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs sm:h-9 sm:text-sm"
>
<span className="truncate">
{item.replacement
? item.validOptions.find((o) => o.code === item.replacement)?.label || item.replacement
: "대체 값 선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-0" align="start">
<Command
filter={(value, search) => {
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;
}}
>
<CommandInput placeholder="카테고리 검색..." className="text-xs" />
<CommandList className="max-h-52">
<CommandEmpty className="py-3 text-xs"> </CommandEmpty>
<CommandGroup>
{item.validOptions.map((opt) => (
<CommandItem
key={opt.code}
value={opt.code}
onSelect={(val) => {
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"
>
<Check className={cn("mr-2 h-3 w-3", item.replacement === opt.code ? "opacity-100" : "opacity-0")} />
<span style={{ paddingLeft: `${opt.depth * 12}px` }}>
{opt.depth > 0 && <span className="mr-1 text-muted-foreground"></span>}
{opt.label}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
))}
</div>
@ -2201,17 +2346,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
>
</Button>
<Button
variant="outline"
onClick={() => {
setShowCategoryValidation(false);
setCategoryMismatches({});
setCurrentStep(3);
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={applyCategoryReplacements}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"

View File

@ -28,7 +28,11 @@ import {
Zap,
Download,
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 { importFromExcel, getExcelSheetNames, exportToExcel } from "@/lib/utils/excelExport";
import { cn } from "@/lib/utils";
import { EditableSpreadsheet } from "./EditableSpreadsheet";
@ -51,6 +55,34 @@ interface ColumnMapping {
targetColumn: string | null;
}
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 MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProps> = ({
open,
onOpenChange,
@ -88,7 +120,7 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
Record<string, Array<{
invalidValue: string;
replacement: string | null;
validOptions: Array<{ code: string; label: string }>;
validOptions: Array<{ code: string; label: string; depth: number; ancestors: string[] }>;
rowIndices: number[];
}>>
>({});
@ -356,10 +388,7 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
const valuesResponse = await getCategoryValues(level.tableName, catColName);
if (!valuesResponse.success || !valuesResponse.data) continue;
const validValues = valuesResponse.data as Array<{
valueCode: string;
valueLabel: string;
}>;
const validValues = flattenCategoryValues(valuesResponse.data as any[]);
const validCodes = new Set(validValues.map((v) => v.valueCode));
const validLabels = new Set(validValues.map((v) => v.valueLabel));
@ -387,6 +416,8 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
const options = validValues.map((v) => ({
code: v.valueCode,
label: v.valueLabel,
depth: v.depth,
ancestors: v.ancestors,
}));
const key = `${catColName}|||[${level.label}] ${catDisplayName}`;
@ -464,8 +495,7 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
setDisplayData(newData);
setShowCategoryValidation(false);
setCategoryMismatches({});
toast.success("카테고리 값이 대체되었습니다.");
setCurrentStep(3);
toast.success("카테고리 값이 대체되었습니다. '다음'을 눌러 진행해주세요.");
return true;
};
@ -532,7 +562,7 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={(v) => { if (!showCategoryValidation) onOpenChange(v); }}>
<DialogContent
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
style={{ width: "1000px", height: "700px", minWidth: "700px", minHeight: "500px", maxWidth: "1400px", maxHeight: "900px" }}
@ -1009,33 +1039,63 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
</span>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<Select
value={item.replacement || ""}
onValueChange={(val) => {
setCategoryMismatches((prev) => {
const updated = { ...prev };
updated[key] = updated[key].map((it, i) =>
i === idx ? { ...it, replacement: val } : it
);
return updated;
});
}}
>
<SelectTrigger className="h-8 text-xs sm:h-9 sm:text-sm">
<SelectValue placeholder="대체 값 선택" />
</SelectTrigger>
<SelectContent>
{item.validOptions.map((opt) => (
<SelectItem
key={opt.code}
value={opt.code}
className="text-xs sm:text-sm"
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs sm:h-9 sm:text-sm"
>
<span className="truncate">
{item.replacement
? item.validOptions.find((o) => o.code === item.replacement)?.label || item.replacement
: "대체 값 선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-0" align="start">
<Command
filter={(value, search) => {
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;
}}
>
<CommandInput placeholder="카테고리 검색..." className="text-xs" />
<CommandList className="max-h-52">
<CommandEmpty className="py-3 text-xs"> </CommandEmpty>
<CommandGroup>
{item.validOptions.map((opt) => (
<CommandItem
key={opt.code}
value={opt.code}
onSelect={(val) => {
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"
>
<Check className={cn("mr-2 h-3 w-3", item.replacement === opt.code ? "opacity-100" : "opacity-0")} />
<span style={{ paddingLeft: `${opt.depth * 12}px` }}>
{opt.depth > 0 && <span className="mr-1 text-muted-foreground"></span>}
{opt.label}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
))}
</div>
@ -1054,17 +1114,6 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
>
</Button>
<Button
variant="outline"
onClick={() => {
setShowCategoryValidation(false);
setCategoryMismatches({});
setCurrentStep(3);
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={applyCategoryReplacements}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"

View File

@ -1,6 +1,6 @@
"use client";
import { useState, Suspense, useEffect } from "react";
import { useState, Suspense, useEffect, useCallback } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
@ -341,6 +341,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const currentMenus = isAdminMode ? adminMenus : userMenus;
const currentTabs = useTabStore((s) => s[s.mode].tabs);
const currentActiveTabId = useTabStore((s) => s[s.mode].activeTabId);
const activeTab = currentTabs.find((t) => t.id === currentActiveTabId);
const toggleMenu = (menuId: string) => {
const newExpanded = new Set(expandedMenus);
if (newExpanded.has(menuId)) {
@ -478,6 +482,26 @@ function AppLayoutInner({ children }: AppLayoutProps) {
}
};
// pathname + 활성 탭 기반 활성 메뉴 판별 (탭 네비게이션에서도 사이드바 활성 표시)
const isMenuActive = useCallback(
(menu: any): boolean => {
if (pathname === menu.url) return true;
if (!activeTab) return false;
const menuObjid = parseInt((menu.objid || menu.id)?.toString() || "0");
if (activeTab.type === "admin" && activeTab.adminUrl) {
return menu.url === activeTab.adminUrl;
}
if (activeTab.type === "screen") {
if (activeTab.menuObjid != null && menuObjid === activeTab.menuObjid) return true;
if (activeTab.screenId != null && menu.screenId === activeTab.screenId) return true;
}
return false;
},
[pathname, activeTab],
);
// 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용)
const renderMenu = (menu: any, level: number = 0) => {
const isExpanded = expandedMenus.has(menu.id);
@ -489,8 +513,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
draggable={isLeaf}
onDragStart={(e) => handleMenuDragStart(e, menu)}
className={`group flex min-h-[44px] cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150 ease-in-out sm:min-h-[40px] ${
pathname === menu.url
? "border-primary bg-primary/8 text-primary border-l-3 font-semibold"
isMenuActive(menu)
? "border-l-[3px] border-l-primary bg-primary/10 dark:bg-primary/15 text-primary font-semibold"
: isExpanded
? "bg-accent/60 text-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
@ -518,8 +542,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
draggable={!child.hasChildren}
onDragStart={(e) => handleMenuDragStart(e, child)}
className={`flex min-h-[44px] cursor-pointer items-center rounded-md px-3 py-2 text-sm transition-colors duration-150 hover:cursor-pointer sm:min-h-[40px] ${
pathname === child.url
? "border-primary bg-primary/8 text-primary border-l-3 font-semibold"
isMenuActive(child)
? "border-l-[3px] border-l-primary bg-primary/10 dark:bg-primary/15 text-primary font-semibold"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
onClick={() => handleMenuClick(child)}
@ -544,6 +568,30 @@ function AppLayoutInner({ children }: AppLayoutProps) {
);
}
const uiMenus = user ? convertMenuToUI(currentMenus, user as ExtendedUserInfo) : [];
// 활성 탭에 해당하는 메뉴가 속한 부모 메뉴 자동 확장
useEffect(() => {
if (!activeTab || uiMenus.length === 0) return;
const toExpand: string[] = [];
for (const menu of uiMenus) {
if (menu.hasChildren && menu.children) {
const hasActiveChild = menu.children.some((child: any) => isMenuActive(child));
if (hasActiveChild && !expandedMenus.has(menu.id)) {
toExpand.push(menu.id);
}
}
}
if (toExpand.length > 0) {
setExpandedMenus((prev) => {
const next = new Set(prev);
toExpand.forEach((id) => next.add(id));
return next;
});
}
}, [activeTab, uiMenus, isMenuActive, expandedMenus]);
if (!user) {
return (
<div className="flex h-screen items-center justify-center">
@ -555,8 +603,6 @@ function AppLayoutInner({ children }: AppLayoutProps) {
);
}
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);
return (
<div className="bg-background flex h-screen flex-col">
{/* 모바일 헤더 */}

View File

@ -493,8 +493,8 @@ export function TabBar() {
className={cn(
"group relative flex h-7 shrink-0 cursor-pointer items-center gap-0.5 rounded-t-md border border-b-0 px-3 select-none",
isActive
? "text-foreground z-10 -mb-px h-[30px] bg-white"
: "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground border-transparent",
? "text-primary z-10 -mb-px h-[30px] bg-primary/15 dark:bg-primary/20 border-primary/40 border-t-[3px] border-t-primary font-semibold"
: "bg-transparent text-muted-foreground hover:bg-muted/50 hover:text-foreground border-transparent",
)}
style={{
width: TAB_WIDTH,

View File

@ -478,7 +478,7 @@ const DateConfigPanel: React.FC<DateConfigPanelProps> = ({
</PopoverContent>
</Popover>
{sourceTableName && columns.length === 0 && !loadingColumns && (
<p className="mt-1 text-[10px] text-amber-600 sm:text-xs">
<p className="mt-1 text-[10px] text-warning sm:text-xs">
</p>
)}

View File

@ -1,7 +1,6 @@
"use client";
import React from "react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -27,25 +26,24 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
tableName,
}) => {
return (
<Card className="border-border bg-card flex-1">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<Badge variant="outline" className="text-xs sm:text-sm">
{part.order}
</Badge>
<Button
variant="ghost"
size="icon"
onClick={onDelete}
className="text-destructive h-7 w-7 sm:h-8 sm:w-8"
disabled={isPreview}
>
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</div>
</CardHeader>
<div className="config-field flex-1 rounded-[8px] border border-border bg-muted/50 px-3 py-3 sm:px-4 sm:py-4">
<div className="mb-3 flex items-center justify-between sm:mb-4">
<Badge variant="outline" className="text-xs sm:text-sm">
{part.order}
</Badge>
<Button
variant="destructive"
size="icon"
onClick={onDelete}
className="h-7 w-7 sm:h-8 sm:w-8"
disabled={isPreview}
aria-label="규칙 삭제"
>
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</div>
<CardContent className="space-y-3 sm:space-y-4">
<div className="space-y-3 sm:space-y-4">
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Select
@ -117,7 +115,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
isPreview={isPreview}
/>
)}
</CardContent>
</Card>
</div>
</div>
);
};

View File

@ -5,28 +5,16 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Save, Edit2, FolderTree } from "lucide-react";
import { Plus, Save, ListOrdered } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
import { CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule";
import { NumberingRuleCard } from "./NumberingRuleCard";
import { NumberingRulePreview } from "./NumberingRulePreview";
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
import { apiClient } from "@/lib/api/client";
import { NumberingRulePreview, computePartDisplayItems, getPartTypeColorClass } from "./NumberingRulePreview";
import { getNumberingRules, saveNumberingRuleToTest } from "@/lib/api/numberingRule";
import { cn } from "@/lib/utils";
interface NumberingColumn {
tableName: string;
tableLabel: string;
columnName: string;
columnLabel: string;
}
interface GroupedColumns {
tableLabel: string;
columns: NumberingColumn[];
}
interface NumberingRuleDesignerProps {
initialConfig?: NumberingRuleConfig;
onSave?: (config: NumberingRuleConfig) => void;
@ -34,8 +22,8 @@ interface NumberingRuleDesignerProps {
maxRules?: number;
isPreview?: boolean;
className?: string;
currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용)
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
currentTableName?: string;
menuObjid?: number;
}
export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
@ -48,124 +36,84 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
currentTableName,
menuObjid,
}) => {
const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]);
const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null);
const [rulesList, setRulesList] = useState<NumberingRuleConfig[]>([]);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
const [selectedPartOrder, setSelectedPartOrder] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [columnSearch, setColumnSearch] = useState("");
const [rightTitle, setRightTitle] = useState("규칙 편집");
const [editingRightTitle, setEditingRightTitle] = useState(false);
// 구분자 관련 상태 (개별 파트 사이 구분자)
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
// 좌측: 채번 타입 컬럼 목록 로드
const selectedRule = rulesList.find((r) => r.ruleId === selectedRuleId) ?? currentRule;
// 좌측: 규칙 목록 로드
useEffect(() => {
loadNumberingColumns();
loadRules();
}, []);
const loadNumberingColumns = async () => {
const loadRules = async () => {
setLoading(true);
try {
const response = await apiClient.get("/table-management/numbering-columns");
if (response.data.success && response.data.data) {
setNumberingColumns(response.data.data);
const response = await getNumberingRules();
if (response.success && response.data) {
setRulesList(response.data);
if (response.data.length > 0 && !selectedRuleId) {
const first = response.data[0];
setSelectedRuleId(first.ruleId);
setCurrentRule(JSON.parse(JSON.stringify(first)));
}
}
} catch (error: any) {
console.error("채번 컬럼 목록 로드 실패:", error);
} catch (e) {
console.error("채번 규칙 목록 로드 실패:", e);
} finally {
setLoading(false);
}
};
// 컬럼 선택 시 해당 컬럼의 채번 규칙 로드
const handleSelectColumn = async (tableName: string, columnName: string) => {
setSelectedColumn({ tableName, columnName });
setLoading(true);
try {
const response = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`);
if (response.data.success && response.data.data) {
const rule = response.data.data as NumberingRuleConfig;
setCurrentRule(JSON.parse(JSON.stringify(rule)));
} else {
// 규칙 없으면 신규 생성 모드
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: `${columnName} 채번`,
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "table",
tableName,
columnName,
};
setCurrentRule(newRule);
}
} catch {
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: `${columnName} 채번`,
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "table",
tableName,
columnName,
};
setCurrentRule(newRule);
} finally {
setLoading(false);
}
const handleSelectRule = (rule: NumberingRuleConfig) => {
setSelectedRuleId(rule.ruleId);
setCurrentRule(JSON.parse(JSON.stringify(rule)));
setSelectedPartOrder(null);
};
// 테이블별로 그룹화
const groupedColumns = numberingColumns.reduce<Record<string, GroupedColumns>>((acc, col) => {
if (!acc[col.tableName]) {
acc[col.tableName] = { tableLabel: col.tableLabel, columns: [] };
}
acc[col.tableName].columns.push(col);
return acc;
}, {});
// 검색 필터 적용
const filteredGroups = Object.entries(groupedColumns).filter(([tableName, group]) => {
if (!columnSearch) return true;
const search = columnSearch.toLowerCase();
return (
tableName.toLowerCase().includes(search) ||
group.tableLabel.toLowerCase().includes(search) ||
group.columns.some(
(c) => c.columnName.toLowerCase().includes(search) || c.columnLabel.toLowerCase().includes(search)
)
);
});
const handleAddNewRule = () => {
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: "새 규칙",
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "global",
tableName: currentTableName ?? "",
columnName: "",
};
setRulesList((prev) => [...prev, newRule]);
setSelectedRuleId(newRule.ruleId);
setCurrentRule(JSON.parse(JSON.stringify(newRule)));
setSelectedPartOrder(null);
toast.success("새 규칙이 추가되었습니다");
};
useEffect(() => {
if (currentRule) {
onChange?.(currentRule);
}
if (currentRule) onChange?.(currentRule);
}, [currentRule, onChange]);
// currentRule이 변경될 때 파트별 구분자 상태 동기화
useEffect(() => {
if (currentRule && currentRule.parts.length > 0) {
const newSepTypes: Record<number, SeparatorType> = {};
const newCustomSeps: Record<number, string> = {};
currentRule.parts.forEach((part) => {
const sep = part.separatorAfter ?? currentRule.separator ?? "-";
if (sep === "") {
newSepTypes[part.order] = "none";
newCustomSeps[part.order] = "";
} else {
const predefinedOption = SEPARATOR_OPTIONS.find(
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
const opt = SEPARATOR_OPTIONS.find(
(o) => o.value !== "custom" && o.value !== "none" && o.displayValue === sep
);
if (predefinedOption) {
newSepTypes[part.order] = predefinedOption.value;
if (opt) {
newSepTypes[part.order] = opt.value;
newCustomSeps[part.order] = "";
} else {
newSepTypes[part.order] = "custom";
@ -173,54 +121,45 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
}
}
});
setSeparatorTypes(newSepTypes);
setCustomSeparators(newCustomSeps);
}
}, [currentRule?.ruleId]);
// 개별 파트 구분자 변경 핸들러
const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => {
setSeparatorTypes(prev => ({ ...prev, [partOrder]: type }));
setSeparatorTypes((prev) => ({ ...prev, [partOrder]: type }));
if (type !== "custom") {
const option = SEPARATOR_OPTIONS.find(opt => opt.value === type);
const option = SEPARATOR_OPTIONS.find((opt) => opt.value === type);
const newSeparator = option?.displayValue ?? "";
setCustomSeparators(prev => ({ ...prev, [partOrder]: "" }));
setCustomSeparators((prev) => ({ ...prev, [partOrder]: "" }));
setCurrentRule((prev) => {
if (!prev) return null;
return {
...prev,
parts: prev.parts.map((part) =>
part.order === partOrder ? { ...part, separatorAfter: newSeparator } : part
),
parts: prev.parts.map((p) => (p.order === partOrder ? { ...p, separatorAfter: newSeparator } : p)),
};
});
}
}, []);
// 개별 파트 직접 입력 구분자 변경 핸들러
const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => {
const trimmedValue = value.slice(0, 2);
setCustomSeparators(prev => ({ ...prev, [partOrder]: trimmedValue }));
setCustomSeparators((prev) => ({ ...prev, [partOrder]: trimmedValue }));
setCurrentRule((prev) => {
if (!prev) return null;
return {
...prev,
parts: prev.parts.map((part) =>
part.order === partOrder ? { ...part, separatorAfter: trimmedValue } : part
),
parts: prev.parts.map((p) => (p.order === partOrder ? { ...p, separatorAfter: trimmedValue } : p)),
};
});
}, []);
const handleAddPart = useCallback(() => {
if (!currentRule) return;
if (currentRule.parts.length >= maxRules) {
toast.error(`최대 ${maxRules}개까지 추가할 수 있습니다`);
return;
}
const newPart: NumberingRulePart = {
id: `part-${Date.now()}`,
order: currentRule.parts.length + 1,
@ -229,40 +168,33 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
autoConfig: { textValue: "CODE" },
separatorAfter: "-",
};
setCurrentRule((prev) => {
if (!prev) return null;
return { ...prev, parts: [...prev.parts, newPart] };
});
// 새 파트의 구분자 상태 초기화
setSeparatorTypes(prev => ({ ...prev, [newPart.order]: "-" }));
setCustomSeparators(prev => ({ ...prev, [newPart.order]: "" }));
setCurrentRule((prev) => (prev ? { ...prev, parts: [...prev.parts, newPart] } : null));
setSeparatorTypes((prev) => ({ ...prev, [newPart.order]: "-" }));
setCustomSeparators((prev) => ({ ...prev, [newPart.order]: "" }));
toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
}, [currentRule, maxRules]);
// partOrder 기반으로 파트 업데이트 (id가 null일 수 있으므로 order 사용)
const handleUpdatePart = useCallback((partOrder: number, updates: Partial<NumberingRulePart>) => {
setCurrentRule((prev) => {
if (!prev) return null;
return {
...prev,
parts: prev.parts.map((part) => (part.order === partOrder ? { ...part, ...updates } : part)),
parts: prev.parts.map((p) => (p.order === partOrder ? { ...p, ...updates } : p)),
};
});
}, []);
// partOrder 기반으로 파트 삭제 (id가 null일 수 있으므로 order 사용)
const handleDeletePart = useCallback((partOrder: number) => {
setCurrentRule((prev) => {
if (!prev) return null;
return {
...prev,
parts: prev.parts.filter((part) => part.order !== partOrder).map((part, index) => ({ ...part, order: index + 1 })),
parts: prev.parts
.filter((p) => p.order !== partOrder)
.map((p, i) => ({ ...p, order: i + 1 })),
};
});
setSelectedPartOrder(null);
toast.success("규칙이 삭제되었습니다");
}, []);
@ -271,246 +203,283 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
toast.error("저장할 규칙이 없습니다");
return;
}
if (currentRule.parts.length === 0) {
toast.error("최소 1개 이상의 규칙을 추가해주세요");
return;
}
setLoading(true);
try {
// 파트별 기본 autoConfig 정의
const defaultAutoConfigs: Record<string, any> = {
sequence: { sequenceLength: 3, startFrom: 1 },
number: { numberLength: 4, numberValue: 1 },
date: { dateFormat: "YYYYMMDD" },
text: { textValue: "" },
};
// 저장 전에 각 파트의 autoConfig에 기본값 채우기
const partsWithDefaults = currentRule.parts.map((part) => {
if (part.generationMethod === "auto") {
const defaults = defaultAutoConfigs[part.partType] || {};
return {
...part,
autoConfig: { ...defaults, ...part.autoConfig },
};
return { ...part, autoConfig: { ...defaults, ...part.autoConfig } };
}
return part;
});
const ruleToSave = {
...currentRule,
parts: partsWithDefaults,
scopeType: "table" as const,
tableName: selectedColumn?.tableName || currentRule.tableName || "",
columnName: selectedColumn?.columnName || currentRule.columnName || "",
scopeType: "global" as const,
tableName: currentRule.tableName || currentTableName || "",
columnName: currentRule.columnName || "",
};
// 테스트 테이블에 저장 (numbering_rules)
const response = await saveNumberingRuleToTest(ruleToSave);
if (response.success && response.data) {
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
setCurrentRule(currentData);
const saved: NumberingRuleConfig = JSON.parse(JSON.stringify(response.data));
setCurrentRule(saved);
setRulesList((prev) => {
const idx = prev.findIndex((r) => r.ruleId === currentRule.ruleId);
if (idx >= 0) {
const next = [...prev];
next[idx] = saved;
return next;
}
return [...prev, saved];
});
setSelectedRuleId(saved.ruleId);
await onSave?.(response.data);
toast.success("채번 규칙이 저장되었습니다");
} else {
showErrorToast("채번 규칙 저장에 실패했습니다", response.error, { guidance: "설정을 확인하고 다시 시도해 주세요." });
showErrorToast("채번 규칙 저장에 실패했습니다", response.error, {
guidance: "설정을 확인하고 다시 시도해 주세요.",
});
}
} catch (error: any) {
showErrorToast("채번 규칙 저장에 실패했습니다", error, { guidance: "설정을 확인하고 다시 시도해 주세요." });
} catch (error: unknown) {
showErrorToast("채번 규칙 저장에 실패했습니다", error, {
guidance: "설정을 확인하고 다시 시도해 주세요.",
});
} finally {
setLoading(false);
}
}, [currentRule, onSave, selectedColumn]);
}, [currentRule, onSave, currentTableName]);
const selectedPart = currentRule?.parts.find((p) => p.order === selectedPartOrder) ?? null;
const globalSep = currentRule?.separator ?? "-";
const partItems = currentRule ? computePartDisplayItems(currentRule) : [];
return (
<div className={`flex h-full gap-4 ${className}`}>
{/* 좌측: 채번 컬럼 목록 (카테고리 패턴) */}
<div className="flex w-72 flex-shrink-0 flex-col gap-3">
<h2 className="text-sm font-semibold sm:text-base"> </h2>
<Input
value={columnSearch}
onChange={(e) => setColumnSearch(e.target.value)}
placeholder="검색..."
className="h-8 text-xs"
/>
<div className="flex-1 space-y-1 overflow-y-auto">
{loading && numberingColumns.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-muted-foreground text-xs"> ...</p>
<div className={cn("flex h-full", className)}>
{/* 좌측: 규칙 리스트 (code-nav, 220px) */}
<div className="code-nav flex w-[220px] flex-shrink-0 flex-col border-r border-border">
<div className="code-nav-head flex items-center justify-between gap-2 border-b border-border px-3 py-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2">
<ListOrdered className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate text-xs font-bold"> ({rulesList.length})</span>
</div>
<Button
size="sm"
variant="default"
className="h-8 shrink-0 gap-1 text-xs font-medium"
onClick={handleAddNewRule}
disabled={isPreview || loading}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
<div className="code-nav-list flex-1 overflow-y-auto">
{loading && rulesList.length === 0 ? (
<div className="flex h-24 items-center justify-center text-xs text-muted-foreground">
...
</div>
) : filteredGroups.length === 0 ? (
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed">
<p className="text-muted-foreground text-xs">
{numberingColumns.length === 0
? "채번 타입 컬럼이 없습니다"
: "검색 결과가 없습니다"}
</p>
) : rulesList.length === 0 ? (
<div className="flex h-24 items-center justify-center rounded-lg border border-dashed border-border bg-muted/50 text-xs text-muted-foreground">
</div>
) : (
filteredGroups.map(([tableName, group]) => (
<div key={tableName} className="mb-2">
<div className="text-muted-foreground mb-1 flex items-center gap-1 px-1 text-[11px] font-medium">
<FolderTree className="h-3 w-3" />
<span>{group.tableLabel}</span>
<span className="text-muted-foreground/60">({group.columns.length})</span>
</div>
{group.columns.map((col) => {
const isSelected =
selectedColumn?.tableName === col.tableName &&
selectedColumn?.columnName === col.columnName;
return (
<div
key={`${col.tableName}.${col.columnName}`}
className={cn(
"cursor-pointer rounded-md px-3 py-1.5 text-xs transition-colors",
isSelected
? "bg-primary/10 text-primary border-primary border font-medium"
: "hover:bg-accent"
)}
onClick={() => handleSelectColumn(col.tableName, col.columnName)}
>
{col.columnLabel}
</div>
);
})}
</div>
))
rulesList.map((rule) => {
const isSelected = selectedRuleId === rule.ruleId;
return (
<button
key={rule.ruleId}
type="button"
className={cn(
"code-nav-item flex w-full items-center gap-2 border-b border-border/50 px-3 py-2 text-left transition-colors",
isSelected
? "border-l-[3px] border-primary bg-primary/5 pl-2.5 font-bold"
: "hover:bg-accent"
)}
onClick={() => handleSelectRule(rule)}
>
<span className="rule-name min-w-0 flex-1 truncate text-xs font-semibold">
{rule.ruleName}
</span>
<span className="rule-table max-w-[70px] shrink-0 truncate text-[9px] text-muted-foreground">
{rule.tableName || "-"}
</span>
<span className="rule-parts shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[8px] font-bold text-muted-foreground">
{rule.parts?.length ?? 0}
</span>
</button>
);
})
)}
</div>
</div>
{/* 구분선 */}
<div className="bg-border h-full w-px"></div>
{/* 우측: 편집 영역 */}
<div className="flex flex-1 flex-col gap-4">
{/* 우측: 미리보기 + 파이프라인 + 설정 + 저장 바 (code-main) */}
<div className="code-main flex min-w-0 flex-1 flex-col overflow-hidden">
{!currentRule ? (
<div className="flex h-full flex-col items-center justify-center">
<div className="text-center">
<FolderTree className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
<p className="text-muted-foreground mb-2 text-lg font-medium"> </p>
<p className="text-muted-foreground text-sm"> </p>
</div>
<div className="flex flex-1 flex-col items-center justify-center text-center">
<ListOrdered className="mb-3 h-10 w-10 text-muted-foreground" />
<p className="mb-2 text-lg font-medium text-muted-foreground"> </p>
<p className="text-sm text-muted-foreground">
&quot;&quot;
</p>
</div>
) : (
<>
<div className="flex items-center justify-between">
{editingRightTitle ? (
<Input
value={rightTitle}
onChange={(e) => setRightTitle(e.target.value)}
onBlur={() => setEditingRightTitle(false)}
onKeyDown={(e) => e.key === "Enter" && setEditingRightTitle(false)}
className="h-8 text-sm font-semibold"
autoFocus
/>
) : (
<h2 className="text-sm font-semibold sm:text-base">{rightTitle}</h2>
)}
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditingRightTitle(true)}>
<Edit2 className="h-3 w-3" />
</Button>
<div className="flex flex-col gap-2 px-6 pt-4">
<Label className="text-xs font-medium"></Label>
<Input
value={currentRule.ruleName}
onChange={(e) => setCurrentRule((prev) => (prev ? { ...prev, ruleName: e.target.value } : null))}
placeholder="예: 프로젝트 코드"
className="h-9 text-sm"
/>
</div>
<div className="space-y-3">
{/* 첫 번째 줄: 규칙명 + 미리보기 */}
<div className="flex items-center gap-3">
<div className="flex-1 space-y-2">
<Label className="text-sm font-medium"></Label>
<Input
value={currentRule.ruleName}
onChange={(e) => setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value }))}
className="h-9"
placeholder="예: 프로젝트 코드"
/>
</div>
<div className="flex-1 space-y-2">
<Label className="text-sm font-medium"></Label>
<NumberingRulePreview config={currentRule} />
</div>
</div>
{/* 큰 미리보기 스트립 (code-preview-strip) */}
<div className="code-preview-strip flex-shrink-0 border-b border-border px-6 py-5">
<NumberingRulePreview config={currentRule} variant="strip" />
</div>
<div className="flex-1 overflow-y-auto">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold"> </h3>
<span className="text-muted-foreground text-xs">
{/* 파이프라인 영역 (code-pipeline-area) */}
<div className="code-pipeline-area flex flex-col gap-3 border-b border-border px-6 py-5">
<div className="area-label flex items-center gap-1.5">
<span className="text-xs font-bold"> </span>
<span className="cnt text-xs font-medium text-muted-foreground">
{currentRule.parts.length}/{maxRules}
</span>
</div>
{currentRule.parts.length === 0 ? (
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed">
<p className="text-muted-foreground text-xs sm:text-sm"> </p>
</div>
) : (
<div className="flex flex-wrap items-stretch gap-3">
{currentRule.parts.map((part, index) => (
<React.Fragment key={`part-${part.order}-${index}`}>
<div className="flex w-[200px] flex-col">
<NumberingRuleCard
part={part}
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
onDelete={() => handleDeletePart(part.order)}
isPreview={isPreview}
tableName={selectedColumn?.tableName}
/>
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
{index < currentRule.parts.length - 1 && (
<div className="mt-2 flex items-center gap-1">
<span className="text-muted-foreground text-[10px] whitespace-nowrap"> </span>
<Select
value={separatorTypes[part.order] || "-"}
onValueChange={(value) => handlePartSeparatorChange(part.order, value as SeparatorType)}
>
<SelectTrigger className="h-6 flex-1 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEPARATOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} className="text-xs">
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{separatorTypes[part.order] === "custom" && (
<Input
value={customSeparators[part.order] || ""}
onChange={(e) => handlePartCustomSeparatorChange(part.order, e.target.value)}
className="h-6 w-14 text-center text-[10px]"
placeholder="2자"
maxLength={2}
/>
<div className="code-pipeline flex flex-1 flex-wrap items-center gap-0 overflow-x-auto overflow-y-hidden pb-2">
{currentRule.parts.length === 0 ? (
<div className="flex h-24 min-w-[200px] items-center justify-center rounded-xl border-2 border-dashed border-border bg-muted/30 text-xs text-muted-foreground">
</div>
) : (
<>
{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 (
<React.Fragment key={`part-${part.order}-${index}`}>
<button
type="button"
className={cn(
"pipe-segment min-w-[120px] flex-shrink-0 rounded-[10px] border-2 px-4 py-3 text-center transition-all",
part.partType === "date" && "border-warning",
part.partType === "text" && "border-primary",
part.partType === "sequence" && "border-primary",
(part.partType === "number" || part.partType === "category" || part.partType === "reference") && "border-border",
isSelected && "border-primary bg-primary/5 shadow-md ring-2 ring-primary/30"
)}
</div>
)}
</div>
</React.Fragment>
))}
</div>
)}
onClick={() => setSelectedPartOrder(part.order)}
>
<div className="seg-type text-[8px] font-bold uppercase tracking-wide text-muted-foreground">
{typeLabel}
</div>
<div className={cn("seg-value mt-1 truncate font-mono text-base font-extrabold leading-none", getPartTypeColorClass(part.partType))}>
{item?.displayValue ?? "-"}
</div>
</button>
{index < currentRule.parts.length - 1 && (
<div className="pipe-connector flex w-8 flex-shrink-0 flex-col items-center justify-center gap-0.5">
<span className="conn-line text-xs font-bold text-muted-foreground"></span>
<span className="conn-sep rounded border border-border bg-muted px-1 py-0.5 text-[8px] font-semibold text-muted-foreground">
{sep || "-"}
</span>
</div>
)}
</React.Fragment>
);
})}
<button
type="button"
className="pipe-add flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full border-2 border-dashed border-border text-muted-foreground transition-colors hover:border-primary hover:bg-primary/5 hover:text-primary"
onClick={handleAddPart}
disabled={currentRule.parts.length >= maxRules || isPreview || loading}
aria-label="규칙 추가"
>
<Plus className="h-5 w-5" />
</button>
</>
)}
</div>
</div>
<div className="flex gap-2">
{/* 설정 패널 (선택된 세그먼트 상세, code-config-panel) */}
{selectedPart && (
<div className="code-config-panel min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div className="code-config-grid grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
<NumberingRuleCard
part={selectedPart}
onUpdate={(updates) => handleUpdatePart(selectedPart.order, updates)}
onDelete={() => handleDeletePart(selectedPart.order)}
isPreview={isPreview}
tableName={currentRule.tableName ?? currentTableName}
/>
</div>
{currentRule.parts.some((p) => p.order === selectedPart.order) && (
<div className="mt-3 flex items-center gap-2">
<span className="text-[10px] text-muted-foreground"> </span>
<Select
value={separatorTypes[selectedPart.order] ?? "-"}
onValueChange={(v) => handlePartSeparatorChange(selectedPart.order, v as SeparatorType)}
>
<SelectTrigger className="h-7 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEPARATOR_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{separatorTypes[selectedPart.order] === "custom" && (
<Input
value={customSeparators[selectedPart.order] ?? ""}
onChange={(e) => handlePartCustomSeparatorChange(selectedPart.order, e.target.value)}
className="h-7 w-14 text-center text-[10px]"
placeholder="2자"
maxLength={2}
/>
)}
</div>
)}
</div>
)}
{/* 저장 바 (code-save-bar) */}
<div className="code-save-bar flex flex-shrink-0 items-center justify-between gap-4 border-t border-border bg-muted/30 px-6 py-4">
<div className="min-w-0 flex-1 text-xs text-muted-foreground">
{currentRule.tableName && (
<span>: {currentRule.tableName}</span>
)}
{currentRule.columnName && (
<span className="ml-2">: {currentRule.columnName}</span>
)}
<span className="ml-2">: {globalSep || "-"}</span>
{currentRule.resetPeriod && currentRule.resetPeriod !== "none" && (
<span className="ml-2">: {currentRule.resetPeriod}</span>
)}
</div>
<Button
onClick={handleAddPart}
disabled={currentRule.parts.length >= maxRules || isPreview || loading}
variant="outline"
className="h-9 flex-1 text-sm"
onClick={handleSave}
disabled={isPreview || loading}
className="h-9 gap-2 text-sm font-medium"
>
<Plus className="mr-2 h-4 w-4" />
</Button>
<Button onClick={handleSave} disabled={isPreview || loading} className="h-9 flex-1 text-sm">
<Save className="mr-2 h-4 w-4" />
<Save className="h-4 w-4" />
{loading ? "저장 중..." : "저장"}
</Button>
</div>

View File

@ -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<NumberingRulePreviewProps> = ({
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 (
<div className="rounded-lg border border-border bg-gradient-to-b from-muted to-card px-4 py-4 sm:px-6 sm:py-5">
<div className="font-mono text-[22px] font-extrabold tracking-tight sm:text-[28px]">
{partItems.length === 0 ? (
<span className="text-muted-foreground"> </span>
) : (
partItems.map((item, idx) => (
<React.Fragment key={item.order}>
<span className={getPartTypeColorClass(item.partType)}>{item.displayValue}</span>
{idx < partItems.length - 1 && (
<span className="text-muted-foreground">
{sortedParts.find((p) => p.order === item.order)?.separatorAfter ?? globalSep}
</span>
)}
</React.Fragment>
))
)}
</div>
{partItems.length > 0 && (
<div className="preview-desc mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
{CODE_PART_TYPE_OPTIONS.filter((opt) => partItems.some((p) => p.partType === opt.value)).map((opt) => (
<span key={opt.value} className="flex items-center gap-1.5">
<span className={cn("h-[6px] w-[6px] shrink-0 rounded-full", getPartTypeDotClass(opt.value))} />
{opt.label}
</span>
))}
</div>
)}
</div>
);
}
if (compact) {
return (

View File

@ -362,15 +362,15 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
// 런타임 모드에서 컴포넌트 타입별 높이 처리
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 = [

View File

@ -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 (
<div
ref={containerRef}
data-screen-runtime="true"
className="bg-background relative w-full overflow-x-hidden"
style={{ minHeight: containerW > 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,
}}
>

View File

@ -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<V2PropertiesPanelProps> = ({
// 🆕 전체 테이블 목록 (selected-items-detail-input 등에서 사용)
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName?: string }>>([]);
// 🆕 선택된 컴포넌트의 테이블에 대한 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<V2PropertiesPanelProps> = ({
// 현재 화면의 테이블명 가져오기
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<string, any> = {};
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<V2PropertiesPanelProps> = ({
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<V2PropertiesPanelProps> = ({
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<V2PropertiesPanelProps> = ({
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<V2PropertiesPanelProps> = ({
<span className="text-muted-foreground text-xs"></span>
<div className="w-[160px]">
<Input
value={group.title || area.title || ""}
value={(group as any).title || (area as any).title || ""}
onChange={(e) => handleUpdate("title", e.target.value)}
placeholder="제목"
className="h-7 text-xs"
@ -503,7 +520,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
<span className="text-muted-foreground text-xs"></span>
<div className="w-[160px]">
<Input
value={area.description || ""}
value={(area as any).description || ""}
onChange={(e) => handleUpdate("description", e.target.value)}
placeholder="설명"
className="h-7 text-xs"
@ -519,9 +536,9 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
<h4 className="text-muted-foreground py-2 text-[10px] font-semibold tracking-wider uppercase">OPTIONS</h4>
{(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<V2PropertiesPanelProps> = ({
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs"></span>
<Checkbox
checked={selectedComponent.hidden === true || selectedComponent.componentConfig?.hidden === true}
checked={(selectedComponent as any).hidden === true || selectedComponent.componentConfig?.hidden === true}
onCheckedChange={(checked) => {
handleUpdate("hidden", checked);
handleUpdate("componentConfig.hidden", checked);
@ -689,7 +706,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs"></span>
<Checkbox
checked={selectedComponent.style?.labelDisplay === true || selectedComponent.labelDisplay === true}
checked={selectedComponent.style?.labelDisplay === true || (selectedComponent as any).labelDisplay === true}
onCheckedChange={(checked) => {
const boolValue = checked === true;
handleUpdate("style.labelDisplay", boolValue);
@ -785,7 +802,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
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<V2PropertiesPanelProps> = ({
<DynamicComponentConfigPanel
componentId={componentId}
config={selectedComponent.componentConfig || {}}
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} // 🆕 메뉴 OBJID 전달
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
@ -1006,8 +1023,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
<DynamicComponentConfigPanel
componentId={widget.widgetType}
config={widget.componentConfig || {}}
screenTableName={widget.tableName || currentTable?.tableName || currentTableName}
tableColumns={currentTable?.columns || []}
screenTableName={(widget as any).tableName || currentTable?.tableName || currentTableName}
tableColumns={(currentTable as any)?.columns || []}
tables={tables}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
@ -1023,17 +1040,17 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
return (
<div className="space-y-4">
{/* WebType 선택 (있는 경우만) */}
{widget.webType && (
{(widget as any).webType && (
<div>
<Label> </Label>
<Select value={widget.webType} onValueChange={(value) => handleUpdate("webType", value)}>
<Select value={(widget as any).webType} onValueChange={(value) => handleUpdate("webType", value)}>
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{webTypes.map((wt) => (
<SelectItem key={wt.web_type} value={wt.web_type}>
{wt.web_type_name_kor || wt.web_type}
{(wt as any).web_type_name_kor || wt.web_type}
</SelectItem>
))}
</SelectContent>

View File

@ -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<CategoryColumn[]>([]);
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 (
<div className="flex h-full flex-col">
<div className="border-b p-2.5">
<div className="relative">
<Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
type="text"
placeholder="테이블 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 border-0 bg-transparent pl-8 pr-8 text-xs shadow-none focus-visible:ring-0"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-muted-foreground hover:text-foreground absolute right-2 top-1/2 -translate-y-1/2"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
<div className="flex-1 space-y-0 overflow-y-auto">
{filteredColumns.length === 0 && searchQuery ? (
<div className="text-muted-foreground py-4 text-center text-xs">
&apos;{searchQuery}&apos;
</div>
) : null}
{groupedColumns.map((group) => {
const totalValues = group.columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0);
const isActive = selectedTable === group.tableName;
return (
<button
key={group.tableName}
type="button"
onClick={() => onTableSelect(group.tableName)}
className={cn(
"flex w-full items-center gap-2 px-3 py-2.5 text-left transition-colors",
isActive
? "border-l-[3px] border-primary bg-primary/5 font-bold text-primary"
: "hover:bg-muted/50",
)}
>
<div
className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-[5px] bg-primary/20 text-primary"
aria-hidden
>
<FolderTree className="h-3.5 w-3.5" />
</div>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{group.tableLabel || group.tableName}
</span>
<span className="bg-muted text-muted-foreground shrink-0 rounded-full px-1.5 py-0.5 text-[9px] font-bold">
{group.columns.length}
</span>
</button>
);
})}
</div>
</div>
);
}
return (
<div className="space-y-3">
<div className="space-y-1">
@ -298,7 +378,6 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
<p className="text-muted-foreground text-xs"> </p>
</div>
{/* 검색 입력 필드 */}
<div className="relative">
<Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
@ -310,6 +389,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-muted-foreground hover:text-foreground absolute right-2 top-1/2 -translate-y-1/2"
>

View File

@ -120,7 +120,7 @@ export const CategoryValueAddDialog: React.FC<
<div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="valueLabel"

View File

@ -86,7 +86,7 @@ export const CategoryValueEditDialog: React.FC<
<div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="valueLabel"

View File

@ -30,6 +30,8 @@ interface CategoryValueManagerProps {
columnLabel: string;
onValueCountChange?: (count: number) => void;
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
/** 편집기 헤더 오른쪽에 표시할 내용 (예: 트리/목록 세그먼트) */
headerRight?: React.ReactNode;
}
export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
@ -38,6 +40,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
columnLabel,
onValueCountChange,
menuObjid,
headerRight,
}) => {
const { toast } = useToast();
const [values, setValues] = useState<TableCategoryValue[]>([]);
@ -284,7 +287,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
{/* 편집기 헤더: 컬럼명 + 값 수 + 비활성 토글 + 새 값 추가 + headerRight(트리·목록 세그먼트 등) */}
<div className="border-b p-4">
<div className="mb-4 flex items-center justify-between">
<div>
@ -308,11 +311,11 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
</label>
</div>
<Button onClick={() => setIsAddDialogOpen(true)} size="sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
{headerRight != null ? <div className="flex items-center">{headerRight}</div> : null}
</div>
</div>
@ -405,7 +408,6 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
value.isActive !== false
)
}
className="data-[state=checked]:bg-emerald-500"
/>
<Button

View File

@ -19,6 +19,7 @@ import {
Search,
RefreshCw,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
@ -59,6 +60,8 @@ interface CategoryValueManagerTreeProps {
columnName: string;
columnLabel: string;
onValueCountChange?: (count: number) => void;
/** 편집기 헤더 오른쪽에 표시할 내용 (예: 트리/목록 세그먼트) */
headerRight?: React.ReactNode;
}
// 트리 노드 컴포넌트
@ -114,13 +117,13 @@ const TreeNode: React.FC<TreeNodeProps> = ({
return null;
}
// 깊이별 아이콘
// 깊이별 아이콘 (대/중분류 = Folder, 소분류 = Tag)
const getIcon = () => {
if (hasChildren) {
return isExpanded ? (
<FolderOpen className="h-4 w-4 text-amber-500" />
<FolderOpen className="text-muted-foreground h-4 w-4" />
) : (
<Folder className="h-4 w-4 text-amber-500" />
<Folder className="text-muted-foreground h-4 w-4" />
);
}
return <Tag className="h-4 w-4 text-primary" />;
@ -141,31 +144,28 @@ const TreeNode: React.FC<TreeNodeProps> = ({
};
return (
<div>
<div className="mb-px">
<div
className={cn(
"group flex items-center gap-1 rounded-md px-2 py-2 transition-colors",
isSelected ? "border-primary bg-primary/10 border-l-2" : "hover:bg-muted/50",
"group flex cursor-pointer items-center gap-[5px] rounded-[6px] px-[8px] py-[5px] transition-colors",
isSelected ? "border-primary border-l-2 bg-primary/10" : "hover:bg-muted/50",
isChecked && "bg-primary/5",
"cursor-pointer",
)}
style={{ paddingLeft: `${level * 20 + 8}px` }}
onClick={() => onSelect(node)}
>
{/* 체크박스 */}
<Checkbox
checked={isChecked}
onCheckedChange={(checked) => {
onCheck(node.valueId, checked as boolean);
}}
onClick={(e) => e.stopPropagation()}
className="mr-1"
className="mr-1 shrink-0"
/>
{/* 확장 토글 */}
<button
type="button"
className="hover:bg-muted flex h-6 w-6 items-center justify-center rounded"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded hover:bg-muted"
onClick={(e) => {
e.stopPropagation();
if (hasChildren) {
@ -184,22 +184,24 @@ const TreeNode: React.FC<TreeNodeProps> = ({
)}
</button>
{/* 아이콘 */}
{getIcon()}
{/* 라벨 */}
<div className="flex flex-1 items-center gap-2">
<span className={cn("text-sm", node.depth === 1 && "font-medium")}>{node.valueLabel}</span>
<span className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]">{getDepthLabel()}</span>
<div className="flex min-w-0 flex-1 items-center gap-[5px]">
<span className={cn("truncate text-sm", node.depth === 1 && "font-medium")}>
{node.valueLabel}
</span>
<span className="bg-muted text-muted-foreground shrink-0 rounded-[4px] px-1.5 py-0.5 text-[8px] font-bold">
{getDepthLabel()}
</span>
</div>
{/* 비활성 표시 */}
{!node.isActive && (
<span className="bg-destructive/10 text-destructive rounded px-1.5 py-0.5 text-[10px]"></span>
<span className="bg-destructive/5 text-destructive shrink-0 rounded-[4px] px-1.5 py-0.5 text-[8px] font-bold">
</span>
)}
{/* 액션 버튼 */}
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
{canAddChild && (
<Button
variant="ghost"
@ -272,6 +274,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
columnName,
columnLabel,
onValueCountChange,
headerRight,
}) => {
// 상태
const [tree, setTree] = useState<CategoryValue[]>([]);
@ -634,10 +637,13 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
{/* 편집기 헤더: 컬럼명 + 값 수 Badge + 비활성/전체펼침/대분류추가 + headerRight(트리·목록 세그먼트 등) */}
<div className="mb-3 flex items-center justify-between border-b pb-3">
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold">{columnLabel} </h3>
<Badge variant="secondary" className="rounded-full px-2 py-0.5 text-xs font-bold">
{countAllValues(tree)}
</Badge>
{checkedIds.size > 0 && (
<span className="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs">
{checkedIds.size}
@ -665,6 +671,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
<Plus className="h-3.5 w-3.5" />
</Button>
{headerRight != null ? <div className="flex items-center">{headerRight}</div> : null}
</div>
</div>
@ -720,7 +727,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
) : (
<div className="p-2">
<div className="py-1">
{tree.map((node) => (
<TreeNode
key={node.valueId}

View File

@ -26,7 +26,7 @@ function TabsList({
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted/30 text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
"bg-muted/50 text-muted-foreground inline-flex h-10 w-fit items-center justify-center rounded-lg border border-border/50 p-1",
className
)}
{...props}
@ -42,7 +42,7 @@ function TabsTrigger({
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"data-[state=active]:bg-background data-[state=active]:font-semibold dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-foreground/70 inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}

View File

@ -347,7 +347,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
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<V2RepeaterProps> = ({
const hasFkSource = sourceCol && mainFormData && mainFormData[sourceCol] !== undefined;
if (!hasFkSource && !masterRecordId) {
console.warn("🔴 [V2Repeater] FK 소스 값/masterRecordId 모두 없어 저장 스킵");
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
return;
}
}

View File

@ -433,9 +433,9 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
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 (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
@ -879,7 +878,6 @@ const ActionDetailSection: React.FC<{
<span className="text-sm font-medium">
{actionType === "save" && "저장 설정"}
{actionType === "delete" && "삭제 설정"}
{actionType === "edit" && "편집 설정"}
{actionType === "quickInsert" && "즉시 저장 설정"}
</span>
</div>
@ -900,6 +898,147 @@ const ActionDetailSection: React.FC<{
</div>
);
case "edit":
return (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<Pencil className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
{/* 대상 화면 선택 */}
<div>
<Label className="text-xs"> </Label>
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-8 w-full justify-between text-xs"
disabled={screensLoading}
>
{screensLoading
? "로딩 중..."
: action.targetScreenId
? screens.find((s) => s.id === action.targetScreenId)?.name || `화면 #${action.targetScreenId}`
: "화면 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="화면 검색..."
value={modalSearchTerm}
onValueChange={setModalSearchTerm}
className="text-xs"
/>
<CommandList className="max-h-48">
<CommandEmpty className="py-3 text-xs"> .</CommandEmpty>
<CommandGroup>
{screens
.filter((s) =>
!modalSearchTerm ||
s.name.toLowerCase().includes(modalSearchTerm.toLowerCase()) ||
s.description?.toLowerCase().includes(modalSearchTerm.toLowerCase()) ||
String(s.id).includes(modalSearchTerm)
)
.map((screen) => (
<CommandItem
key={screen.id}
value={String(screen.id)}
onSelect={() => {
updateActionConfig("targetScreenId", screen.id);
setModalScreenOpen(false);
setModalSearchTerm("");
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
action.targetScreenId === screen.id ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && (
<span className="text-[10px] text-muted-foreground">{screen.description}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 편집 모드 선택 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={action.editMode || "modal"}
onValueChange={(value) => updateActionConfig("editMode", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="modal" className="text-xs"> </SelectItem>
<SelectItem value="navigate" className="text-xs"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 모달 모드일 때 추가 설정 */}
{(action.editMode || "modal") === "modal" && (
<>
<div>
<Label className="text-xs"> </Label>
<Input
value={action.editModalTitle || ""}
onChange={(e) => updateActionConfig("editModalTitle", e.target.value)}
placeholder="데이터 수정"
className="h-7 text-xs"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={action.editModalDescription || ""}
onChange={(e) => updateActionConfig("editModalDescription", e.target.value)}
placeholder="모달 설명"
className="h-7 text-xs"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={action.modalSize || "lg"}
onValueChange={(value) => updateActionConfig("modalSize", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm" className="text-xs"> (sm)</SelectItem>
<SelectItem value="md" className="text-xs"> (md)</SelectItem>
<SelectItem value="lg" className="text-xs"> (lg)</SelectItem>
<SelectItem value="xl" className="text-xs"> (xl)</SelectItem>
<SelectItem value="full" className="text-xs"> (full)</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
{commonMessageSection}
</div>
);
case "modal":
return (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">

View File

@ -77,9 +77,9 @@ interface CategoryValueOption {
valueLabel: string;
}
// ─── 하위 호환: 기존 config에서 fieldType 추론 ───
// ─── 하위 호환: 기존 config에서 fieldType 추론 (우선순위: DB값 > 사용자 fieldType > 컴포넌트구조 > saved config > 기본값) ───
function resolveFieldType(config: Record<string, any>, 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<string, any>, 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<string, any>, 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";
}

View File

@ -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<LoginResponse> => {
// 로컬 스토리지에서 토큰 가져오기
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],
);
// 컴포넌트 마운트 시 기존 인증 상태 확인

View File

@ -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 };
}

View File

@ -30,18 +30,20 @@ export function invalidateColumnMetaCache(tableName?: string): void {
}
}
async function loadColumnMeta(tableName: string, forceReload = false): Promise<void> {
export async function loadColumnMeta(tableName: string, forceReload = false): Promise<void> {
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<DynamicComponentRendererProps> =
}
// 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<DynamicComponentRendererProps> =
// 렌더러 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<DynamicComponentRendererProps> =
// 새로운 기능들 전달
// 🆕 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<DynamicComponentRendererProps> =
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 = <NewComponentRenderer key={refreshKey} {...rendererProps} />;
const needsKeyRefresh =
componentType === "v2-table-list" ||
componentType === "table-list" ||
componentType === "v2-repeater";
renderedElement = <NewComponentRenderer key={needsKeyRefresh ? refreshKey : component.id} {...rendererProps} />;
}
// 수평 라벨 → 라벨을 컴포넌트 영역 바깥에 absolute 배치, 입력은 100% 채움
@ -1004,7 +1012,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
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<DynamicComponentRendererProps> =
}
// 2. 레거시 시스템에서 조회
const renderer = legacyComponentRegistry.get(componentType);
const renderer =
componentType != null ? legacyComponentRegistry.get(componentType) : undefined;
if (!renderer) {
console.error(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`, {

View File

@ -106,6 +106,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const [forceUpdate, setForceUpdate] = useState(0);
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
@ -217,18 +218,17 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}
}, [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<FileUploadComponentProps> = ({
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<FileUploadComponentProps> = ({
return (
<div
ref={containerRef}
style={{
...componentStyle,
width: "100%", // 🆕 부모 컨테이너 너비에 맞춤
height: "100%", // 🆕 부모 컨테이너 높이에 맞춤
width: "100%",
height: "100%",
border: "none !important",
boxShadow: "none !important",
outline: "none !important",

View File

@ -119,6 +119,7 @@ import "./v2-approval-step/ApprovalStepRenderer"; // 결재 단계 시각화
import "./v2-status-count/StatusCountRenderer"; // 상태별 카운트 카드
import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준
import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅
import "./v2-shipping-plan-editor/ShippingPlanEditorRenderer"; // 출하계획 동시등록
/**
*

View File

@ -604,7 +604,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
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<ButtonPrimaryComponentProps> = ({
// 실패한 경우 오류 처리
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<ButtonPrimaryComponentProps> = ({
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<ButtonPrimaryComponentProps> = ({
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<string, any> = {};
if (sourceTableName) {
sourceTracking.source_table = sourceTableName;
}
if (row.id) {
sourceTracking.source_id = row.id;
}
return {
...mappedRow,
...sourceTracking,
...additionalData,
};
});

View File

@ -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<CategoryColumn[]>([]);
const [selectedTable, setSelectedTable] = useState<string | null>(null);
const [selectedColumn, setSelectedColumn] = useState<{
uniqueKey: string;
columnName: string;
columnLabel: string;
tableName: string;
} | null>(null);
// 뷰 모드 상태
const [viewMode, setViewMode] = useState<ViewMode>(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 ? (
<div className="flex rounded-md border p-0.5">
<Button
variant="ghost"
size="sm"
className={cn("h-7 gap-1.5 px-2.5 text-xs", viewMode === "tree" && "bg-accent")}
onClick={() => setViewMode("tree")}
>
<TreeDeciduous className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className={cn("h-7 gap-1.5 px-2.5 text-xs", viewMode === "list" && "bg-accent")}
onClick={() => setViewMode("list")}
>
<LayoutList className="h-3.5 w-3.5" />
</Button>
</div>
) : null;
const rightContent = (
<>
{/* 뷰 모드 토글 */}
{config.showViewModeToggle && (
<div className="mb-2 flex items-center justify-end gap-1">
<span className="text-muted-foreground mr-2 text-xs"> :</span>
<div className="flex rounded-md border p-0.5">
<Button
variant="ghost"
size="sm"
className={cn("h-7 gap-1.5 px-2.5 text-xs", viewMode === "tree" && "bg-accent")}
onClick={() => setViewMode("tree")}
>
<TreeDeciduous className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className={cn("h-7 gap-1.5 px-2.5 text-xs", viewMode === "list" && "bg-accent")}
onClick={() => setViewMode("list")}
>
<LayoutList className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
{/* 카테고리 값 관리 */}
<div className="min-h-0 flex-1 overflow-y-auto">
{selectedColumn ? (
viewMode === "tree" ? (
@ -115,6 +123,7 @@ export function V2CategoryManagerComponent({
tableName={selectedColumn.tableName}
columnName={selectedColumn.columnName}
columnLabel={selectedColumn.columnLabel}
headerRight={viewModeSegment}
/>
) : (
<CategoryValueManager
@ -123,6 +132,7 @@ export function V2CategoryManagerComponent({
columnName={selectedColumn.columnName}
columnLabel={selectedColumn.columnLabel}
menuObjid={effectiveMenuObjid}
headerRight={viewModeSegment}
/>
)
) : (
@ -130,7 +140,9 @@ export function V2CategoryManagerComponent({
<div className="flex flex-col items-center gap-2 text-center">
<TreeDeciduous className="text-muted-foreground/30 h-10 w-10" />
<p className="text-muted-foreground text-sm">
{config.showColumnList ? "좌측에서 관리할 카테고리 컬럼을 선택하세요" : "카테고리 컬럼이 설정되지 않았습니다"}
{config.showColumnList
? "칩에서 카테고리 컬럼을 선택하세요"
: "카테고리 컬럼이 설정되지 않았습니다"}
</p>
</div>
</div>
@ -148,24 +160,107 @@ export function V2CategoryManagerComponent({
}
return (
<ResponsiveSplitPanel
left={
<CategoryColumnList
tableName={effectiveTableName}
selectedColumn={selectedColumn?.uniqueKey || null}
onColumnSelect={handleColumnSelect}
menuObjid={effectiveMenuObjid}
/>
}
right={rightContent}
leftTitle="카테고리 컬럼"
leftWidth={config.leftPanelWidth}
minLeftWidth={10}
maxLeftWidth={40}
height={config.height}
/>
<div
className="flex h-full flex-col overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm"
style={{ height: config.height }}
>
{/* Stat Strip: 카테고리 컬럼(primary) | 전체 값(success) | 테이블(primary) | 비활성(warning) */}
<div className="grid grid-cols-4 border-b bg-background">
<div className="border-r py-3.5 text-center last:border-r-0">
<div className="text-[22px] font-extrabold leading-none tracking-tight text-primary">
{stats.columnCount}
</div>
<div className="mt-1 text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
</div>
</div>
<div className="border-r py-3.5 text-center last:border-r-0">
<div className="text-[22px] font-extrabold leading-none tracking-tight text-success">
{stats.totalValues}
</div>
<div className="mt-1 text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
</div>
</div>
<div className="border-r py-3.5 text-center last:border-r-0">
<div className="text-[22px] font-extrabold leading-none tracking-tight text-primary">
{stats.tableCount}
</div>
<div className="mt-1 text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
</div>
</div>
<div className="py-3.5 text-center">
<div className="text-[22px] font-extrabold leading-none tracking-tight text-warning">
{stats.inactiveCount}
</div>
<div className="mt-1 text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
</div>
</div>
</div>
<div className="flex min-h-0 flex-1">
{/* 좌측 테이블 nav: 240px */}
<div className="flex w-[240px] shrink-0 flex-col border-r">
<CategoryColumnList
tableName={effectiveTableName}
selectedColumn={selectedColumn?.uniqueKey ?? null}
onColumnSelect={handleColumnSelect}
menuObjid={effectiveMenuObjid}
selectedTable={selectedTable}
onTableSelect={setSelectedTable}
onColumnsLoaded={handleColumnsLoaded}
/>
</div>
{/* 우측: 칩 바 + 편집기 */}
<div className="flex min-w-0 flex-1 flex-col">
{/* 칩 바 */}
<div className="flex flex-wrap gap-1.5 border-b bg-background px-4 py-3">
{columnsForSelectedTable.map((col) => {
const uniqueKey = `${col.tableName}.${col.columnName}`;
const isActive = selectedColumn?.uniqueKey === uniqueKey;
return (
<button
key={uniqueKey}
type="button"
onClick={() =>
handleColumnSelect(uniqueKey, col.columnLabel || col.columnName, col.tableName)
}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-[10px] py-[5px] text-[11px] font-semibold transition-colors",
isActive
? "border-primary bg-primary/5 text-primary"
: "border-border bg-muted/50 hover:border-primary hover:bg-primary/5 hover:text-primary",
)}
>
<span>{col.columnLabel || col.columnName}</span>
<Badge
variant="secondary"
className={cn(
"h-4 rounded-full px-1.5 text-[9px] font-bold",
isActive ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground",
)}
>
{col.valueCount ?? 0}
</Badge>
</button>
);
})}
{selectedTable && columnsForSelectedTable.length === 0 && (
<span className="text-muted-foreground text-xs"> </span>
)}
</div>
{/* 편집기 영역 */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden p-3">
{rightContent}
</div>
</div>
</div>
</div>
);
}
export default V2CategoryManagerComponent;

View File

@ -105,6 +105,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const [forceUpdate, setForceUpdate] = useState(0);
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// objid 기반으로 파일이 로드되었는지 추적 (다른 이펙트가 덮어쓰지 않도록 방지)
const filesLoadedFromObjidRef = useRef(false);
@ -197,7 +198,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
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<FileUploadComponentProps> = ({
return (
<div
ref={containerRef}
style={{
...componentStyle,
width: "100%",
height: "100%",
// 🔧 !important 제거 - 커스텀 스타일이 없을 때만 기본값 적용
border: hasCustomBorder ? undefined : "none",
boxShadow: "none",
outline: "none",

View File

@ -0,0 +1,797 @@
"use client";
import React, { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { ComponentRendererProps } from "@/types/component";
import {
Loader2,
Package,
TrendingUp,
Warehouse,
CheckCircle,
Factory,
Truck,
Plus,
X,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import {
ShippingPlanEditorConfig,
ItemGroup,
PlanDetailRow,
ItemAggregation,
} from "./types";
import { getShippingPlanAggregate, batchSaveShippingPlans } from "@/lib/api/shipping";
export interface ShippingPlanEditorComponentProps
extends ComponentRendererProps {}
export const ShippingPlanEditorComponent: React.FC<
ShippingPlanEditorComponentProps
> = ({ component, isDesignMode = false, groupedData, formData, onFormDataChange, onClose, ...props }) => {
const config = (component?.componentConfig ||
{}) as ShippingPlanEditorConfig;
const [itemGroups, setItemGroups] = useState<ItemGroup[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [source, setSource] = useState<"master" | "detail">("detail");
const itemGroupsRef = useRef<ItemGroup[]>([]);
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<string, number>();
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<string, { total: number; balance: number }>();
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 (
<div className="flex h-full w-full flex-col gap-3 rounded-lg border border-dashed border-gray-300 p-4">
<div className="flex items-center gap-2">
<Truck className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">
{config.title || "출하계획 등록"}
</span>
</div>
<div className="flex gap-2">
{[
"총수주잔량",
"총출하계획량",
"현재고",
"가용재고",
"생산중수량",
].map((label) => (
<div
key={label}
className="flex flex-1 flex-col items-center rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
>
<span className="text-lg font-bold text-gray-400">0</span>
<span className="text-[10px] text-gray-400">{label}</span>
</div>
))}
</div>
<div className="flex-1 rounded-lg border border-gray-200 bg-gray-50 p-3">
<span className="text-xs text-gray-400"> </span>
</div>
</div>
);
}
if (loading || saving) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">
{saving ? "출하계획 저장 중..." : "데이터 로딩 중..."}
</span>
</div>
</div>
);
}
if (selectedIds.length === 0) {
return (
<div className="flex h-full w-full items-center justify-center">
<span className="text-sm text-muted-foreground">
</span>
</div>
);
}
const showSummary = config.showSummaryCards !== false;
const showExisting = config.showExistingPlans !== false;
return (
<div className="flex h-full w-full flex-col gap-4 overflow-auto p-4">
{itemGroups.map((group, groupIdx) => (
<div key={group.partCode} className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-primary" />
<span className="text-sm font-semibold">
{group.partName} ({group.partCode})
</span>
</div>
{showSummary && (
<SummaryCards
aggregation={group.aggregation}
visibleCards={config.visibleSummaryCards}
/>
)}
<DetailTable
details={group.details}
groupIdx={groupIdx}
onPlanQtyChange={handlePlanQtyChange}
onPlanDateChange={handlePlanDateChange}
onAddSplit={handleAddSplitRow}
onRemoveSplit={handleRemoveSplitRow}
showExisting={showExisting}
/>
</div>
))}
</div>
);
};
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 (
<div className="flex gap-2">
{cards.map((card) => {
const Icon = card.icon;
return (
<div
key={card.label}
className={`flex flex-1 flex-col items-center rounded-lg border ${card.color.border} ${card.color.bg} px-3 py-2 transition-shadow hover:shadow-sm`}
>
<div className="flex items-center gap-1">
<Icon className={`h-3.5 w-3.5 ${card.color.text}`} />
<span className={`text-xl font-bold ${card.color.text}`}>
{card.value.toLocaleString()}
</span>
</div>
<span className={`mt-0.5 text-[10px] ${card.color.text}`}>
{card.label}
</span>
</div>
);
})}
</div>
);
};
// 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<string, SourceGroup>();
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 (
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="w-14 px-3 py-2 text-left text-xs font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-center text-xs font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-center text-xs font-medium text-muted-foreground">
</th>
<th className="w-16 px-2 py-2 text-center text-xs font-medium text-muted-foreground">
</th>
</tr>
</thead>
<tbody>
{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 (
<tr
key={row.splitKey || `${row.type}-${row.sourceId}-${row.existingPlanId || row._origIdx}`}
className={`hover:bg-muted/30 ${rowIdx < totalRows - 1 ? "" : "border-b"}`}
>
{/* 구분 - 매 행마다 표시 */}
<td className={`px-3 py-2 ${!isFirst ? "border-t border-dashed border-muted" : "border-t"}`}>
{row.type === "existing" ? (
<Badge variant="secondary" className="text-[10px]"></Badge>
) : (
<Badge className="bg-primary text-[10px] text-primary-foreground"></Badge>
)}
</td>
{/* 수주번호, 거래처, 납기일, 미출하 - 첫 행만 rowSpan */}
{isFirst && (
<>
<td className="border-t px-3 py-2 text-xs align-middle" rowSpan={totalRows}>
{sg.orderNo}
</td>
<td className="border-t px-3 py-2 text-xs align-middle" rowSpan={totalRows}>
{sg.partnerName}
</td>
<td className="border-t px-3 py-2 text-xs align-middle" rowSpan={totalRows}>
{sg.dueDate || "-"}
</td>
<td className="border-t px-3 py-2 text-right text-xs font-bold align-middle" rowSpan={totalRows}>
{sg.balanceQty.toLocaleString()}
</td>
</>
)}
{/* 출하계획량 */}
<td className={`px-3 py-2 text-center ${!isFirst ? "border-t border-dashed border-muted" : "border-t"}`}>
{row.type === "existing" ? (
<span className="text-xs text-muted-foreground">
{row.planQty.toLocaleString()}
</span>
) : (
<Input
type="number"
min={0}
max={remaining}
value={row.planQty || ""}
onChange={(e) => 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"
/>
)}
</td>
{/* 출하계획일 */}
<td className={`px-3 py-2 text-center ${!isFirst ? "border-t border-dashed border-muted" : "border-t"}`}>
{row.type === "existing" ? (
<span className="text-xs text-muted-foreground">-</span>
) : (
<Input
type="date"
value={row.planDate || ""}
onChange={(e) => onPlanDateChange(groupIdx, row._origIdx, e.target.value)}
disabled={isDisabled}
className="mx-auto h-7 w-32 text-xs disabled:opacity-40"
/>
)}
</td>
{/* 분할 버튼 */}
<td className={`px-2 py-2 text-center ${!isFirst ? "border-t border-dashed border-muted" : "border-t"}`}>
{row.type === "new" && isFirst && (
<button
type="button"
onClick={() => onAddSplit(groupIdx, row.sourceId)}
disabled={sg.balanceQty <= 0}
className="flex h-6 w-6 items-center justify-center rounded-md text-primary hover:bg-primary/10 disabled:opacity-30 disabled:cursor-not-allowed mx-auto"
title="분할 행 추가"
>
<Plus className="h-3.5 w-3.5" />
</button>
)}
{row.type === "new" && !isFirst && newCount > 1 && (
<button
type="button"
onClick={() => onRemoveSplit(groupIdx, row._origIdx)}
className="flex h-6 w-6 items-center justify-center rounded-md text-destructive hover:bg-destructive/10 mx-auto"
title="분할 행 삭제"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</td>
</tr>
);
});
})}
{sourceGroups.length === 0 && (
<tr>
<td colSpan={8} className="px-3 py-6 text-center text-xs text-muted-foreground">
</td>
</tr>
)}
</tbody>
</table>
</div>
);
};
export const ShippingPlanEditorWrapper: React.FC<
ShippingPlanEditorComponentProps
> = (props) => {
return <ShippingPlanEditorComponent {...props} />;
};

View File

@ -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<string, string> = {
totalBalance: "총수주잔량",
totalPlanQty: "총출하계획량",
currentStock: "현재고",
availableStock: "가용재고",
inProductionQty: "생산중수량",
};
return (
<div className="space-y-4 p-4">
{/* 기본 설정 */}
<div className="text-sm font-semibold text-muted-foreground">
</div>
<div className="space-y-2">
<Label className="text-xs"></Label>
<Input
value={config.title || "출하계획 등록"}
onChange={(e) => handleChange("title", e.target.value)}
placeholder="출하계획 등록"
className="h-8 text-xs"
/>
</div>
<Separator />
{/* 표시 설정 */}
<div className="text-sm font-semibold text-muted-foreground">
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.showSummaryCards !== false}
onCheckedChange={(checked) =>
handleChange("showSummaryCards", checked)
}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.showExistingPlans !== false}
onCheckedChange={(checked) =>
handleChange("showExistingPlans", checked)
}
/>
</div>
{config.showSummaryCards !== false && (
<>
<Separator />
<div className="text-sm font-semibold text-muted-foreground">
</div>
<div className="space-y-2">
{Object.entries(summaryCardLabels).map(([key, label]) => (
<div key={key} className="flex items-center justify-between">
<Label className="text-xs">{label}</Label>
<Switch
checked={summaryCards[key] !== false}
onCheckedChange={(checked) =>
handleSummaryCardToggle(key, checked)
}
/>
</div>
))}
</div>
</>
)}
<Separator />
{/* 저장 설정 */}
<div className="text-sm font-semibold text-muted-foreground">
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.allowOverPlan === true}
onCheckedChange={(checked) =>
handleChange("allowOverPlan", checked)
}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.autoCloseOnSave !== false}
onCheckedChange={(checked) =>
handleChange("autoCloseOnSave", checked)
}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.confirmBeforeSave === true}
onCheckedChange={(checked) =>
handleChange("confirmBeforeSave", checked)
}
/>
</div>
{config.confirmBeforeSave && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Textarea
value={config.confirmMessage || "출하계획을 저장하시겠습니까?"}
onChange={(e) => handleChange("confirmMessage", e.target.value)}
placeholder="출하계획을 저장하시겠습니까?"
className="min-h-[60px] text-xs"
/>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,16 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2ShippingPlanEditorDefinition } from "./index";
import { ShippingPlanEditorComponent } from "./ShippingPlanEditorComponent";
export class ShippingPlanEditorRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2ShippingPlanEditorDefinition;
render(): React.ReactElement {
return <ShippingPlanEditorComponent {...this.props} />;
}
}
ShippingPlanEditorRenderer.registerSelf();

View File

@ -0,0 +1,27 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { ShippingPlanEditorWrapper } from "./ShippingPlanEditorComponent";
import { ShippingPlanEditorConfigPanel } from "./ShippingPlanEditorConfigPanel";
export const V2ShippingPlanEditorDefinition = createComponentDefinition({
id: "v2-shipping-plan-editor",
name: "출하계획 동시등록",
nameEng: "Shipping Plan Editor",
description: "수주 선택 후 품목별 그룹핑하여 출하계획을 일괄 등록하는 컴포넌트",
category: ComponentCategory.DISPLAY,
webType: "text",
component: ShippingPlanEditorWrapper,
configPanel: ShippingPlanEditorConfigPanel,
defaultConfig: {
title: "출하계획 등록",
},
defaultSize: { width: 900, height: 600 },
icon: "Truck",
tags: ["출하", "계획", "수주", "일괄등록", "v2"],
version: "1.0.0",
author: "개발팀",
});
export type { ShippingPlanEditorConfig, ItemGroup, PlanDetailRow } from "./types";

View File

@ -0,0 +1,69 @@
import { ComponentConfig } from "@/types/component";
export interface ShippingPlanEditorConfig extends ComponentConfig {
title?: string;
showSummaryCards?: boolean;
showExistingPlans?: boolean;
allowOverPlan?: boolean;
autoCloseOnSave?: boolean;
confirmBeforeSave?: boolean;
confirmMessage?: string;
visibleSummaryCards?: {
totalBalance?: boolean;
totalPlanQty?: boolean;
currentStock?: boolean;
availableStock?: boolean;
inProductionQty?: boolean;
};
}
// 백엔드에서 정규화해서 내려주는 수주 정보
export interface EnrichedOrder {
sourceId: string;
orderNo: string;
partCode: string;
partName: string;
partnerName: string;
dueDate: string;
orderQty: number;
shipQty: number;
balanceQty: number;
}
export interface ItemAggregation {
totalBalance: number;
totalPlanQty: number;
currentStock: number;
availableStock: number;
inProductionQty: number;
}
export interface ExistingPlan {
id: number;
sourceId: string;
planQty: number;
planDate: string;
shipmentPlanNo: string;
status: string;
}
// 상세 테이블 행 (기존 출하계획 + 신규 입력)
export interface PlanDetailRow {
type: "existing" | "new";
sourceId: string;
orderNo: string;
partnerName: string;
dueDate: string;
balanceQty: number;
planQty: number;
planDate?: string;
existingPlanId?: number;
splitKey?: string;
}
export interface ItemGroup {
partCode: string;
partName: string;
aggregation: ItemAggregation;
details: PlanDetailRow[];
}

View File

@ -35,7 +35,11 @@ interface SingleTableWithStickyProps {
editingValue?: string;
onEditingValueChange?: (value: string) => void;
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
editInputRef?: React.RefObject<HTMLInputElement>;
onEditSave?: () => void;
editInputRef?: React.RefObject<HTMLInputElement | HTMLSelectElement>;
// 인라인 편집 타입별 옵션 (select/category/code, number, date 지원)
columnMeta?: Record<string, { inputType?: string }>;
categoryMappings?: Record<string, Record<string, { label: string }>>;
// 검색 하이라이트 관련 props
searchHighlights?: Set<string>;
currentSearchIndex?: number;
@ -69,7 +73,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
editingValue,
onEditingValueChange,
onEditKeyDown,
onEditSave,
editInputRef,
columnMeta,
categoryMappings,
// 검색 하이라이트 관련 props
searchHighlights,
currentSearchIndex = 0,
@ -102,9 +109,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
}}
>
<TableHeader
className={cn("bg-background border-b", tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm")}
className={cn("border-b border-border/60", tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm")}
style={{ backgroundColor: "hsl(var(--muted) / 0.4)" }}
>
<TableRow className="border-b">
<TableRow className="border-b border-border/60">
{actualColumns.map((column, colIndex) => {
// 왼쪽 고정 컬럼들의 누적 너비 계산
const leftFixedWidth = actualColumns
@ -125,10 +133,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
key={column.columnName}
className={cn(
column.columnName === "__checkbox__"
? "bg-background h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2"
: "text-foreground hover:text-foreground bg-background h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-xs font-semibold whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-sm",
? "h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2"
: "text-muted-foreground hover:text-foreground h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-[10px] font-bold uppercase tracking-[0.04em] whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-xs",
`text-${column.align}`,
column.sortable && "hover:bg-primary/10",
column.sortable && "hover:bg-muted/50",
// 고정 컬럼 스타일
column.fixed === "left" && "border-border bg-background sticky z-40 border-r shadow-sm",
column.fixed === "right" && "border-border bg-background sticky z-40 border-l shadow-sm",
@ -136,28 +144,33 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
)}
style={{
width: getColumnWidth(column),
minWidth: "100px", // 최소 너비 보장
maxWidth: "300px", // 최대 너비 제한
width: column.columnName === "__checkbox__" ? 48 : getColumnWidth(column),
minWidth: column.columnName === "__checkbox__" ? "48px" : "100px",
maxWidth: column.columnName === "__checkbox__" ? "48px" : "300px",
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
backgroundColor: "hsl(var(--background))",
// sticky 위치 설정
whiteSpace: "nowrap",
backgroundColor: "hsl(var(--muted) / 0.4)",
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onClick={() => column.sortable && sortHandler(column.columnName)}
>
<div className="flex items-center gap-2">
<div className={cn("flex items-center", column.columnName === "__checkbox__" ? "justify-center" : "gap-2")}>
{column.columnName === "__checkbox__" ? (
checkboxConfig.selectAll && (
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
style={{ zIndex: 1 }}
style={{
width: 16,
height: 16,
borderWidth: 1.5,
borderColor: isAllSelected ? "hsl(var(--primary))" : "hsl(var(--muted-foreground) / 0.5)",
zIndex: 1,
}}
/>
)
) : (
@ -221,8 +234,9 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
<TableRow
key={`row-${index}`}
className={cn(
"bg-background h-10 cursor-pointer border-b transition-colors",
tableConfig.tableStyle?.hoverEffect && "hover:bg-muted/50",
"cursor-pointer border-b border-border/50 transition-[background] duration-75",
index % 2 === 0 ? "bg-background" : "bg-muted/20",
tableConfig.tableStyle?.hoverEffect !== false && "hover:bg-accent",
)}
onClick={(e) => handleRowClick?.(row, index, e)}
>
@ -266,9 +280,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
highlightArray[currentSearchIndex] === cellKey;
// formatCellValue 결과 (이미지 등 JSX 반환 가능)
const rawCellValue =
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
// 이미지 등 JSX 반환 여부 확인
const formattedValue = formatCellValue(row[column.columnName], column.format, column.columnName, row);
const rawCellValue = (formattedValue === null || formattedValue === undefined || formattedValue === "")
? <span className="text-muted-foreground/50">-</span>
: formattedValue;
const isReactElement = typeof rawCellValue === "object" && React.isValidElement(rawCellValue);
// 셀 값에서 검색어 하이라이트 렌더링
@ -317,26 +332,22 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
key={`cell-${column.columnName}`}
id={isCurrentSearchResult ? "current-search-result" : undefined}
className={cn(
"text-foreground h-10 px-3 py-1.5 align-middle text-xs transition-colors sm:px-4 sm:py-2 sm:text-sm",
// 이미지 셀은 overflow/ellipsis 제외 (이미지 잘림 방지)
"text-foreground h-10 align-middle text-[11px] transition-colors",
column.columnName === "__checkbox__" ? "px-0 py-[7px] text-center" : "px-3 py-[7px]",
!isReactElement && "whitespace-nowrap",
`text-${column.align}`,
// 고정 컬럼 스타일
column.columnName !== "__checkbox__" && `text-${column.align}`,
column.fixed === "left" &&
"border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm",
column.fixed === "right" &&
"border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm",
// 편집 가능 셀 스타일
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
)}
style={{
width: getColumnWidth(column),
minWidth: "100px", // 최소 너비 보장
maxWidth: "300px", // 최대 너비 제한
width: column.columnName === "__checkbox__" ? 48 : getColumnWidth(column),
minWidth: column.columnName === "__checkbox__" ? "48px" : "100px",
maxWidth: column.columnName === "__checkbox__" ? "48px" : "300px",
boxSizing: "border-box",
// 이미지 셀은 overflow 허용
...(isReactElement ? {} : { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }),
// sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
@ -350,15 +361,20 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
{column.columnName === "__checkbox__" ? (
renderCheckboxCell?.(row, index)
) : isEditing ? (
// 인라인 편집 입력 필드
<input
ref={editInputRef}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={() => {
// blur 시 저장 (Enter와 동일)
// 인라인 편집: inputType에 따라 select(category/code), number, date, text
(() => {
const meta = columnMeta?.[column.columnName];
const inputType = meta?.inputType ?? (column as { inputType?: string }).inputType;
const isNumeric = inputType === "number" || inputType === "decimal";
const isCategoryType = inputType === "category" || inputType === "code";
const categoryOptions = categoryMappings?.[column.columnName];
const hasCategoryOptions =
isCategoryType && categoryOptions && Object.keys(categoryOptions).length > 0;
// 인라인 편집: 행 높이 유지를 위해 select/input 모두 h-8(32px) 고정
const commonInputClass =
"border-primary bg-background focus:ring-primary h-8 w-full shrink-0 rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm";
const handleBlurSave = () => {
if (onEditKeyDown) {
const fakeEvent = {
key: "Enter",
@ -366,10 +382,79 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
} as React.KeyboardEvent<HTMLInputElement>;
onEditKeyDown(fakeEvent);
}
}}
className="border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm"
onClick={(e) => e.stopPropagation()}
/>
onEditSave?.();
};
// category/code 타입: select는 반드시 h-8(32px)로 행 높이 유지
if (hasCategoryOptions) {
const selectOptions = Object.entries(categoryOptions).map(([value, info]) => ({
value,
label: info.label,
}));
return (
<select
ref={editInputRef as React.RefObject<HTMLSelectElement>}
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={handleBlurSave}
className={cn(commonInputClass, "h-8")}
onClick={(e) => e.stopPropagation()}
>
<option value=""></option>
{selectOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}
if (inputType === "date" || inputType === "datetime") {
try {
const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker");
return (
<InlineCellDatePicker
value={editingValue ?? ""}
onChange={(v) => onEditingValueChange?.(v)}
onSave={() => {
handleBlurSave();
}}
onKeyDown={onEditKeyDown}
inputRef={editInputRef as React.RefObject<HTMLInputElement>}
/>
);
} catch {
return (
<input
ref={editInputRef as React.RefObject<HTMLInputElement>}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={handleBlurSave}
className={commonInputClass}
onClick={(e) => e.stopPropagation()}
/>
);
}
}
return (
<input
ref={editInputRef as React.RefObject<HTMLInputElement>}
type={isNumeric ? "number" : "text"}
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={handleBlurSave}
className={commonInputClass}
style={isNumeric ? { textAlign: "right" } : undefined}
onClick={(e) => e.stopPropagation()}
/>
);
})()
) : (
renderCellContent()
)}

View File

@ -368,6 +368,7 @@ import {
CheckSquare,
Trash2,
Lock,
GripVertical,
} from "lucide-react";
import * as XLSX from "xlsx";
import { FileText, ChevronRightIcon } from "lucide-react";
@ -1067,10 +1068,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false);
const [showGridLines, setShowGridLines] = useState(true);
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
// 체크박스 컬럼은 항상 기본 틀고정
const [frozenColumns, setFrozenColumns] = useState<string[]>(
(tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [],
);
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
const [frozenColumnCount, setFrozenColumnCount] = useState<number>(0);
// 🆕 Search Panel (통합 검색) 관련 상태
@ -1164,6 +1162,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setSelectedRows(new Set());
setIsAllSelected(false);
},
getEntityJoinColumns: () => {
return (tableConfig.columns || [])
.filter((col) => col.additionalJoinInfo)
.map((col) => ({
sourceTable: col.additionalJoinInfo!.sourceTable || tableConfig.selectedTable,
sourceColumn: col.additionalJoinInfo!.sourceColumn,
joinAlias: col.additionalJoinInfo!.joinAlias,
referenceTable: col.additionalJoinInfo!.referenceTable,
}));
},
};
// DataReceivable 인터페이스 구현
@ -1362,14 +1371,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
frozenColumnCount, // 현재 틀고정 컬럼 수
onFrozenColumnCountChange: (count: number) => {
setFrozenColumnCount(count);
// 체크박스 컬럼은 항상 틀고정에 포함
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [];
// 표시 가능한 컬럼 중 처음 N개를 틀고정 컬럼으로 설정
const visibleCols = columnsToRegister
.filter((col) => col.visible !== false)
.map((col) => col.columnName || col.field);
const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, count)];
setFrozenColumns(newFrozenColumns);
setFrozenColumns(visibleCols.slice(0, count));
},
// 탭 관련 정보 (탭 내부의 테이블인 경우)
parentTabId,
@ -3269,12 +3274,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (state.sortDirection) setSortDirection(state.sortDirection);
if (state.groupByColumns) setGroupByColumns(state.groupByColumns);
if (state.frozenColumns) {
// 체크박스 컬럼이 항상 포함되도록 보장
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? "__checkbox__" : null;
const restoredFrozenColumns =
checkboxColumn && !state.frozenColumns.includes(checkboxColumn)
? [checkboxColumn, ...state.frozenColumns]
: state.frozenColumns;
// 체크박스 컬럼은 frozen 대상에서 제외 (배경색 이중 적용 방지)
const restoredFrozenColumns = (state.frozenColumns || []).filter((col: string) => col !== "__checkbox__");
setFrozenColumns(restoredFrozenColumns);
}
if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); // 틀고정 컬럼 수 복원
@ -4423,7 +4424,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (!tableConfig.checkbox?.selectAll) return null;
if (tableConfig.checkbox?.multiple === false) return null;
return <Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" />;
return (
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
style={{
width: 16,
height: 16,
borderWidth: 1.5,
borderColor: isAllSelected ? "hsl(var(--primary))" : "hsl(var(--muted-foreground) / 0.5)",
}}
/>
);
};
const renderCheckboxCell = (row: any, index: number) => {
@ -4435,6 +4448,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
checked={isChecked}
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)}
aria-label={`${index + 1} 선택`}
style={{
width: 16,
height: 16,
borderWidth: 1.5,
borderColor: isChecked ? "hsl(var(--primary))" : "hsl(var(--muted-foreground) / 0.5)",
}}
/>
);
};
@ -5623,6 +5642,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}}
getColumnWidth={getColumnWidth}
containerWidth={calculatedWidth}
onCellDoubleClick={handleCellDoubleClick}
editingCell={editingCell}
editingValue={editingValue}
onEditingValueChange={setEditingValue}
onEditKeyDown={handleEditKeyDown}
onEditSave={saveEditing}
editInputRef={editInputRef}
columnMeta={columnMeta}
categoryMappings={categoryMappings}
/>
</div>
@ -5827,7 +5855,41 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
)}
</div>
{/* 🆕 배치 편집 툴바 */}
{/* 필터 칩 바 */}
{filterGroups.length > 0 && filterGroups.some(g => g.conditions.some(c => c.column && c.value)) && (
<div className="border-border bg-muted/30 flex items-center gap-2 border-b px-4 py-1.5">
{filterGroups.flatMap(group =>
group.conditions
.filter(c => c.column && c.value)
.map(condition => {
const label = columnLabels[condition.column] || condition.column;
const opLabel = condition.operator === "equals" ? "=" : condition.operator === "contains" ? "⊃" : condition.operator === "notEquals" ? "≠" : condition.operator === "startsWith" ? "^" : condition.operator === "endsWith" ? "$" : condition.operator === "greaterThan" ? ">" : condition.operator === "lessThan" ? "<" : condition.operator;
return (
<span
key={condition.id}
className="border-border bg-background text-muted-foreground inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-[10px] font-semibold"
>
{label} {opLabel} {condition.value}
<button
onClick={() => removeFilterCondition(group.id, condition.id)}
className="hover:text-destructive ml-0.5 leading-none transition-colors"
>
<X className="h-3 w-3" />
</button>
</span>
);
})
)}
<button
onClick={clearFilterBuilder}
className="text-muted-foreground hover:text-foreground ml-auto text-[9px] font-semibold transition-colors"
>
</button>
</div>
)}
{/* 배치 편집 툴바 */}
{(editMode === "batch" || pendingChanges.size > 0) && (
<div className="border-border flex items-center justify-between border-b bg-amber-50 px-4 py-2 sm:px-6 dark:bg-amber-950/30">
<div className="flex items-center gap-3 text-xs sm:text-sm">
@ -5935,8 +5997,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 🆕 Multi-Level Headers (Column Bands) */}
{columnBandsInfo?.hasBands && (
<tr
className="border-primary/10 bg-muted/70 h-8 border-b sm:h-10"
style={{ backgroundColor: "hsl(var(--muted) / 0.7)" }}
className="border-border/60 bg-muted/40 h-8 border-b sm:h-10"
style={{ backgroundColor: "hsl(var(--muted) / 0.4)" }}
>
{visibleColumns.map((column, colIdx) => {
// 이 컬럼이 속한 band 찾기
@ -5977,9 +6039,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</tr>
)}
<tr
className="border-primary/20 bg-muted h-10 border-b-2 sm:h-12"
className="bg-muted/80 h-10 border-b border-border/60 sm:h-12"
style={{
backgroundColor: "hsl(var(--muted))",
backgroundColor: "hsl(var(--muted) / 0.4)",
}}
>
{visibleColumns.map((column, columnIndex) => {
@ -6007,11 +6069,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
key={column.columnName}
ref={(el) => (columnRefs.current[column.columnName] = el)}
className={cn(
"text-foreground/90 relative h-8 overflow-hidden text-xs font-bold text-ellipsis whitespace-nowrap select-none sm:h-10 sm:text-sm",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
"group text-muted-foreground relative h-8 overflow-hidden text-[10px] font-bold uppercase tracking-[0.04em] text-ellipsis whitespace-nowrap select-none sm:h-10 sm:text-xs",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-3 py-2",
column.sortable !== false &&
column.columnName !== "__checkbox__" &&
"hover:bg-muted/70 cursor-pointer transition-colors",
"hover:text-foreground hover:bg-muted/50 cursor-pointer transition-colors",
sortColumn === column.columnName && "!text-primary",
isFrozen && "sticky z-40 shadow-[2px_0_4px_rgba(0,0,0,0.1)]",
// 🆕 Column Reordering 스타일
isColumnDragEnabled &&
@ -6031,7 +6094,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
userSelect: "none",
backgroundColor: "hsl(var(--muted))",
backgroundColor: "hsl(var(--muted) / 0.4)",
...(isFrozen && { left: `${leftPosition}px` }),
}}
// 🆕 Column Reordering 이벤트
@ -6051,9 +6114,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
renderCheckboxHeader()
) : (
<div style={{ display: "flex", alignItems: "center", gap: "4px", justifyContent: "center" }}>
{isColumnDragEnabled && (
<GripVertical className="absolute left-0.5 top-1/2 h-3 w-3 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-40" />
)}
<span>{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable !== false && sortColumn === column.columnName && (
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
<span className="text-primary">{sortDirection === "asc" ? "↑" : "↓"}</span>
)}
{/* 🆕 헤더 필터 버튼 */}
{tableConfig.headerFilter !== false &&
@ -6278,7 +6344,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<tr
key={index}
className={cn(
"bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors",
"hover:bg-accent cursor-pointer border-b border-border/50 transition-[background] duration-75",
index % 2 === 0 ? "bg-background" : "bg-muted/20",
)}
onClick={(e) => handleRowClick(row, index, e)}
>
@ -6309,13 +6376,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<td
key={column.columnName}
className={cn(
"text-foreground text-xs font-normal sm:text-sm",
// 이미지 컬럼은 overflow/ellipsis 제외 (이미지 잘림 방지)
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap",
"text-foreground text-[11px] font-normal",
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap max-w-[170px]",
column.columnName === "__checkbox__"
? "px-0 py-1"
: "px-2 py-1 sm:px-4 sm:py-1.5",
? "px-0 py-[7px] text-center"
: "px-3 py-[7px]",
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
(inputType === "code" || inputType === "category") && "font-mono text-[10px] text-primary font-medium",
isNumeric && "tabular-nums",
)}
style={{
textAlign:
@ -6330,16 +6398,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
: `${100 / visibleColumns.length}%`,
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
...(isFrozen && {
left: `${leftPosition}px`,
backgroundColor: "hsl(var(--background))",
}),
}}
>
...(isFrozen && {
left: `${leftPosition}px`,
backgroundColor: index % 2 === 0
? "hsl(var(--background))"
: "hsl(var(--muted) / 0.2)",
}),
}}
>
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(cellValue, column, row)}
</td>
</td>
);
})}
</tr>
@ -6415,10 +6485,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<tr
key={index}
className={cn(
"bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors",
isRowSelected && "bg-primary/10 hover:bg-primary/15",
"hover:bg-accent cursor-pointer border-b border-border/50 transition-[background] duration-75",
index % 2 === 0 ? "bg-background" : "bg-muted/20",
isRowSelected && "!bg-primary/10 hover:!bg-primary/15",
isRowFocused && "ring-primary/50 ring-1 ring-inset",
// 🆕 Drag & Drop 스타일
isDragEnabled && "cursor-grab active:cursor-grabbing",
isDragging && "bg-muted opacity-50",
isDropTarget && "border-t-primary border-t-2",
@ -6478,23 +6548,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
data-row={index}
data-col={colIndex}
className={cn(
"text-foreground text-xs font-normal sm:text-sm",
// 이미지 컬럼은 overflow/ellipsis 제외 (이미지 잘림 방지)
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
"text-foreground text-[11px] font-normal",
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap max-w-[170px]",
column.columnName === "__checkbox__" ? "px-0 py-[7px] text-center" : "px-3 py-[7px]",
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
// 🆕 포커스된 셀 스타일
isCellFocused && !editingCell && "ring-primary bg-primary/5 ring-2 ring-inset",
// 🆕 편집 중인 셀 스타일
editingCell?.rowIndex === index && editingCell?.colIndex === colIndex && "p-0",
// 🆕 배치 편집: 수정된 셀 스타일 (노란 배경)
isModified && !cellValidationError && "bg-amber-100 dark:bg-amber-900/40",
// 🆕 유효성 에러: 빨간 테두리 및 배경
cellValidationError && "bg-red-50 ring-2 ring-red-500 ring-inset dark:bg-red-950/40",
// 🆕 검색 하이라이트 스타일 (노란 배경)
isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50",
// 🆕 편집 불가 컬럼 스타일 (연한 회색 배경)
column.editable === false && "bg-gray-50 dark:bg-gray-900/30",
column.editable === false && "bg-muted/10 dark:bg-muted/10",
// 코드 컬럼: mono 폰트 + primary 색상
(inputType === "code" || inputType === "category") && "font-mono text-[10px] text-primary font-medium",
// 숫자 컬럼: tabular-nums 오른쪽 정렬
isNumeric && "tabular-nums",
)}
// 🆕 유효성 에러 툴팁
title={cellValidationError || undefined}
@ -6511,7 +6578,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
...(isFrozen && {
left: `${leftPosition}px`,
backgroundColor: "hsl(var(--background))",
backgroundColor: index % 2 === 0
? "hsl(var(--background))"
: "hsl(var(--muted) / 0.2)",
}),
}}
onClick={(e) => handleCellClick(index, colIndex, e)}
@ -6570,7 +6639,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onChange={(e) => setEditingValue(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={saveEditing}
className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
className="border-primary bg-background h-8 w-full shrink-0 border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
autoFocus
>
<option value=""></option>
@ -6598,7 +6667,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
);
}
// 일반 입력 필드
// 일반 입력 필드 (행 높이 유지: h-8 고정)
return (
<input
ref={editInputRef}
@ -6607,7 +6676,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onChange={(e) => setEditingValue(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={saveEditing}
className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
className="border-primary bg-background h-8 w-full shrink-0 border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
style={{
textAlign: isNumeric ? "right" : column.align || "left",
}}
@ -6616,7 +6685,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
})()
: column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(cellValue, column, row)}
: (cellValue === null || cellValue === undefined || cellValue === "")
? <span className="text-muted-foreground/50">-</span>
: formatCellValue(cellValue, column, row)}
</td>
);
})}
@ -6676,7 +6747,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
: undefined,
...(isFrozen && {
left: `${leftPosition}px`,
backgroundColor: "hsl(var(--muted) / 0.8)",
backgroundColor: "hsl(var(--muted) / 0.4)",
}),
}}
>

View File

@ -48,7 +48,7 @@ const TabsDesignEditor: React.FC<{
return cn(
"px-4 py-2 text-sm font-medium cursor-pointer transition-colors",
isActive
? "bg-background border-b-2 border-primary text-primary"
? "bg-primary/20 dark:bg-primary/25 border-b-2 border-primary text-primary font-semibold"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
);
};
@ -283,7 +283,7 @@ const TabsDesignEditor: React.FC<{
return (
<div className="flex h-full w-full flex-col overflow-hidden rounded-lg border bg-background">
{/* 탭 헤더 */}
<div className="flex items-center border-b bg-muted/30">
<div className="flex items-center border-b bg-muted/50">
{tabs.length > 0 ? (
tabs.map((tab) => (
<div
@ -649,8 +649,8 @@ ComponentRegistry.registerComponent({
return cn(
"px-4 py-2 text-sm font-medium cursor-pointer transition-colors",
isActive
? "bg-background border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
? "bg-primary/10 border-b-2 border-primary text-primary font-semibold"
: "text-foreground/70 hover:text-foreground hover:bg-muted/50"
);
};
@ -662,7 +662,7 @@ ComponentRegistry.registerComponent({
onDragEnd={onDragEnd}
>
{/* 탭 헤더 */}
<div className="flex items-center border-b bg-muted/30">
<div className="flex items-center border-b bg-muted/50">
{tabs.length > 0 ? (
tabs.map((tab) => (
<div

View File

@ -542,15 +542,6 @@ export class ButtonActionExecutor {
this.saveCallCount++;
const callId = this.saveCallCount;
// 🔧 디버그: context.formData 확인 (handleSave 진입 시점)
console.log("🔍 [handleSave] 진입 시 context.formData:", {
keys: Object.keys(context.formData || {}),
hasCompanyImage: "company_image" in (context.formData || {}),
hasCompanyLogo: "company_logo" in (context.formData || {}),
companyImageValue: context.formData?.company_image,
companyLogoValue: context.formData?.company_logo,
});
const { formData, originalData, tableName, screenId, onSave } = context;
// 🆕 중복 호출 방지: 같은 screenId + tableName + formData 조합으로 2초 내 재호출 시 무시
@ -621,6 +612,18 @@ export class ButtonActionExecutor {
if (onSave) {
try {
await onSave();
// 모달 저장 후에도 제어관리 실행 (onSave 경로에서도 dataflow 지원)
if (config.enableDataflowControl && config.dataflowConfig) {
console.log("📦 [handleSave] onSave 콜백 후 제어관리 실행 시작");
const contextWithSavedData = {
...context,
savedData: context.formData,
selectedRowsData: context.selectedRowsData || [],
};
await this.executeAfterSaveControl(config, contextWithSavedData);
}
return true;
} catch (error: any) {
console.error("❌ [handleSave] onSave 콜백 실행 오류:", error);
@ -636,13 +639,6 @@ export class ButtonActionExecutor {
console.log("⚠️ [handleSave] onSave 콜백 없음 - 기본 저장 로직 실행");
// 🔧 디버그: beforeFormSave 이벤트 후 formData 확인
console.log("🔍 [handleSave] beforeFormSave 이벤트 후:", {
keys: Object.keys(context.formData || {}),
hasCompanyImage: "company_image" in (context.formData || {}),
companyImageValue: context.formData?.company_image,
});
// skipDefaultSave 플래그 확인
if (beforeSaveEventDetail.skipDefaultSave) {
return true;
@ -749,11 +745,6 @@ export class ButtonActionExecutor {
return await this.handleBatchSave(config, context, selectedItemsKeys);
} else {
console.log("⚠️ [handleSave] SelectedItemsDetailInput 데이터 감지 실패 - 일반 저장 진행");
// 🔧 디버그: formData 상세 확인
console.log("🔍 [handleSave] formData 키 목록:", Object.keys(context.formData || {}));
console.log("🔍 [handleSave] formData.company_image:", context.formData?.company_image);
console.log("🔍 [handleSave] formData.company_logo:", context.formData?.company_logo);
console.log("⚠️ [handleSave] formData 전체 내용:", context.formData);
}
// 🆕 RepeaterFieldGroup JSON 문자열 파싱 및 저장 처리
@ -1011,6 +1002,9 @@ export class ButtonActionExecutor {
// saveResult를 상위 스코프에서 정의 (repeaterSave 이벤트에서 사용)
let saveResult: { success: boolean; data?: any; message?: string } | undefined;
// 제어 실행 데이터를 상위 스코프에서 정의 (리피터 저장 완료 후 실행 위해)
let pendingDataflowControl: { config: ButtonActionConfig; context: ButtonActionContext } | null = null;
if (tableName && screenId) {
// DB에서 실제 기본키 조회하여 INSERT/UPDATE 자동 판단
const primaryKeyResult = await DynamicFormApi.getTablePrimaryKeys(tableName);
@ -1758,25 +1752,20 @@ export class ButtonActionExecutor {
return false;
}
// 🔥 저장 성공 후 연결된 제어 실행 (dataflowTiming이 'after'인 경우)
// 제어 실행 준비 (실제 실행은 리피터 저장 완료 후로 지연)
console.log("🔍 [handleSave] 제어관리 설정 체크:", {
enableDataflowControl: config.enableDataflowControl,
hasDataflowConfig: !!config.dataflowConfig,
flowControls: config.dataflowConfig?.flowControls?.length || 0,
});
if (config.enableDataflowControl && config.dataflowConfig) {
// 테이블 섹션 데이터 파싱 (comp_로 시작하는 필드에 JSON 배열이 있는 경우)
// 입고 화면 등에서 품목 목록이 comp_xxx 필드에 JSON 문자열로 저장됨
// 🔧 수정: saveResult.data가 3단계로 중첩된 경우 실제 폼 데이터 추출
// saveResult.data = API 응답 { success, data, message }
// saveResult.data.data = 저장된 레코드 { id, screenId, tableName, data, createdAt... }
// saveResult.data.data.data = 실제 폼 데이터 { sabun, user_name... }
const savedRecord = saveResult?.data?.data || saveResult?.data || {};
const actualFormData = savedRecord?.data || savedRecord;
const formData: Record<string, any> = (
Object.keys(actualFormData).length > 0 ? actualFormData : context.formData || {}
) as Record<string, any>;
console.log("📦 [executeAfterSaveControl] savedRecord 구조:", Object.keys(savedRecord));
console.log("📦 [executeAfterSaveControl] actualFormData 추출:", Object.keys(formData));
console.log("📦 [executeAfterSaveControl] formData.sabun:", formData.sabun);
let parsedSectionData: any[] = [];
// comp_로 시작하는 필드에서 테이블 섹션 데이터 찾기
const compFieldKey = Object.keys(formData).find(
(key) => key.startsWith("comp_") && typeof formData[key] === "string",
);
@ -1785,11 +1774,8 @@ export class ButtonActionExecutor {
try {
const sectionData = JSON.parse(formData[compFieldKey]);
if (Array.isArray(sectionData) && sectionData.length > 0) {
// 공통 필드와 섹션 데이터 병합
parsedSectionData = sectionData.map((item: any) => {
// 섹션 데이터에서 불필요한 내부 필드 제거
const { _isNewItem, _targetTable, _existingRecord, ...cleanItem } = item;
// 공통 필드(comp_ 필드 제외) + 섹션 아이템 병합
const commonFields: Record<string, any> = {};
Object.keys(formData).forEach((key) => {
if (!key.startsWith("comp_") && !key.endsWith("_numberingRuleId")) {
@ -1804,14 +1790,14 @@ export class ButtonActionExecutor {
}
}
// 저장된 데이터를 context에 추가하여 플로우에 전달
const contextWithSavedData = {
...context,
savedData: formData,
// 파싱된 섹션 데이터가 있으면 selectedRowsData로 전달
selectedRowsData: parsedSectionData.length > 0 ? parsedSectionData : context.selectedRowsData,
pendingDataflowControl = {
config,
context: {
...context,
savedData: formData,
selectedRowsData: parsedSectionData.length > 0 ? parsedSectionData : context.selectedRowsData,
},
};
await this.executeAfterSaveControl(config, contextWithSavedData);
}
} else {
throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
@ -1935,13 +1921,26 @@ export class ButtonActionExecutor {
await repeaterSavePromise;
}
// 리피터 저장 완료 후 제어관리 실행 (디테일 레코드가 DB에 있는 상태에서 실행)
console.log("🔍 [handleSave] 리피터 저장 완료, pendingDataflowControl:", !!pendingDataflowControl);
if (pendingDataflowControl) {
console.log("📦 [handleSave] 리피터 저장 완료 후 제어관리 실행 시작");
await this.executeAfterSaveControl(
pendingDataflowControl.config,
pendingDataflowControl.context,
);
}
// 테이블과 플로우 새로고침 (모달 닫기 전에 실행)
context.onRefresh?.();
context.onFlowRefresh?.();
// 저장 성공 후 모달 닫기 이벤트 발생
window.dispatchEvent(new CustomEvent("closeEditModal"));
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
// 저장 성공 후 모달 닫기 이벤트 발생 (모달 내부에서만)
// 비모달 화면에서 이 이벤트를 발행하면 ScreenModal이 반응하여 컴포넌트 트리 재마운트 발생
if (context.onClose) {
window.dispatchEvent(new CustomEvent("closeEditModal"));
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
}
return true;
} catch (error: any) {

View File

@ -8,21 +8,27 @@ import type {
Condition,
TransformFunction,
} from "@/types/screen-embedding";
import type { EntityJoinColumnMeta } from "@/types/data-transfer";
import { logger } from "./logger";
/**
*
* @param data
* @param rules
* @param entityJoinColumns () - sourceField alias에서
* @returns
*/
export function applyMappingRules(data: any[] | any, rules: MappingRule[]): any[] {
export function applyMappingRules(
data: any[] | any,
rules: MappingRule[],
entityJoinColumns?: EntityJoinColumnMeta[],
): any[] {
// 빈 데이터 처리
if (!data) {
return [];
}
// 🆕 배열이 아닌 경우 배열로 변환
// 배열이 아닌 경우 배열로 변환
const dataArray = Array.isArray(data) ? data : [data];
if (dataArray.length === 0) {
@ -42,22 +48,34 @@ export function applyMappingRules(data: any[] | any, rules: MappingRule[]): any[
return [applyTransformRules(dataArray, rules)];
}
// 엔티티 조인 alias 역방향 맵 구성: { referenceColumn → joinAlias }
// ex) joinAlias "part_code_item_name" → sourceColumn "part_code", referenceColumn "item_name"
const joinAliasMap = buildJoinAliasMap(entityJoinColumns);
// 일반 매핑 (각 행에 대해 매핑)
// 🆕 원본 데이터를 복사한 후 매핑 규칙 적용 (매핑되지 않은 필드도 유지)
// 원본 데이터를 복사한 후 매핑 규칙 적용 (매핑되지 않은 필드도 유지)
return dataArray.map((row) => {
// 원본 데이터 복사
const mappedRow: any = { ...row };
for (const rule of rules) {
// sourceField와 targetField가 모두 있어야 매핑 적용
if (!rule.sourceField || !rule.targetField) {
continue;
}
const sourceValue = getNestedValue(row, rule.sourceField);
let sourceValue = getNestedValue(row, rule.sourceField);
// sourceField 값이 비어있으면 엔티티 조인 alias에서 해결 시도
if (isEmptyValue(sourceValue) && joinAliasMap.size > 0) {
sourceValue = resolveFromEntityJoin(row, rule.targetField, joinAliasMap);
if (sourceValue !== undefined) {
logger.info(
`[dataMapping] 엔티티 조인 해결: ${rule.sourceField}(비어있음) → targetField "${rule.targetField}" → alias에서 값 획득`,
);
}
}
const targetValue = sourceValue ?? rule.defaultValue;
// 소스 필드와 타겟 필드가 다르면 소스 필드 제거 후 타겟 필드에 설정
if (rule.sourceField !== rule.targetField) {
delete mappedRow[rule.sourceField];
}
@ -69,6 +87,50 @@ export function applyMappingRules(data: any[] | any, rules: MappingRule[]): any[
});
}
/**
* alias에서
* joinAlias : {sourceColumn}_{referenceColumn}
* : "part_code_item_name" sourceColumn="part_code", referenceColumn="item_name"
*
* Map: referenceColumn joinAlias
* : "item_name" "part_code_item_name"
*/
function buildJoinAliasMap(
entityJoinColumns?: EntityJoinColumnMeta[],
): Map<string, string> {
const map = new Map<string, string>();
if (!entityJoinColumns || entityJoinColumns.length === 0) return map;
for (const meta of entityJoinColumns) {
const prefix = `${meta.sourceColumn}_`;
if (meta.joinAlias.startsWith(prefix)) {
const referenceColumn = meta.joinAlias.slice(prefix.length);
map.set(referenceColumn, meta.joinAlias);
}
}
return map;
}
/**
* alias에서 targetField에
* targetField alias를 row에서
*/
function resolveFromEntityJoin(
row: any,
targetField: string,
joinAliasMap: Map<string, string>,
): any {
const joinAlias = joinAliasMap.get(targetField);
if (!joinAlias) return undefined;
const value = row[joinAlias];
return isEmptyValue(value) ? undefined : value;
}
function isEmptyValue(value: any): boolean {
return value === null || value === undefined || value === "";
}
/**
*
*/

View File

@ -54,6 +54,9 @@ export const V2_EVENTS = {
RELATED_BUTTON_UNREGISTER: "v2:related-button:unregister",
RELATED_BUTTON_SELECT: "v2:related-button:select",
// 출하계획 저장
SHIPPING_PLAN_SAVE: "v2:shipping-plan:save",
// 스케줄 자동 생성
SCHEDULE_GENERATE_REQUEST: "v2:schedule:generate:request",
SCHEDULE_GENERATE_PREVIEW: "v2:schedule:generate:preview",
@ -237,6 +240,15 @@ export interface V2RelatedButtonSelectEvent {
selectedData: any[];
}
// ============================================================================
// 출하계획 저장 이벤트
// ============================================================================
/** 출하계획 저장 요청 이벤트 */
export interface V2ShippingPlanSaveEvent {
requestId: string;
}
// ============================================================================
// 스케줄 자동 생성 이벤트
// ============================================================================
@ -334,6 +346,8 @@ export interface V2EventPayloadMap {
[V2_EVENTS.RELATED_BUTTON_UNREGISTER]: V2RelatedButtonUnregisterEvent;
[V2_EVENTS.RELATED_BUTTON_SELECT]: V2RelatedButtonSelectEvent;
[V2_EVENTS.SHIPPING_PLAN_SAVE]: V2ShippingPlanSaveEvent;
[V2_EVENTS.SCHEDULE_GENERATE_REQUEST]: V2ScheduleGenerateRequestEvent;
[V2_EVENTS.SCHEDULE_GENERATE_PREVIEW]: V2ScheduleGeneratePreviewEvent;
[V2_EVENTS.SCHEDULE_GENERATE_APPLY]: V2ScheduleGenerateApplyEvent;

View File

@ -157,6 +157,17 @@ export interface DataReceivable {
getData(): any;
}
/**
*
* FK가
*/
export interface EntityJoinColumnMeta {
sourceColumn: string;
joinAlias: string;
referenceTable: string;
sourceTable?: string;
}
/**
*
*
@ -180,5 +191,11 @@ export interface DataProvidable {
*
*/
clearSelection(): void;
/**
* ()
* alias
*/
getEntityJoinColumns?(): EntityJoinColumnMeta[];
}