Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node
This commit is contained in:
commit
c8226b0ba6
|
|
@ -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,458 @@
|
||||||
|
/**
|
||||||
|
* 출하계획 컨트롤러
|
||||||
|
*
|
||||||
|
* 수주 마스터(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 } = plan;
|
||||||
|
if (!sourceId || !planQty || planQty <= 0) continue;
|
||||||
|
|
||||||
|
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, CURRENT_DATE, 'READY', $5)
|
||||||
|
RETURNING *`,
|
||||||
|
[companyCode, sourceId, detail.master_id, planQty, 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, CURRENT_DATE, 'READY', $4)
|
||||||
|
RETURNING *`,
|
||||||
|
[companyCode, masterId, planQty, 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;
|
||||||
|
|
@ -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) 필터링 확인
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,576 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||||
|
import { ComponentRendererProps } from "@/types/component";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Package,
|
||||||
|
TrendingUp,
|
||||||
|
Warehouse,
|
||||||
|
CheckCircle,
|
||||||
|
Factory,
|
||||||
|
Truck,
|
||||||
|
} 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 출하계획 아래에 표시
|
||||||
|
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, balanceQty: d.balanceQty }))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (plans.length === 0) {
|
||||||
|
toast.warning("저장할 출하계획이 없습니다. 수량을 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 잔량 초과 검증 (allowOverPlan = false일 때)
|
||||||
|
if (!currentConfig.allowOverPlan) {
|
||||||
|
const overPlan = plans.find((p) => p.balanceQty > 0 && p.planQty > p.balanceQty);
|
||||||
|
if (overPlan) {
|
||||||
|
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 }));
|
||||||
|
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 handlePlanQtyChange = useCallback(
|
||||||
|
(groupIdx: number, detailIdx: number, value: string) => {
|
||||||
|
setItemGroups((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const group = { ...next[groupIdx] };
|
||||||
|
const details = [...group.details];
|
||||||
|
const detail = { ...details[detailIdx] };
|
||||||
|
|
||||||
|
detail.planQty = Number(value) || 0;
|
||||||
|
details[detailIdx] = detail;
|
||||||
|
group.details = details;
|
||||||
|
|
||||||
|
const newPlanTotal = details
|
||||||
|
.filter((d) => d.type === "new")
|
||||||
|
.reduce((sum, d) => sum + d.planQty, 0);
|
||||||
|
const existingPlanTotal = details
|
||||||
|
.filter((d) => d.type === "existing")
|
||||||
|
.reduce((sum, d) => sum + d.planQty, 0);
|
||||||
|
|
||||||
|
group.aggregation = {
|
||||||
|
...group.aggregation,
|
||||||
|
totalPlanQty: existingPlanTotal + newPlanTotal,
|
||||||
|
availableStock:
|
||||||
|
group.aggregation.currentStock -
|
||||||
|
(existingPlanTotal + newPlanTotal),
|
||||||
|
};
|
||||||
|
|
||||||
|
next[groupIdx] = 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}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DetailTable: React.FC<{
|
||||||
|
details: PlanDetailRow[];
|
||||||
|
groupIdx: number;
|
||||||
|
onPlanQtyChange: (
|
||||||
|
groupIdx: number,
|
||||||
|
detailIdx: number,
|
||||||
|
value: string
|
||||||
|
) => void;
|
||||||
|
showExisting?: boolean;
|
||||||
|
}> = ({ details, groupIdx, onPlanQtyChange, showExisting = true }) => {
|
||||||
|
const visibleDetails = details
|
||||||
|
.map((d, idx) => ({ ...d, _origIdx: idx }))
|
||||||
|
.filter((d) => showExisting || d.type === "new");
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-lg border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<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-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-right text-xs font-medium text-muted-foreground">
|
||||||
|
출하계획량
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visibleDetails.map((detail, detailIdx) => (
|
||||||
|
<tr
|
||||||
|
key={`${detail.type}-${detail.sourceId}-${detail.existingPlanId || detailIdx}`}
|
||||||
|
className="border-b last:border-b-0 hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{detail.type === "existing" ? (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
기존
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-primary text-[10px] text-primary-foreground">
|
||||||
|
신규
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-xs">{detail.orderNo}</td>
|
||||||
|
<td className="px-3 py-2 text-xs">{detail.partnerName}</td>
|
||||||
|
<td className="px-3 py-2 text-xs">
|
||||||
|
{detail.dueDate || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-xs font-medium">
|
||||||
|
{detail.balanceQty.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
{detail.type === "existing" ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{detail.planQty.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={
|
||||||
|
detail.balanceQty > 0 ? detail.balanceQty : undefined
|
||||||
|
}
|
||||||
|
value={detail.planQty || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onPlanQtyChange(groupIdx, detail._origIdx, e.target.value)
|
||||||
|
}
|
||||||
|
className="ml-auto h-7 w-24 text-right text-xs"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{visibleDetails.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
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,67 @@
|
||||||
|
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;
|
||||||
|
existingPlanId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemGroup {
|
||||||
|
partCode: string;
|
||||||
|
partName: string;
|
||||||
|
aggregation: ItemAggregation;
|
||||||
|
details: PlanDetailRow[];
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue