Implement outbound management features with new routes and controller

- Added outbound management routes for listing, creating, updating, and deleting outbound records.
- Introduced a new outbound controller to handle business logic for outbound operations, including inventory updates and source data retrieval.
- Enhanced the application by integrating outbound management functionalities into the existing logistics module.
- Improved user experience with responsive design and real-time data handling for outbound operations.
This commit is contained in:
kjs 2026-03-25 10:48:47 +09:00
parent 5d4cf8d462
commit e2f18b19bc
22 changed files with 5603 additions and 115 deletions

View File

@ -151,6 +151,7 @@ import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황
import receivingRoutes from "./routes/receivingRoutes"; // 입고관리
import outboundRoutes from "./routes/outboundRoutes"; // 출고관리
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
@ -353,6 +354,7 @@ app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
app.use("/api/design", designRoutes); // 설계 모듈
app.use("/api/receiving", receivingRoutes); // 입고관리
app.use("/api/outbound", outboundRoutes); // 출고관리
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리

View File

@ -0,0 +1,509 @@
/**
*
*
* :
* - shipment_instruction + shipment_instruction_detail ()
* - purchase_order_mng (/)
* - item_info ()
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
// 출고 목록 조회
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const {
outbound_type,
outbound_status,
search_keyword,
date_from,
date_to,
} = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIdx = 1;
if (companyCode === "*") {
// 최고 관리자: 전체 조회
} else {
conditions.push(`om.company_code = $${paramIdx}`);
params.push(companyCode);
paramIdx++;
}
if (outbound_type && outbound_type !== "all") {
conditions.push(`om.outbound_type = $${paramIdx}`);
params.push(outbound_type);
paramIdx++;
}
if (outbound_status && outbound_status !== "all") {
conditions.push(`om.outbound_status = $${paramIdx}`);
params.push(outbound_status);
paramIdx++;
}
if (search_keyword) {
conditions.push(
`(om.outbound_number ILIKE $${paramIdx} OR om.item_name ILIKE $${paramIdx} OR om.item_code ILIKE $${paramIdx} OR om.customer_name ILIKE $${paramIdx} OR om.reference_number ILIKE $${paramIdx})`
);
params.push(`%${search_keyword}%`);
paramIdx++;
}
if (date_from) {
conditions.push(`om.outbound_date >= $${paramIdx}`);
params.push(date_from);
paramIdx++;
}
if (date_to) {
conditions.push(`om.outbound_date <= $${paramIdx}`);
params.push(date_to);
paramIdx++;
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
om.*,
wh.warehouse_name
FROM outbound_mng om
LEFT JOIN warehouse_info wh
ON om.warehouse_code = wh.warehouse_code
AND om.company_code = wh.company_code
${whereClause}
ORDER BY om.created_date DESC
`;
const pool = getPool();
const result = await pool.query(query, params);
logger.info("출고 목록 조회", {
companyCode,
rowCount: result.rowCount,
});
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("출고 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 출고 등록 (다건)
export async function create(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { items, outbound_number, outbound_date, warehouse_code, location_code, manager_id, memo } = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: "출고 품목이 없습니다." });
}
await client.query("BEGIN");
const insertedRows: any[] = [];
for (const item of items) {
const result = await client.query(
`INSERT INTO outbound_mng (
company_code, outbound_number, outbound_type, outbound_date,
reference_number, customer_code, customer_name,
item_code, item_name, specification, material, unit,
outbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
outbound_status, manager_id, memo,
source_type, sales_order_id, shipment_plan_id, item_info_id,
destination_code, delivery_destination, delivery_address,
created_date, created_by, writer, status
) VALUES (
$1, $2, $3, $4,
$5, $6, $7,
$8, $9, $10, $11, $12,
$13, $14, $15,
$16, $17, $18,
$19, $20, $21,
$22, $23, $24, $25,
$26, $27, $28,
NOW(), $29, $29, '출고'
) RETURNING *`,
[
companyCode,
outbound_number || item.outbound_number,
item.outbound_type,
outbound_date || item.outbound_date,
item.reference_number || null,
item.customer_code || null,
item.customer_name || null,
item.item_code || item.item_number || null,
item.item_name || null,
item.spec || item.specification || null,
item.material || null,
item.unit || "EA",
item.outbound_qty || 0,
item.unit_price || 0,
item.total_amount || 0,
item.lot_number || null,
warehouse_code || item.warehouse_code || null,
location_code || item.location_code || null,
item.outbound_status || "대기",
manager_id || item.manager_id || null,
memo || item.memo || null,
item.source_type || null,
item.sales_order_id || null,
item.shipment_plan_id || null,
item.item_info_id || null,
item.destination_code || null,
item.delivery_destination || null,
item.delivery_address || null,
userId,
]
);
insertedRows.push(result.rows[0]);
// 재고 업데이트 (inventory_stock): 출고 수량 차감
const itemCode = item.item_code || item.item_number || null;
const whCode = warehouse_code || item.warehouse_code || null;
const locCode = location_code || item.location_code || null;
const outQty = Number(item.outbound_qty) || 0;
if (itemCode && outQty > 0) {
const existingStock = await client.query(
`SELECT id FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
);
if (existingStock.rows.length > 0) {
await client.query(
`UPDATE inventory_stock
SET current_qty = CAST(GREATEST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) - $1, 0) AS text),
last_out_date = NOW(),
updated_date = NOW()
WHERE id = $2`,
[outQty, existingStock.rows[0].id]
);
} else {
// 재고 레코드가 없으면 0으로 생성 (마이너스 방지)
await client.query(
`INSERT INTO inventory_stock (
company_code, item_code, warehouse_code, location_code,
current_qty, safety_qty, last_out_date,
created_date, updated_date, writer
) VALUES ($1, $2, $3, $4, '0', '0', NOW(), NOW(), NOW(), $5)`,
[companyCode, itemCode, whCode, locCode, userId]
);
}
}
// 판매출고인 경우 출하지시의 ship_qty 업데이트
if (item.outbound_type === "판매출고" && item.source_id && item.source_type === "shipment_instruction_detail") {
await client.query(
`UPDATE shipment_instruction_detail
SET ship_qty = COALESCE(ship_qty, 0) + $1,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[item.outbound_qty || 0, item.source_id, companyCode]
);
}
}
await client.query("COMMIT");
logger.info("출고 등록 완료", {
companyCode,
userId,
count: insertedRows.length,
outbound_number,
});
return res.json({
success: true,
data: insertedRows,
message: `${insertedRows.length}건 출고 등록 완료`,
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("출고 등록 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
// 출고 수정
export async function update(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { id } = req.params;
const {
outbound_date, outbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
outbound_status, manager_id: mgr, memo,
} = req.body;
const pool = getPool();
const result = await pool.query(
`UPDATE outbound_mng SET
outbound_date = COALESCE($1, outbound_date),
outbound_qty = COALESCE($2, outbound_qty),
unit_price = COALESCE($3, unit_price),
total_amount = COALESCE($4, total_amount),
lot_number = COALESCE($5, lot_number),
warehouse_code = COALESCE($6, warehouse_code),
location_code = COALESCE($7, location_code),
outbound_status = COALESCE($8, outbound_status),
manager_id = COALESCE($9, manager_id),
memo = COALESCE($10, memo),
updated_date = NOW(),
updated_by = $11
WHERE id = $12 AND company_code = $13
RETURNING *`,
[
outbound_date, outbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
outbound_status, mgr, memo,
userId, id, companyCode,
]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "출고 데이터를 찾을 수 없습니다." });
}
logger.info("출고 수정", { companyCode, userId, id });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("출고 수정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 출고 삭제
export async function deleteOutbound(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const pool = getPool();
const result = await pool.query(
`DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
[id, companyCode]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
}
logger.info("출고 삭제", { companyCode, id });
return res.json({ success: true, message: "삭제 완료" });
} catch (error: any) {
logger.error("출고 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 판매출고용: 출하지시 데이터 조회
export async function getShipmentInstructions(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const conditions: string[] = ["si.company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (keyword) {
conditions.push(
`(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`
);
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const result = await pool.query(
`SELECT
sid.id AS detail_id,
si.id AS instruction_id,
si.instruction_no,
si.instruction_date,
si.partner_id,
si.status AS instruction_status,
sid.item_code,
sid.item_name,
sid.spec,
sid.material,
COALESCE(sid.plan_qty, 0) AS plan_qty,
COALESCE(sid.ship_qty, 0) AS ship_qty,
COALESCE(sid.order_qty, 0) AS order_qty,
GREATEST(COALESCE(sid.plan_qty, 0) - COALESCE(sid.ship_qty, 0), 0) AS remain_qty,
sid.source_type
FROM shipment_instruction si
JOIN shipment_instruction_detail sid
ON si.id = sid.instruction_id
AND si.company_code = sid.company_code
WHERE ${conditions.join(" AND ")}
AND COALESCE(sid.plan_qty, 0) > COALESCE(sid.ship_qty, 0)
ORDER BY si.instruction_date DESC, si.instruction_no`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("출하지시 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 반품출고용: 발주(입고) 데이터 조회
export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
// 입고된 것만 (반품 대상)
conditions.push(
`COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0`
);
if (keyword) {
conditions.push(
`(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})`
);
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const result = await pool.query(
`SELECT
id, purchase_no, order_date, supplier_code, supplier_name,
item_code, item_name, spec, material,
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) AS order_qty,
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) AS received_qty,
COALESCE(CAST(NULLIF(unit_price, '') AS numeric), 0) AS unit_price,
status, due_date
FROM purchase_order_mng
WHERE ${conditions.join(" AND ")}
ORDER BY order_date DESC, purchase_no`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("발주 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 기타출고용: 품목 데이터 조회
export async function getItems(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (keyword) {
conditions.push(
`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`
);
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const result = await pool.query(
`SELECT
id, item_number, item_name, size AS spec, material, unit,
COALESCE(CAST(NULLIF(standard_price, '') AS numeric), 0) AS standard_price
FROM item_info
WHERE ${conditions.join(" AND ")}
ORDER BY item_name`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("품목 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 출고번호 자동생성
export async function generateNumber(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const today = new Date();
const yyyy = today.getFullYear();
const prefix = `OUT-${yyyy}-`;
const result = await pool.query(
`SELECT outbound_number FROM outbound_mng
WHERE company_code = $1 AND outbound_number LIKE $2
ORDER BY outbound_number DESC LIMIT 1`,
[companyCode, `${prefix}%`]
);
let seq = 1;
if (result.rows.length > 0) {
const lastNo = result.rows[0].outbound_number;
const lastSeq = parseInt(lastNo.replace(prefix, ""), 10);
if (!isNaN(lastSeq)) seq = lastSeq + 1;
}
const newNumber = `${prefix}${String(seq).padStart(4, "0")}`;
return res.json({ success: true, data: newNumber });
} catch (error: any) {
logger.error("출고번호 생성 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 창고 목록 조회
export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const result = await pool.query(
`SELECT warehouse_code, warehouse_name, warehouse_type
FROM warehouse_info
WHERE company_code = $1 AND status != '삭제'
ORDER BY warehouse_name`,
[companyCode]
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("창고 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -476,3 +476,112 @@ export async function deleteLoadingUnitPkg(
res.status(500).json({ success: false, message: error.message });
}
}
// ──────────────────────────────────────────────
// 품목정보 연동 (division별 item_info 조회)
// ──────────────────────────────────────────────
export async function getItemsByDivision(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { divisionLabel } = req.params;
const { keyword } = req.query;
const pool = getPool();
// division 카테고리에서 해당 라벨의 코드 찾기
const catResult = await pool.query(
`SELECT value_code FROM category_values
WHERE table_name = 'item_info' AND column_name = 'division'
AND value_label = $1 AND company_code = $2
LIMIT 1`,
[divisionLabel, companyCode]
);
if (catResult.rows.length === 0) {
res.json({ success: true, data: [] });
return;
}
const divisionCode = catResult.rows[0].value_code;
const conditions: string[] = ["company_code = $1", `$2 = ANY(string_to_array(division, ','))`];
const params: any[] = [companyCode, divisionCode];
let paramIdx = 3;
if (keyword) {
conditions.push(`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`);
params.push(`%${keyword}%`);
paramIdx++;
}
const result = await pool.query(
`SELECT id, item_number, item_name, size, material, unit, division
FROM item_info
WHERE ${conditions.join(" AND ")}
ORDER BY item_name`,
params
);
logger.info(`품목 조회 (division=${divisionLabel})`, { companyCode, count: result.rowCount });
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("품목 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// 일반 품목 조회 (포장재/적재함 제외, 매칭용)
export async function getGeneralItems(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const pool = getPool();
// 포장재/적재함 division 코드 조회
const catResult = await pool.query(
`SELECT value_code FROM category_values
WHERE table_name = 'item_info' AND column_name = 'division'
AND value_label IN ('포장재', '적재함') AND company_code = $1`,
[companyCode]
);
const excludeCodes = catResult.rows.map((r: any) => r.value_code);
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (excludeCodes.length > 0) {
// 다중 값(콤마 구분) 지원: 포장재/적재함 코드가 포함된 품목 제외
const excludeConditions = excludeCodes.map((_: any, i: number) => `$${paramIdx + i} = ANY(string_to_array(division, ','))`);
conditions.push(`(division IS NULL OR division = '' OR NOT (${excludeConditions.join(" OR ")}))`);
params.push(...excludeCodes);
paramIdx += excludeCodes.length;
}
if (keyword) {
conditions.push(`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`);
params.push(`%${keyword}%`);
paramIdx++;
}
const result = await pool.query(
`SELECT id, item_number, item_name, size AS spec, material, unit, division
FROM item_info
WHERE ${conditions.join(" AND ")}
ORDER BY item_name
LIMIT 200`,
params
);
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("일반 품목 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -170,6 +170,42 @@ export async function create(req: AuthenticatedRequest, res: Response) {
insertedRows.push(result.rows[0]);
// 재고 업데이트 (inventory_stock): 입고 수량 증가
const itemCode = item.item_number || null;
const whCode = warehouse_code || item.warehouse_code || null;
const locCode = location_code || item.location_code || null;
const inQty = Number(item.inbound_qty) || 0;
if (itemCode && inQty > 0) {
const existingStock = await client.query(
`SELECT id FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
);
if (existingStock.rows.length > 0) {
await client.query(
`UPDATE inventory_stock
SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text),
last_in_date = NOW(),
updated_date = NOW()
WHERE id = $2`,
[inQty, existingStock.rows[0].id]
);
} else {
await client.query(
`INSERT INTO inventory_stock (
company_code, item_code, warehouse_code, location_code,
current_qty, safety_qty, last_in_date,
created_date, updated_date, writer
) VALUES ($1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)`,
[companyCode, itemCode, whCode, locCode, String(inQty), userId]
);
}
}
// 구매입고인 경우 발주의 received_qty 업데이트
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") {
await client.query(

View File

@ -0,0 +1,40 @@
/**
*
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as outboundController from "../controllers/outboundController";
const router = Router();
router.use(authenticateToken);
// 출고 목록 조회
router.get("/list", outboundController.getList);
// 출고번호 자동생성
router.get("/generate-number", outboundController.generateNumber);
// 창고 목록 조회
router.get("/warehouses", outboundController.getWarehouses);
// 소스 데이터: 출하지시 (판매출고)
router.get("/source/shipment-instructions", outboundController.getShipmentInstructions);
// 소스 데이터: 발주 (반품출고)
router.get("/source/purchase-orders", outboundController.getPurchaseOrders);
// 소스 데이터: 품목 (기타출고)
router.get("/source/items", outboundController.getItems);
// 출고 등록
router.post("/", outboundController.create);
// 출고 수정
router.put("/:id", outboundController.update);
// 출고 삭제
router.delete("/:id", outboundController.deleteOutbound);
export default router;

View File

@ -5,6 +5,7 @@ import {
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
getItemsByDivision, getGeneralItems,
} from "../controllers/packagingController";
const router = Router();
@ -33,4 +34,8 @@ router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs);
router.post("/loading-unit-pkgs", createLoadingUnitPkg);
router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg);
// 품목정보 연동 (division별)
router.get("/items/general", getGeneralItems);
router.get("/items/:divisionLabel", getItemsByDivision);
export default router;

View File

@ -49,7 +49,7 @@ export async function getOrderSummary(
SELECT
item_number,
id AS item_id,
COALESCE(lead_time, 0) AS lead_time
COALESCE(lead_time::int, 0) AS lead_time
FROM item_info
WHERE company_code = $1
),`
@ -371,43 +371,51 @@ export async function previewSchedule(
const deletedSchedules: any[] = [];
const keptSchedules: any[] = [];
for (const item of items) {
if (options.recalculate_unstarted) {
// 삭제 대상(planned) 상세 조회
// 같은 item_code에 대한 삭제/유지 조회는 한 번만 수행
if (options.recalculate_unstarted) {
const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))];
for (const itemCode of uniqueItemCodes) {
const deleteResult = await pool.query(
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status
FROM production_plan_mng
WHERE company_code = $1 AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status = 'planned'`,
[companyCode, item.item_code, productType]
[companyCode, itemCode, productType]
);
deletedSchedules.push(...deleteResult.rows);
// 유지 대상(진행중 등) 상세 조회
const keptResult = await pool.query(
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status, completed_qty
FROM production_plan_mng
WHERE company_code = $1 AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status NOT IN ('planned', 'completed', 'cancelled')`,
[companyCode, item.item_code, productType]
[companyCode, itemCode, productType]
);
keptSchedules.push(...keptResult.rows);
}
}
for (const item of items) {
const dailyCapacity = item.daily_capacity || 800;
const itemLeadTime = item.lead_time || 0;
let requiredQty = item.required_qty;
// recalculate_unstarted가 true이면 기존 planned 삭제 후 재생성이므로,
// 프론트에서 이미 차감된 기존 계획 수량을 다시 더해줘야 정확한 필요 수량이 됨
// recalculate_unstarted 시, 삭제된 수량을 비율로 분배
if (options.recalculate_unstarted) {
const deletedQtyForItem = deletedSchedules
.filter((d: any) => d.item_code === item.item_code)
.reduce((sum: number, d: any) => sum + (parseFloat(d.plan_qty) || 0), 0);
requiredQty += deletedQtyForItem;
if (deletedQtyForItem > 0) {
const totalRequestedForItem = items
.filter((i) => i.item_code === item.item_code)
.reduce((sum, i) => sum + i.required_qty, 0);
if (totalRequestedForItem > 0) {
requiredQty += Math.round(deletedQtyForItem * (item.required_qty / totalRequestedForItem));
}
}
}
if (requiredQty <= 0) continue;
@ -492,24 +500,22 @@ export async function generateSchedule(
let deletedCount = 0;
let keptCount = 0;
const newSchedules: any[] = [];
const deletedQtyByItem = new Map<string, number>();
for (const item of items) {
// 삭제 전에 기존 planned 수량 먼저 조회
let deletedQtyForItem = 0;
if (options.recalculate_unstarted) {
// 같은 item_code에 대한 삭제는 한 번만 수행
if (options.recalculate_unstarted) {
const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))];
for (const itemCode of uniqueItemCodes) {
const deletedQtyResult = await client.query(
`SELECT COALESCE(SUM(COALESCE(plan_qty::numeric, 0)), 0) AS deleted_qty
FROM production_plan_mng
WHERE company_code = $1 AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status = 'planned'`,
[companyCode, item.item_code, productType]
[companyCode, itemCode, productType]
);
deletedQtyForItem = parseFloat(deletedQtyResult.rows[0].deleted_qty) || 0;
}
deletedQtyByItem.set(itemCode, parseFloat(deletedQtyResult.rows[0].deleted_qty) || 0);
// 기존 미진행(planned) 스케줄 삭제
if (options.recalculate_unstarted) {
const deleteResult = await client.query(
`DELETE FROM production_plan_mng
WHERE company_code = $1
@ -517,7 +523,7 @@ export async function generateSchedule(
AND COALESCE(product_type, '완제품') = $3
AND status = 'planned'
RETURNING id`,
[companyCode, item.item_code, productType]
[companyCode, itemCode, productType]
);
deletedCount += deleteResult.rowCount || 0;
@ -527,15 +533,29 @@ export async function generateSchedule(
AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status NOT IN ('planned', 'completed', 'cancelled')`,
[companyCode, item.item_code, productType]
[companyCode, itemCode, productType]
);
keptCount += parseInt(keptResult.rows[0].cnt, 10);
}
}
// 필요 수량 계산 (삭제된 planned 수량을 복원)
for (const item of items) {
// 필요 수량 계산 (삭제된 planned 수량을 비율로 분배)
const dailyCapacity = item.daily_capacity || 800;
const itemLeadTime = item.lead_time || 0;
let requiredQty = item.required_qty + deletedQtyForItem;
let requiredQty = item.required_qty;
if (options.recalculate_unstarted) {
const deletedQty = deletedQtyByItem.get(item.item_code) || 0;
if (deletedQty > 0) {
const totalRequestedForItem = items
.filter((i) => i.item_code === item.item_code)
.reduce((sum, i) => sum + i.required_qty, 0);
if (totalRequestedForItem > 0) {
requiredQty += Math.round(deletedQty * (item.required_qty / totalRequestedForItem));
}
}
}
if (requiredQty <= 0) continue;
// 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
@ -739,7 +759,7 @@ async function getBomChildItems(
) AS has_lead_time
`);
const hasLeadTime = colCheck.rows[0]?.has_lead_time === true;
const leadTimeCol = hasLeadTime ? "COALESCE(ii.lead_time, 0)" : "0";
const leadTimeCol = hasLeadTime ? "COALESCE(ii.lead_time::int, 0)" : "0";
const bomQuery = `
SELECT

View File

@ -1575,7 +1575,7 @@ export class TableManagementService {
switch (operator) {
case "equals":
return {
whereClause: `${columnName}::text = $${paramIndex}`,
whereClause: `($${paramIndex} = ANY(string_to_array(${columnName}::text, ',')) OR ${columnName}::text = $${paramIndex})`,
values: [actualValue],
paramCount: 1,
};
@ -1859,10 +1859,10 @@ export class TableManagementService {
};
}
// select 필터(equals)인 경우 정확한 코드값 매칭만 수행
// select 필터(equals)인 경우 — 다중 값(콤마 구분) 지원
if (operator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
whereClause: `($${paramIndex} = ANY(string_to_array(${columnName}::text, ',')) OR ${columnName}::text = $${paramIndex})`,
values: [String(value)],
paramCount: 1,
};
@ -3357,16 +3357,20 @@ export class TableManagementService {
const safeColumn = `main."${columnName}"`;
switch (operator) {
case "equals":
case "equals": {
const safeVal = String(value).replace(/'/g, "''");
filterConditions.push(
`${safeColumn} = '${String(value).replace(/'/g, "''")}'`
`('${safeVal}' = ANY(string_to_array(${safeColumn}::text, ',')) OR ${safeColumn}::text = '${safeVal}')`
);
break;
case "not_equals":
}
case "not_equals": {
const safeVal2 = String(value).replace(/'/g, "''");
filterConditions.push(
`${safeColumn} != '${String(value).replace(/'/g, "''")}'`
`NOT ('${safeVal2}' = ANY(string_to_array(${safeColumn}::text, ',')) OR ${safeColumn}::text = '${safeVal2}')`
);
break;
}
case "in": {
const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
if (inArr.length > 0) {

View File

@ -0,0 +1,10 @@
{"t":0,"agent":"ad233db","agent_type":"Explore","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a31a0f7","agent_type":"Explore","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"ad233db","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":59735}
{"t":0,"agent":"a31a0f7","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":93607}
{"t":0,"agent":"a9510b7","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a1c1d18","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a1c1d18","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":136249}
{"t":0,"agent":"a9510b7","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":261624}
{"t":0,"agent":"a9a231d","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a9a231d","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":139427}

View File

@ -1,3 +1,3 @@
{
"lastSentAt": "2026-03-24T01:08:38.875Z"
"lastSentAt": "2026-03-25T01:37:37.051Z"
}

View File

@ -0,0 +1,7 @@
{
"tool_name": "Read",
"tool_input_preview": "{\"file_path\":\"/Users/kimjuseok/ERP-node/frontend/app/(main)/sales/sales-item/page.tsx\"}",
"error": "File content (13282 tokens) exceeds maximum allowed tokens (10000). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file.",
"timestamp": "2026-03-25T01:36:58.910Z",
"retry_count": 1
}

View File

@ -0,0 +1,109 @@
{
"updatedAt": "2026-03-25T01:37:19.659Z",
"missions": [
{
"id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none",
"source": "session",
"name": "none",
"objective": "Session mission",
"createdAt": "2026-03-25T00:33:45.197Z",
"updatedAt": "2026-03-25T01:37:19.659Z",
"status": "done",
"workerCount": 5,
"taskCounts": {
"total": 5,
"pending": 0,
"blocked": 0,
"inProgress": 0,
"completed": 5,
"failed": 0
},
"agents": [
{
"name": "Explore:ad233db",
"role": "Explore",
"ownership": "ad233db7fa6f059dd",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T00:34:44.932Z"
},
{
"name": "Explore:a31a0f7",
"role": "Explore",
"ownership": "a31a0f729d328643f",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T00:35:24.588Z"
},
{
"name": "executor:a9510b7",
"role": "executor",
"ownership": "a9510b7d8ec5a1ce7",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T00:42:01.730Z"
},
{
"name": "executor:a1c1d18",
"role": "executor",
"ownership": "a1c1d186f0eb6dfc1",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T00:40:12.608Z"
},
{
"name": "executor:a9a231d",
"role": "executor",
"ownership": "a9a231d40fd5a150b",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T01:37:19.659Z"
}
],
"timeline": [
{
"id": "session-stop:a1c1d186f0eb6dfc1:2026-03-25T00:40:12.608Z",
"at": "2026-03-25T00:40:12.608Z",
"kind": "completion",
"agent": "executor:a1c1d18",
"detail": "completed",
"sourceKey": "session-stop:a1c1d186f0eb6dfc1"
},
{
"id": "session-stop:a9510b7d8ec5a1ce7:2026-03-25T00:42:01.730Z",
"at": "2026-03-25T00:42:01.730Z",
"kind": "completion",
"agent": "executor:a9510b7",
"detail": "completed",
"sourceKey": "session-stop:a9510b7d8ec5a1ce7"
},
{
"id": "session-start:a9a231d40fd5a150b:2026-03-25T01:35:00.232Z",
"at": "2026-03-25T01:35:00.232Z",
"kind": "update",
"agent": "executor:a9a231d",
"detail": "started executor:a9a231d",
"sourceKey": "session-start:a9a231d40fd5a150b"
},
{
"id": "session-stop:a9a231d40fd5a150b:2026-03-25T01:37:19.659Z",
"at": "2026-03-25T01:37:19.659Z",
"kind": "completion",
"agent": "executor:a9a231d",
"detail": "completed",
"sourceKey": "session-stop:a9a231d40fd5a150b"
}
]
}
]
}

View File

@ -0,0 +1,53 @@
{
"agents": [
{
"agent_id": "ad233db7fa6f059dd",
"agent_type": "Explore",
"started_at": "2026-03-25T00:33:45.197Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T00:34:44.932Z",
"duration_ms": 59735
},
{
"agent_id": "a31a0f729d328643f",
"agent_type": "Explore",
"started_at": "2026-03-25T00:33:50.981Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T00:35:24.588Z",
"duration_ms": 93607
},
{
"agent_id": "a9510b7d8ec5a1ce7",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T00:37:40.106Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T00:42:01.730Z",
"duration_ms": 261624
},
{
"agent_id": "a1c1d186f0eb6dfc1",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T00:37:56.359Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T00:40:12.608Z",
"duration_ms": 136249
},
{
"agent_id": "a9a231d40fd5a150b",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T01:35:00.232Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T01:37:19.659Z",
"duration_ms": 139427
}
],
"total_spawned": 5,
"total_completed": 5,
"total_failed": 0,
"last_updated": "2026-03-25T01:37:19.762Z"
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,911 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
ResizableHandle, ResizablePanel, ResizablePanelGroup,
} from "@/components/ui/resizable";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import {
Search, Plus, Trash2, RotateCcw, Loader2, Package, Box, X, Save, Edit2, Download,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit,
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
getItemsByDivision, getGeneralItems,
type PkgUnit, type PkgUnitItem, type LoadingUnit, type LoadingUnitPkg, type ItemInfoForPkg,
} from "@/lib/api/packaging";
// --- 코드 → 라벨 매핑 ---
const PKG_TYPE_LABEL: Record<string, string> = {
BOX: "박스", PACK: "팩", CANBOARD: "캔보드", AIRCAP: "에어캡",
ZIPCOS: "집코스", CYLINDER: "원통형", POLYCARTON: "포리/카톤",
};
const LOADING_TYPE_LABEL: Record<string, string> = {
PALLET: "파렛트", WOOD_PALLET: "목재파렛트", PLASTIC_PALLET: "플라스틱파렛트",
ALU_PALLET: "알루미늄파렛트", CONTAINER: "컨테이너", STEEL_BOX: "철재함",
CAGE: "케이지", ETC: "기타",
};
const STATUS_LABEL: Record<string, string> = { ACTIVE: "사용", INACTIVE: "미사용" };
const getStatusColor = (s: string) => s === "ACTIVE" ? "bg-emerald-100 text-emerald-800" : "bg-gray-100 text-gray-600";
const fmtSize = (w: any, l: any, h: any) => {
const vals = [w, l, h].map(v => Number(v) || 0);
return vals.some(v => v > 0) ? vals.join("×") : "-";
};
// 규격 문자열에서 치수 파싱
function parseSpecDimensions(spec: string | null) {
if (!spec) return { w: 0, l: 0, h: 0 };
const m3 = spec.match(/(\d+)\s*[x×]\s*(\d+)\s*[x×]\s*(\d+)/i);
if (m3) return { w: parseInt(m3[1]), l: parseInt(m3[2]), h: parseInt(m3[3]) };
const m2 = spec.match(/(\d+)\s*[x×]\s*(\d+)/i);
if (m2) return { w: parseInt(m2[1]), l: parseInt(m2[2]), h: 0 };
return { w: 0, l: 0, h: 0 };
}
export default function PackagingPage() {
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const [activeTab, setActiveTab] = useState<"packing" | "loading">("packing");
// 검색
const [searchKeyword, setSearchKeyword] = useState("");
// 포장재 데이터
const [pkgUnits, setPkgUnits] = useState<PkgUnit[]>([]);
const [pkgLoading, setPkgLoading] = useState(false);
const [selectedPkg, setSelectedPkg] = useState<PkgUnit | null>(null);
const [pkgItems, setPkgItems] = useState<PkgUnitItem[]>([]);
const [pkgItemsLoading, setPkgItemsLoading] = useState(false);
// 적재함 데이터
const [loadingUnits, setLoadingUnits] = useState<LoadingUnit[]>([]);
const [loadingLoading, setLoadingLoading] = useState(false);
const [selectedLoading, setSelectedLoading] = useState<LoadingUnit | null>(null);
const [loadingPkgs, setLoadingPkgs] = useState<LoadingUnitPkg[]>([]);
const [loadingPkgsLoading, setLoadingPkgsLoading] = useState(false);
// 모달
const [pkgModalOpen, setPkgModalOpen] = useState(false);
const [pkgModalMode, setPkgModalMode] = useState<"create" | "edit">("create");
const [pkgForm, setPkgForm] = useState<Record<string, any>>({});
const [pkgItemOptions, setPkgItemOptions] = useState<ItemInfoForPkg[]>([]);
const [pkgItemSearchKw, setPkgItemSearchKw] = useState("");
const [loadModalOpen, setLoadModalOpen] = useState(false);
const [loadModalMode, setLoadModalMode] = useState<"create" | "edit">("create");
const [loadForm, setLoadForm] = useState<Record<string, any>>({});
const [loadItemOptions, setLoadItemOptions] = useState<ItemInfoForPkg[]>([]);
const [loadItemSearchKw, setLoadItemSearchKw] = useState("");
const [itemMatchModalOpen, setItemMatchModalOpen] = useState(false);
const [itemMatchKeyword, setItemMatchKeyword] = useState("");
const [itemMatchResults, setItemMatchResults] = useState<ItemInfoForPkg[]>([]);
const [itemMatchSelected, setItemMatchSelected] = useState<ItemInfoForPkg | null>(null);
const [itemMatchQty, setItemMatchQty] = useState(1);
const [pkgMatchModalOpen, setPkgMatchModalOpen] = useState(false);
const [pkgMatchQty, setPkgMatchQty] = useState(1);
const [pkgMatchMethod, setPkgMatchMethod] = useState("");
const [pkgMatchSelected, setPkgMatchSelected] = useState<PkgUnit | null>(null);
const [saving, setSaving] = useState(false);
// --- 데이터 로드 ---
const fetchPkgUnits = useCallback(async () => {
setPkgLoading(true);
try {
const res = await getPkgUnits();
if (res.success) setPkgUnits(res.data);
} catch { /* ignore */ } finally { setPkgLoading(false); }
}, []);
const fetchLoadingUnits = useCallback(async () => {
setLoadingLoading(true);
try {
const res = await getLoadingUnits();
if (res.success) setLoadingUnits(res.data);
} catch { /* ignore */ } finally { setLoadingLoading(false); }
}, []);
useEffect(() => { fetchPkgUnits(); fetchLoadingUnits(); }, [fetchPkgUnits, fetchLoadingUnits]);
// 포장재 선택 시 매칭 품목 로드
const selectPkg = useCallback(async (pkg: PkgUnit) => {
setSelectedPkg(pkg);
setPkgItemsLoading(true);
try {
const res = await getPkgUnitItems(pkg.pkg_code);
if (res.success) setPkgItems(res.data);
} catch { setPkgItems([]); } finally { setPkgItemsLoading(false); }
}, []);
// 적재함 선택 시 포장구성 로드
const selectLoading = useCallback(async (lu: LoadingUnit) => {
setSelectedLoading(lu);
setLoadingPkgsLoading(true);
try {
const res = await getLoadingUnitPkgs(lu.loading_code);
if (res.success) setLoadingPkgs(res.data);
} catch { setLoadingPkgs([]); } finally { setLoadingPkgsLoading(false); }
}, []);
// 검색 필터 적용
const filteredPkgUnits = pkgUnits.filter((p) => {
if (!searchKeyword) return true;
const kw = searchKeyword.toLowerCase();
return (p.pkg_code?.toLowerCase().includes(kw) || p.pkg_name?.toLowerCase().includes(kw));
});
const filteredLoadingUnits = loadingUnits.filter((l) => {
if (!searchKeyword) return true;
const kw = searchKeyword.toLowerCase();
return (l.loading_code?.toLowerCase().includes(kw) || l.loading_name?.toLowerCase().includes(kw));
});
// --- 포장재 등록/수정 모달 ---
const openPkgModal = async (mode: "create" | "edit") => {
setPkgModalMode(mode);
if (mode === "edit" && selectedPkg) {
setPkgForm({ ...selectedPkg });
} else {
setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "" });
}
setPkgItemSearchKw("");
setPkgItemOptions([]);
setPkgModalOpen(true);
};
const searchPkgItems = async (kw?: string) => {
try {
const res = await getItemsByDivision("포장재", kw || undefined);
if (res.success) setPkgItemOptions(res.data);
} catch { setPkgItemOptions([]); }
};
const onPkgItemSelect = (item: ItemInfoForPkg) => {
const dims = parseSpecDimensions(item.size);
setPkgForm((prev) => ({
...prev,
pkg_code: item.item_number,
pkg_name: item.item_name,
width_mm: dims.w || prev.width_mm,
length_mm: dims.l || prev.length_mm,
height_mm: dims.h || prev.height_mm,
}));
};
const savePkgUnit = async () => {
if (!pkgForm.pkg_code || !pkgForm.pkg_name) { toast.error("포장코드와 포장명은 필수입니다."); return; }
if (!pkgForm.pkg_type) { toast.error("포장유형을 선택해주세요."); return; }
setSaving(true);
try {
if (pkgModalMode === "create") {
const res = await createPkgUnit(pkgForm);
if (res.success) { toast.success("포장재 등록 완료"); setPkgModalOpen(false); fetchPkgUnits(); }
} else {
const res = await updatePkgUnit(pkgForm.id, pkgForm);
if (res.success) { toast.success("포장재 수정 완료"); setPkgModalOpen(false); fetchPkgUnits(); setSelectedPkg(res.data); }
}
} catch { toast.error("저장 실패"); } finally { setSaving(false); }
};
const handleDeletePkg = async (pkg: PkgUnit) => {
const ok = await confirm(`"${pkg.pkg_name}" 포장재를 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deletePkgUnit(pkg.id);
toast.success("삭제 완료");
setSelectedPkg(null); setPkgItems([]);
fetchPkgUnits();
} catch { toast.error("삭제 실패"); }
};
// --- 적재함 등록/수정 모달 ---
const openLoadModal = async (mode: "create" | "edit") => {
setLoadModalMode(mode);
if (mode === "edit" && selectedLoading) {
setLoadForm({ ...selectedLoading });
} else {
setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "" });
}
setLoadItemSearchKw("");
setLoadItemOptions([]);
setLoadModalOpen(true);
};
const searchLoadItems = async (kw?: string) => {
try {
const res = await getItemsByDivision("적재함", kw || undefined);
if (res.success) setLoadItemOptions(res.data);
} catch { setLoadItemOptions([]); }
};
const onLoadItemSelect = (item: ItemInfoForPkg) => {
const dims = parseSpecDimensions(item.size);
setLoadForm((prev) => ({
...prev,
loading_code: item.item_number,
loading_name: item.item_name,
width_mm: dims.w || prev.width_mm,
length_mm: dims.l || prev.length_mm,
height_mm: dims.h || prev.height_mm,
}));
};
const saveLoadingUnit = async () => {
if (!loadForm.loading_code || !loadForm.loading_name) { toast.error("적재함코드와 적재함명은 필수입니다."); return; }
if (!loadForm.loading_type) { toast.error("적재유형을 선택해주세요."); return; }
setSaving(true);
try {
if (loadModalMode === "create") {
const res = await createLoadingUnit(loadForm);
if (res.success) { toast.success("적재함 등록 완료"); setLoadModalOpen(false); fetchLoadingUnits(); }
} else {
const res = await updateLoadingUnit(loadForm.id, loadForm);
if (res.success) { toast.success("적재함 수정 완료"); setLoadModalOpen(false); fetchLoadingUnits(); setSelectedLoading(res.data); }
}
} catch { toast.error("저장 실패"); } finally { setSaving(false); }
};
const handleDeleteLoading = async (lu: LoadingUnit) => {
const ok = await confirm(`"${lu.loading_name}" 적재함을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deleteLoadingUnit(lu.id);
toast.success("삭제 완료");
setSelectedLoading(null); setLoadingPkgs([]);
fetchLoadingUnits();
} catch { toast.error("삭제 실패"); }
};
// --- 품목 추가 모달 (포장재 매칭) ---
const openItemMatchModal = () => {
setItemMatchKeyword(""); setItemMatchResults([]); setItemMatchSelected(null); setItemMatchQty(1);
setItemMatchModalOpen(true);
};
const searchItemsForMatch = async () => {
try {
const res = await getGeneralItems(itemMatchKeyword || undefined);
if (res.success) setItemMatchResults(res.data);
} catch { setItemMatchResults([]); }
};
const saveItemMatch = async () => {
if (!selectedPkg || !itemMatchSelected) { toast.error("품목을 선택해주세요."); return; }
if (itemMatchQty <= 0) { toast.error("포장수량을 입력해주세요."); return; }
setSaving(true);
try {
const res = await createPkgUnitItem({
pkg_code: selectedPkg.pkg_code,
item_number: itemMatchSelected.item_number,
pkg_qty: itemMatchQty,
});
if (res.success) { toast.success("품목 추가 완료"); setItemMatchModalOpen(false); selectPkg(selectedPkg); }
} catch { toast.error("추가 실패"); } finally { setSaving(false); }
};
const handleDeletePkgItem = async (item: PkgUnitItem) => {
const ok = await confirm("매칭 품목을 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deletePkgUnitItem(item.id);
toast.success("삭제 완료");
if (selectedPkg) selectPkg(selectedPkg);
} catch { toast.error("삭제 실패"); }
};
// --- 포장단위 추가 모달 (적재함 구성) ---
const openPkgMatchModal = () => {
setPkgMatchSelected(null); setPkgMatchQty(1); setPkgMatchMethod("");
setPkgMatchModalOpen(true);
};
const savePkgMatch = async () => {
if (!selectedLoading || !pkgMatchSelected) { toast.error("포장단위를 선택해주세요."); return; }
if (pkgMatchQty <= 0) { toast.error("최대적재수량을 입력해주세요."); return; }
setSaving(true);
try {
const res = await createLoadingUnitPkg({
loading_code: selectedLoading.loading_code,
pkg_code: pkgMatchSelected.pkg_code,
max_load_qty: pkgMatchQty,
load_method: pkgMatchMethod || undefined,
});
if (res.success) { toast.success("포장단위 추가 완료"); setPkgMatchModalOpen(false); selectLoading(selectedLoading); }
} catch { toast.error("추가 실패"); } finally { setSaving(false); }
};
const handleDeleteLoadPkg = async (lp: LoadingUnitPkg) => {
const ok = await confirm("적재 구성을 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deleteLoadingUnitPkg(lp.id);
toast.success("삭제 완료");
if (selectedLoading) selectLoading(selectedLoading);
} catch { toast.error("삭제 실패"); }
};
return (
<div className="flex h-full flex-col gap-3 p-4">
{/* 검색 바 */}
<div className="flex items-center gap-2 rounded-lg border bg-card p-3">
<Input
placeholder="포장코드 / 포장명 / 적재함명 검색"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="h-9 w-[280px] text-xs"
/>
<Button size="sm" variant="outline" onClick={() => setSearchKeyword("")} className="h-9">
<RotateCcw className="mr-1 h-4 w-4" />
</Button>
</div>
{/* 탭 */}
<div className="flex gap-1 border-b">
{([["packing", "포장재 관리", filteredPkgUnits.length] as const, ["loading", "적재함 관리", filteredLoadingUnits.length] as const]).map(([tab, label, count]) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px",
activeTab === tab ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{tab === "packing" ? <Package className="h-4 w-4" /> : <Box className="h-4 w-4" />}
{label}
<Badge variant="secondary" className="text-[10px] px-1.5">{count}</Badge>
</button>
))}
</div>
{/* 탭 콘텐츠 */}
<div className="flex-1 overflow-hidden">
{activeTab === "packing" ? (
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
{/* 좌측: 포장재 목록 */}
<ResizablePanel defaultSize={45} minSize={30}>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-2.5">
<span className="text-sm font-semibold"> <span className="text-muted-foreground font-normal">({filteredPkgUnits.length})</span></span>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openPkgModal("create")}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="text-[11px] bg-muted/50">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[90px]">(mm)</TableHead>
<TableHead className="p-2 w-[70px] text-right"></TableHead>
<TableHead className="p-2 w-[55px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pkgLoading ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
) : filteredPkgUnits.length === 0 ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center text-muted-foreground text-xs"> </TableCell></TableRow>
) : filteredPkgUnits.map((p) => (
<TableRow
key={p.id}
className={cn("cursor-pointer text-xs", selectedPkg?.id === p.id && "bg-primary/5")}
onClick={() => selectPkg(p)}
>
<TableCell className="p-2 font-medium truncate max-w-[100px]">{p.pkg_code}</TableCell>
<TableCell className="p-2 truncate max-w-[120px]">{p.pkg_name}</TableCell>
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type || "-"}</TableCell>
<TableCell className="p-2 text-[10px]">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>
<TableCell className="p-2 text-center">
<Badge variant="outline" className={cn("text-[10px]", getStatusColor(p.status))}>{STATUS_LABEL[p.status] || p.status}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 상세 */}
<ResizablePanel defaultSize={55} minSize={30}>
{!selectedPkg ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Package className="h-12 w-12 opacity-20 mb-2" />
<p className="text-sm"> </p>
</div>
) : (
<div className="flex h-full flex-col">
{/* 요약 헤더 */}
<div className="flex items-center justify-between border-b bg-blue-50 dark:bg-blue-950/20 px-4 py-3">
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-blue-600" />
<div>
<div className="font-bold text-sm">{selectedPkg.pkg_name}</div>
<div className="text-[11px] text-muted-foreground">{selectedPkg.pkg_code} · {PKG_TYPE_LABEL[selectedPkg.pkg_type] || selectedPkg.pkg_type} · {fmtSize(selectedPkg.width_mm, selectedPkg.length_mm, selectedPkg.height_mm)}mm</div>
</div>
</div>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openPkgModal("edit")}>
<Edit2 className="mr-1 h-3 w-3" />
</Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => handleDeletePkg(selectedPkg)}>
<Trash2 className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
{/* 매칭 품목 */}
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-xs font-semibold text-muted-foreground"> ({pkgItems.length})</span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openItemMatchModal}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="flex-1 overflow-auto">
{pkgItemsLoading ? (
<div className="flex h-32 items-center justify-center"><Loader2 className="h-5 w-5 animate-spin" /></div>
) : pkgItems.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground text-xs"> </div>
) : (
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[50px]"></TableHead>
<TableHead className="p-2 w-[80px] text-right"></TableHead>
<TableHead className="p-2 w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{pkgItems.map((item) => (
<TableRow key={item.id} className="text-xs">
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name || "-"}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
<TableCell className="p-2 text-right font-semibold">{Number(item.pkg_qty).toLocaleString()}</TableCell>
<TableCell className="p-2 text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeletePkgItem(item)}>
<X className="h-3 w-3" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
) : (
/* 적재함 관리 탭 */
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
<ResizablePanel defaultSize={45} minSize={30}>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-2.5">
<span className="text-sm font-semibold"> <span className="text-muted-foreground font-normal">({filteredLoadingUnits.length})</span></span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openLoadModal("create")}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="text-[11px] bg-muted/50">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[80px]"></TableHead>
<TableHead className="p-2 w-[90px]">(mm)</TableHead>
<TableHead className="p-2 w-[70px] text-right"></TableHead>
<TableHead className="p-2 w-[55px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadingLoading ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
) : filteredLoadingUnits.length === 0 ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center text-muted-foreground text-xs"> </TableCell></TableRow>
) : filteredLoadingUnits.map((l) => (
<TableRow
key={l.id}
className={cn("cursor-pointer text-xs", selectedLoading?.id === l.id && "bg-primary/5")}
onClick={() => selectLoading(l)}
>
<TableCell className="p-2 font-medium truncate max-w-[100px]">{l.loading_code}</TableCell>
<TableCell className="p-2 truncate max-w-[120px]">{l.loading_name}</TableCell>
<TableCell className="p-2">{LOADING_TYPE_LABEL[l.loading_type] || l.loading_type || "-"}</TableCell>
<TableCell className="p-2 text-[10px]">{fmtSize(l.width_mm, l.length_mm, l.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(l.max_load_kg || 0) > 0 ? `${l.max_load_kg}kg` : "-"}</TableCell>
<TableCell className="p-2 text-center">
<Badge variant="outline" className={cn("text-[10px]", getStatusColor(l.status))}>{STATUS_LABEL[l.status] || l.status}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={55} minSize={30}>
{!selectedLoading ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Box className="h-12 w-12 opacity-20 mb-2" />
<p className="text-sm"> </p>
</div>
) : (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b bg-green-50 dark:bg-green-950/20 px-4 py-3">
<div className="flex items-center gap-3">
<Box className="h-5 w-5 text-green-600" />
<div>
<div className="font-bold text-sm">{selectedLoading.loading_name}</div>
<div className="text-[11px] text-muted-foreground">{selectedLoading.loading_code} · {LOADING_TYPE_LABEL[selectedLoading.loading_type] || selectedLoading.loading_type} · {fmtSize(selectedLoading.width_mm, selectedLoading.length_mm, selectedLoading.height_mm)}mm</div>
</div>
</div>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openLoadModal("edit")}><Edit2 className="mr-1 h-3 w-3" /> </Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => handleDeleteLoading(selectedLoading)}><Trash2 className="mr-1 h-3 w-3" /> </Button>
</div>
</div>
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-xs font-semibold text-muted-foreground"> ({loadingPkgs.length})</span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openPkgMatchModal}><Plus className="mr-1 h-3 w-3" /> </Button>
</div>
<div className="flex-1 overflow-auto">
{loadingPkgsLoading ? (
<div className="flex h-32 items-center justify-center"><Loader2 className="h-5 w-5 animate-spin" /></div>
) : loadingPkgs.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground text-xs"> </div>
) : (
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[80px] text-right"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{loadingPkgs.map((lp) => (
<TableRow key={lp.id} className="text-xs">
<TableCell className="p-2 font-medium">{lp.pkg_code}</TableCell>
<TableCell className="p-2">{lp.pkg_name || "-"}</TableCell>
<TableCell className="p-2">{PKG_TYPE_LABEL[lp.pkg_type || ""] || lp.pkg_type || "-"}</TableCell>
<TableCell className="p-2 text-right font-semibold">{Number(lp.max_load_qty).toLocaleString()}</TableCell>
<TableCell className="p-2">{lp.load_method || "-"}</TableCell>
<TableCell className="p-2 text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeleteLoadPkg(lp)}><X className="h-3 w-3" /></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
)}
</div>
{/* 포장재 등록/수정 모달 */}
<FullscreenDialog open={pkgModalOpen} onOpenChange={setPkgModalOpen}
title={pkgModalMode === "create" ? "포장재 등록" : "포장재 수정"}
description="품목정보에서 포장재를 선택하면 코드와 이름이 자동 연동됩니다."
footer={
<div className="flex gap-2">
<Button variant="outline" onClick={() => setPkgModalOpen(false)}></Button>
<Button onClick={savePkgUnit} disabled={saving}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />} </Button>
</div>
}
>
<div className="space-y-4 p-6">
{/* 품목정보 연결 */}
{pkgModalMode === "create" && (
<div className="rounded-lg border bg-blue-50 dark:bg-blue-950/20 p-4">
<Label className="text-xs font-semibold mb-2 block"> (구분: 포장재)</Label>
<div className="flex gap-2 mb-2">
<Input
placeholder="품목코드 또는 품목명으로 검색"
value={pkgItemSearchKw}
onChange={(e) => setPkgItemSearchKw(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchPkgItems(pkgItemSearchKw)}
className="h-9 text-xs flex-1"
/>
<Button size="sm" onClick={() => searchPkgItems(pkgItemSearchKw)} className="h-9">
<Search className="mr-1 h-3 w-3" />
</Button>
</div>
{pkgItemOptions.length > 0 && (
<div className="max-h-[150px] overflow-auto border rounded">
<Table>
<TableHeader>
<TableRow className="text-[10px]">
<TableHead className="p-1.5 w-[30px]" />
<TableHead className="p-1.5"></TableHead>
<TableHead className="p-1.5"></TableHead>
<TableHead className="p-1.5 w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pkgItemOptions.map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer text-xs", pkgForm.pkg_code === item.item_number && "bg-primary/10")}
onClick={() => onPkgItemSelect(item)}>
<TableCell className="p-1.5 text-center text-[10px]">{pkgForm.pkg_code === item.item_number ? "✓" : ""}</TableCell>
<TableCell className="p-1.5 font-medium">{item.item_number}</TableCell>
<TableCell className="p-1.5">{item.item_name}</TableCell>
<TableCell className="p-1.5 text-[10px]">{item.size || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{pkgItemOptions.length === 0 && <p className="text-xs text-muted-foreground"> </p>}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div><Label className="text-xs"></Label><Input value={pkgForm.pkg_code || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div><Label className="text-xs"></Label><Input value={pkgForm.pkg_name || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div>
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Select value={pkgForm.pkg_type || ""} onValueChange={(v) => setPkgForm((p) => ({ ...p, pkg_type: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>{Object.entries(PKG_TYPE_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select value={pkgForm.status || "ACTIVE"} onValueChange={(v) => setPkgForm((p) => ({ ...p, status: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(STATUS_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs font-semibold"></Label>
<div className="grid grid-cols-3 gap-3 mt-2">
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={pkgForm.width_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={pkgForm.length_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={pkgForm.height_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={pkgForm.self_weight_kg || ""} onChange={(e) => setPkgForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={pkgForm.max_load_kg || ""} onChange={(e) => setPkgForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]">(L)</Label><Input type="number" value={pkgForm.volume_l || ""} onChange={(e) => setPkgForm((p) => ({ ...p, volume_l: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
</div>
</div>
<div><Label className="text-xs"></Label><Input value={pkgForm.remarks || ""} onChange={(e) => setPkgForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" /></div>
</div>
</FullscreenDialog>
{/* 적재함 등록/수정 모달 */}
<FullscreenDialog open={loadModalOpen} onOpenChange={setLoadModalOpen}
title={loadModalMode === "create" ? "적재함 등록" : "적재함 수정"}
description="품목정보에서 적재함을 선택하면 코드와 이름이 자동 연동됩니다."
footer={
<div className="flex gap-2">
<Button variant="outline" onClick={() => setLoadModalOpen(false)}></Button>
<Button onClick={saveLoadingUnit} disabled={saving}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />} </Button>
</div>
}
>
<div className="space-y-4 p-6">
{loadModalMode === "create" && (
<div className="rounded-lg border bg-green-50 dark:bg-green-950/20 p-4">
<Label className="text-xs font-semibold mb-2 block"> (구분: 적재함)</Label>
<div className="flex gap-2 mb-2">
<Input
placeholder="품목코드 또는 품목명으로 검색"
value={loadItemSearchKw}
onChange={(e) => setLoadItemSearchKw(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchLoadItems(loadItemSearchKw)}
className="h-9 text-xs flex-1"
/>
<Button size="sm" onClick={() => searchLoadItems(loadItemSearchKw)} className="h-9">
<Search className="mr-1 h-3 w-3" />
</Button>
</div>
{loadItemOptions.length > 0 && (
<div className="max-h-[150px] overflow-auto border rounded">
<Table>
<TableHeader>
<TableRow className="text-[10px]">
<TableHead className="p-1.5 w-[30px]" />
<TableHead className="p-1.5"></TableHead>
<TableHead className="p-1.5"></TableHead>
<TableHead className="p-1.5 w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadItemOptions.map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer text-xs", loadForm.loading_code === item.item_number && "bg-primary/10")}
onClick={() => onLoadItemSelect(item)}>
<TableCell className="p-1.5 text-center text-[10px]">{loadForm.loading_code === item.item_number ? "✓" : ""}</TableCell>
<TableCell className="p-1.5 font-medium">{item.item_number}</TableCell>
<TableCell className="p-1.5">{item.item_name}</TableCell>
<TableCell className="p-1.5 text-[10px]">{item.size || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{loadItemOptions.length === 0 && <p className="text-xs text-muted-foreground"> </p>}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div><Label className="text-xs"></Label><Input value={loadForm.loading_code || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div><Label className="text-xs"></Label><Input value={loadForm.loading_name || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div>
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Select value={loadForm.loading_type || ""} onValueChange={(v) => setLoadForm((p) => ({ ...p, loading_type: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>{Object.entries(LOADING_TYPE_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select value={loadForm.status || "ACTIVE"} onValueChange={(v) => setLoadForm((p) => ({ ...p, status: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(STATUS_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs font-semibold"></Label>
<div className="grid grid-cols-3 gap-3 mt-2">
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={loadForm.width_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={loadForm.length_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={loadForm.height_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={loadForm.self_weight_kg || ""} onChange={(e) => setLoadForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={loadForm.max_load_kg || ""} onChange={(e) => setLoadForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]"></Label><Input type="number" value={loadForm.max_stack || ""} onChange={(e) => setLoadForm((p) => ({ ...p, max_stack: e.target.value }))} className="h-8 text-xs" /></div>
</div>
</div>
<div><Label className="text-xs"></Label><Input value={loadForm.remarks || ""} onChange={(e) => setLoadForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" /></div>
</div>
</FullscreenDialog>
{/* 품목 추가 모달 (포장재 매칭) */}
<Dialog open={itemMatchModalOpen} onOpenChange={setItemMatchModalOpen}>
<DialogContent className="max-w-[650px]">
<DialogHeader>
<DialogTitle> {selectedPkg?.pkg_name}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex gap-2">
<Input placeholder="품목코드 / 품목명 검색" value={itemMatchKeyword} onChange={(e) => setItemMatchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchItemsForMatch()} className="h-9 text-xs" />
<Button size="sm" onClick={searchItemsForMatch} className="h-9"><Search className="mr-1 h-3 w-3" /> </Button>
</div>
<div className="max-h-[200px] overflow-auto border rounded">
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2 w-[30px]" />
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{itemMatchResults.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground text-xs h-16"> </TableCell></TableRow>
) : itemMatchResults.map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer text-xs", itemMatchSelected?.id === item.id && "bg-primary/10")}
onClick={() => setItemMatchSelected(item)}>
<TableCell className="p-2 text-center">{itemMatchSelected?.id === item.id ? "✓" : ""}</TableCell>
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex items-end gap-4">
<div className="flex-1">
<Label className="text-xs"> </Label>
<Input value={itemMatchSelected ? `${itemMatchSelected.item_name} (${itemMatchSelected.item_number})` : ""} readOnly className="h-9 bg-muted text-xs" />
</div>
<div className="w-[120px]">
<Label className="text-xs">(EA) <span className="text-destructive">*</span></Label>
<Input type="number" value={itemMatchQty} onChange={(e) => setItemMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setItemMatchModalOpen(false)}></Button>
<Button onClick={saveItemMatch} disabled={saving || !itemMatchSelected}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 포장단위 추가 모달 (적재함 구성) */}
<Dialog open={pkgMatchModalOpen} onOpenChange={setPkgMatchModalOpen}>
<DialogContent className="max-w-[550px]">
<DialogHeader>
<DialogTitle> {selectedLoading?.loading_name}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="max-h-[200px] overflow-auto border rounded">
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2 w-[30px]" />
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pkgUnits.length === 0 ? (
<TableRow><TableCell colSpan={4} className="text-center text-muted-foreground text-xs h-16"> </TableCell></TableRow>
) : pkgUnits.filter(p => p.status === "ACTIVE").map((p) => (
<TableRow key={p.id} className={cn("cursor-pointer text-xs", pkgMatchSelected?.id === p.id && "bg-primary/10")}
onClick={() => setPkgMatchSelected(p)}>
<TableCell className="p-2 text-center">{pkgMatchSelected?.id === p.id ? "✓" : ""}</TableCell>
<TableCell className="p-2 font-medium">{p.pkg_code}</TableCell>
<TableCell className="p-2">{p.pkg_name}</TableCell>
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex items-end gap-4">
<div className="w-[150px]">
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Input type="number" value={pkgMatchQty} onChange={(e) => setPkgMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
</div>
<div className="flex-1">
<Label className="text-xs"></Label>
<Input value={pkgMatchMethod} onChange={(e) => setPkgMatchMethod(e.target.value)} placeholder="수직/수평/혼합" className="h-9 text-xs" />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPkgMatchModalOpen(false)}></Button>
<Button onClick={savePkgMatch} disabled={saving || !pkgMatchSelected}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{ConfirmDialogComponent}
</div>
);
}

View File

@ -18,14 +18,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
@ -693,14 +686,51 @@ export default function ReceivingPage() {
</div>
{/* 입고 등록 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="flex h-[90vh] max-w-[95vw] flex-col p-0 sm:max-w-[1600px]">
<DialogHeader className="border-b px-6 py-4">
<DialogTitle className="text-lg"> </DialogTitle>
<DialogDescription className="text-xs">
, .
</DialogDescription>
</DialogHeader>
<FullscreenDialog
open={isModalOpen}
onOpenChange={setIsModalOpen}
title="입고 등록"
description="입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가하세요."
defaultMaxWidth="sm:max-w-[1600px]"
defaultWidth="w-[95vw]"
className="h-[90vh] p-0"
footer={
<div className="flex w-full items-center justify-between px-6 py-3">
<div className="text-muted-foreground text-xs">
{selectedItems.length > 0 ? (
<>
{totalSummary.count} | :{" "}
{totalSummary.qty.toLocaleString()} | :{" "}
{totalSummary.amount.toLocaleString()}
</>
) : (
"품목을 추가해주세요"
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
className="h-9 text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={saving || selectedItems.length === 0}
className="h-9 text-sm"
>
{saving ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Save className="mr-1 h-4 w-4" />
)}
</Button>
</div>
</div>
}
>
{/* 입고유형 선택 */}
<div className="flex items-center gap-4 border-b px-6 py-3">
@ -974,43 +1004,7 @@ export default function ReceivingPage() {
</ResizablePanelGroup>
</div>
{/* 푸터 */}
<DialogFooter className="flex items-center justify-between border-t px-6 py-3">
<div className="text-muted-foreground text-xs">
{selectedItems.length > 0 ? (
<>
{totalSummary.count} | :{" "}
{totalSummary.qty.toLocaleString()} | :{" "}
{totalSummary.amount.toLocaleString()}
</>
) : (
"품목을 추가해주세요"
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
className="h-9 text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={saving || selectedItems.length === 0}
className="h-9 text-sm"
>
{saving ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Save className="mr-1 h-4 w-4" />
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</FullscreenDialog>
</div>
);
}

View File

@ -0,0 +1,510 @@
"use client";
/**
*
*
* 좌측: 품목 (subcontractor_item_mapping , item_info )
* 우측: 선택한 (subcontractor_item_mapping subcontractor_mng )
*
* ( subcontractor_item_mapping )
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Plus, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
const ITEM_TABLE = "item_info";
const MAPPING_TABLE = "subcontractor_item_mapping";
const SUBCONTRACTOR_TABLE = "subcontractor_mng";
// 좌측: 품목 컬럼
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "item_number", label: "품번", width: "w-[110px]" },
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
{ key: "size", label: "규격", width: "w-[90px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "standard_price", label: "기준단가", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "selling_price", label: "판매가격", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
{ key: "status", label: "상태", width: "w-[60px]" },
];
// 우측: 외주업체 정보 컬럼
const RIGHT_COLUMNS: DataGridColumn[] = [
{ key: "subcontractor_code", label: "외주업체코드", width: "w-[110px]" },
{ key: "subcontractor_name", label: "외주업체명", minWidth: "min-w-[120px]" },
{ key: "subcontractor_item_code", label: "외주품번", width: "w-[100px]" },
{ key: "subcontractor_item_name", label: "외주품명", width: "w-[100px]" },
{ key: "base_price", label: "기준가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "calculated_price", label: "단가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
];
export default function SubcontractorItemPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측: 품목
const [items, setItems] = useState<any[]>([]);
const [itemLoading, setItemLoading] = useState(false);
const [itemCount, setItemCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
// 우측: 외주업체
const [subcontractorItems, setSubcontractorItems] = useState<any[]>([]);
const [subcontractorLoading, setSubcontractorLoading] = useState(false);
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 외주업체 추가 모달
const [subSelectOpen, setSubSelectOpen] = useState(false);
const [subSearchKeyword, setSubSearchKeyword] = useState("");
const [subSearchResults, setSubSearchResults] = useState<any[]>([]);
const [subSearchLoading, setSubSearchLoading] = useState(false);
const [subCheckedIds, setSubCheckedIds] = useState<Set<string>>(new Set());
// 품목 수정 모달
const [editItemOpen, setEditItemOpen] = useState(false);
const [editItemForm, setEditItemForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) {
try {
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
};
load();
}, []);
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
// 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링)
const outsourcingDivisionCode = categoryOptions["division"]?.find(
(o) => o.label === "외주관리" || o.label === "외주" || o.label.includes("외주")
)?.code;
const fetchItems = useCallback(async () => {
setItemLoading(true);
try {
const filters: any[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
// division = 외주관리 필터 추가
if (outsourcingDivisionCode) {
filters.push({ columnName: "division", operator: "equals", value: outsourcingDivisionCode });
}
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
return converted;
});
setItems(data);
setItemCount(res.data?.data?.total || raw.length);
} catch (err) {
console.error("품목 조회 실패:", err);
toast.error("품목 목록을 불러오는데 실패했습니다.");
} finally {
setItemLoading(false);
}
}, [searchFilters, categoryOptions, outsourcingDivisionCode]);
useEffect(() => { fetchItems(); }, [fetchItems]);
// 선택된 품목
const selectedItem = items.find((i) => i.id === selectedItemId);
// 우측: 외주업체 목록 조회
useEffect(() => {
if (!selectedItem?.item_number) { setSubcontractorItems([]); return; }
const itemKey = selectedItem.item_number;
const fetchSubcontractorItems = async () => {
setSubcontractorLoading(true);
try {
// subcontractor_item_mapping에서 해당 품목의 매핑 조회
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
// subcontractor_id → subcontractor_mng 조인 (외주업체명)
const subIds = [...new Set(mappings.map((m: any) => m.subcontractor_id).filter(Boolean))];
let subMap: Record<string, any> = {};
if (subIds.length > 0) {
try {
const subRes = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
page: 1, size: subIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "subcontractor_code", operator: "in", value: subIds }] },
autoFilter: true,
});
for (const s of (subRes.data?.data?.data || subRes.data?.data?.rows || [])) {
subMap[s.subcontractor_code] = s;
}
} catch { /* skip */ }
}
setSubcontractorItems(mappings.map((m: any) => ({
...m,
subcontractor_code: m.subcontractor_id,
subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "",
})));
} catch (err) {
console.error("외주업체 조회 실패:", err);
} finally {
setSubcontractorLoading(false);
}
};
fetchSubcontractorItems();
}, [selectedItem?.item_number]);
// 외주업체 검색
const searchSubcontractors = async () => {
setSubSearchLoading(true);
try {
const filters: any[] = [];
if (subSearchKeyword) filters.push({ columnName: "subcontractor_name", operator: "contains", value: subSearchKeyword });
const res = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const all = res.data?.data?.data || res.data?.data?.rows || [];
// 이미 등록된 외주업체 제외
const existing = new Set(subcontractorItems.map((s: any) => s.subcontractor_id || s.subcontractor_code));
setSubSearchResults(all.filter((s: any) => !existing.has(s.subcontractor_code)));
} catch { /* skip */ } finally { setSubSearchLoading(false); }
};
// 외주업체 추가 저장
const addSelectedSubcontractors = async () => {
const selected = subSearchResults.filter((s) => subCheckedIds.has(s.id));
if (selected.length === 0 || !selectedItem) return;
try {
for (const sub of selected) {
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
subcontractor_id: sub.subcontractor_code,
item_id: selectedItem.item_number,
});
}
toast.success(`${selected.length}개 외주업체가 추가되었습니다.`);
setSubCheckedIds(new Set());
setSubSelectOpen(false);
// 우측 새로고침
const sid = selectedItemId;
setSelectedItemId(null);
setTimeout(() => setSelectedItemId(sid), 50);
} catch (err: any) {
toast.error(err.response?.data?.message || "외주업체 추가에 실패했습니다.");
}
};
// 품목 수정
const openEditItem = () => {
if (!selectedItem) return;
setEditItemForm({ ...selectedItem });
setEditItemOpen(true);
};
const handleEditSave = async () => {
if (!editItemForm.id) return;
setSaving(true);
try {
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
originalData: { id: editItemForm.id },
updatedData: {
selling_price: editItemForm.selling_price || null,
standard_price: editItemForm.standard_price || null,
currency_code: editItemForm.currency_code || null,
},
});
toast.success("수정되었습니다.");
setEditItemOpen(false);
fetchItems();
} catch (err: any) {
toast.error(err.response?.data?.message || "수정에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) return;
const data = items.map((i) => ({
품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.unit,
기준단가: i.standard_price, 판매가격: i.selling_price, 통화: i.currency_code, 상태: i.status,
}));
await exportToExcel(data, "외주품목정보.xlsx", "외주품목");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<DynamicSearchFilter
tableName={ITEM_TABLE}
filterId="subcontractor-item"
onFilterChange={setSearchFilters}
dataCount={itemCount}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
{/* 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 외주품목 목록 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Package className="w-4 h-4" />
<Badge variant="secondary" className="font-normal">{itemCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button variant="outline" size="sm" disabled={!selectedItemId} onClick={openEditItem}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
<DataGrid
gridId="subcontractor-item-left"
columns={LEFT_COLUMNS}
data={items}
loading={itemLoading}
selectedId={selectedItemId}
onSelect={setSelectedItemId}
onRowDoubleClick={() => openEditItem()}
emptyMessage="등록된 외주품목이 없습니다"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 외주업체 정보 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Users className="w-4 h-4" />
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
</div>
<Button variant="outline" size="sm" disabled={!selectedItemId}
onClick={() => { setSubCheckedIds(new Set()); setSubSelectOpen(true); searchSubcontractors(); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{!selectedItemId ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
</div>
) : (
<DataGrid
gridId="subcontractor-item-right"
columns={RIGHT_COLUMNS}
data={subcontractorItems}
loading={subcontractorLoading}
showRowNumber={false}
emptyMessage="등록된 외주업체가 없습니다"
/>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 품목 수정 모달 */}
<FullscreenDialog
open={editItemOpen}
onOpenChange={setEditItemOpen}
title="외주품목 수정"
description={`${editItemForm.item_number || ""}${editItemForm.item_name || ""}`}
defaultMaxWidth="max-w-2xl"
footer={
<>
<Button variant="outline" onClick={() => setEditItemOpen(false)}></Button>
<Button onClick={handleEditSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="grid grid-cols-2 gap-4 py-4">
{[
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "size", label: "규격" },
{ key: "unit", label: "단위" },
{ key: "material", label: "재질" },
{ key: "status", label: "상태" },
].map((f) => (
<div key={f.key} className="space-y-1.5">
<Label className="text-sm text-muted-foreground">{f.label}</Label>
<Input value={editItemForm[f.key] || ""} className="h-9 bg-muted/50" disabled />
</div>
))}
<div className="col-span-2 border-t my-2" />
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.selling_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, selling_price: e.target.value }))}
placeholder="판매가격" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.standard_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))}
placeholder="기준단가" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={editItemForm.currency_code || ""} onValueChange={(v) => setEditItemForm((p) => ({ ...p, currency_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="통화" /></SelectTrigger>
<SelectContent>
{(categoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</FullscreenDialog>
{/* 외주업체 추가 모달 */}
<Dialog open={subSelectOpen} onOpenChange={setSubSelectOpen}>
<DialogContent className="max-w-2xl max-h-[70vh]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-2 mb-3">
<Input placeholder="외주업체명 검색" value={subSearchKeyword}
onChange={(e) => setSubSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchSubcontractors()}
className="h-9 flex-1" />
<Button size="sm" onClick={searchSubcontractors} disabled={subSearchLoading} className="h-9">
{subSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox"
checked={subSearchResults.length > 0 && subCheckedIds.size === subSearchResults.length}
onChange={(e) => {
if (e.target.checked) setSubCheckedIds(new Set(subSearchResults.map((s) => s.id)));
else setSubCheckedIds(new Set());
}} />
</TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="min-w-[130px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subSearchResults.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : subSearchResults.map((s) => (
<TableRow key={s.id} className={cn("cursor-pointer", subCheckedIds.has(s.id) && "bg-primary/5")}
onClick={() => setSubCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(s.id)) next.delete(s.id); else next.add(s.id);
return next;
})}>
<TableCell className="text-center"><input type="checkbox" checked={subCheckedIds.has(s.id)} readOnly /></TableCell>
<TableCell className="text-xs">{s.subcontractor_code}</TableCell>
<TableCell className="text-sm">{s.subcontractor_name}</TableCell>
<TableCell className="text-xs">{s.division}</TableCell>
<TableCell className="text-xs">{s.contact_person}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{subCheckedIds.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setSubSelectOpen(false)}></Button>
<Button onClick={addSelectedSubcontractors} disabled={subCheckedIds.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {subCheckedIds.size}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={ITEM_TABLE}
userId={user?.userId}
onSuccess={() => fetchItems()}
/>
{ConfirmDialogComponent}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -390,15 +390,62 @@ export default function ProductionPlanManagementPage() {
return;
}
const items = orderItems
// 납기일별로 분리하여 각각 계획 생성
const items: GenerateScheduleRequest["items"] = [];
orderItems
.filter((item) => selectedItemGroups.has(item.item_code))
.map((item) => ({
item_code: item.item_code,
item_name: item.item_name,
required_qty: Number(item.required_plan_qty),
earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0],
lead_time: Number(item.lead_time) || 0,
}));
.forEach((item) => {
const leadTime = Number(item.lead_time) || 0;
const totalRequired = Number(item.required_plan_qty);
if (totalRequired <= 0) return;
// 수주가 여러 건이고 납기일이 다르면 각각 분리
if (item.orders && item.orders.length > 1) {
const byDueDate = new Map<string, number>();
for (const order of item.orders) {
const dd = order.due_date || new Date().toISOString().split("T")[0];
byDueDate.set(dd, (byDueDate.get(dd) || 0) + Number(order.balance_qty || 0));
}
if (byDueDate.size > 1) {
// 납기일별 잔량 비율로 required_plan_qty 분배
const totalBalance = Number(item.total_balance_qty) || 1;
let distributed = 0;
const entries = [...byDueDate.entries()];
entries.forEach(([dueDate, balanceQty], idx) => {
if (balanceQty <= 0) return;
// 마지막 건은 나머지 할당 (반올림 오차 방지)
const qty = idx === entries.length - 1
? totalRequired - distributed
: Math.round(totalRequired * (balanceQty / totalBalance));
if (qty <= 0) return;
distributed += qty;
items.push({
item_code: item.item_code,
item_name: item.item_name,
required_qty: qty,
earliest_due_date: dueDate,
lead_time: leadTime,
});
});
} else {
items.push({
item_code: item.item_code,
item_name: item.item_name,
required_qty: totalRequired,
earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0],
lead_time: leadTime,
});
}
} else {
items.push({
item_code: item.item_code,
item_name: item.item_name,
required_qty: totalRequired,
earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0],
lead_time: leadTime,
});
}
});
setGenerating(true);
try {
@ -425,15 +472,59 @@ export default function ProductionPlanManagementPage() {
const handleApplySchedule = useCallback(async () => {
if (selectedItemGroups.size === 0) return;
const items = orderItems
// 납기일별로 분리하여 각각 계획 생성
const items: GenerateScheduleRequest["items"] = [];
orderItems
.filter((item) => selectedItemGroups.has(item.item_code))
.map((item) => ({
item_code: item.item_code,
item_name: item.item_name,
required_qty: Number(item.required_plan_qty),
earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0],
lead_time: Number(item.lead_time) || 0,
}));
.forEach((item) => {
const leadTime = Number(item.lead_time) || 0;
const totalRequired = Number(item.required_plan_qty);
if (totalRequired <= 0) return;
if (item.orders && item.orders.length > 1) {
const byDueDate = new Map<string, number>();
for (const order of item.orders) {
const dd = order.due_date || new Date().toISOString().split("T")[0];
byDueDate.set(dd, (byDueDate.get(dd) || 0) + Number(order.balance_qty || 0));
}
if (byDueDate.size > 1) {
const totalBalance = Number(item.total_balance_qty) || 1;
let distributed = 0;
const entries = [...byDueDate.entries()];
entries.forEach(([dueDate, balanceQty], idx) => {
if (balanceQty <= 0) return;
const qty = idx === entries.length - 1
? totalRequired - distributed
: Math.round(totalRequired * (balanceQty / totalBalance));
if (qty <= 0) return;
distributed += qty;
items.push({
item_code: item.item_code,
item_name: item.item_name,
required_qty: qty,
earliest_due_date: dueDate,
lead_time: leadTime,
});
});
} else {
items.push({
item_code: item.item_code,
item_name: item.item_name,
required_qty: totalRequired,
earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0],
lead_time: leadTime,
});
}
} else {
items.push({
item_code: item.item_code,
item_name: item.item_name,
required_qty: totalRequired,
earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0],
lead_time: leadTime,
});
}
});
setGenerating(true);
try {

View File

@ -20,7 +20,7 @@ import {
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search } from "lucide-react";
import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
@ -31,6 +31,7 @@ import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
const ITEM_TABLE = "item_info";
const MAPPING_TABLE = "customer_item_mapping";
@ -92,6 +93,19 @@ export default function SalesItemPage() {
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 거래처 상세 입력 모달 (거래처 품번/품명 + 단가)
const [custDetailOpen, setCustDetailOpen] = useState(false);
const [selectedCustsForDetail, setSelectedCustsForDetail] = useState<any[]>([]);
const [custMappings, setCustMappings] = useState<Record<string, Array<{ _id: string; customer_item_code: string; customer_item_name: string }>>>({});
const [custPrices, setCustPrices] = useState<Record<string, Array<{
_id: string; start_date: string; end_date: string; currency_code: string;
base_price_type: string; base_price: string; discount_type: string;
discount_value: string; rounding_type: string; rounding_unit_value: string;
calculated_price: string;
}>>>({});
const [priceCategoryOptions, setPriceCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
const [editCustData, setEditCustData] = useState<any>(null);
// 카테고리 로드
useEffect(() => {
const load = async () => {
@ -111,6 +125,16 @@ export default function SalesItemPage() {
} catch { /* skip */ }
}
setCategoryOptions(optMap);
// 단가 카테고리
const priceOpts: Record<string, { code: string; label: string }[]> = {};
for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) {
try {
const res = await apiClient.get(`/table-categories/customer_item_prices/${col}/values`);
if (res.data?.success) priceOpts[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setPriceCategoryOptions(priceOpts);
};
load();
}, []);
@ -217,26 +241,236 @@ export default function SalesItemPage() {
} catch { /* skip */ } finally { setCustSearchLoading(false); }
};
// 거래처 추가 저장
const addSelectedCustomers = async () => {
// 거래처 선택 → 상세 모달로 이동
const goToCustDetail = () => {
const selected = custSearchResults.filter((c) => custCheckedIds.has(c.id));
if (selected.length === 0 || !selectedItem) return;
if (selected.length === 0) { toast.error("거래처를 선택해주세요."); return; }
setSelectedCustsForDetail(selected);
const mappings: typeof custMappings = {};
const prices: typeof custPrices = {};
for (const cust of selected) {
const key = cust.customer_code || cust.id;
mappings[key] = [];
prices[key] = [{
_id: `p_${Date.now()}_${Math.random()}`,
start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: selectedItem?.standard_price || selectedItem?.selling_price || "",
discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "",
calculated_price: selectedItem?.standard_price || selectedItem?.selling_price || "",
}];
}
setCustMappings(mappings);
setCustPrices(prices);
setCustSelectOpen(false);
setCustDetailOpen(true);
};
const addMappingRow = (custKey: string) => {
setCustMappings((prev) => ({
...prev,
[custKey]: [...(prev[custKey] || []), { _id: `m_${Date.now()}_${Math.random()}`, customer_item_code: "", customer_item_name: "" }],
}));
};
const removeMappingRow = (custKey: string, rowId: string) => {
setCustMappings((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId),
}));
};
const updateMappingRow = (custKey: string, rowId: string, field: string, value: string) => {
setCustMappings((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).map((r) => r._id === rowId ? { ...r, [field]: value } : r),
}));
};
const addPriceRow = (custKey: string) => {
setCustPrices((prev) => ({
...prev,
[custKey]: [...(prev[custKey] || []), {
_id: `p_${Date.now()}_${Math.random()}`,
start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "",
discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "",
calculated_price: "",
}],
}));
};
const removePriceRow = (custKey: string, rowId: string) => {
setCustPrices((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId),
}));
};
const updatePriceRow = (custKey: string, rowId: string, field: string, value: string) => {
setCustPrices((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).map((r) => {
if (r._id !== rowId) return r;
const updated = { ...r, [field]: value };
if (["base_price", "discount_type", "discount_value"].includes(field)) {
const bp = Number(updated.base_price) || 0;
const dv = Number(updated.discount_value) || 0;
const dt = updated.discount_type;
let calc = bp;
if (dt === "CAT_MLAMBEC8_URQA") calc = bp * (1 - dv / 100);
else if (dt === "CAT_MLAMBLFM_JTLO") calc = bp - dv;
updated.calculated_price = String(Math.round(calc));
}
return updated;
}),
}));
};
const openEditCust = async (row: any) => {
const custKey = row.customer_code || row.customer_id;
// customer_mng에서 거래처 정보 조회
let custInfo: any = { customer_code: custKey, customer_name: row.customer_name || "" };
try {
for (const cust of selected) {
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
customer_id: cust.customer_code,
item_id: selectedItem.item_number,
});
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: custKey }] },
autoFilter: true,
});
const found = (res.data?.data?.data || res.data?.data?.rows || [])[0];
if (found) custInfo = found;
} catch { /* skip */ }
const mappingRows = [{
_id: `m_existing_${row.id}`,
customer_item_code: row.customer_item_code || "",
customer_item_name: row.customer_item_name || "",
}].filter((m) => m.customer_item_code || m.customer_item_name);
const priceRows = [{
_id: `p_existing_${row.id}`,
start_date: row.start_date || "",
end_date: row.end_date || "",
currency_code: row.currency_code || "CAT_MLAMDKVN_PZJI",
base_price_type: row.base_price_type || "CAT_MLAMFGFT_4RZW",
base_price: row.base_price ? String(row.base_price) : "",
discount_type: row.discount_type || "",
discount_value: row.discount_value ? String(row.discount_value) : "",
rounding_type: row.rounding_type || "",
rounding_unit_value: row.rounding_unit_value || "",
calculated_price: row.calculated_price ? String(row.calculated_price) : "",
}].filter((p) => p.base_price || p.start_date);
if (priceRows.length === 0) {
priceRows.push({
_id: `p_${Date.now()}`, start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "", discount_type: "", discount_value: "",
rounding_type: "", rounding_unit_value: "", calculated_price: "",
});
}
setSelectedCustsForDetail([custInfo]);
setCustMappings({ [custKey]: mappingRows });
setCustPrices({ [custKey]: priceRows });
setEditCustData(row);
setCustDetailOpen(true);
};
const handleCustDetailSave = async () => {
if (!selectedItem) return;
const isEditingExisting = !!editCustData;
setSaving(true);
try {
for (const cust of selectedCustsForDetail) {
const custKey = cust.customer_code || cust.id;
const mappingRows = custMappings[custKey] || [];
if (isEditingExisting && editCustData?.id) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: editCustData.id },
updatedData: {
customer_item_code: mappingRows[0]?.customer_item_code || "",
customer_item_name: mappingRows[0]?.customer_item_name || "",
},
});
// 기존 prices 삭제 후 재등록
try {
const existingPrices = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "mapping_id", operator: "equals", value: editCustData.id },
]}, autoFilter: true,
});
const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, {
data: existing.map((p: any) => ({ id: p.id })),
});
}
} catch { /* skip */ }
const priceRows = (custPrices[custKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
mapping_id: editCustData.id,
customer_id: custKey,
item_id: selectedItem.item_number,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
});
}
} else {
// 신규 등록
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
customer_id: custKey, item_id: selectedItem.item_number,
customer_item_code: mappingRows[0]?.customer_item_code || "",
customer_item_name: mappingRows[0]?.customer_item_name || "",
});
const mappingId = mappingRes.data?.data?.id || null;
for (let mi = 1; mi < mappingRows.length; mi++) {
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
customer_id: custKey, item_id: selectedItem.item_number,
customer_item_code: mappingRows[mi].customer_item_code || "",
customer_item_name: mappingRows[mi].customer_item_name || "",
});
}
const priceRows = (custPrices[custKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
mapping_id: mappingId || "", customer_id: custKey, item_id: selectedItem.item_number,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
});
}
}
}
toast.success(`${selected.length}개 거래처가 추가되었습니다.`);
toast.success(isEditingExisting ? "수정되었습니다." : `${selectedCustsForDetail.length}개 거래처가 추가되었습니다.`);
setCustDetailOpen(false);
setEditCustData(null);
setCustCheckedIds(new Set());
setCustSelectOpen(false);
// 우측 새로고침
const sid = selectedItemId;
setSelectedItemId(null);
setTimeout(() => setSelectedItemId(sid), 50);
} catch (err: any) {
toast.error(err.response?.data?.message || "거래처 추가에 실패했습니다.");
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
@ -357,6 +591,7 @@ export default function SalesItemPage() {
loading={customerLoading}
showRowNumber={false}
emptyMessage="등록된 거래처가 없습니다"
onRowDoubleClick={(row) => openEditCust(row)}
/>
)}
</div>
@ -480,8 +715,8 @@ export default function SalesItemPage() {
<span className="text-sm text-muted-foreground">{custCheckedIds.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setCustSelectOpen(false)}></Button>
<Button onClick={addSelectedCustomers} disabled={custCheckedIds.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {custCheckedIds.size}
<Button onClick={goToCustDetail} disabled={custCheckedIds.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {custCheckedIds.size}
</Button>
</div>
</div>
@ -489,6 +724,156 @@ export default function SalesItemPage() {
</DialogContent>
</Dialog>
{/* 거래처 상세 입력/수정 모달 */}
<FullscreenDialog
open={custDetailOpen}
onOpenChange={setCustDetailOpen}
title={`📋 거래처 상세정보 ${editCustData ? "수정" : "입력"}${selectedItem?.item_name || ""}`}
description={editCustData ? "거래처 품번/품명과 기간별 단가를 수정합니다." : "선택한 거래처의 품번/품명과 기간별 단가를 설정합니다."}
defaultMaxWidth="max-w-[1100px]"
footer={
<>
<Button variant="outline" onClick={() => {
setCustDetailOpen(false);
if (!editCustData) setCustSelectOpen(true);
setEditCustData(null);
}}>{editCustData ? "취소" : "← 이전"}</Button>
<Button onClick={handleCustDetailSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="space-y-6 py-2">
{selectedCustsForDetail.map((cust, idx) => {
const custKey = cust.customer_code || cust.id;
const mappingRows = custMappings[custKey] || [];
const prices = custPrices[custKey] || [];
return (
<div key={custKey} className="border rounded-xl overflow-hidden bg-card">
<div className="px-5 py-3 bg-muted/30 border-b">
<div className="font-bold">{idx + 1}. {cust.customer_name || custKey}</div>
<div className="text-xs text-muted-foreground">{custKey}</div>
</div>
<div className="flex gap-4 p-4">
{/* 좌: 거래처 품번/품명 */}
<div className="flex-1 border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/10">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold"> / </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addMappingRow(custKey)}>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{mappingRows.length === 0 ? (
<div className="text-xs text-muted-foreground py-2"> </div>
) : mappingRows.map((mRow, mIdx) => (
<div key={mRow._id} className="flex gap-2 items-center">
<span className="text-xs text-muted-foreground w-4 shrink-0">{mIdx + 1}</span>
<Input value={mRow.customer_item_code}
onChange={(e) => updateMappingRow(custKey, mRow._id, "customer_item_code", e.target.value)}
placeholder="거래처 품번" className="h-8 text-sm flex-1" />
<Input value={mRow.customer_item_name}
onChange={(e) => updateMappingRow(custKey, mRow._id, "customer_item_name", e.target.value)}
placeholder="거래처 품명" className="h-8 text-sm flex-1" />
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive shrink-0"
onClick={() => removeMappingRow(custKey, mRow._id)}>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
{/* 우: 기간별 단가 */}
<div className="flex-1 border rounded-lg p-4 bg-amber-50/30 dark:bg-amber-950/10">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold"> </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addPriceRow(custKey)}>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-3">
{prices.map((price, pIdx) => (
<div key={price._id} className="border rounded-lg p-3 bg-background space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground"> {pIdx + 1}</span>
{prices.length > 1 && (
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-destructive"
onClick={() => removePriceRow(custKey, price._id)}>
<X className="h-3 w-3" />
</Button>
)}
</div>
<div className="flex gap-2 items-center">
<div className="flex-1">
<FormDatePicker value={price.start_date}
onChange={(v) => updatePriceRow(custKey, price._id, "start_date", v)} placeholder="시작일" />
</div>
<span className="text-xs text-muted-foreground">~</span>
<div className="flex-1">
<FormDatePicker value={price.end_date}
onChange={(v) => updatePriceRow(custKey, price._id, "end_date", v)} placeholder="종료일" />
</div>
<div className="w-[80px]">
<Select value={price.currency_code} onValueChange={(v) => updatePriceRow(custKey, price._id, "currency_code", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="통화" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="flex gap-2 items-center">
<div className="w-[90px]">
<Select value={price.base_price_type} onValueChange={(v) => updatePriceRow(custKey, price._id, "base_price_type", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="기준" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["base_price_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Input value={price.base_price}
onChange={(e) => updatePriceRow(custKey, price._id, "base_price", e.target.value)}
className="h-8 text-xs text-right flex-1" placeholder="기준가" />
<div className="w-[90px]">
<Select value={price.discount_type} onValueChange={(v) => updatePriceRow(custKey, price._id, "discount_type", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="할인" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{(priceCategoryOptions["discount_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Input value={price.discount_value}
onChange={(e) => updatePriceRow(custKey, price._id, "discount_value", e.target.value)}
className="h-8 text-xs text-right w-[60px]" placeholder="0" />
<div className="w-[90px]">
<Select value={price.rounding_unit_value} onValueChange={(v) => updatePriceRow(custKey, price._id, "rounding_unit_value", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="반올림" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["rounding_unit_value"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1 border-t">
<span className="text-xs text-muted-foreground"> :</span>
<span className="font-bold text-sm">{price.calculated_price ? Number(price.calculated_price).toLocaleString() : "-"}</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
})}
</div>
</FullscreenDialog>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}

View File

@ -0,0 +1,188 @@
import { apiClient } from "./client";
// --- 타입 정의 ---
export interface OutboundItem {
id: string;
company_code: string;
outbound_number: string;
outbound_type: string;
outbound_date: string;
reference_number: string | null;
customer_code: string | null;
customer_name: string | null;
item_code: string | null;
item_name: string | null;
specification: string | null;
material: string | null;
unit: string | null;
outbound_qty: number;
unit_price: number;
total_amount: number;
lot_number: string | null;
warehouse_code: string | null;
warehouse_name?: string | null;
location_code: string | null;
outbound_status: string;
manager_id: string | null;
memo: string | null;
source_type: string | null;
sales_order_id: string | null;
shipment_plan_id: string | null;
item_info_id: string | null;
destination_code: string | null;
delivery_destination: string | null;
delivery_address: string | null;
created_date: string;
created_by: string | null;
}
export interface ShipmentInstructionSource {
detail_id: number;
instruction_id: number;
instruction_no: string;
instruction_date: string;
partner_id: string;
instruction_status: string;
item_code: string;
item_name: string;
spec: string | null;
material: string | null;
plan_qty: number;
ship_qty: number;
order_qty: number;
remain_qty: number;
source_type: string | null;
}
export interface PurchaseOrderSource {
id: string;
purchase_no: string;
order_date: string;
supplier_code: string;
supplier_name: string;
item_code: string;
item_name: string;
spec: string | null;
material: string | null;
order_qty: number;
received_qty: number;
unit_price: number;
status: string;
due_date: string | null;
}
export interface ItemSource {
id: string;
item_number: string;
item_name: string;
spec: string | null;
material: string | null;
unit: string | null;
standard_price: number;
}
export interface WarehouseOption {
warehouse_code: string;
warehouse_name: string;
warehouse_type: string;
}
export interface CreateOutboundPayload {
outbound_number: string;
outbound_date: string;
warehouse_code?: string;
location_code?: string;
manager_id?: string;
memo?: string;
items: Array<{
outbound_type: string;
reference_number?: string;
customer_code?: string;
customer_name?: string;
item_code?: string;
item_number?: string;
item_name?: string;
spec?: string;
specification?: string;
material?: string;
unit?: string;
outbound_qty: number;
unit_price?: number;
total_amount?: number;
lot_number?: string;
warehouse_code?: string;
location_code?: string;
outbound_status?: string;
manager_id?: string;
memo?: string;
source_type?: string;
source_id?: string;
sales_order_id?: string;
shipment_plan_id?: string;
item_info_id?: string;
destination_code?: string;
delivery_destination?: string;
delivery_address?: string;
}>;
}
// --- API 호출 ---
export async function getOutboundList(params?: {
outbound_type?: string;
outbound_status?: string;
search_keyword?: string;
date_from?: string;
date_to?: string;
}) {
const res = await apiClient.get("/outbound/list", { params });
return res.data as { success: boolean; data: OutboundItem[] };
}
export async function createOutbound(payload: CreateOutboundPayload) {
const res = await apiClient.post("/outbound", payload);
return res.data as { success: boolean; data: OutboundItem[]; message?: string };
}
export async function updateOutbound(id: string, payload: Partial<OutboundItem>) {
const res = await apiClient.put(`/outbound/${id}`, payload);
return res.data as { success: boolean; data: OutboundItem };
}
export async function deleteOutbound(id: string) {
const res = await apiClient.delete(`/outbound/${id}`);
return res.data as { success: boolean; message?: string };
}
export async function generateOutboundNumber() {
const res = await apiClient.get("/outbound/generate-number");
return res.data as { success: boolean; data: string };
}
export async function getOutboundWarehouses() {
const res = await apiClient.get("/outbound/warehouses");
return res.data as { success: boolean; data: WarehouseOption[] };
}
// 소스 데이터 조회
export async function getShipmentInstructionSources(keyword?: string) {
const res = await apiClient.get("/outbound/source/shipment-instructions", {
params: keyword ? { keyword } : {},
});
return res.data as { success: boolean; data: ShipmentInstructionSource[] };
}
export async function getPurchaseOrderSources(keyword?: string) {
const res = await apiClient.get("/outbound/source/purchase-orders", {
params: keyword ? { keyword } : {},
});
return res.data as { success: boolean; data: PurchaseOrderSource[] };
}
export async function getItemSources(keyword?: string) {
const res = await apiClient.get("/outbound/source/items", {
params: keyword ? { keyword } : {},
});
return res.data as { success: boolean; data: ItemSource[] };
}

View File

@ -0,0 +1,168 @@
import { apiClient } from "./client";
// --- 타입 정의 ---
export interface PkgUnit {
id: string;
company_code: string;
pkg_code: string;
pkg_name: string;
pkg_type: string;
status: string;
width_mm: number | null;
length_mm: number | null;
height_mm: number | null;
self_weight_kg: number | null;
max_load_kg: number | null;
volume_l: number | null;
remarks: string | null;
created_date: string;
writer: string | null;
}
export interface PkgUnitItem {
id: string;
company_code: string;
pkg_code: string;
item_number: string;
pkg_qty: number;
// JOIN된 필드
item_name?: string;
spec?: string;
unit?: string;
}
export interface LoadingUnit {
id: string;
company_code: string;
loading_code: string;
loading_name: string;
loading_type: string;
status: string;
width_mm: number | null;
length_mm: number | null;
height_mm: number | null;
self_weight_kg: number | null;
max_load_kg: number | null;
max_stack: number | null;
remarks: string | null;
created_date: string;
writer: string | null;
}
export interface LoadingUnitPkg {
id: string;
company_code: string;
loading_code: string;
pkg_code: string;
max_load_qty: number;
load_method: string | null;
// JOIN된 필드
pkg_name?: string;
pkg_type?: string;
}
export interface ItemInfoForPkg {
id: string;
item_number: string;
item_name: string;
size: string | null;
spec?: string | null;
material: string | null;
unit: string | null;
division: string | null;
}
// --- 포장단위 API ---
export async function getPkgUnits() {
const res = await apiClient.get("/packaging/pkg-units");
return res.data as { success: boolean; data: PkgUnit[] };
}
export async function createPkgUnit(data: Partial<PkgUnit>) {
const res = await apiClient.post("/packaging/pkg-units", data);
return res.data as { success: boolean; data: PkgUnit; message?: string };
}
export async function updatePkgUnit(id: string, data: Partial<PkgUnit>) {
const res = await apiClient.put(`/packaging/pkg-units/${id}`, data);
return res.data as { success: boolean; data: PkgUnit };
}
export async function deletePkgUnit(id: string) {
const res = await apiClient.delete(`/packaging/pkg-units/${id}`);
return res.data as { success: boolean; message?: string };
}
// --- 포장단위 매칭품목 API ---
export async function getPkgUnitItems(pkgCode: string) {
const res = await apiClient.get(`/packaging/pkg-unit-items/${encodeURIComponent(pkgCode)}`);
return res.data as { success: boolean; data: PkgUnitItem[] };
}
export async function createPkgUnitItem(data: { pkg_code: string; item_number: string; pkg_qty: number }) {
const res = await apiClient.post("/packaging/pkg-unit-items", data);
return res.data as { success: boolean; data: PkgUnitItem; message?: string };
}
export async function deletePkgUnitItem(id: string) {
const res = await apiClient.delete(`/packaging/pkg-unit-items/${id}`);
return res.data as { success: boolean; message?: string };
}
// --- 적재함 API ---
export async function getLoadingUnits() {
const res = await apiClient.get("/packaging/loading-units");
return res.data as { success: boolean; data: LoadingUnit[] };
}
export async function createLoadingUnit(data: Partial<LoadingUnit>) {
const res = await apiClient.post("/packaging/loading-units", data);
return res.data as { success: boolean; data: LoadingUnit; message?: string };
}
export async function updateLoadingUnit(id: string, data: Partial<LoadingUnit>) {
const res = await apiClient.put(`/packaging/loading-units/${id}`, data);
return res.data as { success: boolean; data: LoadingUnit };
}
export async function deleteLoadingUnit(id: string) {
const res = await apiClient.delete(`/packaging/loading-units/${id}`);
return res.data as { success: boolean; message?: string };
}
// --- 적재함 포장구성 API ---
export async function getLoadingUnitPkgs(loadingCode: string) {
const res = await apiClient.get(`/packaging/loading-unit-pkgs/${encodeURIComponent(loadingCode)}`);
return res.data as { success: boolean; data: LoadingUnitPkg[] };
}
export async function createLoadingUnitPkg(data: { loading_code: string; pkg_code: string; max_load_qty: number; load_method?: string }) {
const res = await apiClient.post("/packaging/loading-unit-pkgs", data);
return res.data as { success: boolean; data: LoadingUnitPkg; message?: string };
}
export async function deleteLoadingUnitPkg(id: string) {
const res = await apiClient.delete(`/packaging/loading-unit-pkgs/${id}`);
return res.data as { success: boolean; message?: string };
}
// --- 품목정보 연동 API ---
export async function getItemsByDivision(divisionLabel: string, keyword?: string) {
const res = await apiClient.get(`/packaging/items/${encodeURIComponent(divisionLabel)}`, {
params: keyword ? { keyword } : {},
});
return res.data as { success: boolean; data: ItemInfoForPkg[] };
}
export async function getGeneralItems(keyword?: string) {
const res = await apiClient.get("/packaging/items/general", {
params: keyword ? { keyword } : {},
});
return res.data as { success: boolean; data: ItemInfoForPkg[] };
}