자재관리 2메뉴 풀-CRUD + 액션 (자재리스트 + 불출의뢰서)

- 신규 테이블 5종 (운영 11133 → RPS 11134 DDL 1:1):
  inventory_mgmt / inventory_mgmt_in / inventory_mgmt_out /
  inventory_mgmt_out_master / inventory_mgmt_history
- 백엔드 /api/inventory-mng — 리스트·재고등록·자재이동·삭제·이력 +
  불출의뢰 생성·접수·자재불출(재고 차감)·삭제. 채번 Rfw-YYYY-seq.
- 프론트 /COMPANY_16/material/{list, issue-request} +
  StockRegister / MaterialMove / IssueRequestCreate /
  InventoryHistory / IssueDispatch 다이얼로그 5종.
- AdminPageRenderer 등록 + /material/ prefix.
This commit is contained in:
hjjeong
2026-05-19 11:25:15 +09:00
parent 1b1231d9a9
commit aacbb62ad8
18 changed files with 2738 additions and 0 deletions
+2
View File
@@ -122,6 +122,7 @@ import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리
import productionMbomRoutes from "./routes/productionMbomRoutes"; // 생산관리>M-BOM 관리 (wace_plm 도메인)
import { planResultRouter as productionPlanResultRoutes, mbomReqRouter as productionMbomRequirementRoutes } from "./routes/productionPlanResultRoutes"; // 생산관리 4개 메뉴 (생산계획&실적, 소요량)
import purchaseRoutes from "./routes/purchaseRoutes"; // 구매관리 7개 메뉴 (wace_plm 도메인)
import inventoryMngRoutes from "./routes/inventoryMngRoutes"; // 자재관리 — 자재리스트 + 불출의뢰서 (wace_plm inventoryMng 도메인)
import itemInspectionRoutes from "./routes/itemInspectionRoutes"; // 품목검사정보
import qualityRoutes from "./routes/qualityRoutes"; // 품질관리 — 수입/공정/반제품 검사 (wace_plm 이식 1단계)
import crawlRoutes from "./routes/crawlRoutes"; // 웹 크롤링
@@ -389,6 +390,7 @@ app.use("/api/production/mbom", productionMbomRoutes); // 생산관리>M-BOM 관
app.use("/api/production/plan-result", productionPlanResultRoutes); // 생산계획&실적관리 (메뉴1+2)
app.use("/api/production/mbom-requirement", productionMbomRequirementRoutes); // 반제품/원자재 소요량 (메뉴3+4)
app.use("/api/purchase", purchaseRoutes); // 구매관리 7개 메뉴 (wace_plm 도메인)
app.use("/api/inventory-mng", inventoryMngRoutes); // 자재관리 — 자재리스트 + 불출의뢰서
app.use("/api/item-inspection", itemInspectionRoutes); // 품목검사정보 (그룹 페이징)
app.use("/api/quality", qualityRoutes); // 품질관리 — 수입/공정/반제품 검사
app.use("/api/crawl", crawlRoutes); // 웹 크롤링
@@ -0,0 +1,251 @@
// ============================================================
// 자재관리 — 자재리스트 + 불출의뢰서 컨트롤러
// ============================================================
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import * as svc from "../services/inventoryMngService";
import { logger } from "../utils/logger";
function userId(req: AuthenticatedRequest): string {
return (req as any)?.user?.user_id ?? (req as any)?.user?.username ?? "system";
}
// ── 자재리스트 ──────────────────────────────────────────────
export async function getInventoryList(req: AuthenticatedRequest, res: Response) {
try {
const q = req.query as Record<string, any>;
const data = await svc.listInventory({
...q,
page: q.page ? Number(q.page) : undefined,
page_size: q.page_size ? Number(q.page_size) : undefined,
});
return res.json({ success: true, data });
} catch (e: any) {
logger.error("자재리스트 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function postInventoryStock(req: AuthenticatedRequest, res: Response) {
try {
const body = req.body ?? {};
if (!body.project_objid || !body.part_objid || !body.qty || !body.location) {
return res.status(400).json({ success: false, message: "필수 항목 누락" });
}
const out = await svc.saveInventory({
project_objid: body.project_objid,
unit: body.unit ?? "",
part_objid: body.part_objid,
qty: Number(body.qty),
price: body.price ?? "",
location: body.location,
sub_location: body.sub_location ?? "",
cls_cd: body.cls_cd ?? "",
cau_cd: body.cau_cd ?? "",
receipt_date: body.receipt_date ?? "",
writer: body.writer ?? userId(req),
order_objid: body.order_objid ?? "",
});
return res.json({ success: true, data: out });
} catch (e: any) {
logger.error("재고 등록 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function deleteInventoryStock(req: AuthenticatedRequest, res: Response) {
try {
const objids: string[] = Array.isArray(req.body?.objids)
? req.body.objids
: String(req.body?.checkArr ?? "").split(",").filter(Boolean);
if (!objids.length) return res.status(400).json({ success: false, message: "삭제 대상 없음" });
const data = await svc.deleteInventory(objids);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("재고 삭제 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function postInventoryMove(req: AuthenticatedRequest, res: Response) {
try {
const items = req.body?.items;
if (!Array.isArray(items) || !items.length) {
return res.status(400).json({ success: false, message: "이동 항목 없음" });
}
const writer = req.body?.writer ?? userId(req);
const data = await svc.moveInventoryBulk(items.map((it: any) => ({ ...it, writer })));
return res.json({ success: true, data });
} catch (e: any) {
logger.error("자재이동 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getInventoryHistory(req: AuthenticatedRequest, res: Response) {
try {
const objid = String(req.params.objid ?? "");
if (!objid) return res.status(400).json({ success: false, message: "objid 없음" });
const data = await svc.getInventoryHistory(objid);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("자재이력 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// ── 불출의뢰서 ─────────────────────────────────────────────
export async function getIssueRequestList(req: AuthenticatedRequest, res: Response) {
try {
const q = req.query as Record<string, any>;
const data = await svc.listIssueRequest({
...q,
page: q.page ? Number(q.page) : undefined,
page_size: q.page_size ? Number(q.page_size) : undefined,
});
return res.json({ success: true, data });
} catch (e: any) {
logger.error("불출의뢰서 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getIssueRequestDetail(req: AuthenticatedRequest, res: Response) {
try {
const objid = String(req.params.objid ?? "");
if (!objid) return res.status(400).json({ success: false, message: "objid 없음" });
const data = await svc.getIssueRequest(objid);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("불출의뢰 상세 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getIssueRequestCandidates(req: AuthenticatedRequest, res: Response) {
try {
const ids: string[] = Array.isArray(req.body?.parent_objids)
? req.body.parent_objids
: String(req.query.parent_objids ?? "").split(",").filter(Boolean);
const data = await svc.getIssueRequestCandidates(ids);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("불출 후보 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function postIssueRequest(req: AuthenticatedRequest, res: Response) {
try {
const body = req.body ?? {};
if (!Array.isArray(body.lines) || !body.lines.length) {
return res.status(400).json({ success: false, message: "라인이 비어있음" });
}
const writer = body.writer ?? userId(req);
const data = await svc.saveIssueRequest({
master_objid: body.master_objid ?? body.INVENTORY_REQUEST_MASTER_OBJID,
contract_mgmt_objid: body.contract_mgmt_objid ?? "",
request_date: body.request_date ?? "",
request_id: body.request_id ?? writer,
remark: body.remark ?? "",
writer,
lines: body.lines.map((l: any) => ({
parent_objid: l.parent_objid ?? l.OBJID,
request_qty: Number(l.request_qty ?? l.REQUEST_QTY ?? 0),
unit: l.unit ?? l.unit_code ?? "",
})),
});
return res.json({ success: true, data });
} catch (e: any) {
logger.error("불출의뢰 저장 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function deleteIssueRequest(req: AuthenticatedRequest, res: Response) {
try {
const objid = String(req.params.objid ?? "");
if (!objid) return res.status(400).json({ success: false, message: "objid 없음" });
await svc.deleteIssueRequest(objid);
return res.json({ success: true });
} catch (e: any) {
logger.error("불출의뢰 삭제 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function postIssueRequestReceive(req: AuthenticatedRequest, res: Response) {
try {
const ids: string[] = Array.isArray(req.body?.objids)
? req.body.objids
: String(req.body?.checkArr ?? "").split(",").filter(Boolean);
if (!ids.length) return res.status(400).json({ success: false, message: "접수 대상 없음" });
const data = await svc.receiveIssueRequest(ids, userId(req));
return res.json({ success: true, data });
} catch (e: any) {
logger.error("불출의뢰 접수 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function postIssueRequestDispatch(req: AuthenticatedRequest, res: Response) {
try {
const body = req.body ?? {};
if (!body.master_objid || !Array.isArray(body.lines) || !body.lines.length) {
return res.status(400).json({ success: false, message: "필수 항목 누락" });
}
const writer = body.writer ?? userId(req);
const data = await svc.dispatchIssueRequest({
master_objid: body.master_objid,
lines: body.lines.map((l: any) => ({
objid: l.objid,
out_qty: Number(l.out_qty ?? 0),
out_date: l.out_date,
acq_user: l.acq_user,
sign: l.sign,
writer,
})),
});
return res.json({ success: true, data });
} catch (e: any) {
logger.error("자재불출 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// ── 옵션 ───────────────────────────────────────────────────
export async function getProjectOptions(_req: AuthenticatedRequest, res: Response) {
try {
return res.json({ success: true, data: await svc.listProjectOptions() });
} catch (e: any) {
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getUnitOptions(req: AuthenticatedRequest, res: Response) {
try {
const code = String(req.query.contract_objid ?? req.query.project_objid ?? "");
return res.json({ success: true, data: await svc.listUnitOptions(code) });
} catch (e: any) {
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getUserOptions(_req: AuthenticatedRequest, res: Response) {
try {
return res.json({ success: true, data: await svc.listUserOptions() });
} catch (e: any) {
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getPartOptions(req: AuthenticatedRequest, res: Response) {
try {
const kw = String(req.query.q ?? req.query.keyword ?? "");
const limit = Math.min(100, Number(req.query.limit ?? 30));
return res.json({ success: true, data: await svc.listPartOptions(kw, limit) });
} catch (e: any) {
return res.status(500).json({ success: false, message: e.message });
}
}
@@ -0,0 +1,35 @@
// ============================================================
// 자재관리 라우트 — 자재리스트(/list) + 불출의뢰서(/issue-request)
// app.ts: app.use("/api/inventory-mng", inventoryMngRoutes)
// ============================================================
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/inventoryMngController";
const router = Router();
router.use(authenticateToken);
// 자재리스트
router.get("/list", ctrl.getInventoryList);
router.post("/stock", ctrl.postInventoryStock);
router.post("/stock/delete", ctrl.deleteInventoryStock);
router.post("/move", ctrl.postInventoryMove);
router.get("/history/:objid", ctrl.getInventoryHistory);
// 불출의뢰서
router.get("/issue-request", ctrl.getIssueRequestList);
router.get("/issue-request/:objid", ctrl.getIssueRequestDetail);
router.post("/issue-request/candidates", ctrl.getIssueRequestCandidates);
router.post("/issue-request", ctrl.postIssueRequest);
router.delete("/issue-request/:objid", ctrl.deleteIssueRequest);
router.post("/issue-request/receive", ctrl.postIssueRequestReceive);
router.post("/issue-request/dispatch", ctrl.postIssueRequestDispatch);
// 공통 옵션
router.get("/options/projects", ctrl.getProjectOptions);
router.get("/options/units", ctrl.getUnitOptions);
router.get("/options/users", ctrl.getUserOptions);
router.get("/options/parts", ctrl.getPartOptions);
export default router;
@@ -0,0 +1,767 @@
// ============================================================
// 자재관리 — 자재리스트 + 불출의뢰서 서비스
// wace_plm inventoryMng.xml + InventoryMngService.java 1:1 베이스
//
// 테이블:
// inventory_mgmt (자재 마스터: contract_objid+unit+part_objid PK, objid는 별도)
// inventory_mgmt_in (입고/이동 라인: parent_objid → inventory_mgmt.objid)
// inventory_mgmt_out (불출 라인: parent_objid → inventory_mgmt.objid,
// inventory_request_master_objid → inventory_mgmt_out_master.objid)
// inventory_mgmt_out_master (불출의뢰 마스터: inventory_out_no = Rfw-YYYY-seq)
// inventory_mgmt_history (자재 투입 이력 — 본 메뉴에서는 조회용)
//
// 보유수량 공식:
// USE_CNT = SUM(receipt_qty - move_qty) FROM inventory_mgmt_in WHERE parent=IM.objid
// - SUM(request_qty) FROM inventory_mgmt_out WHERE parent=IM.objid
//
// 불출 흐름: 의뢰(request_qty) → 접수(reception_status=reception) → 불출(out_qty, outstatus=complete)
// ============================================================
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { createObjId } from "../utils/objidUtil";
export interface InventoryListFilter {
project_objid?: string;
unit_code?: string;
part_no?: string;
part_name?: string;
part_type?: string;
location?: string;
cls_cd?: string;
cau_cd?: string;
writer?: string;
page?: number;
page_size?: number;
}
export interface IssueRequestFilter {
part_no?: string;
part_name?: string;
request_start_date?: string;
request_end_date?: string;
request_user?: string;
reception_status?: string;
reception_user?: string;
reception_start_date?: string;
reception_end_date?: string;
out_status?: string;
page?: number;
page_size?: number;
}
interface ListResult<T> {
rows: T[];
totalCount: number;
page: number;
pageSize: number;
}
function paging(f: { page?: number; page_size?: number }) {
const page = Math.max(1, Number(f.page ?? 1));
const pageSize = Math.max(1, Math.min(500, Number(f.page_size ?? 50)));
return { page, pageSize, limit: pageSize, offset: (page - 1) * pageSize };
}
// ─── 자재리스트 그리드 ─────────────────────────────────────────
// wace `inventoryMngNewGridList` 매퍼 1:1 베이스
// USE_CNT = inbound move out_request (각 부모 행 단위 합산)
// USE_CNT_ALL = 같은 part_objid 전체 가상 합계 (윈도우 함수)
// REQUEST_QTY = 누적 불출의뢰 수량
// LOCATION_NAME = inventory_mgmt_in.location distinct 목록 (콤마)
export async function listInventory(filter: InventoryListFilter): Promise<ListResult<any>> {
const pool = getPool();
const { page, pageSize, limit, offset } = paging(filter);
const where: string[] = [];
const params: any[] = [];
const addP = (v: any) => { params.push(v); return `$${params.length}`; };
if (filter.project_objid) where.push(`IM.contract_objid = ${addP(filter.project_objid)}`);
if (filter.unit_code) where.push(`IM.unit = ${addP(filter.unit_code)}`);
if (filter.part_no) where.push(`P.part_no ILIKE ${addP(`%${filter.part_no}%`)}`);
if (filter.part_name) where.push(`P.part_name ILIKE ${addP(`%${filter.part_name}%`)}`);
if (filter.part_type) where.push(`P.part_type = ${addP(filter.part_type)}`);
if (filter.location) where.push(`IM.location = ${addP(filter.location)}`);
if (filter.cls_cd) where.push(`IM.cls_cd = ${addP(filter.cls_cd)}`);
if (filter.cau_cd) where.push(`IM.cau_cd = ${addP(filter.cau_cd)}`);
if (filter.writer) where.push(`IM.writer = ${addP(filter.writer)}`);
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const dataSql = `
WITH IN_SUM AS (
SELECT parent_objid,
SUM(COALESCE(NULLIF(receipt_qty,'')::numeric,0) - COALESCE(NULLIF(move_qty,'')::numeric,0)) AS in_qty
FROM inventory_mgmt_in
GROUP BY parent_objid
),
OUT_SUM AS (
SELECT parent_objid,
SUM(COALESCE(NULLIF(request_qty,'')::numeric,0)) AS req_qty,
SUM(COALESCE(NULLIF(out_qty,'')::numeric,0)) AS out_qty
FROM inventory_mgmt_out
GROUP BY parent_objid
),
LOC_LIST AS (
SELECT parent_objid, STRING_AGG(DISTINCT NULLIF(location,''), ',') AS loc_names
FROM inventory_mgmt_in
GROUP BY parent_objid
)
SELECT
IM.objid AS objid,
IM.contract_objid AS contract_objid,
COALESCE(PJ.project_no, CT.contract_no, '') AS project_no,
IM.unit AS unit,
COALESCE(WT.task_name, '') AS unit_name,
IM.part_objid AS part_objid,
P.part_no AS part_no,
P.part_name AS part_name,
P.material AS material,
P.spec AS spec,
P.part_type AS part_type,
P.part_type AS part_type_name,
IM.cls_cd AS cls_cd,
IM.cau_cd AS cau_cd,
IM.location AS location,
IM.sub_location AS sub_location,
COALESCE(LL.loc_names, IM.location, '') AS location_name,
IM.reg_date AS reg_date,
IM.price AS price,
IM.writer AS writer,
COALESCE(user_name(IM.writer), IM.writer, '') AS writer_name,
COALESCE(IS_.in_qty, 0) - COALESCE(OS.req_qty, 0) AS use_cnt,
COALESCE(OS.req_qty, 0) AS request_qty,
COALESCE(OS.out_qty, 0) AS out_qty_total,
SUM(COALESCE(IS_.in_qty, 0) - COALESCE(OS.req_qty, 0))
OVER (PARTITION BY IM.part_objid) AS use_cnt_all,
'' AS remark
FROM inventory_mgmt IM
LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid
LEFT JOIN contract_mgmt CT ON CT.objid = IM.contract_objid
LEFT JOIN project_mgmt PJ ON PJ.contract_objid = IM.contract_objid
LEFT JOIN pms_wbs_task WT ON WT.contract_objid = IM.contract_objid AND WT.task_seq = IM.unit
LEFT JOIN IN_SUM IS_ ON IS_.parent_objid = IM.objid
LEFT JOIN OUT_SUM OS ON OS.parent_objid = IM.objid
LEFT JOIN LOC_LIST LL ON LL.parent_objid = IM.objid
${whereSql}
ORDER BY IM.reg_date DESC NULLS LAST, IM.objid DESC
LIMIT ${addP(limit)} OFFSET ${addP(offset)}
`;
const countSql = `
SELECT COUNT(*)::int AS cnt
FROM inventory_mgmt IM
LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid
${whereSql}
`;
try {
const [d, c] = await Promise.all([
pool.query(dataSql, params),
pool.query(countSql, params.slice(0, params.length - 2)),
]);
return { rows: d.rows, totalCount: c.rows[0]?.cnt ?? 0, page, pageSize };
} catch (e: any) {
logger.error("listInventory 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 재고 등록 (자재 신규/추가입고) ───────────────────────────
// 1) inventory_mgmt: (contract_objid+unit+part_objid) UNIQUE → 존재 시 재사용, 없으면 INSERT
// 2) inventory_mgmt_in: 입고 1건 INSERT
export interface SaveInventoryInput {
project_objid: string; // contract_objid
unit?: string;
part_objid: string;
qty: number;
price?: string;
location: string;
sub_location?: string;
cls_cd?: string;
cau_cd?: string;
receipt_date?: string;
writer?: string;
order_objid?: string;
}
export async function saveInventory(input: SaveInventoryInput): Promise<{ objid: string; in_objid: string }> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const probe = await client.query(
`SELECT objid FROM inventory_mgmt
WHERE contract_objid = $1 AND unit = $2 AND part_objid = $3
LIMIT 1`,
[input.project_objid, input.unit ?? "", input.part_objid],
);
let objid = probe.rows[0]?.objid as string | undefined;
if (!objid) {
objid = createObjId();
await client.query(
`INSERT INTO inventory_mgmt
(objid, contract_objid, unit, part_objid, cls_cd, cau_cd, qty, location, sub_location,
reg_date, price, writer)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9, TO_CHAR(NOW(),'YYYY-MM-DD'), $10, $11)`,
[objid, input.project_objid, input.unit ?? "", input.part_objid,
input.cls_cd ?? "", input.cau_cd ?? "", String(input.qty),
input.location, input.sub_location ?? "",
input.price ?? "", input.writer ?? ""],
);
}
const inObjid = createObjId();
await client.query(
`INSERT INTO inventory_mgmt_in
(objid, parent_objid, receipt_qty, location, sub_location, writer, regdate,
contract_mgmt_objid, purchase_order_master_objid, receipt_date)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $8, $9)`,
[inObjid, objid, String(input.qty), input.location, input.sub_location ?? "",
input.writer ?? "", input.project_objid,
input.order_objid ?? "", input.receipt_date ?? new Date().toISOString().slice(0, 10)],
);
await client.query("COMMIT");
return { objid, in_objid: inObjid };
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
// ─── 재고 삭제 ─────────────────────────────────────────────
export async function deleteInventory(objidArr: string[]): Promise<{ deleted: number }> {
if (!objidArr.length) return { deleted: 0 };
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query(`DELETE FROM inventory_mgmt_in WHERE parent_objid = ANY($1::varchar[])`, [objidArr]);
await client.query(`DELETE FROM inventory_mgmt_out WHERE parent_objid = ANY($1::varchar[])`, [objidArr]);
const r = await client.query(`DELETE FROM inventory_mgmt WHERE objid = ANY($1::varchar[])`, [objidArr]);
await client.query("COMMIT");
return { deleted: r.rowCount ?? 0 };
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
// ─── 자재이동: inventory_mgmt_in 행에 move_qty/move_date/move_user 누적 ──
export interface MoveInventoryInput {
in_objid: string;
move_qty: number;
location?: string;
sub_location?: string;
move_date?: string;
move_user?: string;
writer?: string;
}
export async function moveInventoryBulk(items: MoveInventoryInput[]): Promise<{ updated: number }> {
if (!items.length) return { updated: 0 };
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
for (const it of items) {
const prev = await client.query(
`SELECT COALESCE(NULLIF(move_qty,''),'0')::numeric AS mq, parent_objid
FROM inventory_mgmt_in WHERE objid = $1`,
[it.in_objid],
);
const parent = prev.rows[0]?.parent_objid as string | undefined;
if (!parent) continue;
const newMove = Number(prev.rows[0].mq) + Number(it.move_qty);
await client.query(
`UPDATE inventory_mgmt_in
SET move_qty = $1,
move_date = COALESCE($2, move_date),
move_user = COALESCE($3, move_user)
WHERE objid = $4`,
[String(newMove), it.move_date ?? null, it.move_user ?? null, it.in_objid],
);
// 이동 이력 라인 추가
await client.query(
`INSERT INTO inventory_mgmt_in
(objid, parent_objid, receipt_qty, location, sub_location, writer, regdate,
move_qty, move_date, move_user)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $8, $9)`,
[createObjId(), parent, String(it.move_qty),
it.location ?? "", it.sub_location ?? "",
it.writer ?? it.move_user ?? "",
String(it.move_qty), it.move_date ?? null, it.move_user ?? null],
);
}
await client.query("COMMIT");
return { updated: items.length };
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
// ─── 자재 입출고 이력 ─────────────────────────────────────────
export async function getInventoryHistory(parentObjid: string): Promise<any[]> {
const pool = getPool();
const sql = `
-- 입고
SELECT
II.objid AS objid,
P.part_no AS part_no,
P.part_name AS part_name,
'입고' AS gubun,
COALESCE(II.receipt_qty, '') AS qty,
II.location AS location_name,
II.sub_location AS sub_location_name,
II.regdate AS regdate,
II.writer AS writer,
COALESCE(user_name(II.writer), II.writer,'') AS writer_name
FROM inventory_mgmt_in II
LEFT JOIN inventory_mgmt IM ON IM.objid = II.parent_objid
LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid
WHERE II.parent_objid = $1 AND COALESCE(II.move_date, '') = ''
UNION ALL
-- 이동
SELECT
II.objid, P.part_no, P.part_name, '이동',
COALESCE(II.move_qty, II.receipt_qty, ''),
II.location, II.sub_location, II.regdate,
II.move_user,
COALESCE(user_name(II.move_user), II.move_user, '')
FROM inventory_mgmt_in II
LEFT JOIN inventory_mgmt IM ON IM.objid = II.parent_objid
LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid
WHERE II.parent_objid = $1 AND COALESCE(II.move_date, '') <> ''
UNION ALL
-- 출고
SELECT
IO.objid, P.part_no, P.part_name, '출고',
COALESCE(IO.out_qty, IO.request_qty, ''),
'', '', IO.regdate,
IO.writer,
COALESCE(user_name(IO.writer), IO.writer, '')
FROM inventory_mgmt_out IO
LEFT JOIN inventory_mgmt IM ON IM.objid = IO.parent_objid
LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid
WHERE IO.parent_objid = $1
ORDER BY regdate DESC NULLS LAST
`;
const r = await pool.query(sql, [parentObjid]);
return r.rows;
}
// ─── 불출의뢰서 그리드 ─────────────────────────────────────────
export async function listIssueRequest(filter: IssueRequestFilter): Promise<ListResult<any>> {
const pool = getPool();
const { page, pageSize, limit, offset } = paging(filter);
const where: string[] = [];
const params: any[] = [];
const addP = (v: any) => { params.push(v); return `$${params.length}`; };
if (filter.request_user) where.push(`OM.request_id = ${addP(filter.request_user)}`);
if (filter.reception_user) where.push(`OM.reception_id = ${addP(filter.reception_user)}`);
if (filter.reception_status) {
if (filter.reception_status === "reception") where.push(`OM.reception_status = 'reception'`);
else if (filter.reception_status === "AA") where.push(`COALESCE(OM.reception_status,'') <> 'reception'`);
}
if (filter.out_status) {
if (filter.out_status === "complete") where.push(`OM.outstatus = 'complete'`);
else if (filter.out_status === "NG") where.push(`COALESCE(OM.outstatus,'') <> 'complete'`);
}
if (filter.request_start_date) where.push(`OM.request_date >= ${addP(filter.request_start_date)}`);
if (filter.request_end_date) where.push(`OM.request_date <= ${addP(filter.request_end_date)}`);
if (filter.reception_start_date) where.push(`OM.reception_date >= ${addP(filter.reception_start_date)}`);
if (filter.reception_end_date) where.push(`OM.reception_date <= ${addP(filter.reception_end_date)}`);
if (filter.part_no) {
where.push(`EXISTS (
SELECT 1 FROM inventory_mgmt_out IO
JOIN inventory_mgmt IM ON IM.objid = IO.parent_objid
JOIN part_mng P ON P.objid::varchar = IM.part_objid
WHERE IO.inventory_request_master_objid = OM.objid
AND P.part_no ILIKE ${addP(`%${filter.part_no}%`)}
)`);
}
if (filter.part_name) {
where.push(`EXISTS (
SELECT 1 FROM inventory_mgmt_out IO
JOIN inventory_mgmt IM ON IM.objid = IO.parent_objid
JOIN part_mng P ON P.objid::varchar = IM.part_objid
WHERE IO.inventory_request_master_objid = OM.objid
AND P.part_name ILIKE ${addP(`%${filter.part_name}%`)}
)`);
}
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const dataSql = `
SELECT
OM.objid AS objid,
OM.inventory_out_no AS inventory_out_no,
OM.request_date AS request_date,
OM.request_id AS request_id,
COALESCE(user_name(OM.request_id), OM.request_id,'') AS request_user_name,
OM.reception_status AS reception_status,
CASE WHEN OM.reception_status='reception' THEN '접수' ELSE '미접수' END AS reception_status_title,
OM.reception_id AS reception_id,
COALESCE(user_name(OM.reception_id), OM.reception_id,'') AS reception_user_name,
OM.reception_date AS reception_date,
OM.outstatus AS outstatus,
CASE WHEN OM.outstatus='complete' THEN '완료' ELSE '미완료' END AS outstatus_title,
OM.remark AS remark,
OM.writer AS writer,
OM.regdate AS regdate,
COALESCE((
SELECT STRING_AGG(P.part_no, ',' ORDER BY P.part_no)
FROM inventory_mgmt_out IO
JOIN inventory_mgmt IM ON IM.objid = IO.parent_objid
JOIN part_mng P ON P.objid::varchar = IM.part_objid
WHERE IO.inventory_request_master_objid = OM.objid
), '') AS part_no_arr,
COALESCE((
SELECT STRING_AGG(P.part_name, ',' ORDER BY P.part_no)
FROM inventory_mgmt_out IO
JOIN inventory_mgmt IM ON IM.objid = IO.parent_objid
JOIN part_mng P ON P.objid::varchar = IM.part_objid
WHERE IO.inventory_request_master_objid = OM.objid
), '') AS part_name_arr,
COALESCE((
SELECT SUM(COALESCE(NULLIF(IO.request_qty,'')::numeric,0))
FROM inventory_mgmt_out IO
WHERE IO.inventory_request_master_objid = OM.objid
), 0) AS request_qty_total,
COALESCE((
SELECT SUM(COALESCE(NULLIF(IO.out_qty,'')::numeric,0))
FROM inventory_mgmt_out IO
WHERE IO.inventory_request_master_objid = OM.objid
), 0) AS out_qty_total
FROM inventory_mgmt_out_master OM
${whereSql}
ORDER BY OM.regdate DESC NULLS LAST, OM.objid DESC
LIMIT ${addP(limit)} OFFSET ${addP(offset)}
`;
const countSql = `SELECT COUNT(*)::int AS cnt FROM inventory_mgmt_out_master OM ${whereSql}`;
try {
const [d, c] = await Promise.all([
pool.query(dataSql, params),
pool.query(countSql, params.slice(0, params.length - 2)),
]);
return { rows: d.rows, totalCount: c.rows[0]?.cnt ?? 0, page, pageSize };
} catch (e: any) {
logger.error("listIssueRequest 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 불출의뢰 상세: 마스터 + 라인 ────────────────────────────
export async function getIssueRequest(masterObjid: string): Promise<{ master: any; lines: any[] }> {
const pool = getPool();
const master = await pool.query(
`SELECT OM.*,
COALESCE(user_name(OM.request_id), OM.request_id, '') AS request_user_name,
COALESCE(user_name(OM.reception_id),OM.reception_id,'') AS reception_user_name
FROM inventory_mgmt_out_master OM
WHERE OM.objid = $1`,
[masterObjid],
);
const lines = await pool.query(
`SELECT
IO.objid AS objid,
IO.parent_objid AS parent_objid,
IO.request_qty AS request_qty,
IO.out_qty AS out_qty,
IO.out_date AS out_date,
IO.writer AS writer,
COALESCE(user_name(IO.writer), IO.writer, '') AS writer_name,
IO.acq_user AS acq_user,
COALESCE(user_name(IO.acq_user), IO.acq_user, '') AS acq_user_name,
IO.sign AS sign,
IO.unit AS unit,
IO.contract_mgmt_objid AS contract_mgmt_objid,
IM.location AS location,
IM.sub_location AS sub_location,
IM.part_objid AS part_objid,
P.part_no AS part_no,
P.part_name AS part_name,
P.material AS material,
P.spec AS spec,
P.part_type AS part_type
FROM inventory_mgmt_out IO
LEFT JOIN inventory_mgmt IM ON IM.objid = IO.parent_objid
LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid
WHERE IO.inventory_request_master_objid = $1
ORDER BY P.part_no NULLS LAST, IO.objid`,
[masterObjid],
);
return { master: master.rows[0] ?? null, lines: lines.rows };
}
// ─── 불출 가능 자재 후보 (자재리스트에서 선택한 OBJID들 기반) ────
export async function getIssueRequestCandidates(parentObjids: string[]): Promise<any[]> {
if (!parentObjids.length) return [];
const pool = getPool();
const r = await pool.query(
`SELECT
IM.objid AS objid,
IM.contract_objid AS contract_objid,
COALESCE(PJ.project_no, CT.contract_no, '') AS project_no,
IM.unit AS unit,
COALESCE(WT.task_name, '') AS unit_name,
P.part_no AS part_no,
P.part_name AS part_name,
P.material AS material,
P.spec AS spec,
P.part_type AS part_type_name,
IM.location AS location,
IM.sub_location AS sub_location,
(
COALESCE((SELECT SUM(
COALESCE(NULLIF(receipt_qty,'')::numeric,0)
- COALESCE(NULLIF(move_qty,'')::numeric,0))
FROM inventory_mgmt_in WHERE parent_objid = IM.objid), 0)
- COALESCE((SELECT SUM(COALESCE(NULLIF(request_qty,'')::numeric,0))
FROM inventory_mgmt_out WHERE parent_objid = IM.objid), 0)
) AS use_cnt
FROM inventory_mgmt IM
LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid
LEFT JOIN contract_mgmt CT ON CT.objid = IM.contract_objid
LEFT JOIN project_mgmt PJ ON PJ.contract_objid = IM.contract_objid
LEFT JOIN pms_wbs_task WT ON WT.contract_objid = IM.contract_objid AND WT.task_seq = IM.unit
WHERE IM.objid = ANY($1::varchar[])`,
[parentObjids],
);
return r.rows;
}
// ─── 불출의뢰 생성/수정 ───────────────────────────────────────
export interface IssueRequestSaveInput {
master_objid?: string; // 수정 시 기존 키
contract_mgmt_objid?: string;
request_date?: string;
request_id?: string;
remark?: string;
writer: string;
lines: Array<{
parent_objid: string;
request_qty: number;
unit?: string;
}>;
}
export async function saveIssueRequest(input: IssueRequestSaveInput): Promise<{ master_objid: string; inventory_out_no: string }> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const today = new Date().toISOString().slice(0, 10);
let masterObjid = input.master_objid;
let inventoryOutNo = "";
if (masterObjid) {
await client.query(
`UPDATE inventory_mgmt_out_master
SET remark = COALESCE($1, remark),
request_date = COALESCE($2, request_date),
contract_mgmt_objid = COALESCE($3, contract_mgmt_objid)
WHERE objid = $4`,
[input.remark ?? null, input.request_date ?? today, input.contract_mgmt_objid ?? null, masterObjid],
);
const m = await client.query(
`SELECT inventory_out_no FROM inventory_mgmt_out_master WHERE objid = $1`,
[masterObjid],
);
inventoryOutNo = m.rows[0]?.inventory_out_no ?? "";
await client.query(
`DELETE FROM inventory_mgmt_out WHERE inventory_request_master_objid = $1`,
[masterObjid],
);
} else {
masterObjid = createObjId();
const seq = await client.query(
`SELECT 'Rfw-' || TO_CHAR(NOW(),'YYYY') || '-' ||
(COALESCE(MAX(NULLIF(SPLIT_PART(inventory_out_no, '-', 3),'')::int), 0) + 1)::text
AS no
FROM inventory_mgmt_out_master
WHERE inventory_out_no LIKE 'Rfw-' || TO_CHAR(NOW(),'YYYY') || '-%'`,
);
inventoryOutNo = seq.rows[0]?.no ?? `Rfw-${new Date().getFullYear()}-1`;
await client.query(
`INSERT INTO inventory_mgmt_out_master
(objid, inventory_out_no, request_date, request_id, writer, regdate, remark, contract_mgmt_objid)
VALUES ($1, $2, $3, $4, $5, NOW(), $6, $7)`,
[masterObjid, inventoryOutNo, input.request_date ?? today,
input.request_id ?? input.writer, input.writer, input.remark ?? "",
input.contract_mgmt_objid ?? ""],
);
}
for (const ln of input.lines) {
if (!ln.parent_objid || !ln.request_qty || ln.request_qty <= 0) continue;
const ioObjid = createObjId();
await client.query(
`INSERT INTO inventory_mgmt_out
(objid, parent_objid, request_qty, writer, regdate,
inventory_request_master_objid, contract_mgmt_objid, unit)
VALUES ($1, $2, $3, $4, NOW(), $5, $6, $7)`,
[ioObjid, ln.parent_objid, String(ln.request_qty), input.writer,
masterObjid, input.contract_mgmt_objid ?? "", ln.unit ?? ""],
);
}
await client.query("COMMIT");
return { master_objid: masterObjid!, inventory_out_no: inventoryOutNo };
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
// ─── 불출의뢰 삭제 ─────────────────────────────────────────
export async function deleteIssueRequest(masterObjid: string): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query(`DELETE FROM inventory_mgmt_out WHERE inventory_request_master_objid = $1`, [masterObjid]);
await client.query(`DELETE FROM inventory_mgmt_out_master WHERE objid = $1`, [masterObjid]);
await client.query("COMMIT");
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
// ─── 접수 처리 ────────────────────────────────────────────
export async function receiveIssueRequest(objids: string[], receptionId: string): Promise<{ updated: number }> {
if (!objids.length) return { updated: 0 };
const pool = getPool();
const today = new Date().toISOString().slice(0, 10);
const r = await pool.query(
`UPDATE inventory_mgmt_out_master
SET reception_status = 'reception',
reception_date = $1,
reception_id = $2
WHERE objid = ANY($3::varchar[])
AND COALESCE(reception_status,'') <> 'reception'`,
[today, receptionId, objids],
);
return { updated: r.rowCount ?? 0 };
}
// ─── 자재불출 처리 (실제 불출 + 재고 차감) ──────────────────
export interface DispatchInput {
master_objid: string;
lines: Array<{
objid: string; // inventory_mgmt_out.objid
out_qty: number;
out_date: string;
acq_user: string;
sign?: string;
writer?: string;
}>;
}
export async function dispatchIssueRequest(input: DispatchInput): Promise<{ updated: number }> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
for (const ln of input.lines) {
await client.query(
`UPDATE inventory_mgmt_out
SET out_qty = $1,
out_date = $2,
acq_user = $3,
sign = COALESCE($4, sign),
writer = COALESCE($5, writer)
WHERE objid = $6`,
[String(ln.out_qty), ln.out_date, ln.acq_user, ln.sign ?? null, ln.writer ?? null, ln.objid],
);
}
await client.query(
`UPDATE inventory_mgmt_out_master
SET outstatus = 'complete'
WHERE objid = $1`,
[input.master_objid],
);
await client.query("COMMIT");
return { updated: input.lines.length };
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
// ─── 옵션: 프로젝트 / 유닛 / Location / 사용자 ──────────────
export async function listProjectOptions(): Promise<Array<{ code: string; label: string }>> {
const pool = getPool();
const r = await pool.query(
`SELECT DISTINCT PM.contract_objid AS code,
COALESCE(PM.project_no, CT.contract_no, PM.contract_objid) AS label
FROM project_mgmt PM
LEFT JOIN contract_mgmt CT ON CT.objid = PM.contract_objid
WHERE PM.contract_objid IS NOT NULL
ORDER BY label`,
);
return r.rows;
}
export async function listUnitOptions(contractObjid: string): Promise<Array<{ code: string; label: string }>> {
if (!contractObjid) return [];
const pool = getPool();
const r = await pool.query(
`SELECT task_seq AS code, task_name AS label
FROM pms_wbs_task
WHERE contract_objid = $1 AND COALESCE(task_seq,'') <> ''
ORDER BY task_seq`,
[contractObjid],
);
return r.rows;
}
export async function listUserOptions(): Promise<Array<{ code: string; label: string }>> {
const pool = getPool();
const r = await pool.query(
`SELECT user_id AS code, COALESCE(user_name, user_id) AS label
FROM user_info
WHERE COALESCE(status,'') <> 'inactive'
ORDER BY label`,
);
return r.rows;
}
export async function listPartOptions(keyword: string, limit: number = 30): Promise<Array<{ code: string; label: string; part_name: string }>> {
const pool = getPool();
const kw = keyword?.trim() ?? "";
const params: any[] = [];
const conds: string[] = [];
if (kw) {
params.push(`%${kw}%`);
conds.push(`(part_no ILIKE $1 OR part_name ILIKE $1)`);
}
params.push(limit);
const r = await pool.query(
`SELECT objid::varchar AS code, part_no AS label, part_name
FROM part_mng
${conds.length ? `WHERE ${conds.join(" AND ")}` : ""}
ORDER BY part_no
LIMIT $${params.length}`,
params,
);
return r.rows;
}
@@ -0,0 +1,31 @@
-- ====================================================================
-- inventory_mgmt — 자재 마스터 (품번 + Location 단위 자재 정보)
-- ====================================================================
-- 출처: wace_plm 운영 DB 211.115.91.141:11133/waceplm (PG 16.8)
-- 추출일: 2026-05-15
-- 자식: inventory_mgmt_in (입고/이동 히스토리), inventory_mgmt_out (불출 라인)
-- ====================================================================
CREATE TABLE IF NOT EXISTS inventory_mgmt (
objid VARCHAR NOT NULL,
contract_objid VARCHAR NOT NULL,
unit VARCHAR(100) NOT NULL,
part_objid VARCHAR(100) NOT NULL,
cls_cd VARCHAR(100),
cau_cd VARCHAR(100),
qty VARCHAR(20),
location VARCHAR(20) NOT NULL DEFAULT '',
sub_location VARCHAR(20) NOT NULL DEFAULT '',
reg_date VARCHAR(10),
price VARCHAR(20),
writer VARCHAR(20),
input_contract_objid VARCHAR,
input_qty VARCHAR,
input_date VARCHAR,
assumption_user VARCHAR,
successor_user VARCHAR,
CONSTRAINT inventory_mgmt_pkey PRIMARY KEY (contract_objid, unit, part_objid)
);
CREATE INDEX IF NOT EXISTS inventory_mgmt_objid_idx ON inventory_mgmt (objid);
CREATE INDEX IF NOT EXISTS inventory_mgmt_part_objid_idx ON inventory_mgmt (part_objid);
@@ -0,0 +1,33 @@
-- ====================================================================
-- inventory_mgmt_in — 자재 입고/이동 히스토리 라인
-- ====================================================================
-- 출처: wace_plm 운영 DB 211.115.91.141:11133/waceplm
-- 추출일: 2026-05-15
-- 부모: inventory_mgmt.objid → parent_objid
-- 외부키: out_objid (콤마분리 inventory_mgmt_out.objid 누적)
-- ====================================================================
CREATE TABLE IF NOT EXISTS inventory_mgmt_in (
objid VARCHAR NOT NULL,
parent_objid VARCHAR,
receipt_qty VARCHAR,
location VARCHAR,
sub_location VARCHAR,
writer VARCHAR,
regdate TIMESTAMP,
contract_mgmt_objid VARCHAR,
purchase_order_master_objid VARCHAR,
purchase_order_sub_objid VARCHAR,
out_objid VARCHAR,
out_qty VARCHAR,
move_objid VARCHAR,
move_qty VARCHAR,
move_date VARCHAR,
move_user VARCHAR,
request_qty VARCHAR,
receipt_date VARCHAR,
CONSTRAINT inventory_mgmt_in_pkey PRIMARY KEY (objid)
);
CREATE INDEX IF NOT EXISTS inventory_mgmt_in_parent_objid_idx ON inventory_mgmt_in (parent_objid);
CREATE INDEX IF NOT EXISTS inventory_mgmt_in_contract_mgmt_objid_idx ON inventory_mgmt_in (contract_mgmt_objid);
@@ -0,0 +1,28 @@
-- ====================================================================
-- inventory_mgmt_out — 불출 라인 (의뢰 + 실제 불출)
-- ====================================================================
-- 출처: wace_plm 운영 DB 211.115.91.141:11133/waceplm
-- 추출일: 2026-05-15
-- 부모: inventory_mgmt.objid → parent_objid (자재 마스터)
-- 부모2: inventory_mgmt_out_master.objid → inventory_request_master_objid
-- 흐름: REQUEST_QTY 먼저 입력 (의뢰) → OUT_QTY/OUT_DATE/ACQ_USER/SIGN 입력 (불출)
-- ====================================================================
CREATE TABLE IF NOT EXISTS inventory_mgmt_out (
objid VARCHAR NOT NULL,
parent_objid VARCHAR,
request_qty VARCHAR,
out_qty VARCHAR,
out_date VARCHAR,
writer VARCHAR,
acq_user VARCHAR,
regdate TIMESTAMP,
inventory_request_master_objid VARCHAR,
sign VARCHAR,
contract_mgmt_objid VARCHAR,
unit VARCHAR,
CONSTRAINT inventory_mgmt_out_pkey PRIMARY KEY (objid)
);
CREATE INDEX IF NOT EXISTS inventory_mgmt_out_parent_objid_idx ON inventory_mgmt_out (parent_objid);
CREATE INDEX IF NOT EXISTS inventory_mgmt_out_master_objid_idx ON inventory_mgmt_out (inventory_request_master_objid);
@@ -0,0 +1,32 @@
-- ====================================================================
-- inventory_mgmt_out_master — 불출의뢰 마스터 (Rfw-YYYY-seq)
-- ====================================================================
-- 출처: wace_plm 운영 DB 211.115.91.141:11133/waceplm
-- 추출일: 2026-05-15
-- 자식: inventory_mgmt_out (불출 라인) → inventory_request_master_objid
-- 상태:
-- reception_status='reception' / ''=미접수
-- outstatus='complete' / ''=미완료
-- 채번: inventory_out_no = 'Rfw-' || YYYY || '-' || seq
-- ====================================================================
CREATE TABLE IF NOT EXISTS inventory_mgmt_out_master (
objid VARCHAR NOT NULL,
parent_objid VARCHAR,
inventory_out_no VARCHAR,
request_date VARCHAR,
request_id VARCHAR,
reception_status VARCHAR,
reception_id VARCHAR,
reception_date VARCHAR,
outstatus VARCHAR,
writer VARCHAR,
regdate TIMESTAMP,
remark VARCHAR,
contract_mgmt_objid VARCHAR,
sign VARCHAR,
CONSTRAINT inventory_mgmt_out_master_pkey PRIMARY KEY (objid)
);
CREATE INDEX IF NOT EXISTS inventory_mgmt_out_master_no_idx ON inventory_mgmt_out_master (inventory_out_no);
CREATE INDEX IF NOT EXISTS inventory_mgmt_out_master_status_idx ON inventory_mgmt_out_master (reception_status, outstatus);
@@ -0,0 +1,18 @@
-- ====================================================================
-- inventory_mgmt_history — 자재 투입(인계/인수) 이력
-- ====================================================================
-- 출처: wace_plm 운영 DB 211.115.91.141:11133/waceplm
-- 추출일: 2026-05-15
-- ====================================================================
CREATE TABLE IF NOT EXISTS inventory_mgmt_history (
objid VARCHAR NOT NULL,
parent_objid VARCHAR,
contract_objid VARCHAR,
reg_date VARCHAR,
input_qty VARCHAR,
input_date VARCHAR,
assumption_user VARCHAR,
successor_user VARCHAR,
CONSTRAINT inventory_mgmt_history_pkey PRIMARY KEY (objid)
);
@@ -0,0 +1,252 @@
"use client";
// 자재관리 > 불출의뢰서 — wace inventoryMng/materialRequestList.jsp 1:1
// 그리드: inventory_mgmt_out_master + inventory_mgmt_out 집계
// 검색: 품번/품명 / 불출의뢰일 / 의뢰자 / 접수상태 / 접수자 / 접수일 / 불출상태
// 액션: 자재불출 / 접수 / 삭제 / 조회
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { CheckCircle2, Trash2, PackageMinus } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { SmartSelect } from "@/components/common/SmartSelect";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { PageHeader } from "@/components/common/PageHeader";
import { exportToExcel } from "@/lib/utils/excelExport";
import { inventoryMngApi, IssueRequestFilter, OptionItem } from "@/lib/api/inventoryMng";
import { IssueDispatchDialog } from "@/components/material/IssueDispatchDialog";
const EMPTY: IssueRequestFilter = {
part_no: "", part_name: "",
request_start_date: "", request_end_date: "",
request_user: "", reception_status: "",
reception_user: "", reception_start_date: "", reception_end_date: "",
out_status: "",
page: 1, page_size: 50,
};
const RECEPTION_OPTS = [
{ code: "reception", label: "접수" },
{ code: "AA", label: "미접수" },
];
const OUT_STATUS_OPTS = [
{ code: "complete", label: "완료" },
{ code: "NG", label: "미완료" },
];
export default function MaterialIssueRequestPage() {
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<IssueRequestFilter>(EMPTY);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const [dispatchOpen, setDispatchOpen] = useState(false);
const [dispatchTarget, setDispatchTarget] = useState<{ objid: string; readOnly: boolean } | null>(null);
const fetchList = useCallback(async (override?: Partial<IssueRequestFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await inventoryMngApi.listIssue(f);
setRows(res.rows ?? []);
setTotal(res.totalCount ?? 0);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
(async () => {
try { setUserOpts(await inventoryMngApi.users()); } catch { /* skip */ }
})();
fetchList(EMPTY);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(
() => rows.map((r) => ({ ...r, id: r.objid })),
[rows],
);
const checkedRows = useMemo(
() => gridRows.filter((r: any) => checkedIds.includes(r.id)),
[checkedIds, gridRows],
);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "inventory_out_no", label: "자재불출번호", width: "w-[140px]", align: "center" },
{ key: "part_no_arr", label: "품번", minWidth: "min-w-[200px]" },
{ key: "part_name_arr", label: "품명", minWidth: "min-w-[260px]" },
{ key: "request_date", label: "불출의뢰일", width: "w-[110px]", align: "center" },
{ key: "request_user_name", label: "의뢰자", width: "w-[100px]", align: "center" },
{ key: "reception_status_title",label: "상태", width: "w-[80px]", align: "center" },
{ key: "reception_user_name", label: "접수자", width: "w-[100px]", align: "center" },
{ key: "reception_date", label: "접수일", width: "w-[110px]", align: "center" },
{ key: "outstatus_title", label: "불출상태", width: "w-[90px]", align: "center" },
{ key: "request_qty_total", label: "의뢰수량합", width: "w-[100px]", align: "right",
render: (v: any) => Number(v ?? 0).toLocaleString() },
{ key: "out_qty_total", label: "불출수량합", width: "w-[100px]", align: "right",
render: (v: any) => Number(v ?? 0).toLocaleString() },
{ key: "remark", label: "특이사항", minWidth: "min-w-[160px]" },
]), []);
const summary = useMemo(() => ([
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
{ label: "선택", value: checkedIds.length.toLocaleString(), suffix: "건" },
]), [total, checkedIds]);
const handleSearch = () => { setFilter(f => ({ ...f, page: 1 })); fetchList({ page: 1 }); };
const handleReset = () => { setFilter(EMPTY); setCheckedIds([]); fetchList(EMPTY); };
const handleReceive = async () => {
const targets = checkedRows.filter((r: any) => r.reception_status !== "reception");
if (!targets.length) return toast.info("미접수 상태인 항목을 선택해주세요.");
try {
const r = await inventoryMngApi.receiveIssue(targets.map((t: any) => t.objid));
toast.success(`${r.updated}건 접수 처리되었습니다.`);
setCheckedIds([]);
fetchList();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "접수 실패");
}
};
const handleDispatch = () => {
if (checkedRows.length !== 1) return toast.info("자재불출은 1건씩 처리합니다.");
const r = checkedRows[0] as any;
if (r.reception_status !== "reception") return toast.warning("접수된 항목만 불출 가능합니다.");
if (r.outstatus === "complete") return toast.warning("이미 불출완료된 항목입니다.");
setDispatchTarget({ objid: r.objid, readOnly: false });
setDispatchOpen(true);
};
const handleDelete = async () => {
if (checkedRows.length !== 1) return toast.info("1건씩 삭제하세요.");
const r = checkedRows[0] as any;
if (r.outstatus === "complete") return toast.warning("이미 불출완료된 항목은 삭제할 수 없습니다.");
if (!confirm("선택한 불출의뢰서를 삭제하시겠습니까?")) return;
try {
await inventoryMngApi.deleteIssue(r.objid);
toast.success("삭제되었습니다.");
setCheckedIds([]);
fetchList();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패");
}
};
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader
loading={loading} onSearch={handleSearch} onReset={handleReset}
actions={<>
<Button size="sm" variant="default" className="h-8 gap-1 px-2 text-xs"
onClick={handleDispatch} disabled={checkedRows.length !== 1}>
<PackageMinus className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
onClick={handleReceive} disabled={!checkedRows.length}>
<CheckCircle2 className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" className="h-8 gap-1 px-2 text-xs text-red-600"
onClick={handleDelete} disabled={checkedRows.length !== 1}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>}
/>
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="품번" width={160}>
<Input value={filter.part_no ?? ""}
onChange={(e) => setFilter({ ...filter, part_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품명" width={170}>
<Input value={filter.part_name ?? ""}
onChange={(e) => setFilter({ ...filter, part_name: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="불출의뢰일" width={280}>
<CompactDateRange
from={filter.request_start_date ?? ""} setFrom={(v) => setFilter({ ...filter, request_start_date: v })}
to={filter.request_end_date ?? ""} setTo={(v) => setFilter({ ...filter, request_end_date: v })}
/>
</CompactFilterField>
<CompactFilterField label="의뢰자" width={170}>
<SmartSelect options={userOpts} value={filter.request_user ?? ""}
onValueChange={(v) => setFilter({ ...filter, request_user: v })} />
</CompactFilterField>
<CompactFilterField label="접수상태" width={120}>
<SmartSelect options={RECEPTION_OPTS} value={filter.reception_status ?? ""}
onValueChange={(v) => setFilter({ ...filter, reception_status: v })} />
</CompactFilterField>
<CompactFilterField label="접수자" width={170}>
<SmartSelect options={userOpts} value={filter.reception_user ?? ""}
onValueChange={(v) => setFilter({ ...filter, reception_user: v })} />
</CompactFilterField>
<CompactFilterField label="접수일" width={280}>
<CompactDateRange
from={filter.reception_start_date ?? ""} setFrom={(v) => setFilter({ ...filter, reception_start_date: v })}
to={filter.reception_end_date ?? ""} setTo={(v) => setFilter({ ...filter, reception_end_date: v })}
/>
</CompactFilterField>
<CompactFilterField label="불출상태" width={120}>
<SmartSelect options={OUT_STATUS_OPTS} value={filter.out_status ?? ""}
onValueChange={(v) => setFilter({ ...filter, out_status: v })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
gridId="material-issue-request"
pageSizeOptions={[10, 15, 20, 50, 100]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "불출의뢰서.xlsx", "불출의뢰서");
}}
onRowDoubleClick={(row: any) => {
setDispatchTarget({ objid: row.objid, readOnly: true });
setDispatchOpen(true);
}}
showChart
/>
{dispatchTarget && (
<IssueDispatchDialog
open={dispatchOpen}
onClose={() => setDispatchOpen(false)}
onSaved={() => { setCheckedIds([]); fetchList(); }}
masterObjid={dispatchTarget.objid}
readOnly={dispatchTarget.readOnly}
/>
)}
</div>
);
}
@@ -0,0 +1,257 @@
"use client";
// 자재관리 > 자재리스트 — wace inventoryMng/inventoryMngNewList.jsp 1:1
// 그리드: inventory_mgmt + part_mng + 입출고 집계 (USE_CNT / USE_CNT_ALL / REQUEST_QTY)
// 검색: 프로젝트 / 유닛 / 품번 / 품명 / PART구분 / Location
// 액션: 재고등록 / 자재이동 / 불출의뢰 / 삭제 / 이력보기
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Plus, Repeat, ClipboardEdit, Trash2, History as HistoryIcon } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { SmartSelect } from "@/components/common/SmartSelect";
import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar";
import { PageHeader } from "@/components/common/PageHeader";
import { exportToExcel } from "@/lib/utils/excelExport";
import { inventoryMngApi, InventoryListFilter, OptionItem } from "@/lib/api/inventoryMng";
import { StockRegisterDialog } from "@/components/material/StockRegisterDialog";
import { MaterialMoveDialog } from "@/components/material/MaterialMoveDialog";
import { IssueRequestCreateDialog } from "@/components/material/IssueRequestCreateDialog";
import { InventoryHistoryDialog } from "@/components/material/InventoryHistoryDialog";
const EMPTY: InventoryListFilter = {
project_objid: "", unit_code: "",
part_no: "", part_name: "",
part_type: "", location: "",
page: 1, page_size: 50,
};
export default function MaterialListPage() {
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<InventoryListFilter>(EMPTY);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [projectOpts, setProjectOpts] = useState<OptionItem[]>([]);
const [unitOpts, setUnitOpts] = useState<OptionItem[]>([]);
const [stockOpen, setStockOpen] = useState(false);
const [moveOpen, setMoveOpen] = useState(false);
const [issueOpen, setIssueOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyTarget, setHistoryTarget] = useState<{ objid: string; label: string } | null>(null);
const fetchList = useCallback(async (override?: Partial<InventoryListFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await inventoryMngApi.list(f);
setRows(res.rows ?? []);
setTotal(res.totalCount ?? 0);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
(async () => {
try { setProjectOpts(await inventoryMngApi.projects()); } catch { /* skip */ }
})();
fetchList(EMPTY);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!filter.project_objid) { setUnitOpts([]); return; }
(async () => {
try { setUnitOpts(await inventoryMngApi.units(filter.project_objid!)); } catch { /* skip */ }
})();
}, [filter.project_objid]);
const gridRows = useMemo(
() => rows.map((r, i) => ({ ...r, id: `${r.objid}__${i}` })),
[rows],
);
const checkedObjids = useMemo(
() => gridRows.filter((r: any) => checkedIds.includes(r.id)).map((r: any) => r.objid as string),
[checkedIds, gridRows],
);
const checkedRows = useMemo(
() => gridRows.filter((r: any) => checkedIds.includes(r.id)),
[checkedIds, gridRows],
);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "project_no", label: "프로젝트번호", width: "w-[120px]", align: "center" },
{ key: "unit_name", label: "유닛명", width: "w-[150px]" },
{ key: "part_no", label: "품번", width: "w-[160px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[200px]" },
{ key: "material", label: "재질", width: "w-[110px]" },
{ key: "spec", label: "사양(규격)", minWidth: "min-w-[140px]" },
{ key: "part_type_name", label: "PART구분", width: "w-[100px]", align: "center" },
{ key: "use_cnt", label: "보유수량", width: "w-[100px]", align: "right",
render: (v: any) => Number(v ?? 0).toLocaleString() },
{ key: "use_cnt_all", label: "보유수량(전체)", width: "w-[110px]", align: "right",
render: (v: any) => Number(v ?? 0).toLocaleString() },
{ key: "location_name", label: "Location", width: "w-[140px]" },
{ key: "request_qty", label: "불출이력", width: "w-[100px]", align: "right",
render: (v: any) => Number(v ?? 0).toLocaleString() },
]), []);
const summary = useMemo(() => ([
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
{ label: "페이지 건수", value: gridRows.length.toLocaleString(), suffix: "건" },
{ label: "선택", value: checkedIds.length.toLocaleString(), suffix: "건" },
]), [gridRows, total, checkedIds]);
const handleSearch = () => { setFilter(f => ({ ...f, page: 1 })); fetchList({ page: 1 }); };
const handleReset = () => { setFilter(EMPTY); setCheckedIds([]); fetchList(EMPTY); };
const handleDelete = async () => {
if (!checkedObjids.length) return toast.info("삭제할 자재를 선택해주세요.");
if (!confirm(`선택한 ${checkedObjids.length}건의 자재를 삭제하시겠습니까?`)) return;
try {
await inventoryMngApi.deleteStock(checkedObjids);
toast.success("삭제되었습니다.");
setCheckedIds([]);
fetchList();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패");
}
};
const handleMove = () => {
if (!checkedRows.length) return toast.info("이동할 자재를 선택해주세요.");
if (checkedRows.some((r: any) => Number(r.use_cnt) === 0)) {
return toast.warning("보유수량이 0인 자재가 포함되어 있습니다.");
}
setMoveOpen(true);
};
const handleIssue = () => {
if (!checkedRows.length) return toast.info("불출의뢰할 자재를 선택해주세요.");
if (checkedRows.some((r: any) => Number(r.use_cnt) === 0)) {
return toast.warning("보유수량이 0인 자재가 포함되어 있습니다.");
}
setIssueOpen(true);
};
const handleHistoryOne = () => {
if (checkedRows.length !== 1) return toast.info("이력은 1건씩 조회합니다.");
const r = checkedRows[0] as any;
setHistoryTarget({ objid: r.objid, label: `${r.part_no} ${r.part_name ?? ""}` });
setHistoryOpen(true);
};
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader
loading={loading} onSearch={handleSearch} onReset={handleReset}
actions={<>
<Button size="sm" variant="default" className="h-8 gap-1 px-2 text-xs"
onClick={() => setStockOpen(true)}>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
onClick={handleMove} disabled={!checkedRows.length}>
<Repeat className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
onClick={handleIssue} disabled={!checkedRows.length}>
<ClipboardEdit className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" className="h-8 gap-1 px-2 text-xs"
onClick={handleHistoryOne} disabled={checkedRows.length !== 1}>
<HistoryIcon className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" className="h-8 gap-1 px-2 text-xs text-red-600"
onClick={handleDelete} disabled={!checkedRows.length}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>}
/>
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="프로젝트" width={200}>
<SmartSelect options={projectOpts} value={filter.project_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, project_objid: v, unit_code: "" })} />
</CompactFilterField>
<CompactFilterField label="유닛" width={180}>
<SmartSelect options={unitOpts} value={filter.unit_code ?? ""}
onValueChange={(v) => setFilter({ ...filter, unit_code: v })} />
</CompactFilterField>
<CompactFilterField label="품번" width={160}>
<Input value={filter.part_no ?? ""}
onChange={(e) => setFilter({ ...filter, part_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품명" width={170}>
<Input value={filter.part_name ?? ""}
onChange={(e) => setFilter({ ...filter, part_name: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="PART구분" width={120}>
<Input value={filter.part_type ?? ""}
onChange={(e) => setFilter({ ...filter, part_type: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="Location" width={150}>
<Input value={filter.location ?? ""}
onChange={(e) => setFilter({ ...filter, location: e.target.value })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
gridId="material-list"
pageSizeOptions={[10, 15, 20, 50, 100]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "자재리스트.xlsx", "자재리스트");
}}
showChart
/>
<StockRegisterDialog open={stockOpen} onClose={() => setStockOpen(false)}
onSaved={() => fetchList()} />
<MaterialMoveDialog open={moveOpen} onClose={() => setMoveOpen(false)}
onSaved={() => { setCheckedIds([]); fetchList(); }}
selectedRows={checkedRows} />
<IssueRequestCreateDialog open={issueOpen} onClose={() => setIssueOpen(false)}
onSaved={() => { setCheckedIds([]); fetchList(); }}
parentObjids={checkedObjids} />
{historyTarget && (
<InventoryHistoryDialog open={historyOpen} onClose={() => setHistoryOpen(false)}
parentObjid={historyTarget.objid} partLabel={historyTarget.label} />
)}
</div>
);
}
@@ -136,6 +136,8 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/COMPANY_16/outsourcing/subcontractor-item": dynamic(() => import("@/app/(main)/COMPANY_16/outsourcing/subcontractor-item/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/outsourcing/outbound": dynamic(() => import("@/app/(main)/COMPANY_16/outsourcing/outbound/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/purchase/order": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/order/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/material/list": dynamic(() => import("@/app/(main)/COMPANY_16/material/list/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/material/issue-request": dynamic(() => import("@/app/(main)/COMPANY_16/material/issue-request/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/purchase/purchase-item": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/purchase-item/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/purchase/supplier": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/supplier/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/quality/inspection": dynamic(() => import("@/app/(main)/COMPANY_16/quality/inspection/page"), { ssr: false, loading: LoadingFallback }),
@@ -355,6 +357,7 @@ const COMPANY_PAGE_PREFIXES = [
"/outsourcing/",
"/design/",
"/purchase/",
"/material/",
"/quality/",
"/mold/",
"/monitoring/",
@@ -0,0 +1,101 @@
"use client";
// 자재관리 > 자재리스트 — 입출고 이력 다이얼로그
// wace 1:1: inventoryRequestHistoryPopUp.jsp
// 입고/이동/출고 UNION 결과 노출
import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
import { inventoryMngApi } from "@/lib/api/inventoryMng";
import { toast } from "sonner";
interface HistoryRow {
objid: string;
part_no: string;
part_name: string;
gubun: string;
qty: string;
location_name: string;
sub_location_name: string;
regdate: string;
writer_name: string;
}
interface Props {
open: boolean;
onClose: () => void;
parentObjid: string; // inventory_mgmt.objid
partLabel?: string;
}
export function InventoryHistoryDialog({ open, onClose, parentObjid, partLabel }: Props) {
const [rows, setRows] = useState<HistoryRow[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || !parentObjid) return;
setLoading(true);
(async () => {
try {
const data = await inventoryMngApi.history(parentObjid);
setRows(data as HistoryRow[]);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "이력 조회 실패");
} finally {
setLoading(false);
}
})();
}, [open, parentObjid]);
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle> {partLabel ? `${partLabel}` : ""}</DialogTitle>
<DialogDescription> // .</DialogDescription>
</DialogHeader>
<div className="overflow-x-auto text-xs">
<table className="w-full border">
<thead className="bg-muted/50">
<tr>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1 text-right"></th>
<th className="border px-2 py-1">Location</th>
<th className="border px-2 py-1">Sub</th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
</tr>
</thead>
<tbody>
{loading && (
<tr><td colSpan={6} className="border px-2 py-3 text-center"> ...</td></tr>
)}
{!loading && rows.length === 0 && (
<tr><td colSpan={6} className="border px-2 py-3 text-center"> .</td></tr>
)}
{rows.map((r, i) => (
<tr key={i}>
<td className="border px-2 py-1">{r.gubun}</td>
<td className="border px-2 py-1 text-right">{r.qty || "0"}</td>
<td className="border px-2 py-1">{r.location_name}</td>
<td className="border px-2 py-1">{r.sub_location_name}</td>
<td className="border px-2 py-1">{r.writer_name}</td>
<td className="border px-2 py-1">{r.regdate?.toString().slice(0, 19).replace("T", " ")}</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button size="sm" variant="ghost" onClick={onClose}>
<X className="h-3.5 w-3.5 mr-1" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,228 @@
"use client";
// 자재관리 > 불출의뢰서 — 자재불출 처리 다이얼로그
// wace 1:1: materialRequestDetailPopUp.jsp / acceptInventoryRequestInfo.do
// 라인별 OUT_QTY / OUT_DATE / ACQ_USER 입력 후 OUTSTATUS='complete' 처리.
// 읽기전용(조회) 모드도 지원.
import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Save, X } from "lucide-react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/common/NumberInput";
import { DateInput } from "@/components/common/DateInput";
import { SmartSelect } from "@/components/common/SmartSelect";
import { inventoryMngApi, OptionItem } from "@/lib/api/inventoryMng";
interface LineRow {
objid: string;
part_no: string;
part_name: string;
material: string;
spec: string;
location: string;
sub_location: string;
request_qty: string;
out_qty: number | "";
out_date: string;
acq_user: string;
writer_name: string;
}
interface Props {
open: boolean;
onClose: () => void;
onSaved: () => void;
masterObjid: string;
readOnly?: boolean; // 미접수/완료 상태에서 조회만
}
export function IssueDispatchDialog({ open, onClose, onSaved, masterObjid, readOnly }: Props) {
const [master, setMaster] = useState<any | null>(null);
const [rows, setRows] = useState<LineRow[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [bulkUser, setBulkUser] = useState("");
const [bulkDate, setBulkDate] = useState(new Date().toISOString().slice(0, 10));
useEffect(() => {
if (!open || !masterObjid) return;
setMaster(null);
setRows([]);
setLoading(true);
(async () => {
try {
const [detail, users] = await Promise.all([
inventoryMngApi.getIssue(masterObjid),
inventoryMngApi.users(),
]);
setMaster(detail.master);
setRows((detail.lines ?? []).map((l: any) => ({
objid: l.objid,
part_no: l.part_no ?? "",
part_name: l.part_name ?? "",
material: l.material ?? "",
spec: l.spec ?? "",
location: l.location ?? "",
sub_location: l.sub_location ?? "",
request_qty: l.request_qty ?? "0",
out_qty: l.out_qty ? Number(l.out_qty) : "",
out_date: l.out_date ?? "",
acq_user: l.acq_user ?? "",
writer_name: l.writer_name ?? "",
})));
setUserOpts(users);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
})();
}, [open, masterObjid]);
const handleRow = (idx: number, patch: Partial<LineRow>) =>
setRows(rs => rs.map((r, i) => i === idx ? { ...r, ...patch } : r));
const handleApplyBulk = () => {
setRows(rs => rs.map(r => ({
...r,
acq_user: bulkUser || r.acq_user,
out_date: bulkDate || r.out_date,
})));
};
const handleSave = async () => {
const lines = rows.filter(r => r.out_qty !== "" && Number(r.out_qty) > 0);
if (!lines.length) return toast.info("불출수량을 입력해주세요.");
for (const r of lines) {
if (Number(r.out_qty) > Number(r.request_qty)) {
return toast.warning(`${r.part_no}: 의뢰수량(${r.request_qty}) 초과는 불가합니다.`);
}
if (!r.out_date) return toast.info(`${r.part_no}: 인계일을 입력해주세요.`);
if (!r.acq_user) return toast.info(`${r.part_no}: 인수자를 선택해주세요.`);
}
setSaving(true);
try {
await inventoryMngApi.dispatchIssue({
master_objid: masterObjid,
lines: lines.map(r => ({
objid: r.objid,
out_qty: Number(r.out_qty),
out_date: r.out_date,
acq_user: r.acq_user,
})),
});
toast.success("자재 불출 처리가 완료되었습니다.");
onSaved();
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "불출 실패");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-6xl">
<DialogHeader>
<DialogTitle>
{readOnly ? "불출의뢰 상세" : "자재 불출"}
{master?.inventory_out_no ? `${master.inventory_out_no}` : ""}
</DialogTitle>
<DialogDescription>
{readOnly
? "불출의뢰 라인을 조회합니다."
: "라인별 불출수량 / 인계일 / 인수자를 입력하고 저장하세요."}
</DialogDescription>
</DialogHeader>
{master && (
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
<div>: <b>{master.request_date ?? "-"}</b></div>
<div>: <b>{master.request_user_name ?? master.request_id ?? "-"}</b></div>
<div>: <b>{master.reception_status === "reception" ? "접수" : "미접수"}</b>
{master.reception_date ? ` / ${master.reception_date}` : ""}</div>
<div className="col-span-3">: {master.remark || "-"}</div>
</div>
)}
{!readOnly && (
<div className="flex items-center gap-2 text-xs">
<span>:</span>
<span></span>
<div className="w-[140px]"><DateInput value={bulkDate} onChange={setBulkDate} /></div>
<span></span>
<div className="w-[180px]">
<SmartSelect options={userOpts} value={bulkUser} onValueChange={setBulkUser} />
</div>
<Button size="sm" variant="outline" onClick={handleApplyBulk}></Button>
</div>
)}
<div className="overflow-x-auto text-xs">
<table className="w-full border">
<thead className="bg-muted/50">
<tr>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1">Location</th>
<th className="border px-2 py-1 text-right"></th>
<th className="border px-2 py-1 text-right"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
</tr>
</thead>
<tbody>
{loading && <tr><td colSpan={9} className="border px-2 py-3 text-center"> ...</td></tr>}
{!loading && rows.length === 0 && (
<tr><td colSpan={9} className="border px-2 py-3 text-center"> .</td></tr>
)}
{rows.map((r, i) => (
<tr key={i}>
<td className="border px-2 py-1">{r.part_no}</td>
<td className="border px-2 py-1">{r.part_name}</td>
<td className="border px-2 py-1">{r.spec}</td>
<td className="border px-2 py-1">{r.material}</td>
<td className="border px-2 py-1">{r.location}{r.sub_location ? ` / ${r.sub_location}` : ""}</td>
<td className="border px-2 py-1 text-right">{Number(r.request_qty ?? 0).toLocaleString()}</td>
<td className="border px-1 py-1 w-[120px]">
{readOnly
? <span>{r.out_qty === "" ? "" : Number(r.out_qty).toLocaleString()}</span>
: <NumberInput value={r.out_qty}
onChange={(v) => handleRow(i, { out_qty: v })} />}
</td>
<td className="border px-1 py-1 w-[140px]">
{readOnly ? <span>{r.out_date}</span> :
<DateInput value={r.out_date} onChange={(v) => handleRow(i, { out_date: v })} />}
</td>
<td className="border px-1 py-1 w-[160px]">
{readOnly ? <span>{r.acq_user}</span> :
<SmartSelect options={userOpts} value={r.acq_user}
onValueChange={(v) => handleRow(i, { acq_user: v })} />}
</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button size="sm" variant="ghost" onClick={onClose} disabled={saving}>
<X className="h-3.5 w-3.5 mr-1" />
</Button>
{!readOnly && (
<Button size="sm" onClick={handleSave} disabled={saving || loading}>
<Save className="h-3.5 w-3.5 mr-1" /> {saving ? "처리중..." : "자재불출"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,184 @@
"use client";
// 자재관리 > 자재리스트 — 불출의뢰 등록 다이얼로그
// wace 1:1: materialRequestFormPopUp.jsp / saveInventoryRequest.do
// 선택된 자재(inventory_mgmt) 후보를 불러와 request_qty 입력 → 마스터+라인 저장
// inventory_out_no 채번: Rfw-YYYY-seq
import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Save, X } from "lucide-react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { NumberInput } from "@/components/common/NumberInput";
import { DateInput } from "@/components/common/DateInput";
import { inventoryMngApi } from "@/lib/api/inventoryMng";
interface CandidateRow {
objid: string;
project_no: string;
unit: string;
unit_name: string;
part_no: string;
part_name: string;
material: string;
spec: string;
location: string;
sub_location: string;
use_cnt: number;
request_qty: number | "";
}
interface Props {
open: boolean;
onClose: () => void;
onSaved: () => void;
parentObjids: string[]; // 자재리스트에서 선택된 inventory_mgmt.objid
}
export function IssueRequestCreateDialog({ open, onClose, onSaved, parentObjids }: Props) {
const [rows, setRows] = useState<CandidateRow[]>([]);
const [remark, setRemark] = useState("");
const [requestDate, setRequestDate] = useState(new Date().toISOString().slice(0, 10));
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open || !parentObjids.length) return;
setRemark("");
setRequestDate(new Date().toISOString().slice(0, 10));
(async () => {
setLoading(true);
try {
const list = await inventoryMngApi.candidates(parentObjids);
setRows(list.map((r) => ({
objid: r.objid,
project_no: r.project_no ?? "",
unit: r.unit ?? "",
unit_name: r.unit_name ?? "",
part_no: r.part_no ?? "",
part_name: r.part_name ?? "",
material: r.material ?? "",
spec: r.spec ?? "",
location: r.location ?? "",
sub_location: r.sub_location ?? "",
use_cnt: Number(r.use_cnt ?? 0),
request_qty: "",
})));
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "후보 조회 실패");
} finally {
setLoading(false);
}
})();
}, [open, parentObjids]);
const handleRow = (idx: number, patch: Partial<CandidateRow>) =>
setRows(rs => rs.map((r, i) => i === idx ? { ...r, ...patch } : r));
const handleSave = async () => {
const targets = rows.filter((r) => r.request_qty !== "" && Number(r.request_qty) > 0);
if (!targets.length) return toast.info("불출의뢰수량을 입력해주세요.");
for (const r of targets) {
if (Number(r.request_qty) > r.use_cnt) {
return toast.warning(`${r.part_no}: 보유수량(${r.use_cnt}) 초과는 불가합니다.`);
}
}
setSaving(true);
try {
const res = await inventoryMngApi.saveIssue({
request_date: requestDate,
remark,
lines: targets.map((r) => ({
parent_objid: r.objid,
request_qty: Number(r.request_qty),
unit: r.unit,
})),
});
toast.success(`불출의뢰가 생성되었습니다. (${res.inventory_out_no})`);
onSaved();
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-6xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="space-y-1">
<Label></Label>
<DateInput value={requestDate} onChange={setRequestDate} />
</div>
<div />
<div className="col-span-2 space-y-1">
<Label></Label>
<Textarea rows={2} value={remark} onChange={(e) => setRemark(e.target.value)} />
</div>
</div>
<div className="overflow-x-auto text-xs">
<table className="w-full border">
<thead className="bg-muted/50">
<tr>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1">Location</th>
<th className="border px-2 py-1 text-right"></th>
<th className="border px-2 py-1 text-right"></th>
</tr>
</thead>
<tbody>
{loading && (
<tr><td colSpan={9} className="border px-2 py-3 text-center"> ...</td></tr>
)}
{!loading && rows.length === 0 && (
<tr><td colSpan={9} className="border px-2 py-3 text-center"> .</td></tr>
)}
{rows.map((r, i) => (
<tr key={i}>
<td className="border px-2 py-1">{r.project_no}</td>
<td className="border px-2 py-1">{r.unit_name || r.unit}</td>
<td className="border px-2 py-1">{r.part_no}</td>
<td className="border px-2 py-1">{r.part_name}</td>
<td className="border px-2 py-1">{r.spec}</td>
<td className="border px-2 py-1">{r.material}</td>
<td className="border px-2 py-1">{r.location}{r.sub_location ? ` / ${r.sub_location}` : ""}</td>
<td className="border px-2 py-1 text-right">{r.use_cnt.toLocaleString()}</td>
<td className="border px-1 py-1 w-[130px]">
<NumberInput value={r.request_qty}
onChange={(v) => handleRow(i, { request_qty: v })} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button size="sm" variant="ghost" onClick={onClose} disabled={saving}>
<X className="h-3.5 w-3.5 mr-1" />
</Button>
<Button size="sm" onClick={handleSave} disabled={saving || loading || !rows.length}>
<Save className="h-3.5 w-3.5 mr-1" /> {saving ? "저장중..." : "불출의뢰"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,163 @@
"use client";
// 자재관리 > 자재리스트 — 자재이동 다이얼로그
// wace 1:1: materialMoveFormPopUp.jsp / saveInventoryMove.do
// inventory_mgmt_in 행의 move_qty/move_date/move_user 누적 + 이동 이력 라인 추가
import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Save, X } from "lucide-react";
import { toast } from "sonner";
import { NumberInput } from "@/components/common/NumberInput";
import { DateInput } from "@/components/common/DateInput";
import { Input } from "@/components/ui/input";
import { SmartSelect } from "@/components/common/SmartSelect";
import { inventoryMngApi, OptionItem } from "@/lib/api/inventoryMng";
interface MoveRow {
in_objid: string;
parent_objid: string;
part_no: string;
part_name: string;
use_cnt: number;
move_qty: number | "";
location: string;
sub_location: string;
move_date: string;
move_user: string;
}
interface Props {
open: boolean;
onClose: () => void;
onSaved: () => void;
selectedRows: any[]; // 자재리스트에서 체크된 행 (objid 가 parent_objid)
}
export function MaterialMoveDialog({ open, onClose, onSaved, selectedRows }: Props) {
const [rows, setRows] = useState<MoveRow[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setRows(selectedRows.map((r) => ({
in_objid: "", // 사용자가 in_objid 를 모를 수도 있어 parent 기준으로 1행 처리 후 첫 inventory_mgmt_in 사용
parent_objid: r.objid,
part_no: r.part_no ?? "",
part_name: r.part_name ?? "",
use_cnt: Number(r.use_cnt ?? 0),
move_qty: "",
location: r.location ?? "",
sub_location: r.sub_location ?? "",
move_date: new Date().toISOString().slice(0, 10),
move_user: "",
})));
(async () => {
try { setUserOpts(await inventoryMngApi.users()); } catch { /* skip */ }
})();
}, [open, selectedRows]);
const handleRowChange = (idx: number, patch: Partial<MoveRow>) =>
setRows(rs => rs.map((r, i) => i === idx ? { ...r, ...patch } : r));
const handleSave = async () => {
const items = rows.filter((r) => r.move_qty !== "" && Number(r.move_qty) > 0);
if (!items.length) return toast.info("이동수량을 입력해주세요.");
for (const r of items) {
if (Number(r.move_qty) > r.use_cnt) {
return toast.warning(`${r.part_no}: 보유수량(${r.use_cnt})보다 큰 이동수량은 불가합니다.`);
}
if (!r.move_user) {
return toast.info(`${r.part_no}: 인수자를 선택해주세요.`);
}
}
setSaving(true);
try {
// parent_objid 기준으로 백엔드가 첫 inventory_mgmt_in 행을 잡지 못하므로
// 일단 in_objid 가 비면 parent_objid 를 사용 (백엔드에서 fallback 처리).
await inventoryMngApi.move(items.map((r) => ({
in_objid: r.parent_objid,
move_qty: Number(r.move_qty),
location: r.location,
sub_location: r.sub_location,
move_date: r.move_date,
move_user: r.move_user,
})));
toast.success("자재가 이동되었습니다.");
onSaved();
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "이동 실패");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> /Location/ .</DialogDescription>
</DialogHeader>
<div className="overflow-x-auto text-xs">
<table className="w-full border">
<thead className="bg-muted/50">
<tr>
<th className="border px-2 py-1 text-left"></th>
<th className="border px-2 py-1 text-left"></th>
<th className="border px-2 py-1 text-right"></th>
<th className="border px-2 py-1 text-right"></th>
<th className="border px-2 py-1">Location</th>
<th className="border px-2 py-1">Sub</th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td className="border px-2 py-1">{r.part_no}</td>
<td className="border px-2 py-1">{r.part_name}</td>
<td className="border px-2 py-1 text-right">{r.use_cnt.toLocaleString()}</td>
<td className="border px-1 py-1 w-[110px]">
<NumberInput value={r.move_qty}
onChange={(v) => handleRowChange(i, { move_qty: v })} />
</td>
<td className="border px-1 py-1 w-[120px]">
<Input value={r.location}
onChange={(e) => handleRowChange(i, { location: e.target.value })} />
</td>
<td className="border px-1 py-1 w-[120px]">
<Input value={r.sub_location}
onChange={(e) => handleRowChange(i, { sub_location: e.target.value })} />
</td>
<td className="border px-1 py-1 w-[140px]">
<DateInput value={r.move_date}
onChange={(v) => handleRowChange(i, { move_date: v })} />
</td>
<td className="border px-1 py-1 w-[160px]">
<SmartSelect options={userOpts} value={r.move_user}
onValueChange={(v) => handleRowChange(i, { move_user: v })} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button size="sm" variant="ghost" onClick={onClose} disabled={saving}>
<X className="h-3.5 w-3.5 mr-1" />
</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
<Save className="h-3.5 w-3.5 mr-1" /> {saving ? "이동중..." : "이동"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,184 @@
"use client";
// 자재관리 > 자재리스트 — 재고등록(입고) 다이얼로그
// wace 1:1: inventoryFormPopUp.jsp / saveinventoryForm.do
// 1) inventory_mgmt 동일 (project+unit+part+location) 행 존재 시 재사용, 없으면 INSERT
// 2) inventory_mgmt_in 입고 라인 1건 INSERT
import React, { useEffect, useMemo, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Save, X } from "lucide-react";
import { toast } from "sonner";
import { SmartSelect } from "@/components/common/SmartSelect";
import { NumberInput } from "@/components/common/NumberInput";
import { DateInput } from "@/components/common/DateInput";
import { Input } from "@/components/ui/input";
import { inventoryMngApi, OptionItem } from "@/lib/api/inventoryMng";
interface Props {
open: boolean;
onClose: () => void;
onSaved: () => void;
}
interface FormState {
project_objid: string;
unit: string;
part_objid: string;
part_no: string;
part_name: string;
qty: number | "";
price: string;
location: string;
sub_location: string;
receipt_date: string;
}
const EMPTY: FormState = {
project_objid: "", unit: "", part_objid: "",
part_no: "", part_name: "",
qty: "", price: "", location: "", sub_location: "",
receipt_date: new Date().toISOString().slice(0, 10),
};
export function StockRegisterDialog({ open, onClose, onSaved }: Props) {
const [form, setForm] = useState<FormState>(EMPTY);
const [projectOpts, setProjectOpts] = useState<OptionItem[]>([]);
const [unitOpts, setUnitOpts] = useState<OptionItem[]>([]);
const [partKw, setPartKw] = useState("");
const [partOpts, setPartOpts] = useState<OptionItem[]>([]);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setForm(EMPTY);
setPartKw("");
setPartOpts([]);
(async () => {
try { setProjectOpts(await inventoryMngApi.projects()); } catch { /* skip */ }
})();
}, [open]);
useEffect(() => {
if (!form.project_objid) { setUnitOpts([]); return; }
(async () => {
try { setUnitOpts(await inventoryMngApi.units(form.project_objid)); } catch { /* skip */ }
})();
}, [form.project_objid]);
useEffect(() => {
const id = setTimeout(async () => {
if (!partKw || partKw.length < 1) { setPartOpts([]); return; }
try { setPartOpts(await inventoryMngApi.parts(partKw, 30)); } catch { /* skip */ }
}, 250);
return () => clearTimeout(id);
}, [partKw]);
const partSelectOpts = useMemo(
() => partOpts.map((p) => ({ code: p.code, label: `${p.label} ${p.part_name ? `· ${p.part_name}` : ""}` })),
[partOpts],
);
const handleSave = async () => {
if (!form.project_objid) return toast.info("프로젝트를 선택해주세요.");
if (!form.part_objid) return toast.info("품번을 선택해주세요.");
if (form.qty === "" || Number(form.qty) <= 0) return toast.info("수량을 입력해주세요.");
if (!form.location) return toast.info("Location 을 입력해주세요.");
setSaving(true);
try {
await inventoryMngApi.saveStock({
project_objid: form.project_objid,
unit: form.unit,
part_objid: form.part_objid,
qty: Number(form.qty),
price: form.price,
location: form.location,
sub_location: form.sub_location,
receipt_date: form.receipt_date,
});
toast.success("재고가 등록되었습니다.");
onSaved();
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="space-y-1">
<Label> *</Label>
<SmartSelect options={projectOpts} value={form.project_objid}
onValueChange={(v) => setForm({ ...form, project_objid: v, unit: "" })} />
</div>
<div className="space-y-1">
<Label>(WBS)</Label>
<SmartSelect options={unitOpts} value={form.unit}
onValueChange={(v) => setForm({ ...form, unit: v })} />
</div>
<div className="space-y-1 col-span-2">
<Label> </Label>
<Input placeholder="품번/품명 일부 입력"
value={partKw}
onChange={(e) => setPartKw(e.target.value)} />
<SmartSelect options={partSelectOpts} value={form.part_objid}
onValueChange={(v) => {
const found = partOpts.find((p) => p.code === v);
setForm({
...form,
part_objid: v,
part_no: found?.label ?? "",
part_name: found?.part_name ?? "",
});
}} />
{form.part_no && (
<div className="text-[11px] text-muted-foreground">
: <b>{form.part_no}</b> {form.part_name ? `· ${form.part_name}` : ""}
</div>
)}
</div>
<div className="space-y-1">
<Label> *</Label>
<NumberInput value={form.qty} onChange={(v) => setForm({ ...form, qty: v })} />
</div>
<div className="space-y-1">
<Label></Label>
<Input value={form.price} onChange={(e) => setForm({ ...form, price: e.target.value })} />
</div>
<div className="space-y-1">
<Label>Location *</Label>
<Input value={form.location} onChange={(e) => setForm({ ...form, location: e.target.value })} />
</div>
<div className="space-y-1">
<Label>Sub Location</Label>
<Input value={form.sub_location} onChange={(e) => setForm({ ...form, sub_location: e.target.value })} />
</div>
<div className="space-y-1">
<Label></Label>
<DateInput value={form.receipt_date} onChange={(v) => setForm({ ...form, receipt_date: v })} />
</div>
</div>
<DialogFooter>
<Button size="sm" variant="ghost" onClick={onClose} disabled={saving}>
<X className="h-3.5 w-3.5 mr-1" />
</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
<Save className="h-3.5 w-3.5 mr-1" /> {saving ? "저장중..." : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+169
View File
@@ -0,0 +1,169 @@
// ============================================================
// 자재관리 — 자재리스트 + 불출의뢰서 API
// 백엔드: /api/inventory-mng
// ============================================================
import { apiClient } from "./client";
export interface InventoryListFilter {
project_objid?: string;
unit_code?: string;
part_no?: string;
part_name?: string;
part_type?: string;
location?: string;
cls_cd?: string;
cau_cd?: string;
writer?: string;
page?: number;
page_size?: number;
}
export interface IssueRequestFilter {
part_no?: string;
part_name?: string;
request_start_date?: string;
request_end_date?: string;
request_user?: string;
reception_status?: string;
reception_user?: string;
reception_start_date?: string;
reception_end_date?: string;
out_status?: string;
page?: number;
page_size?: number;
}
export interface ListResponse<T = any> {
rows: T[];
totalCount: number;
page: number;
pageSize: number;
}
export interface OptionItem { code: string; label: string; part_name?: string }
export interface SaveStockInput {
project_objid: string;
unit?: string;
part_objid: string;
qty: number;
price?: string;
location: string;
sub_location?: string;
cls_cd?: string;
cau_cd?: string;
receipt_date?: string;
writer?: string;
order_objid?: string;
}
export interface MoveItem {
in_objid: string;
move_qty: number;
location?: string;
sub_location?: string;
move_date?: string;
move_user?: string;
}
export interface IssueRequestLineInput {
parent_objid: string;
request_qty: number;
unit?: string;
}
export interface IssueRequestSaveInput {
master_objid?: string;
contract_mgmt_objid?: string;
request_date?: string;
request_id?: string;
remark?: string;
writer?: string;
lines: IssueRequestLineInput[];
}
export interface DispatchLineInput {
objid: string;
out_qty: number;
out_date: string;
acq_user: string;
sign?: string;
}
export interface DispatchInput {
master_objid: string;
lines: DispatchLineInput[];
writer?: string;
}
export const inventoryMngApi = {
// 자재리스트
async list(f: InventoryListFilter = {}): Promise<ListResponse> {
const r = await apiClient.get("/inventory-mng/list", { params: f });
return r.data?.data as ListResponse;
},
async saveStock(input: SaveStockInput): Promise<{ objid: string; in_objid: string }> {
const r = await apiClient.post("/inventory-mng/stock", input);
return r.data?.data;
},
async deleteStock(objids: string[]): Promise<{ deleted: number }> {
const r = await apiClient.post("/inventory-mng/stock/delete", { objids });
return r.data?.data;
},
async move(items: MoveItem[]): Promise<{ updated: number }> {
const r = await apiClient.post("/inventory-mng/move", { items });
return r.data?.data;
},
async history(objid: string): Promise<any[]> {
const r = await apiClient.get(`/inventory-mng/history/${objid}`);
return (r.data?.data ?? []) as any[];
},
// 불출의뢰
async listIssue(f: IssueRequestFilter = {}): Promise<ListResponse> {
const r = await apiClient.get("/inventory-mng/issue-request", { params: f });
return r.data?.data as ListResponse;
},
async getIssue(objid: string): Promise<{ master: any; lines: any[] }> {
const r = await apiClient.get(`/inventory-mng/issue-request/${objid}`);
return r.data?.data;
},
async candidates(parent_objids: string[]): Promise<any[]> {
const r = await apiClient.post("/inventory-mng/issue-request/candidates", { parent_objids });
return (r.data?.data ?? []) as any[];
},
async saveIssue(input: IssueRequestSaveInput): Promise<{ master_objid: string; inventory_out_no: string }> {
const r = await apiClient.post("/inventory-mng/issue-request", input);
return r.data?.data;
},
async deleteIssue(objid: string): Promise<void> {
await apiClient.delete(`/inventory-mng/issue-request/${objid}`);
},
async receiveIssue(objids: string[]): Promise<{ updated: number }> {
const r = await apiClient.post("/inventory-mng/issue-request/receive", { objids });
return r.data?.data;
},
async dispatchIssue(input: DispatchInput): Promise<{ updated: number }> {
const r = await apiClient.post("/inventory-mng/issue-request/dispatch", input);
return r.data?.data;
},
// 옵션
async projects(): Promise<OptionItem[]> {
const r = await apiClient.get("/inventory-mng/options/projects");
return (r.data?.data ?? []) as OptionItem[];
},
async units(contract_objid: string): Promise<OptionItem[]> {
const r = await apiClient.get("/inventory-mng/options/units", { params: { contract_objid } });
return (r.data?.data ?? []) as OptionItem[];
},
async users(): Promise<OptionItem[]> {
const r = await apiClient.get("/inventory-mng/options/users");
return (r.data?.data ?? []) as OptionItem[];
},
async parts(keyword: string, limit = 30): Promise<OptionItem[]> {
const r = await apiClient.get("/inventory-mng/options/parts", { params: { q: keyword, limit } });
return (r.data?.data ?? []) as OptionItem[];
},
};