개발관리>설계변경 리스트 메뉴 신설 (PR-C) — wace partMng 1:1 이식

backend (M5, read-only):
- devEoHistoryService: list + getByObjid
- partMngHistList SQL 1:1 (NVL→COALESCE, PART_MNG.OBJID bigint cast, CODE_NAME→LEFT JOIN comm_code)
- 동적 필터 10종 (Year/contract_objid/unit_code/part_no/part_name/change_option/eo_start~end/change_type/part_type/writer_id)
- 신규등록 제외 가드: NOT (HIS_STATUS='DEPLOY' AND CHANGE_TYPE IS NULL AND REVISION='RE') + BOM_STATUS='deploy'
- 품번변경(CHANGE_OPTION=0001790) 'A->B' 머지 CASE (part_no_disp/part_name_disp/revision_disp)

frontend (M5):
- change-list/page.tsx: 16셀 그리드 + 검색 8필드 + 페이징
- PartHisDetailDialog: 모든 필드 disabled, 4 섹션 (EO/프로젝트/PART/수량)
- AdminPageRenderer dynamic 임포트 + 기존 menu_info URL 그대로 사용

개발관리 5개 메뉴 (M1~M5) baseline 완료.

본 PR 제외 (별 PR): writer SmartSelect, change_type/change_option comm_code 그룹 SmartSelect (그룹 ID 확정 후)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-12 16:27:37 +09:00
parent 0872199b30
commit c9adfd7327
9 changed files with 835 additions and 0 deletions
@@ -0,0 +1,187 @@
"use client";
// 개발관리 > 설계변경 리스트 (M5, read-only) — wace partMngHisList.jsp 1:1
// 그리드: part_mng_history 16셀
// 검색: 8 필드 (1차) — Year/contract_objid/part_no/part_name/eo_start~end/change_type/change_option/part_type
// 참조: docs/migration/development/03-eo-history.md
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Search, Loader2, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { devEoHistoryApi, EoHistoryListFilter, EoHistoryRow } from "@/lib/api/devEoHistory";
import { PartHisDetailDialog } from "@/components/development/PartHisDetailDialog";
// comm_code 그룹 (vexplor_rps)
const GROUP_PART_TYPE = "0000062";
// change_type/change_option은 wace 운영판 그룹 ID가 명확하지 않으므로 text input으로 우선 운영.
// (시드 후 그룹 ID 확인되면 SmartSelect 전환)
const YEAR_OPTIONS = (() => {
const cur = new Date().getFullYear();
const arr: string[] = [];
for (let y = cur + 4; y >= cur - 8; y--) arr.push(String(y));
return arr;
})();
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "eo_no", label: "EO No", width: "w-[100px]", frozen: true },
{ key: "project_no", label: "프로젝트번호", width: "w-[120px]" },
{ key: "project_name", label: "프로젝트명", width: "w-[180px]" },
{ key: "unit_name", label: "유닛명", width: "w-[160px]" },
{ key: "parent_part_info", label: "모품번", width: "w-[160px]" },
{ key: "part_no_disp", label: "품번", width: "w-[160px]" },
{ key: "part_name_disp", label: "품명", minWidth: "min-w-[180px]" },
{ key: "qty", label: "수량", width: "w-[70px]", align: "right", formatNumber: true },
{ key: "qty_temp", label: "변경수량", width: "w-[80px]", align: "right", formatNumber: true },
{ key: "change_type_name", label: "EO구분", width: "w-[90px]", align: "center" },
{ key: "change_option_name", label: "EO사유", width: "w-[100px]", align: "center" },
{ key: "revision_disp", label: "Revision", width: "w-[90px]", align: "center" },
{ key: "eo_date", label: "EO Date", width: "w-[100px]", align: "center" },
{ key: "part_type_name", label: "PART구분", width: "w-[90px]", align: "center" },
{ key: "writer_name", label: "담당자", width: "w-[90px]", align: "center" },
{ key: "his_reg_date_title", label: "실행일", width: "w-[100px]", align: "center" },
];
const EMPTY_FILTER: EoHistoryListFilter = {
Year: "", contract_objid: "",
part_no: "", part_name: "",
change_option: "", change_type: "", part_type: "",
eo_start_date: "", eo_end_date: "",
page: 1, page_size: 50,
};
export default function EoHistoryPage() {
const [rows, setRows] = useState<EoHistoryRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<EoHistoryListFilter>(EMPTY_FILTER);
const [detailOpen, setDetailOpen] = useState(false);
const [detailObjid, setDetailObjid] = useState<string | null>(null);
const fetchList = useCallback(async (override?: Partial<EoHistoryListFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await devEoHistoryApi.list(f);
setRows(res.rows ?? []);
setTotal(res.total ?? 0);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => { fetchList(); /* eslint-disable-next-line */ }, []);
// EO_NO 셀 클릭 → 상세 다이얼로그
const columns = useMemo(
() => GRID_COLUMNS.map((c) =>
c.key === "eo_no"
? { ...c, onClick: (row: any) => { setDetailObjid(row.objid); setDetailOpen(true); } }
: c,
),
[],
);
return (
<div className="flex h-full flex-col">
<div className="border-b bg-card px-4 py-3">
<div className="grid grid-cols-4 gap-3 text-sm">
<Field label="년도">
<select className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={filter.Year ?? ""}
onChange={(e) => setFilter({ ...filter, Year: e.target.value })}>
<option value=""></option>
{YEAR_OPTIONS.map((y) => <option key={y} value={y}>{y}</option>)}
</select>
</Field>
<Field label="프로젝트 OBJID">
<Input value={filter.contract_objid ?? ""}
onChange={(e) => setFilter({ ...filter, contract_objid: e.target.value })}
placeholder="project_mgmt.objid" />
</Field>
<Field label="품번">
<Input value={filter.part_no ?? ""}
onChange={(e) => setFilter({ ...filter, part_no: e.target.value })}
placeholder="part_no LIKE" />
</Field>
<Field label="품명">
<Input value={filter.part_name ?? ""}
onChange={(e) => setFilter({ ...filter, part_name: e.target.value })}
placeholder="part_name LIKE" />
</Field>
<Field label="EO Date 시작">
<Input type="date" value={filter.eo_start_date ?? ""}
onChange={(e) => setFilter({ ...filter, eo_start_date: e.target.value })} />
</Field>
<Field label="EO Date 종료">
<Input type="date" value={filter.eo_end_date ?? ""}
onChange={(e) => setFilter({ ...filter, eo_end_date: e.target.value })} />
</Field>
<Field label="PART구분">
<CommCodeSelect groupId={GROUP_PART_TYPE}
value={filter.part_type ?? ""}
onValueChange={(v) => setFilter({ ...filter, part_type: v })} />
</Field>
<Field label="EO구분 / EO사유 (code_id)">
<div className="flex items-center gap-1">
<Input value={filter.change_type ?? ""}
onChange={(e) => setFilter({ ...filter, change_type: e.target.value })}
placeholder="EO구분 code_id" />
<Input value={filter.change_option ?? ""}
onChange={(e) => setFilter({ ...filter, change_option: e.target.value })}
placeholder="EO사유 code_id" />
</div>
</Field>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="text-xs text-muted-foreground"> {total.toLocaleString()} (read-only)</div>
<div className="flex items-end gap-2">
<Button variant="outline" size="sm"
onClick={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }}>
<RotateCcw className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" onClick={() => fetchList()} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
</div>
</div>
</div>
<div className="min-h-0 flex-1 p-2">
<DataGrid
columns={columns}
data={rows}
loading={loading}
showRowNumber
emptyMessage="설계변경 이력이 없습니다."
gridId="development-eo-history"
/>
</div>
<PartHisDetailDialog
open={detailOpen}
onOpenChange={setDetailOpen}
objid={detailObjid}
/>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<Label className="mb-1 block text-xs text-muted-foreground">{label}</Label>
{children}
</div>
);
}