Merge pull request 'jskim-node' (#31) from jskim-node into main

Reviewed-on: https://g.wace.me/jskim/vexplor_dev/pulls/31
This commit is contained in:
jskim
2026-04-20 09:09:36 +00:00
88 changed files with 4210 additions and 808 deletions
+2
View File
@@ -157,6 +157,7 @@ import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시
import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지시 관리
import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
import reportPresetRoutes from "./routes/reportPresetRoutes"; // 리포트 프리셋 저장 (회사별/리포트별)
import reportCellValueRoutes from "./routes/reportCellValueRoutes"; // 리포트 셀 커스텀 입력값 (input 셀)
import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형)
import systemNoticeRoutes from "./routes/systemNoticeRoutes"; // 시스템 공지
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
@@ -381,6 +382,7 @@ app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리
app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리
app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
app.use("/api/report-presets", reportPresetRoutes); // 리포트 프리셋 (회사별/리포트별 저장)
app.use("/api/report-cell-values", reportCellValueRoutes); // 리포트 셀 커스텀 입력값
app.use("/api/system-notice", systemNoticeRoutes); // 시스템 공지
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
app.use("/api/design", designRoutes); // 설계 모듈
@@ -10,6 +10,7 @@
import type { Response } from "express";
import { getPool } from "../database/db";
import type { AuthenticatedRequest } from "../types/auth";
import { adjustInventory } from "../utils/inventoryUtils";
import { logger } from "../utils/logger";
// 출고 목록 조회
@@ -324,6 +325,9 @@ export async function create(req: AuthenticatedRequest, res: Response) {
// 출고 수정
export async function update(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
@@ -341,8 +345,90 @@ export async function update(req: AuthenticatedRequest, res: Response) {
memo,
} = req.body;
const pool = getPool();
const result = await pool.query(
await client.query("BEGIN");
// 변경 전 값 조회
const oldRes = await client.query(
`SELECT * FROM outbound_mng WHERE id = $1 AND company_code = $2`,
[id, companyCode],
);
if (oldRes.rowCount === 0) {
await client.query("ROLLBACK");
return res
.status(404)
.json({ success: false, message: "출고 데이터를 찾을 수 없습니다." });
}
const old = oldRes.rows[0];
const oldQty = Number(old.outbound_qty) || 0;
const oldWhCode = old.warehouse_code || null;
const oldLocCode = old.location_code || null;
const itemCode = old.item_code || old.item_number || null;
const outboundNumber = old.outbound_number;
const newQty =
outbound_qty !== undefined && outbound_qty !== null
? Number(outbound_qty)
: oldQty;
const newWhCode =
warehouse_code !== undefined ? warehouse_code : oldWhCode;
const newLocCode =
location_code !== undefined ? location_code : oldLocCode;
// 재고/이력 반영 (append-only): 수량 또는 창고/위치 변경 시
const qtyChanged = newQty !== oldQty;
const whChanged =
(newWhCode || "") !== (oldWhCode || "") ||
(newLocCode || "") !== (oldLocCode || "");
if (itemCode && (qtyChanged || whChanged)) {
if (whChanged) {
// 기존 창고 복구
if (oldQty > 0) {
await adjustInventory(client, {
companyCode,
userId,
itemCode,
whCode: oldWhCode,
locCode: oldLocCode,
delta: +oldQty,
transactionType: "출고취소",
remark: `출고수정-창고변경 (${outboundNumber}) ${oldWhCode || ""}${newWhCode || ""}`,
});
}
// 신규 창고 차감 (재고부족 검증)
if (newQty > 0) {
await adjustInventory(client, {
companyCode,
userId,
itemCode,
whCode: newWhCode,
locCode: newLocCode,
delta: -newQty,
transactionType: "출고수정",
remark: `출고수정-창고변경 (${outboundNumber}) ${oldWhCode || ""}${newWhCode || ""}, 수량 ${oldQty}${newQty}`,
validateStockEnough: true,
});
}
} else {
// 창고 동일, 수량만 변경: 기존 복구(+oldQty) + 신규 차감(-newQty) = delta(+복구/-추가차감)
const delta = oldQty - newQty;
if (delta !== 0) {
await adjustInventory(client, {
companyCode,
userId,
itemCode,
whCode: newWhCode,
locCode: newLocCode,
delta,
transactionType: "출고수정",
remark: `출고수정 (${outboundNumber}) 수량 ${oldQty}${newQty}`,
validateStockEnough: delta < 0,
});
}
}
}
const result = await client.query(
`UPDATE outbound_mng SET
outbound_date = COALESCE($1, outbound_date),
outbound_qty = COALESCE($2, outbound_qty),
@@ -375,45 +461,95 @@ export async function update(req: AuthenticatedRequest, res: Response) {
],
);
if (result.rowCount === 0) {
return res
.status(404)
.json({ success: false, message: "출고 데이터를 찾을 수 없습니다." });
}
await client.query("COMMIT");
logger.info("출고 수정", { companyCode, userId, id });
logger.info("출고 수정", {
companyCode,
userId,
id,
oldQty,
newQty,
oldWhCode,
newWhCode,
});
return res.json({ success: true, data: result.rows[0] });
} 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 deleteOutbound(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
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`,
await client.query("BEGIN");
// 대상 출고 조회
const oldRes = await client.query(
`SELECT * FROM outbound_mng WHERE id = $1 AND company_code = $2`,
[id, companyCode],
);
if (result.rowCount === 0) {
if (oldRes.rowCount === 0) {
await client.query("ROLLBACK");
return res
.status(404)
.json({ success: false, message: "데이터를 찾을 수 없습니다." });
}
const old = oldRes.rows[0];
const itemCode = old.item_code || old.item_number || null;
const whCode = old.warehouse_code || null;
const locCode = old.location_code || null;
const qty = Number(old.outbound_qty) || 0;
const outboundNumber = old.outbound_number;
logger.info("출고 삭제", { companyCode, id });
// 재고 복구 + 이력
if (itemCode && qty > 0) {
await adjustInventory(client, {
companyCode,
userId,
itemCode,
whCode,
locCode,
delta: +qty,
transactionType: "출고취소",
remark: `출고 삭제 (${outboundNumber})`,
});
} else {
logger.warn("출고 삭제 - 재고 복구 스킵", {
companyCode,
id,
itemCode,
qty,
});
}
await client.query(
`DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2`,
[id, companyCode],
);
await client.query("COMMIT");
logger.info("출고 삭제", { companyCode, userId, id, itemCode, qty });
return res.json({ success: true, message: "삭제 완료" });
} 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();
}
}
@@ -384,26 +384,33 @@ export async function getRoutingDetails(req: AuthenticatedRequest, res: Response
const rows = result.rows;
const detailIds = rows.map((r: any) => r.id).filter(Boolean);
let mappingByDetail: Record<string, string[]> = {};
let idsByDetail: Record<string, string[]> = {};
let codesByDetail: Record<string, string[]> = {};
if (detailIds.length > 0) {
const mapRes = await pool.query(
`SELECT routing_detail_id, subcontractor_code
FROM item_routing_subcontractor
WHERE routing_detail_id = ANY($1::uuid[])
ORDER BY seq_order`,
`SELECT irs.routing_detail_id, irs.subcontractor_id, sm.subcontractor_code
FROM item_routing_subcontractor irs
LEFT JOIN subcontractor_mng sm ON irs.subcontractor_id = sm.id
WHERE irs.routing_detail_id = ANY($1::varchar[])
ORDER BY irs.seq_order`,
[detailIds]
);
for (const m of mapRes.rows) {
const key = String(m.routing_detail_id);
if (!mappingByDetail[key]) mappingByDetail[key] = [];
mappingByDetail[key].push(m.subcontractor_code);
(idsByDetail[key] ||= []).push(m.subcontractor_id);
if (m.subcontractor_code) (codesByDetail[key] ||= []).push(m.subcontractor_code);
}
}
const enriched = rows.map((r: any) => {
const list = mappingByDetail[String(r.id)] || [];
// 레거시 폴백: 매핑이 비어있고 legacy 단일 컬럼에 값이 있으면 배열로 포장
if (list.length === 0 && r.outsource_supplier) list.push(r.outsource_supplier);
return { ...r, outsource_supplier_list: list };
const ids = idsByDetail[String(r.id)] || [];
const codes = codesByDetail[String(r.id)] || [];
// 레거시 폴백: 매핑이 비어있고 legacy 단일 컬럼(code)에 값이 있으면 code 배열로 반환
const legacyCodes = ids.length === 0 && r.outsource_supplier ? [r.outsource_supplier] : codes;
return {
...r,
outsource_supplier_ids: ids,
outsource_supplier_list: legacyCodes, // 하위호환 별칭 (code 배열)
};
});
return res.json({ success: true, data: enriched });
@@ -440,24 +447,36 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons
);
for (const d of details) {
const suppliers: string[] = Array.isArray(d.outsource_supplier_list)
? d.outsource_supplier_list.filter((s: any) => typeof s === "string" && s.trim() !== "")
: (d.outsource_supplier ? [d.outsource_supplier] : []);
const primaryLegacy = suppliers[0] || d.outsource_supplier || "";
const supplierIds: string[] = Array.isArray(d.outsource_supplier_ids)
? d.outsource_supplier_ids.filter((s: any) => typeof s === "string" && s.trim() !== "")
: [];
// legacy code 해석: 첫 번째 subcontractor_id → subcontractor_code 조회
let legacyCode = "";
if (supplierIds.length > 0) {
const codeRes = await client.query(
`SELECT subcontractor_code FROM subcontractor_mng WHERE id=$1 LIMIT 1`,
[supplierIds[0]]
);
legacyCode = codeRes.rows[0]?.subcontractor_code || "";
} else if (d.outsource_supplier) {
// 프론트가 아직 id 없이 code만 보낸 경우(레거시 호환)
legacyCode = d.outsource_supplier;
}
const insertRes = await client.query(
`INSERT INTO item_routing_detail (id, company_code, routing_version_id, seq_no, process_code, is_required, is_fixed_order, work_type, standard_time, outsource_supplier, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id`,
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", primaryLegacy, writer]
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", legacyCode, writer]
);
const newDetailId = insertRes.rows[0].id;
for (let i = 0; i < suppliers.length; i++) {
for (let i = 0; i < supplierIds.length; i++) {
await client.query(
`INSERT INTO item_routing_subcontractor (id, company_code, routing_detail_id, subcontractor_code, seq_order)
VALUES (gen_random_uuid(), $1, $2, $3, $4)`,
[companyCode, newDetailId, suppliers[i], i]
`INSERT INTO item_routing_subcontractor (id, company_code, routing_detail_id, subcontractor_id, seq_order)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4)`,
[companyCode, newDetailId, supplierIds[i], i]
);
}
}
@@ -10,6 +10,7 @@
import type { Response } from "express";
import { getPool } from "../database/db";
import type { AuthenticatedRequest } from "../types/auth";
import { adjustInventory } from "../utils/inventoryUtils";
import { logger } from "../utils/logger";
// 입고 목록 조회 (헤더-디테일 JOIN, 레거시 호환)
@@ -472,6 +473,45 @@ export async function update(req: AuthenticatedRequest, res: Response) {
await client.query("BEGIN");
// 변경 전 값 조회 (헤더)
const oldHeaderRes = await client.query(
`SELECT * FROM inbound_mng WHERE id = $1 AND company_code = $2`,
[id, companyCode],
);
if (oldHeaderRes.rowCount === 0) {
await client.query("ROLLBACK");
return res
.status(404)
.json({ success: false, message: "입고 데이터를 찾을 수 없습니다." });
}
const oldHeader = oldHeaderRes.rows[0];
// 변경 전 값 조회 (디테일, 있을 경우)
let oldDetail: any = null;
if (detail_id) {
const oldDetailRes = await client.query(
`SELECT * FROM inbound_detail WHERE id = $1 AND company_code = $2`,
[detail_id, companyCode],
);
oldDetail = oldDetailRes.rows[0] || null;
}
const oldQty =
Number(oldDetail?.inbound_qty ?? oldHeader.inbound_qty) || 0;
const oldWhCode = oldHeader.warehouse_code || null;
const oldLocCode = oldHeader.location_code || null;
const itemCode = oldDetail?.item_number || oldHeader.item_number || null;
const inboundNumber = oldHeader.inbound_number;
const newQty =
inbound_qty !== undefined && inbound_qty !== null
? Number(inbound_qty)
: oldQty;
const newWhCode =
warehouse_code !== undefined ? warehouse_code : oldWhCode;
const newLocCode =
location_code !== undefined ? location_code : oldLocCode;
// 입고 레코드 업데이트 (헤더 + 품목 필드 모두)
const headerResult = await client.query(
`UPDATE inbound_mng SET
@@ -506,13 +546,6 @@ export async function update(req: AuthenticatedRequest, res: Response) {
],
);
if (headerResult.rowCount === 0) {
await client.query("ROLLBACK");
return res
.status(404)
.json({ success: false, message: "입고 데이터를 찾을 수 없습니다." });
}
// 디테일 업데이트 (inbound_detail) — detail_id가 있으면 디테일 레벨 필드 업데이트
let detailRow = null;
if (detail_id) {
@@ -563,9 +596,67 @@ export async function update(req: AuthenticatedRequest, res: Response) {
);
}
// 재고/이력 반영 (append-only): 수량 또는 창고/위치 변경 시
const qtyChanged = newQty !== oldQty;
const whChanged =
(newWhCode || "") !== (oldWhCode || "") ||
(newLocCode || "") !== (oldLocCode || "");
if (itemCode && (qtyChanged || whChanged)) {
if (whChanged) {
if (oldQty > 0) {
await adjustInventory(client, {
companyCode,
userId,
itemCode,
whCode: oldWhCode,
locCode: oldLocCode,
delta: -oldQty,
transactionType: "입고취소",
remark: `입고수정-창고변경 (${inboundNumber}) ${oldWhCode || ""}${newWhCode || ""}`,
});
}
if (newQty > 0) {
await adjustInventory(client, {
companyCode,
userId,
itemCode,
whCode: newWhCode,
locCode: newLocCode,
delta: newQty,
transactionType: "입고수정",
remark: `입고수정-창고변경 (${inboundNumber}) ${oldWhCode || ""}${newWhCode || ""}, 수량 ${oldQty}${newQty}`,
});
}
} else {
const delta = newQty - oldQty;
if (delta !== 0) {
await adjustInventory(client, {
companyCode,
userId,
itemCode,
whCode: newWhCode,
locCode: newLocCode,
delta,
transactionType: "입고수정",
remark: `입고수정 (${inboundNumber}) 수량 ${oldQty}${newQty}`,
});
}
}
}
await client.query("COMMIT");
logger.info("입고 수정", { companyCode, userId, id, detail_id });
logger.info("입고 수정", {
companyCode,
userId,
id,
detail_id,
oldQty,
newQty,
oldWhCode,
newWhCode,
});
return res.json({
success: true,
@@ -0,0 +1,93 @@
/**
* 리포트 셀 커스텀 입력값 컨트롤러
*
* 리포트 디자이너에서 cellType="input"으로 지정한 셀에 대해
* 각 대상 레코드(quote 등)별로 사용자가 입력한 값을 관리
*/
import type { Response } from "express";
import { getPool } from "../database/db";
import type { AuthenticatedRequest } from "../types/auth";
import { logger } from "../utils/logger";
// 목록 조회: 특정 리포트 + 타겟에 대한 모든 셀 값
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { report_id, target_type, target_id } = req.query;
if (!report_id || !target_type || !target_id) {
return res.status(400).json({
success: false,
message: "report_id, target_type, target_id는 필수입니다.",
});
}
const pool = getPool();
const result = await pool.query(
`SELECT id, report_id, target_type, target_id, component_id, cell_id, value
FROM report_cell_values
WHERE company_code = $1 AND report_id = $2 AND target_type = $3 AND target_id = $4`,
[companyCode, report_id, target_type, target_id],
);
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 });
}
}
// UPSERT 단건: 같은 (report_id, target_type, target_id, component_id, cell_id)면 UPDATE, 아니면 INSERT
export async function upsert(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { report_id, target_type, target_id, component_id, cell_id, value } =
req.body;
if (!report_id || !target_type || !target_id || !component_id || !cell_id) {
return res.status(400).json({
success: false,
message: "필수 필드 누락",
});
}
const pool = getPool();
// value가 빈 문자열이면 DELETE (오버라이드 해제)
if (value === "" || value === null || value === undefined) {
await pool.query(
`DELETE FROM report_cell_values
WHERE company_code = $1 AND report_id = $2 AND target_type = $3
AND target_id = $4 AND component_id = $5 AND cell_id = $6`,
[companyCode, report_id, target_type, target_id, component_id, cell_id],
);
return res.json({ success: true, data: null });
}
const result = await pool.query(
`INSERT INTO report_cell_values
(id, company_code, report_id, target_type, target_id, component_id, cell_id, value, created_by, updated_by)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $8)
ON CONFLICT (company_code, report_id, target_type, target_id, component_id, cell_id)
DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP, updated_by = EXCLUDED.updated_by
RETURNING *`,
[
companyCode,
report_id,
target_type,
target_id,
component_id,
cell_id,
value,
userId,
],
);
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 });
}
}
@@ -23,7 +23,12 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
try {
await ensureDetailRoutingColumn();
const companyCode = req.user!.companyCode;
const { dateFrom, dateTo, status, progressStatus, keyword } = req.query;
const { dateFrom, dateTo, status, progressStatus, keyword, page, pageSize } = req.query;
// 페이지네이션 파라미터 파싱 (page 없으면 전체 반환 — 하위호환)
const pageNum = page ? Math.max(1, parseInt(page as string, 10) || 1) : null;
const sizeNum = pageSize ? Math.max(1, Math.min(1000, parseInt(pageSize as string, 10) || 20)) : null;
const paginated = pageNum !== null && sizeNum !== null;
const conditions: string[] = [];
const params: any[] = [];
@@ -54,14 +59,110 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
params.push(progressStatus);
idx++;
}
// keyword 검색: wi 자체 필드 + detail.item_number 존재 여부로 EXISTS
if (keyword) {
conditions.push(`(wi.work_instruction_no ILIKE $${idx} OR wi.worker ILIKE $${idx} OR COALESCE(itm.item_name,'') ILIKE $${idx} OR COALESCE(d.item_number,'') ILIKE $${idx})`);
conditions.push(`(
wi.work_instruction_no ILIKE $${idx}
OR wi.worker ILIKE $${idx}
OR EXISTS (
SELECT 1 FROM work_instruction_detail dd
LEFT JOIN item_info ii ON ii.item_number = dd.item_number AND ii.company_code = wi.company_code
WHERE dd.work_instruction_id = wi.id
AND (dd.item_number ILIKE $${idx} OR COALESCE(ii.item_name,'') ILIKE $${idx})
)
)`);
params.push(`%${keyword}%`);
idx++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const pool = getPool();
// 페이지네이션 모드: WI 단위로 페이지 잘라낸 뒤 detail과 JOIN
if (paginated) {
// 1) 총 WI 개수 카운트
const countSql = `
SELECT COUNT(*)::int AS cnt
FROM work_instruction wi
${whereClause}
`;
const countRes = await pool.query(countSql, params);
const totalCount = countRes.rows[0]?.cnt ?? 0;
// 2) 현재 페이지 WI id 목록
const offset = (pageNum! - 1) * sizeNum!;
const pageSql = `
SELECT wi.id
FROM work_instruction wi
${whereClause}
ORDER BY wi.created_date DESC, wi.id DESC
LIMIT ${sizeNum} OFFSET ${offset}
`;
const pageRes = await pool.query(pageSql, params);
const wiIds = pageRes.rows.map((r) => r.id);
if (wiIds.length === 0) {
return res.json({ success: true, data: [], totalCount, page: pageNum, pageSize: sizeNum });
}
// 3) 해당 WI들의 detail + 품목/설비/라우팅 JOIN
const dataSql = `
SELECT
wi.id AS wi_id,
wi.work_instruction_no,
wi.status,
wi.progress_status,
wi.qty AS total_qty,
wi.completed_qty,
wi.start_date,
wi.end_date,
wi.equipment_id,
wi.work_team,
wi.worker,
wi.remark AS wi_remark,
wi.created_date,
d.id AS detail_id,
d.item_number,
d.qty AS detail_qty,
d.remark AS detail_remark,
d.part_code,
d.source_table,
d.source_id,
d.routing_version_id AS detail_routing_version_id,
COALESCE(itm.item_name, '') AS item_name,
COALESCE(itm.type, '') AS item_type,
COALESCE(itm.size, '') AS item_spec,
COALESCE(e.equipment_name, '') AS equipment_name,
COALESCE(e.equipment_code, '') AS equipment_code,
wi.routing AS routing_version_id,
COALESCE(rv.version_name, '') AS routing_name,
ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date) AS detail_seq,
COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count
FROM work_instruction wi
INNER JOIN work_instruction_detail d
ON d.work_instruction_id = wi.id
LEFT JOIN item_info itm
ON itm.item_number = d.item_number AND itm.company_code = wi.company_code
LEFT JOIN equipment_mng e
ON wi.equipment_id = e.id AND wi.company_code = e.company_code
LEFT JOIN item_routing_version rv
ON wi.routing = rv.id AND rv.company_code = wi.company_code
WHERE wi.id = ANY($1::varchar[])
ORDER BY wi.created_date DESC, wi.id DESC, d.created_date ASC
`;
const dataRes = await pool.query(dataSql, [wiIds]);
return res.json({
success: true,
data: dataRes.rows,
totalCount,
page: pageNum,
pageSize: sizeNum,
});
}
// 비페이지 모드 (하위호환): 기존 방식 유지, LATERAL만 LEFT JOIN으로 교체
const query = `
SELECT
wi.id AS wi_id,
@@ -97,17 +198,14 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
FROM work_instruction wi
INNER JOIN work_instruction_detail d
ON d.work_instruction_id = wi.id
LEFT JOIN LATERAL (
SELECT item_name, size, type FROM item_info
WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1
) itm ON true
LEFT JOIN item_info itm
ON itm.item_number = d.item_number AND itm.company_code = wi.company_code
LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code
LEFT JOIN item_routing_version rv ON wi.routing = rv.id AND rv.company_code = wi.company_code
${whereClause}
ORDER BY wi.created_date DESC, d.created_date ASC
`;
const pool = getPool();
const result = await pool.query(query, params);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
@@ -0,0 +1,12 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as controller from "../controllers/reportCellValueController";
const router = Router();
router.use(authenticateToken);
router.get("/", controller.getList);
router.post("/", controller.upsert);
export default router;
+8 -3
View File
@@ -884,18 +884,23 @@ export class ReportService {
menuObjid: number,
companyCode: string
): Promise<{ items: ReportMaster[]; total: number }> {
// 매핑 없는 리포트(글로벌)는 어느 메뉴에서나 보이고,
// 매핑 있는 리포트는 해당 menu_objid에 매핑된 경우에만 보임.
const companyFilter = companyCode !== "*" ? " AND rm.company_code = $2" : "";
const params = companyCode !== "*" ? [menuObjid, companyCode] : [menuObjid];
const items = await query<ReportMaster>(
`SELECT rm.report_id, rm.report_name_kor, rm.report_name_eng,
`SELECT DISTINCT rm.report_id, rm.report_name_kor, rm.report_name_eng,
rm.template_id, rt.template_name_kor AS template_name,
rm.report_type, rm.company_code, rm.description, rm.use_yn,
rm.created_at, rm.created_by, rm.updated_at, rm.updated_by
FROM report_master rm
JOIN report_menu_mapping rmm ON rm.report_id = rmm.report_id
LEFT JOIN report_template rt ON rm.template_id = rt.template_id
WHERE rmm.menu_objid = $1 AND rm.use_yn = 'Y'${companyFilter}
WHERE rm.use_yn = 'Y'${companyFilter}
AND (
NOT EXISTS (SELECT 1 FROM report_menu_mapping WHERE report_id = rm.report_id)
OR EXISTS (SELECT 1 FROM report_menu_mapping WHERE report_id = rm.report_id AND menu_objid = $1)
)
ORDER BY rm.report_name_kor ASC`,
params
);
+130
View File
@@ -0,0 +1,130 @@
import type { PoolClient } from "pg";
export interface AdjustInventoryParams {
companyCode: string;
userId: string;
itemCode: string;
whCode: string | null;
locCode: string | null;
delta: number;
transactionType: string;
remark: string;
validateStockEnough?: boolean;
}
export async function adjustInventory(
client: PoolClient,
params: AdjustInventoryParams,
): Promise<void> {
const {
companyCode,
userId,
itemCode,
whCode,
locCode,
delta,
transactionType,
remark,
validateStockEnough,
} = params;
if (!itemCode || delta === 0) return;
if (validateStockEnough && delta < 0) {
const stockRes = await client.query(
`SELECT COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) AS cur
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 || ""],
);
const cur = parseFloat(stockRes.rows[0]?.cur || "0");
if (cur + delta < 0) {
throw new Error(
`재고 부족: 품목 ${itemCode} (창고 ${whCode || "미지정"}) — 현재 재고 ${cur}, 차감 요청 ${-delta}`,
);
}
}
const existing = 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 (existing.rows.length > 0) {
if (delta >= 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`,
[delta, existing.rows[0].id],
);
} else {
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`,
[delta, existing.rows[0].id],
);
}
} else {
const initQty = Math.max(delta, 0);
await client.query(
`INSERT INTO inventory_stock (
id, company_code, item_code, warehouse_code, location_code,
current_qty, safety_qty, last_in_date, last_out_date,
created_date, updated_date, writer
) VALUES (
gen_random_uuid()::text, $1, $2, $3, $4,
$5, '0',
${delta > 0 ? "NOW()" : "NULL"},
${delta < 0 ? "NOW()" : "NULL"},
NOW(), NOW(), $6
)`,
[companyCode, itemCode, whCode, locCode, String(initQty), userId],
);
}
const afterRes = await client.query(
`SELECT current_qty 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 || ""],
);
const afterQty = afterRes.rows[0]?.current_qty || "0";
await client.query(
`INSERT INTO inventory_history (
id, company_code, item_code, warehouse_code, location_code,
transaction_type, transaction_date, quantity, balance_qty, remark,
writer, created_date
) VALUES (
gen_random_uuid()::text, $1, $2, $3, $4,
$5, NOW(), $6, $7, $8,
$9, NOW()
)`,
[
companyCode,
itemCode,
whCode,
locCode,
transactionType,
(delta > 0 ? "+" : "") + String(delta),
afterQty,
remark,
userId,
],
);
}
@@ -86,6 +86,7 @@ export default function EquipmentInfoPage() {
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
const [inspectionContinuous, setInspectionContinuous] = useState(false);
const [inspectionEditMode, setInspectionEditMode] = useState(false);
const [checkedInspectionIds, setCheckedInspectionIds] = useState<Set<string>>(new Set());
// 소모품 추가/수정 모달
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
@@ -93,6 +94,7 @@ export default function EquipmentInfoPage() {
const [consumableContinuous, setConsumableContinuous] = useState(false);
const [consumableEditMode, setConsumableEditMode] = useState(false);
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
const [checkedConsumableIds, setCheckedConsumableIds] = useState<Set<string>>(new Set());
// 점검항목 복사
const [copyModalOpen, setCopyModalOpen] = useState(false);
@@ -200,6 +202,7 @@ export default function EquipmentInfoPage() {
// 우측: 점검항목 조회
useEffect(() => {
setCheckedInspectionIds(new Set());
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
const fetchData = async () => {
setInspectionLoading(true);
@@ -217,6 +220,7 @@ export default function EquipmentInfoPage() {
// 우측: 소모품 조회
useEffect(() => {
setCheckedConsumableIds(new Set());
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
const fetchData = async () => {
setConsumableLoading(true);
@@ -292,6 +296,34 @@ export default function EquipmentInfoPage() {
} catch { toast.error("삭제 실패"); }
};
// 점검항목 삭제
const handleInspectionDelete = async () => {
const ids = Array.from(checkedInspectionIds);
if (ids.length === 0) { toast.error("삭제할 점검항목을 선택해주세요."); return; }
const ok = await confirm(`선택한 ${ids.length}건의 점검항목을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
toast.success("삭제되었습니다.");
setCheckedInspectionIds(new Set());
refreshRight();
} catch { toast.error("삭제 실패"); }
};
// 소모품 삭제
const handleConsumableDelete = async () => {
const ids = Array.from(checkedConsumableIds);
if (ids.length === 0) { toast.error("삭제할 소모품을 선택해주세요."); return; }
const ok = await confirm(`선택한 ${ids.length}건의 소모품을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${CONSUMABLE_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
toast.success("삭제되었습니다.");
setCheckedConsumableIds(new Set());
refreshRight();
} catch { toast.error("삭제 실패"); }
};
// 점검항목 추가
const handleInspectionSave = async () => {
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
@@ -546,15 +578,23 @@ export default function EquipmentInfoPage() {
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionEditMode(false); setInspectionModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={checkedInspectionIds.size === 0} onClick={handleInspectionDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
<Copy className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
{rightTab === "consumable" && (
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={checkedConsumableIds.size === 0} onClick={handleConsumableDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
</div>
</div>
@@ -633,6 +673,16 @@ export default function EquipmentInfoPage() {
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead
className="w-[40px] text-center cursor-pointer"
onClick={() => {
const allChecked = inspections.length > 0 && checkedInspectionIds.size === inspections.length;
if (allChecked) setCheckedInspectionIds(new Set());
else setCheckedInspectionIds(new Set(inspections.map((i) => i.id)));
}}
>
<Checkbox checked={inspections.length > 0 && checkedInspectionIds.size === inspections.length} />
</TableHead>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -660,6 +710,20 @@ export default function EquipmentInfoPage() {
setInspectionEditMode(true);
setInspectionModalOpen(true);
}}>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedInspectionIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
});
}}
onDoubleClick={(e) => e.stopPropagation()}
>
<Checkbox checked={checkedInspectionIds.has(item.id)} />
</TableCell>
<TableCell className="text-sm">{item.inspection_item || "-"}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
@@ -688,6 +752,16 @@ export default function EquipmentInfoPage() {
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead
className="w-[40px] text-center cursor-pointer"
onClick={() => {
const allChecked = consumables.length > 0 && checkedConsumableIds.size === consumables.length;
if (allChecked) setCheckedConsumableIds(new Set());
else setCheckedConsumableIds(new Set(consumables.map((i) => i.id)));
}}
>
<Checkbox checked={consumables.length > 0 && checkedConsumableIds.size === consumables.length} />
</TableHead>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -703,6 +777,20 @@ export default function EquipmentInfoPage() {
loadConsumableItems();
setConsumableModalOpen(true);
}}>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedConsumableIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
});
}}
onDoubleClick={(e) => e.stopPropagation()}
>
<Checkbox checked={checkedConsumableIds.has(item.id)} />
</TableCell>
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
@@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
@@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock";
const STOCK_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "spec", label: "규격" },
{ key: "warehouse_code", label: "창고" },
{ key: "location_code", label: "위치" },
{ key: "current_qty", label: "현재수량", align: "right" as const },
@@ -87,6 +89,8 @@ const getStatusVariant = (
return "destructive";
case "과잉":
return "secondary";
case "미등록":
return "outline";
default:
return "outline";
}
@@ -119,6 +123,15 @@ export default function InventoryStatusPage() {
const [stockLoading, setStockLoading] = useState(false);
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
// 재고 없는 품목 표시 여부
const [showMissingItems, setShowMissingItems] = useState(false);
// 창고 목록 (조정 모달에서 사용)
const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]);
// 선택된 창고의 위치 목록 (조정 모달에서 사용)
const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]);
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
@@ -132,7 +145,9 @@ export default function InventoryStatusPage() {
adjust_type: string;
adjust_qty: string;
reason: string;
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
warehouse_code: string;
location_code: string;
}>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
const [adjustSaving, setAdjustSaving] = useState(false);
// 카테고리 옵션
@@ -201,8 +216,9 @@ export default function InventoryStatusPage() {
const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || [];
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || [];
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "" }]));
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "" }]));
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: w.warehouse_name || w.warehouse_code })));
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
@@ -213,19 +229,50 @@ export default function InventoryStatusPage() {
return {
...r,
item_name: itemInfo?.name || "",
spec: itemInfo?.spec || "",
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
status: resolve("status", r.status),
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
};
});
setStockItems(data);
if (showMissingItems) {
const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean));
const missingRows = items
.filter((i: any) => {
const code = i.item_number || i.item_code;
return code && !existingCodes.has(code);
})
.map((i: any) => {
const code = i.item_number || i.item_code;
const rawUnit = i.inventory_unit || "";
return {
id: `missing-${code}`,
item_code: code,
item_name: i.item_name || "",
spec: i.size || "",
warehouse_code: "",
warehouse_name: "",
location_code: "",
current_qty: "0",
safety_qty: "",
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
status: "미등록",
_isLow: false,
_isMissing: true,
};
});
setStockItems([...data, ...missingRows]);
} else {
setStockItems(data);
}
} catch {
toast.error("재고 목록을 불러오지 못했어요");
} finally {
setStockLoading(false);
}
}, [categoryOptions, searchFilters]);
}, [categoryOptions, searchFilters, showMissingItems]);
useEffect(() => {
fetchStock();
@@ -279,6 +326,35 @@ export default function InventoryStatusPage() {
fetchHistory();
}, [fetchHistory]);
useEffect(() => {
const whCode = adjustForm.warehouse_code;
if (!whCode) {
setLocationList([]);
return;
}
(async () => {
try {
const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, {
page: 1,
size: 0,
dataFilter: {
enabled: true,
filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }],
},
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setLocationList(
rows
.filter((r: any) => r.location_code)
.map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code }))
);
} catch {
setLocationList([]);
}
})();
}, [adjustForm.warehouse_code]);
// 재고 조정 저장
const handleAdjustSave = async () => {
if (!selectedStock) return;
@@ -291,6 +367,20 @@ export default function InventoryStatusPage() {
toast.error("조정 사유를 입력해주세요");
return;
}
const isMissing = !!selectedStock._isMissing;
const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || "");
const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || "");
if (isMissing && !targetWhCode) {
toast.error("창고를 선택해주세요");
return;
}
if (isMissing && adjustForm.adjust_type === "감소") {
toast.error("미등록 품목은 감소 조정이 불가해요");
return;
}
setAdjustSaving(true);
try {
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
@@ -301,8 +391,8 @@ export default function InventoryStatusPage() {
{
id: crypto.randomUUID(),
item_code: selectedStock.item_code,
warehouse_code: selectedStock.warehouse_code || "",
location_code: selectedStock.location_code || "",
warehouse_code: targetWhCode,
location_code: targetLocCode,
transaction_type: "조정",
transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
quantity: String(changeQty),
@@ -311,17 +401,33 @@ export default function InventoryStatusPage() {
}
);
await apiClient.put(
`/table-management/tables/${STOCK_TABLE}/edit`,
{
originalData: { id: selectedStock.id },
updatedData: { current_qty: afterQty },
}
);
if (isMissing) {
await apiClient.post(
`/table-management/tables/${STOCK_TABLE}/add`,
{
id: crypto.randomUUID(),
item_code: selectedStock.item_code,
warehouse_code: targetWhCode,
location_code: targetLocCode,
current_qty: String(afterQty),
safety_qty: "0",
last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
}
);
} else {
await apiClient.put(
`/table-management/tables/${STOCK_TABLE}/edit`,
{
originalData: { id: selectedStock.id },
updatedData: { current_qty: afterQty },
}
);
}
toast.success("재고가 조정되었어요");
setAdjustModalOpen(false);
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
setSelectedStockId(null);
fetchStock();
} catch {
toast.error("재고 조정에 실패했어요");
@@ -385,6 +491,7 @@ export default function InventoryStatusPage() {
stockItems.map((r) => ({
품목코드: r.item_code,
품명: r.item_name,
규격: r.spec || "",
창고: r.warehouse_name || r.warehouse_code,
위치: r.location_code,
현재수량: r.current_qty,
@@ -438,6 +545,13 @@ export default function InventoryStatusPage() {
{stockItems.length}
</Badge>
</div>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox
checked={showMissingItems}
onCheckedChange={(v) => setShowMissingItems(!!v)}
/>
<span> </span>
</label>
</div>
<EDataTable
@@ -513,6 +627,8 @@ export default function InventoryStatusPage() {
adjust_type: "증가",
adjust_qty: "",
reason: "",
warehouse_code: selectedStock._isMissing ? "" : (selectedStock.warehouse_code || ""),
location_code: selectedStock._isMissing ? "" : (selectedStock.location_code || ""),
});
setAdjustModalOpen(true);
}}
@@ -672,6 +788,68 @@ export default function InventoryStatusPage() {
</DialogHeader>
<div className="grid gap-4 py-2">
{selectedStock?._isMissing && (
<>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Select
value={adjustForm.warehouse_code}
onValueChange={(v) =>
setAdjustForm((prev) => ({
...prev,
warehouse_code: v,
location_code: "",
}))
}
>
<SelectTrigger>
<SelectValue placeholder="창고를 선택해주세요" />
</SelectTrigger>
<SelectContent>
{warehouseList.map((w) => (
<SelectItem key={w.code} value={w.code}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
</Label>
<Select
value={adjustForm.location_code}
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, location_code: v }))
}
disabled={!adjustForm.warehouse_code || locationList.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
!adjustForm.warehouse_code
? "창고를 먼저 선택하세요"
: locationList.length === 0
? "등록된 위치가 없어요"
: "위치 선택 (선택 사항)"
}
/>
</SelectTrigger>
<SelectContent>
{locationList.map((l) => (
<SelectItem key={l.code} value={l.code}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
@@ -681,6 +859,7 @@ export default function InventoryStatusPage() {
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
}
disabled={!!selectedStock?._isMissing}
>
<SelectTrigger>
<SelectValue placeholder="조정 유형 선택" />
@@ -690,6 +869,11 @@ export default function InventoryStatusPage() {
<SelectItem value="감소"> ( )</SelectItem>
</SelectContent>
</Select>
{selectedStock?._isMissing && (
<p className="text-[10px] text-muted-foreground">
( )
</p>
)}
</div>
<div className="grid gap-1.5">
@@ -528,9 +528,9 @@ export default function PackagingPage() {
{/* 4. 콘텐츠 영역 */}
{activeTab === "packing" ? (
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
{/* 포장재 목록 테이블 */}
<div className={cn("overflow-auto", selectedPkg ? "flex-[0_0_50%] border-b" : "flex-1")}>
<div className="flex-[0_0_50%] overflow-auto border-r">
<EDataTable
columns={ts.visibleColumns.map((col): EDataTableColumn<PkgUnit> => {
const renderMap: Record<string, Partial<EDataTableColumn<PkgUnit>>> = {
@@ -570,8 +570,8 @@ export default function PackagingPage() {
</div>
{/* 매칭 품목 서브패널 */}
{selectedPkg && (
<>
{selectedPkg ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
@@ -635,14 +635,21 @@ export default function PackagingPage() {
</Table>
)}
</div>
</>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<Package className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)}
</div>
) : (
/* 적재함 관리 탭 */
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
{/* 적재함 목록 테이블 */}
<div className={cn("overflow-auto", selectedLoading ? "flex-[0_0_50%] border-b" : "flex-1")}>
<div className="flex-[0_0_50%] overflow-auto border-r">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
@@ -709,8 +716,8 @@ export default function PackagingPage() {
</div>
{/* 포장구성 서브패널 */}
{selectedLoading && (
<>
{selectedLoading ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
@@ -774,7 +781,14 @@ export default function PackagingPage() {
</Table>
)}
</div>
</>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<Box className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)}
</div>
)}
@@ -250,6 +250,8 @@ interface SelectedSourceItem {
total_amount: number;
source_table: string;
source_id: string;
detail_id?: string;
header_id?: string;
}
export default function ReceivingPage() {
@@ -584,7 +586,7 @@ export default function ReceivingPage() {
const first = grouped[0] || row;
setEditMode(true);
setEditItemIds(grouped.map((g) => g.id));
setEditItemIds(grouped.map((g, idx) => (g as any).detail_id || `${g.id}__${idx}`));
setModalInboundNo(inNo);
setModalInboundType(first.inbound_type || "구매입고");
setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : "");
@@ -594,8 +596,10 @@ export default function ReceivingPage() {
setModalManager((first as any).manager || "");
setModalMemo(first.memo || "");
setSelectedItems(
grouped.map((g) => ({
key: g.id,
grouped.map((g, idx) => ({
key: (g as any).detail_id || `${g.id}__${idx}`,
detail_id: (g as any).detail_id || undefined,
header_id: g.id,
inbound_type: (g as any).detail_inbound_type || g.inbound_type || "",
reference_number: g.reference_number || "",
supplier_code: (g as any).supplier_code || "",
@@ -782,7 +786,7 @@ export default function ReceivingPage() {
await Promise.all([
...toDelete.map((id) => deleteReceiving(id)),
...toUpdate.map((item) =>
updateReceiving(item.key, {
updateReceiving(item.header_id || item.key, {
inbound_date: modalInboundDate,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
@@ -790,6 +794,7 @@ export default function ReceivingPage() {
warehouse_code: modalWarehouse || undefined,
location_code: modalLocation || undefined,
memo: modalMemo || undefined,
detail_id: item.detail_id,
} as any)
),
...(toCreate.length > 0
@@ -1772,7 +1772,7 @@ export default function BomManagementPage() {
{/* 소요량 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? (bomHeader?.base_qty || "1") : (node.quantity || "-")}</td>
{/* 단위 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? "-" : (node.unit || "-")}</td>
<td className="px-3 py-2 text-center">{isVirtualRoot ? (categoryOptions["inventory_unit"]?.find((o) => o.code === bomHeader?.unit)?.label || bomHeader?.unit || "-") : (node.unit || "-")}</td>
{/* 공정구분 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2">{isVirtualRoot ? "-" : (node.process_type || "-")}</td>
{/* 규격 */}
@@ -586,7 +586,7 @@ export function WorkStandardEditModal({
<thead className="sticky top-0 bg-muted/50">
<tr className="border-b">
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground"></th>
<th className="w-20 px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="w-24 px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="w-14 px-2 py-2 text-center font-medium text-muted-foreground"></th>
<th className="w-16 px-2 py-2 text-center font-medium text-muted-foreground"></th>
@@ -597,7 +597,7 @@ export function WorkStandardEditModal({
<tr key={detail.id || idx} className="border-b transition-colors hover:bg-muted/30">
<td className="px-2 py-1.5 text-center text-muted-foreground">{idx + 1}</td>
<td className="px-2 py-1.5">
<Badge variant="outline" className="text-[10px] font-normal">
<Badge variant="outline" className="text-[10px] font-normal whitespace-nowrap">
{getDetailTypeLabel(detail.detail_type || "checklist")}
</Badge>
</td>
@@ -30,6 +30,7 @@ import { toast } from "sonner";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const MASTER_TABLE = "purchase_order_mng";
@@ -555,6 +556,48 @@ export default function PurchaseOrderPage() {
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
// 공급업체 선택 시 supplier_item_mapping으로 매핑 id 정규화 → 서버 필터 적용
const supplierCode = masterForm.supplier_code;
if (supplierCode) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, {
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierCode }] },
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
if (rawIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const uuidIds = rawIds.filter((v) => uuidRegex.test(v));
const codeIds = rawIds.filter((v) => !uuidRegex.test(v));
let convertedIds: string[] = [];
if (codeIds.length > 0) {
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: codeIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
autoFilter: true,
});
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
}
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
if (finalIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
filters.push({ columnName: "id", operator: "in", value: finalIds });
} catch { /* skip */ }
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
@@ -984,7 +1027,8 @@ export default function PurchaseOrderPage() {
<div className="grid grid-cols-2 gap-3.5">
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide"></Label>
<Select
<SmartSelect
options={categoryOptions["supplier_code"] || []}
value={masterForm.supplier_code || ""}
onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v);
@@ -992,15 +1036,9 @@ export default function PurchaseOrderPage() {
setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name }));
recalcPrices(masterForm.price_mode || "", v);
}}
placeholder="공급업체 선택"
disabled={isReadOnly}
>
<SelectTrigger className="h-9"><SelectValue placeholder="공급업체 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
</div>
@@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
const DETAIL_TABLE = "sales_order_detail";
@@ -1481,17 +1482,12 @@ export default function SalesOrderPage() {
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select
<SmartSelect
options={categoryOptions["partner_id"] || []}
value={masterForm.partner_id || ""}
onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); recalcPrices(masterForm.price_mode || "", v); }}
>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
placeholder="거래처 선택"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
@@ -16,6 +16,7 @@ import {
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { reportApi } from "@/lib/api/reportApi";
import { useCurrent2ndLevelMenuObjid } from "@/hooks/useCurrent2ndLevelMenuObjid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
@@ -153,10 +154,13 @@ export default function QuoteManagementPage() {
useEffect(() => { fetchQuotes(); }, [fetchQuotes]);
const current2ndLevelMenuObjid = useCurrent2ndLevelMenuObjid();
useEffect(() => {
if (current2ndLevelMenuObjid === null) return;
(async () => {
try {
const res = await reportApi.getReports({ page: 1, limit: 100 });
const res = await reportApi.getReportsByMenuObjid(current2ndLevelMenuObjid);
if (res.success) {
const items = res.data.items ?? [];
setReportList(items);
@@ -164,7 +168,7 @@ export default function QuoteManagementPage() {
}
} catch { /* 무시 */ }
})();
}, []);
}, [current2ndLevelMenuObjid]);
// ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ──
@@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
@@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock";
const STOCK_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "spec", label: "규격" },
{ key: "warehouse_code", label: "창고" },
{ key: "location_code", label: "위치" },
{ key: "current_qty", label: "현재수량", align: "right" as const },
@@ -87,6 +89,8 @@ const getStatusVariant = (
return "destructive";
case "과잉":
return "secondary";
case "미등록":
return "outline";
default:
return "outline";
}
@@ -119,6 +123,15 @@ export default function InventoryStatusPage() {
const [stockLoading, setStockLoading] = useState(false);
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
// 재고 없는 품목 표시 여부
const [showMissingItems, setShowMissingItems] = useState(false);
// 창고 목록 (조정 모달에서 사용)
const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]);
// 선택된 창고의 위치 목록 (조정 모달에서 사용)
const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]);
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
@@ -132,7 +145,9 @@ export default function InventoryStatusPage() {
adjust_type: string;
adjust_qty: string;
reason: string;
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
warehouse_code: string;
location_code: string;
}>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
const [adjustSaving, setAdjustSaving] = useState(false);
// 카테고리 옵션
@@ -201,8 +216,9 @@ export default function InventoryStatusPage() {
const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || [];
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || [];
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "" }]));
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "" }]));
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: w.warehouse_name || w.warehouse_code })));
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
@@ -213,19 +229,51 @@ export default function InventoryStatusPage() {
return {
...r,
item_name: itemInfo?.name || "",
spec: itemInfo?.spec || "",
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
status: resolve("status", r.status),
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
};
});
setStockItems(data);
// 재고 없는 품목 표시: inventory_stock에 없는 item_info 품목을 미등록 가상 행으로 추가
if (showMissingItems) {
const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean));
const missingRows = items
.filter((i: any) => {
const code = i.item_number || i.item_code;
return code && !existingCodes.has(code);
})
.map((i: any) => {
const code = i.item_number || i.item_code;
const rawUnit = i.inventory_unit || "";
return {
id: `missing-${code}`,
item_code: code,
item_name: i.item_name || "",
spec: i.size || "",
warehouse_code: "",
warehouse_name: "",
location_code: "",
current_qty: "0",
safety_qty: "",
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
status: "미등록",
_isLow: false,
_isMissing: true,
};
});
setStockItems([...data, ...missingRows]);
} else {
setStockItems(data);
}
} catch {
toast.error("재고 목록을 불러오지 못했어요");
} finally {
setStockLoading(false);
}
}, [categoryOptions, searchFilters]);
}, [categoryOptions, searchFilters, showMissingItems]);
useEffect(() => {
fetchStock();
@@ -279,6 +327,36 @@ export default function InventoryStatusPage() {
fetchHistory();
}, [fetchHistory]);
// 창고 선택 시 해당 창고의 위치 목록 조회 (조정 모달용)
useEffect(() => {
const whCode = adjustForm.warehouse_code;
if (!whCode) {
setLocationList([]);
return;
}
(async () => {
try {
const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, {
page: 1,
size: 0,
dataFilter: {
enabled: true,
filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }],
},
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setLocationList(
rows
.filter((r: any) => r.location_code)
.map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code }))
);
} catch {
setLocationList([]);
}
})();
}, [adjustForm.warehouse_code]);
// 재고 조정 저장
const handleAdjustSave = async () => {
if (!selectedStock) return;
@@ -291,6 +369,20 @@ export default function InventoryStatusPage() {
toast.error("조정 사유를 입력해주세요");
return;
}
const isMissing = !!selectedStock._isMissing;
const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || "");
const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || "");
if (isMissing && !targetWhCode) {
toast.error("창고를 선택해주세요");
return;
}
if (isMissing && adjustForm.adjust_type === "감소") {
toast.error("미등록 품목은 감소 조정이 불가해요");
return;
}
setAdjustSaving(true);
try {
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
@@ -301,8 +393,8 @@ export default function InventoryStatusPage() {
{
id: crypto.randomUUID(),
item_code: selectedStock.item_code,
warehouse_code: selectedStock.warehouse_code || "",
location_code: selectedStock.location_code || "",
warehouse_code: targetWhCode,
location_code: targetLocCode,
transaction_type: "조정",
transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
quantity: String(changeQty),
@@ -311,17 +403,34 @@ export default function InventoryStatusPage() {
}
);
await apiClient.put(
`/table-management/tables/${STOCK_TABLE}/edit`,
{
originalData: { id: selectedStock.id },
updatedData: { current_qty: afterQty },
}
);
if (isMissing) {
// 새 재고 레코드 생성
await apiClient.post(
`/table-management/tables/${STOCK_TABLE}/add`,
{
id: crypto.randomUUID(),
item_code: selectedStock.item_code,
warehouse_code: targetWhCode,
location_code: targetLocCode,
current_qty: String(afterQty),
safety_qty: "0",
last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
}
);
} else {
await apiClient.put(
`/table-management/tables/${STOCK_TABLE}/edit`,
{
originalData: { id: selectedStock.id },
updatedData: { current_qty: afterQty },
}
);
}
toast.success("재고가 조정되었어요");
setAdjustModalOpen(false);
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
setSelectedStockId(null);
fetchStock();
} catch {
toast.error("재고 조정에 실패했어요");
@@ -385,6 +494,7 @@ export default function InventoryStatusPage() {
stockItems.map((r) => ({
품목코드: r.item_code,
품명: r.item_name,
규격: r.spec || "",
창고: r.warehouse_name || r.warehouse_code,
위치: r.location_code,
현재수량: r.current_qty,
@@ -438,6 +548,13 @@ export default function InventoryStatusPage() {
{stockItems.length}
</Badge>
</div>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox
checked={showMissingItems}
onCheckedChange={(v) => setShowMissingItems(!!v)}
/>
<span> </span>
</label>
</div>
<EDataTable
@@ -513,6 +630,8 @@ export default function InventoryStatusPage() {
adjust_type: "증가",
adjust_qty: "",
reason: "",
warehouse_code: selectedStock._isMissing ? "" : (selectedStock.warehouse_code || ""),
location_code: selectedStock._isMissing ? "" : (selectedStock.location_code || ""),
});
setAdjustModalOpen(true);
}}
@@ -672,6 +791,68 @@ export default function InventoryStatusPage() {
</DialogHeader>
<div className="grid gap-4 py-2">
{selectedStock?._isMissing && (
<>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Select
value={adjustForm.warehouse_code}
onValueChange={(v) =>
setAdjustForm((prev) => ({
...prev,
warehouse_code: v,
location_code: "",
}))
}
>
<SelectTrigger>
<SelectValue placeholder="창고를 선택해주세요" />
</SelectTrigger>
<SelectContent>
{warehouseList.map((w) => (
<SelectItem key={w.code} value={w.code}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
</Label>
<Select
value={adjustForm.location_code}
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, location_code: v }))
}
disabled={!adjustForm.warehouse_code || locationList.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
!adjustForm.warehouse_code
? "창고를 먼저 선택하세요"
: locationList.length === 0
? "등록된 위치가 없어요"
: "위치 선택 (선택 사항)"
}
/>
</SelectTrigger>
<SelectContent>
{locationList.map((l) => (
<SelectItem key={l.code} value={l.code}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
@@ -681,6 +862,7 @@ export default function InventoryStatusPage() {
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
}
disabled={!!selectedStock?._isMissing}
>
<SelectTrigger>
<SelectValue placeholder="조정 유형 선택" />
@@ -690,6 +872,11 @@ export default function InventoryStatusPage() {
<SelectItem value="감소"> ( )</SelectItem>
</SelectContent>
</Select>
{selectedStock?._isMissing && (
<p className="text-[10px] text-muted-foreground">
( )
</p>
)}
</div>
<div className="grid gap-1.5">
@@ -528,9 +528,9 @@ export default function PackagingPage() {
{/* 4. 콘텐츠 영역 */}
{activeTab === "packing" ? (
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
{/* 포장재 목록 테이블 */}
<div className={cn("overflow-auto", selectedPkg ? "flex-[0_0_50%] border-b" : "flex-1")}>
<div className="flex-[0_0_50%] overflow-auto border-r">
<EDataTable
columns={ts.visibleColumns.map((col): EDataTableColumn<PkgUnit> => {
const renderMap: Record<string, Partial<EDataTableColumn<PkgUnit>>> = {
@@ -570,8 +570,8 @@ export default function PackagingPage() {
</div>
{/* 매칭 품목 서브패널 */}
{selectedPkg && (
<>
{selectedPkg ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
@@ -635,14 +635,21 @@ export default function PackagingPage() {
</Table>
)}
</div>
</>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<Package className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)}
</div>
) : (
/* 적재함 관리 탭 */
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
{/* 적재함 목록 테이블 */}
<div className={cn("overflow-auto", selectedLoading ? "flex-[0_0_50%] border-b" : "flex-1")}>
<div className="flex-[0_0_50%] overflow-auto border-r">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
@@ -709,8 +716,8 @@ export default function PackagingPage() {
</div>
{/* 포장구성 서브패널 */}
{selectedLoading && (
<>
{selectedLoading ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
@@ -774,7 +781,14 @@ export default function PackagingPage() {
</Table>
)}
</div>
</>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<Box className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)}
</div>
)}
@@ -250,6 +250,8 @@ interface SelectedSourceItem {
total_amount: number;
source_table: string;
source_id: string;
detail_id?: string;
header_id?: string;
}
export default function ReceivingPage() {
@@ -584,7 +586,7 @@ export default function ReceivingPage() {
const first = grouped[0] || row;
setEditMode(true);
setEditItemIds(grouped.map((g) => g.id));
setEditItemIds(grouped.map((g, idx) => (g as any).detail_id || `${g.id}__${idx}`));
setModalInboundNo(inNo);
setModalInboundType(first.inbound_type || "구매입고");
setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : "");
@@ -594,8 +596,10 @@ export default function ReceivingPage() {
setModalManager((first as any).manager || "");
setModalMemo(first.memo || "");
setSelectedItems(
grouped.map((g) => ({
key: g.id,
grouped.map((g, idx) => ({
key: (g as any).detail_id || `${g.id}__${idx}`,
detail_id: (g as any).detail_id || undefined,
header_id: g.id,
inbound_type: (g as any).detail_inbound_type || g.inbound_type || "",
reference_number: g.reference_number || "",
supplier_code: (g as any).supplier_code || "",
@@ -782,7 +786,7 @@ export default function ReceivingPage() {
await Promise.all([
...toDelete.map((id) => deleteReceiving(id)),
...toUpdate.map((item) =>
updateReceiving(item.key, {
updateReceiving(item.header_id || item.key, {
inbound_date: modalInboundDate,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
@@ -790,6 +794,7 @@ export default function ReceivingPage() {
warehouse_code: modalWarehouse || undefined,
location_code: modalLocation || undefined,
memo: modalMemo || undefined,
detail_id: item.detail_id,
} as any)
),
...(toCreate.length > 0
@@ -1772,7 +1772,7 @@ export default function BomManagementPage() {
{/* 소요량 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? (bomHeader?.base_qty || "1") : (node.quantity || "-")}</td>
{/* 단위 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? "-" : (node.unit || "-")}</td>
<td className="px-3 py-2 text-center">{isVirtualRoot ? (categoryOptions["inventory_unit"]?.find((o) => o.code === bomHeader?.unit)?.label || bomHeader?.unit || "-") : (node.unit || "-")}</td>
{/* 공정구분 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2">{isVirtualRoot ? "-" : (node.process_type || "-")}</td>
{/* 규격 */}
@@ -586,7 +586,7 @@ export function WorkStandardEditModal({
<thead className="sticky top-0 bg-muted/50">
<tr className="border-b">
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground"></th>
<th className="w-20 px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="w-24 px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="w-14 px-2 py-2 text-center font-medium text-muted-foreground"></th>
<th className="w-16 px-2 py-2 text-center font-medium text-muted-foreground"></th>
@@ -597,7 +597,7 @@ export function WorkStandardEditModal({
<tr key={detail.id || idx} className="border-b transition-colors hover:bg-muted/30">
<td className="px-2 py-1.5 text-center text-muted-foreground">{idx + 1}</td>
<td className="px-2 py-1.5">
<Badge variant="outline" className="text-[10px] font-normal">
<Badge variant="outline" className="text-[10px] font-normal whitespace-nowrap">
{getDetailTypeLabel(detail.detail_type || "checklist")}
</Badge>
</td>
@@ -30,6 +30,7 @@ import { toast } from "sonner";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const MASTER_TABLE = "purchase_order_mng";
@@ -555,6 +556,50 @@ export default function PurchaseOrderPage() {
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
// 공급업체 선택 시 supplier_item_mapping으로 매핑 id 정규화 → 서버 필터 적용
const supplierCode = masterForm.supplier_code;
if (supplierCode) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, {
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierCode }] },
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
if (rawIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
// UUID와 문자열(item_number) 분리
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const uuidIds = rawIds.filter((v) => uuidRegex.test(v));
const codeIds = rawIds.filter((v) => !uuidRegex.test(v));
// 문자열(item_number)을 item_info에서 id로 변환
let convertedIds: string[] = [];
if (codeIds.length > 0) {
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: codeIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
autoFilter: true,
});
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
}
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
if (finalIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
filters.push({ columnName: "id", operator: "in", value: finalIds });
} catch { /* skip */ }
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
@@ -984,7 +1029,8 @@ export default function PurchaseOrderPage() {
<div className="grid grid-cols-2 gap-3.5">
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide"></Label>
<Select
<SmartSelect
options={categoryOptions["supplier_code"] || []}
value={masterForm.supplier_code || ""}
onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v);
@@ -992,15 +1038,9 @@ export default function PurchaseOrderPage() {
setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name }));
recalcPrices(masterForm.price_mode || "", v);
}}
placeholder="공급업체 선택"
disabled={isReadOnly}
>
<SelectTrigger className="h-9"><SelectValue placeholder="공급업체 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
</div>
@@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
const DETAIL_TABLE = "sales_order_detail";
@@ -1481,17 +1482,12 @@ export default function SalesOrderPage() {
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select
<SmartSelect
options={categoryOptions["partner_id"] || []}
value={masterForm.partner_id || ""}
onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); recalcPrices(masterForm.price_mode || "", v); }}
>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
placeholder="거래처 선택"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
@@ -1642,10 +1638,8 @@ export default function SalesOrderPage() {
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[120px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -1664,14 +1658,6 @@ export default function SalesOrderPage() {
</TableCell>
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.spec}</TableCell>
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.material}</TableCell>
<TableCell>
<Input
value={row.packing_material || ""}
onChange={(e) => updateDetailRow(idx, "packing_material", e.target.value)}
placeholder="포장재"
className="h-8 text-xs w-full"
/>
</TableCell>
<TableCell>
<Select value={row.unit || ""} onValueChange={(v) => updateDetailRow(idx, "unit", v)}>
<SelectTrigger className="h-8 text-xs w-full"><SelectValue placeholder="단위" /></SelectTrigger>
@@ -1692,15 +1678,6 @@ export default function SalesOrderPage() {
className="h-8 text-xs text-right font-mono w-full"
/>
</TableCell>
<TableCell>
<Input
type="number"
min="0"
value={row.pack_qty || "0"}
onChange={(e) => updateDetailRow(idx, "pack_qty", e.target.value)}
className="h-8 text-xs text-right font-mono w-full"
/>
</TableCell>
<TableCell>
<Input
value={formatNumber(row.unit_price || "")}
@@ -16,6 +16,7 @@ import {
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { reportApi } from "@/lib/api/reportApi";
import { useCurrent2ndLevelMenuObjid } from "@/hooks/useCurrent2ndLevelMenuObjid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
@@ -28,7 +29,7 @@ import {
ResizablePanelGroup, ResizablePanel, ResizableHandle,
} from "@/components/ui/resizable";
import { ReportInlineViewer } from "@/components/report/ReportInlineViewer";
import { ReportMaster, ComponentConfig } from "@/types/report";
import { ReportMaster, ComponentConfig, GridCell } from "@/types/report";
const MASTER_TABLE = "quote_mng";
@@ -82,6 +83,12 @@ export default function QuoteManagementPage() {
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
const [basicForm, setBasicForm] = useState({ quote_date: "", valid_until: "", status: "draft" });
// 리포트 셀 input 오버라이드
const [cellOverrides, setCellOverrides] = useState<Record<string, Record<string, string>>>({});
const [inputCellOpen, setInputCellOpen] = useState(false);
const [inputCellCtx, setInputCellCtx] = useState<{ comp: ComponentConfig; cells: GridCell[] } | null>(null);
const [inputCellValues, setInputCellValues] = useState<Record<string, string>>({});
// 엑셀 / 리포트
const [excelOpen, setExcelOpen] = useState(false);
const [reportList, setReportList] = useState<ReportMaster[]>([]);
@@ -153,10 +160,13 @@ export default function QuoteManagementPage() {
useEffect(() => { fetchQuotes(); }, [fetchQuotes]);
const current2ndLevelMenuObjid = useCurrent2ndLevelMenuObjid();
useEffect(() => {
if (current2ndLevelMenuObjid === null) return;
(async () => {
try {
const res = await reportApi.getReports({ page: 1, limit: 100 });
const res = await reportApi.getReportsByMenuObjid(current2ndLevelMenuObjid);
if (res.success) {
const items = res.data.items ?? [];
setReportList(items);
@@ -164,7 +174,105 @@ export default function QuoteManagementPage() {
}
} catch { /* 무시 */ }
})();
}, []);
}, [current2ndLevelMenuObjid]);
// ── 리포트 셀 오버라이드: 견적/리포트 변경 시 로드 ──
useEffect(() => {
if (!selectedRow?.objid || !selectedReportId) {
setCellOverrides({});
return;
}
(async () => {
try {
const res = await apiClient.get("/report-cell-values", {
params: { report_id: selectedReportId, target_type: "quote", target_id: String(selectedRow.objid) },
});
const rows = res.data?.data || [];
const map: Record<string, Record<string, string>> = {};
for (const r of rows) {
if (!map[r.component_id]) map[r.component_id] = {};
map[r.component_id][r.cell_id] = r.value ?? "";
}
setCellOverrides(map);
} catch {
setCellOverrides({});
}
})();
}, [selectedRow?.objid, selectedReportId]);
// ── input 셀 클릭 → 해당 테이블의 모든 input 셀을 모아 한 모달에 표시 ──
const handleInputCellClick = (comp: ComponentConfig, _cell: GridCell) => {
const allCells = ((comp as any).gridCells || []) as GridCell[];
const inputCells = allCells
.filter((c) => c.cellType === "input" && !c.merged)
.sort((a, b) => (a.row - b.row) || (a.col - b.col));
const vals: Record<string, string> = {};
for (const c of inputCells) {
vals[c.id] = cellOverrides[comp.id]?.[c.id] ?? "";
}
setInputCellCtx({ comp, cells: inputCells });
setInputCellValues(vals);
setInputCellOpen(true);
};
// ── input 셀 라벨 찾기: 같은 행의 static 라벨 셀 값 → 없으면 placeholder ──
const getInputCellLabel = (comp: ComponentConfig, cell: GridCell): string => {
const allCells = ((comp as any).gridCells || []) as GridCell[];
const labelCell = allCells
.filter((c) => c.row === cell.row && c.col < cell.col && c.cellType === "static" && c.value && !c.merged)
.sort((a, b) => b.col - a.col)[0];
if (labelCell?.value) return String(labelCell.value).trim();
return cell.inputPlaceholder || "값";
};
// ── input 셀 저장: 변경된 셀들만 일괄 저장 ──
const handleInputCellSave = async () => {
if (!inputCellCtx || !selectedRow?.objid || !selectedReportId) return;
const { comp, cells } = inputCellCtx;
const existing = cellOverrides[comp.id] || {};
const toSave: { cellId: string; value: string }[] = [];
for (const c of cells) {
const newVal = inputCellValues[c.id] ?? "";
const oldVal = existing[c.id] ?? "";
if (newVal !== oldVal) toSave.push({ cellId: c.id, value: newVal });
}
if (toSave.length === 0) {
setInputCellOpen(false);
setInputCellCtx(null);
return;
}
try {
await Promise.all(
toSave.map((t) =>
apiClient.post("/report-cell-values", {
report_id: selectedReportId,
target_type: "quote",
target_id: String(selectedRow.objid),
component_id: comp.id,
cell_id: t.cellId,
value: t.value,
})
)
);
setCellOverrides((prev) => {
const next = { ...prev };
const curr = { ...(next[comp.id] || {}) };
for (const c of cells) {
const v = inputCellValues[c.id] ?? "";
if (v === "") delete curr[c.id];
else curr[c.id] = v;
}
if (Object.keys(curr).length === 0) delete next[comp.id];
else next[comp.id] = curr;
return next;
});
toast.success(`${toSave.length}개 항목이 저장됐어요`);
setInputCellOpen(false);
setInputCellCtx(null);
} catch {
toast.error("저장 실패");
}
};
// ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ──
@@ -214,6 +322,15 @@ export default function QuoteManagementPage() {
setEditComp(comp);
if (comp.type === "table") {
// 품목 테이블 판별: tableColumns 중 품목 관련 필드가 포함되어야 편집 대상
const cols = (comp as any).tableColumns || [];
const ITEM_FIELDS = new Set(["item_code", "item_name", "qty", "unit_price", "spec", "total_amount", "supply_amount", "vat_amount"]);
const isItemTable = cols.some((c: any) => ITEM_FIELDS.has((c.field || "").toLowerCase()));
if (!isItemTable) {
toast.info("이 테이블의 각 셀에서 직접 입력하세요 (input 셀로 지정된 곳만 편집 가능)");
setEditComp(null);
return;
}
// 테이블 → 품목 편집
try {
const res = await apiClient.get(`/quotes/${selectedRow.objid}`);
@@ -692,6 +809,8 @@ export default function QuoteManagementPage() {
reportId={selectedReportId}
contextParams={contextParams}
onComponentClick={handleComponentClick}
cellOverrides={cellOverrides}
onInputCellClick={handleInputCellClick}
/>
)}
</div>
@@ -848,6 +967,42 @@ export default function QuoteManagementPage() {
</DialogContent>
</Dialog>
{/* ═══ 리포트 input 셀 입력 모달 (테이블 내 모든 input 셀을 한 번에 편집) ═══ */}
<Dialog open={inputCellOpen} onOpenChange={(o) => { if (!o) { setInputCellOpen(false); setInputCellCtx(null); } }}>
<DialogContent className="max-h-[85vh] max-w-lg overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</DialogHeader>
<div className="flex-1 space-y-3 overflow-auto py-2">
{inputCellCtx?.cells.map((c) => (
<div key={c.id} className="space-y-1">
<Label className="text-xs font-semibold">
{inputCellCtx ? getInputCellLabel(inputCellCtx.comp, c) : ""}
</Label>
<Textarea
value={inputCellValues[c.id] ?? ""}
onChange={(e) => setInputCellValues((prev) => ({ ...prev, [c.id]: e.target.value }))}
placeholder={c.inputPlaceholder || "값"}
rows={2}
/>
</div>
))}
{inputCellCtx?.cells.length === 0 && (
<p className="text-center text-xs text-muted-foreground"> </p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setInputCellOpen(false); setInputCellCtx(null); }}></Button>
<Button onClick={handleInputCellSave} className="gap-1.5">
<Save className="h-4 w-4" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ═══ 담당자(사원) 검색 모달 ═══ */}
<Dialog open={userSearchOpen} onOpenChange={setUserSearchOpen}>
<DialogContent className="flex max-h-[70vh] max-w-lg flex-col overflow-hidden">
@@ -86,6 +86,7 @@ export default function EquipmentInfoPage() {
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
const [inspectionContinuous, setInspectionContinuous] = useState(false);
const [inspectionEditMode, setInspectionEditMode] = useState(false);
const [checkedInspectionIds, setCheckedInspectionIds] = useState<Set<string>>(new Set());
// 소모품 추가/수정 모달
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
@@ -93,6 +94,7 @@ export default function EquipmentInfoPage() {
const [consumableContinuous, setConsumableContinuous] = useState(false);
const [consumableEditMode, setConsumableEditMode] = useState(false);
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
const [checkedConsumableIds, setCheckedConsumableIds] = useState<Set<string>>(new Set());
// 점검항목 복사
const [copyModalOpen, setCopyModalOpen] = useState(false);
@@ -200,6 +202,7 @@ export default function EquipmentInfoPage() {
// 우측: 점검항목 조회
useEffect(() => {
setCheckedInspectionIds(new Set());
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
const fetchData = async () => {
setInspectionLoading(true);
@@ -217,6 +220,7 @@ export default function EquipmentInfoPage() {
// 우측: 소모품 조회
useEffect(() => {
setCheckedConsumableIds(new Set());
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
const fetchData = async () => {
setConsumableLoading(true);
@@ -292,6 +296,34 @@ export default function EquipmentInfoPage() {
} catch { toast.error("삭제 실패"); }
};
// 점검항목 삭제
const handleInspectionDelete = async () => {
const ids = Array.from(checkedInspectionIds);
if (ids.length === 0) { toast.error("삭제할 점검항목을 선택해주세요."); return; }
const ok = await confirm(`선택한 ${ids.length}건의 점검항목을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
toast.success("삭제되었습니다.");
setCheckedInspectionIds(new Set());
refreshRight();
} catch { toast.error("삭제 실패"); }
};
// 소모품 삭제
const handleConsumableDelete = async () => {
const ids = Array.from(checkedConsumableIds);
if (ids.length === 0) { toast.error("삭제할 소모품을 선택해주세요."); return; }
const ok = await confirm(`선택한 ${ids.length}건의 소모품을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${CONSUMABLE_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
toast.success("삭제되었습니다.");
setCheckedConsumableIds(new Set());
refreshRight();
} catch { toast.error("삭제 실패"); }
};
// 점검항목 추가
const handleInspectionSave = async () => {
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
@@ -546,15 +578,23 @@ export default function EquipmentInfoPage() {
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionEditMode(false); setInspectionModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={checkedInspectionIds.size === 0} onClick={handleInspectionDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
<Copy className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
{rightTab === "consumable" && (
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={checkedConsumableIds.size === 0} onClick={handleConsumableDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
</div>
</div>
@@ -633,6 +673,16 @@ export default function EquipmentInfoPage() {
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead
className="w-[40px] text-center cursor-pointer"
onClick={() => {
const allChecked = inspections.length > 0 && checkedInspectionIds.size === inspections.length;
if (allChecked) setCheckedInspectionIds(new Set());
else setCheckedInspectionIds(new Set(inspections.map((i) => i.id)));
}}
>
<Checkbox checked={inspections.length > 0 && checkedInspectionIds.size === inspections.length} />
</TableHead>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -660,6 +710,20 @@ export default function EquipmentInfoPage() {
setInspectionEditMode(true);
setInspectionModalOpen(true);
}}>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedInspectionIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
});
}}
onDoubleClick={(e) => e.stopPropagation()}
>
<Checkbox checked={checkedInspectionIds.has(item.id)} />
</TableCell>
<TableCell className="text-sm">{item.inspection_item || "-"}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
@@ -688,6 +752,16 @@ export default function EquipmentInfoPage() {
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead
className="w-[40px] text-center cursor-pointer"
onClick={() => {
const allChecked = consumables.length > 0 && checkedConsumableIds.size === consumables.length;
if (allChecked) setCheckedConsumableIds(new Set());
else setCheckedConsumableIds(new Set(consumables.map((i) => i.id)));
}}
>
<Checkbox checked={consumables.length > 0 && checkedConsumableIds.size === consumables.length} />
</TableHead>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -703,6 +777,20 @@ export default function EquipmentInfoPage() {
loadConsumableItems();
setConsumableModalOpen(true);
}}>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedConsumableIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
});
}}
onDoubleClick={(e) => e.stopPropagation()}
>
<Checkbox checked={checkedConsumableIds.has(item.id)} />
</TableCell>
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
@@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
@@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock";
const STOCK_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "spec", label: "규격" },
{ key: "warehouse_code", label: "창고" },
{ key: "location_code", label: "위치" },
{ key: "current_qty", label: "현재수량", align: "right" as const },
@@ -87,6 +89,8 @@ const getStatusVariant = (
return "destructive";
case "과잉":
return "secondary";
case "미등록":
return "outline";
default:
return "outline";
}
@@ -119,6 +123,15 @@ export default function InventoryStatusPage() {
const [stockLoading, setStockLoading] = useState(false);
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
// 재고 없는 품목 표시 여부
const [showMissingItems, setShowMissingItems] = useState(false);
// 창고 목록 (조정 모달에서 사용)
const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]);
// 선택된 창고의 위치 목록 (조정 모달에서 사용)
const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]);
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
@@ -132,7 +145,9 @@ export default function InventoryStatusPage() {
adjust_type: string;
adjust_qty: string;
reason: string;
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
warehouse_code: string;
location_code: string;
}>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
const [adjustSaving, setAdjustSaving] = useState(false);
// 카테고리 옵션
@@ -201,8 +216,9 @@ export default function InventoryStatusPage() {
const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || [];
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || [];
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "" }]));
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "" }]));
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: w.warehouse_name || w.warehouse_code })));
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
@@ -213,19 +229,50 @@ export default function InventoryStatusPage() {
return {
...r,
item_name: itemInfo?.name || "",
spec: itemInfo?.spec || "",
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
status: resolve("status", r.status),
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
};
});
setStockItems(data);
if (showMissingItems) {
const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean));
const missingRows = items
.filter((i: any) => {
const code = i.item_number || i.item_code;
return code && !existingCodes.has(code);
})
.map((i: any) => {
const code = i.item_number || i.item_code;
const rawUnit = i.inventory_unit || "";
return {
id: `missing-${code}`,
item_code: code,
item_name: i.item_name || "",
spec: i.size || "",
warehouse_code: "",
warehouse_name: "",
location_code: "",
current_qty: "0",
safety_qty: "",
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
status: "미등록",
_isLow: false,
_isMissing: true,
};
});
setStockItems([...data, ...missingRows]);
} else {
setStockItems(data);
}
} catch {
toast.error("재고 목록을 불러오지 못했어요");
} finally {
setStockLoading(false);
}
}, [categoryOptions, searchFilters]);
}, [categoryOptions, searchFilters, showMissingItems]);
useEffect(() => {
fetchStock();
@@ -279,6 +326,35 @@ export default function InventoryStatusPage() {
fetchHistory();
}, [fetchHistory]);
useEffect(() => {
const whCode = adjustForm.warehouse_code;
if (!whCode) {
setLocationList([]);
return;
}
(async () => {
try {
const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, {
page: 1,
size: 0,
dataFilter: {
enabled: true,
filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }],
},
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setLocationList(
rows
.filter((r: any) => r.location_code)
.map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code }))
);
} catch {
setLocationList([]);
}
})();
}, [adjustForm.warehouse_code]);
// 재고 조정 저장
const handleAdjustSave = async () => {
if (!selectedStock) return;
@@ -291,6 +367,20 @@ export default function InventoryStatusPage() {
toast.error("조정 사유를 입력해주세요");
return;
}
const isMissing = !!selectedStock._isMissing;
const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || "");
const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || "");
if (isMissing && !targetWhCode) {
toast.error("창고를 선택해주세요");
return;
}
if (isMissing && adjustForm.adjust_type === "감소") {
toast.error("미등록 품목은 감소 조정이 불가해요");
return;
}
setAdjustSaving(true);
try {
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
@@ -301,8 +391,8 @@ export default function InventoryStatusPage() {
{
id: crypto.randomUUID(),
item_code: selectedStock.item_code,
warehouse_code: selectedStock.warehouse_code || "",
location_code: selectedStock.location_code || "",
warehouse_code: targetWhCode,
location_code: targetLocCode,
transaction_type: "조정",
transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
quantity: String(changeQty),
@@ -311,17 +401,33 @@ export default function InventoryStatusPage() {
}
);
await apiClient.put(
`/table-management/tables/${STOCK_TABLE}/edit`,
{
originalData: { id: selectedStock.id },
updatedData: { current_qty: afterQty },
}
);
if (isMissing) {
await apiClient.post(
`/table-management/tables/${STOCK_TABLE}/add`,
{
id: crypto.randomUUID(),
item_code: selectedStock.item_code,
warehouse_code: targetWhCode,
location_code: targetLocCode,
current_qty: String(afterQty),
safety_qty: "0",
last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
}
);
} else {
await apiClient.put(
`/table-management/tables/${STOCK_TABLE}/edit`,
{
originalData: { id: selectedStock.id },
updatedData: { current_qty: afterQty },
}
);
}
toast.success("재고가 조정되었어요");
setAdjustModalOpen(false);
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
setSelectedStockId(null);
fetchStock();
} catch {
toast.error("재고 조정에 실패했어요");
@@ -385,6 +491,7 @@ export default function InventoryStatusPage() {
stockItems.map((r) => ({
품목코드: r.item_code,
품명: r.item_name,
규격: r.spec || "",
창고: r.warehouse_name || r.warehouse_code,
위치: r.location_code,
현재수량: r.current_qty,
@@ -438,6 +545,13 @@ export default function InventoryStatusPage() {
{stockItems.length}
</Badge>
</div>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox
checked={showMissingItems}
onCheckedChange={(v) => setShowMissingItems(!!v)}
/>
<span> </span>
</label>
</div>
<EDataTable
@@ -513,6 +627,8 @@ export default function InventoryStatusPage() {
adjust_type: "증가",
adjust_qty: "",
reason: "",
warehouse_code: selectedStock._isMissing ? "" : (selectedStock.warehouse_code || ""),
location_code: selectedStock._isMissing ? "" : (selectedStock.location_code || ""),
});
setAdjustModalOpen(true);
}}
@@ -672,6 +788,68 @@ export default function InventoryStatusPage() {
</DialogHeader>
<div className="grid gap-4 py-2">
{selectedStock?._isMissing && (
<>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Select
value={adjustForm.warehouse_code}
onValueChange={(v) =>
setAdjustForm((prev) => ({
...prev,
warehouse_code: v,
location_code: "",
}))
}
>
<SelectTrigger>
<SelectValue placeholder="창고를 선택해주세요" />
</SelectTrigger>
<SelectContent>
{warehouseList.map((w) => (
<SelectItem key={w.code} value={w.code}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
</Label>
<Select
value={adjustForm.location_code}
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, location_code: v }))
}
disabled={!adjustForm.warehouse_code || locationList.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
!adjustForm.warehouse_code
? "창고를 먼저 선택하세요"
: locationList.length === 0
? "등록된 위치가 없어요"
: "위치 선택 (선택 사항)"
}
/>
</SelectTrigger>
<SelectContent>
{locationList.map((l) => (
<SelectItem key={l.code} value={l.code}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
@@ -681,6 +859,7 @@ export default function InventoryStatusPage() {
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
}
disabled={!!selectedStock?._isMissing}
>
<SelectTrigger>
<SelectValue placeholder="조정 유형 선택" />
@@ -690,6 +869,11 @@ export default function InventoryStatusPage() {
<SelectItem value="감소"> ( )</SelectItem>
</SelectContent>
</Select>
{selectedStock?._isMissing && (
<p className="text-[10px] text-muted-foreground">
( )
</p>
)}
</div>
<div className="grid gap-1.5">
@@ -528,9 +528,9 @@ export default function PackagingPage() {
{/* 4. 콘텐츠 영역 */}
{activeTab === "packing" ? (
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
{/* 포장재 목록 테이블 */}
<div className={cn("overflow-auto", selectedPkg ? "flex-[0_0_50%] border-b" : "flex-1")}>
<div className="flex-[0_0_50%] overflow-auto border-r">
<EDataTable
columns={ts.visibleColumns.map((col): EDataTableColumn<PkgUnit> => {
const renderMap: Record<string, Partial<EDataTableColumn<PkgUnit>>> = {
@@ -570,8 +570,8 @@ export default function PackagingPage() {
</div>
{/* 매칭 품목 서브패널 */}
{selectedPkg && (
<>
{selectedPkg ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
@@ -635,14 +635,21 @@ export default function PackagingPage() {
</Table>
)}
</div>
</>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<Package className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)}
</div>
) : (
/* 적재함 관리 탭 */
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
{/* 적재함 목록 테이블 */}
<div className={cn("overflow-auto", selectedLoading ? "flex-[0_0_50%] border-b" : "flex-1")}>
<div className="flex-[0_0_50%] overflow-auto border-r">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
@@ -709,8 +716,8 @@ export default function PackagingPage() {
</div>
{/* 포장구성 서브패널 */}
{selectedLoading && (
<>
{selectedLoading ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
@@ -774,7 +781,14 @@ export default function PackagingPage() {
</Table>
)}
</div>
</>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<Box className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)}
</div>
)}
@@ -250,6 +250,8 @@ interface SelectedSourceItem {
total_amount: number;
source_table: string;
source_id: string;
detail_id?: string;
header_id?: string;
}
export default function ReceivingPage() {
@@ -584,7 +586,7 @@ export default function ReceivingPage() {
const first = grouped[0] || row;
setEditMode(true);
setEditItemIds(grouped.map((g) => g.id));
setEditItemIds(grouped.map((g, idx) => (g as any).detail_id || `${g.id}__${idx}`));
setModalInboundNo(inNo);
setModalInboundType(first.inbound_type || "구매입고");
setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : "");
@@ -594,8 +596,10 @@ export default function ReceivingPage() {
setModalManager((first as any).manager || "");
setModalMemo(first.memo || "");
setSelectedItems(
grouped.map((g) => ({
key: g.id,
grouped.map((g, idx) => ({
key: (g as any).detail_id || `${g.id}__${idx}`,
detail_id: (g as any).detail_id || undefined,
header_id: g.id,
inbound_type: (g as any).detail_inbound_type || g.inbound_type || "",
reference_number: g.reference_number || "",
supplier_code: (g as any).supplier_code || "",
@@ -782,7 +786,7 @@ export default function ReceivingPage() {
await Promise.all([
...toDelete.map((id) => deleteReceiving(id)),
...toUpdate.map((item) =>
updateReceiving(item.key, {
updateReceiving(item.header_id || item.key, {
inbound_date: modalInboundDate,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
@@ -790,6 +794,7 @@ export default function ReceivingPage() {
warehouse_code: modalWarehouse || undefined,
location_code: modalLocation || undefined,
memo: modalMemo || undefined,
detail_id: item.detail_id,
} as any)
),
...(toCreate.length > 0
@@ -1772,7 +1772,7 @@ export default function BomManagementPage() {
{/* 소요량 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? (bomHeader?.base_qty || "1") : (node.quantity || "-")}</td>
{/* 단위 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? "-" : (node.unit || "-")}</td>
<td className="px-3 py-2 text-center">{isVirtualRoot ? (categoryOptions["inventory_unit"]?.find((o) => o.code === bomHeader?.unit)?.label || bomHeader?.unit || "-") : (node.unit || "-")}</td>
{/* 공정구분 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2">{isVirtualRoot ? "-" : (node.process_type || "-")}</td>
{/* 규격 */}
@@ -586,7 +586,7 @@ export function WorkStandardEditModal({
<thead className="sticky top-0 bg-muted/50">
<tr className="border-b">
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground"></th>
<th className="w-20 px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="w-24 px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="w-14 px-2 py-2 text-center font-medium text-muted-foreground"></th>
<th className="w-16 px-2 py-2 text-center font-medium text-muted-foreground"></th>
@@ -597,7 +597,7 @@ export function WorkStandardEditModal({
<tr key={detail.id || idx} className="border-b transition-colors hover:bg-muted/30">
<td className="px-2 py-1.5 text-center text-muted-foreground">{idx + 1}</td>
<td className="px-2 py-1.5">
<Badge variant="outline" className="text-[10px] font-normal">
<Badge variant="outline" className="text-[10px] font-normal whitespace-nowrap">
{getDetailTypeLabel(detail.detail_type || "checklist")}
</Badge>
</td>
@@ -30,6 +30,7 @@ import { toast } from "sonner";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const MASTER_TABLE = "purchase_order_mng";
@@ -555,6 +556,48 @@ export default function PurchaseOrderPage() {
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
// 공급업체 선택 시 supplier_item_mapping으로 매핑 id 정규화 → 서버 필터 적용
const supplierCode = masterForm.supplier_code;
if (supplierCode) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, {
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierCode }] },
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
if (rawIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const uuidIds = rawIds.filter((v) => uuidRegex.test(v));
const codeIds = rawIds.filter((v) => !uuidRegex.test(v));
let convertedIds: string[] = [];
if (codeIds.length > 0) {
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: codeIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
autoFilter: true,
});
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
}
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
if (finalIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
filters.push({ columnName: "id", operator: "in", value: finalIds });
} catch { /* skip */ }
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
@@ -984,7 +1027,8 @@ export default function PurchaseOrderPage() {
<div className="grid grid-cols-2 gap-3.5">
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide"></Label>
<Select
<SmartSelect
options={categoryOptions["supplier_code"] || []}
value={masterForm.supplier_code || ""}
onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v);
@@ -992,15 +1036,9 @@ export default function PurchaseOrderPage() {
setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name }));
recalcPrices(masterForm.price_mode || "", v);
}}
placeholder="공급업체 선택"
disabled={isReadOnly}
>
<SelectTrigger className="h-9"><SelectValue placeholder="공급업체 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
</div>
@@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
const DETAIL_TABLE = "sales_order_detail";
@@ -1481,17 +1482,12 @@ export default function SalesOrderPage() {
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select
<SmartSelect
options={categoryOptions["partner_id"] || []}
value={masterForm.partner_id || ""}
onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); recalcPrices(masterForm.price_mode || "", v); }}
>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
placeholder="거래처 선택"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
@@ -16,6 +16,7 @@ import {
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { reportApi } from "@/lib/api/reportApi";
import { useCurrent2ndLevelMenuObjid } from "@/hooks/useCurrent2ndLevelMenuObjid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
@@ -153,10 +154,13 @@ export default function QuoteManagementPage() {
useEffect(() => { fetchQuotes(); }, [fetchQuotes]);
const current2ndLevelMenuObjid = useCurrent2ndLevelMenuObjid();
useEffect(() => {
if (current2ndLevelMenuObjid === null) return;
(async () => {
try {
const res = await reportApi.getReports({ page: 1, limit: 100 });
const res = await reportApi.getReportsByMenuObjid(current2ndLevelMenuObjid);
if (res.success) {
const items = res.data.items ?? [];
setReportList(items);
@@ -164,7 +168,7 @@ export default function QuoteManagementPage() {
}
} catch { /* 무시 */ }
})();
}, []);
}, [current2ndLevelMenuObjid]);
// ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ──
@@ -84,6 +84,7 @@ export default function EquipmentInfoPage() {
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
const [inspectionContinuous, setInspectionContinuous] = useState(false);
const [inspectionEditMode, setInspectionEditMode] = useState(false);
const [checkedInspectionIds, setCheckedInspectionIds] = useState<Set<string>>(new Set());
// 소모품 추가/수정 모달
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
@@ -91,6 +92,7 @@ export default function EquipmentInfoPage() {
const [consumableContinuous, setConsumableContinuous] = useState(false);
const [consumableEditMode, setConsumableEditMode] = useState(false);
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
const [checkedConsumableIds, setCheckedConsumableIds] = useState<Set<string>>(new Set());
// 점검항목 복사
const [copyModalOpen, setCopyModalOpen] = useState(false);
@@ -198,6 +200,7 @@ export default function EquipmentInfoPage() {
// 우측: 점검항목 조회
useEffect(() => {
setCheckedInspectionIds(new Set());
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
const fetchData = async () => {
setInspectionLoading(true);
@@ -215,6 +218,7 @@ export default function EquipmentInfoPage() {
// 우측: 소모품 조회
useEffect(() => {
setCheckedConsumableIds(new Set());
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
const fetchData = async () => {
setConsumableLoading(true);
@@ -270,6 +274,34 @@ export default function EquipmentInfoPage() {
} catch { toast.error("삭제 실패"); }
};
// 점검항목 삭제
const handleInspectionDelete = async () => {
const ids = Array.from(checkedInspectionIds);
if (ids.length === 0) { toast.error("삭제할 점검항목을 선택해주세요."); return; }
const ok = await confirm(`선택한 ${ids.length}건의 점검항목을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
toast.success("삭제되었습니다.");
setCheckedInspectionIds(new Set());
refreshRight();
} catch { toast.error("삭제 실패"); }
};
// 소모품 삭제
const handleConsumableDelete = async () => {
const ids = Array.from(checkedConsumableIds);
if (ids.length === 0) { toast.error("삭제할 소모품을 선택해주세요."); return; }
const ok = await confirm(`선택한 ${ids.length}건의 소모품을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${CONSUMABLE_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
toast.success("삭제되었습니다.");
setCheckedConsumableIds(new Set());
refreshRight();
} catch { toast.error("삭제 실패"); }
};
// 점검항목 추가
const handleInspectionSave = async () => {
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
@@ -524,15 +556,23 @@ export default function EquipmentInfoPage() {
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionEditMode(false); setInspectionModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={checkedInspectionIds.size === 0} onClick={handleInspectionDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
<Copy className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
{rightTab === "consumable" && (
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={checkedConsumableIds.size === 0} onClick={handleConsumableDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
</div>
</div>
@@ -611,6 +651,16 @@ export default function EquipmentInfoPage() {
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead
className="w-[40px] text-center cursor-pointer"
onClick={() => {
const allChecked = inspections.length > 0 && checkedInspectionIds.size === inspections.length;
if (allChecked) setCheckedInspectionIds(new Set());
else setCheckedInspectionIds(new Set(inspections.map((i) => i.id)));
}}
>
<Checkbox checked={inspections.length > 0 && checkedInspectionIds.size === inspections.length} />
</TableHead>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -638,6 +688,20 @@ export default function EquipmentInfoPage() {
setInspectionEditMode(true);
setInspectionModalOpen(true);
}}>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedInspectionIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
});
}}
onDoubleClick={(e) => e.stopPropagation()}
>
<Checkbox checked={checkedInspectionIds.has(item.id)} />
</TableCell>
<TableCell className="text-sm">{item.inspection_item || "-"}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
@@ -666,6 +730,16 @@ export default function EquipmentInfoPage() {
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead
className="w-[40px] text-center cursor-pointer"
onClick={() => {
const allChecked = consumables.length > 0 && checkedConsumableIds.size === consumables.length;
if (allChecked) setCheckedConsumableIds(new Set());
else setCheckedConsumableIds(new Set(consumables.map((i) => i.id)));
}}
>
<Checkbox checked={consumables.length > 0 && checkedConsumableIds.size === consumables.length} />
</TableHead>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -681,6 +755,20 @@ export default function EquipmentInfoPage() {
loadConsumableItems();
setConsumableModalOpen(true);
}}>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedConsumableIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
});
}}
onDoubleClick={(e) => e.stopPropagation()}
>
<Checkbox checked={checkedConsumableIds.has(item.id)} />
</TableCell>
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
@@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
@@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock";
const STOCK_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "spec", label: "규격" },
{ key: "warehouse_code", label: "창고" },
{ key: "location_code", label: "위치" },
{ key: "current_qty", label: "현재수량", align: "right" as const },
@@ -90,6 +92,8 @@ const getStatusVariant = (
return "destructive";
case "과잉":
return "secondary";
case "미등록":
return "outline";
default:
return "outline";
}
@@ -122,6 +126,15 @@ export default function InventoryStatusPage() {
const [stockLoading, setStockLoading] = useState(false);
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
// 재고 없는 품목 표시 여부
const [showMissingItems, setShowMissingItems] = useState(false);
// 창고 목록 (조정 모달에서 사용)
const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]);
// 선택된 창고의 위치 목록 (조정 모달에서 사용)
const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]);
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
@@ -135,7 +148,9 @@ export default function InventoryStatusPage() {
adjust_type: string;
adjust_qty: string;
reason: string;
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
warehouse_code: string;
location_code: string;
}>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
const [adjustSaving, setAdjustSaving] = useState(false);
// 카테고리 옵션
@@ -204,8 +219,9 @@ export default function InventoryStatusPage() {
const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || [];
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || [];
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", width: i.width || "", height: i.height || "", thickness: i.thickness || "" }]));
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "", width: i.width || "", height: i.height || "", thickness: i.thickness || "" }]));
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: w.warehouse_name || w.warehouse_code })));
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
@@ -216,6 +232,7 @@ export default function InventoryStatusPage() {
return {
...r,
item_name: itemInfo?.name || "",
spec: itemInfo?.spec || "",
width: itemInfo?.width || "",
height: itemInfo?.height || "",
thickness: itemInfo?.thickness || "",
@@ -225,13 +242,46 @@ export default function InventoryStatusPage() {
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
};
});
setStockItems(data);
if (showMissingItems) {
const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean));
const missingRows = items
.filter((i: any) => {
const code = i.item_number || i.item_code;
return code && !existingCodes.has(code);
})
.map((i: any) => {
const code = i.item_number || i.item_code;
const rawUnit = i.inventory_unit || "";
return {
id: `missing-${code}`,
item_code: code,
item_name: i.item_name || "",
spec: i.size || "",
width: i.width || "",
height: i.height || "",
thickness: i.thickness || "",
warehouse_code: "",
warehouse_name: "",
location_code: "",
current_qty: "0",
safety_qty: "",
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
status: "미등록",
_isLow: false,
_isMissing: true,
};
});
setStockItems([...data, ...missingRows]);
} else {
setStockItems(data);
}
} catch {
toast.error("재고 목록을 불러오지 못했어요");
} finally {
setStockLoading(false);
}
}, [categoryOptions, searchFilters]);
}, [categoryOptions, searchFilters, showMissingItems]);
useEffect(() => {
fetchStock();
@@ -285,6 +335,35 @@ export default function InventoryStatusPage() {
fetchHistory();
}, [fetchHistory]);
useEffect(() => {
const whCode = adjustForm.warehouse_code;
if (!whCode) {
setLocationList([]);
return;
}
(async () => {
try {
const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, {
page: 1,
size: 0,
dataFilter: {
enabled: true,
filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }],
},
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setLocationList(
rows
.filter((r: any) => r.location_code)
.map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code }))
);
} catch {
setLocationList([]);
}
})();
}, [adjustForm.warehouse_code]);
// 재고 조정 저장
const handleAdjustSave = async () => {
if (!selectedStock) return;
@@ -297,6 +376,20 @@ export default function InventoryStatusPage() {
toast.error("조정 사유를 입력해주세요");
return;
}
const isMissing = !!selectedStock._isMissing;
const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || "");
const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || "");
if (isMissing && !targetWhCode) {
toast.error("창고를 선택해주세요");
return;
}
if (isMissing && adjustForm.adjust_type === "감소") {
toast.error("미등록 품목은 감소 조정이 불가해요");
return;
}
setAdjustSaving(true);
try {
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
@@ -307,8 +400,8 @@ export default function InventoryStatusPage() {
{
id: crypto.randomUUID(),
item_code: selectedStock.item_code,
warehouse_code: selectedStock.warehouse_code || "",
location_code: selectedStock.location_code || "",
warehouse_code: targetWhCode,
location_code: targetLocCode,
transaction_type: "조정",
transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
quantity: String(changeQty),
@@ -317,17 +410,33 @@ export default function InventoryStatusPage() {
}
);
await apiClient.put(
`/table-management/tables/${STOCK_TABLE}/edit`,
{
originalData: { id: selectedStock.id },
updatedData: { current_qty: afterQty },
}
);
if (isMissing) {
await apiClient.post(
`/table-management/tables/${STOCK_TABLE}/add`,
{
id: crypto.randomUUID(),
item_code: selectedStock.item_code,
warehouse_code: targetWhCode,
location_code: targetLocCode,
current_qty: String(afterQty),
safety_qty: "0",
last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
}
);
} else {
await apiClient.put(
`/table-management/tables/${STOCK_TABLE}/edit`,
{
originalData: { id: selectedStock.id },
updatedData: { current_qty: afterQty },
}
);
}
toast.success("재고가 조정되었어요");
setAdjustModalOpen(false);
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
setSelectedStockId(null);
fetchStock();
} catch {
toast.error("재고 조정에 실패했어요");
@@ -391,6 +500,7 @@ export default function InventoryStatusPage() {
stockItems.map((r) => ({
품목코드: r.item_code,
품명: r.item_name,
규격: r.spec || "",
창고: r.warehouse_name || r.warehouse_code,
위치: r.location_code,
현재수량: r.current_qty,
@@ -444,6 +554,13 @@ export default function InventoryStatusPage() {
{stockItems.length}
</Badge>
</div>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox
checked={showMissingItems}
onCheckedChange={(v) => setShowMissingItems(!!v)}
/>
<span> </span>
</label>
</div>
<EDataTable
@@ -519,6 +636,8 @@ export default function InventoryStatusPage() {
adjust_type: "증가",
adjust_qty: "",
reason: "",
warehouse_code: selectedStock._isMissing ? "" : (selectedStock.warehouse_code || ""),
location_code: selectedStock._isMissing ? "" : (selectedStock.location_code || ""),
});
setAdjustModalOpen(true);
}}
@@ -678,6 +797,68 @@ export default function InventoryStatusPage() {
</DialogHeader>
<div className="grid gap-4 py-2">
{selectedStock?._isMissing && (
<>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Select
value={adjustForm.warehouse_code}
onValueChange={(v) =>
setAdjustForm((prev) => ({
...prev,
warehouse_code: v,
location_code: "",
}))
}
>
<SelectTrigger>
<SelectValue placeholder="창고를 선택해주세요" />
</SelectTrigger>
<SelectContent>
{warehouseList.map((w) => (
<SelectItem key={w.code} value={w.code}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
</Label>
<Select
value={adjustForm.location_code}
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, location_code: v }))
}
disabled={!adjustForm.warehouse_code || locationList.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
!adjustForm.warehouse_code
? "창고를 먼저 선택하세요"
: locationList.length === 0
? "등록된 위치가 없어요"
: "위치 선택 (선택 사항)"
}
/>
</SelectTrigger>
<SelectContent>
{locationList.map((l) => (
<SelectItem key={l.code} value={l.code}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
@@ -687,6 +868,7 @@ export default function InventoryStatusPage() {
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
}
disabled={!!selectedStock?._isMissing}
>
<SelectTrigger>
<SelectValue placeholder="조정 유형 선택" />
@@ -696,6 +878,11 @@ export default function InventoryStatusPage() {
<SelectItem value="감소"> ( )</SelectItem>
</SelectContent>
</Select>
{selectedStock?._isMissing && (
<p className="text-[10px] text-muted-foreground">
( )
</p>
)}
</div>
<div className="grid gap-1.5">
@@ -528,9 +528,9 @@ export default function PackagingPage() {
{/* 4. 콘텐츠 영역 */}
{activeTab === "packing" ? (
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
{/* 포장재 목록 테이블 */}
<div className={cn("overflow-auto", selectedPkg ? "flex-[0_0_50%] border-b" : "flex-1")}>
<div className="flex-[0_0_50%] overflow-auto border-r">
<EDataTable
columns={ts.visibleColumns.map((col): EDataTableColumn<PkgUnit> => {
const renderMap: Record<string, Partial<EDataTableColumn<PkgUnit>>> = {
@@ -570,8 +570,8 @@ export default function PackagingPage() {
</div>
{/* 매칭 품목 서브패널 */}
{selectedPkg && (
<>
{selectedPkg ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
@@ -635,14 +635,21 @@ export default function PackagingPage() {
</Table>
)}
</div>
</>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<Package className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)}
</div>
) : (
/* 적재함 관리 탭 */
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
{/* 적재함 목록 테이블 */}
<div className={cn("overflow-auto", selectedLoading ? "flex-[0_0_50%] border-b" : "flex-1")}>
<div className="flex-[0_0_50%] overflow-auto border-r">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
@@ -709,8 +716,8 @@ export default function PackagingPage() {
</div>
{/* 포장구성 서브패널 */}
{selectedLoading && (
<>
{selectedLoading ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
@@ -774,7 +781,14 @@ export default function PackagingPage() {
</Table>
)}
</div>
</>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<Box className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)}
</div>
)}
@@ -280,6 +280,8 @@ interface SelectedSourceItem {
total_amount: number;
source_table: string;
source_id: string;
detail_id?: string;
header_id?: string;
}
export default function ReceivingPage() {
@@ -616,7 +618,7 @@ export default function ReceivingPage() {
const first = grouped[0] || row;
setEditMode(true);
setEditItemIds(grouped.map((g) => g.id));
setEditItemIds(grouped.map((g, idx) => (g as any).detail_id || `${g.id}__${idx}`));
setModalInboundNo(inNo);
setModalInboundType(first.inbound_type || "구매입고");
setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : "");
@@ -626,8 +628,10 @@ export default function ReceivingPage() {
setModalManager((first as any).manager || "");
setModalMemo(first.memo || "");
setSelectedItems(
grouped.map((g) => ({
key: g.id,
grouped.map((g, idx) => ({
key: (g as any).detail_id || `${g.id}__${idx}`,
detail_id: (g as any).detail_id || undefined,
header_id: g.id,
inbound_type: (g as any).detail_inbound_type || g.inbound_type || "",
reference_number: g.reference_number || "",
supplier_code: (g as any).supplier_code || "",
@@ -814,7 +818,7 @@ export default function ReceivingPage() {
await Promise.all([
...toDelete.map((id) => deleteReceiving(id)),
...toUpdate.map((item) =>
updateReceiving(item.key, {
updateReceiving(item.header_id || item.key, {
inbound_date: modalInboundDate,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
@@ -822,6 +826,7 @@ export default function ReceivingPage() {
warehouse_code: modalWarehouse || undefined,
location_code: modalLocation || undefined,
memo: modalMemo || undefined,
detail_id: item.detail_id,
} as any)
),
...(toCreate.length > 0
@@ -297,7 +297,11 @@ export default function BomManagementPage() {
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
const [itemSearchLoading, setItemSearchLoading] = useState(false);
const [itemSearchTarget, setItemSearchTarget] = useState<"master" | "detail">("master");
const [itemSearchTarget, setItemSearchTarget] = useState<"master" | "detail" | "tree">("master");
// 서버사이드 페이지네이션
const [itemSearchPage, setItemSearchPage] = useState(1);
const [itemSearchPageSize] = useState(20);
const [itemSearchTotal, setItemSearchTotal] = useState(0);
// 엑셀 업로드
const [showExcelUpload, setShowExcelUpload] = useState(false);
@@ -780,11 +784,14 @@ export default function BomManagementPage() {
setTreeHasChanges(true);
};
// 하위 품목 추가 시작
// 하위 품목 추가 시작 (BOM 등록 품목검색 모달 재사용)
const handleTreeAddChild = (parentId: string | null) => {
setAddTargetParentId(parentId);
setTreeItemSearchOpen(true);
searchItems("");
setItemSearchTarget("tree");
setItemSearchKeyword("");
setItemSearchPage(1);
setShowItemSearchModal(true);
searchItems(1);
};
// 트리 품목 선택 완료 (트리에 추가)
@@ -1191,33 +1198,37 @@ export default function BomManagementPage() {
}
};
// ─── 품목 검색 ───────────────────────────────
const searchItems = async (keyword?: string) => {
// ─── 품목 검색 (서버 페이지네이션) ───────────────────────────────
const searchItems = async (pageOverride?: number, keyword?: string) => {
const kw = (keyword ?? itemSearchKeyword).trim();
const pageCandidate = typeof pageOverride === "number" && Number.isFinite(pageOverride) && pageOverride > 0
? pageOverride
: itemSearchPage;
const page = Number.isFinite(pageCandidate) && pageCandidate > 0 ? pageCandidate : 1;
setItemSearchLoading(true);
try {
// 키워드를 품명 또는 품목코드 어느 쪽에든 매칭 (OR 조건 불가 시 품명 우선)
const filters: any[] = [];
if (kw) {
filters.push({ columnName: "item_name", operator: "contains", value: kw });
}
if (kw) filters.push({ columnName: "item_name", operator: "contains", value: kw });
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1,
size: 50,
page, size: itemSearchPageSize,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
let rows = res.data?.data?.data || res.data?.data?.rows || [];
let total = res.data?.data?.total || 0;
// 키워드가 있고 품명으로 못 찾으면 품목코드로 재시도
// 키워드가 있고 품명 매칭이 없으면 품목코드로 재시도
if (kw && rows.length === 0) {
const res2 = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 50,
page, size: itemSearchPageSize,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "contains", value: kw }] },
autoFilter: true,
});
rows = res2.data?.data?.data || res2.data?.data?.rows || [];
total = res2.data?.data?.total || 0;
}
// 카테고리 코드 → 라벨 변환 (division + inventory_unit)
// 렌더 전 코드 → 라벨 변환 (division + inventory_unit)
const resolved = rows.map((r: any) => {
const out = { ...r };
if (out.division) {
@@ -1229,9 +1240,17 @@ export default function BomManagementPage() {
if (out.inventory_unit) {
out.inventory_unit = categoryOptions["inventory_unit"]?.find((o) => o.code === out.inventory_unit)?.label || out.inventory_unit;
}
// 규격: width x height x thickness 우선, 없으면 size 필드
const w = out.width, h = out.height, t = out.thickness;
if (w || h || t) {
out._spec = [w || "-", h || "-", t || "-"].join(" x ");
} else {
out._spec = out.size || "";
}
return out;
});
setItemSearchResults(resolved);
setItemSearchTotal(total);
} catch {
toast.error("품목 검색에 실패했어요");
} finally {
@@ -1239,6 +1258,12 @@ export default function BomManagementPage() {
}
};
// 페이지 변경 시 재조회
useEffect(() => {
if (showItemSearchModal) searchItems();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemSearchPage]);
const resolveUnit = (code: string) => {
if (!code) return "";
return categoryOptions["inventory_unit"]?.find((o) => o.code === code)?.label || code;
@@ -1246,6 +1271,13 @@ export default function BomManagementPage() {
const selectItem = (item: any) => {
const unitLabel = resolveUnit(item.inventory_unit);
if (itemSearchTarget === "tree") {
handleTreeItemSelect(item);
setShowItemSearchModal(false);
setItemSearchKeyword("");
setItemSearchResults([]);
return;
}
if (itemSearchTarget === "master") {
setMasterForm((prev) => ({
...prev,
@@ -1772,7 +1804,7 @@ export default function BomManagementPage() {
{/* 소요량 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? (bomHeader?.base_qty || "1") : (node.quantity || "-")}</td>
{/* 단위 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? "-" : (node.unit || "-")}</td>
<td className="px-3 py-2 text-center">{isVirtualRoot ? (categoryOptions["inventory_unit"]?.find((o) => o.code === bomHeader?.unit)?.label || bomHeader?.unit || "-") : (node.unit || "-")}</td>
{/* 공정구분 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2">{isVirtualRoot ? "-" : (node.process_type || "-")}</td>
{/* 규격 */}
@@ -1972,7 +2004,7 @@ export default function BomManagementPage() {
setItemSearchTarget("master");
setItemSearchKeyword("");
setShowItemSearchModal(true);
searchItems("");
searchItems();
}}
/>
<Button
@@ -1983,7 +2015,7 @@ export default function BomManagementPage() {
setItemSearchTarget("master");
setItemSearchKeyword("");
setShowItemSearchModal(true);
searchItems("");
searchItems();
}}
>
<Search className="w-3.5 h-3.5" />
@@ -2084,7 +2116,7 @@ export default function BomManagementPage() {
setItemSearchTarget("detail");
setItemSearchKeyword("");
setShowItemSearchModal(true);
searchItems("");
searchItems();
}}
>
<Plus className="w-3.5 h-3.5 mr-1" />
@@ -2186,8 +2218,8 @@ export default function BomManagementPage() {
</Dialog>
{/* ─── 품목 검색 모달 ──────────────────────── */}
<Dialog open={showItemSearchModal} onOpenChange={setShowItemSearchModal}>
<DialogContent className="max-w-lg">
<Dialog open={showItemSearchModal} onOpenChange={(o) => { if (!o) { setItemSearchKeyword(""); setItemSearchResults([]); setItemSearchPage(1); setItemSearchTotal(0); } setShowItemSearchModal(o); }}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> </DialogDescription>
@@ -2198,13 +2230,13 @@ export default function BomManagementPage() {
placeholder="품목코드 또는 품명 입력"
value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchItems()}
onKeyDown={(e) => { if (e.key === "Enter") { setItemSearchPage(1); searchItems(1); } }}
/>
<Button size="sm" className="h-9" onClick={() => searchItems()} disabled={itemSearchLoading}>
<Button size="sm" className="h-9" onClick={() => { setItemSearchPage(1); searchItems(1); }} disabled={itemSearchLoading}>
{itemSearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
</Button>
</div>
<div className="max-h-[300px] overflow-auto border rounded-lg">
<div className="max-h-[320px] overflow-auto border rounded-lg">
{itemSearchResults.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-1">
<Search className="w-6 h-6 text-muted-foreground/40" />
@@ -2216,20 +2248,35 @@ export default function BomManagementPage() {
return (
<div
key={item.id}
className="flex items-center gap-3 px-3 py-2 border-b last:border-b-0 hover:bg-accent cursor-pointer transition-colors"
className="flex items-center gap-2 px-3 py-2 border-b last:border-b-0 hover:bg-accent cursor-pointer transition-colors"
onClick={() => selectItem(item)}
>
<span className={cn("text-[10px] font-semibold px-1.5 py-0.5 rounded shrink-0", badge.className)}>
{badge.label}
</span>
<span className="font-mono text-[11px] text-muted-foreground">{item.item_number}</span>
<span className="text-xs">{item.item_name}</span>
<span className="ml-auto text-[11px] text-muted-foreground">{item.inventory_unit || ""}</span>
<span className="font-mono text-[11px] text-muted-foreground shrink-0">{item.item_number}</span>
<span className="text-xs truncate flex-1">{item.item_name}</span>
{item._spec && (
<span className="text-[11px] text-muted-foreground font-mono shrink-0" title={item._spec}>[{item._spec}]</span>
)}
<span className="text-[11px] text-muted-foreground shrink-0">{item.inventory_unit || ""}</span>
</div>
);
})
)}
</div>
{/* 페이지네이션 */}
{itemSearchTotal > 0 && (
<div className="flex items-center justify-between text-xs text-muted-foreground px-1 pt-2">
<span> <b className="text-foreground">{itemSearchTotal.toLocaleString()}</b> · {itemSearchPage}/{Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize))} </span>
<div className="flex items-center gap-1">
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage(1)}>«</Button>
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage((p) => Math.max(1, p - 1))}></Button>
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage((p) => p + 1)}></Button>
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage(Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize)))}>»</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
@@ -89,6 +89,7 @@ export default function ProductionResultPage() {
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [pageSizeInput, setPageSizeInput] = useState("20");
const [wiTotalCount, setWiTotalCount] = useState(0);
// ── 우측: 실적 ──
const [rightTab, setRightTab] = useState<"result" | "defect">("result");
@@ -135,7 +136,7 @@ export default function ProductionResultPage() {
const fetchWiList = useCallback(async () => {
setWiLoading(true);
try {
const params: Record<string, string> = {};
const params: Record<string, string> = { page: String(currentPage), pageSize: String(pageSize) };
for (const f of searchFilters) {
if (f.value) {
if (f.columnName === "progress_status") params.progressStatus = f.value;
@@ -145,6 +146,7 @@ export default function ProductionResultPage() {
}
const res = await apiClient.get("/work-instruction/list", { params });
const raw: any[] = res.data?.data || [];
const total: number = res.data?.totalCount ?? raw.length;
// work_instruction_no 기준 중복 제거 (detail JOIN으로 여러 행 반환)
const seen = new Set<string>();
@@ -167,15 +169,19 @@ export default function ProductionResultPage() {
};
});
setWiList(enriched);
setWiTotalCount(total);
} catch {
toast.error("작업지시 목록 조회 실패");
} finally {
setWiLoading(false);
}
}, [searchFilters]);
}, [searchFilters, currentPage, pageSize]);
useEffect(() => { fetchWiList(); }, [fetchWiList]);
// 검색 조건 변경 시 1페이지로 리셋
useEffect(() => { setCurrentPage(1); }, [searchFilters]);
// 실적 로드
useEffect(() => {
if (!selectedWiId) { setProcessData([]); return; }
@@ -237,13 +243,11 @@ export default function ProductionResultPage() {
return result;
}, [wiList, groupBy]);
// 페이지네이션 계산
const totalPages = Math.max(1, Math.ceil(wiList.length / pageSize));
// 페이지네이션 계산 (서버사이드)
const totalPages = Math.max(1, Math.ceil(wiTotalCount / pageSize));
const safePage = Math.min(Math.max(1, currentPage), totalPages);
const paginatedRows = useMemo(() => {
const start = (safePage - 1) * pageSize;
return wiList.slice(start, start + pageSize);
}, [wiList, safePage, pageSize]);
// 서버가 이미 페이지 분량만 반환하므로 slice 불필요
const paginatedRows = wiList;
const paginatedGroupedData = useMemo(() => {
if (groupBy === "none") return paginatedRows;
@@ -283,8 +287,7 @@ export default function ProductionResultPage() {
return pages;
};
// 필터 변경 시 페이지로 이동
useEffect(() => { setCurrentPage(1); }, [wiList.length]);
// (검색 조건 변경 시 1페이지 리셋은 위 useEffect에서 처리)
const toggleGroup = (key: string) => {
setExpandedGroups((prev) => {
@@ -337,7 +340,7 @@ export default function ProductionResultPage() {
tableName={WI_TABLE}
filterId="c16-production-result"
onFilterChange={setSearchFilters}
dataCount={wiList.length}
dataCount={wiTotalCount}
/>
{/* 메인 */}
@@ -351,7 +354,7 @@ export default function ProductionResultPage() {
<div className="flex items-center gap-2">
<ClipboardList className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="font-mono text-xs">{wiList.length}</Badge>
<Badge variant="secondary" className="font-mono text-xs">{wiTotalCount}</Badge>
</div>
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
<SelectTrigger className="h-8 w-[120px] text-xs">
@@ -459,7 +462,7 @@ export default function ProductionResultPage() {
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span></span>
<span className="font-medium text-foreground">{wiList.length.toLocaleString()}</span>
<span className="font-medium text-foreground">{wiTotalCount.toLocaleString()}</span>
<span></span>
</div>
<div className="flex items-center gap-1.5">
@@ -586,7 +586,7 @@ export function WorkStandardEditModal({
<thead className="sticky top-0 bg-muted/50">
<tr className="border-b">
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground"></th>
<th className="w-20 px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="w-24 px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="w-14 px-2 py-2 text-center font-medium text-muted-foreground"></th>
<th className="w-16 px-2 py-2 text-center font-medium text-muted-foreground"></th>
@@ -597,7 +597,7 @@ export function WorkStandardEditModal({
<tr key={detail.id || idx} className="border-b transition-colors hover:bg-muted/30">
<td className="px-2 py-1.5 text-center text-muted-foreground">{idx + 1}</td>
<td className="px-2 py-1.5">
<Badge variant="outline" className="text-[10px] font-normal">
<Badge variant="outline" className="text-[10px] font-normal whitespace-nowrap">
{getDetailTypeLabel(detail.detail_type || "checklist")}
</Badge>
</td>
@@ -65,6 +65,10 @@ export default function WorkInstructionPage() {
const ts = useTableSettings("c16-work-instruction", "work_instruction", GRID_COLUMNS);
const [orders, setOrders] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// 서버사이드 페이지네이션 (WI 단위)
const [wiPage, setWiPage] = useState(1);
const [wiPageSize, setWiPageSize] = useState(20);
const [wiTotalCount, setWiTotalCount] = useState(0);
const [equipmentOptions, setEquipmentOptions] = useState<EquipmentOption[]>([]);
const [employeeOptions, setEmployeeOptions] = useState<EmployeeOption[]>([]);
@@ -143,7 +147,7 @@ export default function WorkInstructionPage() {
const fetchOrders = useCallback(async () => {
setLoading(true);
try {
const params: any = {};
const params: any = { page: wiPage, pageSize: wiPageSize };
for (const f of searchFilters) {
if (f.columnName === "start_date" && f.operator === "between" && f.value) {
const [from, to] = f.value.split("|");
@@ -160,12 +164,18 @@ export default function WorkInstructionPage() {
}
}
const r = await getWorkInstructionList(params);
if (r.success) setOrders(r.data || []);
if (r.success) {
setOrders(r.data || []);
setWiTotalCount(r.totalCount ?? (r.data?.length || 0));
}
} catch {} finally { setLoading(false); }
}, [searchFilters]);
}, [searchFilters, wiPage, wiPageSize]);
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// 검색 조건 변경 시 1페이지로 리셋
useEffect(() => { setWiPage(1); }, [searchFilters]);
// ─── 1단계 등록 ───
const openRegModal = () => {
setRegSourceType("production"); setRegSourceData([]); setRegKeyword(""); setRegCheckedIds(new Set());
@@ -520,6 +530,12 @@ export default function WorkInstructionPage() {
showPagination
draggableColumns
columnOrderKey="c16-work-instruction"
serverPagination
serverCurrentPage={wiPage}
serverPageSize={wiPageSize}
serverTotalCount={wiTotalCount}
onServerPageChange={setWiPage}
onServerPageSizeChange={(n) => { setWiPageSize(n); setWiPage(1); }}
/>
</div>
</div>
@@ -30,6 +30,7 @@ import { toast } from "sonner";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
@@ -596,6 +597,48 @@ export default function PurchaseOrderPage() {
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
// 공급업체 선택 시 supplier_item_mapping으로 매핑 id 정규화 → 서버 필터 적용
const supplierCode = masterForm.supplier_code;
if (supplierCode) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, {
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierCode }] },
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
if (rawIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const uuidIds = rawIds.filter((v) => uuidRegex.test(v));
const codeIds = rawIds.filter((v) => !uuidRegex.test(v));
let convertedIds: string[] = [];
if (codeIds.length > 0) {
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: codeIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
autoFilter: true,
});
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
}
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
if (finalIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
filters.push({ columnName: "id", operator: "in", value: finalIds });
} catch { /* skip */ }
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
@@ -1028,7 +1071,8 @@ export default function PurchaseOrderPage() {
<div className="grid grid-cols-2 gap-3.5">
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide"></Label>
<Select
<SmartSelect
options={categoryOptions["supplier_code"] || []}
value={masterForm.supplier_code || ""}
onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v);
@@ -1036,15 +1080,9 @@ export default function PurchaseOrderPage() {
setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name }));
recalcPrices(masterForm.price_mode || "", v);
}}
placeholder="공급업체 선택"
disabled={isReadOnly}
>
<SelectTrigger className="h-9"><SelectValue placeholder="공급업체 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
</div>
@@ -34,6 +34,7 @@ import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
@@ -215,9 +216,16 @@ export default function ChunganSalesOrderPage() {
// 품목 선택 모달
const [itemSelectOpen, setItemSelectOpen] = useState(false);
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
const [itemSearchWidth, setItemSearchWidth] = useState("");
const [itemSearchHeight, setItemSearchHeight] = useState("");
const [itemSearchThickness, setItemSearchThickness] = useState("");
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
const [itemSearchLoading, setItemSearchLoading] = useState(false);
const [itemCheckedIds, setItemCheckedIds] = useState<Set<string>>(new Set());
// 서버사이드 페이지네이션
const [itemSearchPage, setItemSearchPage] = useState(1);
const [itemSearchPageSize] = useState(20);
const [itemSearchTotal, setItemSearchTotal] = useState(0);
// 기타
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
@@ -706,24 +714,54 @@ export default function ChunganSalesOrderPage() {
}
};
// 품목 검색
const searchItems = async () => {
// 품목 검색 (서버 페이지네이션) — 관리품목=영업관리, 품목구분=제품 고정
// COMPANY_30: type 컬럼에 라벨 "제품"이 직접 저장돼 있어 label로 equals 필터
const searchItems = async (pageOverride?: number) => {
setItemSearchLoading(true);
const page = pageOverride ?? itemSearchPage;
try {
const salesCode = categoryOptions["item_division"]?.find((o) => o.label === "영업관리")?.code;
const filters: any[] = [];
if (itemSearchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
}
if (salesCode) filters.push({ columnName: "division", operator: "contains", value: salesCode });
filters.push({ columnName: "type", operator: "equals", value: "제품" });
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
if (itemSearchWidth) filters.push({ columnName: "width", operator: "equals", value: itemSearchWidth });
if (itemSearchHeight) filters.push({ columnName: "height", operator: "equals", value: itemSearchHeight });
if (itemSearchThickness) filters.push({ columnName: "thickness", operator: "equals", value: itemSearchThickness });
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
page, size: itemSearchPageSize,
dataFilter: { enabled: true, filters },
autoFilter: true,
});
setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setItemSearchResults([]); }
const raw: any[] = res.data?.data?.data || res.data?.data?.rows || [];
// 렌더 전 코드→라벨 변환 (단위)
const resolved = raw.map((r) => ({
...r,
unit: categoryOptions["item_unit"]?.find((o) => o.code === r.unit)?.label || r.unit || "",
}));
setItemSearchResults(resolved);
setItemSearchTotal(res.data?.data?.total || 0);
} catch { setItemSearchResults([]); setItemSearchTotal(0); }
finally { setItemSearchLoading(false); }
};
// 페이지 변경 시 재조회
useEffect(() => {
if (itemSelectOpen) searchItems();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemSearchPage]);
// 필터 입력 시 자동 조회 (debounce 350ms, 1페이지로 리셋)
useEffect(() => {
if (!itemSelectOpen) return;
const t = setTimeout(() => {
setItemSearchPage(1);
searchItems(1);
}, 350);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemSearchKeyword, itemSearchWidth, itemSearchHeight, itemSearchThickness]);
// 품목 선택 → 리피터에 추가
const addSelectedItemsToDetail = () => {
const selected = itemSearchResults.filter((i) => itemCheckedIds.has(i.id));
@@ -761,14 +799,18 @@ export default function ChunganSalesOrderPage() {
setItemCheckedIds(new Set());
};
// 빈 행 추가 (품명 직접 입력용)
// 빈 행 추가 (품명 직접 입력용) — 관리품목=영업관리, 품목구분=제품 고정
const addEmptyRow = () => {
const divisionCode = "CAT_ML8ZFVEL_1TOR";
const typeCode = "CAT_MLYPJFO9_36XG";
const divisionLabel = categoryOptions["item_division"]?.find((o) => o.code === divisionCode)?.label || "영업관리";
const typeLabel = categoryOptions["item_type"]?.find((o) => o.code === typeCode)?.label || "제품";
setModalDetailRows((prev) => [...prev, {
_id: `new_${Date.now()}_${Math.random()}`,
_fromItemInfo: false,
part_code: "", part_name: "", spec: "",
division: "", _divisionLabel: "",
type: "", _typeLabel: "",
division: divisionCode, _divisionLabel: divisionLabel,
type: typeCode, _typeLabel: typeLabel,
unit: "㎡",
width: "", height: "", thickness: "", area: "",
qty: "", unit_price: "", amount: "",
@@ -819,27 +861,11 @@ export default function ChunganSalesOrderPage() {
const renderModalCell = (colKey: string, row: any, idx: number) => {
switch (colKey) {
case "division":
return row._fromItemInfo ? (
<span className="text-sm px-2">{row._divisionLabel || "-"}</span>
) : (
<Select value={row.division || ""} onValueChange={(v) => updateDetailRow(idx, "division", v)}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="관리품목" /></SelectTrigger>
<SelectContent position="popper" sideOffset={4}>
{(categoryOptions["item_division"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
);
// 관리품목=영업관리 고정 (행추가/검색추가 모두 동일)
return <span className="text-sm px-2">{row._divisionLabel || "영업관리"}</span>;
case "type":
return row._fromItemInfo ? (
<span className="text-sm px-2">{row._typeLabel || "-"}</span>
) : (
<Select value={row.type || ""} onValueChange={(v) => updateDetailRow(idx, "type", v)}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="품목구분" /></SelectTrigger>
<SelectContent position="popper" sideOffset={4}>
{(categoryOptions["item_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
);
// 품목구분=제품 고정 (행추가/검색추가 모두 동일)
return <span className="text-sm px-2">{row._typeLabel || "제품"}</span>;
case "part_name":
return row._fromItemInfo ? (
<span className="text-sm px-2">{row.part_name || "-"}</span>
@@ -1151,12 +1177,12 @@ export default function ChunganSalesOrderPage() {
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.partner_id || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, partner_id: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<SmartSelect
options={categoryOptions["partner_id"] || []}
value={masterForm.partner_id || ""}
onValueChange={(v) => setMasterForm((p) => ({ ...p, partner_id: v }))}
placeholder="거래처 선택"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
@@ -1204,7 +1230,7 @@ export default function ChunganSalesOrderPage() {
<Button size="sm" variant="outline" onClick={addEmptyRow}>
<Plus className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSearchKeyword(""); setItemSearchWidth(""); setItemSearchHeight(""); setItemSearchThickness(""); setItemSearchPage(1); setItemSelectOpen(true); searchItems(1); }}>
<Search className="w-4 h-4 mr-1" />
</Button>
</div>
@@ -1287,14 +1313,30 @@ export default function ChunganSalesOrderPage() {
<DialogTitle> </DialogTitle>
<DialogDescription> . &quot; &quot; .</DialogDescription>
</DialogHeader>
<div className="flex gap-2 mb-3">
<Input placeholder="품명 검색" value={itemSearchKeyword}
<div className="flex gap-2 mb-3 flex-wrap">
<Input placeholder="품명" value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchItems()}
className="h-9 flex-1" />
<Button size="sm" onClick={searchItems} disabled={itemSearchLoading} className="h-9">
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
className="h-9 flex-1 min-w-[140px]" />
<Input type="number" placeholder="가로" value={itemSearchWidth}
onChange={(e) => setItemSearchWidth(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
className="h-9 w-[90px]" />
<Input type="number" placeholder="세로" value={itemSearchHeight}
onChange={(e) => setItemSearchHeight(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
className="h-9 w-[90px]" />
<Input type="number" placeholder="두께" value={itemSearchThickness}
onChange={(e) => setItemSearchThickness(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
className="h-9 w-[80px]" />
<Button size="sm" onClick={() => { setItemSearchPage(1); searchItems(1); }} disabled={itemSearchLoading} className="h-9">
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
<Button size="sm" variant="ghost" className="h-9"
onClick={() => { setItemSearchKeyword(""); setItemSearchWidth(""); setItemSearchHeight(""); setItemSearchThickness(""); setItemSearchPage(1); searchItems(1); }}>
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<Table>
@@ -1340,6 +1382,16 @@ export default function ChunganSalesOrderPage() {
</TableBody>
</Table>
</div>
{/* 페이지네이션 */}
<div className="flex items-center justify-between text-xs text-muted-foreground px-1 pt-2">
<span> <b className="text-foreground">{itemSearchTotal.toLocaleString()}</b> · {itemSearchPage} / {Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize))}</span>
<div className="flex items-center gap-1">
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage(1)}>«</Button>
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage((p) => Math.max(1, p - 1))}></Button>
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage((p) => p + 1)}></Button>
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage(Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize)))}>»</Button>
</div>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{itemCheckedIds.size} </span>
@@ -16,6 +16,7 @@ import {
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { reportApi } from "@/lib/api/reportApi";
import { useCurrent2ndLevelMenuObjid } from "@/hooks/useCurrent2ndLevelMenuObjid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
@@ -153,10 +154,13 @@ export default function QuoteManagementPage() {
useEffect(() => { fetchQuotes(); }, [fetchQuotes]);
const current2ndLevelMenuObjid = useCurrent2ndLevelMenuObjid();
useEffect(() => {
if (current2ndLevelMenuObjid === null) return;
(async () => {
try {
const res = await reportApi.getReports({ page: 1, limit: 100 });
const res = await reportApi.getReportsByMenuObjid(current2ndLevelMenuObjid);
if (res.success) {
const items = res.data.items ?? [];
setReportList(items);
@@ -164,7 +168,7 @@ export default function QuoteManagementPage() {
}
} catch { /* 무시 */ }
})();
}, []);
}, [current2ndLevelMenuObjid]);
// ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ──
@@ -86,6 +86,7 @@ export default function EquipmentInfoPage() {
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
const [inspectionContinuous, setInspectionContinuous] = useState(false);
const [inspectionEditMode, setInspectionEditMode] = useState(false);
const [checkedInspectionIds, setCheckedInspectionIds] = useState<Set<string>>(new Set());
// 소모품 추가/수정 모달
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
@@ -93,6 +94,7 @@ export default function EquipmentInfoPage() {
const [consumableContinuous, setConsumableContinuous] = useState(false);
const [consumableEditMode, setConsumableEditMode] = useState(false);
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
const [checkedConsumableIds, setCheckedConsumableIds] = useState<Set<string>>(new Set());
// 점검항목 복사
const [copyModalOpen, setCopyModalOpen] = useState(false);
@@ -200,6 +202,7 @@ export default function EquipmentInfoPage() {
// 우측: 점검항목 조회
useEffect(() => {
setCheckedInspectionIds(new Set());
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
const fetchData = async () => {
setInspectionLoading(true);
@@ -217,6 +220,7 @@ export default function EquipmentInfoPage() {
// 우측: 소모품 조회
useEffect(() => {
setCheckedConsumableIds(new Set());
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
const fetchData = async () => {
setConsumableLoading(true);
@@ -292,6 +296,34 @@ export default function EquipmentInfoPage() {
} catch { toast.error("삭제 실패"); }
};
// 점검항목 삭제
const handleInspectionDelete = async () => {
const ids = Array.from(checkedInspectionIds);
if (ids.length === 0) { toast.error("삭제할 점검항목을 선택해주세요."); return; }
const ok = await confirm(`선택한 ${ids.length}건의 점검항목을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
toast.success("삭제되었습니다.");
setCheckedInspectionIds(new Set());
refreshRight();
} catch { toast.error("삭제 실패"); }
};
// 소모품 삭제
const handleConsumableDelete = async () => {
const ids = Array.from(checkedConsumableIds);
if (ids.length === 0) { toast.error("삭제할 소모품을 선택해주세요."); return; }
const ok = await confirm(`선택한 ${ids.length}건의 소모품을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${CONSUMABLE_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
toast.success("삭제되었습니다.");
setCheckedConsumableIds(new Set());
refreshRight();
} catch { toast.error("삭제 실패"); }
};
// 점검항목 추가
const handleInspectionSave = async () => {
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
@@ -546,15 +578,23 @@ export default function EquipmentInfoPage() {
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionEditMode(false); setInspectionModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={checkedInspectionIds.size === 0} onClick={handleInspectionDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
<Copy className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
{rightTab === "consumable" && (
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={checkedConsumableIds.size === 0} onClick={handleConsumableDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
</div>
</div>
@@ -633,6 +673,16 @@ export default function EquipmentInfoPage() {
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead
className="w-[40px] text-center cursor-pointer"
onClick={() => {
const allChecked = inspections.length > 0 && checkedInspectionIds.size === inspections.length;
if (allChecked) setCheckedInspectionIds(new Set());
else setCheckedInspectionIds(new Set(inspections.map((i) => i.id)));
}}
>
<Checkbox checked={inspections.length > 0 && checkedInspectionIds.size === inspections.length} />
</TableHead>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -660,6 +710,20 @@ export default function EquipmentInfoPage() {
setInspectionEditMode(true);
setInspectionModalOpen(true);
}}>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedInspectionIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
});
}}
onDoubleClick={(e) => e.stopPropagation()}
>
<Checkbox checked={checkedInspectionIds.has(item.id)} />
</TableCell>
<TableCell className="text-sm">{item.inspection_item || "-"}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
@@ -688,6 +752,16 @@ export default function EquipmentInfoPage() {
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead
className="w-[40px] text-center cursor-pointer"
onClick={() => {
const allChecked = consumables.length > 0 && checkedConsumableIds.size === consumables.length;
if (allChecked) setCheckedConsumableIds(new Set());
else setCheckedConsumableIds(new Set(consumables.map((i) => i.id)));
}}
>
<Checkbox checked={consumables.length > 0 && checkedConsumableIds.size === consumables.length} />
</TableHead>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -703,6 +777,20 @@ export default function EquipmentInfoPage() {
loadConsumableItems();
setConsumableModalOpen(true);
}}>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedConsumableIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
});
}}
onDoubleClick={(e) => e.stopPropagation()}
>
<Checkbox checked={checkedConsumableIds.has(item.id)} />
</TableCell>
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
@@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
@@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock";
const STOCK_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "spec", label: "규격" },
{ key: "warehouse_code", label: "창고" },
{ key: "location_code", label: "위치" },
{ key: "current_qty", label: "현재수량", align: "right" as const },
@@ -87,6 +89,8 @@ const getStatusVariant = (
return "destructive";
case "과잉":
return "secondary";
case "미등록":
return "outline";
default:
return "outline";
}
@@ -119,6 +123,15 @@ export default function InventoryStatusPage() {
const [stockLoading, setStockLoading] = useState(false);
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
// 재고 없는 품목 표시 여부
const [showMissingItems, setShowMissingItems] = useState(false);
// 창고 목록 (조정 모달에서 사용)
const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]);
// 선택된 창고의 위치 목록 (조정 모달에서 사용)
const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]);
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
@@ -132,7 +145,9 @@ export default function InventoryStatusPage() {
adjust_type: string;
adjust_qty: string;
reason: string;
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
warehouse_code: string;
location_code: string;
}>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
const [adjustSaving, setAdjustSaving] = useState(false);
// 카테고리 옵션
@@ -201,8 +216,9 @@ export default function InventoryStatusPage() {
const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || [];
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || [];
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "" }]));
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "" }]));
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: w.warehouse_name || w.warehouse_code })));
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
@@ -213,19 +229,51 @@ export default function InventoryStatusPage() {
return {
...r,
item_name: itemInfo?.name || "",
spec: itemInfo?.spec || "",
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
status: resolve("status", r.status),
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
};
});
setStockItems(data);
// 재고 없는 품목 표시: inventory_stock에 없는 item_info 품목을 미등록 가상 행으로 추가
if (showMissingItems) {
const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean));
const missingRows = items
.filter((i: any) => {
const code = i.item_number || i.item_code;
return code && !existingCodes.has(code);
})
.map((i: any) => {
const code = i.item_number || i.item_code;
const rawUnit = i.inventory_unit || "";
return {
id: `missing-${code}`,
item_code: code,
item_name: i.item_name || "",
spec: i.size || "",
warehouse_code: "",
warehouse_name: "",
location_code: "",
current_qty: "0",
safety_qty: "",
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
status: "미등록",
_isLow: false,
_isMissing: true,
};
});
setStockItems([...data, ...missingRows]);
} else {
setStockItems(data);
}
} catch {
toast.error("재고 목록을 불러오지 못했어요");
} finally {
setStockLoading(false);
}
}, [categoryOptions, searchFilters]);
}, [categoryOptions, searchFilters, showMissingItems]);
useEffect(() => {
fetchStock();
@@ -279,6 +327,36 @@ export default function InventoryStatusPage() {
fetchHistory();
}, [fetchHistory]);
// 창고 선택 시 해당 창고의 위치 목록 조회 (조정 모달용)
useEffect(() => {
const whCode = adjustForm.warehouse_code;
if (!whCode) {
setLocationList([]);
return;
}
(async () => {
try {
const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, {
page: 1,
size: 0,
dataFilter: {
enabled: true,
filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }],
},
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setLocationList(
rows
.filter((r: any) => r.location_code)
.map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code }))
);
} catch {
setLocationList([]);
}
})();
}, [adjustForm.warehouse_code]);
// 재고 조정 저장
const handleAdjustSave = async () => {
if (!selectedStock) return;
@@ -291,6 +369,20 @@ export default function InventoryStatusPage() {
toast.error("조정 사유를 입력해주세요");
return;
}
const isMissing = !!selectedStock._isMissing;
const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || "");
const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || "");
if (isMissing && !targetWhCode) {
toast.error("창고를 선택해주세요");
return;
}
if (isMissing && adjustForm.adjust_type === "감소") {
toast.error("미등록 품목은 감소 조정이 불가해요");
return;
}
setAdjustSaving(true);
try {
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
@@ -301,8 +393,8 @@ export default function InventoryStatusPage() {
{
id: crypto.randomUUID(),
item_code: selectedStock.item_code,
warehouse_code: selectedStock.warehouse_code || "",
location_code: selectedStock.location_code || "",
warehouse_code: targetWhCode,
location_code: targetLocCode,
transaction_type: "조정",
transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
quantity: String(changeQty),
@@ -311,17 +403,34 @@ export default function InventoryStatusPage() {
}
);
await apiClient.put(
`/table-management/tables/${STOCK_TABLE}/edit`,
{
originalData: { id: selectedStock.id },
updatedData: { current_qty: afterQty },
}
);
if (isMissing) {
// 새 재고 레코드 생성
await apiClient.post(
`/table-management/tables/${STOCK_TABLE}/add`,
{
id: crypto.randomUUID(),
item_code: selectedStock.item_code,
warehouse_code: targetWhCode,
location_code: targetLocCode,
current_qty: String(afterQty),
safety_qty: "0",
last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
}
);
} else {
await apiClient.put(
`/table-management/tables/${STOCK_TABLE}/edit`,
{
originalData: { id: selectedStock.id },
updatedData: { current_qty: afterQty },
}
);
}
toast.success("재고가 조정되었어요");
setAdjustModalOpen(false);
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
setSelectedStockId(null);
fetchStock();
} catch {
toast.error("재고 조정에 실패했어요");
@@ -385,6 +494,7 @@ export default function InventoryStatusPage() {
stockItems.map((r) => ({
품목코드: r.item_code,
품명: r.item_name,
규격: r.spec || "",
창고: r.warehouse_name || r.warehouse_code,
위치: r.location_code,
현재수량: r.current_qty,
@@ -438,6 +548,13 @@ export default function InventoryStatusPage() {
{stockItems.length}
</Badge>
</div>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox
checked={showMissingItems}
onCheckedChange={(v) => setShowMissingItems(!!v)}
/>
<span> </span>
</label>
</div>
<EDataTable
@@ -513,6 +630,8 @@ export default function InventoryStatusPage() {
adjust_type: "증가",
adjust_qty: "",
reason: "",
warehouse_code: selectedStock._isMissing ? "" : (selectedStock.warehouse_code || ""),
location_code: selectedStock._isMissing ? "" : (selectedStock.location_code || ""),
});
setAdjustModalOpen(true);
}}
@@ -672,6 +791,68 @@ export default function InventoryStatusPage() {
</DialogHeader>
<div className="grid gap-4 py-2">
{selectedStock?._isMissing && (
<>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Select
value={adjustForm.warehouse_code}
onValueChange={(v) =>
setAdjustForm((prev) => ({
...prev,
warehouse_code: v,
location_code: "",
}))
}
>
<SelectTrigger>
<SelectValue placeholder="창고를 선택해주세요" />
</SelectTrigger>
<SelectContent>
{warehouseList.map((w) => (
<SelectItem key={w.code} value={w.code}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
</Label>
<Select
value={adjustForm.location_code}
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, location_code: v }))
}
disabled={!adjustForm.warehouse_code || locationList.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
!adjustForm.warehouse_code
? "창고를 먼저 선택하세요"
: locationList.length === 0
? "등록된 위치가 없어요"
: "위치 선택 (선택 사항)"
}
/>
</SelectTrigger>
<SelectContent>
{locationList.map((l) => (
<SelectItem key={l.code} value={l.code}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
@@ -681,6 +862,7 @@ export default function InventoryStatusPage() {
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
}
disabled={!!selectedStock?._isMissing}
>
<SelectTrigger>
<SelectValue placeholder="조정 유형 선택" />
@@ -690,6 +872,11 @@ export default function InventoryStatusPage() {
<SelectItem value="감소"> ( )</SelectItem>
</SelectContent>
</Select>
{selectedStock?._isMissing && (
<p className="text-[10px] text-muted-foreground">
( )
</p>
)}
</div>
<div className="grid gap-1.5">
@@ -528,9 +528,9 @@ export default function PackagingPage() {
{/* 4. 콘텐츠 영역 */}
{activeTab === "packing" ? (
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
{/* 포장재 목록 테이블 */}
<div className={cn("overflow-auto", selectedPkg ? "flex-[0_0_50%] border-b" : "flex-1")}>
<div className="flex-[0_0_50%] overflow-auto border-r">
<EDataTable
columns={ts.visibleColumns.map((col): EDataTableColumn<PkgUnit> => {
const renderMap: Record<string, Partial<EDataTableColumn<PkgUnit>>> = {
@@ -570,8 +570,8 @@ export default function PackagingPage() {
</div>
{/* 매칭 품목 서브패널 */}
{selectedPkg && (
<>
{selectedPkg ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
@@ -635,14 +635,21 @@ export default function PackagingPage() {
</Table>
)}
</div>
</>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<Package className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)}
</div>
) : (
/* 적재함 관리 탭 */
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
{/* 적재함 목록 테이블 */}
<div className={cn("overflow-auto", selectedLoading ? "flex-[0_0_50%] border-b" : "flex-1")}>
<div className="flex-[0_0_50%] overflow-auto border-r">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
@@ -709,8 +716,8 @@ export default function PackagingPage() {
</div>
{/* 포장구성 서브패널 */}
{selectedLoading && (
<>
{selectedLoading ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
@@ -774,7 +781,14 @@ export default function PackagingPage() {
</Table>
)}
</div>
</>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<Box className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)}
</div>
)}
@@ -250,6 +250,8 @@ interface SelectedSourceItem {
total_amount: number;
source_table: string;
source_id: string;
detail_id?: string;
header_id?: string;
}
export default function ReceivingPage() {
@@ -584,7 +586,7 @@ export default function ReceivingPage() {
const first = grouped[0] || row;
setEditMode(true);
setEditItemIds(grouped.map((g) => g.id));
setEditItemIds(grouped.map((g, idx) => (g as any).detail_id || `${g.id}__${idx}`));
setModalInboundNo(inNo);
setModalInboundType(first.inbound_type || "구매입고");
setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : "");
@@ -594,8 +596,10 @@ export default function ReceivingPage() {
setModalManager((first as any).manager || "");
setModalMemo(first.memo || "");
setSelectedItems(
grouped.map((g) => ({
key: g.id,
grouped.map((g, idx) => ({
key: (g as any).detail_id || `${g.id}__${idx}`,
detail_id: (g as any).detail_id || undefined,
header_id: g.id,
inbound_type: (g as any).detail_inbound_type || g.inbound_type || "",
reference_number: g.reference_number || "",
supplier_code: (g as any).supplier_code || "",
@@ -782,7 +786,7 @@ export default function ReceivingPage() {
await Promise.all([
...toDelete.map((id) => deleteReceiving(id)),
...toUpdate.map((item) =>
updateReceiving(item.key, {
updateReceiving(item.header_id || item.key, {
inbound_date: modalInboundDate,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
@@ -790,6 +794,7 @@ export default function ReceivingPage() {
warehouse_code: modalWarehouse || undefined,
location_code: modalLocation || undefined,
memo: modalMemo || undefined,
detail_id: item.detail_id,
} as any)
),
...(toCreate.length > 0
@@ -1772,7 +1772,7 @@ export default function BomManagementPage() {
{/* 소요량 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? (bomHeader?.base_qty || "1") : (node.quantity || "-")}</td>
{/* 단위 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? "-" : (node.unit || "-")}</td>
<td className="px-3 py-2 text-center">{isVirtualRoot ? (categoryOptions["inventory_unit"]?.find((o) => o.code === bomHeader?.unit)?.label || bomHeader?.unit || "-") : (node.unit || "-")}</td>
{/* 공정구분 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2">{isVirtualRoot ? "-" : (node.process_type || "-")}</td>
{/* 규격 */}
@@ -93,7 +93,7 @@ export function ItemRoutingTab() {
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsources, setFormOutsources] = useState<string[]>([]);
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
const [detailSubmitting, setDetailSubmitting] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
@@ -117,7 +117,7 @@ export function ItemRoutingTab() {
page: 1, size: 500, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
setSubcontractorOptions(rows.map((r: any) => ({ id: r.id, code: r.subcontractor_code || "", name: r.subcontractor_name || "" })));
} catch { /* skip */ }
})();
}, []);
@@ -309,10 +309,19 @@ export function ItemRoutingTab() {
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
setFormWorkType(row.work_type || "내부");
setFormStandardTime(row.standard_time || "");
const loaded = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0
? row.outsource_supplier_list
: (row.outsource_supplier ? [row.outsource_supplier] : []);
setFormOutsources(loaded);
// 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환)
let loadedIds: string[] = [];
if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) {
loadedIds = row.outsource_supplier_ids;
} else {
const legacyCodes = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0
? row.outsource_supplier_list
: (row.outsource_supplier ? [row.outsource_supplier] : []);
loadedIds = legacyCodes
.map((c: string) => subcontractorOptions.find((s) => s.code === c)?.id)
.filter((v): v is string => Boolean(v));
}
setFormOutsources(loadedIds);
setDetailDialogOpen(true);
};
@@ -333,8 +342,10 @@ export function ItemRoutingTab() {
return;
}
const proc = processes.find((p) => p.process_code === formProcessCode);
const outsourceList = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : [];
const outsourcePrimary = outsourceList[0] || "";
const outsourceIds = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : [];
const outsourcePrimaryCode = outsourceIds.length > 0
? (subcontractorOptions.find((s) => s.id === outsourceIds[0])?.code || "")
: "";
setDetailSubmitting(true);
try {
@@ -349,8 +360,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
outsource_supplier: outsourcePrimary,
outsource_supplier_list: outsourceList,
outsource_supplier: outsourcePrimaryCode,
outsource_supplier_ids: outsourceIds,
};
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
@@ -368,8 +379,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
outsource_supplier: outsourcePrimary,
outsource_supplier_list: outsourceList,
outsource_supplier: outsourcePrimaryCode,
outsource_supplier_ids: outsourceIds,
}
: d,
),
@@ -406,7 +417,7 @@ export function ItemRoutingTab() {
work_type: d.work_type || "내부",
standard_time: String(d.standard_time ?? "0"),
outsource_supplier: d.outsource_supplier || "",
outsource_supplier_list: d.outsource_supplier_list || (d.outsource_supplier ? [d.outsource_supplier] : []),
outsource_supplier_ids: d.outsource_supplier_ids || [],
}));
setSaving(true);
@@ -489,12 +500,16 @@ export function ItemRoutingTab() {
const detailsGridData = useMemo(
() =>
details.map((d) => {
const codes = Array.isArray(d.outsource_supplier_list) && d.outsource_supplier_list.length > 0
? d.outsource_supplier_list
: (d.outsource_supplier ? [d.outsource_supplier] : []);
const names = codes
.map((c) => subcontractorOptions.find((s) => s.code === c)?.name || c)
.filter(Boolean);
const ids = Array.isArray(d.outsource_supplier_ids) && d.outsource_supplier_ids.length > 0
? d.outsource_supplier_ids
: [];
let names = ids
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name)
.filter((v): v is string => Boolean(v));
// 레거시 폴백: id 매핑 없을 때 단일 code로 표시
if (names.length === 0 && d.outsource_supplier) {
names = [subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier];
}
return {
...d,
process_display: d.process_name || d.process_code,
@@ -933,7 +948,7 @@ export function ItemRoutingTab() {
{formOutsources.length === 0
? "외주업체 선택"
: formOutsources
.map((c) => subcontractorOptions.find((s) => s.code === c)?.name || c)
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i)
.join(", ")}
</span>
<Badge variant="secondary" className="ml-2 shrink-0">{formOutsources.length}</Badge>
@@ -944,17 +959,17 @@ export function ItemRoutingTab() {
{subcontractorOptions.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-3"> </div>
) : subcontractorOptions.map((s) => {
const checked = formOutsources.includes(s.code);
const checked = formOutsources.includes(s.id);
return (
<label
key={s.code}
key={s.id}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm cursor-pointer hover:bg-muted"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
setFormOutsources((prev) =>
v ? [...prev, s.code] : prev.filter((c) => c !== s.code),
v ? [...prev, s.id] : prev.filter((i) => i !== s.id),
);
}}
/>
@@ -586,7 +586,7 @@ export function WorkStandardEditModal({
<thead className="sticky top-0 bg-muted/50">
<tr className="border-b">
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground"></th>
<th className="w-20 px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="w-24 px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="w-14 px-2 py-2 text-center font-medium text-muted-foreground"></th>
<th className="w-16 px-2 py-2 text-center font-medium text-muted-foreground"></th>
@@ -597,7 +597,7 @@ export function WorkStandardEditModal({
<tr key={detail.id || idx} className="border-b transition-colors hover:bg-muted/30">
<td className="px-2 py-1.5 text-center text-muted-foreground">{idx + 1}</td>
<td className="px-2 py-1.5">
<Badge variant="outline" className="text-[10px] font-normal">
<Badge variant="outline" className="text-[10px] font-normal whitespace-nowrap">
{getDetailTypeLabel(detail.detail_type || "checklist")}
</Badge>
</td>
@@ -30,6 +30,7 @@ import { toast } from "sonner";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const MASTER_TABLE = "purchase_order_mng";
@@ -555,6 +556,48 @@ export default function PurchaseOrderPage() {
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
// 공급업체 선택 시 supplier_item_mapping으로 매핑 id 정규화 → 서버 필터 적용
const supplierCode = masterForm.supplier_code;
if (supplierCode) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, {
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierCode }] },
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
if (rawIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const uuidIds = rawIds.filter((v) => uuidRegex.test(v));
const codeIds = rawIds.filter((v) => !uuidRegex.test(v));
let convertedIds: string[] = [];
if (codeIds.length > 0) {
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: codeIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
autoFilter: true,
});
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
}
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
if (finalIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
filters.push({ columnName: "id", operator: "in", value: finalIds });
} catch { /* skip */ }
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
@@ -984,7 +1027,8 @@ export default function PurchaseOrderPage() {
<div className="grid grid-cols-2 gap-3.5">
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide"></Label>
<Select
<SmartSelect
options={categoryOptions["supplier_code"] || []}
value={masterForm.supplier_code || ""}
onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v);
@@ -992,15 +1036,9 @@ export default function PurchaseOrderPage() {
setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name }));
recalcPrices(masterForm.price_mode || "", v);
}}
placeholder="공급업체 선택"
disabled={isReadOnly}
>
<SelectTrigger className="h-9"><SelectValue placeholder="공급업체 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
</div>
@@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
const DETAIL_TABLE = "sales_order_detail";
@@ -1481,17 +1482,12 @@ export default function SalesOrderPage() {
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select
<SmartSelect
options={categoryOptions["partner_id"] || []}
value={masterForm.partner_id || ""}
onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); recalcPrices(masterForm.price_mode || "", v); }}
>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
placeholder="거래처 선택"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
@@ -16,6 +16,7 @@ import {
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { reportApi } from "@/lib/api/reportApi";
import { useCurrent2ndLevelMenuObjid } from "@/hooks/useCurrent2ndLevelMenuObjid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
@@ -153,10 +154,13 @@ export default function QuoteManagementPage() {
useEffect(() => { fetchQuotes(); }, [fetchQuotes]);
const current2ndLevelMenuObjid = useCurrent2ndLevelMenuObjid();
useEffect(() => {
if (current2ndLevelMenuObjid === null) return;
(async () => {
try {
const res = await reportApi.getReports({ page: 1, limit: 100 });
const res = await reportApi.getReportsByMenuObjid(current2ndLevelMenuObjid);
if (res.success) {
const items = res.data.items ?? [];
setReportList(items);
@@ -164,7 +168,7 @@ export default function QuoteManagementPage() {
}
} catch { /* 무시 */ }
})();
}, []);
}, [current2ndLevelMenuObjid]);
// ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ──
@@ -86,6 +86,7 @@ export default function EquipmentInfoPage() {
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
const [inspectionContinuous, setInspectionContinuous] = useState(false);
const [inspectionEditMode, setInspectionEditMode] = useState(false);
const [checkedInspectionIds, setCheckedInspectionIds] = useState<Set<string>>(new Set());
// 소모품 추가/수정 모달
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
@@ -93,6 +94,7 @@ export default function EquipmentInfoPage() {
const [consumableContinuous, setConsumableContinuous] = useState(false);
const [consumableEditMode, setConsumableEditMode] = useState(false);
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
const [checkedConsumableIds, setCheckedConsumableIds] = useState<Set<string>>(new Set());
// 점검항목 복사
const [copyModalOpen, setCopyModalOpen] = useState(false);
@@ -200,6 +202,7 @@ export default function EquipmentInfoPage() {
// 우측: 점검항목 조회
useEffect(() => {
setCheckedInspectionIds(new Set());
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
const fetchData = async () => {
setInspectionLoading(true);
@@ -217,6 +220,7 @@ export default function EquipmentInfoPage() {
// 우측: 소모품 조회
useEffect(() => {
setCheckedConsumableIds(new Set());
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
const fetchData = async () => {
setConsumableLoading(true);
@@ -292,6 +296,34 @@ export default function EquipmentInfoPage() {
} catch { toast.error("삭제 실패"); }
};
// 점검항목 삭제
const handleInspectionDelete = async () => {
const ids = Array.from(checkedInspectionIds);
if (ids.length === 0) { toast.error("삭제할 점검항목을 선택해주세요."); return; }
const ok = await confirm(`선택한 ${ids.length}건의 점검항목을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
toast.success("삭제되었습니다.");
setCheckedInspectionIds(new Set());
refreshRight();
} catch { toast.error("삭제 실패"); }
};
// 소모품 삭제
const handleConsumableDelete = async () => {
const ids = Array.from(checkedConsumableIds);
if (ids.length === 0) { toast.error("삭제할 소모품을 선택해주세요."); return; }
const ok = await confirm(`선택한 ${ids.length}건의 소모품을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${CONSUMABLE_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
toast.success("삭제되었습니다.");
setCheckedConsumableIds(new Set());
refreshRight();
} catch { toast.error("삭제 실패"); }
};
// 점검항목 추가
const handleInspectionSave = async () => {
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
@@ -546,15 +578,23 @@ export default function EquipmentInfoPage() {
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionEditMode(false); setInspectionModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={checkedInspectionIds.size === 0} onClick={handleInspectionDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
<Copy className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
{rightTab === "consumable" && (
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={checkedConsumableIds.size === 0} onClick={handleConsumableDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
</div>
</div>
@@ -633,6 +673,16 @@ export default function EquipmentInfoPage() {
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead
className="w-[40px] text-center cursor-pointer"
onClick={() => {
const allChecked = inspections.length > 0 && checkedInspectionIds.size === inspections.length;
if (allChecked) setCheckedInspectionIds(new Set());
else setCheckedInspectionIds(new Set(inspections.map((i) => i.id)));
}}
>
<Checkbox checked={inspections.length > 0 && checkedInspectionIds.size === inspections.length} />
</TableHead>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -660,6 +710,20 @@ export default function EquipmentInfoPage() {
setInspectionEditMode(true);
setInspectionModalOpen(true);
}}>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedInspectionIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
});
}}
onDoubleClick={(e) => e.stopPropagation()}
>
<Checkbox checked={checkedInspectionIds.has(item.id)} />
</TableCell>
<TableCell className="text-sm">{item.inspection_item || "-"}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
@@ -688,6 +752,16 @@ export default function EquipmentInfoPage() {
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead
className="w-[40px] text-center cursor-pointer"
onClick={() => {
const allChecked = consumables.length > 0 && checkedConsumableIds.size === consumables.length;
if (allChecked) setCheckedConsumableIds(new Set());
else setCheckedConsumableIds(new Set(consumables.map((i) => i.id)));
}}
>
<Checkbox checked={consumables.length > 0 && checkedConsumableIds.size === consumables.length} />
</TableHead>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -703,6 +777,20 @@ export default function EquipmentInfoPage() {
loadConsumableItems();
setConsumableModalOpen(true);
}}>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedConsumableIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
});
}}
onDoubleClick={(e) => e.stopPropagation()}
>
<Checkbox checked={checkedConsumableIds.has(item.id)} />
</TableCell>
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
@@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
@@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock";
const STOCK_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "spec", label: "규격" },
{ key: "warehouse_code", label: "창고" },
{ key: "location_code", label: "위치" },
{ key: "current_qty", label: "현재수량", align: "right" as const },
@@ -87,6 +89,8 @@ const getStatusVariant = (
return "destructive";
case "과잉":
return "secondary";
case "미등록":
return "outline";
default:
return "outline";
}
@@ -119,6 +123,15 @@ export default function InventoryStatusPage() {
const [stockLoading, setStockLoading] = useState(false);
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
// 재고 없는 품목 표시 여부
const [showMissingItems, setShowMissingItems] = useState(false);
// 창고 목록 (조정 모달에서 사용)
const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]);
// 선택된 창고의 위치 목록 (조정 모달에서 사용)
const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]);
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
@@ -132,7 +145,9 @@ export default function InventoryStatusPage() {
adjust_type: string;
adjust_qty: string;
reason: string;
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
warehouse_code: string;
location_code: string;
}>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
const [adjustSaving, setAdjustSaving] = useState(false);
// 카테고리 옵션
@@ -201,8 +216,9 @@ export default function InventoryStatusPage() {
const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || [];
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || [];
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "" }]));
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "" }]));
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: w.warehouse_name || w.warehouse_code })));
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
@@ -213,19 +229,51 @@ export default function InventoryStatusPage() {
return {
...r,
item_name: itemInfo?.name || "",
spec: itemInfo?.spec || "",
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
status: resolve("status", r.status),
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
};
});
setStockItems(data);
// 재고 없는 품목 표시: inventory_stock에 없는 item_info 품목을 미등록 가상 행으로 추가
if (showMissingItems) {
const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean));
const missingRows = items
.filter((i: any) => {
const code = i.item_number || i.item_code;
return code && !existingCodes.has(code);
})
.map((i: any) => {
const code = i.item_number || i.item_code;
const rawUnit = i.inventory_unit || "";
return {
id: `missing-${code}`,
item_code: code,
item_name: i.item_name || "",
spec: i.size || "",
warehouse_code: "",
warehouse_name: "",
location_code: "",
current_qty: "0",
safety_qty: "",
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
status: "미등록",
_isLow: false,
_isMissing: true,
};
});
setStockItems([...data, ...missingRows]);
} else {
setStockItems(data);
}
} catch {
toast.error("재고 목록을 불러오지 못했어요");
} finally {
setStockLoading(false);
}
}, [categoryOptions, searchFilters]);
}, [categoryOptions, searchFilters, showMissingItems]);
useEffect(() => {
fetchStock();
@@ -279,6 +327,36 @@ export default function InventoryStatusPage() {
fetchHistory();
}, [fetchHistory]);
// 창고 선택 시 해당 창고의 위치 목록 조회 (조정 모달용)
useEffect(() => {
const whCode = adjustForm.warehouse_code;
if (!whCode) {
setLocationList([]);
return;
}
(async () => {
try {
const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, {
page: 1,
size: 0,
dataFilter: {
enabled: true,
filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }],
},
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setLocationList(
rows
.filter((r: any) => r.location_code)
.map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code }))
);
} catch {
setLocationList([]);
}
})();
}, [adjustForm.warehouse_code]);
// 재고 조정 저장
const handleAdjustSave = async () => {
if (!selectedStock) return;
@@ -291,6 +369,20 @@ export default function InventoryStatusPage() {
toast.error("조정 사유를 입력해주세요");
return;
}
const isMissing = !!selectedStock._isMissing;
const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || "");
const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || "");
if (isMissing && !targetWhCode) {
toast.error("창고를 선택해주세요");
return;
}
if (isMissing && adjustForm.adjust_type === "감소") {
toast.error("미등록 품목은 감소 조정이 불가해요");
return;
}
setAdjustSaving(true);
try {
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
@@ -301,8 +393,8 @@ export default function InventoryStatusPage() {
{
id: crypto.randomUUID(),
item_code: selectedStock.item_code,
warehouse_code: selectedStock.warehouse_code || "",
location_code: selectedStock.location_code || "",
warehouse_code: targetWhCode,
location_code: targetLocCode,
transaction_type: "조정",
transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
quantity: String(changeQty),
@@ -311,17 +403,33 @@ export default function InventoryStatusPage() {
}
);
await apiClient.put(
`/table-management/tables/${STOCK_TABLE}/edit`,
{
originalData: { id: selectedStock.id },
updatedData: { current_qty: afterQty },
}
);
if (isMissing) {
await apiClient.post(
`/table-management/tables/${STOCK_TABLE}/add`,
{
id: crypto.randomUUID(),
item_code: selectedStock.item_code,
warehouse_code: targetWhCode,
location_code: targetLocCode,
current_qty: String(afterQty),
safety_qty: "0",
last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
}
);
} else {
await apiClient.put(
`/table-management/tables/${STOCK_TABLE}/edit`,
{
originalData: { id: selectedStock.id },
updatedData: { current_qty: afterQty },
}
);
}
toast.success("재고가 조정되었어요");
setAdjustModalOpen(false);
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
setSelectedStockId(null);
fetchStock();
} catch {
toast.error("재고 조정에 실패했어요");
@@ -385,6 +493,7 @@ export default function InventoryStatusPage() {
stockItems.map((r) => ({
품목코드: r.item_code,
품명: r.item_name,
규격: r.spec || "",
창고: r.warehouse_name || r.warehouse_code,
위치: r.location_code,
현재수량: r.current_qty,
@@ -438,6 +547,13 @@ export default function InventoryStatusPage() {
{stockItems.length}
</Badge>
</div>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox
checked={showMissingItems}
onCheckedChange={(v) => setShowMissingItems(!!v)}
/>
<span> </span>
</label>
</div>
<EDataTable
@@ -513,6 +629,8 @@ export default function InventoryStatusPage() {
adjust_type: "증가",
adjust_qty: "",
reason: "",
warehouse_code: selectedStock._isMissing ? "" : (selectedStock.warehouse_code || ""),
location_code: selectedStock._isMissing ? "" : (selectedStock.location_code || ""),
});
setAdjustModalOpen(true);
}}
@@ -672,6 +790,68 @@ export default function InventoryStatusPage() {
</DialogHeader>
<div className="grid gap-4 py-2">
{selectedStock?._isMissing && (
<>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Select
value={adjustForm.warehouse_code}
onValueChange={(v) =>
setAdjustForm((prev) => ({
...prev,
warehouse_code: v,
location_code: "",
}))
}
>
<SelectTrigger>
<SelectValue placeholder="창고를 선택해주세요" />
</SelectTrigger>
<SelectContent>
{warehouseList.map((w) => (
<SelectItem key={w.code} value={w.code}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
</Label>
<Select
value={adjustForm.location_code}
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, location_code: v }))
}
disabled={!adjustForm.warehouse_code || locationList.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
!adjustForm.warehouse_code
? "창고를 먼저 선택하세요"
: locationList.length === 0
? "등록된 위치가 없어요"
: "위치 선택 (선택 사항)"
}
/>
</SelectTrigger>
<SelectContent>
{locationList.map((l) => (
<SelectItem key={l.code} value={l.code}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
@@ -681,6 +861,7 @@ export default function InventoryStatusPage() {
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
}
disabled={!!selectedStock?._isMissing}
>
<SelectTrigger>
<SelectValue placeholder="조정 유형 선택" />
@@ -690,6 +871,11 @@ export default function InventoryStatusPage() {
<SelectItem value="감소"> ( )</SelectItem>
</SelectContent>
</Select>
{selectedStock?._isMissing && (
<p className="text-[10px] text-muted-foreground">
( )
</p>
)}
</div>
<div className="grid gap-1.5">
@@ -528,9 +528,9 @@ export default function PackagingPage() {
{/* 4. 콘텐츠 영역 */}
{activeTab === "packing" ? (
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
{/* 포장재 목록 테이블 */}
<div className={cn("overflow-auto", selectedPkg ? "flex-[0_0_50%] border-b" : "flex-1")}>
<div className="flex-[0_0_50%] overflow-auto border-r">
<EDataTable
columns={ts.visibleColumns.map((col): EDataTableColumn<PkgUnit> => {
const renderMap: Record<string, Partial<EDataTableColumn<PkgUnit>>> = {
@@ -570,8 +570,8 @@ export default function PackagingPage() {
</div>
{/* 매칭 품목 서브패널 */}
{selectedPkg && (
<>
{selectedPkg ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
@@ -635,14 +635,21 @@ export default function PackagingPage() {
</Table>
)}
</div>
</>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<Package className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)}
</div>
) : (
/* 적재함 관리 탭 */
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
{/* 적재함 목록 테이블 */}
<div className={cn("overflow-auto", selectedLoading ? "flex-[0_0_50%] border-b" : "flex-1")}>
<div className="flex-[0_0_50%] overflow-auto border-r">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
@@ -709,8 +716,8 @@ export default function PackagingPage() {
</div>
{/* 포장구성 서브패널 */}
{selectedLoading && (
<>
{selectedLoading ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
@@ -774,7 +781,14 @@ export default function PackagingPage() {
</Table>
)}
</div>
</>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<Box className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)}
</div>
)}
@@ -249,6 +249,8 @@ interface SelectedSourceItem {
total_amount: number;
source_table: string;
source_id: string;
detail_id?: string;
header_id?: string;
}
export default function ReceivingPage() {
@@ -583,7 +585,7 @@ export default function ReceivingPage() {
const first = grouped[0] || row;
setEditMode(true);
setEditItemIds(grouped.map((g) => g.id));
setEditItemIds(grouped.map((g, idx) => (g as any).detail_id || `${g.id}__${idx}`));
setModalInboundNo(inNo);
setModalInboundType(first.inbound_type || "구매입고");
setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : "");
@@ -593,8 +595,10 @@ export default function ReceivingPage() {
setModalManager((first as any).manager || "");
setModalMemo(first.memo || "");
setSelectedItems(
grouped.map((g) => ({
key: g.id,
grouped.map((g, idx) => ({
key: (g as any).detail_id || `${g.id}__${idx}`,
detail_id: (g as any).detail_id || undefined,
header_id: g.id,
inbound_type: g.inbound_type || "",
reference_number: g.reference_number || "",
supplier_code: (g as any).supplier_code || "",
@@ -781,7 +785,7 @@ export default function ReceivingPage() {
await Promise.all([
...toDelete.map((id) => deleteReceiving(id)),
...toUpdate.map((item) =>
updateReceiving(item.key, {
updateReceiving(item.header_id || item.key, {
inbound_date: modalInboundDate,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
@@ -789,6 +793,7 @@ export default function ReceivingPage() {
warehouse_code: modalWarehouse || undefined,
location_code: modalLocation || undefined,
memo: modalMemo || undefined,
detail_id: item.detail_id,
} as any)
),
...(toCreate.length > 0
@@ -1772,7 +1772,7 @@ export default function BomManagementPage() {
{/* 소요량 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? (bomHeader?.base_qty || "1") : (node.quantity || "-")}</td>
{/* 단위 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? "-" : (node.unit || "-")}</td>
<td className="px-3 py-2 text-center">{isVirtualRoot ? (categoryOptions["inventory_unit"]?.find((o) => o.code === bomHeader?.unit)?.label || bomHeader?.unit || "-") : (node.unit || "-")}</td>
{/* 공정구분 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2">{isVirtualRoot ? "-" : (node.process_type || "-")}</td>
{/* 규격 */}
@@ -586,7 +586,7 @@ export function WorkStandardEditModal({
<thead className="sticky top-0 bg-muted/50">
<tr className="border-b">
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground"></th>
<th className="w-20 px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="w-24 px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="w-14 px-2 py-2 text-center font-medium text-muted-foreground"></th>
<th className="w-16 px-2 py-2 text-center font-medium text-muted-foreground"></th>
@@ -597,7 +597,7 @@ export function WorkStandardEditModal({
<tr key={detail.id || idx} className="border-b transition-colors hover:bg-muted/30">
<td className="px-2 py-1.5 text-center text-muted-foreground">{idx + 1}</td>
<td className="px-2 py-1.5">
<Badge variant="outline" className="text-[10px] font-normal">
<Badge variant="outline" className="text-[10px] font-normal whitespace-nowrap">
{getDetailTypeLabel(detail.detail_type || "checklist")}
</Badge>
</td>
@@ -30,6 +30,7 @@ import { toast } from "sonner";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const MASTER_TABLE = "purchase_order_mng";
@@ -555,6 +556,48 @@ export default function PurchaseOrderPage() {
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
// 공급업체 선택 시 supplier_item_mapping으로 매핑 id 정규화 → 서버 필터 적용
const supplierCode = masterForm.supplier_code;
if (supplierCode) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, {
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierCode }] },
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
if (rawIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const uuidIds = rawIds.filter((v) => uuidRegex.test(v));
const codeIds = rawIds.filter((v) => !uuidRegex.test(v));
let convertedIds: string[] = [];
if (codeIds.length > 0) {
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: codeIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
autoFilter: true,
});
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
}
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
if (finalIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
filters.push({ columnName: "id", operator: "in", value: finalIds });
} catch { /* skip */ }
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
@@ -984,7 +1027,8 @@ export default function PurchaseOrderPage() {
<div className="grid grid-cols-2 gap-3.5">
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide"></Label>
<Select
<SmartSelect
options={categoryOptions["supplier_code"] || []}
value={masterForm.supplier_code || ""}
onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v);
@@ -992,15 +1036,9 @@ export default function PurchaseOrderPage() {
setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name }));
recalcPrices(masterForm.price_mode || "", v);
}}
placeholder="공급업체 선택"
disabled={isReadOnly}
>
<SelectTrigger className="h-9"><SelectValue placeholder="공급업체 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
</div>
@@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
const DETAIL_TABLE = "sales_order_detail";
@@ -1481,17 +1482,12 @@ export default function SalesOrderPage() {
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select
<SmartSelect
options={categoryOptions["partner_id"] || []}
value={masterForm.partner_id || ""}
onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); recalcPrices(masterForm.price_mode || "", v); }}
>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
placeholder="거래처 선택"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
@@ -16,6 +16,7 @@ import {
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { reportApi } from "@/lib/api/reportApi";
import { useCurrent2ndLevelMenuObjid } from "@/hooks/useCurrent2ndLevelMenuObjid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
@@ -153,10 +154,13 @@ export default function QuoteManagementPage() {
useEffect(() => { fetchQuotes(); }, [fetchQuotes]);
const current2ndLevelMenuObjid = useCurrent2ndLevelMenuObjid();
useEffect(() => {
if (current2ndLevelMenuObjid === null) return;
(async () => {
try {
const res = await reportApi.getReports({ page: 1, limit: 100 });
const res = await reportApi.getReportsByMenuObjid(current2ndLevelMenuObjid);
if (res.success) {
const items = res.data.items ?? [];
setReportList(items);
@@ -164,7 +168,7 @@ export default function QuoteManagementPage() {
}
} catch { /* 무시 */ }
})();
}, []);
}, [current2ndLevelMenuObjid]);
// ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ──
@@ -86,6 +86,7 @@ export default function EquipmentInfoPage() {
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
const [inspectionContinuous, setInspectionContinuous] = useState(false);
const [inspectionEditMode, setInspectionEditMode] = useState(false);
const [checkedInspectionIds, setCheckedInspectionIds] = useState<Set<string>>(new Set());
// 소모품 추가/수정 모달
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
@@ -93,6 +94,7 @@ export default function EquipmentInfoPage() {
const [consumableContinuous, setConsumableContinuous] = useState(false);
const [consumableEditMode, setConsumableEditMode] = useState(false);
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
const [checkedConsumableIds, setCheckedConsumableIds] = useState<Set<string>>(new Set());
// 점검항목 복사
const [copyModalOpen, setCopyModalOpen] = useState(false);
@@ -200,6 +202,7 @@ export default function EquipmentInfoPage() {
// 우측: 점검항목 조회
useEffect(() => {
setCheckedInspectionIds(new Set());
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
const fetchData = async () => {
setInspectionLoading(true);
@@ -217,6 +220,7 @@ export default function EquipmentInfoPage() {
// 우측: 소모품 조회
useEffect(() => {
setCheckedConsumableIds(new Set());
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
const fetchData = async () => {
setConsumableLoading(true);
@@ -292,6 +296,34 @@ export default function EquipmentInfoPage() {
} catch { toast.error("삭제 실패"); }
};
// 점검항목 삭제
const handleInspectionDelete = async () => {
const ids = Array.from(checkedInspectionIds);
if (ids.length === 0) { toast.error("삭제할 점검항목을 선택해주세요."); return; }
const ok = await confirm(`선택한 ${ids.length}건의 점검항목을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
toast.success("삭제되었습니다.");
setCheckedInspectionIds(new Set());
refreshRight();
} catch { toast.error("삭제 실패"); }
};
// 소모품 삭제
const handleConsumableDelete = async () => {
const ids = Array.from(checkedConsumableIds);
if (ids.length === 0) { toast.error("삭제할 소모품을 선택해주세요."); return; }
const ok = await confirm(`선택한 ${ids.length}건의 소모품을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${CONSUMABLE_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
toast.success("삭제되었습니다.");
setCheckedConsumableIds(new Set());
refreshRight();
} catch { toast.error("삭제 실패"); }
};
// 점검항목 추가
const handleInspectionSave = async () => {
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
@@ -546,15 +578,23 @@ export default function EquipmentInfoPage() {
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionEditMode(false); setInspectionModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={checkedInspectionIds.size === 0} onClick={handleInspectionDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
<Copy className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
{rightTab === "consumable" && (
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={checkedConsumableIds.size === 0} onClick={handleConsumableDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
</div>
</div>
@@ -633,6 +673,16 @@ export default function EquipmentInfoPage() {
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead
className="w-[40px] text-center cursor-pointer"
onClick={() => {
const allChecked = inspections.length > 0 && checkedInspectionIds.size === inspections.length;
if (allChecked) setCheckedInspectionIds(new Set());
else setCheckedInspectionIds(new Set(inspections.map((i) => i.id)));
}}
>
<Checkbox checked={inspections.length > 0 && checkedInspectionIds.size === inspections.length} />
</TableHead>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -660,6 +710,20 @@ export default function EquipmentInfoPage() {
setInspectionEditMode(true);
setInspectionModalOpen(true);
}}>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedInspectionIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
});
}}
onDoubleClick={(e) => e.stopPropagation()}
>
<Checkbox checked={checkedInspectionIds.has(item.id)} />
</TableCell>
<TableCell className="text-sm">{item.inspection_item || "-"}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
@@ -688,6 +752,16 @@ export default function EquipmentInfoPage() {
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead
className="w-[40px] text-center cursor-pointer"
onClick={() => {
const allChecked = consumables.length > 0 && checkedConsumableIds.size === consumables.length;
if (allChecked) setCheckedConsumableIds(new Set());
else setCheckedConsumableIds(new Set(consumables.map((i) => i.id)));
}}
>
<Checkbox checked={consumables.length > 0 && checkedConsumableIds.size === consumables.length} />
</TableHead>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -703,6 +777,20 @@ export default function EquipmentInfoPage() {
loadConsumableItems();
setConsumableModalOpen(true);
}}>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedConsumableIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
});
}}
onDoubleClick={(e) => e.stopPropagation()}
>
<Checkbox checked={checkedConsumableIds.has(item.id)} />
</TableCell>
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
@@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
@@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock";
const STOCK_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "spec", label: "규격" },
{ key: "warehouse_code", label: "창고" },
{ key: "location_code", label: "위치" },
{ key: "current_qty", label: "현재수량", align: "right" as const },
@@ -87,6 +89,8 @@ const getStatusVariant = (
return "destructive";
case "과잉":
return "secondary";
case "미등록":
return "outline";
default:
return "outline";
}
@@ -119,6 +123,15 @@ export default function InventoryStatusPage() {
const [stockLoading, setStockLoading] = useState(false);
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
// 재고 없는 품목 표시 여부
const [showMissingItems, setShowMissingItems] = useState(false);
// 창고 목록 (조정 모달에서 사용)
const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]);
// 선택된 창고의 위치 목록 (조정 모달에서 사용)
const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]);
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
@@ -132,7 +145,9 @@ export default function InventoryStatusPage() {
adjust_type: string;
adjust_qty: string;
reason: string;
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
warehouse_code: string;
location_code: string;
}>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
const [adjustSaving, setAdjustSaving] = useState(false);
// 카테고리 옵션
@@ -201,8 +216,9 @@ export default function InventoryStatusPage() {
const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || [];
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || [];
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "" }]));
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "" }]));
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: w.warehouse_name || w.warehouse_code })));
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
@@ -213,19 +229,50 @@ export default function InventoryStatusPage() {
return {
...r,
item_name: itemInfo?.name || "",
spec: itemInfo?.spec || "",
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
status: resolve("status", r.status),
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
};
});
setStockItems(data);
if (showMissingItems) {
const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean));
const missingRows = items
.filter((i: any) => {
const code = i.item_number || i.item_code;
return code && !existingCodes.has(code);
})
.map((i: any) => {
const code = i.item_number || i.item_code;
const rawUnit = i.inventory_unit || "";
return {
id: `missing-${code}`,
item_code: code,
item_name: i.item_name || "",
spec: i.size || "",
warehouse_code: "",
warehouse_name: "",
location_code: "",
current_qty: "0",
safety_qty: "",
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
status: "미등록",
_isLow: false,
_isMissing: true,
};
});
setStockItems([...data, ...missingRows]);
} else {
setStockItems(data);
}
} catch {
toast.error("재고 목록을 불러오지 못했어요");
} finally {
setStockLoading(false);
}
}, [categoryOptions, searchFilters]);
}, [categoryOptions, searchFilters, showMissingItems]);
useEffect(() => {
fetchStock();
@@ -279,6 +326,35 @@ export default function InventoryStatusPage() {
fetchHistory();
}, [fetchHistory]);
useEffect(() => {
const whCode = adjustForm.warehouse_code;
if (!whCode) {
setLocationList([]);
return;
}
(async () => {
try {
const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, {
page: 1,
size: 0,
dataFilter: {
enabled: true,
filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }],
},
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setLocationList(
rows
.filter((r: any) => r.location_code)
.map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code }))
);
} catch {
setLocationList([]);
}
})();
}, [adjustForm.warehouse_code]);
// 재고 조정 저장
const handleAdjustSave = async () => {
if (!selectedStock) return;
@@ -291,6 +367,20 @@ export default function InventoryStatusPage() {
toast.error("조정 사유를 입력해주세요");
return;
}
const isMissing = !!selectedStock._isMissing;
const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || "");
const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || "");
if (isMissing && !targetWhCode) {
toast.error("창고를 선택해주세요");
return;
}
if (isMissing && adjustForm.adjust_type === "감소") {
toast.error("미등록 품목은 감소 조정이 불가해요");
return;
}
setAdjustSaving(true);
try {
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
@@ -301,8 +391,8 @@ export default function InventoryStatusPage() {
{
id: crypto.randomUUID(),
item_code: selectedStock.item_code,
warehouse_code: selectedStock.warehouse_code || "",
location_code: selectedStock.location_code || "",
warehouse_code: targetWhCode,
location_code: targetLocCode,
transaction_type: "조정",
transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
quantity: String(changeQty),
@@ -311,17 +401,33 @@ export default function InventoryStatusPage() {
}
);
await apiClient.put(
`/table-management/tables/${STOCK_TABLE}/edit`,
{
originalData: { id: selectedStock.id },
updatedData: { current_qty: afterQty },
}
);
if (isMissing) {
await apiClient.post(
`/table-management/tables/${STOCK_TABLE}/add`,
{
id: crypto.randomUUID(),
item_code: selectedStock.item_code,
warehouse_code: targetWhCode,
location_code: targetLocCode,
current_qty: String(afterQty),
safety_qty: "0",
last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
}
);
} else {
await apiClient.put(
`/table-management/tables/${STOCK_TABLE}/edit`,
{
originalData: { id: selectedStock.id },
updatedData: { current_qty: afterQty },
}
);
}
toast.success("재고가 조정되었어요");
setAdjustModalOpen(false);
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
setSelectedStockId(null);
fetchStock();
} catch {
toast.error("재고 조정에 실패했어요");
@@ -385,6 +491,7 @@ export default function InventoryStatusPage() {
stockItems.map((r) => ({
품목코드: r.item_code,
품명: r.item_name,
규격: r.spec || "",
창고: r.warehouse_name || r.warehouse_code,
위치: r.location_code,
현재수량: r.current_qty,
@@ -438,6 +545,13 @@ export default function InventoryStatusPage() {
{stockItems.length}
</Badge>
</div>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox
checked={showMissingItems}
onCheckedChange={(v) => setShowMissingItems(!!v)}
/>
<span> </span>
</label>
</div>
<EDataTable
@@ -513,6 +627,8 @@ export default function InventoryStatusPage() {
adjust_type: "증가",
adjust_qty: "",
reason: "",
warehouse_code: selectedStock._isMissing ? "" : (selectedStock.warehouse_code || ""),
location_code: selectedStock._isMissing ? "" : (selectedStock.location_code || ""),
});
setAdjustModalOpen(true);
}}
@@ -672,6 +788,68 @@ export default function InventoryStatusPage() {
</DialogHeader>
<div className="grid gap-4 py-2">
{selectedStock?._isMissing && (
<>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Select
value={adjustForm.warehouse_code}
onValueChange={(v) =>
setAdjustForm((prev) => ({
...prev,
warehouse_code: v,
location_code: "",
}))
}
>
<SelectTrigger>
<SelectValue placeholder="창고를 선택해주세요" />
</SelectTrigger>
<SelectContent>
{warehouseList.map((w) => (
<SelectItem key={w.code} value={w.code}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
</Label>
<Select
value={adjustForm.location_code}
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, location_code: v }))
}
disabled={!adjustForm.warehouse_code || locationList.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
!adjustForm.warehouse_code
? "창고를 먼저 선택하세요"
: locationList.length === 0
? "등록된 위치가 없어요"
: "위치 선택 (선택 사항)"
}
/>
</SelectTrigger>
<SelectContent>
{locationList.map((l) => (
<SelectItem key={l.code} value={l.code}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
@@ -681,6 +859,7 @@ export default function InventoryStatusPage() {
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
}
disabled={!!selectedStock?._isMissing}
>
<SelectTrigger>
<SelectValue placeholder="조정 유형 선택" />
@@ -690,6 +869,11 @@ export default function InventoryStatusPage() {
<SelectItem value="감소"> ( )</SelectItem>
</SelectContent>
</Select>
{selectedStock?._isMissing && (
<p className="text-[10px] text-muted-foreground">
( )
</p>
)}
</div>
<div className="grid gap-1.5">
@@ -528,9 +528,9 @@ export default function PackagingPage() {
{/* 4. 콘텐츠 영역 */}
{activeTab === "packing" ? (
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
{/* 포장재 목록 테이블 */}
<div className={cn("overflow-auto", selectedPkg ? "flex-[0_0_50%] border-b" : "flex-1")}>
<div className="flex-[0_0_50%] overflow-auto border-r">
<EDataTable
columns={ts.visibleColumns.map((col): EDataTableColumn<PkgUnit> => {
const renderMap: Record<string, Partial<EDataTableColumn<PkgUnit>>> = {
@@ -570,8 +570,8 @@ export default function PackagingPage() {
</div>
{/* 매칭 품목 서브패널 */}
{selectedPkg && (
<>
{selectedPkg ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
@@ -635,14 +635,21 @@ export default function PackagingPage() {
</Table>
)}
</div>
</>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<Package className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)}
</div>
) : (
/* 적재함 관리 탭 */
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
{/* 적재함 목록 테이블 */}
<div className={cn("overflow-auto", selectedLoading ? "flex-[0_0_50%] border-b" : "flex-1")}>
<div className="flex-[0_0_50%] overflow-auto border-r">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
@@ -709,8 +716,8 @@ export default function PackagingPage() {
</div>
{/* 포장구성 서브패널 */}
{selectedLoading && (
<>
{selectedLoading ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
@@ -774,7 +781,14 @@ export default function PackagingPage() {
</Table>
)}
</div>
</>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<Box className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)}
</div>
)}
@@ -250,6 +250,8 @@ interface SelectedSourceItem {
total_amount: number;
source_table: string;
source_id: string;
detail_id?: string;
header_id?: string;
}
export default function ReceivingPage() {
@@ -584,7 +586,7 @@ export default function ReceivingPage() {
const first = grouped[0] || row;
setEditMode(true);
setEditItemIds(grouped.map((g) => g.id));
setEditItemIds(grouped.map((g, idx) => (g as any).detail_id || `${g.id}__${idx}`));
setModalInboundNo(inNo);
setModalInboundType(first.inbound_type || "구매입고");
setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : "");
@@ -594,8 +596,10 @@ export default function ReceivingPage() {
setModalManager((first as any).manager || "");
setModalMemo(first.memo || "");
setSelectedItems(
grouped.map((g) => ({
key: g.id,
grouped.map((g, idx) => ({
key: (g as any).detail_id || `${g.id}__${idx}`,
detail_id: (g as any).detail_id || undefined,
header_id: g.id,
inbound_type: (g as any).detail_inbound_type || g.inbound_type || "",
reference_number: g.reference_number || "",
supplier_code: (g as any).supplier_code || "",
@@ -782,7 +786,7 @@ export default function ReceivingPage() {
await Promise.all([
...toDelete.map((id) => deleteReceiving(id)),
...toUpdate.map((item) =>
updateReceiving(item.key, {
updateReceiving(item.header_id || item.key, {
inbound_date: modalInboundDate,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
@@ -790,6 +794,7 @@ export default function ReceivingPage() {
warehouse_code: modalWarehouse || undefined,
location_code: modalLocation || undefined,
memo: modalMemo || undefined,
detail_id: item.detail_id,
} as any)
),
...(toCreate.length > 0
@@ -297,7 +297,11 @@ export default function BomManagementPage() {
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
const [itemSearchLoading, setItemSearchLoading] = useState(false);
const [itemSearchTarget, setItemSearchTarget] = useState<"master" | "detail">("master");
const [itemSearchTarget, setItemSearchTarget] = useState<"master" | "detail" | "tree">("master");
// 서버사이드 페이지네이션
const [itemSearchPage, setItemSearchPage] = useState(1);
const [itemSearchPageSize] = useState(20);
const [itemSearchTotal, setItemSearchTotal] = useState(0);
// 엑셀 업로드
const [showExcelUpload, setShowExcelUpload] = useState(false);
@@ -780,11 +784,14 @@ export default function BomManagementPage() {
setTreeHasChanges(true);
};
// 하위 품목 추가 시작
// 하위 품목 추가 시작 (BOM 등록 품목검색 모달 재사용)
const handleTreeAddChild = (parentId: string | null) => {
setAddTargetParentId(parentId);
setTreeItemSearchOpen(true);
searchItems("");
setItemSearchTarget("tree");
setItemSearchKeyword("");
setItemSearchPage(1);
setShowItemSearchModal(true);
searchItems(1);
};
// 트리 품목 선택 완료 (트리에 추가)
@@ -1191,33 +1198,34 @@ export default function BomManagementPage() {
}
};
// ─── 품목 검색 ───────────────────────────────
const searchItems = async (keyword?: string) => {
// ─── 품목 검색 (서버 페이지네이션) ───────────────────────────────
const searchItems = async (pageOverride?: number, keyword?: string) => {
const kw = (keyword ?? itemSearchKeyword).trim();
const pageCandidate = typeof pageOverride === "number" && Number.isFinite(pageOverride) && pageOverride > 0
? pageOverride
: itemSearchPage;
const page = Number.isFinite(pageCandidate) && pageCandidate > 0 ? pageCandidate : 1;
setItemSearchLoading(true);
try {
const filters: any[] = [];
if (kw) {
filters.push({ columnName: "item_name", operator: "contains", value: kw });
}
if (kw) filters.push({ columnName: "item_name", operator: "contains", value: kw });
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1,
size: 50,
page, size: itemSearchPageSize,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
let rows = res.data?.data?.data || res.data?.data?.rows || [];
let total = res.data?.data?.total || 0;
// 키워드가 있고 품명으로 못 찾으면 품목코드로 재시도
if (kw && rows.length === 0) {
const res2 = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 50,
page, size: itemSearchPageSize,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "contains", value: kw }] },
autoFilter: true,
});
rows = res2.data?.data?.data || res2.data?.data?.rows || [];
total = res2.data?.data?.total || 0;
}
// 카테고리 코드 → 라벨 변환 (division + inventory_unit)
const resolved = rows.map((r: any) => {
const out = { ...r };
if (out.division) {
@@ -1229,9 +1237,16 @@ export default function BomManagementPage() {
if (out.inventory_unit) {
out.inventory_unit = categoryOptions["inventory_unit"]?.find((o) => o.code === out.inventory_unit)?.label || out.inventory_unit;
}
const w = out.width, h = out.height, t = out.thickness;
if (w || h || t) {
out._spec = [w || "-", h || "-", t || "-"].join(" x ");
} else {
out._spec = out.size || "";
}
return out;
});
setItemSearchResults(resolved);
setItemSearchTotal(total);
} catch {
toast.error("품목 검색에 실패했어요");
} finally {
@@ -1239,6 +1254,12 @@ export default function BomManagementPage() {
}
};
// 페이지 변경 시 재조회
useEffect(() => {
if (showItemSearchModal) searchItems();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemSearchPage]);
const resolveUnit = (code: string) => {
if (!code) return "";
return categoryOptions["inventory_unit"]?.find((o) => o.code === code)?.label || code;
@@ -1246,6 +1267,13 @@ export default function BomManagementPage() {
const selectItem = (item: any) => {
const unitLabel = resolveUnit(item.inventory_unit);
if (itemSearchTarget === "tree") {
handleTreeItemSelect(item);
setShowItemSearchModal(false);
setItemSearchKeyword("");
setItemSearchResults([]);
return;
}
if (itemSearchTarget === "master") {
setMasterForm((prev) => ({
...prev,
@@ -1772,7 +1800,7 @@ export default function BomManagementPage() {
{/* 소요량 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? (bomHeader?.base_qty || "1") : (node.quantity || "-")}</td>
{/* 단위 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? "-" : (node.unit || "-")}</td>
<td className="px-3 py-2 text-center">{isVirtualRoot ? (categoryOptions["inventory_unit"]?.find((o) => o.code === bomHeader?.unit)?.label || bomHeader?.unit || "-") : (node.unit || "-")}</td>
{/* 공정구분 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2">{isVirtualRoot ? "-" : (node.process_type || "-")}</td>
{/* 규격 */}
@@ -1972,7 +2000,7 @@ export default function BomManagementPage() {
setItemSearchTarget("master");
setItemSearchKeyword("");
setShowItemSearchModal(true);
searchItems("");
searchItems();
}}
/>
<Button
@@ -1983,7 +2011,7 @@ export default function BomManagementPage() {
setItemSearchTarget("master");
setItemSearchKeyword("");
setShowItemSearchModal(true);
searchItems("");
searchItems();
}}
>
<Search className="w-3.5 h-3.5" />
@@ -2084,7 +2112,7 @@ export default function BomManagementPage() {
setItemSearchTarget("detail");
setItemSearchKeyword("");
setShowItemSearchModal(true);
searchItems("");
searchItems();
}}
>
<Plus className="w-3.5 h-3.5 mr-1" />
@@ -2186,8 +2214,8 @@ export default function BomManagementPage() {
</Dialog>
{/* ─── 품목 검색 모달 ──────────────────────── */}
<Dialog open={showItemSearchModal} onOpenChange={setShowItemSearchModal}>
<DialogContent className="max-w-lg">
<Dialog open={showItemSearchModal} onOpenChange={(o) => { if (!o) { setItemSearchKeyword(""); setItemSearchResults([]); setItemSearchPage(1); setItemSearchTotal(0); } setShowItemSearchModal(o); }}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> </DialogDescription>
@@ -2198,13 +2226,13 @@ export default function BomManagementPage() {
placeholder="품목코드 또는 품명 입력"
value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchItems()}
onKeyDown={(e) => { if (e.key === "Enter") { setItemSearchPage(1); searchItems(1); } }}
/>
<Button size="sm" className="h-9" onClick={() => searchItems()} disabled={itemSearchLoading}>
<Button size="sm" className="h-9" onClick={() => { setItemSearchPage(1); searchItems(1); }} disabled={itemSearchLoading}>
{itemSearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
</Button>
</div>
<div className="max-h-[300px] overflow-auto border rounded-lg">
<div className="max-h-[320px] overflow-auto border rounded-lg">
{itemSearchResults.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-1">
<Search className="w-6 h-6 text-muted-foreground/40" />
@@ -2216,20 +2244,35 @@ export default function BomManagementPage() {
return (
<div
key={item.id}
className="flex items-center gap-3 px-3 py-2 border-b last:border-b-0 hover:bg-accent cursor-pointer transition-colors"
className="flex items-center gap-2 px-3 py-2 border-b last:border-b-0 hover:bg-accent cursor-pointer transition-colors"
onClick={() => selectItem(item)}
>
<span className={cn("text-[10px] font-semibold px-1.5 py-0.5 rounded shrink-0", badge.className)}>
{badge.label}
</span>
<span className="font-mono text-[11px] text-muted-foreground">{item.item_number}</span>
<span className="text-xs">{item.item_name}</span>
<span className="ml-auto text-[11px] text-muted-foreground">{item.inventory_unit || ""}</span>
<span className="font-mono text-[11px] text-muted-foreground shrink-0">{item.item_number}</span>
<span className="text-xs truncate flex-1">{item.item_name}</span>
{item._spec && (
<span className="text-[11px] text-muted-foreground font-mono shrink-0" title={item._spec}>[{item._spec}]</span>
)}
<span className="text-[11px] text-muted-foreground shrink-0">{item.inventory_unit || ""}</span>
</div>
);
})
)}
</div>
{/* 페이지네이션 */}
{itemSearchTotal > 0 && (
<div className="flex items-center justify-between text-xs text-muted-foreground px-1 pt-2">
<span> <b className="text-foreground">{itemSearchTotal.toLocaleString()}</b> · {itemSearchPage}/{Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize))} </span>
<div className="flex items-center gap-1">
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage(1)}>«</Button>
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage((p) => Math.max(1, p - 1))}></Button>
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage((p) => p + 1)}></Button>
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage(Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize)))}>»</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
@@ -586,7 +586,7 @@ export function WorkStandardEditModal({
<thead className="sticky top-0 bg-muted/50">
<tr className="border-b">
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground"></th>
<th className="w-20 px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="w-24 px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="w-14 px-2 py-2 text-center font-medium text-muted-foreground"></th>
<th className="w-16 px-2 py-2 text-center font-medium text-muted-foreground"></th>
@@ -597,7 +597,7 @@ export function WorkStandardEditModal({
<tr key={detail.id || idx} className="border-b transition-colors hover:bg-muted/30">
<td className="px-2 py-1.5 text-center text-muted-foreground">{idx + 1}</td>
<td className="px-2 py-1.5">
<Badge variant="outline" className="text-[10px] font-normal">
<Badge variant="outline" className="text-[10px] font-normal whitespace-nowrap">
{getDetailTypeLabel(detail.detail_type || "checklist")}
</Badge>
</td>
@@ -30,6 +30,7 @@ import { toast } from "sonner";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const MASTER_TABLE = "purchase_order_mng";
@@ -564,6 +565,48 @@ export default function PurchaseOrderPage() {
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
// 공급업체 선택 시 supplier_item_mapping으로 매핑 id 정규화 → 서버 필터 적용
const supplierCode = masterForm.supplier_code;
if (supplierCode) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, {
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierCode }] },
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
if (rawIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const uuidIds = rawIds.filter((v) => uuidRegex.test(v));
const codeIds = rawIds.filter((v) => !uuidRegex.test(v));
let convertedIds: string[] = [];
if (codeIds.length > 0) {
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: codeIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
autoFilter: true,
});
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
}
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
if (finalIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
filters.push({ columnName: "id", operator: "in", value: finalIds });
} catch { /* skip */ }
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
@@ -996,7 +1039,8 @@ export default function PurchaseOrderPage() {
<div className="grid grid-cols-2 gap-3.5">
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide"></Label>
<Select
<SmartSelect
options={categoryOptions["supplier_code"] || []}
value={masterForm.supplier_code || ""}
onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v);
@@ -1004,15 +1048,9 @@ export default function PurchaseOrderPage() {
setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name }));
recalcPrices(masterForm.price_mode || "", v);
}}
placeholder="공급업체 선택"
disabled={isReadOnly}
>
<SelectTrigger className="h-9"><SelectValue placeholder="공급업체 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
</div>
@@ -31,6 +31,7 @@ import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
@@ -118,9 +119,16 @@ export default function JeilGlassOrderPage() {
// 품목 선택 모달
const [itemSelectOpen, setItemSelectOpen] = useState(false);
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
const [itemSearchWidth, setItemSearchWidth] = useState("");
const [itemSearchHeight, setItemSearchHeight] = useState("");
const [itemSearchThickness, setItemSearchThickness] = useState("");
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
const [itemSearchLoading, setItemSearchLoading] = useState(false);
const [itemCheckedIds, setItemCheckedIds] = useState<Set<string>>(new Set());
// 서버사이드 페이지네이션
const [itemSearchPage, setItemSearchPage] = useState(1);
const [itemSearchPageSize] = useState(20);
const [itemSearchTotal, setItemSearchTotal] = useState(0);
// 기타
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
@@ -558,23 +566,55 @@ export default function JeilGlassOrderPage() {
};
// 품목 검색
const searchItems = async () => {
// 품목 검색 (서버 페이지네이션) — 관리품목=영업관리, 품목구분=제품 고정
// COMPANY_9: type 컬럼에 코드가 저장돼 있어 코드값으로 equals 필터
const searchItems = async (pageOverride?: number) => {
setItemSearchLoading(true);
const page = pageOverride ?? itemSearchPage;
try {
const salesCode = categoryOptions["item_division"]?.find((o) => o.label === "영업관리")?.code;
const productCode = categoryOptions["item_type"]?.find((o) => o.label === "제품")?.code;
const filters: any[] = [];
if (itemSearchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
}
if (salesCode) filters.push({ columnName: "division", operator: "contains", value: salesCode });
if (productCode) filters.push({ columnName: "type", operator: "equals", value: productCode });
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
if (itemSearchWidth) filters.push({ columnName: "width", operator: "equals", value: itemSearchWidth });
if (itemSearchHeight) filters.push({ columnName: "height", operator: "equals", value: itemSearchHeight });
if (itemSearchThickness) filters.push({ columnName: "thickness", operator: "equals", value: itemSearchThickness });
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
page, size: itemSearchPageSize,
dataFilter: { enabled: true, filters },
autoFilter: true,
});
setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setItemSearchResults([]); }
const raw: any[] = res.data?.data?.data || res.data?.data?.rows || [];
// 렌더 전 코드→라벨 변환 (단위)
const resolved = raw.map((r) => ({
...r,
unit: categoryOptions["item_unit"]?.find((o) => o.code === r.unit)?.label || r.unit || "",
}));
setItemSearchResults(resolved);
setItemSearchTotal(res.data?.data?.total || 0);
} catch { setItemSearchResults([]); setItemSearchTotal(0); }
finally { setItemSearchLoading(false); }
};
// 페이지 변경 시 재조회
useEffect(() => {
if (itemSelectOpen) searchItems();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemSearchPage]);
// 필터 입력 시 자동 조회 (debounce 350ms, 1페이지로 리셋)
useEffect(() => {
if (!itemSelectOpen) return;
const t = setTimeout(() => {
setItemSearchPage(1);
searchItems(1);
}, 350);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemSearchKeyword, itemSearchWidth, itemSearchHeight, itemSearchThickness]);
// 품목 선택 → 리피터에 추가
const addSelectedItemsToDetail = () => {
const selected = itemSearchResults.filter((i) => itemCheckedIds.has(i.id));
@@ -613,11 +653,14 @@ export default function JeilGlassOrderPage() {
};
// 빈 행 추가 (품명 직접 입력용)
// 빈 행 추가 — 관리품목=영업관리 고정
const addEmptyRow = () => {
const divisionCode = "CAT_ML8ZFVEL_1TOR";
const divisionLabel = categoryOptions["item_division"]?.find((o) => o.code === divisionCode)?.label || "영업관리";
setModalDetailRows((prev) => [...prev, {
_id: `new_${Date.now()}_${Math.random()}`,
_fromItemInfo: false,
part_code: "", part_name: "", spec: "", division: "", _divisionLabel: "", unit: "㎡",
part_code: "", part_name: "", spec: "", division: divisionCode, _divisionLabel: divisionLabel, unit: "㎡",
width: "", height: "", thickness: "", area: "",
qty: "", unit_price: "", amount: "",
due_date: "", memo: "",
@@ -936,12 +979,12 @@ export default function JeilGlassOrderPage() {
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.partner_id || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, partner_id: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<SmartSelect
options={categoryOptions["partner_id"] || []}
value={masterForm.partner_id || ""}
onValueChange={(v) => setMasterForm((p) => ({ ...p, partner_id: v }))}
placeholder="거래처 선택"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
@@ -989,7 +1032,7 @@ export default function JeilGlassOrderPage() {
<Button size="sm" variant="outline" onClick={addEmptyRow}>
<Plus className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSearchKeyword(""); setItemSearchWidth(""); setItemSearchHeight(""); setItemSearchThickness(""); setItemSearchPage(1); setItemSelectOpen(true); searchItems(1); }}>
<Search className="w-4 h-4 mr-1" />
</Button>
</div>
@@ -1049,18 +1092,9 @@ export default function JeilGlassOrderPage() {
</Button>
</TableCell>
<TableCell className="text-center text-xs text-muted-foreground">{idx + 1}</TableCell>
{/* 구분: 품목검색 → 읽기전용, 행추가 → Select */}
{/* 구분: 영업관리 고정 */}
<TableCell>
{row._fromItemInfo ? (
<span className="text-sm px-2">{row._divisionLabel || "-"}</span>
) : (
<Select value={row.division || ""} onValueChange={(v) => updateDetailRow(idx, "division", v)}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="구분" /></SelectTrigger>
<SelectContent position="popper" sideOffset={4} className="z-[9999]">
{(categoryOptions["item_division"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
)}
<span className="text-sm px-2">{row._divisionLabel || "영업관리"}</span>
</TableCell>
{/* 품명: 품목검색 → 읽기전용, 행추가 → 입력 */}
<TableCell>
@@ -1147,14 +1181,30 @@ export default function JeilGlassOrderPage() {
<DialogTitle> </DialogTitle>
<DialogDescription> . &quot; &quot; .</DialogDescription>
</DialogHeader>
<div className="flex gap-2 mb-3">
<Input placeholder="품명 검색" value={itemSearchKeyword}
<div className="flex gap-2 mb-3 flex-wrap">
<Input placeholder="품명" value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchItems()}
className="h-9 flex-1" />
<Button size="sm" onClick={searchItems} disabled={itemSearchLoading} className="h-9">
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
className="h-9 flex-1 min-w-[140px]" />
<Input type="number" placeholder="가로" value={itemSearchWidth}
onChange={(e) => setItemSearchWidth(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
className="h-9 w-[90px]" />
<Input type="number" placeholder="세로" value={itemSearchHeight}
onChange={(e) => setItemSearchHeight(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
className="h-9 w-[90px]" />
<Input type="number" placeholder="두께" value={itemSearchThickness}
onChange={(e) => setItemSearchThickness(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
className="h-9 w-[80px]" />
<Button size="sm" onClick={() => { setItemSearchPage(1); searchItems(1); }} disabled={itemSearchLoading} className="h-9">
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
<Button size="sm" variant="ghost" className="h-9"
onClick={() => { setItemSearchKeyword(""); setItemSearchWidth(""); setItemSearchHeight(""); setItemSearchThickness(""); setItemSearchPage(1); searchItems(1); }}>
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<Table>
@@ -1200,6 +1250,16 @@ export default function JeilGlassOrderPage() {
</TableBody>
</Table>
</div>
{/* 페이지네이션 */}
<div className="flex items-center justify-between text-xs text-muted-foreground px-1 pt-2">
<span> <b className="text-foreground">{itemSearchTotal.toLocaleString()}</b> · {itemSearchPage} / {Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize))}</span>
<div className="flex items-center gap-1">
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage(1)}>«</Button>
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage((p) => Math.max(1, p - 1))}></Button>
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage((p) => p + 1)}></Button>
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage(Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize)))}>»</Button>
</div>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{itemCheckedIds.size} </span>
@@ -16,6 +16,7 @@ import {
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { reportApi } from "@/lib/api/reportApi";
import { useCurrent2ndLevelMenuObjid } from "@/hooks/useCurrent2ndLevelMenuObjid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
@@ -153,10 +154,13 @@ export default function QuoteManagementPage() {
useEffect(() => { fetchQuotes(); }, [fetchQuotes]);
const current2ndLevelMenuObjid = useCurrent2ndLevelMenuObjid();
useEffect(() => {
if (current2ndLevelMenuObjid === null) return;
(async () => {
try {
const res = await reportApi.getReports({ page: 1, limit: 100 });
const res = await reportApi.getReportsByMenuObjid(current2ndLevelMenuObjid);
if (res.success) {
const items = res.data.items ?? [];
setReportList(items);
@@ -164,7 +168,7 @@ export default function QuoteManagementPage() {
}
} catch { /* 무시 */ }
})();
}, []);
}, [current2ndLevelMenuObjid]);
// ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ──
+2
View File
@@ -67,6 +67,7 @@
/* ===== CSS Variables (Vivid Blue Theme) ===== */
:root {
color-scheme: light;
/* Light Theme Colors - HSL Format */
--background: 0 0% 100%;
--foreground: 224 71% 4%;
@@ -123,6 +124,7 @@
/* ===== Dark Theme (Palantir-Inspired) ===== */
.dark {
color-scheme: dark;
/* 배경: 팔란티어 스타일 깊은 네이비 */
--background: 222 47% 6%;
--foreground: 210 20% 95%;
+45 -14
View File
@@ -83,6 +83,15 @@ export interface EDataTableProps<T extends Record<string, any> = any> {
showPagination?: boolean;
defaultPageSize?: number;
// ─── 서버사이드 페이지네이션 모드 ───
// serverPagination=true 일 때: 내부 slice/filter/sort 미사용, data는 이미 해당 페이지 분량
serverPagination?: boolean;
serverCurrentPage?: number;
serverPageSize?: number;
serverTotalCount?: number;
onServerPageChange?: (page: number) => void;
onServerPageSizeChange?: (size: number) => void;
className?: string;
}
@@ -275,6 +284,12 @@ export function EDataTable<T extends Record<string, any> = any>({
showRowNumber = false,
showPagination = true,
defaultPageSize = 50,
serverPagination = false,
serverCurrentPage,
serverPageSize,
serverTotalCount,
onServerPageChange,
onServerPageSizeChange,
className,
}: EDataTableProps<T>) {
const [columns, setColumns] = useState(initialColumns);
@@ -287,10 +302,21 @@ export function EDataTable<T extends Record<string, any> = any>({
// 헤더 필터
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
// 페이지네이션
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);
const [pageSizeInput, setPageSizeInput] = useState(String(defaultPageSize));
// 페이지네이션 — 서버사이드 모드면 외부 state 사용
const [internalCurrentPage, setInternalCurrentPage] = useState(1);
const [internalPageSize, setInternalPageSize] = useState(defaultPageSize);
const currentPage = serverPagination ? (serverCurrentPage ?? 1) : internalCurrentPage;
const pageSize = serverPagination ? (serverPageSize ?? defaultPageSize) : internalPageSize;
const setCurrentPage = (next: number | ((prev: number) => number)) => {
const resolved = typeof next === "function" ? (next as (p: number) => number)(currentPage) : next;
if (serverPagination) onServerPageChange?.(resolved);
else setInternalCurrentPage(resolved);
};
const setPageSize = (n: number) => {
if (serverPagination) onServerPageSizeChange?.(n);
else setInternalPageSize(n);
};
const [pageSizeInput, setPageSizeInput] = useState(String(serverPagination ? (serverPageSize ?? defaultPageSize) : defaultPageSize));
// 그룹 접기/펼치기
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
@@ -394,8 +420,9 @@ export function EDataTable<T extends Record<string, any> = any>({
});
};
// 필터 + 정렬
// 필터 + 정렬 (서버사이드 모드면 원본 data 그대로 사용)
const processedData = useMemo(() => {
if (serverPagination) return data;
let result = [...data];
// 헤더 필터
@@ -425,24 +452,28 @@ export function EDataTable<T extends Record<string, any> = any>({
}
return result;
}, [data, headerFilters, sortState, onSortChange]);
}, [data, headerFilters, sortState, onSortChange, serverPagination]);
// 필터/데이터 건수 변경 시 1페이지 리셋 (참조만 바뀐 경우는 리셋 안 함)
useEffect(() => { setCurrentPage(1); }, [data.length, headerFilters]);
// 필터/데이터 건수 변경 시 1페이지 리셋 (서버사이드에선 외부가 제어)
useEffect(() => {
if (!serverPagination) setCurrentPage(1);
}, [data.length, headerFilters, serverPagination]);
// 페이지네이션
const totalItems = processedData.length;
const totalItems = serverPagination ? (serverTotalCount ?? data.length) : processedData.length;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const safePage = Math.min(currentPage, totalPages);
useEffect(() => {
if (currentPage > totalPages) setCurrentPage(totalPages);
}, [currentPage, totalPages]);
if (!serverPagination && currentPage > totalPages) setCurrentPage(totalPages);
}, [currentPage, totalPages, serverPagination]);
const pageOffset = (safePage - 1) * pageSize;
const paginatedDataRaw = showPagination
? processedData.slice(pageOffset, pageOffset + pageSize)
: processedData;
const paginatedDataRaw = serverPagination
? processedData
: showPagination
? processedData.slice(pageOffset, pageOffset + pageSize)
: processedData;
// 접힌 그룹의 데이터 행 숨김
const paginatedData = useMemo(() => {
+87 -34
View File
@@ -4,19 +4,22 @@
* SmartSelect
*
* .
* - 5 미만: 기본 Select ()
* - 5 이상: Combobox ( + )
* - 5 미만: 기본 Select
* - 5 이상: 검색 + Combobox ( )
*/
import React, { useState, useMemo } from "react";
import React, { useState, useMemo, useEffect, useRef } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Check, ChevronsUpDown } from "lucide-react";
import { Check, ChevronsUpDown, Search as SearchIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { useVirtualizer } from "@tanstack/react-virtual";
const SEARCH_THRESHOLD = 5;
const ITEM_HEIGHT = 36;
const LIST_HEIGHT = 280;
export interface SmartSelectOption {
code: string;
@@ -41,12 +44,40 @@ export function SmartSelect({
className,
}: SmartSelectProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const scrollRef = useRef<HTMLDivElement | null>(null);
const selectedLabel = useMemo(
() => options.find((o) => o.code === value)?.label,
[options, value],
);
// 팝오버 닫힐 때 검색어 리셋
useEffect(() => {
if (!open) setSearch("");
}, [open]);
// 검색어로 옵션 필터 (대소문자 무시)
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return options;
return options.filter((o) => o.label.toLowerCase().includes(q));
}, [options, search]);
const virtualizer = useVirtualizer({
count: filtered.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ITEM_HEIGHT,
overscan: 10,
});
// 팝오버 열릴 때 측정 강제 (Portal 렌더 타이밍 대응)
useEffect(() => {
if (!open) return;
const id = requestAnimationFrame(() => virtualizer.measure());
return () => cancelAnimationFrame(id);
}, [open, virtualizer, filtered.length]);
if (options.length < SEARCH_THRESHOLD) {
return (
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
@@ -85,37 +116,59 @@ export function SmartSelect({
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command
filter={(val, search) => {
if (!search) return 1;
return val.toLowerCase().includes(search.toLowerCase()) ? 1 : 0;
}}
>
<CommandInput placeholder="검색..." className="h-9" />
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{options.map((o) => (
<CommandItem
key={o.code}
value={o.label}
onSelect={() => {
onValueChange(o.code);
setOpen(false);
}}
>
<Check
<div className="flex items-center border-b px-2">
<SearchIcon className="h-4 w-4 text-muted-foreground mr-1 shrink-0" />
<Input
autoFocus
placeholder="검색..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 border-0 px-1 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
{filtered.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground"> .</div>
) : (
<div
ref={scrollRef}
className="overflow-auto py-1"
style={{ height: LIST_HEIGHT }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const o = filtered[vItem.index];
const isSelected = value === o.code;
return (
<button
key={o.code}
type="button"
onClick={() => {
onValueChange(o.code);
setOpen(false);
}}
className={cn(
"mr-2 h-4 w-4",
value === o.code ? "opacity-100" : "opacity-0",
"absolute left-0 top-0 w-full flex items-center px-2 text-sm text-left hover:bg-accent",
isSelected && "bg-accent/60",
)}
/>
{o.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
style={{
height: `${vItem.size}px`,
transform: `translateY(${vItem.start}px)`,
}}
>
<Check className={cn("mr-2 h-4 w-4 shrink-0", isSelected ? "opacity-100" : "opacity-0")} />
<span className="truncate">{o.label}</span>
</button>
);
})}
</div>
</div>
)}
</PopoverContent>
</Popover>
);
@@ -218,6 +218,7 @@ export default function TimelineScheduler({
origStartDate: string;
origEndDate: string;
startX: number;
startScrollLeft: number;
currentOffsetDays: number;
} | null>(null);
@@ -378,6 +379,7 @@ export default function TimelineScheduler({
origStartDate: startDate,
origEndDate: endDate,
startX: e.clientX,
startScrollLeft: scrollRef.current?.scrollLeft ?? 0,
currentOffsetDays: 0,
});
},
@@ -388,16 +390,78 @@ export default function TimelineScheduler({
useEffect(() => {
if (!dragState) return;
// 드래그 결과가 차트 가시 범위를 벗어나지 않도록 오프셋 제한
const clampOffset = (rawOffset: number): number => {
const origStart = parseDate(dragState.origStartDate);
const origEnd = parseDate(dragState.origEndDate);
const lastDate = addDays(baseDate, config.spanDays - 1);
const msPerDay = 86400000;
if (dragState.mode === "move") {
const minOffset = Math.ceil((baseDate.getTime() - origStart.getTime()) / msPerDay);
const maxOffset = Math.floor((lastDate.getTime() - origEnd.getTime()) / msPerDay);
return Math.max(minOffset, Math.min(maxOffset, rawOffset));
} else if (dragState.mode === "resize-left") {
const minOffset = Math.ceil((baseDate.getTime() - origStart.getTime()) / msPerDay);
const maxOffset = Math.floor((origEnd.getTime() - origStart.getTime()) / msPerDay);
return Math.max(minOffset, Math.min(maxOffset, rawOffset));
} else if (dragState.mode === "resize-right") {
const minOffset = Math.ceil((origStart.getTime() - origEnd.getTime()) / msPerDay);
const maxOffset = Math.floor((lastDate.getTime() - origEnd.getTime()) / msPerDay);
return Math.max(minOffset, Math.min(maxOffset, rawOffset));
}
return rawOffset;
};
// 스크롤 변화 보정: 드래그 시작 이후 스크롤된 만큼 dx에 더해줌
const getEffectiveDx = (clientX: number): number => {
const currentScrollLeft = scrollRef.current?.scrollLeft ?? 0;
const scrollDelta = currentScrollLeft - dragState.startScrollLeft;
return (clientX - dragState.startX) + scrollDelta;
};
// 자동 스크롤: 뷰포트 가장자리 근처에서 RAF 루프로 스크롤
const EDGE = 50; // 가장자리 감지 영역 (px)
const MAX_SPEED = 18; // 최대 스크롤 속도 (px per frame)
let rafId: number | null = null;
let lastClientX = 0;
const autoScrollTick = () => {
const sc = scrollRef.current;
if (!sc) { rafId = null; return; }
const rect = sc.getBoundingClientRect();
const leftDist = lastClientX - rect.left;
const rightDist = rect.right - lastClientX;
let delta = 0;
if (leftDist < EDGE) {
delta = -Math.round(((EDGE - Math.max(0, leftDist)) / EDGE) * MAX_SPEED);
} else if (rightDist < EDGE) {
delta = Math.round(((EDGE - Math.max(0, rightDist)) / EDGE) * MAX_SPEED);
}
if (delta !== 0) {
const before = sc.scrollLeft;
sc.scrollLeft = before + delta;
if (sc.scrollLeft !== before) {
// 스크롤이 실제로 변했으면 dragState.currentOffsetDays 재계산
const dx = getEffectiveDx(lastClientX);
const dayOffset = clampOffset(Math.round(dx / config.cellWidth));
setDragState((prev) => (prev ? { ...prev, currentOffsetDays: dayOffset } : null));
}
}
rafId = requestAnimationFrame(autoScrollTick);
};
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - dragState.startX;
const dayOffset = Math.round(dx / config.cellWidth);
lastClientX = e.clientX;
const dx = getEffectiveDx(e.clientX);
const dayOffset = clampOffset(Math.round(dx / config.cellWidth));
setDragState((prev) => (prev ? { ...prev, currentOffsetDays: dayOffset } : null));
if (rafId === null) rafId = requestAnimationFrame(autoScrollTick);
};
const handleMouseUp = (e: MouseEvent) => {
if (!dragState) return;
const dx = e.clientX - dragState.startX;
const dayOffset = Math.round(dx / config.cellWidth);
const dx = getEffectiveDx(e.clientX);
const dayOffset = clampOffset(Math.round(dx / config.cellWidth));
if (dayOffset !== 0) {
const origStart = parseDate(dragState.origStartDate);
@@ -445,8 +509,9 @@ export default function TimelineScheduler({
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [dragState, config.cellWidth, onEventMove, onEventResize]);
}, [dragState, config.cellWidth, config.spanDays, baseDate, onEventMove, onEventResize]);
// 드래그 중인 이벤트의 현재 표시 위치 계산
const getDraggedBarStyle = useCallback(
@@ -618,7 +683,7 @@ export default function TimelineScheduler({
<div className="flex" style={{ minWidth: resourceWidth + totalWidth }}>
{/* 좌측: 리소스 라벨 */}
<div
className="shrink-0 border-r bg-muted/30 z-20 sticky left-0"
className="shrink-0 border-r bg-background z-20 sticky left-0"
style={{ width: resourceWidth }}
>
{/* 헤더 공간 */}
@@ -3,7 +3,7 @@
import React, { useEffect, useRef, useState } from "react";
import { FileDown, FileText, Loader2, Printer } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ComponentConfig, ReportPage, WatermarkConfig } from "@/types/report";
import { ComponentConfig, GridCell, ReportPage, WatermarkConfig } from "@/types/report";
import { useReportRenderer, QueryResult } from "@/hooks/useReportRenderer";
import { getFullImageUrl } from "@/lib/api/client";
import {
@@ -28,6 +28,10 @@ interface ReportInlineViewerProps {
showToolbar?: boolean;
/** 컴포넌트 클릭 콜백 — 편집 모드에서 사용 */
onComponentClick?: (component: ComponentConfig) => void;
/** cellType="input" 셀의 커스텀 값 오버라이드: { [componentId]: { [cellId]: value } } */
cellOverrides?: Record<string, Record<string, string>>;
/** input 셀 클릭 콜백 */
onInputCellClick?: (component: ComponentConfig, cell: GridCell) => void;
}
export function ReportInlineViewer({
@@ -36,6 +40,8 @@ export function ReportInlineViewer({
className = "",
showToolbar = true,
onComponentClick,
cellOverrides,
onInputCellClick,
}: ReportInlineViewerProps) {
const { detail, pages, watermark, getQueryResult, isLoading } = useReportRenderer(reportId, contextParams);
@@ -190,6 +196,7 @@ export function ReportInlineViewer({
page={page} pageIndex={pageIndex} totalPages={pages.length}
pages={pages} watermark={watermark} getQueryResult={getQueryResult}
editable={editable} onComponentClick={onComponentClick}
cellOverrides={cellOverrides} onInputCellClick={onInputCellClick}
/>
</div>
</div>
@@ -226,10 +233,12 @@ function WatermarkLayer({ watermark, pageWidth, pageHeight }: { watermark: Water
return null;
}
function PagePreview({ page, pageIndex, totalPages, pages, watermark, getQueryResult, editable, onComponentClick }: {
function PagePreview({ page, pageIndex, totalPages, pages, watermark, getQueryResult, editable, onComponentClick, cellOverrides, onInputCellClick }: {
page: ReportPage; pageIndex: number; totalPages: number; pages: ReportPage[];
watermark?: WatermarkConfig; getQueryResult: (queryId: string) => QueryResult | null;
editable?: boolean; onComponentClick?: (comp: ComponentConfig) => void;
cellOverrides?: Record<string, Record<string, string>>;
onInputCellClick?: (component: ComponentConfig, cell: GridCell) => void;
}) {
const comps = page.components ?? [];
const sortedByY = [...comps].sort((a, b) => a.y - b.y);
@@ -286,6 +295,7 @@ function PagePreview({ page, pageIndex, totalPages, pages, watermark, getQueryRe
{sortedByY.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0)).map((comp) => (
<ComponentRenderer key={comp.id} comp={comp} pageIndex={pageIndex} totalPages={totalPages}
pages={pages} getQueryResult={getQueryResult} editable={editable} onComponentClick={onComponentClick}
cellOverrides={cellOverrides} onInputCellClick={onInputCellClick}
yOffset={offsets[comp.id] || 0}
measureRef={growableTypes.has(comp.type) ? setRef(comp.id) : undefined} />
))}
@@ -293,10 +303,12 @@ function PagePreview({ page, pageIndex, totalPages, pages, watermark, getQueryRe
);
}
function ComponentRenderer({ comp, pageIndex, totalPages, pages, getQueryResult, editable, onComponentClick, yOffset = 0, measureRef }: {
function ComponentRenderer({ comp, pageIndex, totalPages, pages, getQueryResult, editable, onComponentClick, cellOverrides, onInputCellClick, yOffset = 0, measureRef }: {
comp: ComponentConfig; pageIndex: number; totalPages: number; pages: ReportPage[];
getQueryResult: (queryId: string) => QueryResult | null;
editable?: boolean; onComponentClick?: (comp: ComponentConfig) => void;
cellOverrides?: Record<string, Record<string, string>>;
onInputCellClick?: (component: ComponentConfig, cell: GridCell) => void;
yOffset?: number; measureRef?: (el: HTMLDivElement | null) => void;
}) {
const [hovered, setHovered] = useState(false);
@@ -360,7 +372,7 @@ function ComponentRenderer({ comp, pageIndex, totalPages, pages, getQueryResult,
onMouseLeave={() => setHovered(false)}
>
{(comp.type === "text" || comp.type === "label") && <TextRenderer component={comp} displayValue={displayValue} getQueryResult={getQueryResult} />}
{comp.type === "table" && <TableRenderer component={comp} getQueryResult={getQueryResult} />}
{comp.type === "table" && <TableRenderer component={comp} getQueryResult={getQueryResult} cellOverrides={cellOverrides} onInputCellClick={onInputCellClick} />}
{comp.type === "image" && <ImageRenderer component={comp} />}
{comp.type === "divider" && <DividerRenderer component={comp} />}
{comp.type === "signature" && <SignatureRenderer component={comp} />}
@@ -14,7 +14,7 @@ import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/commo
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Loader2, Search, ChevronRight, ChevronDown, FolderOpen, FileText } from "lucide-react";
import { Loader2, Search, FileText } from "lucide-react";
import { menuApi } from "@/lib/api/menu";
import { MenuItem } from "@/types/menu";
import { cn } from "@/lib/utils";
@@ -26,13 +26,10 @@ interface MenuSelectModalProps {
selectedMenuObjids?: number[];
}
interface MenuTreeNode {
interface FlatMenuEntry {
objid: string;
menuNameKor: string;
menuUrl: string;
level: number;
children: MenuTreeNode[];
parentObjId: string;
parentNameKor: string;
}
export function MenuSelectModal({ isOpen, onClose, onConfirm, selectedMenuObjids = [] }: MenuSelectModalProps) {
@@ -40,7 +37,6 @@ export function MenuSelectModal({ isOpen, onClose, onConfirm, selectedMenuObjids
const [isLoading, setIsLoading] = useState(false);
const [searchText, setSearchText] = useState("");
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set(selectedMenuObjids));
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const initialSelectionRef = useRef<string>("");
const hasChanges = useCallback(() => {
@@ -73,14 +69,6 @@ export function MenuSelectModal({ isOpen, onClose, onConfirm, selectedMenuObjids
const response = await menuApi.getUserMenus();
if (response.success && response.data) {
setMenus(response.data as MenuItem[]);
const initialExpanded = new Set<string>();
(response.data as MenuItem[]).forEach((menu: any) => {
const level = menu.lev || menu.LEV || 1;
if (level <= 2) {
initialExpanded.add(menu.objid || menu.OBJID || "");
}
});
setExpandedIds(initialExpanded);
}
} catch (error) {
console.error("메뉴 로드 오류:", error);
@@ -89,63 +77,36 @@ export function MenuSelectModal({ isOpen, onClose, onConfirm, selectedMenuObjids
}
};
const menuTree = useMemo(() => {
const menuMap = new Map<string, MenuTreeNode>();
const rootMenus: MenuTreeNode[] = [];
menus.forEach((menu) => {
const objid = menu.objid || menu.OBJID || "";
const parentObjId = menu.parentObjId || menu.PARENT_OBJ_ID || "";
const menuNameKor = menu.menuNameKor || menu.MENU_NAME_KOR || menu.translated_name || menu.TRANSLATED_NAME || "";
const menuUrl = menu.menuUrl || menu.MENU_URL || "";
const level = menu.lev || menu.LEV || 1;
menuMap.set(objid, { objid, menuNameKor, menuUrl, level, children: [], parentObjId });
const level2List = useMemo<FlatMenuEntry[]>(() => {
const byObjid = new Map<string, any>();
menus.forEach((menu: any) => {
const objid = String(menu.objid || menu.OBJID || "");
byObjid.set(objid, menu);
});
menus.forEach((menu) => {
const objid = menu.objid || menu.OBJID || "";
const parentObjId = menu.parentObjId || menu.PARENT_OBJ_ID || "";
const node = menuMap.get(objid);
if (!node) return;
const parent = menuMap.get(parentObjId);
if (parent) {
parent.children.push(node);
} else {
rootMenus.push(node);
}
});
const sortChildren = (nodes: MenuTreeNode[]) => {
nodes.sort((a, b) => a.menuNameKor.localeCompare(b.menuNameKor, "ko"));
nodes.forEach((node) => sortChildren(node.children));
};
sortChildren(rootMenus);
return rootMenus;
return menus
.filter((menu: any) => Number(menu.lev ?? menu.LEV ?? 0) === 2)
.map((menu: any) => {
const objid = String(menu.objid || menu.OBJID || "");
const parentObjId = String(menu.parent_obj_id || menu.PARENT_OBJ_ID || "");
const parent = byObjid.get(parentObjId);
const parentNameKor = parent
? parent.menu_name_kor || parent.MENU_NAME_KOR || parent.translated_name || parent.TRANSLATED_NAME || ""
: "";
const menuNameKor = menu.menu_name_kor || menu.MENU_NAME_KOR || menu.translated_name || menu.TRANSLATED_NAME || "";
return { objid, menuNameKor, parentNameKor };
})
.sort((a, b) => a.menuNameKor.localeCompare(b.menuNameKor, "ko"));
}, [menus]);
const filteredTree = useMemo(() => {
if (!searchText.trim()) return menuTree;
const searchLower = searchText.toLowerCase();
const filterNodes = (nodes: MenuTreeNode[]): MenuTreeNode[] => {
return nodes
.map((node) => {
const filteredChildren = filterNodes(node.children);
const matches = node.menuNameKor.toLowerCase().includes(searchLower);
if (matches || filteredChildren.length > 0) {
return { ...node, children: filteredChildren };
}
return null;
})
.filter((node): node is MenuTreeNode => node !== null);
};
return filterNodes(menuTree);
}, [menuTree, searchText]);
const filteredList = useMemo(() => {
if (!searchText.trim()) return level2List;
const q = searchText.toLowerCase();
return level2List.filter(
(m) =>
m.menuNameKor.toLowerCase().includes(q) || m.parentNameKor.toLowerCase().includes(q),
);
}, [level2List, searchText]);
const toggleSelect = useCallback((objid: string) => {
const numericId = Number(objid);
@@ -160,87 +121,20 @@ export function MenuSelectModal({ isOpen, onClose, onConfirm, selectedMenuObjids
});
}, []);
const toggleExpand = useCallback((objid: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(objid)) {
next.delete(objid);
} else {
next.add(objid);
}
return next;
});
}, []);
const handleConfirm = () => {
onConfirm(Array.from(selectedIds));
onClose();
};
const renderMenuNode = (node: MenuTreeNode, depth: number = 0) => {
const hasChildren = node.children.length > 0;
const isExpanded = expandedIds.has(node.objid);
const isSelected = selectedIds.has(Number(node.objid));
return (
<div key={node.objid}>
<div
className={cn(
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5",
isSelected && "bg-primary/10",
)}
style={{ paddingLeft: `${depth * 20 + 8}px` }}
onClick={() => toggleSelect(node.objid)}
>
{hasChildren ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleExpand(node.objid);
}}
className="hover:bg-muted rounded p-0.5"
>
{isExpanded ? (
<ChevronDown className="text-muted-foreground h-4 w-4" />
) : (
<ChevronRight className="text-muted-foreground h-4 w-4" />
)}
</button>
) : (
<div className="w-5" />
)}
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelect(node.objid)}
onClick={(e) => e.stopPropagation()}
/>
{hasChildren ? (
<FolderOpen className="h-4 w-4 text-amber-500" />
) : (
<FileText className="text-muted-foreground h-4 w-4" />
)}
<span className={cn("flex-1 truncate text-sm", isSelected && "text-primary font-medium")}>
{node.menuNameKor}
</span>
</div>
{hasChildren && isExpanded && <div>{node.children.map((child) => renderMenuNode(child, depth + 1))}</div>}
</div>
);
};
return (
<>
<Dialog open={isOpen} onOpenChange={guard.handleOpenChange}>
<DialogContent className="flex max-h-[80vh] max-w-[600px] flex-col">
<DialogContent className="flex h-[80vh] max-w-[600px] flex-col overflow-hidden">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogTitle> ()</DialogTitle>
<DialogDescription>
. .
2() .
. .
</DialogDescription>
</DialogHeader>
@@ -256,18 +150,45 @@ export function MenuSelectModal({ isOpen, onClose, onConfirm, selectedMenuObjids
<div className="text-muted-foreground text-sm">{selectedIds.size} </div>
<ScrollArea className="flex-1 rounded-md border">
<ScrollArea className="min-h-0 flex-1 rounded-md border">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div>
) : filteredTree.length === 0 ? (
) : filteredList.length === 0 ? (
<div className="text-muted-foreground flex items-center justify-center py-8 text-sm">
{searchText ? "검색 결과가 없습니다." : "표시할 메뉴가 없습니다."}
{searchText ? "검색 결과가 없습니다." : "표시할 2레벨 메뉴가 없습니다."}
</div>
) : (
<div className="p-2">{filteredTree.map((node) => renderMenuNode(node))}</div>
<div className="p-2">
{filteredList.map((node) => {
const isSelected = selectedIds.has(Number(node.objid));
return (
<div
key={node.objid}
className={cn(
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded-md px-2 py-2",
isSelected && "bg-primary/10",
)}
onClick={() => toggleSelect(node.objid)}
>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelect(node.objid)}
onClick={(e) => e.stopPropagation()}
/>
<FileText className="text-muted-foreground h-4 w-4" />
<span className={cn("flex-1 truncate text-sm", isSelected && "text-primary font-medium")}>
{node.menuNameKor}
</span>
{node.parentNameKor && (
<span className="text-muted-foreground text-xs">{node.parentNameKor}</span>
)}
</div>
);
})}
</div>
)}
</ScrollArea>
@@ -34,6 +34,7 @@ import {
ZoomIn,
ZoomOut,
Maximize,
FolderTree,
} from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { useState, useCallback } from "react";
@@ -465,6 +466,16 @@ export function ReportDesignerToolbar() {
<BookTemplate className="h-4 w-4" />
릿
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowMenuSelect(true)}
className="h-9 gap-1 px-2 lg:gap-2 lg:px-3"
title="이 리포트를 사용할 대분류 메뉴 선택"
>
<FolderTree className="h-4 w-4" />
<span className="hidden lg:inline"> </span>
</Button>
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="h-9 gap-1 px-2 lg:gap-2 lg:px-3">
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
<span className="hidden lg:inline"></span>
@@ -782,6 +782,9 @@ export function GridEditor({ component, onUpdate, schemaColumns = [] }: GridEdit
<SelectItem value="field">
<span className="flex items-center gap-1"><Database className="h-3 w-3" /> </span>
</SelectItem>
<SelectItem value="input">
<span className="flex items-center gap-1"><Type className="h-3 w-3" /> </span>
</SelectItem>
</SelectContent>
</Select>
</div>
@@ -812,6 +815,19 @@ export function GridEditor({ component, onUpdate, schemaColumns = [] }: GridEdit
</div>
)}
{selectedCell.cellType === "input" && (
<div className="space-y-1">
<label className="text-[10px] font-medium text-gray-500"> (placeholder)</label>
<Input
className="h-8 w-full text-xs"
value={selectedCell.inputPlaceholder ?? ""}
onChange={(e) => updateCellProps(selectedCell.row, selectedCell.col, { inputPlaceholder: e.target.value })}
placeholder="예: 결제 조건"
/>
<p className="text-[10px] text-gray-400"> </p>
</div>
)}
{/* 구분선 */}
<div className="h-px bg-gray-200" />
@@ -51,7 +51,12 @@ function calcSummary(col: TableColumn, rows: Record<string, unknown>[]): string
function getGridCellValue(
cell: GridCell,
row?: Record<string, unknown>,
override?: string,
): string {
if (cell.cellType === "input") {
return override ?? "";
}
if (cell.cellType === "static") return cell.value ?? "";
if (cell.cellType === "field") {
@@ -67,7 +72,7 @@ function getGridCellValue(
// ─── 그리드 테이블 렌더러 ────────────────────────────────────────────────────
function GridTableRenderer({ component, getQueryResult }: TableRendererProps) {
function GridTableRenderer({ component, getQueryResult, cellOverrides, onInputCellClick }: TableRendererProps) {
const cells = component.gridCells ?? [];
const rowCount = component.gridRowCount ?? 0;
const colCount = component.gridColCount ?? 0;
@@ -118,28 +123,38 @@ function GridTableRenderer({ component, getQueryResult }: TableRendererProps) {
const cellBg = cell.backgroundColor || (isHeader ? hdrBg : "white");
const cellColor = cell.textColor || (isHeader ? hdrColor : "#111827");
const displayValue = getGridCellValue(cell, dataRow);
const overrideValue = cellOverrides?.[component.id]?.[cell.id];
const displayValue = getGridCellValue(cell, dataRow, overrideValue);
const isInputCell = cell.cellType === "input";
const showPlaceholder = isInputCell && !overrideValue;
tds.push(
<td
key={cell.id}
rowSpan={rSpan > 1 ? rSpan : undefined}
colSpan={cSpan > 1 ? cSpan : undefined}
onClick={
isInputCell && onInputCellClick
? (e) => { e.stopPropagation(); onInputCellClick(component, cell); }
: undefined
}
style={{
backgroundColor: cellBg,
backgroundColor: isInputCell && onInputCellClick && !overrideValue ? "#fffbe6" : cellBg,
border: `${borderW}px solid #d1d5db`,
padding: "2px 4px",
fontSize: cell.fontSize ?? 12,
fontWeight: cell.fontWeight === "bold" ? 700 : (isHeader ? 600 : 400),
color: cellColor,
color: showPlaceholder ? "#9ca3af" : cellColor,
textAlign: cell.align || "center",
verticalAlign: cell.verticalAlign || "middle",
overflow: "hidden",
whiteSpace: "pre-line",
wordBreak: "break-word",
fontStyle: showPlaceholder ? "italic" : undefined,
cursor: isInputCell && onInputCellClick ? "pointer" : undefined,
}}
>
{displayValue}
{showPlaceholder ? (cell.inputPlaceholder || "입력") : displayValue}
</td>,
);
}
@@ -1,4 +1,4 @@
import { ComponentConfig } from "@/types/report";
import { ComponentConfig, GridCell } from "@/types/report";
export interface QueryResult {
fields: string[];
@@ -14,7 +14,12 @@ export interface TextRendererProps extends BaseRendererProps {
displayValue: string;
}
export interface TableRendererProps extends BaseRendererProps {}
export interface TableRendererProps extends BaseRendererProps {
/** cellType="input" 셀의 커스텀 값 맵: { [componentId]: { [cellId]: value } } */
cellOverrides?: Record<string, Record<string, string>>;
/** input 셀 클릭 시 호출 */
onInputCellClick?: (component: ComponentConfig, cell: GridCell) => void;
}
export interface ImageRendererProps {
component: ComponentConfig;
@@ -0,0 +1,78 @@
"use client";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import { useMenu } from "@/contexts/MenuContext";
import { useTabStore, selectTabs, selectActiveTabId } from "@/stores/tabStore";
function stripCompanyPrefix(pathname: string): string {
return pathname.replace(/^\/COMPANY_\d+/, "") || "/";
}
/**
* "대분류" objid .
* - parent_obj_id를 ,
* - lev ( )
* - (/main ) : usePathname '/main'
* adminUrl을 fallback으로
*/
export function useCurrent2ndLevelMenuObjid(): number | null {
const pathname = usePathname();
const { userMenus, adminMenus } = useMenu();
const tabs = useTabStore(selectTabs);
const activeTabId = useTabStore(selectActiveTabId);
return useMemo(() => {
if (!pathname) return null;
const all: any[] = [...(userMenus as any[]), ...(adminMenus as any[])];
if (all.length === 0) return null;
// 1차: 실제 pathname 기준. 2차(탭 컨테이너 경로 등): 활성 탭 URL 기준
let targetUrl = stripCompanyPrefix(pathname);
const isRootLikePath = pathname === "/main" || pathname === "/" || pathname === "";
if (isRootLikePath) {
const activeTab = tabs.find((t) => t.id === activeTabId);
if (activeTab?.adminUrl) {
targetUrl = stripCompanyPrefix(activeTab.adminUrl);
}
}
const byObjid = new Map<string, any>();
for (const m of all) {
byObjid.set(String(m.objid), m);
}
const current = all.find((m) => m.menu_url === targetUrl);
if (!current) {
// eslint-disable-next-line no-console
console.warn("[useCurrent2ndLevelMenuObjid] 메뉴 매칭 실패", { targetUrl, sample: all.slice(0, 3) });
return null;
}
let node: any = current;
let prev: any = null;
let safety = 20;
while (node && safety-- > 0) {
const parentId = node.parent_obj_id;
if (parentId === null || parentId === undefined || parentId === 0 || parentId === "0") {
break;
}
const parent = byObjid.get(String(parentId));
if (!parent) break;
prev = node;
node = parent;
}
const resultObjid = prev ? Number(prev.objid) : Number(node.objid);
// eslint-disable-next-line no-console
console.log("[useCurrent2ndLevelMenuObjid]", {
targetUrl,
currentObjid: current.objid,
currentName: current.menu_name_kor,
resultObjid,
resultName: prev ? prev.menu_name_kor : node.menu_name_kor,
});
return resultObjid;
}, [pathname, userMenus, adminMenus, tabs, activeTabId]);
}
+2 -1
View File
@@ -58,7 +58,8 @@ export interface RoutingDetail {
work_type: string;
standard_time: string;
outsource_supplier: string;
outsource_supplier_list?: string[];
outsource_supplier_ids?: string[];
outsource_supplier_list?: string[]; // legacy code 배열 (호환용)
}
interface ApiResponse<T> {
+1 -1
View File
@@ -4,7 +4,7 @@ export interface PaginatedResponse { success: boolean; data: any[]; totalCount:
export async function getWorkInstructionList(params?: Record<string, any>) {
const res = await apiClient.get("/work-instruction/list", { params });
return res.data as { success: boolean; data: any[] };
return res.data as { success: boolean; data: any[]; totalCount?: number; page?: number; pageSize?: number };
}
export async function previewWorkInstructionNo() {
+3 -1
View File
@@ -388,10 +388,12 @@ export interface GridCell {
col: number;
rowSpan?: number;
colSpan?: number;
cellType: "static" | "field" | "formula";
cellType: "static" | "field" | "formula" | "input";
value?: string;
field?: string;
formula?: string;
/** input 셀의 placeholder 힌트 */
inputPlaceholder?: string;
align?: "left" | "center" | "right";
verticalAlign?: "top" | "middle" | "bottom";
fontWeight?: "normal" | "bold";