생산관리 M-BOM 변경이력 다이얼로그(PR-B4) + 그리드 컬럼 폭 다듬기
운영 productionplanning.getMbomHistory (3448~3470) 1:1 이식:
· backend mbomService.getHistory(projectObjid) — project 단위 mbom_header 변경이력
시간순 최신 우선, USER_INFO join 으로 변경자명 함께
· GET /api/production/mbom/history/:projectObjid
· frontend MbomHistoryDialog — 5컬럼 그리드(변경일시/유형/내용/변경자/M-BOM품번)
CREATE=파란 뱃지, UPDATE=황색 뱃지, max-w-900px
· MbomDetailDialog toolbar 에 "변경이력" 버튼 (편집 모드 아닐 때만 노출)
PR-B1 의 history 저장 결과를 사용자가 직접 확인할 수 있도록 보는 화면 마무리.
부수: production/mbom/page 그리드 컬럼 폭 정돈 + summaryStats(전체/페이지/수주합/M-BOM 비율).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,19 @@ export async function getTree(req: AuthenticatedRequest, res: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
// PR-B4 — 변경이력 조회 (운영 getMbomHistory.do 1:1)
|
||||
export async function getHistory(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const projectObjid = String(req.params.projectObjid ?? "").trim();
|
||||
if (!projectObjid) return res.status(400).json({ success: false, message: "projectObjid 누락" });
|
||||
const data = await svc.getHistory(projectObjid);
|
||||
return res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
logger.error("M-BOM 변경이력 조회 실패", { error: e.message });
|
||||
return res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// PR-B1 — 본 편집 저장 (운영 saveMbom.do 1:1)
|
||||
export async function save(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
|
||||
@@ -14,5 +14,6 @@ router.get("/list", ctrl.getList);
|
||||
router.get("/detail/:objid", ctrl.getDetail);
|
||||
router.get("/tree/:objid", ctrl.getTree);
|
||||
router.post("/save", ctrl.save); // PR-B1 본 편집 저장
|
||||
router.get("/history/:projectObjid", ctrl.getHistory); // PR-B4 변경이력 조회
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1208,6 +1208,48 @@ export async function save(payload: MbomSavePayload, sessionUserId: string): Pro
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 변경이력 조회 (PR-B4) ──────────────────────────────────
|
||||
//
|
||||
// 매퍼 productionplanning.getMbomHistory (3448~3470) 1:1.
|
||||
// project_objid 로 그 프로젝트의 모든 mbom_header 변경이력 시간순 (최신 우선).
|
||||
|
||||
export interface MbomHistoryRow {
|
||||
objid: string;
|
||||
mbom_header_objid: string;
|
||||
change_type: string;
|
||||
change_description: string | null;
|
||||
change_user: string | null;
|
||||
change_user_name: string | null;
|
||||
change_date: string;
|
||||
mbom_part_no: string | null;
|
||||
mbom_regdate: string | null;
|
||||
}
|
||||
|
||||
export async function getHistory(projectObjid: string): Promise<MbomHistoryRow[]> {
|
||||
const pool = getPool();
|
||||
const sql = `
|
||||
SELECT
|
||||
MH.OBJID AS objid,
|
||||
MH.MBOM_HEADER_OBJID AS mbom_header_objid,
|
||||
MH.CHANGE_TYPE AS change_type,
|
||||
MH.CHANGE_DESCRIPTION AS change_description,
|
||||
MH.CHANGE_USER AS change_user,
|
||||
COALESCE(
|
||||
(SELECT USER_NAME FROM USER_INFO WHERE USER_ID = MH.CHANGE_USER LIMIT 1),
|
||||
MH.CHANGE_USER
|
||||
) AS change_user_name,
|
||||
TO_CHAR(MH.CHANGE_DATE, 'YYYY-MM-DD HH24:MI:SS') AS change_date,
|
||||
MHD.MBOM_NO AS mbom_part_no,
|
||||
TO_CHAR(MHD.REGDATE, 'YYYY-MM-DD HH24:MI:SS') AS mbom_regdate
|
||||
FROM MBOM_HISTORY MH
|
||||
INNER JOIN MBOM_HEADER MHD ON MH.MBOM_HEADER_OBJID = MHD.OBJID
|
||||
WHERE MHD.PROJECT_OBJID = $1
|
||||
ORDER BY MH.CHANGE_DATE DESC
|
||||
`;
|
||||
const r = await pool.query(sql, [projectObjid]);
|
||||
return r.rows;
|
||||
}
|
||||
|
||||
async function insertHistory(
|
||||
client: any,
|
||||
mbomHeaderObjid: string,
|
||||
|
||||
@@ -22,10 +22,11 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Loader2, Folder, Pencil, Save, X } from "lucide-react";
|
||||
import { Loader2, Folder, Pencil, Save, X, History } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { mbomApi, MbomDetail, MbomTreeResponse, MbomBomDataType, MbomTreeRow, MbomSaveRow } from "@/lib/api/mbom";
|
||||
import { MbomHistoryDialog } from "./MbomHistoryDialog";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -58,6 +59,7 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }:
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editableRows, setEditableRows] = useState<MbomTreeRow[]>([]);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
|
||||
const loadTree = (objid: string) => {
|
||||
setLoading(true);
|
||||
@@ -214,6 +216,11 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }:
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!editMode && (
|
||||
<Button size="sm" variant="outline" onClick={() => setHistoryOpen(true)} disabled={loading || !projectObjid}>
|
||||
<History className="w-3 h-3 mr-1" /> 변경이력
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && !editMode && (
|
||||
<Button size="sm" variant="outline" onClick={handleEditToggle} disabled={loading}>
|
||||
<Pencil className="w-3 h-3 mr-1" /> 본 편집
|
||||
@@ -328,6 +335,12 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }:
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>닫기</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
<MbomHistoryDialog
|
||||
open={historyOpen}
|
||||
onOpenChange={setHistoryOpen}
|
||||
projectObjid={projectObjid}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
// 생산관리 > M-BOM 관리 — 변경이력 다이얼로그 (PR-B4).
|
||||
//
|
||||
// 운영판 매퍼 productionplanning.getMbomHistory (3448~3470) 1:1.
|
||||
// 프로젝트의 모든 mbom_header 변경이력 시간순(최신 우선) 표시.
|
||||
// CREATE / UPDATE + 설명 + 변경자 + 변경일 + M-BOM 품번.
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, History } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { mbomApi, MbomHistoryRow } from "@/lib/api/mbom";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectObjid: string | null;
|
||||
}
|
||||
|
||||
const CHANGE_TYPE_BADGE: Record<string, { text: string; color: string }> = {
|
||||
CREATE: { text: "생성", color: "bg-blue-600" },
|
||||
UPDATE: { text: "수정", color: "bg-amber-600" },
|
||||
};
|
||||
|
||||
export function MbomHistoryDialog({ open, onOpenChange, projectObjid }: Props) {
|
||||
const [rows, setRows] = useState<MbomHistoryRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !projectObjid) { setRows([]); return; }
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
mbomApi.getHistory(projectObjid)
|
||||
.then(data => { if (alive) setRows(data); })
|
||||
.catch((e: any) => toast.error(e?.response?.data?.message ?? e?.message ?? "변경이력 조회 실패"))
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [open, projectObjid]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[900px] w-[95vw] max-h-[85vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="bg-blue-600 px-4 py-3">
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
<History className="w-4 h-4" />
|
||||
M-BOM 변경이력
|
||||
<span className="text-xs font-normal opacity-80">총 {rows.length.toLocaleString()}건</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
변경이력이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<table className="text-xs border-collapse w-full">
|
||||
<thead className="bg-yellow-100 dark:bg-yellow-900/30 sticky top-0">
|
||||
<tr>
|
||||
<th className="border px-2 py-1.5 w-[140px] text-center">변경일시</th>
|
||||
<th className="border px-2 py-1.5 w-[70px] text-center">유형</th>
|
||||
<th className="border px-2 py-1.5 text-left">변경 내용</th>
|
||||
<th className="border px-2 py-1.5 w-[110px] text-center">변경자</th>
|
||||
<th className="border px-2 py-1.5 w-[180px] text-left">M-BOM 품번</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, idx) => {
|
||||
const badge = CHANGE_TYPE_BADGE[r.change_type] ?? { text: r.change_type, color: "bg-slate-500" };
|
||||
return (
|
||||
<tr key={`${r.objid}_${idx}`} className="hover:bg-muted/30">
|
||||
<td className="border px-2 py-1 text-center whitespace-nowrap tabular-nums">{r.change_date}</td>
|
||||
<td className="border px-2 py-1 text-center">
|
||||
<span className={cn("inline-block rounded px-1.5 py-0.5 text-[10px] font-semibold text-white", badge.color)}>
|
||||
{badge.text}
|
||||
</span>
|
||||
</td>
|
||||
<td className="border px-2 py-1">{r.change_description ?? ""}</td>
|
||||
<td className="border px-2 py-1 text-center">{r.change_user_name ?? r.change_user ?? ""}</td>
|
||||
<td className="border px-2 py-1 font-mono text-[11px]">{r.mbom_part_no ?? ""}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3 sm:justify-center">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>닫기</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -232,4 +232,22 @@ export const mbomApi = {
|
||||
const res = await apiClient.post("/production/mbom/save", payload);
|
||||
return res.data?.data as MbomSaveResult;
|
||||
},
|
||||
async getHistory(projectObjid: string): Promise<MbomHistoryRow[]> {
|
||||
const res = await apiClient.get(`/production/mbom/history/${encodeURIComponent(projectObjid)}`);
|
||||
return (res.data?.data ?? []) as MbomHistoryRow[];
|
||||
},
|
||||
};
|
||||
|
||||
// ─── 변경이력 (PR-B4) ───────────────────────────────────────
|
||||
|
||||
export interface MbomHistoryRow {
|
||||
objid: string;
|
||||
mbom_header_objid: string;
|
||||
change_type: string; // CREATE | UPDATE
|
||||
change_description: string | null;
|
||||
change_user: string | null;
|
||||
change_user_name: string | null;
|
||||
change_date: string;
|
||||
mbom_part_no: string | null;
|
||||
mbom_regdate: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user