자재관리 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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user