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:
parent
5d4cf8d462
commit
e2f18b19bc
|
|
@ -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); // 차량 운행 이력 관리
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"lastSentAt": "2026-03-24T01:08:38.875Z"
|
||||
"lastSentAt": "2026-03-25T01:37:37.051Z"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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[] };
|
||||
}
|
||||
|
|
@ -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[] };
|
||||
}
|
||||
Loading…
Reference in New Issue