Merge pull request 'jskim-node' (#421) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/421
This commit is contained in:
commit
8e228bbf12
|
|
@ -191,3 +191,7 @@ mcp-task-queue/
|
||||||
.cursor/rules/multi-agent-tester.mdc
|
.cursor/rules/multi-agent-tester.mdc
|
||||||
.cursor/rules/multi-agent-reviewer.mdc
|
.cursor/rules/multi-agent-reviewer.mdc
|
||||||
.cursor/rules/multi-agent-knowledge.mdc
|
.cursor/rules/multi-agent-knowledge.mdc
|
||||||
|
|
||||||
|
# 파이프라인 회고록 (자동 생성)
|
||||||
|
docs/retrospectives/
|
||||||
|
mes-architecture-guide.md
|
||||||
|
|
@ -143,6 +143,7 @@ import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; //
|
||||||
import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스)
|
import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스)
|
||||||
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
|
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
|
||||||
import moldRoutes from "./routes/moldRoutes"; // 금형 관리
|
import moldRoutes from "./routes/moldRoutes"; // 금형 관리
|
||||||
|
import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리
|
||||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
// 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/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
|
||||||
app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
|
app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
|
||||||
app.use("/api/mold", moldRoutes); // 금형 관리
|
app.use("/api/mold", moldRoutes); // 금형 관리
|
||||||
|
app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리
|
||||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||||
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
||||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -952,13 +952,20 @@ export class NodeFlowExecutionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const schemaPrefix = schema ? `${schema}.` : "";
|
const schemaPrefix = schema ? `${schema}.` : "";
|
||||||
|
|
||||||
|
// WHERE 조건에서 field 값 조회를 위해 컨텍스트 데이터 전달
|
||||||
|
// sourceData(저장된 폼 데이터) + buttonContext(인증 정보) 병합
|
||||||
|
const contextForWhere = {
|
||||||
|
...(context.buttonContext || {}),
|
||||||
|
...(context.sourceData?.[0] || {}),
|
||||||
|
};
|
||||||
const whereResult = whereConditions
|
const whereResult = whereConditions
|
||||||
? this.buildWhereClause(whereConditions)
|
? this.buildWhereClause(whereConditions, contextForWhere)
|
||||||
: { clause: "", values: [] };
|
: { clause: "", values: [] };
|
||||||
|
|
||||||
const sql = `SELECT * FROM ${schemaPrefix}${tableName} ${whereResult.clause}`;
|
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);
|
const result = await query(sql, whereResult.values);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) 필터링 확인
|
||||||
|
|
@ -217,10 +217,16 @@ export default function TableManagementPage() {
|
||||||
// 메모이제이션된 입력타입 옵션
|
// 메모이제이션된 입력타입 옵션
|
||||||
const memoizedInputTypeOptions = useMemo(() => inputTypeOptions, []);
|
const memoizedInputTypeOptions = useMemo(() => inputTypeOptions, []);
|
||||||
|
|
||||||
// 참조 테이블 옵션 (실제 테이블 목록에서 가져옴)
|
// 참조 테이블 옵션 (한글라벨 (영어명) 동시 표시)
|
||||||
const referenceTableOptions = [
|
const referenceTableOptions = [
|
||||||
{ value: "none", label: getTextFromUI(TABLE_MANAGEMENT_KEYS.LABEL_NONE, "선택 안함") },
|
{ 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}
|
selectedColumn={selectedColumn}
|
||||||
onSelectColumn={setSelectedColumn}
|
onSelectColumn={setSelectedColumn}
|
||||||
onColumnChange={(columnName, field, value) => {
|
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);
|
const idx = columns.findIndex((c) => c.columnName === columnName);
|
||||||
if (idx >= 0) handleColumnChange(idx, field, value);
|
if (idx >= 0) handleColumnChange(idx, field, value);
|
||||||
}}
|
}}
|
||||||
|
|
@ -1596,6 +1616,8 @@ export default function TableManagementPage() {
|
||||||
onIndexToggle={(columnName, checked) =>
|
onIndexToggle={(columnName, checked) =>
|
||||||
handleIndexToggle(columnName, "index", 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>
|
<p className="text-sm font-medium">변경될 PK 컬럼:</p>
|
||||||
{pendingPkColumns.length > 0 ? (
|
{pendingPkColumns.length > 0 ? (
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
{pendingPkColumns.map((col) => (
|
{pendingPkColumns.map((col) => {
|
||||||
<Badge key={col} variant="secondary" className="font-mono text-xs">
|
const colInfo = columns.find((c) => c.columnName === col);
|
||||||
{col}
|
return (
|
||||||
</Badge>
|
<Badge key={col} variant="secondary" className="text-xs">
|
||||||
))}
|
{colInfo?.displayName && colInfo.displayName !== col
|
||||||
|
? `${colInfo.displayName} (${col})`
|
||||||
|
: col}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-destructive mt-2 text-sm">PK가 모두 제거됩니다</p>
|
<p className="text-destructive mt-2 text-sm">PK가 모두 제거됩니다</p>
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,8 @@ export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProp
|
||||||
? "bg-accent/50 font-semibold"
|
? "bg-accent/50 font-semibold"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
|
role="button"
|
||||||
|
aria-label={`${company.company_name} ${company.company_code}`}
|
||||||
onClick={() => handleCompanySwitch(company.company_code)}
|
onClick={() => handleCompanySwitch(company.company_code)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
|
|
||||||
|
|
@ -76,9 +76,34 @@ export function ColumnDetailPanel({
|
||||||
|
|
||||||
if (!column) return null;
|
if (!column) return null;
|
||||||
|
|
||||||
const refTableOpts = referenceTableOptions.length
|
const refTableOpts = useMemo(() => {
|
||||||
? referenceTableOptions
|
const hasKorean = (s: string) => /[가-힣]/.test(s);
|
||||||
: [{ value: "none", label: "선택 안함" }, ...tables.map((t) => ({ value: t.tableName, label: t.displayName || t.tableName }))];
|
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 (
|
return (
|
||||||
<div className="flex h-full w-full flex-col border-l bg-card">
|
<div className="flex h-full w-full flex-col border-l bg-card">
|
||||||
|
|
@ -90,7 +115,11 @@ export function ColumnDetailPanel({
|
||||||
{typeConf.label}
|
{typeConf.label}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={onClose} aria-label="닫기">
|
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={onClose} aria-label="닫기">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
|
|
@ -170,23 +199,33 @@ export function ColumnDetailPanel({
|
||||||
<CommandList className="max-h-[200px]">
|
<CommandList className="max-h-[200px]">
|
||||||
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{refTableOpts.map((opt) => (
|
{refTableOpts.map((opt) => {
|
||||||
<CommandItem
|
const hasKorean = opt.value !== "none" && opt.label !== opt.value && !opt.label.startsWith(opt.value);
|
||||||
key={opt.value}
|
return (
|
||||||
value={`${opt.label} ${opt.value}`}
|
<CommandItem
|
||||||
onSelect={() => {
|
key={opt.value}
|
||||||
onColumnChange("referenceTable", opt.value === "none" ? undefined : opt.value);
|
value={`${opt.label} ${opt.value}`}
|
||||||
if (opt.value !== "none") onLoadReferenceColumns?.(opt.value);
|
onSelect={() => {
|
||||||
setEntityTableOpen(false);
|
onColumnChange("referenceTable", opt.value === "none" ? undefined : opt.value);
|
||||||
}}
|
if (opt.value !== "none") onLoadReferenceColumns?.(opt.value);
|
||||||
className="text-xs"
|
setEntityTableOpen(false);
|
||||||
>
|
}}
|
||||||
<Check
|
className="text-xs"
|
||||||
className={cn("mr-2 h-3 w-3", column.referenceTable === opt.value ? "opacity-100" : "opacity-0")}
|
>
|
||||||
/>
|
<Check
|
||||||
{opt.label}
|
className={cn("mr-2 h-3 w-3", column.referenceTable === opt.value ? "opacity-100" : "opacity-0")}
|
||||||
</CommandItem>
|
/>
|
||||||
))}
|
{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>
|
</CommandGroup>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
|
|
@ -207,7 +246,12 @@ export function ColumnDetailPanel({
|
||||||
className="h-9 w-full justify-between text-xs"
|
className="h-9 w-full justify-between text-xs"
|
||||||
>
|
>
|
||||||
{column.referenceColumn && column.referenceColumn !== "none"
|
{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" />
|
<ChevronsUpDown className="ml-2 h-3 w-3 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -245,7 +289,14 @@ export function ColumnDetailPanel({
|
||||||
column.referenceColumn === refCol.columnName ? "opacity-100" : "opacity-0",
|
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>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
@ -259,12 +310,20 @@ export function ColumnDetailPanel({
|
||||||
{/* 참조 요약 미니맵 */}
|
{/* 참조 요약 미니맵 */}
|
||||||
{column.referenceTable && column.referenceTable !== "none" && column.referenceColumn && (
|
{column.referenceTable && column.referenceTable !== "none" && column.referenceColumn && (
|
||||||
<div className="flex items-center gap-2 rounded-md bg-violet-50 px-3 py-2">
|
<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">
|
<span className="text-[11px] font-semibold text-violet-600">
|
||||||
{column.referenceTable}
|
{(() => {
|
||||||
|
const tbl = refTableOpts.find((o) => o.value === column.referenceTable);
|
||||||
|
return tbl?.label ?? column.referenceTable;
|
||||||
|
})()}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground text-[10px]">→</span>
|
<span className="text-muted-foreground text-[10px]">→</span>
|
||||||
<span className="font-mono text-[11px] font-semibold text-violet-600">
|
<span className="text-[11px] font-semibold text-violet-600">
|
||||||
{column.referenceColumn}
|
{(() => {
|
||||||
|
const col = refColumns.find((c) => c.columnName === column.referenceColumn);
|
||||||
|
return col?.displayName && col.displayName !== column.referenceColumn
|
||||||
|
? `${col.displayName} (${column.referenceColumn})`
|
||||||
|
: column.referenceColumn;
|
||||||
|
})()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@ import { MoreHorizontal, Database, Layers, FileStack } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ColumnTypeInfo } from "./types";
|
import type { ColumnTypeInfo, TableInfo } from "./types";
|
||||||
import { INPUT_TYPE_COLORS, getColumnGroup } from "./types";
|
import { INPUT_TYPE_COLORS, getColumnGroup } from "./types";
|
||||||
|
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||||
|
|
||||||
export interface ColumnGridConstraints {
|
export interface ColumnGridConstraints {
|
||||||
primaryKey: { columns: string[] };
|
primaryKey: { columns: string[] };
|
||||||
|
|
@ -23,6 +24,9 @@ export interface ColumnGridProps {
|
||||||
getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean };
|
getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean };
|
||||||
onPkToggle?: (columnName: string, checked: boolean) => void;
|
onPkToggle?: (columnName: string, checked: boolean) => void;
|
||||||
onIndexToggle?: (columnName: string, checked: boolean) => void;
|
onIndexToggle?: (columnName: string, checked: boolean) => void;
|
||||||
|
/** 호버 시 한글 라벨 표시용 (Badge title) */
|
||||||
|
tables?: TableInfo[];
|
||||||
|
referenceTableColumns?: Record<string, ReferenceTableColumn[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIndexState(
|
function getIndexState(
|
||||||
|
|
@ -53,6 +57,8 @@ export function ColumnGrid({
|
||||||
getColumnIndexState: externalGetIndexState,
|
getColumnIndexState: externalGetIndexState,
|
||||||
onPkToggle,
|
onPkToggle,
|
||||||
onIndexToggle,
|
onIndexToggle,
|
||||||
|
tables,
|
||||||
|
referenceTableColumns,
|
||||||
}: ColumnGridProps) {
|
}: ColumnGridProps) {
|
||||||
const getIdxState = useMemo(
|
const getIdxState = useMemo(
|
||||||
() => externalGetIndexState ?? ((name: string) => getIndexState(name, constraints)),
|
() => externalGetIndexState ?? ((name: string) => getIndexState(name, constraints)),
|
||||||
|
|
@ -136,13 +142,12 @@ export function ColumnGrid({
|
||||||
{/* 4px 색상바 (타입별 진한 색) */}
|
{/* 4px 색상바 (타입별 진한 색) */}
|
||||||
<div className={cn("h-full min-h-8 w-1 rounded-full", typeConf.barColor)} />
|
<div className={cn("h-full min-h-8 w-1 rounded-full", typeConf.barColor)} />
|
||||||
|
|
||||||
{/* 라벨 + 컬럼명 */}
|
{/* 라벨 + 컬럼명 (한글라벨 (영어명) 동시 표시) */}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-medium">
|
<div className="truncate text-sm font-medium">
|
||||||
{column.displayName || column.columnName}
|
{column.displayName && column.displayName !== column.columnName
|
||||||
</div>
|
? `${column.displayName} (${column.columnName})`
|
||||||
<div className="truncate font-mono text-xs text-muted-foreground">
|
: column.columnName}
|
||||||
{column.columnName}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -150,11 +155,38 @@ export function ColumnGrid({
|
||||||
<div className="flex min-w-0 flex-wrap gap-1">
|
<div className="flex min-w-0 flex-wrap gap-1">
|
||||||
{column.inputType === "entity" && column.referenceTable && column.referenceTable !== "none" && (
|
{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}
|
{column.referenceTable}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-muted-foreground text-xs">→</span>
|
<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 || "—"}
|
{column.referenceColumn || "—"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,11 @@ import {
|
||||||
Zap,
|
Zap,
|
||||||
Copy,
|
Copy,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
} from "lucide-react";
|
} 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 { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
|
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
|
||||||
import { DynamicFormApi } from "@/lib/api/dynamicForm";
|
import { DynamicFormApi } from "@/lib/api/dynamicForm";
|
||||||
|
|
@ -97,10 +101,37 @@ export interface ExcelUploadModalProps {
|
||||||
interface ColumnMapping {
|
interface ColumnMapping {
|
||||||
excelColumn: string;
|
excelColumn: string;
|
||||||
systemColumn: string | null;
|
systemColumn: string | null;
|
||||||
// 중복 체크 설정 (해당 컬럼을 중복 체크 키로 사용할지)
|
|
||||||
checkDuplicate?: boolean;
|
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> = ({
|
export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|
@ -137,6 +168,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
// 중복 처리 방법 (전역 설정)
|
// 중복 처리 방법 (전역 설정)
|
||||||
const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip");
|
const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip");
|
||||||
|
|
||||||
|
// 검증 화면에서 DB 중복 처리 방법 (null이면 미선택 = 업로드 차단)
|
||||||
|
const [dbDuplicateAction, setDbDuplicateAction] = useState<"overwrite" | "skip" | null>(null);
|
||||||
|
|
||||||
// 엑셀 데이터 사전 검증 결과
|
// 엑셀 데이터 사전 검증 결과
|
||||||
const [isDataValidating, setIsDataValidating] = useState(false);
|
const [isDataValidating, setIsDataValidating] = useState(false);
|
||||||
const [validationResult, setValidationResult] = useState<import("@/lib/api/tableManagement").ExcelValidationResult | null>(null);
|
const [validationResult, setValidationResult] = useState<import("@/lib/api/tableManagement").ExcelValidationResult | null>(null);
|
||||||
|
|
@ -149,7 +183,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
Record<string, Array<{
|
Record<string, Array<{
|
||||||
invalidValue: string;
|
invalidValue: string;
|
||||||
replacement: string | null;
|
replacement: string | null;
|
||||||
validOptions: Array<{ code: string; label: string }>;
|
validOptions: Array<{ code: string; label: string; depth: number; ancestors: string[] }>;
|
||||||
rowIndices: number[];
|
rowIndices: number[];
|
||||||
}>>
|
}>>
|
||||||
>({});
|
>({});
|
||||||
|
|
@ -681,12 +715,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
const valuesResponse = await getCategoryValues(targetTableName, catCol.systemCol);
|
const valuesResponse = await getCategoryValues(targetTableName, catCol.systemCol);
|
||||||
if (!valuesResponse.success || !valuesResponse.data) continue;
|
if (!valuesResponse.success || !valuesResponse.data) continue;
|
||||||
|
|
||||||
const validValues = valuesResponse.data as Array<{
|
const validValues = flattenCategoryValues(valuesResponse.data as any[]);
|
||||||
valueCode: string;
|
|
||||||
valueLabel: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
// 유효한 코드와 라벨 Set 생성
|
|
||||||
const validCodes = new Set(validValues.map((v) => v.valueCode));
|
const validCodes = new Set(validValues.map((v) => v.valueCode));
|
||||||
const validLabels = new Set(validValues.map((v) => v.valueLabel));
|
const validLabels = new Set(validValues.map((v) => v.valueLabel));
|
||||||
const validLabelsLower = new Set(validValues.map((v) => v.valueLabel.toLowerCase()));
|
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) => ({
|
const options = validValues.map((v) => ({
|
||||||
code: v.valueCode,
|
code: v.valueCode,
|
||||||
label: v.valueLabel,
|
label: v.valueLabel,
|
||||||
|
depth: v.depth,
|
||||||
|
ancestors: v.ancestors,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mismatches[`${catCol.systemCol}|||${catCol.displayName}`] = Array.from(invalidMap.entries()).map(
|
mismatches[`${catCol.systemCol}|||${catCol.displayName}`] = Array.from(invalidMap.entries()).map(
|
||||||
|
|
@ -786,8 +818,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
setDisplayData(newData);
|
setDisplayData(newData);
|
||||||
setShowCategoryValidation(false);
|
setShowCategoryValidation(false);
|
||||||
setCategoryMismatches({});
|
setCategoryMismatches({});
|
||||||
toast.success("카테고리 값이 대체되었습니다.");
|
toast.success("카테고리 값이 대체되었습니다. '다음'을 눌러 진행해주세요.");
|
||||||
setCurrentStep(3);
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -881,6 +912,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터 사전 검증 (NOT NULL 값 누락, UNIQUE 중복)
|
// 데이터 사전 검증 (NOT NULL 값 누락, UNIQUE 중복)
|
||||||
|
setDbDuplicateAction(null);
|
||||||
setIsDataValidating(true);
|
setIsDataValidating(true);
|
||||||
try {
|
try {
|
||||||
const { validateExcelData: validateExcel } = await import("@/lib/api/tableManagement");
|
const { validateExcelData: validateExcel } = await import("@/lib/api/tableManagement");
|
||||||
|
|
@ -1096,9 +1128,33 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
const hasNumbering = !!numberingInfo;
|
const hasNumbering = !!numberingInfo;
|
||||||
|
|
||||||
// 중복 체크 설정 확인
|
// 중복 체크 설정 확인
|
||||||
const duplicateCheckMappings = columnMappings.filter(
|
let duplicateCheckMappings = columnMappings.filter(
|
||||||
(m) => m.checkDuplicate && m.systemColumn
|
(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;
|
const hasDuplicateCheck = duplicateCheckMappings.length > 0;
|
||||||
|
|
||||||
// 중복 체크를 위한 기존 데이터 조회 (중복 체크가 설정된 경우에만)
|
// 중복 체크를 위한 기존 데이터 조회 (중복 체크가 설정된 경우에만)
|
||||||
|
|
@ -1161,7 +1217,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
|
|
||||||
if (existingDataMap.has(key)) {
|
if (existingDataMap.has(key)) {
|
||||||
existingRow = existingDataMap.get(key);
|
existingRow = existingDataMap.get(key);
|
||||||
if (duplicateAction === "skip") {
|
if (effectiveDuplicateAction === "skip") {
|
||||||
shouldSkip = true;
|
shouldSkip = true;
|
||||||
skipCount++;
|
skipCount++;
|
||||||
console.log(`⏭️ [행 ${rowIdx + 1}] 중복으로 건너뛰기: ${key}`);
|
console.log(`⏭️ [행 ${rowIdx + 1}] 중복으로 건너뛰기: ${key}`);
|
||||||
|
|
@ -1343,6 +1399,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
setSystemColumns([]);
|
setSystemColumns([]);
|
||||||
setColumnMappings([]);
|
setColumnMappings([]);
|
||||||
setDuplicateAction("skip");
|
setDuplicateAction("skip");
|
||||||
|
setDbDuplicateAction(null);
|
||||||
// 검증 상태 초기화
|
// 검증 상태 초기화
|
||||||
setValidationResult(null);
|
setValidationResult(null);
|
||||||
setIsDataValidating(false);
|
setIsDataValidating(false);
|
||||||
|
|
@ -1357,7 +1414,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={(v) => { if (!showCategoryValidation) onOpenChange(v); }}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -1965,12 +2022,50 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
|
|
||||||
{/* DB 기존 데이터 중복 */}
|
{/* DB 기존 데이터 중복 */}
|
||||||
{validationResult.uniqueInDbErrors.length > 0 && (
|
{validationResult.uniqueInDbErrors.length > 0 && (
|
||||||
<div className="rounded-md border border-destructive bg-destructive/10 p-4">
|
<div className={cn(
|
||||||
<h3 className="flex items-center gap-2 text-sm font-medium text-destructive sm:text-base">
|
"rounded-md border p-4",
|
||||||
<XCircle className="h-4 w-4" />
|
dbDuplicateAction
|
||||||
DB 기존 데이터와 중복 ({validationResult.uniqueInDbErrors.length}건)
|
? "border-primary/30 bg-primary/5"
|
||||||
</h3>
|
: "border-destructive bg-destructive/10"
|
||||||
<div className="mt-2 max-h-[150px] space-y-0.5 overflow-y-auto text-[10px] text-destructive sm:text-xs">
|
)}>
|
||||||
|
<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[] }[]>();
|
const grouped = new Map<string, { value: string; rows: number[] }[]>();
|
||||||
for (const err of validationResult.uniqueInDbErrors) {
|
for (const err of validationResult.uniqueInDbErrors) {
|
||||||
|
|
@ -1984,7 +2079,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
<div key={label}>
|
<div key={label}>
|
||||||
{items.slice(0, 5).map((item, i) => (
|
{items.slice(0, 5).map((item, i) => (
|
||||||
<p key={i}>
|
<p key={i}>
|
||||||
<span className="font-medium">{label}</span> "{item.value}": 행 {item.rows.join(", ")}
|
<span className="font-medium">{label}</span> "{item.value}": 행 {item.rows.join(", ")}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
{items.length > 5 && <p className="font-medium">...외 {items.length - 5}건</p>}
|
{items.length > 5 && <p className="font-medium">...외 {items.length - 5}건</p>}
|
||||||
|
|
@ -1992,6 +2087,13 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
));
|
));
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
{dbDuplicateAction && (
|
||||||
|
<p className="mt-2 text-[10px] text-primary sm:text-xs font-medium">
|
||||||
|
{dbDuplicateAction === "skip"
|
||||||
|
? "중복 데이터는 건너뛰고 신규 데이터만 업로드합니다."
|
||||||
|
: "중복 데이터는 새 값으로 덮어씁니다."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2105,11 +2207,24 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
disabled={
|
disabled={
|
||||||
isUploading ||
|
isUploading ||
|
||||||
columnMappings.filter((m) => m.systemColumn).length === 0 ||
|
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"
|
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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
@ -2156,33 +2271,63 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
<Select
|
<Popover>
|
||||||
value={item.replacement || ""}
|
<PopoverTrigger asChild>
|
||||||
onValueChange={(val) => {
|
<Button
|
||||||
setCategoryMismatches((prev) => {
|
variant="outline"
|
||||||
const updated = { ...prev };
|
role="combobox"
|
||||||
updated[key] = updated[key].map((it, i) =>
|
className="h-8 w-full justify-between text-xs sm:h-9 sm:text-sm"
|
||||||
i === idx ? { ...it, replacement: val } : it
|
>
|
||||||
);
|
<span className="truncate">
|
||||||
return updated;
|
{item.replacement
|
||||||
});
|
? item.validOptions.find((o) => o.code === item.replacement)?.label || item.replacement
|
||||||
}}
|
: "대체 값 선택"}
|
||||||
>
|
</span>
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-9 sm:text-sm">
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
<SelectValue placeholder="대체 값 선택" />
|
</Button>
|
||||||
</SelectTrigger>
|
</PopoverTrigger>
|
||||||
<SelectContent>
|
<PopoverContent className="w-[260px] p-0" align="start">
|
||||||
{item.validOptions.map((opt) => (
|
<Command
|
||||||
<SelectItem
|
filter={(value, search) => {
|
||||||
key={opt.code}
|
const opt = item.validOptions.find((o) => o.code === value);
|
||||||
value={opt.code}
|
if (!opt) return 0;
|
||||||
className="text-xs sm:text-sm"
|
const s = search.toLowerCase();
|
||||||
>
|
if (opt.label.toLowerCase().includes(s)) return 1;
|
||||||
{opt.label}
|
if (opt.ancestors.some((a) => a.toLowerCase().includes(s))) return 1;
|
||||||
</SelectItem>
|
return 0;
|
||||||
))}
|
}}
|
||||||
</SelectContent>
|
>
|
||||||
</Select>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2201,17 +2346,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</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
|
<Button
|
||||||
onClick={applyCategoryReplacements}
|
onClick={applyCategoryReplacements}
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,11 @@ import {
|
||||||
Zap,
|
Zap,
|
||||||
Download,
|
Download,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
} from "lucide-react";
|
} 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 { importFromExcel, getExcelSheetNames, exportToExcel } from "@/lib/utils/excelExport";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EditableSpreadsheet } from "./EditableSpreadsheet";
|
import { EditableSpreadsheet } from "./EditableSpreadsheet";
|
||||||
|
|
@ -51,6 +55,34 @@ interface ColumnMapping {
|
||||||
targetColumn: string | null;
|
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> = ({
|
export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProps> = ({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|
@ -88,7 +120,7 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||||
Record<string, Array<{
|
Record<string, Array<{
|
||||||
invalidValue: string;
|
invalidValue: string;
|
||||||
replacement: string | null;
|
replacement: string | null;
|
||||||
validOptions: Array<{ code: string; label: string }>;
|
validOptions: Array<{ code: string; label: string; depth: number; ancestors: string[] }>;
|
||||||
rowIndices: number[];
|
rowIndices: number[];
|
||||||
}>>
|
}>>
|
||||||
>({});
|
>({});
|
||||||
|
|
@ -356,10 +388,7 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||||
const valuesResponse = await getCategoryValues(level.tableName, catColName);
|
const valuesResponse = await getCategoryValues(level.tableName, catColName);
|
||||||
if (!valuesResponse.success || !valuesResponse.data) continue;
|
if (!valuesResponse.success || !valuesResponse.data) continue;
|
||||||
|
|
||||||
const validValues = valuesResponse.data as Array<{
|
const validValues = flattenCategoryValues(valuesResponse.data as any[]);
|
||||||
valueCode: string;
|
|
||||||
valueLabel: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const validCodes = new Set(validValues.map((v) => v.valueCode));
|
const validCodes = new Set(validValues.map((v) => v.valueCode));
|
||||||
const validLabels = new Set(validValues.map((v) => v.valueLabel));
|
const validLabels = new Set(validValues.map((v) => v.valueLabel));
|
||||||
|
|
@ -387,6 +416,8 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||||
const options = validValues.map((v) => ({
|
const options = validValues.map((v) => ({
|
||||||
code: v.valueCode,
|
code: v.valueCode,
|
||||||
label: v.valueLabel,
|
label: v.valueLabel,
|
||||||
|
depth: v.depth,
|
||||||
|
ancestors: v.ancestors,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const key = `${catColName}|||[${level.label}] ${catDisplayName}`;
|
const key = `${catColName}|||[${level.label}] ${catDisplayName}`;
|
||||||
|
|
@ -464,8 +495,7 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||||
setDisplayData(newData);
|
setDisplayData(newData);
|
||||||
setShowCategoryValidation(false);
|
setShowCategoryValidation(false);
|
||||||
setCategoryMismatches({});
|
setCategoryMismatches({});
|
||||||
toast.success("카테고리 값이 대체되었습니다.");
|
toast.success("카테고리 값이 대체되었습니다. '다음'을 눌러 진행해주세요.");
|
||||||
setCurrentStep(3);
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -532,7 +562,7 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={(v) => { if (!showCategoryValidation) onOpenChange(v); }}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
||||||
style={{ width: "1000px", height: "700px", minWidth: "700px", minHeight: "500px", maxWidth: "1400px", maxHeight: "900px" }}
|
style={{ width: "1000px", height: "700px", minWidth: "700px", minHeight: "500px", maxWidth: "1400px", maxHeight: "900px" }}
|
||||||
|
|
@ -1009,33 +1039,63 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
<Select
|
<Popover>
|
||||||
value={item.replacement || ""}
|
<PopoverTrigger asChild>
|
||||||
onValueChange={(val) => {
|
<Button
|
||||||
setCategoryMismatches((prev) => {
|
variant="outline"
|
||||||
const updated = { ...prev };
|
role="combobox"
|
||||||
updated[key] = updated[key].map((it, i) =>
|
className="h-8 w-full justify-between text-xs sm:h-9 sm:text-sm"
|
||||||
i === idx ? { ...it, replacement: val } : it
|
>
|
||||||
);
|
<span className="truncate">
|
||||||
return updated;
|
{item.replacement
|
||||||
});
|
? item.validOptions.find((o) => o.code === item.replacement)?.label || item.replacement
|
||||||
}}
|
: "대체 값 선택"}
|
||||||
>
|
</span>
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-9 sm:text-sm">
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
<SelectValue placeholder="대체 값 선택" />
|
</Button>
|
||||||
</SelectTrigger>
|
</PopoverTrigger>
|
||||||
<SelectContent>
|
<PopoverContent className="w-[260px] p-0" align="start">
|
||||||
{item.validOptions.map((opt) => (
|
<Command
|
||||||
<SelectItem
|
filter={(value, search) => {
|
||||||
key={opt.code}
|
const opt = item.validOptions.find((o) => o.code === value);
|
||||||
value={opt.code}
|
if (!opt) return 0;
|
||||||
className="text-xs sm:text-sm"
|
const s = search.toLowerCase();
|
||||||
>
|
if (opt.label.toLowerCase().includes(s)) return 1;
|
||||||
{opt.label}
|
if (opt.ancestors.some((a) => a.toLowerCase().includes(s))) return 1;
|
||||||
</SelectItem>
|
return 0;
|
||||||
))}
|
}}
|
||||||
</SelectContent>
|
>
|
||||||
</Select>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1054,17 +1114,6 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</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
|
<Button
|
||||||
onClick={applyCategoryReplacements}
|
onClick={applyCategoryReplacements}
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, Suspense, useEffect } from "react";
|
import { useState, Suspense, useEffect, useCallback } from "react";
|
||||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -341,6 +341,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
|
|
||||||
const currentMenus = isAdminMode ? adminMenus : userMenus;
|
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 toggleMenu = (menuId: string) => {
|
||||||
const newExpanded = new Set(expandedMenus);
|
const newExpanded = new Set(expandedMenus);
|
||||||
if (newExpanded.has(menuId)) {
|
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 스타일 적용)
|
// 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용)
|
||||||
const renderMenu = (menu: any, level: number = 0) => {
|
const renderMenu = (menu: any, level: number = 0) => {
|
||||||
const isExpanded = expandedMenus.has(menu.id);
|
const isExpanded = expandedMenus.has(menu.id);
|
||||||
|
|
@ -489,8 +513,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
draggable={isLeaf}
|
draggable={isLeaf}
|
||||||
onDragStart={(e) => handleMenuDragStart(e, menu)}
|
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] ${
|
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
|
isMenuActive(menu)
|
||||||
? "border-primary bg-primary/8 text-primary border-l-3 font-semibold"
|
? "border-l-[3px] border-l-primary bg-primary/10 dark:bg-primary/15 text-primary font-semibold"
|
||||||
: isExpanded
|
: isExpanded
|
||||||
? "bg-accent/60 text-foreground"
|
? "bg-accent/60 text-foreground"
|
||||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
|
@ -518,8 +542,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
draggable={!child.hasChildren}
|
draggable={!child.hasChildren}
|
||||||
onDragStart={(e) => handleMenuDragStart(e, child)}
|
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] ${
|
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
|
isMenuActive(child)
|
||||||
? "border-primary bg-primary/8 text-primary border-l-3 font-semibold"
|
? "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"
|
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleMenuClick(child)}
|
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) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center">
|
<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 (
|
return (
|
||||||
<div className="bg-background flex h-screen flex-col">
|
<div className="bg-background flex h-screen flex-col">
|
||||||
{/* 모바일 헤더 */}
|
{/* 모바일 헤더 */}
|
||||||
|
|
|
||||||
|
|
@ -493,8 +493,8 @@ export function TabBar() {
|
||||||
className={cn(
|
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",
|
"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
|
isActive
|
||||||
? "text-foreground z-10 -mb-px h-[30px] bg-white"
|
? "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-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground border-transparent",
|
: "bg-transparent text-muted-foreground hover:bg-muted/50 hover:text-foreground border-transparent",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: TAB_WIDTH,
|
width: TAB_WIDTH,
|
||||||
|
|
|
||||||
|
|
@ -478,7 +478,7 @@ const DateConfigPanel: React.FC<DateConfigPanelProps> = ({
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
{sourceTableName && columns.length === 0 && !loadingColumns && (
|
{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>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
@ -27,25 +26,24 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
||||||
tableName,
|
tableName,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Card className="border-border bg-card flex-1">
|
<div className="config-field flex-1 rounded-[8px] border border-border bg-muted/50 px-3 py-3 sm:px-4 sm:py-4">
|
||||||
<CardHeader className="pb-3">
|
<div className="mb-3 flex items-center justify-between sm:mb-4">
|
||||||
<div className="flex items-center justify-between">
|
<Badge variant="outline" className="text-xs sm:text-sm">
|
||||||
<Badge variant="outline" className="text-xs sm:text-sm">
|
규칙 {part.order}
|
||||||
규칙 {part.order}
|
</Badge>
|
||||||
</Badge>
|
<Button
|
||||||
<Button
|
variant="destructive"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
size="icon"
|
onClick={onDelete}
|
||||||
onClick={onDelete}
|
className="h-7 w-7 sm:h-8 sm:w-8"
|
||||||
className="text-destructive h-7 w-7 sm:h-8 sm:w-8"
|
disabled={isPreview}
|
||||||
disabled={isPreview}
|
aria-label="규칙 삭제"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4" />
|
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs font-medium sm:text-sm">구분 유형</Label>
|
<Label className="text-xs font-medium sm:text-sm">구분 유형</Label>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -117,7 +115,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,28 +5,16 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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 { toast } from "sonner";
|
||||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||||
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
|
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
|
||||||
|
import { CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule";
|
||||||
import { NumberingRuleCard } from "./NumberingRuleCard";
|
import { NumberingRuleCard } from "./NumberingRuleCard";
|
||||||
import { NumberingRulePreview } from "./NumberingRulePreview";
|
import { NumberingRulePreview, computePartDisplayItems, getPartTypeColorClass } from "./NumberingRulePreview";
|
||||||
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
import { getNumberingRules, saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
||||||
import { apiClient } from "@/lib/api/client";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface NumberingColumn {
|
|
||||||
tableName: string;
|
|
||||||
tableLabel: string;
|
|
||||||
columnName: string;
|
|
||||||
columnLabel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupedColumns {
|
|
||||||
tableLabel: string;
|
|
||||||
columns: NumberingColumn[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NumberingRuleDesignerProps {
|
interface NumberingRuleDesignerProps {
|
||||||
initialConfig?: NumberingRuleConfig;
|
initialConfig?: NumberingRuleConfig;
|
||||||
onSave?: (config: NumberingRuleConfig) => void;
|
onSave?: (config: NumberingRuleConfig) => void;
|
||||||
|
|
@ -34,8 +22,8 @@ interface NumberingRuleDesignerProps {
|
||||||
maxRules?: number;
|
maxRules?: number;
|
||||||
isPreview?: boolean;
|
isPreview?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용)
|
currentTableName?: string;
|
||||||
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
|
menuObjid?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
|
|
@ -48,124 +36,84 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
currentTableName,
|
currentTableName,
|
||||||
menuObjid,
|
menuObjid,
|
||||||
}) => {
|
}) => {
|
||||||
const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]);
|
const [rulesList, setRulesList] = useState<NumberingRuleConfig[]>([]);
|
||||||
const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null);
|
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||||
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
|
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
|
||||||
|
const [selectedPartOrder, setSelectedPartOrder] = useState<number | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
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 [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
|
||||||
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
|
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
// 좌측: 채번 타입 컬럼 목록 로드
|
const selectedRule = rulesList.find((r) => r.ruleId === selectedRuleId) ?? currentRule;
|
||||||
|
|
||||||
|
// 좌측: 규칙 목록 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadNumberingColumns();
|
loadRules();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadNumberingColumns = async () => {
|
const loadRules = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get("/table-management/numbering-columns");
|
const response = await getNumberingRules();
|
||||||
if (response.data.success && response.data.data) {
|
if (response.success && response.data) {
|
||||||
setNumberingColumns(response.data.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) {
|
} catch (e) {
|
||||||
console.error("채번 컬럼 목록 로드 실패:", error);
|
console.error("채번 규칙 목록 로드 실패:", e);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 컬럼 선택 시 해당 컬럼의 채번 규칙 로드
|
const handleSelectRule = (rule: NumberingRuleConfig) => {
|
||||||
const handleSelectColumn = async (tableName: string, columnName: string) => {
|
setSelectedRuleId(rule.ruleId);
|
||||||
setSelectedColumn({ tableName, columnName });
|
setCurrentRule(JSON.parse(JSON.stringify(rule)));
|
||||||
setLoading(true);
|
setSelectedPartOrder(null);
|
||||||
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 handleAddNewRule = () => {
|
||||||
const groupedColumns = numberingColumns.reduce<Record<string, GroupedColumns>>((acc, col) => {
|
const newRule: NumberingRuleConfig = {
|
||||||
if (!acc[col.tableName]) {
|
ruleId: `rule-${Date.now()}`,
|
||||||
acc[col.tableName] = { tableLabel: col.tableLabel, columns: [] };
|
ruleName: "새 규칙",
|
||||||
}
|
parts: [],
|
||||||
acc[col.tableName].columns.push(col);
|
separator: "-",
|
||||||
return acc;
|
resetPeriod: "none",
|
||||||
}, {});
|
currentSequence: 1,
|
||||||
|
scopeType: "global",
|
||||||
// 검색 필터 적용
|
tableName: currentTableName ?? "",
|
||||||
const filteredGroups = Object.entries(groupedColumns).filter(([tableName, group]) => {
|
columnName: "",
|
||||||
if (!columnSearch) return true;
|
};
|
||||||
const search = columnSearch.toLowerCase();
|
setRulesList((prev) => [...prev, newRule]);
|
||||||
return (
|
setSelectedRuleId(newRule.ruleId);
|
||||||
tableName.toLowerCase().includes(search) ||
|
setCurrentRule(JSON.parse(JSON.stringify(newRule)));
|
||||||
group.tableLabel.toLowerCase().includes(search) ||
|
setSelectedPartOrder(null);
|
||||||
group.columns.some(
|
toast.success("새 규칙이 추가되었습니다");
|
||||||
(c) => c.columnName.toLowerCase().includes(search) || c.columnLabel.toLowerCase().includes(search)
|
};
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentRule) {
|
if (currentRule) onChange?.(currentRule);
|
||||||
onChange?.(currentRule);
|
|
||||||
}
|
|
||||||
}, [currentRule, onChange]);
|
}, [currentRule, onChange]);
|
||||||
|
|
||||||
// currentRule이 변경될 때 파트별 구분자 상태 동기화
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentRule && currentRule.parts.length > 0) {
|
if (currentRule && currentRule.parts.length > 0) {
|
||||||
const newSepTypes: Record<number, SeparatorType> = {};
|
const newSepTypes: Record<number, SeparatorType> = {};
|
||||||
const newCustomSeps: Record<number, string> = {};
|
const newCustomSeps: Record<number, string> = {};
|
||||||
|
|
||||||
currentRule.parts.forEach((part) => {
|
currentRule.parts.forEach((part) => {
|
||||||
const sep = part.separatorAfter ?? currentRule.separator ?? "-";
|
const sep = part.separatorAfter ?? currentRule.separator ?? "-";
|
||||||
if (sep === "") {
|
if (sep === "") {
|
||||||
newSepTypes[part.order] = "none";
|
newSepTypes[part.order] = "none";
|
||||||
newCustomSeps[part.order] = "";
|
newCustomSeps[part.order] = "";
|
||||||
} else {
|
} else {
|
||||||
const predefinedOption = SEPARATOR_OPTIONS.find(
|
const opt = SEPARATOR_OPTIONS.find(
|
||||||
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
|
(o) => o.value !== "custom" && o.value !== "none" && o.displayValue === sep
|
||||||
);
|
);
|
||||||
if (predefinedOption) {
|
if (opt) {
|
||||||
newSepTypes[part.order] = predefinedOption.value;
|
newSepTypes[part.order] = opt.value;
|
||||||
newCustomSeps[part.order] = "";
|
newCustomSeps[part.order] = "";
|
||||||
} else {
|
} else {
|
||||||
newSepTypes[part.order] = "custom";
|
newSepTypes[part.order] = "custom";
|
||||||
|
|
@ -173,54 +121,45 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setSeparatorTypes(newSepTypes);
|
setSeparatorTypes(newSepTypes);
|
||||||
setCustomSeparators(newCustomSeps);
|
setCustomSeparators(newCustomSeps);
|
||||||
}
|
}
|
||||||
}, [currentRule?.ruleId]);
|
}, [currentRule?.ruleId]);
|
||||||
|
|
||||||
// 개별 파트 구분자 변경 핸들러
|
|
||||||
const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => {
|
const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => {
|
||||||
setSeparatorTypes(prev => ({ ...prev, [partOrder]: type }));
|
setSeparatorTypes((prev) => ({ ...prev, [partOrder]: type }));
|
||||||
if (type !== "custom") {
|
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 ?? "";
|
const newSeparator = option?.displayValue ?? "";
|
||||||
setCustomSeparators(prev => ({ ...prev, [partOrder]: "" }));
|
setCustomSeparators((prev) => ({ ...prev, [partOrder]: "" }));
|
||||||
setCurrentRule((prev) => {
|
setCurrentRule((prev) => {
|
||||||
if (!prev) return null;
|
if (!prev) return null;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
parts: prev.parts.map((part) =>
|
parts: prev.parts.map((p) => (p.order === partOrder ? { ...p, separatorAfter: newSeparator } : p)),
|
||||||
part.order === partOrder ? { ...part, separatorAfter: newSeparator } : part
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 개별 파트 직접 입력 구분자 변경 핸들러
|
|
||||||
const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => {
|
const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => {
|
||||||
const trimmedValue = value.slice(0, 2);
|
const trimmedValue = value.slice(0, 2);
|
||||||
setCustomSeparators(prev => ({ ...prev, [partOrder]: trimmedValue }));
|
setCustomSeparators((prev) => ({ ...prev, [partOrder]: trimmedValue }));
|
||||||
setCurrentRule((prev) => {
|
setCurrentRule((prev) => {
|
||||||
if (!prev) return null;
|
if (!prev) return null;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
parts: prev.parts.map((part) =>
|
parts: prev.parts.map((p) => (p.order === partOrder ? { ...p, separatorAfter: trimmedValue } : p)),
|
||||||
part.order === partOrder ? { ...part, separatorAfter: trimmedValue } : part
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAddPart = useCallback(() => {
|
const handleAddPart = useCallback(() => {
|
||||||
if (!currentRule) return;
|
if (!currentRule) return;
|
||||||
|
|
||||||
if (currentRule.parts.length >= maxRules) {
|
if (currentRule.parts.length >= maxRules) {
|
||||||
toast.error(`최대 ${maxRules}개까지 추가할 수 있습니다`);
|
toast.error(`최대 ${maxRules}개까지 추가할 수 있습니다`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newPart: NumberingRulePart = {
|
const newPart: NumberingRulePart = {
|
||||||
id: `part-${Date.now()}`,
|
id: `part-${Date.now()}`,
|
||||||
order: currentRule.parts.length + 1,
|
order: currentRule.parts.length + 1,
|
||||||
|
|
@ -229,40 +168,33 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
autoConfig: { textValue: "CODE" },
|
autoConfig: { textValue: "CODE" },
|
||||||
separatorAfter: "-",
|
separatorAfter: "-",
|
||||||
};
|
};
|
||||||
|
setCurrentRule((prev) => (prev ? { ...prev, parts: [...prev.parts, newPart] } : null));
|
||||||
setCurrentRule((prev) => {
|
setSeparatorTypes((prev) => ({ ...prev, [newPart.order]: "-" }));
|
||||||
if (!prev) return null;
|
setCustomSeparators((prev) => ({ ...prev, [newPart.order]: "" }));
|
||||||
return { ...prev, parts: [...prev.parts, newPart] };
|
|
||||||
});
|
|
||||||
|
|
||||||
// 새 파트의 구분자 상태 초기화
|
|
||||||
setSeparatorTypes(prev => ({ ...prev, [newPart.order]: "-" }));
|
|
||||||
setCustomSeparators(prev => ({ ...prev, [newPart.order]: "" }));
|
|
||||||
|
|
||||||
toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
|
toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
|
||||||
}, [currentRule, maxRules]);
|
}, [currentRule, maxRules]);
|
||||||
|
|
||||||
// partOrder 기반으로 파트 업데이트 (id가 null일 수 있으므로 order 사용)
|
|
||||||
const handleUpdatePart = useCallback((partOrder: number, updates: Partial<NumberingRulePart>) => {
|
const handleUpdatePart = useCallback((partOrder: number, updates: Partial<NumberingRulePart>) => {
|
||||||
setCurrentRule((prev) => {
|
setCurrentRule((prev) => {
|
||||||
if (!prev) return null;
|
if (!prev) return null;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...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) => {
|
const handleDeletePart = useCallback((partOrder: number) => {
|
||||||
setCurrentRule((prev) => {
|
setCurrentRule((prev) => {
|
||||||
if (!prev) return null;
|
if (!prev) return null;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...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("규칙이 삭제되었습니다");
|
toast.success("규칙이 삭제되었습니다");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -271,246 +203,283 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
toast.error("저장할 규칙이 없습니다");
|
toast.error("저장할 규칙이 없습니다");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentRule.parts.length === 0) {
|
if (currentRule.parts.length === 0) {
|
||||||
toast.error("최소 1개 이상의 규칙을 추가해주세요");
|
toast.error("최소 1개 이상의 규칙을 추가해주세요");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// 파트별 기본 autoConfig 정의
|
|
||||||
const defaultAutoConfigs: Record<string, any> = {
|
const defaultAutoConfigs: Record<string, any> = {
|
||||||
sequence: { sequenceLength: 3, startFrom: 1 },
|
sequence: { sequenceLength: 3, startFrom: 1 },
|
||||||
number: { numberLength: 4, numberValue: 1 },
|
number: { numberLength: 4, numberValue: 1 },
|
||||||
date: { dateFormat: "YYYYMMDD" },
|
date: { dateFormat: "YYYYMMDD" },
|
||||||
text: { textValue: "" },
|
text: { textValue: "" },
|
||||||
};
|
};
|
||||||
|
|
||||||
// 저장 전에 각 파트의 autoConfig에 기본값 채우기
|
|
||||||
const partsWithDefaults = currentRule.parts.map((part) => {
|
const partsWithDefaults = currentRule.parts.map((part) => {
|
||||||
if (part.generationMethod === "auto") {
|
if (part.generationMethod === "auto") {
|
||||||
const defaults = defaultAutoConfigs[part.partType] || {};
|
const defaults = defaultAutoConfigs[part.partType] || {};
|
||||||
return {
|
return { ...part, autoConfig: { ...defaults, ...part.autoConfig } };
|
||||||
...part,
|
|
||||||
autoConfig: { ...defaults, ...part.autoConfig },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return part;
|
return part;
|
||||||
});
|
});
|
||||||
|
|
||||||
const ruleToSave = {
|
const ruleToSave = {
|
||||||
...currentRule,
|
...currentRule,
|
||||||
parts: partsWithDefaults,
|
parts: partsWithDefaults,
|
||||||
scopeType: "table" as const,
|
scopeType: "global" as const,
|
||||||
tableName: selectedColumn?.tableName || currentRule.tableName || "",
|
tableName: currentRule.tableName || currentTableName || "",
|
||||||
columnName: selectedColumn?.columnName || currentRule.columnName || "",
|
columnName: currentRule.columnName || "",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 테스트 테이블에 저장 (numbering_rules)
|
|
||||||
const response = await saveNumberingRuleToTest(ruleToSave);
|
const response = await saveNumberingRuleToTest(ruleToSave);
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
|
const saved: NumberingRuleConfig = JSON.parse(JSON.stringify(response.data));
|
||||||
setCurrentRule(currentData);
|
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);
|
await onSave?.(response.data);
|
||||||
toast.success("채번 규칙이 저장되었습니다");
|
toast.success("채번 규칙이 저장되었습니다");
|
||||||
} else {
|
} else {
|
||||||
showErrorToast("채번 규칙 저장에 실패했습니다", response.error, { guidance: "설정을 확인하고 다시 시도해 주세요." });
|
showErrorToast("채번 규칙 저장에 실패했습니다", response.error, {
|
||||||
|
guidance: "설정을 확인하고 다시 시도해 주세요.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
showErrorToast("채번 규칙 저장에 실패했습니다", error, { guidance: "설정을 확인하고 다시 시도해 주세요." });
|
showErrorToast("채번 규칙 저장에 실패했습니다", error, {
|
||||||
|
guidance: "설정을 확인하고 다시 시도해 주세요.",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 (
|
return (
|
||||||
<div className={`flex h-full gap-4 ${className}`}>
|
<div className={cn("flex h-full", className)}>
|
||||||
{/* 좌측: 채번 컬럼 목록 (카테고리 패턴) */}
|
{/* 좌측: 규칙 리스트 (code-nav, 220px) */}
|
||||||
<div className="flex w-72 flex-shrink-0 flex-col gap-3">
|
<div className="code-nav flex w-[220px] flex-shrink-0 flex-col border-r border-border">
|
||||||
<h2 className="text-sm font-semibold sm:text-base">채번 컬럼</h2>
|
<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">
|
||||||
<Input
|
<ListOrdered className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
value={columnSearch}
|
<span className="truncate text-xs font-bold">채번 규칙 ({rulesList.length})</span>
|
||||||
onChange={(e) => setColumnSearch(e.target.value)}
|
</div>
|
||||||
placeholder="검색..."
|
<Button
|
||||||
className="h-8 text-xs"
|
size="sm"
|
||||||
/>
|
variant="default"
|
||||||
|
className="h-8 shrink-0 gap-1 text-xs font-medium"
|
||||||
<div className="flex-1 space-y-1 overflow-y-auto">
|
onClick={handleAddNewRule}
|
||||||
{loading && numberingColumns.length === 0 ? (
|
disabled={isPreview || loading}
|
||||||
<div className="flex h-32 items-center justify-center">
|
>
|
||||||
<p className="text-muted-foreground text-xs">로딩 중...</p>
|
<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>
|
</div>
|
||||||
) : filteredGroups.length === 0 ? (
|
) : rulesList.length === 0 ? (
|
||||||
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed">
|
<div className="flex h-24 items-center justify-center rounded-lg border border-dashed border-border bg-muted/50 text-xs text-muted-foreground">
|
||||||
<p className="text-muted-foreground text-xs">
|
규칙이 없습니다
|
||||||
{numberingColumns.length === 0
|
|
||||||
? "채번 타입 컬럼이 없습니다"
|
|
||||||
: "검색 결과가 없습니다"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredGroups.map(([tableName, group]) => (
|
rulesList.map((rule) => {
|
||||||
<div key={tableName} className="mb-2">
|
const isSelected = selectedRuleId === rule.ruleId;
|
||||||
<div className="text-muted-foreground mb-1 flex items-center gap-1 px-1 text-[11px] font-medium">
|
return (
|
||||||
<FolderTree className="h-3 w-3" />
|
<button
|
||||||
<span>{group.tableLabel}</span>
|
key={rule.ruleId}
|
||||||
<span className="text-muted-foreground/60">({group.columns.length})</span>
|
type="button"
|
||||||
</div>
|
className={cn(
|
||||||
{group.columns.map((col) => {
|
"code-nav-item flex w-full items-center gap-2 border-b border-border/50 px-3 py-2 text-left transition-colors",
|
||||||
const isSelected =
|
isSelected
|
||||||
selectedColumn?.tableName === col.tableName &&
|
? "border-l-[3px] border-primary bg-primary/5 pl-2.5 font-bold"
|
||||||
selectedColumn?.columnName === col.columnName;
|
: "hover:bg-accent"
|
||||||
return (
|
)}
|
||||||
<div
|
onClick={() => handleSelectRule(rule)}
|
||||||
key={`${col.tableName}.${col.columnName}`}
|
>
|
||||||
className={cn(
|
<span className="rule-name min-w-0 flex-1 truncate text-xs font-semibold">
|
||||||
"cursor-pointer rounded-md px-3 py-1.5 text-xs transition-colors",
|
{rule.ruleName}
|
||||||
isSelected
|
</span>
|
||||||
? "bg-primary/10 text-primary border-primary border font-medium"
|
<span className="rule-table max-w-[70px] shrink-0 truncate text-[9px] text-muted-foreground">
|
||||||
: "hover:bg-accent"
|
{rule.tableName || "-"}
|
||||||
)}
|
</span>
|
||||||
onClick={() => handleSelectColumn(col.tableName, col.columnName)}
|
<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}
|
||||||
{col.columnLabel}
|
</span>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 구분선 */}
|
{/* 우측: 미리보기 + 파이프라인 + 설정 + 저장 바 (code-main) */}
|
||||||
<div className="bg-border h-full w-px"></div>
|
<div className="code-main flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
|
||||||
{/* 우측: 편집 영역 */}
|
|
||||||
<div className="flex flex-1 flex-col gap-4">
|
|
||||||
{!currentRule ? (
|
{!currentRule ? (
|
||||||
<div className="flex h-full flex-col items-center justify-center">
|
<div className="flex flex-1 flex-col items-center justify-center text-center">
|
||||||
<div className="text-center">
|
<ListOrdered className="mb-3 h-10 w-10 text-muted-foreground" />
|
||||||
<FolderTree className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
|
<p className="mb-2 text-lg font-medium text-muted-foreground">규칙을 선택하세요</p>
|
||||||
<p className="text-muted-foreground mb-2 text-lg font-medium">컬럼을 선택해주세요</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
<p className="text-muted-foreground text-sm">좌측에서 채번 컬럼을 선택하면 규칙을 편집할 수 있습니다</p>
|
좌측에서 채번 규칙을 선택하거나 "추가"로 새 규칙을 만드세요
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-2 px-6 pt-4">
|
||||||
{editingRightTitle ? (
|
<Label className="text-xs font-medium">규칙명</Label>
|
||||||
<Input
|
<Input
|
||||||
value={rightTitle}
|
value={currentRule.ruleName}
|
||||||
onChange={(e) => setRightTitle(e.target.value)}
|
onChange={(e) => setCurrentRule((prev) => (prev ? { ...prev, ruleName: e.target.value } : null))}
|
||||||
onBlur={() => setEditingRightTitle(false)}
|
placeholder="예: 프로젝트 코드"
|
||||||
onKeyDown={(e) => e.key === "Enter" && setEditingRightTitle(false)}
|
className="h-9 text-sm"
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
{/* 큰 미리보기 스트립 (code-preview-strip) */}
|
||||||
{/* 첫 번째 줄: 규칙명 + 미리보기 */}
|
<div className="code-preview-strip flex-shrink-0 border-b border-border px-6 py-5">
|
||||||
<div className="flex items-center gap-3">
|
<NumberingRulePreview config={currentRule} variant="strip" />
|
||||||
<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>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
{/* 파이프라인 영역 (code-pipeline-area) */}
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="code-pipeline-area flex flex-col gap-3 border-b border-border px-6 py-5">
|
||||||
<h3 className="text-sm font-semibold">코드 구성</h3>
|
<div className="area-label flex items-center gap-1.5">
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-xs font-bold">코드 구성</span>
|
||||||
|
<span className="cnt text-xs font-medium text-muted-foreground">
|
||||||
{currentRule.parts.length}/{maxRules}
|
{currentRule.parts.length}/{maxRules}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<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 ? (
|
{currentRule.parts.length === 0 ? (
|
||||||
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed">
|
<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">
|
||||||
<p className="text-muted-foreground text-xs sm:text-sm">규칙을 추가하여 코드를 구성하세요</p>
|
규칙을 추가하여 코드를 구성하세요
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap items-stretch gap-3">
|
<>
|
||||||
{currentRule.parts.map((part, index) => (
|
{currentRule.parts.map((part, index) => {
|
||||||
<React.Fragment key={`part-${part.order}-${index}`}>
|
const item = partItems.find((i) => i.order === part.order);
|
||||||
<div className="flex w-[200px] flex-col">
|
const sep = part.separatorAfter ?? globalSep;
|
||||||
<NumberingRuleCard
|
const isSelected = selectedPartOrder === part.order;
|
||||||
part={part}
|
const typeLabel = CODE_PART_TYPE_OPTIONS.find((o) => o.value === part.partType)?.label ?? part.partType;
|
||||||
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
|
return (
|
||||||
onDelete={() => handleDeletePart(part.order)}
|
<React.Fragment key={`part-${part.order}-${index}`}>
|
||||||
isPreview={isPreview}
|
<button
|
||||||
tableName={selectedColumn?.tableName}
|
type="button"
|
||||||
/>
|
className={cn(
|
||||||
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
|
"pipe-segment min-w-[120px] flex-shrink-0 rounded-[10px] border-2 px-4 py-3 text-center transition-all",
|
||||||
{index < currentRule.parts.length - 1 && (
|
part.partType === "date" && "border-warning",
|
||||||
<div className="mt-2 flex items-center gap-1">
|
part.partType === "text" && "border-primary",
|
||||||
<span className="text-muted-foreground text-[10px] whitespace-nowrap">뒤 구분자</span>
|
part.partType === "sequence" && "border-primary",
|
||||||
<Select
|
(part.partType === "number" || part.partType === "category" || part.partType === "reference") && "border-border",
|
||||||
value={separatorTypes[part.order] || "-"}
|
isSelected && "border-primary bg-primary/5 shadow-md ring-2 ring-primary/30"
|
||||||
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>
|
onClick={() => setSelectedPartOrder(part.order)}
|
||||||
)}
|
>
|
||||||
</div>
|
<div className="seg-type text-[8px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
</React.Fragment>
|
{typeLabel}
|
||||||
))}
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<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
|
<Button
|
||||||
onClick={handleAddPart}
|
onClick={handleSave}
|
||||||
disabled={currentRule.parts.length >= maxRules || isPreview || loading}
|
disabled={isPreview || loading}
|
||||||
variant="outline"
|
className="h-9 gap-2 text-sm font-medium"
|
||||||
className="h-9 flex-1 text-sm"
|
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Save className="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" />
|
|
||||||
{loading ? "저장 중..." : "저장"}
|
{loading ? "저장 중..." : "저장"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,88 +1,163 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
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 {
|
interface NumberingRulePreviewProps {
|
||||||
config: NumberingRuleConfig;
|
config: NumberingRuleConfig;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
/** 큰 미리보기 스트립: 28px, 파트별 색상, 하단 범례 */
|
||||||
|
variant?: "default" | "strip";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
|
export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
|
||||||
config,
|
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(() => {
|
const generatedCode = useMemo(() => {
|
||||||
if (!config.parts || config.parts.length === 0) {
|
if (partItems.length === 0) return "규칙을 추가해주세요";
|
||||||
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";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 파트별 개별 구분자로 결합
|
|
||||||
const globalSep = config.separator ?? "-";
|
const globalSep = config.separator ?? "-";
|
||||||
let result = "";
|
let result = "";
|
||||||
partValues.forEach((val, idx) => {
|
partItems.forEach((item, idx) => {
|
||||||
result += val;
|
result += item.displayValue;
|
||||||
if (idx < partValues.length - 1) {
|
if (idx < partItems.length - 1) {
|
||||||
const sep = sortedParts[idx].separatorAfter ?? globalSep;
|
const part = sortedParts.find((p) => p.order === item.order);
|
||||||
result += sep;
|
result += part?.separatorAfter ?? globalSep;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return result;
|
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) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -362,15 +362,15 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||||
// 런타임 모드에서 컴포넌트 타입별 높이 처리
|
// 런타임 모드에서 컴포넌트 타입별 높이 처리
|
||||||
if (!isDesignMode) {
|
if (!isDesignMode) {
|
||||||
const compType = (component as any).componentType || component.componentConfig?.type || "";
|
const compType = (component as any).componentType || component.componentConfig?.type || "";
|
||||||
// 테이블: 부모 flex 컨테이너가 높이 관리 (flex: 1)
|
// 레이아웃 계열: 부모 래퍼를 꽉 채움 (ResponsiveGridRenderer가 % 높이 관리)
|
||||||
const flexGrowTypes = [
|
const fillParentTypes = [
|
||||||
"table-list", "v2-table-list",
|
"table-list", "v2-table-list",
|
||||||
"split-panel-layout", "split-panel-layout2",
|
"split-panel-layout", "split-panel-layout2",
|
||||||
"v2-split-panel-layout", "screen-split-panel",
|
"v2-split-panel-layout", "screen-split-panel",
|
||||||
"v2-tab-container", "tab-container",
|
"v2-tab-container", "tab-container",
|
||||||
"tabs-widget", "v2-tabs-widget",
|
"tabs-widget", "v2-tabs-widget",
|
||||||
];
|
];
|
||||||
if (flexGrowTypes.some(t => compType === t)) {
|
if (fillParentTypes.some(t => compType === t)) {
|
||||||
return "100%";
|
return "100%";
|
||||||
}
|
}
|
||||||
const autoHeightTypes = [
|
const autoHeightTypes = [
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,9 @@ function getComponentTypeId(component: ComponentData): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 디자이너 절대좌표를 캔버스 대비 비율로 변환하여 렌더링.
|
* 디자이너 절대좌표를 캔버스 대비 비율(%)로 변환하여 렌더링.
|
||||||
* 화면이 줄어들면 비율에 맞게 축소, 늘어나면 확대.
|
* 가로: 컨테이너 너비 대비 % → 반응형 스케일
|
||||||
|
* 세로: 컨테이너 높이 대비 % → 뷰포트에 맞게 자동 조절
|
||||||
*/
|
*/
|
||||||
function ProportionalRenderer({
|
function ProportionalRenderer({
|
||||||
components,
|
components,
|
||||||
|
|
@ -47,19 +48,12 @@ function ProportionalRenderer({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const topLevel = components.filter((c) => !c.parentId);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
data-screen-runtime="true"
|
data-screen-runtime="true"
|
||||||
className="bg-background relative w-full overflow-x-hidden"
|
className="bg-background relative h-full w-full overflow-hidden"
|
||||||
style={{ minHeight: containerW > 0 ? `${maxBottom * ratio}px` : "200px" }}
|
|
||||||
>
|
>
|
||||||
{containerW > 0 &&
|
{containerW > 0 &&
|
||||||
topLevel.map((component) => {
|
topLevel.map((component) => {
|
||||||
|
|
@ -72,9 +66,9 @@ function ProportionalRenderer({
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: `${(component.position.x / canvasWidth) * 100}%`,
|
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}%`,
|
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,
|
zIndex: component.position.z || 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import {
|
||||||
GroupComponent,
|
GroupComponent,
|
||||||
DataTableComponent,
|
DataTableComponent,
|
||||||
TableInfo,
|
TableInfo,
|
||||||
LayoutComponent,
|
|
||||||
FileComponent,
|
FileComponent,
|
||||||
AreaComponent,
|
AreaComponent,
|
||||||
} from "@/types/screen";
|
} from "@/types/screen";
|
||||||
|
|
@ -47,7 +46,7 @@ import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent
|
||||||
|
|
||||||
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
|
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
|
||||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
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 { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
||||||
import StyleEditor from "../StyleEditor";
|
import StyleEditor from "../StyleEditor";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
|
@ -98,6 +97,24 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
// 🆕 전체 테이블 목록 (selected-items-detail-input 등에서 사용)
|
// 🆕 전체 테이블 목록 (selected-items-detail-input 등에서 사용)
|
||||||
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName?: string }>>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
const loadAllTables = async () => {
|
const loadAllTables = async () => {
|
||||||
|
|
@ -211,20 +228,20 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
// 현재 화면의 테이블명 가져오기
|
// 현재 화면의 테이블명 가져오기
|
||||||
const currentTableName = tables?.[0]?.tableName;
|
const currentTableName = tables?.[0]?.tableName;
|
||||||
|
|
||||||
// DB input_type 가져오기 (columnMetaCache에서 최신값 조회)
|
// DB input_type만 조회 (saved config와 분리하여 전달)
|
||||||
const colName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
|
const colName = (selectedComponent as any).columnName || currentConfig.fieldKey || currentConfig.columnName;
|
||||||
const tblName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
const tblName = (selectedComponent as any).tableName || currentTable?.tableName || currentTableName;
|
||||||
const dbMeta = colName && tblName && !colName.includes(".") ? columnMetaCache[tblName]?.[colName] : undefined;
|
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 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
|
// 컴포넌트별 추가 props
|
||||||
const extraProps: Record<string, any> = {};
|
const extraProps: Record<string, any> = {};
|
||||||
const resolvedTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
const resolvedTableName = (selectedComponent as any).tableName || currentTable?.tableName || currentTableName;
|
||||||
const resolvedColumnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
|
const resolvedColumnName = (selectedComponent as any).columnName || currentConfig.fieldKey || currentConfig.columnName;
|
||||||
|
|
||||||
if (componentId === "v2-input" || componentId === "v2-select") {
|
if (componentId === "v2-input" || componentId === "v2-select") {
|
||||||
extraProps.inputType = inputType;
|
extraProps.componentType = componentId;
|
||||||
|
extraProps.inputType = dbInputType;
|
||||||
extraProps.tableName = resolvedTableName;
|
extraProps.tableName = resolvedTableName;
|
||||||
extraProps.columnName = resolvedColumnName;
|
extraProps.columnName = resolvedColumnName;
|
||||||
extraProps.screenTableName = resolvedTableName;
|
extraProps.screenTableName = resolvedTableName;
|
||||||
|
|
@ -256,7 +273,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
const currentConfig = selectedComponent.componentConfig || {};
|
const currentConfig = selectedComponent.componentConfig || {};
|
||||||
|
|
||||||
// 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지)
|
// 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지)
|
||||||
const config = currentConfig || definition.defaultProps?.componentConfig || {};
|
const config = currentConfig || (definition as any).defaultProps?.componentConfig || {};
|
||||||
|
|
||||||
const handlePanelConfigChange = (newConfig: any) => {
|
const handlePanelConfigChange = (newConfig: any) => {
|
||||||
// 🔧 Partial 업데이트: 기존 componentConfig를 유지하면서 새 설정만 병합
|
// 🔧 Partial 업데이트: 기존 componentConfig를 유지하면서 새 설정만 병합
|
||||||
|
|
@ -282,14 +299,14 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
onConfigChange={handlePanelConfigChange}
|
onConfigChange={handlePanelConfigChange}
|
||||||
tables={tables}
|
tables={tables}
|
||||||
allTables={allTables}
|
allTables={allTables}
|
||||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
screenTableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName}
|
||||||
tableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
tableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName}
|
||||||
columnName={
|
columnName={
|
||||||
(selectedComponent as any).columnName || currentConfig?.columnName || currentConfig?.fieldName
|
(selectedComponent as any).columnName || currentConfig?.columnName || currentConfig?.fieldName
|
||||||
}
|
}
|
||||||
inputType={(selectedComponent as any).inputType || currentConfig?.inputType}
|
inputType={(selectedComponent as any).inputType || currentConfig?.inputType}
|
||||||
componentType={componentType}
|
componentType={componentType}
|
||||||
tableColumns={currentTable?.columns || []}
|
tableColumns={(currentTable as any)?.columns || []}
|
||||||
allComponents={allComponents}
|
allComponents={allComponents}
|
||||||
currentComponent={selectedComponent}
|
currentComponent={selectedComponent}
|
||||||
menuObjid={menuObjid}
|
menuObjid={menuObjid}
|
||||||
|
|
@ -323,8 +340,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
componentType={componentType}
|
componentType={componentType}
|
||||||
config={selectedComponent.componentConfig || {}}
|
config={selectedComponent.componentConfig || {}}
|
||||||
onChange={handleDynamicConfigChange}
|
onChange={handleDynamicConfigChange}
|
||||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
screenTableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName}
|
||||||
tableColumns={currentTable?.columns || []}
|
tableColumns={(currentTable as any)?.columns || []}
|
||||||
tables={tables}
|
tables={tables}
|
||||||
menuObjid={menuObjid}
|
menuObjid={menuObjid}
|
||||||
allComponents={allComponents}
|
allComponents={allComponents}
|
||||||
|
|
@ -491,7 +508,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
<span className="text-muted-foreground text-xs">제목</span>
|
<span className="text-muted-foreground text-xs">제목</span>
|
||||||
<div className="w-[160px]">
|
<div className="w-[160px]">
|
||||||
<Input
|
<Input
|
||||||
value={group.title || area.title || ""}
|
value={(group as any).title || (area as any).title || ""}
|
||||||
onChange={(e) => handleUpdate("title", e.target.value)}
|
onChange={(e) => handleUpdate("title", e.target.value)}
|
||||||
placeholder="제목"
|
placeholder="제목"
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
|
|
@ -503,7 +520,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
<span className="text-muted-foreground text-xs">설명</span>
|
<span className="text-muted-foreground text-xs">설명</span>
|
||||||
<div className="w-[160px]">
|
<div className="w-[160px]">
|
||||||
<Input
|
<Input
|
||||||
value={area.description || ""}
|
value={(area as any).description || ""}
|
||||||
onChange={(e) => handleUpdate("description", e.target.value)}
|
onChange={(e) => handleUpdate("description", e.target.value)}
|
||||||
placeholder="설명"
|
placeholder="설명"
|
||||||
className="h-7 text-xs"
|
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>
|
<h4 className="text-muted-foreground py-2 text-[10px] font-semibold tracking-wider uppercase">OPTIONS</h4>
|
||||||
{(isInputField || widget.required !== undefined) &&
|
{(isInputField || widget.required !== undefined) &&
|
||||||
(() => {
|
(() => {
|
||||||
const colName = widget.columnName || selectedComponent?.columnName;
|
const colName = widget.columnName || (selectedComponent as any)?.columnName;
|
||||||
const colMeta = colName
|
const colMeta = colName
|
||||||
? currentTable?.columns?.find(
|
? (currentTable as any)?.columns?.find(
|
||||||
(c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase(),
|
(c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase(),
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -568,7 +585,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
<div className="flex items-center justify-between py-1.5">
|
<div className="flex items-center justify-between py-1.5">
|
||||||
<span className="text-muted-foreground text-xs">숨김</span>
|
<span className="text-muted-foreground text-xs">숨김</span>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedComponent.hidden === true || selectedComponent.componentConfig?.hidden === true}
|
checked={(selectedComponent as any).hidden === true || selectedComponent.componentConfig?.hidden === true}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
handleUpdate("hidden", checked);
|
handleUpdate("hidden", checked);
|
||||||
handleUpdate("componentConfig.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">
|
<div className="flex items-center justify-between py-1.5">
|
||||||
<span className="text-muted-foreground text-xs">표시</span>
|
<span className="text-muted-foreground text-xs">표시</span>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedComponent.style?.labelDisplay === true || selectedComponent.labelDisplay === true}
|
checked={selectedComponent.style?.labelDisplay === true || (selectedComponent as any).labelDisplay === true}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
const boolValue = checked === true;
|
const boolValue = checked === true;
|
||||||
handleUpdate("style.labelDisplay", boolValue);
|
handleUpdate("style.labelDisplay", boolValue);
|
||||||
|
|
@ -785,7 +802,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
const webType = selectedComponent.componentConfig?.webType;
|
const webType = selectedComponent.componentConfig?.webType;
|
||||||
|
|
||||||
// 테이블 패널에서 드래그한 컴포넌트인지 확인
|
// 테이블 패널에서 드래그한 컴포넌트인지 확인
|
||||||
const isFromTablePanel = !!(selectedComponent.tableName && selectedComponent.columnName);
|
const isFromTablePanel = !!((selectedComponent as any).tableName && (selectedComponent as any).columnName);
|
||||||
|
|
||||||
if (!componentId) {
|
if (!componentId) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -845,8 +862,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
<DynamicComponentConfigPanel
|
<DynamicComponentConfigPanel
|
||||||
componentId={componentId}
|
componentId={componentId}
|
||||||
config={selectedComponent.componentConfig || {}}
|
config={selectedComponent.componentConfig || {}}
|
||||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
screenTableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName}
|
||||||
tableColumns={currentTable?.columns || []}
|
tableColumns={(currentTable as any)?.columns || []}
|
||||||
tables={tables}
|
tables={tables}
|
||||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||||
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
||||||
|
|
@ -1006,8 +1023,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
<DynamicComponentConfigPanel
|
<DynamicComponentConfigPanel
|
||||||
componentId={widget.widgetType}
|
componentId={widget.widgetType}
|
||||||
config={widget.componentConfig || {}}
|
config={widget.componentConfig || {}}
|
||||||
screenTableName={widget.tableName || currentTable?.tableName || currentTableName}
|
screenTableName={(widget as any).tableName || currentTable?.tableName || currentTableName}
|
||||||
tableColumns={currentTable?.columns || []}
|
tableColumns={(currentTable as any)?.columns || []}
|
||||||
tables={tables}
|
tables={tables}
|
||||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||||
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
||||||
|
|
@ -1023,17 +1040,17 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* WebType 선택 (있는 경우만) */}
|
{/* WebType 선택 (있는 경우만) */}
|
||||||
{widget.webType && (
|
{(widget as any).webType && (
|
||||||
<div>
|
<div>
|
||||||
<Label>입력 타입</Label>
|
<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">
|
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{webTypes.map((wt) => (
|
{webTypes.map((wt) => (
|
||||||
<SelectItem key={wt.web_type} value={wt.web_type}>
|
<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>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@ import { apiClient } from "@/lib/api/client";
|
||||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||||
import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react";
|
import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface CategoryColumn {
|
export interface CategoryColumn {
|
||||||
tableName: string;
|
tableName: string;
|
||||||
tableLabel?: string; // 테이블 라벨 추가
|
tableLabel?: string;
|
||||||
columnName: string;
|
columnName: string;
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
inputType: string;
|
inputType: string;
|
||||||
|
|
@ -16,17 +17,30 @@ interface CategoryColumn {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CategoryColumnListProps {
|
interface CategoryColumnListProps {
|
||||||
tableName: string; // 현재 화면의 테이블 (사용하지 않음 - 형제 메뉴 전체 표시)
|
tableName: string;
|
||||||
selectedColumn: string | null;
|
selectedColumn: string | null;
|
||||||
onColumnSelect: (columnName: string, columnLabel: string, tableName: string) => void;
|
onColumnSelect: (uniqueKeyOrColumnName: string, columnLabel: string, tableName: string) => void;
|
||||||
menuObjid?: number; // 현재 메뉴 OBJID (필수)
|
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 [columns, setColumns] = useState<CategoryColumn[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
@ -151,8 +165,8 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
||||||
);
|
);
|
||||||
|
|
||||||
setColumns(columnsWithCount);
|
setColumns(columnsWithCount);
|
||||||
|
onColumnsLoaded?.(columnsWithCount);
|
||||||
|
|
||||||
// 첫 번째 컬럼 자동 선택
|
|
||||||
if (columnsWithCount.length > 0 && !selectedColumn) {
|
if (columnsWithCount.length > 0 && !selectedColumn) {
|
||||||
const firstCol = columnsWithCount[0];
|
const firstCol = columnsWithCount[0];
|
||||||
onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName);
|
onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName);
|
||||||
|
|
@ -160,6 +174,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 테이블 기반 카테고리 컬럼 조회 실패:", error);
|
console.error("❌ 테이블 기반 카테고리 컬럼 조회 실패:", error);
|
||||||
setColumns([]);
|
setColumns([]);
|
||||||
|
onColumnsLoaded?.([]);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -248,21 +263,20 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
||||||
}
|
}
|
||||||
|
|
||||||
setColumns(columnsWithCount);
|
setColumns(columnsWithCount);
|
||||||
|
onColumnsLoaded?.(columnsWithCount);
|
||||||
|
|
||||||
// 첫 번째 컬럼 자동 선택
|
|
||||||
if (columnsWithCount.length > 0 && !selectedColumn) {
|
if (columnsWithCount.length > 0 && !selectedColumn) {
|
||||||
const firstCol = columnsWithCount[0];
|
const firstCol = columnsWithCount[0];
|
||||||
onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName);
|
onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 카테고리 컬럼 조회 실패:", error);
|
console.error("❌ 카테고리 컬럼 조회 실패:", error);
|
||||||
// 에러 시에도 tableName 기반으로 fallback
|
|
||||||
if (tableName) {
|
if (tableName) {
|
||||||
console.log("⚠️ menuObjid API 에러, tableName 기반으로 fallback:", tableName);
|
|
||||||
await loadCategoryColumnsByTable();
|
await loadCategoryColumnsByTable();
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
setColumns([]);
|
setColumns([]);
|
||||||
|
onColumnsLoaded?.([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
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">
|
||||||
|
'{searchQuery}'에 대한 검색 결과가 없습니다
|
||||||
|
</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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -298,7 +378,6 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
||||||
<p className="text-muted-foreground text-xs">관리할 카테고리 컬럼을 선택하세요</p>
|
<p className="text-muted-foreground text-xs">관리할 카테고리 컬럼을 선택하세요</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 검색 입력 필드 */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
|
<Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -310,6 +389,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setSearchQuery("")}
|
onClick={() => setSearchQuery("")}
|
||||||
className="text-muted-foreground hover:text-foreground absolute right-2 top-1/2 -translate-y-1/2"
|
className="text-muted-foreground hover:text-foreground absolute right-2 top-1/2 -translate-y-1/2"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ export const CategoryValueAddDialog: React.FC<
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
|
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
|
||||||
이름
|
이름 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="valueLabel"
|
id="valueLabel"
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ export const CategoryValueEditDialog: React.FC<
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
|
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
|
||||||
이름
|
이름 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="valueLabel"
|
id="valueLabel"
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ interface CategoryValueManagerProps {
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
onValueCountChange?: (count: number) => void;
|
onValueCountChange?: (count: number) => void;
|
||||||
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
|
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
|
||||||
|
/** 편집기 헤더 오른쪽에 표시할 내용 (예: 트리/목록 세그먼트) */
|
||||||
|
headerRight?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
|
|
@ -38,6 +40,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
columnLabel,
|
columnLabel,
|
||||||
onValueCountChange,
|
onValueCountChange,
|
||||||
menuObjid,
|
menuObjid,
|
||||||
|
headerRight,
|
||||||
}) => {
|
}) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [values, setValues] = useState<TableCategoryValue[]>([]);
|
const [values, setValues] = useState<TableCategoryValue[]>([]);
|
||||||
|
|
@ -284,7 +287,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* 헤더 */}
|
{/* 편집기 헤더: 컬럼명 + 값 수 + 비활성 토글 + 새 값 추가 + headerRight(트리·목록 세그먼트 등) */}
|
||||||
<div className="border-b p-4">
|
<div className="border-b p-4">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -308,11 +311,11 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
비활성 항목 표시
|
비활성 항목 표시
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={() => setIsAddDialogOpen(true)} size="sm">
|
<Button onClick={() => setIsAddDialogOpen(true)} size="sm">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
새 값 추가
|
새 값 추가
|
||||||
</Button>
|
</Button>
|
||||||
|
{headerRight != null ? <div className="flex items-center">{headerRight}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -405,7 +408,6 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
value.isActive !== false
|
value.isActive !== false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="data-[state=checked]:bg-emerald-500"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
Search,
|
Search,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
@ -59,6 +60,8 @@ interface CategoryValueManagerTreeProps {
|
||||||
columnName: string;
|
columnName: string;
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
onValueCountChange?: (count: number) => void;
|
onValueCountChange?: (count: number) => void;
|
||||||
|
/** 편집기 헤더 오른쪽에 표시할 내용 (예: 트리/목록 세그먼트) */
|
||||||
|
headerRight?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 트리 노드 컴포넌트
|
// 트리 노드 컴포넌트
|
||||||
|
|
@ -114,13 +117,13 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 깊이별 아이콘
|
// 깊이별 아이콘 (대/중분류 = Folder, 소분류 = Tag)
|
||||||
const getIcon = () => {
|
const getIcon = () => {
|
||||||
if (hasChildren) {
|
if (hasChildren) {
|
||||||
return isExpanded ? (
|
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" />;
|
return <Tag className="h-4 w-4 text-primary" />;
|
||||||
|
|
@ -141,31 +144,28 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="mb-px">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex items-center gap-1 rounded-md px-2 py-2 transition-colors",
|
"group flex cursor-pointer items-center gap-[5px] rounded-[6px] px-[8px] py-[5px] transition-colors",
|
||||||
isSelected ? "border-primary bg-primary/10 border-l-2" : "hover:bg-muted/50",
|
isSelected ? "border-primary border-l-2 bg-primary/10" : "hover:bg-muted/50",
|
||||||
isChecked && "bg-primary/5",
|
isChecked && "bg-primary/5",
|
||||||
"cursor-pointer",
|
|
||||||
)}
|
)}
|
||||||
style={{ paddingLeft: `${level * 20 + 8}px` }}
|
style={{ paddingLeft: `${level * 20 + 8}px` }}
|
||||||
onClick={() => onSelect(node)}
|
onClick={() => onSelect(node)}
|
||||||
>
|
>
|
||||||
{/* 체크박스 */}
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
onCheck(node.valueId, checked as boolean);
|
onCheck(node.valueId, checked as boolean);
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="mr-1"
|
className="mr-1 shrink-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 확장 토글 */}
|
|
||||||
<button
|
<button
|
||||||
type="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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (hasChildren) {
|
if (hasChildren) {
|
||||||
|
|
@ -184,22 +184,24 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 아이콘 */}
|
|
||||||
{getIcon()}
|
{getIcon()}
|
||||||
|
|
||||||
{/* 라벨 */}
|
<div className="flex min-w-0 flex-1 items-center gap-[5px]">
|
||||||
<div className="flex flex-1 items-center gap-2">
|
<span className={cn("truncate text-sm", node.depth === 1 && "font-medium")}>
|
||||||
<span className={cn("text-sm", node.depth === 1 && "font-medium")}>{node.valueLabel}</span>
|
{node.valueLabel}
|
||||||
<span className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]">{getDepthLabel()}</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 비활성 표시 */}
|
|
||||||
{!node.isActive && (
|
{!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 shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
|
||||||
{canAddChild && (
|
{canAddChild && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -272,6 +274,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||||
columnName,
|
columnName,
|
||||||
columnLabel,
|
columnLabel,
|
||||||
onValueCountChange,
|
onValueCountChange,
|
||||||
|
headerRight,
|
||||||
}) => {
|
}) => {
|
||||||
// 상태
|
// 상태
|
||||||
const [tree, setTree] = useState<CategoryValue[]>([]);
|
const [tree, setTree] = useState<CategoryValue[]>([]);
|
||||||
|
|
@ -634,10 +637,13 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* 헤더 */}
|
{/* 편집기 헤더: 컬럼명 + 값 수 Badge + 비활성/전체펼침/대분류추가 + headerRight(트리·목록 세그먼트 등) */}
|
||||||
<div className="mb-3 flex items-center justify-between border-b pb-3">
|
<div className="mb-3 flex items-center justify-between border-b pb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-base font-semibold">{columnLabel} 카테고리</h3>
|
<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 && (
|
{checkedIds.size > 0 && (
|
||||||
<span className="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs">
|
<span className="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs">
|
||||||
{checkedIds.size}개 선택
|
{checkedIds.size}개 선택
|
||||||
|
|
@ -665,6 +671,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
대분류 추가
|
대분류 추가
|
||||||
</Button>
|
</Button>
|
||||||
|
{headerRight != null ? <div className="flex items-center">{headerRight}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -720,7 +727,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||||
<p className="text-muted-foreground mt-1 text-xs">상단의 대분류 추가 버튼을 클릭하여 시작하세요</p>
|
<p className="text-muted-foreground mt-1 text-xs">상단의 대분류 추가 버튼을 클릭하여 시작하세요</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2">
|
<div className="py-1">
|
||||||
{tree.map((node) => (
|
{tree.map((node) => (
|
||||||
<TreeNode
|
<TreeNode
|
||||||
key={node.valueId}
|
key={node.valueId}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ function TabsList({
|
||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
data-slot="tabs-list"
|
data-slot="tabs-list"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -42,7 +42,7 @@ function TabsTrigger({
|
||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -347,7 +347,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
if (!tableName || currentData.length === 0) {
|
if (!tableName || currentData.length === 0) {
|
||||||
console.warn("🔴 [V2Repeater] 저장 스킵:", { tableName, dataLength: currentData.length });
|
console.warn("🔴 [V2Repeater] 저장 스킵:", { tableName, dataLength: currentData.length });
|
||||||
toast.warning(`[디버그] V2Repeater 저장 스킵: data=${currentData.length}, table=${tableName}`);
|
toast.warning(`[디버그] V2Repeater 저장 스킵: data=${currentData.length}, table=${tableName}`);
|
||||||
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -356,7 +355,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
const hasFkSource = sourceCol && mainFormData && mainFormData[sourceCol] !== undefined;
|
const hasFkSource = sourceCol && mainFormData && mainFormData[sourceCol] !== undefined;
|
||||||
if (!hasFkSource && !masterRecordId) {
|
if (!hasFkSource && !masterRecordId) {
|
||||||
console.warn("🔴 [V2Repeater] FK 소스 값/masterRecordId 모두 없어 저장 스킵");
|
console.warn("🔴 [V2Repeater] FK 소스 값/masterRecordId 모두 없어 저장 스킵");
|
||||||
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -433,9 +433,9 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
|
||||||
loadAll();
|
loadAll();
|
||||||
}, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, availableTables, loadTableColumns]);
|
}, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, availableTables, loadTableColumns]);
|
||||||
|
|
||||||
// 화면 목록 로드 (모달 액션용)
|
// 화면 목록 로드 (모달/편집/네비게이트 액션용)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (actionType !== "modal" && actionType !== "navigate") return;
|
if (actionType !== "modal" && actionType !== "navigate" && actionType !== "edit") return;
|
||||||
if (screens.length > 0) return;
|
if (screens.length > 0) return;
|
||||||
|
|
||||||
const loadScreens = async () => {
|
const loadScreens = async () => {
|
||||||
|
|
@ -870,7 +870,6 @@ const ActionDetailSection: React.FC<{
|
||||||
switch (actionType) {
|
switch (actionType) {
|
||||||
case "save":
|
case "save":
|
||||||
case "delete":
|
case "delete":
|
||||||
case "edit":
|
|
||||||
case "quickInsert":
|
case "quickInsert":
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
<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">
|
<span className="text-sm font-medium">
|
||||||
{actionType === "save" && "저장 설정"}
|
{actionType === "save" && "저장 설정"}
|
||||||
{actionType === "delete" && "삭제 설정"}
|
{actionType === "delete" && "삭제 설정"}
|
||||||
{actionType === "edit" && "편집 설정"}
|
|
||||||
{actionType === "quickInsert" && "즉시 저장 설정"}
|
{actionType === "quickInsert" && "즉시 저장 설정"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -900,6 +898,147 @@ const ActionDetailSection: React.FC<{
|
||||||
</div>
|
</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":
|
case "modal":
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||||
|
|
|
||||||
|
|
@ -77,9 +77,9 @@ interface CategoryValueOption {
|
||||||
valueLabel: string;
|
valueLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 하위 호환: 기존 config에서 fieldType 추론 ───
|
// ─── 하위 호환: 기존 config에서 fieldType 추론 (우선순위: DB값 > 사용자 fieldType > 컴포넌트구조 > saved config > 기본값) ───
|
||||||
function resolveFieldType(config: Record<string, any>, componentType?: string, metaInputType?: string): FieldType {
|
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") {
|
if (metaInputType && metaInputType !== "direct" && metaInputType !== "auto") {
|
||||||
const dbType = metaInputType as FieldType;
|
const dbType = metaInputType as FieldType;
|
||||||
if (["text", "number", "textarea", "numbering", "select", "category", "entity"].includes(dbType)) {
|
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;
|
if (config.fieldType) return config.fieldType as FieldType;
|
||||||
|
|
||||||
// v2-select 계열
|
// (c) v2-select 계열: componentType 또는 config.source 기반
|
||||||
if (componentType === "v2-select" || config.source) {
|
if (componentType === "v2-select" || config.source) {
|
||||||
const source = config.source === "code" ? "category" : config.source;
|
const source = config.source === "code" ? "category" : config.source;
|
||||||
if (source === "entity") return "entity";
|
if (source === "entity") return "entity";
|
||||||
|
|
@ -97,11 +98,13 @@ function resolveFieldType(config: Record<string, any>, componentType?: string, m
|
||||||
return "select";
|
return "select";
|
||||||
}
|
}
|
||||||
|
|
||||||
// v2-input 계열
|
// (d) saved config fallback (config.inputType / config.type)
|
||||||
const it = config.inputType || config.type;
|
const it = config.inputType || config.type;
|
||||||
if (it === "number") return "number";
|
if (it === "number") return "number";
|
||||||
if (it === "textarea") return "textarea";
|
if (it === "textarea") return "textarea";
|
||||||
if (it === "numbering") return "numbering";
|
if (it === "numbering") return "numbering";
|
||||||
|
|
||||||
|
// (e) 최종 기본값
|
||||||
return "text";
|
return "text";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,12 +2,13 @@
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
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 { 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 = () => {
|
export const useLogin = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -73,67 +74,34 @@ export const useLogin = () => {
|
||||||
}, [formData]);
|
}, [formData]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 호출 공통 함수
|
* 기존 인증 상태 확인 (apiCall 사용)
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 기존 인증 상태 확인
|
|
||||||
*/
|
*/
|
||||||
const checkExistingAuth = useCallback(async () => {
|
const checkExistingAuth = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
// 로컬 스토리지에서 토큰 확인
|
|
||||||
const token = localStorage.getItem("authToken");
|
const token = localStorage.getItem("authToken");
|
||||||
if (!token) {
|
if (!token) return;
|
||||||
// 토큰이 없으면 로그인 페이지 유지
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 토큰이 있으면 API 호출로 유효성 확인
|
const result = await apiCall<{ isAuthenticated?: boolean }>("GET", AUTH_CONFIG.ENDPOINTS.STATUS);
|
||||||
const result = await apiCall(AUTH_CONFIG.ENDPOINTS.STATUS);
|
|
||||||
|
|
||||||
// 백엔드가 isAuthenticated 필드를 반환함
|
|
||||||
if (result.success && result.data?.isAuthenticated) {
|
if (result.success && result.data?.isAuthenticated) {
|
||||||
// 이미 로그인된 경우 메인으로 리다이렉트
|
|
||||||
router.push(AUTH_CONFIG.ROUTES.MAIN);
|
router.push(AUTH_CONFIG.ROUTES.MAIN);
|
||||||
} else {
|
} else {
|
||||||
// 토큰이 유효하지 않으면 제거
|
|
||||||
localStorage.removeItem("authToken");
|
localStorage.removeItem("authToken");
|
||||||
document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax";
|
document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax";
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
// 에러가 발생하면 토큰 제거
|
|
||||||
localStorage.removeItem("authToken");
|
localStorage.removeItem("authToken");
|
||||||
document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax";
|
document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax";
|
||||||
console.debug("기존 인증 체크 중 오류 (정상):", error);
|
|
||||||
}
|
}
|
||||||
}, [apiCall, router]);
|
}, [router]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 로그인 처리
|
* 로그인 처리 (apiCall 사용 - Axios 기반, fetch 미사용)
|
||||||
*/
|
*/
|
||||||
const handleLogin = useCallback(
|
const handleLogin = useCallback(
|
||||||
async (e: React.FormEvent) => {
|
async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// 입력값 검증
|
|
||||||
const validationError = validateForm();
|
const validationError = validateForm();
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
setError(validationError);
|
setError(validationError);
|
||||||
|
|
@ -144,9 +112,13 @@ export const useLogin = () => {
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await apiCall(AUTH_CONFIG.ENDPOINTS.LOGIN, {
|
const result = await apiCall<{
|
||||||
method: "POST",
|
token?: string;
|
||||||
body: JSON.stringify(formData),
|
firstMenuPath?: string;
|
||||||
|
popLandingPath?: string;
|
||||||
|
}>("POST", AUTH_CONFIG.ENDPOINTS.LOGIN, {
|
||||||
|
userId: formData.userId,
|
||||||
|
password: formData.password,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success && result.data?.token) {
|
if (result.success && result.data?.token) {
|
||||||
|
|
@ -185,7 +157,7 @@ export const useLogin = () => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[formData, validateForm, apiCall, router, isPopMode],
|
[formData, validateForm, router, isPopMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 컴포넌트 마운트 시 기존 인증 상태 확인
|
// 컴포넌트 마운트 시 기존 인증 상태 확인
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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 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) {
|
if (forceReload || isStale) {
|
||||||
delete columnMetaCache[tableName];
|
delete columnMetaCache[tableName];
|
||||||
delete columnMetaLoading[tableName];
|
delete columnMetaLoading[tableName];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (columnMetaLoading[tableName]) {
|
if (tableName in columnMetaLoading) {
|
||||||
await columnMetaLoading[tableName];
|
await columnMetaLoading[tableName];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -663,7 +665,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 새 컴포넌트 시스템에서 먼저 조회
|
// 1. 새 컴포넌트 시스템에서 먼저 조회
|
||||||
const newComponent = ComponentRegistry.getComponent(componentType);
|
const newComponent =
|
||||||
|
componentType != null ? ComponentRegistry.getComponent(componentType) : null;
|
||||||
|
|
||||||
if (newComponent) {
|
if (newComponent) {
|
||||||
// 새 컴포넌트 시스템으로 렌더링
|
// 새 컴포넌트 시스템으로 렌더링
|
||||||
|
|
@ -775,7 +778,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
|
|
||||||
// 렌더러 props 구성
|
// 렌더러 props 구성
|
||||||
// 숨김 값 추출
|
// 숨김 값 추출
|
||||||
const hiddenValue = component.hidden || component.componentConfig?.hidden;
|
const hiddenValue = (component as any).hidden || component.componentConfig?.hidden;
|
||||||
|
|
||||||
// 숨김 처리: 인터랙티브 모드(실제 뷰)에서만 숨김, 디자인 모드에서는 표시
|
// 숨김 처리: 인터랙티브 모드(실제 뷰)에서만 숨김, 디자인 모드에서는 표시
|
||||||
if (hiddenValue && isInteractive) {
|
if (hiddenValue && isInteractive) {
|
||||||
|
|
@ -892,7 +895,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
// 새로운 기능들 전달
|
// 새로운 기능들 전달
|
||||||
// 🆕 webTypeConfig.numberingRuleId가 있으면 autoGeneration으로 변환
|
// 🆕 webTypeConfig.numberingRuleId가 있으면 autoGeneration으로 변환
|
||||||
autoGeneration:
|
autoGeneration:
|
||||||
component.autoGeneration ||
|
(component as any).autoGeneration ||
|
||||||
component.componentConfig?.autoGeneration ||
|
component.componentConfig?.autoGeneration ||
|
||||||
((component as any).webTypeConfig?.numberingRuleId
|
((component as any).webTypeConfig?.numberingRuleId
|
||||||
? {
|
? {
|
||||||
|
|
@ -992,10 +995,15 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
|
|
||||||
let renderedElement: React.ReactElement;
|
let renderedElement: React.ReactElement;
|
||||||
if (isClass) {
|
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();
|
renderedElement = rendererInstance.render();
|
||||||
} else {
|
} 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% 채움
|
// 수평 라벨 → 라벨을 컴포넌트 영역 바깥에 absolute 배치, 입력은 100% 채움
|
||||||
|
|
@ -1004,7 +1012,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
const labelFontSize = component.style?.labelFontSize || "14px";
|
const labelFontSize = component.style?.labelFontSize || "14px";
|
||||||
const labelColor = getAdaptiveLabelColor(component.style?.labelColor);
|
const labelColor = getAdaptiveLabelColor(component.style?.labelColor);
|
||||||
const labelFontWeight = component.style?.labelFontWeight || "500";
|
const labelFontWeight = component.style?.labelFontWeight || "500";
|
||||||
const isRequired = effectiveComponent.required || isColumnRequiredByMeta(screenTableName, baseColumnName);
|
const isRequired =
|
||||||
|
effectiveComponent.required ||
|
||||||
|
isColumnRequiredByMeta(screenTableName ?? "", baseColumnName ?? "");
|
||||||
const isLeft = labelPosition === "left";
|
const isLeft = labelPosition === "left";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1038,7 +1048,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 레거시 시스템에서 조회
|
// 2. 레거시 시스템에서 조회
|
||||||
const renderer = legacyComponentRegistry.get(componentType);
|
const renderer =
|
||||||
|
componentType != null ? legacyComponentRegistry.get(componentType) : undefined;
|
||||||
|
|
||||||
if (!renderer) {
|
if (!renderer) {
|
||||||
console.error(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`, {
|
console.error(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`, {
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
const [forceUpdate, setForceUpdate] = useState(0);
|
const [forceUpdate, setForceUpdate] = useState(0);
|
||||||
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
|
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리
|
// 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리
|
||||||
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
|
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
|
||||||
|
|
@ -217,18 +218,17 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
}
|
}
|
||||||
}, [component.id, getUniqueKey, recordId, isRecordMode]);
|
}, [component.id, getUniqueKey, recordId, isRecordMode]);
|
||||||
|
|
||||||
// 🆕 모달 닫힘/저장 성공 시 localStorage 파일 캐시 정리 (등록 후 재등록 시 이전 파일 잔존 방지)
|
// 모달 닫힘/저장 성공 시 localStorage 파일 캐시 정리 (등록 후 재등록 시 이전 파일 잔존 방지)
|
||||||
|
// 모달(Dialog) 내부의 컴포넌트만 초기화 대상 - 일반 화면의 파일 업로드는 초기화하지 않음
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClearFileCache = (event: Event) => {
|
const handleClearFileCache = (event: Event) => {
|
||||||
|
// 모달 내부 컴포넌트만 초기화 (일반 화면에서는 스킵)
|
||||||
|
const isInModal = containerRef.current ? !!containerRef.current.closest('[role="dialog"]') : false;
|
||||||
|
if (!isInModal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const backupKey = getUniqueKey();
|
const backupKey = getUniqueKey();
|
||||||
const eventType = event.type;
|
|
||||||
console.log("🧹 [DEBUG-CLEAR] 파일 캐시 정리 이벤트 수신:", {
|
|
||||||
eventType,
|
|
||||||
backupKey,
|
|
||||||
componentId: component.id,
|
|
||||||
currentFiles: uploadedFiles.length,
|
|
||||||
hasLocalStorage: !!localStorage.getItem(backupKey),
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(backupKey);
|
localStorage.removeItem(backupKey);
|
||||||
setUploadedFiles([]);
|
setUploadedFiles([]);
|
||||||
|
|
@ -238,22 +238,15 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
delete globalFileState[backupKey];
|
delete globalFileState[backupKey];
|
||||||
(window as any).globalFileState = globalFileState;
|
(window as any).globalFileState = globalFileState;
|
||||||
}
|
}
|
||||||
console.log("🧹 [DEBUG-CLEAR] 정리 완료:", backupKey);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("파일 캐시 정리 실패:", e);
|
console.warn("파일 캐시 정리 실패:", e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// EditModal 닫힘, ScreenModal 연속 등록 저장 성공, 일반 저장 성공 모두 처리
|
|
||||||
window.addEventListener("closeEditModal", handleClearFileCache);
|
window.addEventListener("closeEditModal", handleClearFileCache);
|
||||||
window.addEventListener("saveSuccess", handleClearFileCache);
|
window.addEventListener("saveSuccess", handleClearFileCache);
|
||||||
window.addEventListener("saveSuccessInModal", handleClearFileCache);
|
window.addEventListener("saveSuccessInModal", handleClearFileCache);
|
||||||
|
|
||||||
console.log("🔎 [DEBUG-CLEAR] 이벤트 리스너 등록 완료:", {
|
|
||||||
componentId: component.id,
|
|
||||||
backupKey: getUniqueKey(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("closeEditModal", handleClearFileCache);
|
window.removeEventListener("closeEditModal", handleClearFileCache);
|
||||||
window.removeEventListener("saveSuccess", handleClearFileCache);
|
window.removeEventListener("saveSuccess", handleClearFileCache);
|
||||||
|
|
@ -1190,10 +1183,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={containerRef}
|
||||||
style={{
|
style={{
|
||||||
...componentStyle,
|
...componentStyle,
|
||||||
width: "100%", // 🆕 부모 컨테이너 너비에 맞춤
|
width: "100%",
|
||||||
height: "100%", // 🆕 부모 컨테이너 높이에 맞춤
|
height: "100%",
|
||||||
border: "none !important",
|
border: "none !important",
|
||||||
boxShadow: "none !important",
|
boxShadow: "none !important",
|
||||||
outline: "none !important",
|
outline: "none !important",
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ import "./v2-approval-step/ApprovalStepRenderer"; // 결재 단계 시각화
|
||||||
import "./v2-status-count/StatusCountRenderer"; // 상태별 카운트 카드
|
import "./v2-status-count/StatusCountRenderer"; // 상태별 카운트 카드
|
||||||
import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준
|
import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준
|
||||||
import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅
|
import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅
|
||||||
|
import "./v2-shipping-plan-editor/ShippingPlanEditorRenderer"; // 출하계획 동시등록
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
|
|
|
||||||
|
|
@ -604,7 +604,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
toast.dismiss();
|
toast.dismiss();
|
||||||
|
|
||||||
// UI 전환 액션 및 모달 액션은 로딩 토스트 표시하지 않음
|
// 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)) {
|
if (!silentActions.includes(actionConfig.type)) {
|
||||||
currentLoadingToastRef.current = toast.loading(
|
currentLoadingToastRef.current = toast.loading(
|
||||||
actionConfig.type === "save"
|
actionConfig.type === "save"
|
||||||
|
|
@ -631,7 +631,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
// 실패한 경우 오류 처리
|
// 실패한 경우 오류 처리
|
||||||
if (!success) {
|
if (!success) {
|
||||||
// UI 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시)
|
// 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)) {
|
if (silentErrorActions.includes(actionConfig.type)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -691,25 +691,27 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
target: "all",
|
target: "all",
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. 모달 닫기 (약간의 딜레이)
|
// 2. 모달 닫기 (약간의 딜레이, 모달 내부에서만)
|
||||||
setTimeout(() => {
|
const isInEditModal = (props as any).isInModal;
|
||||||
// EditModal 내부인지 확인 (isInModal prop 사용)
|
const isInScreenModal = !!(props as any).isScreenModal || !!context.onClose;
|
||||||
const isInEditModal = (props as any).isInModal;
|
|
||||||
|
|
||||||
if (isInEditModal) {
|
if (isInEditModal || isInScreenModal) {
|
||||||
v2EventBus.emitSync(V2_EVENTS.MODAL_CLOSE, {
|
setTimeout(() => {
|
||||||
modalId: "edit-modal",
|
if (isInEditModal) {
|
||||||
reason: "save",
|
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 || "",
|
||||||
});
|
});
|
||||||
}
|
}, 100);
|
||||||
|
}
|
||||||
// ScreenModal은 연속 등록 모드를 지원하므로 saveSuccessInModal 이벤트 발생
|
|
||||||
v2EventBus.emitSync(V2_EVENTS.MODAL_SAVE_SUCCESS, {
|
|
||||||
modalId: "screen-modal",
|
|
||||||
savedData: context.formData || {},
|
|
||||||
tableName: context.tableName || "",
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 로딩 토스트 제거
|
// 로딩 토스트 제거
|
||||||
|
|
@ -938,11 +940,28 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
effectiveMappingRules = multiTableMappings[0]?.mappingRules || [];
|
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 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 {
|
return {
|
||||||
...mappedRow,
|
...mappedRow,
|
||||||
|
...sourceTracking,
|
||||||
...additionalData,
|
...additionalData,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,19 @@
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2 카테고리 관리 컴포넌트
|
* V2 카테고리 관리 컴포넌트
|
||||||
* - 트리 구조 기반 카테고리 값 관리
|
* - 대시보드 레이아웃: Stat Strip + 좌측 테이블 nav + 칩 바 + 트리/목록 편집기
|
||||||
* - 3단계 계층 구조 지원 (대분류/중분류/소분류)
|
* - 3단계 계층 구조 지원 (대분류/중분류/소분류)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from "react";
|
import React, { useState, useCallback, useMemo } from "react";
|
||||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||||
|
import type { CategoryColumn } from "@/components/table-category/CategoryColumnList";
|
||||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||||
import { LayoutList, TreeDeciduous } from "lucide-react";
|
import { LayoutList, TreeDeciduous } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ResponsiveSplitPanel } from "@/components/common/ResponsiveSplitPanel";
|
|
||||||
import { V2CategoryManagerConfig, defaultV2CategoryManagerConfig, ViewMode } from "./types";
|
import { V2CategoryManagerConfig, defaultV2CategoryManagerConfig, ViewMode } from "./types";
|
||||||
|
|
||||||
interface V2CategoryManagerComponentProps {
|
interface V2CategoryManagerComponentProps {
|
||||||
|
|
@ -33,80 +34,87 @@ export function V2CategoryManagerComponent({
|
||||||
componentConfig,
|
componentConfig,
|
||||||
...props
|
...props
|
||||||
}: V2CategoryManagerComponentProps) {
|
}: V2CategoryManagerComponentProps) {
|
||||||
// 설정 병합 (componentConfig도 포함)
|
|
||||||
const config: V2CategoryManagerConfig = {
|
const config: V2CategoryManagerConfig = {
|
||||||
...defaultV2CategoryManagerConfig,
|
...defaultV2CategoryManagerConfig,
|
||||||
...externalConfig,
|
...externalConfig,
|
||||||
...componentConfig,
|
...componentConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
// tableName 우선순위: props > selectedScreen > componentConfig
|
const effectiveTableName =
|
||||||
const effectiveTableName = tableName || selectedScreen?.tableName || (componentConfig as any)?.tableName || "";
|
tableName || selectedScreen?.tableName || (componentConfig as any)?.tableName || "";
|
||||||
|
|
||||||
// menuObjid 우선순위: props > selectedScreen
|
|
||||||
const propsMenuObjid = typeof props.menuObjid === "number" ? props.menuObjid : undefined;
|
const propsMenuObjid = typeof props.menuObjid === "number" ? props.menuObjid : undefined;
|
||||||
const effectiveMenuObjid = menuObjid || propsMenuObjid || selectedScreen?.menuObjid;
|
const effectiveMenuObjid = menuObjid || propsMenuObjid || selectedScreen?.menuObjid;
|
||||||
|
|
||||||
// 디버그 로그
|
const [columns, setColumns] = useState<CategoryColumn[]>([]);
|
||||||
useEffect(() => {
|
const [selectedTable, setSelectedTable] = useState<string | null>(null);
|
||||||
console.log("🔍 V2CategoryManagerComponent props:", {
|
|
||||||
tableName,
|
|
||||||
menuObjid,
|
|
||||||
selectedScreen,
|
|
||||||
effectiveTableName,
|
|
||||||
effectiveMenuObjid,
|
|
||||||
config,
|
|
||||||
});
|
|
||||||
}, [tableName, menuObjid, selectedScreen, effectiveTableName, effectiveMenuObjid, config]);
|
|
||||||
|
|
||||||
// 선택된 컬럼 상태
|
|
||||||
const [selectedColumn, setSelectedColumn] = useState<{
|
const [selectedColumn, setSelectedColumn] = useState<{
|
||||||
uniqueKey: string;
|
uniqueKey: string;
|
||||||
columnName: string;
|
columnName: string;
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
tableName: string;
|
tableName: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// 뷰 모드 상태
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>(config.viewMode);
|
const [viewMode, setViewMode] = useState<ViewMode>(config.viewMode);
|
||||||
|
|
||||||
// 컬럼 선택 핸들러
|
const handleColumnsLoaded = useCallback((loaded: CategoryColumn[]) => {
|
||||||
const handleColumnSelect = useCallback((uniqueKey: string, columnLabel: string, tableName: string) => {
|
setColumns(loaded);
|
||||||
const columnName = uniqueKey.split(".")[1];
|
if (loaded.length > 0) {
|
||||||
setSelectedColumn({ uniqueKey, columnName, columnLabel, tableName });
|
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 = (
|
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">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
{selectedColumn ? (
|
{selectedColumn ? (
|
||||||
viewMode === "tree" ? (
|
viewMode === "tree" ? (
|
||||||
|
|
@ -115,6 +123,7 @@ export function V2CategoryManagerComponent({
|
||||||
tableName={selectedColumn.tableName}
|
tableName={selectedColumn.tableName}
|
||||||
columnName={selectedColumn.columnName}
|
columnName={selectedColumn.columnName}
|
||||||
columnLabel={selectedColumn.columnLabel}
|
columnLabel={selectedColumn.columnLabel}
|
||||||
|
headerRight={viewModeSegment}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CategoryValueManager
|
<CategoryValueManager
|
||||||
|
|
@ -123,6 +132,7 @@ export function V2CategoryManagerComponent({
|
||||||
columnName={selectedColumn.columnName}
|
columnName={selectedColumn.columnName}
|
||||||
columnLabel={selectedColumn.columnLabel}
|
columnLabel={selectedColumn.columnLabel}
|
||||||
menuObjid={effectiveMenuObjid}
|
menuObjid={effectiveMenuObjid}
|
||||||
|
headerRight={viewModeSegment}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -130,7 +140,9 @@ export function V2CategoryManagerComponent({
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<TreeDeciduous className="text-muted-foreground/30 h-10 w-10" />
|
<TreeDeciduous className="text-muted-foreground/30 h-10 w-10" />
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{config.showColumnList ? "좌측에서 관리할 카테고리 컬럼을 선택하세요" : "카테고리 컬럼이 설정되지 않았습니다"}
|
{config.showColumnList
|
||||||
|
? "칩에서 카테고리 컬럼을 선택하세요"
|
||||||
|
: "카테고리 컬럼이 설정되지 않았습니다"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -148,24 +160,107 @@ export function V2CategoryManagerComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveSplitPanel
|
<div
|
||||||
left={
|
className="flex h-full flex-col overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm"
|
||||||
<CategoryColumnList
|
style={{ height: config.height }}
|
||||||
tableName={effectiveTableName}
|
>
|
||||||
selectedColumn={selectedColumn?.uniqueKey || null}
|
{/* Stat Strip: 카테고리 컬럼(primary) | 전체 값(success) | 테이블(primary) | 비활성(warning) */}
|
||||||
onColumnSelect={handleColumnSelect}
|
<div className="grid grid-cols-4 border-b bg-background">
|
||||||
menuObjid={effectiveMenuObjid}
|
<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}
|
||||||
right={rightContent}
|
</div>
|
||||||
leftTitle="카테고리 컬럼"
|
<div className="mt-1 text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
leftWidth={config.leftPanelWidth}
|
카테고리 컬럼
|
||||||
minLeftWidth={10}
|
</div>
|
||||||
maxLeftWidth={40}
|
</div>
|
||||||
height={config.height}
|
<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;
|
export default V2CategoryManagerComponent;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
const [forceUpdate, setForceUpdate] = useState(0);
|
const [forceUpdate, setForceUpdate] = useState(0);
|
||||||
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
|
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
// objid 기반으로 파일이 로드되었는지 추적 (다른 이펙트가 덮어쓰지 않도록 방지)
|
// objid 기반으로 파일이 로드되었는지 추적 (다른 이펙트가 덮어쓰지 않도록 방지)
|
||||||
const filesLoadedFromObjidRef = useRef(false);
|
const filesLoadedFromObjidRef = useRef(false);
|
||||||
|
|
||||||
|
|
@ -197,7 +198,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!imageObjidFromFormData) {
|
if (!imageObjidFromFormData) {
|
||||||
// formData에서 값이 사라지면 파일 목록도 초기화 (새 등록 시)
|
// formData에서 값이 사라지면 파일 목록도 초기화 (새 등록 시)
|
||||||
if (uploadedFiles.length > 0 && !isRecordMode) {
|
// 단, 모달 내부의 컴포넌트만 초기화 - 일반 화면에서는 저장 후 리셋으로 인한 초기화 방지
|
||||||
|
const isInModal = containerRef.current ? !!containerRef.current.closest('[role="dialog"]') : false;
|
||||||
|
if (uploadedFiles.length > 0 && !isRecordMode && isInModal) {
|
||||||
setUploadedFiles([]);
|
setUploadedFiles([]);
|
||||||
filesLoadedFromObjidRef.current = false;
|
filesLoadedFromObjidRef.current = false;
|
||||||
}
|
}
|
||||||
|
|
@ -1058,11 +1061,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={containerRef}
|
||||||
style={{
|
style={{
|
||||||
...componentStyle,
|
...componentStyle,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
// 🔧 !important 제거 - 커스텀 스타일이 없을 때만 기본값 적용
|
|
||||||
border: hasCustomBorder ? undefined : "none",
|
border: hasCustomBorder ? undefined : "none",
|
||||||
boxShadow: "none",
|
boxShadow: "none",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
|
|
|
||||||
|
|
@ -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} />;
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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[];
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -35,7 +35,11 @@ interface SingleTableWithStickyProps {
|
||||||
editingValue?: string;
|
editingValue?: string;
|
||||||
onEditingValueChange?: (value: string) => void;
|
onEditingValueChange?: (value: string) => void;
|
||||||
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => 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
|
// 검색 하이라이트 관련 props
|
||||||
searchHighlights?: Set<string>;
|
searchHighlights?: Set<string>;
|
||||||
currentSearchIndex?: number;
|
currentSearchIndex?: number;
|
||||||
|
|
@ -69,7 +73,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
editingValue,
|
editingValue,
|
||||||
onEditingValueChange,
|
onEditingValueChange,
|
||||||
onEditKeyDown,
|
onEditKeyDown,
|
||||||
|
onEditSave,
|
||||||
editInputRef,
|
editInputRef,
|
||||||
|
columnMeta,
|
||||||
|
categoryMappings,
|
||||||
// 검색 하이라이트 관련 props
|
// 검색 하이라이트 관련 props
|
||||||
searchHighlights,
|
searchHighlights,
|
||||||
currentSearchIndex = 0,
|
currentSearchIndex = 0,
|
||||||
|
|
@ -102,9 +109,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TableHeader
|
<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) => {
|
{actualColumns.map((column, colIndex) => {
|
||||||
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
||||||
const leftFixedWidth = actualColumns
|
const leftFixedWidth = actualColumns
|
||||||
|
|
@ -125,10 +133,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
className={cn(
|
className={cn(
|
||||||
column.columnName === "__checkbox__"
|
column.columnName === "__checkbox__"
|
||||||
? "bg-background h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2"
|
? "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",
|
: "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}`,
|
`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 === "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",
|
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",
|
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: getColumnWidth(column),
|
width: column.columnName === "__checkbox__" ? 48 : getColumnWidth(column),
|
||||||
minWidth: "100px", // 최소 너비 보장
|
minWidth: column.columnName === "__checkbox__" ? "48px" : "100px",
|
||||||
maxWidth: "300px", // 최대 너비 제한
|
maxWidth: column.columnName === "__checkbox__" ? "48px" : "300px",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
|
whiteSpace: "nowrap",
|
||||||
backgroundColor: "hsl(var(--background))",
|
backgroundColor: "hsl(var(--muted) / 0.4)",
|
||||||
// sticky 위치 설정
|
|
||||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||||
}}
|
}}
|
||||||
onClick={() => column.sortable && sortHandler(column.columnName)}
|
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__" ? (
|
{column.columnName === "__checkbox__" ? (
|
||||||
checkboxConfig.selectAll && (
|
checkboxConfig.selectAll && (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isAllSelected}
|
checked={isAllSelected}
|
||||||
onCheckedChange={handleSelectAll}
|
onCheckedChange={handleSelectAll}
|
||||||
aria-label="전체 선택"
|
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
|
<TableRow
|
||||||
key={`row-${index}`}
|
key={`row-${index}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background h-10 cursor-pointer border-b transition-colors",
|
"cursor-pointer border-b border-border/50 transition-[background] duration-75",
|
||||||
tableConfig.tableStyle?.hoverEffect && "hover:bg-muted/50",
|
index % 2 === 0 ? "bg-background" : "bg-muted/20",
|
||||||
|
tableConfig.tableStyle?.hoverEffect !== false && "hover:bg-accent",
|
||||||
)}
|
)}
|
||||||
onClick={(e) => handleRowClick?.(row, index, e)}
|
onClick={(e) => handleRowClick?.(row, index, e)}
|
||||||
>
|
>
|
||||||
|
|
@ -266,9 +280,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
highlightArray[currentSearchIndex] === cellKey;
|
highlightArray[currentSearchIndex] === cellKey;
|
||||||
|
|
||||||
// formatCellValue 결과 (이미지 등 JSX 반환 가능)
|
// formatCellValue 결과 (이미지 등 JSX 반환 가능)
|
||||||
const rawCellValue =
|
const formattedValue = formatCellValue(row[column.columnName], column.format, column.columnName, row);
|
||||||
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
|
const rawCellValue = (formattedValue === null || formattedValue === undefined || formattedValue === "")
|
||||||
// 이미지 등 JSX 반환 여부 확인
|
? <span className="text-muted-foreground/50">-</span>
|
||||||
|
: formattedValue;
|
||||||
const isReactElement = typeof rawCellValue === "object" && React.isValidElement(rawCellValue);
|
const isReactElement = typeof rawCellValue === "object" && React.isValidElement(rawCellValue);
|
||||||
|
|
||||||
// 셀 값에서 검색어 하이라이트 렌더링
|
// 셀 값에서 검색어 하이라이트 렌더링
|
||||||
|
|
@ -317,26 +332,22 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
key={`cell-${column.columnName}`}
|
key={`cell-${column.columnName}`}
|
||||||
id={isCurrentSearchResult ? "current-search-result" : undefined}
|
id={isCurrentSearchResult ? "current-search-result" : undefined}
|
||||||
className={cn(
|
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",
|
"text-foreground h-10 align-middle text-[11px] transition-colors",
|
||||||
// 이미지 셀은 overflow/ellipsis 제외 (이미지 잘림 방지)
|
column.columnName === "__checkbox__" ? "px-0 py-[7px] text-center" : "px-3 py-[7px]",
|
||||||
!isReactElement && "whitespace-nowrap",
|
!isReactElement && "whitespace-nowrap",
|
||||||
`text-${column.align}`,
|
column.columnName !== "__checkbox__" && `text-${column.align}`,
|
||||||
// 고정 컬럼 스타일
|
|
||||||
column.fixed === "left" &&
|
column.fixed === "left" &&
|
||||||
"border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm",
|
"border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm",
|
||||||
column.fixed === "right" &&
|
column.fixed === "right" &&
|
||||||
"border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm",
|
"border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm",
|
||||||
// 편집 가능 셀 스타일
|
|
||||||
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
|
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: getColumnWidth(column),
|
width: column.columnName === "__checkbox__" ? 48 : getColumnWidth(column),
|
||||||
minWidth: "100px", // 최소 너비 보장
|
minWidth: column.columnName === "__checkbox__" ? "48px" : "100px",
|
||||||
maxWidth: "300px", // 최대 너비 제한
|
maxWidth: column.columnName === "__checkbox__" ? "48px" : "300px",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
// 이미지 셀은 overflow 허용
|
|
||||||
...(isReactElement ? {} : { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }),
|
...(isReactElement ? {} : { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }),
|
||||||
// sticky 위치 설정
|
|
||||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||||
}}
|
}}
|
||||||
|
|
@ -350,15 +361,20 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
{column.columnName === "__checkbox__" ? (
|
{column.columnName === "__checkbox__" ? (
|
||||||
renderCheckboxCell?.(row, index)
|
renderCheckboxCell?.(row, index)
|
||||||
) : isEditing ? (
|
) : isEditing ? (
|
||||||
// 인라인 편집 입력 필드
|
// 인라인 편집: inputType에 따라 select(category/code), number, date, text
|
||||||
<input
|
(() => {
|
||||||
ref={editInputRef}
|
const meta = columnMeta?.[column.columnName];
|
||||||
type="text"
|
const inputType = meta?.inputType ?? (column as { inputType?: string }).inputType;
|
||||||
value={editingValue ?? ""}
|
const isNumeric = inputType === "number" || inputType === "decimal";
|
||||||
onChange={(e) => onEditingValueChange?.(e.target.value)}
|
const isCategoryType = inputType === "category" || inputType === "code";
|
||||||
onKeyDown={onEditKeyDown}
|
const categoryOptions = categoryMappings?.[column.columnName];
|
||||||
onBlur={() => {
|
const hasCategoryOptions =
|
||||||
// blur 시 저장 (Enter와 동일)
|
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) {
|
if (onEditKeyDown) {
|
||||||
const fakeEvent = {
|
const fakeEvent = {
|
||||||
key: "Enter",
|
key: "Enter",
|
||||||
|
|
@ -366,10 +382,79 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
} as React.KeyboardEvent<HTMLInputElement>;
|
} as React.KeyboardEvent<HTMLInputElement>;
|
||||||
onEditKeyDown(fakeEvent);
|
onEditKeyDown(fakeEvent);
|
||||||
}
|
}
|
||||||
}}
|
onEditSave?.();
|
||||||
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()}
|
|
||||||
/>
|
// 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()
|
renderCellContent()
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -368,6 +368,7 @@ import {
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
Trash2,
|
Trash2,
|
||||||
Lock,
|
Lock,
|
||||||
|
GripVertical,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as XLSX from "xlsx";
|
import * as XLSX from "xlsx";
|
||||||
import { FileText, ChevronRightIcon } from "lucide-react";
|
import { FileText, ChevronRightIcon } from "lucide-react";
|
||||||
|
|
@ -1067,10 +1068,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false);
|
const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false);
|
||||||
const [showGridLines, setShowGridLines] = useState(true);
|
const [showGridLines, setShowGridLines] = useState(true);
|
||||||
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
|
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
|
||||||
// 체크박스 컬럼은 항상 기본 틀고정
|
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
|
||||||
const [frozenColumns, setFrozenColumns] = useState<string[]>(
|
|
||||||
(tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [],
|
|
||||||
);
|
|
||||||
const [frozenColumnCount, setFrozenColumnCount] = useState<number>(0);
|
const [frozenColumnCount, setFrozenColumnCount] = useState<number>(0);
|
||||||
|
|
||||||
// 🆕 Search Panel (통합 검색) 관련 상태
|
// 🆕 Search Panel (통합 검색) 관련 상태
|
||||||
|
|
@ -1164,6 +1162,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
setSelectedRows(new Set());
|
setSelectedRows(new Set());
|
||||||
setIsAllSelected(false);
|
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 인터페이스 구현
|
// DataReceivable 인터페이스 구현
|
||||||
|
|
@ -1362,14 +1371,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
frozenColumnCount, // 현재 틀고정 컬럼 수
|
frozenColumnCount, // 현재 틀고정 컬럼 수
|
||||||
onFrozenColumnCountChange: (count: number) => {
|
onFrozenColumnCountChange: (count: number) => {
|
||||||
setFrozenColumnCount(count);
|
setFrozenColumnCount(count);
|
||||||
// 체크박스 컬럼은 항상 틀고정에 포함
|
|
||||||
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [];
|
|
||||||
// 표시 가능한 컬럼 중 처음 N개를 틀고정 컬럼으로 설정
|
|
||||||
const visibleCols = columnsToRegister
|
const visibleCols = columnsToRegister
|
||||||
.filter((col) => col.visible !== false)
|
.filter((col) => col.visible !== false)
|
||||||
.map((col) => col.columnName || col.field);
|
.map((col) => col.columnName || col.field);
|
||||||
const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, count)];
|
setFrozenColumns(visibleCols.slice(0, count));
|
||||||
setFrozenColumns(newFrozenColumns);
|
|
||||||
},
|
},
|
||||||
// 탭 관련 정보 (탭 내부의 테이블인 경우)
|
// 탭 관련 정보 (탭 내부의 테이블인 경우)
|
||||||
parentTabId,
|
parentTabId,
|
||||||
|
|
@ -3269,12 +3274,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
if (state.sortDirection) setSortDirection(state.sortDirection);
|
if (state.sortDirection) setSortDirection(state.sortDirection);
|
||||||
if (state.groupByColumns) setGroupByColumns(state.groupByColumns);
|
if (state.groupByColumns) setGroupByColumns(state.groupByColumns);
|
||||||
if (state.frozenColumns) {
|
if (state.frozenColumns) {
|
||||||
// 체크박스 컬럼이 항상 포함되도록 보장
|
// 체크박스 컬럼은 frozen 대상에서 제외 (배경색 이중 적용 방지)
|
||||||
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? "__checkbox__" : null;
|
const restoredFrozenColumns = (state.frozenColumns || []).filter((col: string) => col !== "__checkbox__");
|
||||||
const restoredFrozenColumns =
|
|
||||||
checkboxColumn && !state.frozenColumns.includes(checkboxColumn)
|
|
||||||
? [checkboxColumn, ...state.frozenColumns]
|
|
||||||
: state.frozenColumns;
|
|
||||||
setFrozenColumns(restoredFrozenColumns);
|
setFrozenColumns(restoredFrozenColumns);
|
||||||
}
|
}
|
||||||
if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); // 틀고정 컬럼 수 복원
|
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?.selectAll) return null;
|
||||||
if (tableConfig.checkbox?.multiple === false) 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) => {
|
const renderCheckboxCell = (row: any, index: number) => {
|
||||||
|
|
@ -4435,6 +4448,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)}
|
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)}
|
||||||
aria-label={`행 ${index + 1} 선택`}
|
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}
|
getColumnWidth={getColumnWidth}
|
||||||
containerWidth={calculatedWidth}
|
containerWidth={calculatedWidth}
|
||||||
|
onCellDoubleClick={handleCellDoubleClick}
|
||||||
|
editingCell={editingCell}
|
||||||
|
editingValue={editingValue}
|
||||||
|
onEditingValueChange={setEditingValue}
|
||||||
|
onEditKeyDown={handleEditKeyDown}
|
||||||
|
onEditSave={saveEditing}
|
||||||
|
editInputRef={editInputRef}
|
||||||
|
columnMeta={columnMeta}
|
||||||
|
categoryMappings={categoryMappings}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -5827,7 +5855,41 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</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) && (
|
{(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="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">
|
<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) */}
|
{/* 🆕 Multi-Level Headers (Column Bands) */}
|
||||||
{columnBandsInfo?.hasBands && (
|
{columnBandsInfo?.hasBands && (
|
||||||
<tr
|
<tr
|
||||||
className="border-primary/10 bg-muted/70 h-8 border-b sm:h-10"
|
className="border-border/60 bg-muted/40 h-8 border-b sm:h-10"
|
||||||
style={{ backgroundColor: "hsl(var(--muted) / 0.7)" }}
|
style={{ backgroundColor: "hsl(var(--muted) / 0.4)" }}
|
||||||
>
|
>
|
||||||
{visibleColumns.map((column, colIdx) => {
|
{visibleColumns.map((column, colIdx) => {
|
||||||
// 이 컬럼이 속한 band 찾기
|
// 이 컬럼이 속한 band 찾기
|
||||||
|
|
@ -5977,9 +6039,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
<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={{
|
style={{
|
||||||
backgroundColor: "hsl(var(--muted))",
|
backgroundColor: "hsl(var(--muted) / 0.4)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{visibleColumns.map((column, columnIndex) => {
|
{visibleColumns.map((column, columnIndex) => {
|
||||||
|
|
@ -6007,11 +6069,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
ref={(el) => (columnRefs.current[column.columnName] = el)}
|
ref={(el) => (columnRefs.current[column.columnName] = el)}
|
||||||
className={cn(
|
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",
|
"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-2 py-1 sm:px-4 sm:py-2",
|
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-3 py-2",
|
||||||
column.sortable !== false &&
|
column.sortable !== false &&
|
||||||
column.columnName !== "__checkbox__" &&
|
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)]",
|
isFrozen && "sticky z-40 shadow-[2px_0_4px_rgba(0,0,0,0.1)]",
|
||||||
// 🆕 Column Reordering 스타일
|
// 🆕 Column Reordering 스타일
|
||||||
isColumnDragEnabled &&
|
isColumnDragEnabled &&
|
||||||
|
|
@ -6031,7 +6094,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
||||||
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
backgroundColor: "hsl(var(--muted))",
|
backgroundColor: "hsl(var(--muted) / 0.4)",
|
||||||
...(isFrozen && { left: `${leftPosition}px` }),
|
...(isFrozen && { left: `${leftPosition}px` }),
|
||||||
}}
|
}}
|
||||||
// 🆕 Column Reordering 이벤트
|
// 🆕 Column Reordering 이벤트
|
||||||
|
|
@ -6051,9 +6114,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
renderCheckboxHeader()
|
renderCheckboxHeader()
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "4px", justifyContent: "center" }}>
|
<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>
|
<span>{columnLabels[column.columnName] || column.displayName}</span>
|
||||||
{column.sortable !== false && sortColumn === column.columnName && (
|
{column.sortable !== false && sortColumn === column.columnName && (
|
||||||
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
|
<span className="text-primary">{sortDirection === "asc" ? "↑" : "↓"}</span>
|
||||||
)}
|
)}
|
||||||
{/* 🆕 헤더 필터 버튼 */}
|
{/* 🆕 헤더 필터 버튼 */}
|
||||||
{tableConfig.headerFilter !== false &&
|
{tableConfig.headerFilter !== false &&
|
||||||
|
|
@ -6278,7 +6344,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
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)}
|
onClick={(e) => handleRowClick(row, index, e)}
|
||||||
>
|
>
|
||||||
|
|
@ -6309,13 +6376,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<td
|
<td
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground text-xs font-normal sm:text-sm",
|
"text-foreground text-[11px] font-normal",
|
||||||
// 이미지 컬럼은 overflow/ellipsis 제외 (이미지 잘림 방지)
|
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap max-w-[170px]",
|
||||||
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap",
|
|
||||||
column.columnName === "__checkbox__"
|
column.columnName === "__checkbox__"
|
||||||
? "px-0 py-1"
|
? "px-0 py-[7px] text-center"
|
||||||
: "px-2 py-1 sm:px-4 sm:py-1.5",
|
: "px-3 py-[7px]",
|
||||||
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
|
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={{
|
style={{
|
||||||
textAlign:
|
textAlign:
|
||||||
|
|
@ -6330,16 +6398,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
: `${100 / visibleColumns.length}%`,
|
: `${100 / visibleColumns.length}%`,
|
||||||
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
||||||
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
||||||
...(isFrozen && {
|
...(isFrozen && {
|
||||||
left: `${leftPosition}px`,
|
left: `${leftPosition}px`,
|
||||||
backgroundColor: "hsl(var(--background))",
|
backgroundColor: index % 2 === 0
|
||||||
}),
|
? "hsl(var(--background))"
|
||||||
}}
|
: "hsl(var(--muted) / 0.2)",
|
||||||
>
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
{column.columnName === "__checkbox__"
|
{column.columnName === "__checkbox__"
|
||||||
? renderCheckboxCell(row, index)
|
? renderCheckboxCell(row, index)
|
||||||
: formatCellValue(cellValue, column, row)}
|
: formatCellValue(cellValue, column, row)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -6415,10 +6485,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
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",
|
||||||
isRowSelected && "bg-primary/10 hover:bg-primary/15",
|
index % 2 === 0 ? "bg-background" : "bg-muted/20",
|
||||||
|
isRowSelected && "!bg-primary/10 hover:!bg-primary/15",
|
||||||
isRowFocused && "ring-primary/50 ring-1 ring-inset",
|
isRowFocused && "ring-primary/50 ring-1 ring-inset",
|
||||||
// 🆕 Drag & Drop 스타일
|
|
||||||
isDragEnabled && "cursor-grab active:cursor-grabbing",
|
isDragEnabled && "cursor-grab active:cursor-grabbing",
|
||||||
isDragging && "bg-muted opacity-50",
|
isDragging && "bg-muted opacity-50",
|
||||||
isDropTarget && "border-t-primary border-t-2",
|
isDropTarget && "border-t-primary border-t-2",
|
||||||
|
|
@ -6478,23 +6548,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
data-row={index}
|
data-row={index}
|
||||||
data-col={colIndex}
|
data-col={colIndex}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground text-xs font-normal sm:text-sm",
|
"text-foreground text-[11px] font-normal",
|
||||||
// 이미지 컬럼은 overflow/ellipsis 제외 (이미지 잘림 방지)
|
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap max-w-[170px]",
|
||||||
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap",
|
column.columnName === "__checkbox__" ? "px-0 py-[7px] text-center" : "px-3 py-[7px]",
|
||||||
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
|
|
||||||
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
|
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",
|
isCellFocused && !editingCell && "ring-primary bg-primary/5 ring-2 ring-inset",
|
||||||
// 🆕 편집 중인 셀 스타일
|
|
||||||
editingCell?.rowIndex === index && editingCell?.colIndex === colIndex && "p-0",
|
editingCell?.rowIndex === index && editingCell?.colIndex === colIndex && "p-0",
|
||||||
// 🆕 배치 편집: 수정된 셀 스타일 (노란 배경)
|
|
||||||
isModified && !cellValidationError && "bg-amber-100 dark:bg-amber-900/40",
|
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",
|
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",
|
isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50",
|
||||||
// 🆕 편집 불가 컬럼 스타일 (연한 회색 배경)
|
column.editable === false && "bg-muted/10 dark:bg-muted/10",
|
||||||
column.editable === false && "bg-gray-50 dark:bg-gray-900/30",
|
// 코드 컬럼: mono 폰트 + primary 색상
|
||||||
|
(inputType === "code" || inputType === "category") && "font-mono text-[10px] text-primary font-medium",
|
||||||
|
// 숫자 컬럼: tabular-nums 오른쪽 정렬
|
||||||
|
isNumeric && "tabular-nums",
|
||||||
)}
|
)}
|
||||||
// 🆕 유효성 에러 툴팁
|
// 🆕 유효성 에러 툴팁
|
||||||
title={cellValidationError || undefined}
|
title={cellValidationError || undefined}
|
||||||
|
|
@ -6511,7 +6578,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
||||||
...(isFrozen && {
|
...(isFrozen && {
|
||||||
left: `${leftPosition}px`,
|
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)}
|
onClick={(e) => handleCellClick(index, colIndex, e)}
|
||||||
|
|
@ -6570,7 +6639,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
onChange={(e) => setEditingValue(e.target.value)}
|
onChange={(e) => setEditingValue(e.target.value)}
|
||||||
onKeyDown={handleEditKeyDown}
|
onKeyDown={handleEditKeyDown}
|
||||||
onBlur={saveEditing}
|
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
|
autoFocus
|
||||||
>
|
>
|
||||||
<option value="">선택하세요</option>
|
<option value="">선택하세요</option>
|
||||||
|
|
@ -6598,7 +6667,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일반 입력 필드
|
// 일반 입력 필드 (행 높이 유지: h-8 고정)
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
ref={editInputRef}
|
ref={editInputRef}
|
||||||
|
|
@ -6607,7 +6676,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
onChange={(e) => setEditingValue(e.target.value)}
|
onChange={(e) => setEditingValue(e.target.value)}
|
||||||
onKeyDown={handleEditKeyDown}
|
onKeyDown={handleEditKeyDown}
|
||||||
onBlur={saveEditing}
|
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={{
|
style={{
|
||||||
textAlign: isNumeric ? "right" : column.align || "left",
|
textAlign: isNumeric ? "right" : column.align || "left",
|
||||||
}}
|
}}
|
||||||
|
|
@ -6616,7 +6685,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
})()
|
})()
|
||||||
: column.columnName === "__checkbox__"
|
: column.columnName === "__checkbox__"
|
||||||
? renderCheckboxCell(row, index)
|
? renderCheckboxCell(row, index)
|
||||||
: formatCellValue(cellValue, column, row)}
|
: (cellValue === null || cellValue === undefined || cellValue === "")
|
||||||
|
? <span className="text-muted-foreground/50">-</span>
|
||||||
|
: formatCellValue(cellValue, column, row)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -6676,7 +6747,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
: undefined,
|
: undefined,
|
||||||
...(isFrozen && {
|
...(isFrozen && {
|
||||||
left: `${leftPosition}px`,
|
left: `${leftPosition}px`,
|
||||||
backgroundColor: "hsl(var(--muted) / 0.8)",
|
backgroundColor: "hsl(var(--muted) / 0.4)",
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ const TabsDesignEditor: React.FC<{
|
||||||
return cn(
|
return cn(
|
||||||
"px-4 py-2 text-sm font-medium cursor-pointer transition-colors",
|
"px-4 py-2 text-sm font-medium cursor-pointer transition-colors",
|
||||||
isActive
|
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"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -283,7 +283,7 @@ const TabsDesignEditor: React.FC<{
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden rounded-lg border bg-background">
|
<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.length > 0 ? (
|
||||||
tabs.map((tab) => (
|
tabs.map((tab) => (
|
||||||
<div
|
<div
|
||||||
|
|
@ -649,8 +649,8 @@ ComponentRegistry.registerComponent({
|
||||||
return cn(
|
return cn(
|
||||||
"px-4 py-2 text-sm font-medium cursor-pointer transition-colors",
|
"px-4 py-2 text-sm font-medium cursor-pointer transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "bg-background border-b-2 border-primary text-primary"
|
? "bg-primary/10 border-b-2 border-primary text-primary font-semibold"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
: "text-foreground/70 hover:text-foreground hover:bg-muted/50"
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -662,7 +662,7 @@ ComponentRegistry.registerComponent({
|
||||||
onDragEnd={onDragEnd}
|
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.length > 0 ? (
|
||||||
tabs.map((tab) => (
|
tabs.map((tab) => (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -542,15 +542,6 @@ export class ButtonActionExecutor {
|
||||||
this.saveCallCount++;
|
this.saveCallCount++;
|
||||||
const callId = 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;
|
const { formData, originalData, tableName, screenId, onSave } = context;
|
||||||
|
|
||||||
// 🆕 중복 호출 방지: 같은 screenId + tableName + formData 조합으로 2초 내 재호출 시 무시
|
// 🆕 중복 호출 방지: 같은 screenId + tableName + formData 조합으로 2초 내 재호출 시 무시
|
||||||
|
|
@ -621,6 +612,18 @@ export class ButtonActionExecutor {
|
||||||
if (onSave) {
|
if (onSave) {
|
||||||
try {
|
try {
|
||||||
await onSave();
|
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;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("❌ [handleSave] onSave 콜백 실행 오류:", error);
|
console.error("❌ [handleSave] onSave 콜백 실행 오류:", error);
|
||||||
|
|
@ -636,13 +639,6 @@ export class ButtonActionExecutor {
|
||||||
|
|
||||||
console.log("⚠️ [handleSave] onSave 콜백 없음 - 기본 저장 로직 실행");
|
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 플래그 확인
|
// skipDefaultSave 플래그 확인
|
||||||
if (beforeSaveEventDetail.skipDefaultSave) {
|
if (beforeSaveEventDetail.skipDefaultSave) {
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -749,11 +745,6 @@ export class ButtonActionExecutor {
|
||||||
return await this.handleBatchSave(config, context, selectedItemsKeys);
|
return await this.handleBatchSave(config, context, selectedItemsKeys);
|
||||||
} else {
|
} else {
|
||||||
console.log("⚠️ [handleSave] SelectedItemsDetailInput 데이터 감지 실패 - 일반 저장 진행");
|
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 문자열 파싱 및 저장 처리
|
// 🆕 RepeaterFieldGroup JSON 문자열 파싱 및 저장 처리
|
||||||
|
|
@ -1011,6 +1002,9 @@ export class ButtonActionExecutor {
|
||||||
// saveResult를 상위 스코프에서 정의 (repeaterSave 이벤트에서 사용)
|
// saveResult를 상위 스코프에서 정의 (repeaterSave 이벤트에서 사용)
|
||||||
let saveResult: { success: boolean; data?: any; message?: string } | undefined;
|
let saveResult: { success: boolean; data?: any; message?: string } | undefined;
|
||||||
|
|
||||||
|
// 제어 실행 데이터를 상위 스코프에서 정의 (리피터 저장 완료 후 실행 위해)
|
||||||
|
let pendingDataflowControl: { config: ButtonActionConfig; context: ButtonActionContext } | null = null;
|
||||||
|
|
||||||
if (tableName && screenId) {
|
if (tableName && screenId) {
|
||||||
// DB에서 실제 기본키 조회하여 INSERT/UPDATE 자동 판단
|
// DB에서 실제 기본키 조회하여 INSERT/UPDATE 자동 판단
|
||||||
const primaryKeyResult = await DynamicFormApi.getTablePrimaryKeys(tableName);
|
const primaryKeyResult = await DynamicFormApi.getTablePrimaryKeys(tableName);
|
||||||
|
|
@ -1758,25 +1752,20 @@ export class ButtonActionExecutor {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 저장 성공 후 연결된 제어 실행 (dataflowTiming이 'after'인 경우)
|
// 제어 실행 준비 (실제 실행은 리피터 저장 완료 후로 지연)
|
||||||
|
console.log("🔍 [handleSave] 제어관리 설정 체크:", {
|
||||||
|
enableDataflowControl: config.enableDataflowControl,
|
||||||
|
hasDataflowConfig: !!config.dataflowConfig,
|
||||||
|
flowControls: config.dataflowConfig?.flowControls?.length || 0,
|
||||||
|
});
|
||||||
if (config.enableDataflowControl && config.dataflowConfig) {
|
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 savedRecord = saveResult?.data?.data || saveResult?.data || {};
|
||||||
const actualFormData = savedRecord?.data || savedRecord;
|
const actualFormData = savedRecord?.data || savedRecord;
|
||||||
const formData: Record<string, any> = (
|
const formData: Record<string, any> = (
|
||||||
Object.keys(actualFormData).length > 0 ? actualFormData : context.formData || {}
|
Object.keys(actualFormData).length > 0 ? actualFormData : context.formData || {}
|
||||||
) as Record<string, any>;
|
) 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[] = [];
|
let parsedSectionData: any[] = [];
|
||||||
|
|
||||||
// comp_로 시작하는 필드에서 테이블 섹션 데이터 찾기
|
|
||||||
const compFieldKey = Object.keys(formData).find(
|
const compFieldKey = Object.keys(formData).find(
|
||||||
(key) => key.startsWith("comp_") && typeof formData[key] === "string",
|
(key) => key.startsWith("comp_") && typeof formData[key] === "string",
|
||||||
);
|
);
|
||||||
|
|
@ -1785,11 +1774,8 @@ export class ButtonActionExecutor {
|
||||||
try {
|
try {
|
||||||
const sectionData = JSON.parse(formData[compFieldKey]);
|
const sectionData = JSON.parse(formData[compFieldKey]);
|
||||||
if (Array.isArray(sectionData) && sectionData.length > 0) {
|
if (Array.isArray(sectionData) && sectionData.length > 0) {
|
||||||
// 공통 필드와 섹션 데이터 병합
|
|
||||||
parsedSectionData = sectionData.map((item: any) => {
|
parsedSectionData = sectionData.map((item: any) => {
|
||||||
// 섹션 데이터에서 불필요한 내부 필드 제거
|
|
||||||
const { _isNewItem, _targetTable, _existingRecord, ...cleanItem } = item;
|
const { _isNewItem, _targetTable, _existingRecord, ...cleanItem } = item;
|
||||||
// 공통 필드(comp_ 필드 제외) + 섹션 아이템 병합
|
|
||||||
const commonFields: Record<string, any> = {};
|
const commonFields: Record<string, any> = {};
|
||||||
Object.keys(formData).forEach((key) => {
|
Object.keys(formData).forEach((key) => {
|
||||||
if (!key.startsWith("comp_") && !key.endsWith("_numberingRuleId")) {
|
if (!key.startsWith("comp_") && !key.endsWith("_numberingRuleId")) {
|
||||||
|
|
@ -1804,14 +1790,14 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 저장된 데이터를 context에 추가하여 플로우에 전달
|
pendingDataflowControl = {
|
||||||
const contextWithSavedData = {
|
config,
|
||||||
...context,
|
context: {
|
||||||
savedData: formData,
|
...context,
|
||||||
// 파싱된 섹션 데이터가 있으면 selectedRowsData로 전달
|
savedData: formData,
|
||||||
selectedRowsData: parsedSectionData.length > 0 ? parsedSectionData : context.selectedRowsData,
|
selectedRowsData: parsedSectionData.length > 0 ? parsedSectionData : context.selectedRowsData,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
await this.executeAfterSaveControl(config, contextWithSavedData);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
|
throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
|
||||||
|
|
@ -1935,13 +1921,26 @@ export class ButtonActionExecutor {
|
||||||
await repeaterSavePromise;
|
await repeaterSavePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 리피터 저장 완료 후 제어관리 실행 (디테일 레코드가 DB에 있는 상태에서 실행)
|
||||||
|
console.log("🔍 [handleSave] 리피터 저장 완료, pendingDataflowControl:", !!pendingDataflowControl);
|
||||||
|
if (pendingDataflowControl) {
|
||||||
|
console.log("📦 [handleSave] 리피터 저장 완료 후 제어관리 실행 시작");
|
||||||
|
await this.executeAfterSaveControl(
|
||||||
|
pendingDataflowControl.config,
|
||||||
|
pendingDataflowControl.context,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 테이블과 플로우 새로고침 (모달 닫기 전에 실행)
|
// 테이블과 플로우 새로고침 (모달 닫기 전에 실행)
|
||||||
context.onRefresh?.();
|
context.onRefresh?.();
|
||||||
context.onFlowRefresh?.();
|
context.onFlowRefresh?.();
|
||||||
|
|
||||||
// 저장 성공 후 모달 닫기 이벤트 발생
|
// 저장 성공 후 모달 닫기 이벤트 발생 (모달 내부에서만)
|
||||||
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
// 비모달 화면에서 이 이벤트를 발행하면 ScreenModal이 반응하여 컴포넌트 트리 재마운트 발생
|
||||||
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
|
if (context.onClose) {
|
||||||
|
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||||||
|
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
|
||||||
|
|
@ -8,21 +8,27 @@ import type {
|
||||||
Condition,
|
Condition,
|
||||||
TransformFunction,
|
TransformFunction,
|
||||||
} from "@/types/screen-embedding";
|
} from "@/types/screen-embedding";
|
||||||
|
import type { EntityJoinColumnMeta } from "@/types/data-transfer";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 매핑 규칙 적용
|
* 매핑 규칙 적용
|
||||||
* @param data 배열 또는 단일 객체
|
* @param data 배열 또는 단일 객체
|
||||||
* @param rules 매핑 규칙 배열
|
* @param rules 매핑 규칙 배열
|
||||||
|
* @param entityJoinColumns 엔티티 조인 메타데이터 (선택적) - sourceField 값이 비었을 때 조인 alias에서 해결
|
||||||
* @returns 매핑된 배열
|
* @returns 매핑된 배열
|
||||||
*/
|
*/
|
||||||
export function applyMappingRules(data: any[] | any, rules: MappingRule[]): any[] {
|
export function applyMappingRules(
|
||||||
|
data: any[] | any,
|
||||||
|
rules: MappingRule[],
|
||||||
|
entityJoinColumns?: EntityJoinColumnMeta[],
|
||||||
|
): any[] {
|
||||||
// 빈 데이터 처리
|
// 빈 데이터 처리
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 배열이 아닌 경우 배열로 변환
|
// 배열이 아닌 경우 배열로 변환
|
||||||
const dataArray = Array.isArray(data) ? data : [data];
|
const dataArray = Array.isArray(data) ? data : [data];
|
||||||
|
|
||||||
if (dataArray.length === 0) {
|
if (dataArray.length === 0) {
|
||||||
|
|
@ -42,22 +48,34 @@ export function applyMappingRules(data: any[] | any, rules: MappingRule[]): any[
|
||||||
return [applyTransformRules(dataArray, rules)];
|
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) => {
|
return dataArray.map((row) => {
|
||||||
// 원본 데이터 복사
|
|
||||||
const mappedRow: any = { ...row };
|
const mappedRow: any = { ...row };
|
||||||
|
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
// sourceField와 targetField가 모두 있어야 매핑 적용
|
|
||||||
if (!rule.sourceField || !rule.targetField) {
|
if (!rule.sourceField || !rule.targetField) {
|
||||||
continue;
|
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;
|
const targetValue = sourceValue ?? rule.defaultValue;
|
||||||
|
|
||||||
// 소스 필드와 타겟 필드가 다르면 소스 필드 제거 후 타겟 필드에 설정
|
|
||||||
if (rule.sourceField !== rule.targetField) {
|
if (rule.sourceField !== rule.targetField) {
|
||||||
delete mappedRow[rule.sourceField];
|
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 === "";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 변환 함수 적용
|
* 변환 함수 적용
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,9 @@ export const V2_EVENTS = {
|
||||||
RELATED_BUTTON_UNREGISTER: "v2:related-button:unregister",
|
RELATED_BUTTON_UNREGISTER: "v2:related-button:unregister",
|
||||||
RELATED_BUTTON_SELECT: "v2:related-button:select",
|
RELATED_BUTTON_SELECT: "v2:related-button:select",
|
||||||
|
|
||||||
|
// 출하계획 저장
|
||||||
|
SHIPPING_PLAN_SAVE: "v2:shipping-plan:save",
|
||||||
|
|
||||||
// 스케줄 자동 생성
|
// 스케줄 자동 생성
|
||||||
SCHEDULE_GENERATE_REQUEST: "v2:schedule:generate:request",
|
SCHEDULE_GENERATE_REQUEST: "v2:schedule:generate:request",
|
||||||
SCHEDULE_GENERATE_PREVIEW: "v2:schedule:generate:preview",
|
SCHEDULE_GENERATE_PREVIEW: "v2:schedule:generate:preview",
|
||||||
|
|
@ -237,6 +240,15 @@ export interface V2RelatedButtonSelectEvent {
|
||||||
selectedData: any[];
|
selectedData: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 출하계획 저장 이벤트
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** 출하계획 저장 요청 이벤트 */
|
||||||
|
export interface V2ShippingPlanSaveEvent {
|
||||||
|
requestId: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 스케줄 자동 생성 이벤트
|
// 스케줄 자동 생성 이벤트
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -334,6 +346,8 @@ export interface V2EventPayloadMap {
|
||||||
[V2_EVENTS.RELATED_BUTTON_UNREGISTER]: V2RelatedButtonUnregisterEvent;
|
[V2_EVENTS.RELATED_BUTTON_UNREGISTER]: V2RelatedButtonUnregisterEvent;
|
||||||
[V2_EVENTS.RELATED_BUTTON_SELECT]: V2RelatedButtonSelectEvent;
|
[V2_EVENTS.RELATED_BUTTON_SELECT]: V2RelatedButtonSelectEvent;
|
||||||
|
|
||||||
|
[V2_EVENTS.SHIPPING_PLAN_SAVE]: V2ShippingPlanSaveEvent;
|
||||||
|
|
||||||
[V2_EVENTS.SCHEDULE_GENERATE_REQUEST]: V2ScheduleGenerateRequestEvent;
|
[V2_EVENTS.SCHEDULE_GENERATE_REQUEST]: V2ScheduleGenerateRequestEvent;
|
||||||
[V2_EVENTS.SCHEDULE_GENERATE_PREVIEW]: V2ScheduleGeneratePreviewEvent;
|
[V2_EVENTS.SCHEDULE_GENERATE_PREVIEW]: V2ScheduleGeneratePreviewEvent;
|
||||||
[V2_EVENTS.SCHEDULE_GENERATE_APPLY]: V2ScheduleGenerateApplyEvent;
|
[V2_EVENTS.SCHEDULE_GENERATE_APPLY]: V2ScheduleGenerateApplyEvent;
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,17 @@ export interface DataReceivable {
|
||||||
getData(): any;
|
getData(): any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엔티티 조인 컬럼 메타데이터
|
||||||
|
* 소스 테이블의 FK가 참조 테이블과 어떻게 조인되었는지 정보
|
||||||
|
*/
|
||||||
|
export interface EntityJoinColumnMeta {
|
||||||
|
sourceColumn: string;
|
||||||
|
joinAlias: string;
|
||||||
|
referenceTable: string;
|
||||||
|
sourceTable?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터 제공 가능한 컴포넌트 인터페이스
|
* 데이터 제공 가능한 컴포넌트 인터페이스
|
||||||
* 데이터를 제공할 수 있는 컴포넌트가 구현해야 하는 인터페이스
|
* 데이터를 제공할 수 있는 컴포넌트가 구현해야 하는 인터페이스
|
||||||
|
|
@ -180,5 +191,11 @@ export interface DataProvidable {
|
||||||
* 선택 초기화 메서드
|
* 선택 초기화 메서드
|
||||||
*/
|
*/
|
||||||
clearSelection(): void;
|
clearSelection(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엔티티 조인 컬럼 메타데이터 반환 (선택적)
|
||||||
|
* 전달 매핑 시 조인 alias 해결에 사용
|
||||||
|
*/
|
||||||
|
getEntityJoinColumns?(): EntityJoinColumnMeta[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue