Merge pull request 'hjjeong' (#10) from hjjeong into main
Reviewed-on: https://g.wace.me/chpark/vexplor_rps/pulls/10
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
// 개발관리 > E-BOM 등록 (M3) — wace structureList.jsp 1:1
|
||||
// 그리드: part_bom_report 9셀
|
||||
// 액션: 조회 / 삭제 / 상태변경 (E-BOM등록 Excel Import는 별 PR)
|
||||
// 참조: docs/migration/development/02-ebom.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, Trash2, Settings, FileSpreadsheet,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
||||
import { devBomApi, BomReportListFilter, BomReportRow } from "@/lib/api/devBom";
|
||||
import { BomReportStatusDialog } from "@/components/development/BomReportStatusDialog";
|
||||
import { BomReportExcelImportDialog } from "@/components/development/BomReportExcelImportDialog";
|
||||
import { BomReportTreeDialog } from "@/components/development/BomReportTreeDialog";
|
||||
|
||||
const PRODUCT_GROUP = "0000001"; // 제품구분 (vexplor 공용)
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ code: "create", label: "등록중" },
|
||||
{ code: "changeDesign", label: "설계변경미배포" },
|
||||
{ code: "deploy", label: "배포완료" },
|
||||
];
|
||||
|
||||
const BASE_GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "product_name", label: "제품구분", width: "w-[160px]", align: "center", frozen: true },
|
||||
{ key: "part_no", label: "품번", width: "w-[210px]" },
|
||||
{ key: "part_name", label: "품명", minWidth: "min-w-[220px]" },
|
||||
// wace fnc_getFolderIcon 1:1 — 폴더 아이콘 클릭 시 BOM 구조 다이얼로그
|
||||
{ key: "bom_cnt", label: "E-BOM", width: "w-[100px]", align: "center", renderType: "folder" },
|
||||
{ key: "dept_user_name", label: "등록자", width: "w-[140px]", align: "center" },
|
||||
{ key: "reg_date", label: "등록일", width: "w-[120px]", align: "center" },
|
||||
{ key: "deploy_date", label: "확정일", width: "w-[120px]", align: "center" },
|
||||
{ key: "revision", label: "Version", width: "w-[100px]", align: "center" },
|
||||
{ key: "status_title", label: "상태", width: "w-[120px]", align: "center" },
|
||||
];
|
||||
|
||||
const EMPTY_FILTER: BomReportListFilter = {
|
||||
product_cd: "", status: "",
|
||||
search_part_no: "", search_part_name: "",
|
||||
page: 1, page_size: 50,
|
||||
};
|
||||
|
||||
export default function EbomRegistPage() {
|
||||
const [rows, setRows] = useState<BomReportRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState<BomReportListFilter>(EMPTY_FILTER);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const [statusObjid, setStatusObjid] = useState<string | null>(null);
|
||||
const [excelOpen, setExcelOpen] = useState(false);
|
||||
const [treeOpen, setTreeOpen] = useState(false);
|
||||
const [treeReport, setTreeReport] = useState<BomReportRow | null>(null);
|
||||
|
||||
const fetchList = useCallback(async (override?: Partial<BomReportListFilter>) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const f = { ...filter, ...override };
|
||||
const res = await devBomApi.list(f);
|
||||
setRows(res.rows ?? []);
|
||||
setTotal(res.total ?? 0);
|
||||
setCheckedIds([]);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
useEffect(() => { fetchList(); /* eslint-disable-next-line */ }, []);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (checkedIds.length === 0) return toast.error("선택된 행이 없습니다.");
|
||||
if (!confirm(`${checkedIds.length}건을 삭제하시겠습니까? (자식 BOM 트리도 함께 삭제됨)`)) return;
|
||||
try {
|
||||
const res = await devBomApi.remove(checkedIds);
|
||||
toast.success(res?.message ?? "삭제되었습니다.");
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패");
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = () => {
|
||||
if (checkedIds.length !== 1) return toast.error("상태 변경할 행 1개를 선택하세요.");
|
||||
setStatusObjid(checkedIds[0]);
|
||||
setStatusOpen(true);
|
||||
};
|
||||
|
||||
// 품번 셀 클릭 → BOM 트리 다이얼로그 (wace fn_openSetStructure 1:1)
|
||||
const openTree = useCallback((row: BomReportRow) => {
|
||||
setTreeReport(row);
|
||||
setTreeOpen(true);
|
||||
}, []);
|
||||
|
||||
const columns: DataGridColumn[] = useMemo(
|
||||
() => BASE_GRID_COLUMNS.map((c) =>
|
||||
c.key === "bom_cnt"
|
||||
? { ...c, onClick: (row: any) => openTree(row as BomReportRow) }
|
||||
: c,
|
||||
),
|
||||
[openTree],
|
||||
);
|
||||
|
||||
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="제품구분">
|
||||
<CommCodeSelect
|
||||
groupId={PRODUCT_GROUP}
|
||||
value={filter.product_cd ?? ""}
|
||||
onValueChange={(v) => setFilter({ ...filter, product_cd: v })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="상태">
|
||||
<select
|
||||
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||||
value={filter.status ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, status: e.target.value })}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{STATUS_OPTIONS.map((o) =>
|
||||
<option key={o.code} value={o.code}>{o.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="품번">
|
||||
<Input
|
||||
value={filter.search_part_no ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
|
||||
placeholder="품번 LIKE"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="품명">
|
||||
<Input
|
||||
value={filter.search_part_name ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
|
||||
placeholder="품명 LIKE"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="text-xs text-muted-foreground">총 {total.toLocaleString()}건</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>
|
||||
<Button size="sm" onClick={() => setExcelOpen(true)}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white">
|
||||
<FileSpreadsheet className="h-4 w-4" /><span className="ml-1">E-BOM 등록(Excel)</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleStatusChange}
|
||||
disabled={checkedIds.length !== 1}>
|
||||
<Settings className="h-4 w-4" /><span className="ml-1">상태변경</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={handleDelete}
|
||||
disabled={checkedIds.length === 0}>
|
||||
<Trash2 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
|
||||
showCheckbox
|
||||
checkedIds={checkedIds}
|
||||
onCheckedChange={setCheckedIds}
|
||||
emptyMessage="등록된 E-BOM이 없습니다."
|
||||
gridId="development-ebom-regist"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BomReportStatusDialog
|
||||
open={statusOpen}
|
||||
onOpenChange={setStatusOpen}
|
||||
objid={statusObjid}
|
||||
onSaved={fetchList}
|
||||
/>
|
||||
<BomReportExcelImportDialog
|
||||
open={excelOpen}
|
||||
onOpenChange={setExcelOpen}
|
||||
initialProductCd={filter.product_cd ?? ""}
|
||||
onSaved={fetchList}
|
||||
/>
|
||||
<BomReportTreeDialog
|
||||
open={treeOpen}
|
||||
onOpenChange={setTreeOpen}
|
||||
bomReport={treeReport}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
// 개발관리 > E-BOM 조회 (M4) — wace structureAscendingList.jsp 1:1
|
||||
// 정전개(루트→리프) / 역전개(리프→부모) 토글. 동적 LEVEL 컬럼.
|
||||
// 참조: docs/migration/development/02-ebom.md
|
||||
|
||||
import React, { useCallback, 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, ChevronsRight, ChevronsLeft, FileSpreadsheet,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { devBomApi, BomTreeFilter, BomTreeRow } from "@/lib/api/devBom";
|
||||
|
||||
type Direction = "ascending" | "descending";
|
||||
|
||||
const EMPTY_FILTER: BomTreeFilter = {
|
||||
project_name: "", unit_code: "",
|
||||
search_part_no: "", search_part_name: "",
|
||||
};
|
||||
|
||||
export default function EbomSearchPage() {
|
||||
const [filter, setFilter] = useState<BomTreeFilter>(EMPTY_FILTER);
|
||||
const [direction, setDirection] = useState<Direction>("ascending");
|
||||
const [rows, setRows] = useState<BomTreeRow[]>([]);
|
||||
const [maxLevel, setMaxLevel] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const runQuery = useCallback(async (dir: Direction) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const fn = dir === "ascending" ? devBomApi.ascending : devBomApi.descending;
|
||||
const res = await fn(filter);
|
||||
setRows(res.rows ?? []);
|
||||
setMaxLevel(Number(res.max_level) || 0);
|
||||
setDirection(dir);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
// wace structureAscending/DescendingListExcel.jsp 1:1 — 현재 검색 조건 그대로 .xlsx 다운로드
|
||||
const downloadExcel = useCallback(async (dir: Direction) => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const fn = dir === "ascending" ? devBomApi.excelAscending : devBomApi.excelDescending;
|
||||
const { blob, fileName } = await fn(filter);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url; a.download = fileName;
|
||||
document.body.appendChild(a); a.click();
|
||||
a.remove(); URL.revokeObjectURL(url);
|
||||
toast.success(`${fileName} 다운로드`);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "엑셀 다운로드 실패");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
// 동적 LEVEL 컬럼: 각 레벨 컬럼은 row.lev === i 일 때만 pm_part_no 표시
|
||||
const columns: DataGridColumn[] = useMemo(() => {
|
||||
const levelCols: DataGridColumn[] = [];
|
||||
for (let i = 1; i <= Math.max(1, maxLevel); i++) {
|
||||
levelCols.push({
|
||||
key: `__lev_${i}`,
|
||||
label: `L${i}`,
|
||||
width: "w-[140px]",
|
||||
});
|
||||
}
|
||||
return [
|
||||
...levelCols,
|
||||
{ key: "pm_part_no", label: "품번", width: "w-[160px]", frozen: false },
|
||||
{ key: "pm_part_name", label: "품명", minWidth: "min-w-[200px]" },
|
||||
{ key: "cu01_cnt", label: "3D", width: "w-[60px]", align: "right", formatNumber: true },
|
||||
{ key: "cu02_cnt", label: "2D", width: "w-[60px]", align: "right", formatNumber: true },
|
||||
{ key: "cu03_cnt", label: "PDF", width: "w-[60px]", align: "right", formatNumber: true },
|
||||
{ key: "qty", label: "수량", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
{ key: "edit_date", label: "변경일", width: "w-[120px]", align: "center" },
|
||||
{ key: "revision", label: "REV", width: "w-[60px]", align: "center" },
|
||||
{ key: "spec", label: "규격", width: "w-[120px]" },
|
||||
{ key: "material", label: "재질", width: "w-[100px]" },
|
||||
{ key: "weight", label: "중량", width: "w-[80px]", align: "right" },
|
||||
{ key: "remark", label: "비고", minWidth: "min-w-[140px]" },
|
||||
];
|
||||
}, [maxLevel]);
|
||||
|
||||
// 행 데이터: __lev_{i} 가상 셀에 lev 일치 시에만 part_no 채움
|
||||
const gridData = useMemo(
|
||||
() => rows.map((r) => {
|
||||
const expanded: any = { ...r };
|
||||
for (let i = 1; i <= Math.max(1, maxLevel); i++) {
|
||||
expanded[`__lev_${i}`] = r.lev === i ? (r.pm_part_no ?? r.part_no ?? "") : "";
|
||||
}
|
||||
return expanded;
|
||||
}),
|
||||
[rows, maxLevel],
|
||||
);
|
||||
|
||||
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="프로젝트 OBJID">
|
||||
<Input value={filter.project_name ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, project_name: e.target.value })}
|
||||
placeholder="project_mgmt.objid" />
|
||||
</Field>
|
||||
<Field label="UNIT_CODE">
|
||||
<Input value={filter.unit_code ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, unit_code: e.target.value })}
|
||||
placeholder="pms_wbs_task.objid" />
|
||||
</Field>
|
||||
<Field label="품번">
|
||||
<Input value={filter.search_part_no ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
|
||||
placeholder="part_no LIKE" />
|
||||
</Field>
|
||||
<Field label="품명">
|
||||
<Input value={filter.search_part_name ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
|
||||
placeholder="part_name LIKE" />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
모드: {direction === "ascending" ? "정전개 (루트 → 리프)" : "역전개 (리프 → 부모)"} · {rows.length.toLocaleString()}행 · MAX_LEVEL = {maxLevel}
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={() => { setFilter(EMPTY_FILTER); setRows([]); setMaxLevel(0); }}>
|
||||
<RotateCcw className="h-4 w-4" /><span className="ml-1">초기화</span>
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => runQuery("ascending")} disabled={loading}
|
||||
variant={direction === "ascending" ? "default" : "secondary"}>
|
||||
{loading && direction === "ascending"
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <ChevronsRight className="h-4 w-4" />}
|
||||
<span className="ml-1">정전개 조회</span>
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => runQuery("descending")} disabled={loading}
|
||||
variant={direction === "descending" ? "default" : "secondary"}>
|
||||
{loading && direction === "descending"
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <ChevronsLeft className="h-4 w-4" />}
|
||||
<span className="ml-1">역전개 조회</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => downloadExcel("ascending")} disabled={exporting}>
|
||||
{exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileSpreadsheet className="h-4 w-4" />}
|
||||
<span className="ml-1">정전개 엑셀</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => downloadExcel("descending")} disabled={exporting}>
|
||||
{exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileSpreadsheet className="h-4 w-4" />}
|
||||
<span className="ml-1">역전개 엑셀</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{direction === "descending" && (
|
||||
<div className="mt-2 text-xs text-amber-600">
|
||||
역전개는 PART 검색(품번/품명) 또는 BOM/프로젝트 한정 조건이 필요합니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 p-2">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={gridData}
|
||||
loading={loading}
|
||||
showRowNumber
|
||||
emptyMessage="조건에 맞는 BOM이 없습니다."
|
||||
gridId={`development-ebom-search-${direction}`}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
// 개발관리 > PART 등록 (M1) — wace partMngTempList.jsp 1:1
|
||||
// 그리드: status != 'release' 인 PART 23셀
|
||||
// 액션: 등록 / 수정 / 삭제 / 확정 / 조회
|
||||
// 참조: docs/migration/development/01-part.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, Plus, Pencil, Trash2, CheckSquare, FileSpreadsheet,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { devPartApi, PartListFilter, PartRow } from "@/lib/api/devPart";
|
||||
import { PartFormDialog } from "@/components/development/PartFormDialog";
|
||||
import { PartDetailDialog } from "@/components/development/PartDetailDialog";
|
||||
import { PartExcelImportDialog } from "@/components/development/PartExcelImportDialog";
|
||||
|
||||
// wace 23셀 + 부속 (PARENT_PART_INFO/PARTNER_TITLE/Q_QTY)
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "part_no", label: "품번", width: "w-[140px]", frozen: true },
|
||||
{ key: "part_name", label: "품명", minWidth: "min-w-[220px]" },
|
||||
{ key: "cu01_cnt", label: "3D", width: "w-[60px]", align: "right", formatNumber: true },
|
||||
{ key: "cu02_cnt", label: "2D", width: "w-[60px]", align: "right", formatNumber: true },
|
||||
{ key: "cu03_cnt", label: "PDF", width: "w-[60px]", align: "right", formatNumber: true },
|
||||
{ key: "material", label: "재료", width: "w-[100px]" },
|
||||
{ key: "heat_treatment_hardness", label: "열처리경도", width: "w-[110px]" },
|
||||
{ key: "heat_treatment_method", label: "열처리방법", width: "w-[110px]" },
|
||||
{ key: "surface_treatment", label: "표면처리", width: "w-[100px]" },
|
||||
{ key: "maker", label: "메이커", width: "w-[100px]" },
|
||||
{ key: "part_type_title", label: "범주", width: "w-[100px]" },
|
||||
{ key: "spec", label: "규격", width: "w-[140px]" },
|
||||
{ key: "acctfg_nm", label: "계정구분", width: "w-[80px]", align: "center" },
|
||||
{ key: "odrfg_nm", label: "조달구분", width: "w-[80px]", align: "center" },
|
||||
{ key: "unit_dc_nm", label: "재고단위", width: "w-[80px]", align: "center" },
|
||||
{ key: "unitmang_dc_nm", label: "관리단위", width: "w-[80px]", align: "center" },
|
||||
{ key: "unitchng_nb", label: "환산수량", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
{ key: "lot_fg_nm", label: "LOT구분", width: "w-[80px]", align: "center" },
|
||||
{ key: "use_yn_nm", label: "사용여부", width: "w-[80px]", align: "center" },
|
||||
{ key: "qc_fg_nm", label: "검사여부", width: "w-[80px]", align: "center" },
|
||||
{ key: "setitem_fg_nm", label: "SET품여부", width: "w-[90px]", align: "center" },
|
||||
{ key: "req_fg_nm", label: "의뢰여부", width: "w-[80px]", align: "center" },
|
||||
{ key: "unit_length", label: "개당길이", width: "w-[90px]", align: "right" },
|
||||
{ key: "unit_qty", label: "개당수량", width: "w-[90px]", align: "right" },
|
||||
// M1 부속
|
||||
{ key: "partner_title", label: "공급업체(시퀀스)", minWidth: "min-w-[180px]" },
|
||||
{ key: "parent_part_info", label: "상위 품번", width: "w-[120px]" },
|
||||
{ key: "q_qty", label: "수량(BOM)", width: "w-[90px]", align: "right" },
|
||||
{ key: "revision", label: "REV", width: "w-[60px]", align: "center" },
|
||||
{ key: "status", label: "상태", width: "w-[80px]", align: "center" },
|
||||
];
|
||||
|
||||
const EMPTY_FILTER: PartListFilter = {
|
||||
search_part_no: "", search_part_name: "",
|
||||
page: 1, page_size: 50,
|
||||
};
|
||||
|
||||
export default function PartRegistPage() {
|
||||
const [rows, setRows] = useState<PartRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState<PartListFilter>(EMPTY_FILTER);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<"create" | "edit">("create");
|
||||
const [formObjid, setFormObjid] = useState<string | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailObjid, setDetailObjid] = useState<string | null>(null);
|
||||
const [excelOpen, setExcelOpen] = useState(false);
|
||||
|
||||
const fetchList = useCallback(async (override?: Partial<PartListFilter>) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const f = { ...filter, ...override };
|
||||
const res = await devPartApi.listTemp(f);
|
||||
setRows(res.rows ?? []);
|
||||
setTotal(res.total ?? 0);
|
||||
setCheckedIds([]);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
useEffect(() => { fetchList(); /* eslint-disable-next-line */ }, []);
|
||||
|
||||
// 행 더블클릭 → 상세 다이얼로그
|
||||
const columns = useMemo(
|
||||
() => GRID_COLUMNS.map((c) =>
|
||||
c.key === "part_no"
|
||||
? { ...c, onClick: (row: any) => { setDetailObjid(row.objid); setDetailOpen(true); } }
|
||||
: c,
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
// 등록
|
||||
const handleCreate = () => {
|
||||
setFormMode("create");
|
||||
setFormObjid(null);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
// 수정 (단일 선택 필요)
|
||||
const handleEdit = () => {
|
||||
if (checkedIds.length !== 1) return toast.error("수정할 행 1개를 선택하세요.");
|
||||
setFormMode("edit");
|
||||
setFormObjid(checkedIds[0]);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 (다중)
|
||||
const handleDelete = async () => {
|
||||
if (checkedIds.length === 0) return toast.error("선택된 행이 없습니다.");
|
||||
if (!confirm(`${checkedIds.length}건을 삭제하시겠습니까?`)) return;
|
||||
try {
|
||||
const res = await devPartApi.remove(checkedIds);
|
||||
toast.success(res?.message ?? "삭제되었습니다.");
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패");
|
||||
}
|
||||
};
|
||||
|
||||
// 확정 (M1 → M2): EO_NO 채번 + part_mng_history 이력
|
||||
const handleDeploy = async () => {
|
||||
if (checkedIds.length === 0) return toast.error("확정할 행을 선택하세요.");
|
||||
if (!confirm(`${checkedIds.length}건을 확정하시겠습니까? (M1 → M2)`)) return;
|
||||
try {
|
||||
const res = await devPartApi.deploy(checkedIds);
|
||||
toast.success(`${res.deployed}건이 확정되었습니다.`);
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "확정 실패");
|
||||
}
|
||||
};
|
||||
|
||||
// 상세 → 수정 전환
|
||||
const handleEditFromDetail = (objid: string) => {
|
||||
setDetailOpen(false);
|
||||
setFormMode("edit");
|
||||
setFormObjid(objid);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 검색폼 — wace partMngTempList.jsp 활성 2필드 */}
|
||||
<div className="border-b bg-card px-4 py-3">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="min-w-[200px]">
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">품번</Label>
|
||||
<Input
|
||||
value={filter.search_part_no ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
|
||||
placeholder="품번 LIKE"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[200px]">
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">품명</Label>
|
||||
<Input
|
||||
value={filter.search_part_name ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
|
||||
placeholder="품명 LIKE"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto 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>
|
||||
<Button size="sm" variant="default" onClick={handleCreate}>
|
||||
<Plus className="h-4 w-4" /><span className="ml-1">등록</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleEdit}
|
||||
disabled={checkedIds.length !== 1}>
|
||||
<Pencil className="h-4 w-4" /><span className="ml-1">수정</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={handleDelete}
|
||||
disabled={checkedIds.length === 0}>
|
||||
<Trash2 className="h-4 w-4" /><span className="ml-1">삭제</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setExcelOpen(true)}>
|
||||
<FileSpreadsheet className="h-4 w-4" /><span className="ml-1">Excel Upload</span>
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleDeploy}
|
||||
disabled={checkedIds.length === 0}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white">
|
||||
<CheckSquare className="h-4 w-4" /><span className="ml-1">확정</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
총 {total.toLocaleString()}건 (M1: status ≠ 'release')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 p-2">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
showRowNumber
|
||||
showCheckbox
|
||||
checkedIds={checkedIds}
|
||||
onCheckedChange={setCheckedIds}
|
||||
emptyMessage="조건에 맞는 PART가 없습니다."
|
||||
gridId="development-part-regist"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PartFormDialog
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
mode={formMode}
|
||||
editObjid={formObjid}
|
||||
onSaved={fetchList}
|
||||
/>
|
||||
<PartDetailDialog
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
objid={detailObjid}
|
||||
onEdit={handleEditFromDetail}
|
||||
/>
|
||||
<PartExcelImportDialog
|
||||
open={excelOpen}
|
||||
onOpenChange={setExcelOpen}
|
||||
onSaved={fetchList}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
// 개발관리 > PART 조회 (M2) — wace partMngList.jsp 1:1
|
||||
// 그리드: status = 'release' 인 PART 23셀 + BOM_QTY
|
||||
// 액션: 등록 / 수정 / 삭제 / 조회 (도면연동/ERP/Excel은 별 PR)
|
||||
// 참조: docs/migration/development/01-part.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, Plus, Pencil, Trash2, FileSpreadsheet,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { devPartApi, PartListFilter, PartRow } from "@/lib/api/devPart";
|
||||
import { PartFormDialog } from "@/components/development/PartFormDialog";
|
||||
import { PartDetailDialog } from "@/components/development/PartDetailDialog";
|
||||
import { PartExcelImportDialog } from "@/components/development/PartExcelImportDialog";
|
||||
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "part_no", label: "품번", width: "w-[140px]", frozen: true },
|
||||
{ key: "part_name", label: "품명", minWidth: "min-w-[220px]" },
|
||||
{ key: "cu01_cnt", label: "3D", width: "w-[60px]", align: "right", formatNumber: true },
|
||||
{ key: "cu02_cnt", label: "2D", width: "w-[60px]", align: "right", formatNumber: true },
|
||||
{ key: "cu03_cnt", label: "PDF", width: "w-[60px]", align: "right", formatNumber: true },
|
||||
{ key: "material", label: "재료", width: "w-[100px]" },
|
||||
{ key: "heat_treatment_hardness", label: "열처리경도", width: "w-[110px]" },
|
||||
{ key: "heat_treatment_method", label: "열처리방법", width: "w-[110px]" },
|
||||
{ key: "surface_treatment", label: "표면처리", width: "w-[100px]" },
|
||||
{ key: "maker", label: "메이커", width: "w-[100px]" },
|
||||
{ key: "part_type_title", label: "범주", width: "w-[100px]" },
|
||||
{ key: "spec", label: "규격", width: "w-[140px]" },
|
||||
{ key: "acctfg_nm", label: "계정구분", width: "w-[80px]", align: "center" },
|
||||
{ key: "odrfg_nm", label: "조달구분", width: "w-[80px]", align: "center" },
|
||||
{ key: "unit_dc_nm", label: "재고단위", width: "w-[80px]", align: "center" },
|
||||
{ key: "unitmang_dc_nm", label: "관리단위", width: "w-[80px]", align: "center" },
|
||||
{ key: "unitchng_nb", label: "환산수량", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
{ key: "lot_fg_nm", label: "LOT구분", width: "w-[80px]", align: "center" },
|
||||
{ key: "use_yn_nm", label: "사용여부", width: "w-[80px]", align: "center" },
|
||||
{ key: "qc_fg_nm", label: "검사여부", width: "w-[80px]", align: "center" },
|
||||
{ key: "setitem_fg_nm", label: "SET품여부", width: "w-[90px]", align: "center" },
|
||||
{ key: "req_fg_nm", label: "의뢰여부", width: "w-[80px]", align: "center" },
|
||||
{ key: "unit_length", label: "개당길이", width: "w-[90px]", align: "right" },
|
||||
{ key: "unit_qty", label: "개당수량", width: "w-[90px]", align: "right" },
|
||||
// M2 추가
|
||||
{ key: "bom_qty", label: "BOM 수량", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
{ key: "revision", label: "REV", width: "w-[60px]", align: "center" },
|
||||
{ key: "eo_no", label: "EO_NO", width: "w-[120px]" },
|
||||
];
|
||||
|
||||
const EMPTY_FILTER: PartListFilter = {
|
||||
search_part_no: "", search_part_name: "",
|
||||
page: 1, page_size: 50,
|
||||
};
|
||||
|
||||
export default function PartSearchPage() {
|
||||
const [rows, setRows] = useState<PartRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState<PartListFilter>(EMPTY_FILTER);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<"create" | "edit">("create");
|
||||
const [formObjid, setFormObjid] = useState<string | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailObjid, setDetailObjid] = useState<string | null>(null);
|
||||
const [excelOpen, setExcelOpen] = useState(false);
|
||||
|
||||
const fetchList = useCallback(async (override?: Partial<PartListFilter>) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const f = { ...filter, ...override };
|
||||
const res = await devPartApi.list(f);
|
||||
setRows(res.rows ?? []);
|
||||
setTotal(res.total ?? 0);
|
||||
setCheckedIds([]);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
useEffect(() => { fetchList(); /* eslint-disable-next-line */ }, []);
|
||||
|
||||
const columns = useMemo(
|
||||
() => GRID_COLUMNS.map((c) =>
|
||||
c.key === "part_no"
|
||||
? { ...c, onClick: (row: any) => { setDetailObjid(row.objid); setDetailOpen(true); } }
|
||||
: c,
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCreate = () => {
|
||||
setFormMode("create"); setFormObjid(null); setFormOpen(true);
|
||||
};
|
||||
const handleEdit = () => {
|
||||
if (checkedIds.length !== 1) return toast.error("수정할 행 1개를 선택하세요.");
|
||||
setFormMode("edit"); setFormObjid(checkedIds[0]); setFormOpen(true);
|
||||
};
|
||||
const handleDelete = async () => {
|
||||
if (checkedIds.length === 0) return toast.error("선택된 행이 없습니다.");
|
||||
if (!confirm(`${checkedIds.length}건을 삭제하시겠습니까?`)) return;
|
||||
try {
|
||||
const res = await devPartApi.remove(checkedIds);
|
||||
toast.success(res?.message ?? "삭제되었습니다.");
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패");
|
||||
}
|
||||
};
|
||||
const handleEditFromDetail = (objid: string) => {
|
||||
setDetailOpen(false);
|
||||
setFormMode("edit"); setFormObjid(objid); setFormOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b bg-card px-4 py-3">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="min-w-[200px]">
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">품번</Label>
|
||||
<Input
|
||||
value={filter.search_part_no ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
|
||||
placeholder="품번 LIKE"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[200px]">
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">품명</Label>
|
||||
<Input
|
||||
value={filter.search_part_name ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
|
||||
placeholder="품명 LIKE"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto 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>
|
||||
<Button size="sm" variant="default" onClick={handleCreate}>
|
||||
<Plus className="h-4 w-4" /><span className="ml-1">등록</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleEdit}
|
||||
disabled={checkedIds.length !== 1}>
|
||||
<Pencil className="h-4 w-4" /><span className="ml-1">수정</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={handleDelete}
|
||||
disabled={checkedIds.length === 0}>
|
||||
<Trash2 className="h-4 w-4" /><span className="ml-1">삭제</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setExcelOpen(true)}>
|
||||
<FileSpreadsheet className="h-4 w-4" /><span className="ml-1">Excel Upload</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
총 {total.toLocaleString()}건 (M2: status = 'release')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 p-2">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
showRowNumber
|
||||
showCheckbox
|
||||
checkedIds={checkedIds}
|
||||
onCheckedChange={setCheckedIds}
|
||||
emptyMessage="조건에 맞는 PART가 없습니다."
|
||||
gridId="development-part-search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PartFormDialog
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
mode={formMode}
|
||||
editObjid={formObjid}
|
||||
onSaved={fetchList}
|
||||
/>
|
||||
<PartDetailDialog
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
objid={detailObjid}
|
||||
onEdit={handleEditFromDetail}
|
||||
/>
|
||||
<PartExcelImportDialog
|
||||
open={excelOpen}
|
||||
onOpenChange={setExcelOpen}
|
||||
onSaved={fetchList}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
"use client";
|
||||
|
||||
// 개발관리 E-BOM 등록 CSV Import 다이얼로그
|
||||
// wace partMng/openBomReportExcelImportPopUp.jsp 1:1
|
||||
//
|
||||
// 운영판 흐름:
|
||||
// · Drop Zone: Drag & Drop CSV 템플릿 (fnc_setFileDropZone(..., "csv"))
|
||||
// · 파싱: parsingExcelFile.do 의 .csv 분기 → parsingCsvFile (수준 기반 부모 자동 매핑)
|
||||
// · 저장: partBomApplySave.do → savePartBomMaster
|
||||
//
|
||||
// CSV 컬럼 (11개, 헤더 1줄 후 데이터):
|
||||
// 0:수준 1:품번 2:품명 3:수량 4:항목수량 5:재료 6:열처리경도 7:열처리방법
|
||||
// 8:표면처리 9:공급업체(MAKER) 10:범주이름(PART_TYPE)
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
||||
import { Download, Upload, Save, Loader2, FileX, Copy } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { devBomApi, BomCsvRow, BomCopySourceRow } from "@/lib/api/devBom";
|
||||
|
||||
const PRODUCT_GROUP = "0000001";
|
||||
const TEMPLATE_DOWNLOAD_URL = "/templates/BOM_REPORT_CSV_IMPORT_TEMPLATE.csv";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editObjid?: string | null;
|
||||
initialProductCd?: string;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
interface Column {
|
||||
key: keyof BomCsvRow;
|
||||
label: string;
|
||||
width: string;
|
||||
align?: "left" | "center" | "right";
|
||||
showNameFor?: keyof BomCsvRow;
|
||||
}
|
||||
|
||||
// 그리드 컬럼: 화면 표시는 핵심 + 자동 채움 컬럼 (운영 그리드 25컬럼 중 CSV 11컬럼 + 자동 ACCTFG/ODRFG)
|
||||
const COLUMNS: Column[] = [
|
||||
{ key: "NOTE", label: "결과", width: "min-w-[200px]", align: "left" },
|
||||
{ key: "LEVEL", label: "수준", width: "min-w-[60px]", align: "center" },
|
||||
{ key: "PARENT_PART_NO", label: "모품번 (자동)", width: "min-w-[140px]", align: "center" },
|
||||
{ key: "PART_NO", label: "품번", width: "min-w-[140px]", align: "center" },
|
||||
{ key: "PART_NAME", label: "품명", width: "min-w-[200px]", align: "left" },
|
||||
{ key: "QTY", label: "수량", width: "min-w-[70px]", align: "right" },
|
||||
{ key: "ITEM_QTY", label: "항목수량", width: "min-w-[80px]", align: "right" },
|
||||
{ key: "MATERIAL", label: "재료", width: "min-w-[100px]" },
|
||||
{ key: "HEAT_TREATMENT_HARDNESS", label: "열처리경도", width: "min-w-[100px]" },
|
||||
{ key: "HEAT_TREATMENT_METHOD", label: "열처리방법", width: "min-w-[100px]" },
|
||||
{ key: "SURFACE_TREATMENT", label: "표면처리", width: "min-w-[100px]" },
|
||||
{ key: "MAKER", label: "공급업체", width: "min-w-[110px]" },
|
||||
{ key: "PART_TYPE", label: "범주", width: "min-w-[90px]", align: "center", showNameFor: "PART_TYPE_NAME" },
|
||||
{ key: "ACCTFG", label: "계정구분(자동)", width: "min-w-[90px]", align: "center" },
|
||||
{ key: "ODRFG", label: "조달구분(자동)", width: "min-w-[90px]", align: "center" },
|
||||
];
|
||||
|
||||
const ACCTFG_LABEL: Record<string, string> = { "4": "반제품", "7": "비용" };
|
||||
const ODRFG_LABEL: Record<string, string> = { "0": "구매", "1": "생산", "8": "Phantom" };
|
||||
|
||||
function displayValue(r: BomCsvRow, col: Column): string {
|
||||
if (col.key === "ACCTFG") {
|
||||
const v = String(r.ACCTFG ?? "");
|
||||
return ACCTFG_LABEL[v] ?? v;
|
||||
}
|
||||
if (col.key === "ODRFG") {
|
||||
const v = String(r.ODRFG ?? "");
|
||||
return ODRFG_LABEL[v] ?? v;
|
||||
}
|
||||
if (col.showNameFor) {
|
||||
const name = r[col.showNameFor];
|
||||
if (name) return String(name);
|
||||
}
|
||||
return String(r[col.key] ?? "");
|
||||
}
|
||||
|
||||
export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, initialProductCd, onSaved }: Props) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [productCd, setProductCd] = useState<string>("");
|
||||
const [bomPartNo, setBomPartNo] = useState<string>("");
|
||||
const [bomPartName, setBomPartName] = useState<string>("");
|
||||
const [version, setVersion] = useState<string>("");
|
||||
|
||||
const [copyOptions, setCopyOptions] = useState<BomCopySourceRow[]>([]);
|
||||
const [copySelect, setCopySelect] = useState<string>("");
|
||||
|
||||
const [rows, setRows] = useState<BomCsvRow[]>([]);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [fileName, setFileName] = useState<string>("");
|
||||
const [encoding, setEncoding] = useState<string>("");
|
||||
const [parsing, setParsing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setProductCd(initialProductCd ?? "");
|
||||
setBomPartNo("");
|
||||
setBomPartName("");
|
||||
setVersion("");
|
||||
setRows([]);
|
||||
setHasError(false);
|
||||
setFileName("");
|
||||
setEncoding("");
|
||||
setCopySelect("");
|
||||
}, [initialProductCd]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
reset();
|
||||
devBomApi.excelCopySource().then(setCopyOptions).catch(() => setCopyOptions([]));
|
||||
}, [open, reset]);
|
||||
|
||||
const handleDialogChange = (v: boolean) => {
|
||||
if (!v) reset();
|
||||
onOpenChange(v);
|
||||
};
|
||||
|
||||
const applyFirstLevelToHeader = (first: { part_no: string; part_name: string } | null) => {
|
||||
if (!first) return;
|
||||
if (first.part_no) setBomPartNo(first.part_no);
|
||||
if (first.part_name) setBomPartName(first.part_name);
|
||||
};
|
||||
|
||||
const parseFile = useCallback(async (file: File) => {
|
||||
if (!/\.csv$/i.test(file.name)) {
|
||||
toast.error("CSV(.csv) 파일만 업로드 가능합니다.");
|
||||
return;
|
||||
}
|
||||
setParsing(true);
|
||||
setFileName(file.name);
|
||||
try {
|
||||
const data = await devBomApi.excelParse(file);
|
||||
setRows(data.rows ?? []);
|
||||
setHasError(!!data.hasError);
|
||||
setEncoding(data.encoding ?? "");
|
||||
applyFirstLevelToHeader(data.firstLevel);
|
||||
if (!data.rows || data.rows.length === 0) {
|
||||
toast.warning("파싱된 데이터가 없습니다. CSV 형식을 확인해 주세요.");
|
||||
} else if (data.hasError) {
|
||||
toast.error("CSV 파일 로딩결과가 유효하지 않습니다. 결과 메시지를 확인해 주세요.");
|
||||
} else {
|
||||
toast.success(`${data.rows.length}건 파싱 완료 (인코딩: ${data.encoding})`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "CSV 파싱 실패");
|
||||
setRows([]); setHasError(false); setFileName(""); setEncoding("");
|
||||
} finally {
|
||||
setParsing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) parseFile(f);
|
||||
e.target.value = "";
|
||||
};
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) parseFile(f);
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!copySelect) { toast.error("복사할 BOM을 선택하세요."); return; }
|
||||
setCopying(true);
|
||||
try {
|
||||
const copied = await devBomApi.excelCopy(copySelect);
|
||||
setRows(copied);
|
||||
setHasError(false);
|
||||
const first = copied.find((r) => !r.PARENT_PART_NO);
|
||||
if (first) applyFirstLevelToHeader({ part_no: first.PART_NO, part_name: first.PART_NAME });
|
||||
toast.success(`BOM 데이터 ${copied.length}건 불러왔습니다.`);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "BOM 복사 실패");
|
||||
} finally {
|
||||
setCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!productCd) { toast.error("제품구분을 선택해 주세요."); return; }
|
||||
if (!bomPartNo) { toast.error("품번을 입력해 주세요."); return; }
|
||||
if (!bomPartName){ toast.error("품명을 입력해 주세요."); return; }
|
||||
if (hasError) {
|
||||
toast.error("CSV 파일 로딩결과가 유효하지 않습니다. 결과 메시지를 확인해 주세요.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const dup = await devBomApi.excelCheckDuplicate(bomPartNo, editObjid ?? undefined);
|
||||
if (dup) {
|
||||
toast.error("입력한 품번이 이미 존재합니다. 다른 품번을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
} catch { /* 비차단 */ }
|
||||
|
||||
const confirmMsg = rows.length > 0 ? "저장 하시겠습니까?" : "품번, 품명으로 빈 E-BOM을 생성하시겠습니까?";
|
||||
if (!confirm(confirmMsg)) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await devBomApi.excelSave({
|
||||
bomReportObjid: editObjid ?? undefined,
|
||||
productCd, partNo: bomPartNo, partName: bomPartName, version,
|
||||
rows,
|
||||
});
|
||||
toast.success(`${result.mode === "create" ? "등록" : "수정"} 완료 — BOM ${result.bomRows}건 (PART 신규 ${result.insertedParts} / 수정 ${result.updatedParts})`);
|
||||
onSaved();
|
||||
handleDialogChange(false);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const errorCount = useMemo(() => rows.filter((r) => r.NOTE).length, [rows]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogChange}>
|
||||
<DialogContent className="max-w-[1500px] w-[97vw] max-h-[92vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>PART 및 구조등록 CSV upload</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="grid grid-cols-4 gap-3 border-b pb-3">
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">제품구분 *</Label>
|
||||
<CommCodeSelect
|
||||
groupId={PRODUCT_GROUP}
|
||||
value={productCd}
|
||||
onValueChange={setProductCd}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">품번 * (1레벨 자동)</Label>
|
||||
<Input value={bomPartNo} readOnly placeholder="CSV 1레벨에서 자동 채움" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">품명 * (1레벨 자동)</Label>
|
||||
<Input value={bomPartName} readOnly placeholder="CSV 1레벨에서 자동 채움" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">Version</Label>
|
||||
<Input value={version} onChange={(e) => setVersion(e.target.value)} placeholder="REV 등" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* E-BOM 복사 + 액션 버튼 */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap">E-BOM 복사</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border bg-background px-2 text-sm min-w-[280px]"
|
||||
value={copySelect}
|
||||
onChange={(e) => setCopySelect(e.target.value)}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{copyOptions.map((o) => (
|
||||
<option key={o.objid} value={o.objid}>
|
||||
{o.part_no} / {o.part_name} {o.revision ? `(v${o.revision})` : ""} - {o.regdate ?? ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button variant="outline" size="sm" onClick={handleCopy} disabled={copying || !copySelect}>
|
||||
{copying ? <Loader2 className="h-4 w-4 animate-spin" /> : <Copy className="h-4 w-4" />}
|
||||
<span className="ml-1">복사</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={TEMPLATE_DOWNLOAD_URL} download>
|
||||
<Download className="h-4 w-4" /><span className="ml-1">Template Download</span>
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()} disabled={parsing}>
|
||||
<Upload className="h-4 w-4" /><span className="ml-1">CSV 파일 선택</span>
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
onChange={handleFileInput}
|
||||
/>
|
||||
{rows.length > 0 && (
|
||||
<Button variant="ghost" size="sm"
|
||||
onClick={() => { setRows([]); setHasError(false); setFileName(""); setEncoding(""); }}>
|
||||
<FileX className="h-4 w-4" /><span className="ml-1">초기화</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-3">
|
||||
{fileName && <span className="truncate max-w-[400px]">{fileName}</span>}
|
||||
{encoding && <span>인코딩: <b>{encoding}</b></span>}
|
||||
<span>총 {rows.length}건</span>
|
||||
{errorCount > 0 && <span className="text-destructive font-semibold">에러 {errorCount}건</span>}
|
||||
</div>
|
||||
|
||||
{/* Drop Zone */}
|
||||
{rows.length === 0 && !parsing && (
|
||||
<div
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded p-8 text-center transition-colors cursor-pointer",
|
||||
dragOver ? "border-primary bg-primary/5" : "border-muted-foreground/30"
|
||||
)}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-9 w-9 mx-auto text-muted-foreground mb-2" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Drag & Drop 또는 클릭하여 CSV 템플릿 업로드 (.csv)
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
컬럼: 수준 / 품번 / 품명 / 수량 / 항목수량 / 재료 / 열처리경도 / 열처리방법 / 표면처리 / 공급업체 / 범주이름
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsing && (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" /><span className="ml-2">파싱 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 결과 그리드 */}
|
||||
{rows.length > 0 && !parsing && (
|
||||
<div className="flex-1 min-h-0 overflow-auto border rounded">
|
||||
<table className="text-xs border-collapse w-max min-w-full">
|
||||
<thead className="bg-muted sticky top-0">
|
||||
<tr>
|
||||
<th className="border px-2 py-1 w-[40px] text-center">#</th>
|
||||
{COLUMNS.map((c) => (
|
||||
<th key={c.key as string} className={cn("border px-2 py-1", c.width)}>
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i} className={r.NOTE ? "bg-destructive/5" : ""}>
|
||||
<td className="border px-2 py-1 text-center">{i + 1}</td>
|
||||
{COLUMNS.map((c) => {
|
||||
const value = displayValue(r, c);
|
||||
const isNote = c.key === "NOTE";
|
||||
return (
|
||||
<td
|
||||
key={c.key as string}
|
||||
className={cn(
|
||||
"border px-2 py-1 whitespace-nowrap",
|
||||
c.align === "right" && "text-right",
|
||||
c.align === "center" && "text-center",
|
||||
isNote && r.NOTE && "text-destructive font-semibold"
|
||||
)}
|
||||
title={value}
|
||||
>
|
||||
{value}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => handleDialogChange(false)}>닫기</Button>
|
||||
<Button onClick={handleSave} disabled={saving || hasError}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
<span className="ml-1">저장</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
// 개발관리 > E-BOM 상태 변경 다이얼로그.
|
||||
// wace structureStatusChangePopup 1:1 — STATUS select(create/changeDesign/deploy) + 부속 4필드.
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { devBomApi, BomReportRow } from "@/lib/api/devBom";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ code: "create", label: "등록중" },
|
||||
{ code: "changeDesign", label: "설계변경미배포" },
|
||||
{ code: "deploy", label: "배포완료" },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
objid: string | null;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export function BomReportStatusDialog({ open, onOpenChange, objid, onSaved }: Props) {
|
||||
const [row, setRow] = useState<BomReportRow | null>(null);
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [version, setVersion] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !objid) return;
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
devBomApi.detail(objid)
|
||||
.then((data) => {
|
||||
if (!alive) return;
|
||||
if (!data) {
|
||||
toast.error("E-BOM 보고서를 찾을 수 없습니다.");
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
setRow(data);
|
||||
setStatus(data.status ?? "");
|
||||
setVersion(data.revision ?? "");
|
||||
})
|
||||
.catch((e: any) => {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
||||
onOpenChange(false);
|
||||
})
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [open, objid, onOpenChange]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!objid) return;
|
||||
if (!status) return toast.error("상태를 선택하세요.");
|
||||
setSaving(true);
|
||||
try {
|
||||
await devBomApi.updateStatus(objid, {
|
||||
status,
|
||||
version: version || undefined,
|
||||
});
|
||||
toast.success("상태가 변경되었습니다.");
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>E-BOM 상태 변경</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading || !row ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-sm space-y-1">
|
||||
<div><span className="text-muted-foreground">제품구분:</span> {row.product_name ?? row.product_cd ?? "—"}</div>
|
||||
<div><span className="text-muted-foreground">품번:</span> {row.part_no ?? "—"}</div>
|
||||
<div><span className="text-muted-foreground">품명:</span> {row.part_name ?? "—"}</div>
|
||||
<div><span className="text-muted-foreground">현재상태:</span> {row.status_title ?? row.status ?? "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">변경 상태 *</Label>
|
||||
<select
|
||||
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{STATUS_OPTIONS.map((o) =>
|
||||
<option key={o.code} value={o.code}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">Version</Label>
|
||||
<Input value={version} onChange={(e) => setVersion(e.target.value)} placeholder="예: RE, A, B..." />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving || loading}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
<span className="ml-1">저장</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
// 개발관리 E-BOM 트리 다이얼로그 — wace setStructurePopupMainFS 1:1 (단일 다이얼로그로 통합)
|
||||
//
|
||||
// 운영판은 frameset(헤더/좌측 트리/우측 디테일/하단 버튼) 4-Frame 팝업이지만 RPS 는 단일
|
||||
// 다이얼로그에 헤더(BOM Report 메타) + 동적 LEVEL 컬럼 트리 그리드 + 엑셀 다운로드 버튼.
|
||||
//
|
||||
// 컬럼 (운영 structureAscendingListExcel.jsp 1:1):
|
||||
// 동적 L1..LMaxLevel ("*" 표시) + 품번 / 품명 / 수량 / 항목수량 / 3D / 2D / PDF /
|
||||
// 재료 / 열처리경도 / 열처리방법 / 표면처리 / 메이커 / 범주 이름 / 비고
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, FileSpreadsheet } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { devBomApi, BomReportRow, BomTreeFullRow } from "@/lib/api/devBom";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
bomReport: BomReportRow | null; // 헤더 정보 표시용
|
||||
}
|
||||
|
||||
export function BomReportTreeDialog({ open, onOpenChange, bomReport }: Props) {
|
||||
const [rows, setRows] = useState<BomTreeFullRow[]>([]);
|
||||
const [maxLevel, setMaxLevel] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !bomReport?.objid) {
|
||||
setRows([]); setMaxLevel(0);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
devBomApi.treeFull({ bom_report_objid: bomReport.objid })
|
||||
.then((data) => {
|
||||
if (!alive) return;
|
||||
setRows(data.rows ?? []);
|
||||
setMaxLevel(Number(data.max_level) || 0);
|
||||
})
|
||||
.catch((e: any) => {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "트리 조회 실패");
|
||||
})
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [open, bomReport?.objid]);
|
||||
|
||||
const handleExcel = async () => {
|
||||
if (!bomReport?.objid) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const { blob, fileName } = await devBomApi.excelAscending({ bom_report_objid: bomReport.objid });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url; a.download = fileName;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(`${fileName} 다운로드`);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "엑셀 다운로드 실패");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const effectiveMax = Math.max(1, maxLevel);
|
||||
|
||||
// 동적 LEVEL 컬럼 헤더 (1..maxLevel)
|
||||
const levelHeaders = useMemo(() => {
|
||||
const h: number[] = [];
|
||||
for (let i = 1; i <= effectiveMax; i++) h.push(i);
|
||||
return h;
|
||||
}, [effectiveMax]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[1500px] w-[97vw] max-h-[92vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="bg-blue-600 px-4 py-3">
|
||||
<DialogTitle className="text-white">BOM 구조 조회</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 헤더 메타 */}
|
||||
{bomReport && (
|
||||
<div className="grid grid-cols-4 gap-x-6 gap-y-1.5 border-b px-4 py-3 text-xs">
|
||||
<MetaRow label="제품구분" value={bomReport.product_name ?? bomReport.product_cd} />
|
||||
<MetaRow label="품번" value={bomReport.part_no} />
|
||||
<MetaRow label="품명" value={bomReport.part_name} />
|
||||
<MetaRow label="Version" value={bomReport.revision} />
|
||||
<MetaRow label="상태" value={bomReport.status_title} />
|
||||
<MetaRow label="등록자" value={bomReport.dept_user_name} />
|
||||
<MetaRow label="등록일" value={bomReport.reg_date} />
|
||||
<MetaRow label="확정일" value={bomReport.deploy_date} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
총 {rows.length.toLocaleString()}행 · MAX_LEVEL = {maxLevel}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={handleExcel} disabled={exporting || rows.length === 0}>
|
||||
{exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileSpreadsheet className="h-4 w-4" />}
|
||||
<span className="ml-1">엑셀 다운로드</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<table className="text-xs border-collapse w-max min-w-full">
|
||||
<thead className="bg-yellow-100 dark:bg-yellow-900/30 sticky top-0">
|
||||
<tr>
|
||||
{levelHeaders.map((i) => (
|
||||
<th key={`l${i}`} className="border px-2 py-1 w-[36px] text-center font-bold">{i}</th>
|
||||
))}
|
||||
<th className="border px-2 py-1 min-w-[150px] text-left">품번</th>
|
||||
<th className="border px-2 py-1 min-w-[180px] text-left">품명</th>
|
||||
<th className="border px-2 py-1 min-w-[60px] text-right">수량</th>
|
||||
<th className="border px-2 py-1 min-w-[70px] text-right">항목수량</th>
|
||||
<th className="border px-2 py-1 min-w-[40px] text-center">3D</th>
|
||||
<th className="border px-2 py-1 min-w-[40px] text-center">2D</th>
|
||||
<th className="border px-2 py-1 min-w-[40px] text-center">PDF</th>
|
||||
<th className="border px-2 py-1 min-w-[100px] text-left">재료</th>
|
||||
<th className="border px-2 py-1 min-w-[100px] text-left">열처리경도</th>
|
||||
<th className="border px-2 py-1 min-w-[100px] text-left">열처리방법</th>
|
||||
<th className="border px-2 py-1 min-w-[100px] text-left">표면처리</th>
|
||||
<th className="border px-2 py-1 min-w-[110px] text-left">메이커</th>
|
||||
<th className="border px-2 py-1 min-w-[100px] text-center">범주 이름</th>
|
||||
<th className="border px-2 py-1 min-w-[130px] text-left">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={levelHeaders.length + 14} className="py-8 text-center text-muted-foreground">
|
||||
BOM 구조가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rows.map((r, idx) => {
|
||||
const lev = Number(r.lev ?? 1);
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-muted/30">
|
||||
{levelHeaders.map((i) => (
|
||||
<td key={`lc${i}`} className={cn("border px-1 py-0.5 text-center", i === lev && "font-bold")}>
|
||||
{i === lev ? "*" : ""}
|
||||
</td>
|
||||
))}
|
||||
<td className="border px-2 py-0.5 whitespace-nowrap">{r.pm_part_no}</td>
|
||||
<td className="border px-2 py-0.5">{r.pm_part_name}</td>
|
||||
<td className="border px-2 py-0.5 text-right">{r.qty}</td>
|
||||
<td className="border px-2 py-0.5 text-right">{r.p_qty}</td>
|
||||
<td className="border px-2 py-0.5 text-center">{Number(r.cu01_cnt ?? 0) > 0 ? "Y" : ""}</td>
|
||||
<td className="border px-2 py-0.5 text-center">{Number(r.cu02_cnt ?? 0) > 0 ? "Y" : ""}</td>
|
||||
<td className="border px-2 py-0.5 text-center">{Number(r.cu03_cnt ?? 0) > 0 ? "Y" : ""}</td>
|
||||
<td className="border px-2 py-0.5">{r.material}</td>
|
||||
<td className="border px-2 py-0.5">{r.heat_treatment_hardness}</td>
|
||||
<td className="border px-2 py-0.5">{r.heat_treatment_method}</td>
|
||||
<td className="border px-2 py-0.5">{r.surface_treatment}</td>
|
||||
<td className="border px-2 py-0.5">{r.maker}</td>
|
||||
<td className="border px-2 py-0.5 text-center">{r.part_type_title}</td>
|
||||
<td className="border px-2 py-0.5">{r.remark}</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>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaRow({ label, value }: { label: string; value: any }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-muted-foreground w-[60px] shrink-0">{label}</span>
|
||||
<span className="font-medium">{value != null && value !== "" ? value : "—"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
// 개발관리 > PART 상세 다이얼로그 — wace partMng/partMngDetailPopUp.jsp 1:1
|
||||
//
|
||||
// 운영판은 form 과 동일 화면을 disabled 로 표시 후 "수정" 클릭 시 활성화.
|
||||
// RPS 에서는 PartFormDialog 와 분리 유지 (호환). 본 다이얼로그는 Form 레이아웃 readonly +
|
||||
// 부속 정보 행 추가 (EO_NO / EO_DATE / EO구분(CHANGE_TYPE) / EO사유(CHANGE_OPTION)) +
|
||||
// CAD Data 영역 (3D / 2D(Drawing) / 2D(PDF)) 표시.
|
||||
//
|
||||
// "수정" 버튼: 부모가 본 다이얼로그를 닫고 PartFormDialog(mode='edit') 오픈하도록 onEdit 콜백 호출.
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, Pencil, FileText } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { devPartApi, PartRow } from "@/lib/api/devPart";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LABEL_ODRFG: Record<string, string> = { "0": "구매", "1": "생산", "8": "Phantom" };
|
||||
const LABEL_LOT_FG: Record<string, string> = { "0": "미사용", "1": "사용" };
|
||||
const LABEL_USE_YN: Record<string, string> = { "0": "미사용", "1": "사용" };
|
||||
const LABEL_QC_FG: Record<string, string> = { "0": "무검사", "1": "검사" };
|
||||
const LABEL_YESNO: Record<string, string> = { "0": "부", "1": "여" };
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
objid: string | null;
|
||||
/** "수정" 버튼 클릭 시 호출 — 부모는 본 다이얼로그 닫고 PartFormDialog(mode='edit') 오픈 */
|
||||
onEdit?: (objid: string) => void;
|
||||
}
|
||||
|
||||
export function PartDetailDialog({ open, onOpenChange, objid, onEdit }: Props) {
|
||||
const [row, setRow] = useState<PartRow | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !objid) return;
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
devPartApi.detail(objid)
|
||||
.then((data) => { if (alive) setRow(data); })
|
||||
.catch((e: any) => {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
||||
onOpenChange(false);
|
||||
})
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [open, objid, onOpenChange]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[92vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="bg-blue-600 px-4 py-3">
|
||||
<DialogTitle className="text-white">품목 상세</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading || !row ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{/* 운영판 colgroup 1:1 (12% / 12% / 25% / 12% / *) */}
|
||||
<table className="w-full border-collapse text-sm table-fixed">
|
||||
<colgroup>
|
||||
<col style={{ width: "12%" }} />
|
||||
<col style={{ width: "12%" }} />
|
||||
<col style={{ width: "25%" }} />
|
||||
<col style={{ width: "12%" }} />
|
||||
<col />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<Tr>
|
||||
<Th>품번</Th><Td colSpan={2}><Ro>{row.part_no}</Ro></Td>
|
||||
<Th>품명</Th><Td><Ro>{row.part_name}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>재료</Th><Td colSpan={2}><Ro>{row.material}</Ro></Td>
|
||||
<Th>열처리경도</Th><Td><Ro>{row.heat_treatment_hardness}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>열처리방법</Th><Td colSpan={2}><Ro>{row.heat_treatment_method}</Ro></Td>
|
||||
<Th>표면처리</Th><Td><Ro>{row.surface_treatment}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>메이커</Th><Td colSpan={2}><Ro>{row.maker}</Ro></Td>
|
||||
<Th>범주 이름</Th><Td><Ro>{row.part_type_title}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>규격</Th><Td colSpan={4}><Ro>{row.spec}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>계정구분</Th><Td colSpan={2}><Ro>{row.acctfg_nm}</Ro></Td>
|
||||
<Th>조달구분</Th><Td><Ro>{LABEL_ODRFG[row.odrfg ?? ""] ?? row.odrfg_nm ?? ""}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>재고단위</Th><Td colSpan={2}><Ro>{row.unit_dc_nm}</Ro></Td>
|
||||
<Th>관리단위</Th><Td><Ro>{row.unitmang_dc_nm}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>환산수량</Th>
|
||||
<Td colSpan={2}><Ro align="right">{row.unitchng_nb != null && row.unitchng_nb !== "" ? String(row.unitchng_nb) : ""}</Ro></Td>
|
||||
<Th>LOT구분</Th><Td><Ro>{LABEL_LOT_FG[row.lot_fg ?? ""] ?? row.lot_fg_nm ?? ""}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>사용여부</Th><Td colSpan={2}><Ro>{LABEL_USE_YN[row.use_yn ?? ""] ?? row.use_yn_nm ?? ""}</Ro></Td>
|
||||
<Th>검사여부</Th><Td><Ro>{LABEL_QC_FG[row.qc_fg ?? ""] ?? row.qc_fg_nm ?? ""}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>SET품여부</Th><Td colSpan={2}><Ro>{LABEL_YESNO[row.setitem_fg ?? ""] ?? row.setitem_fg_nm ?? ""}</Ro></Td>
|
||||
<Th>의뢰여부</Th><Td><Ro>{LABEL_YESNO[row.req_fg ?? ""] ?? row.req_fg_nm ?? ""}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>개당길이</Th><Td colSpan={2}><Ro align="right">{row.unit_length}</Ro></Td>
|
||||
<Th>개당소요량</Th><Td><Ro align="right">{row.unit_qty}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>비고</Th><Td colSpan={4}><Ro>{row.remark}</Ro></Td>
|
||||
</Tr>
|
||||
|
||||
{/* 부속 — wace detail 만 표시 */}
|
||||
<Tr>
|
||||
<Th>EO No</Th><Td colSpan={2}><Ro>{row.eo_no}</Ro></Td>
|
||||
<Th>EO Date</Th><Td><Ro>{row.eo_date}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>EO구분</Th><Td colSpan={2}><Ro>{row.change_type}</Ro></Td>
|
||||
<Th>EO사유</Th><Td><Ro>{row.change_option_name ?? row.change_option}</Ro></Td>
|
||||
</Tr>
|
||||
|
||||
{/* CAD Data */}
|
||||
<tr>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left align-top font-medium" rowSpan={3}>
|
||||
CAD Data
|
||||
</th>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">3D</th>
|
||||
<td className="border px-3 py-2" colSpan={3}>
|
||||
<CadCount label="3D" count={Number(row.cu01_cnt ?? 0)} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">2D(Drawing)</th>
|
||||
<td className="border px-3 py-2" colSpan={3}>
|
||||
<CadCount label="2D(Drawing)" count={Number(row.cu02_cnt ?? 0)} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">2D(PDF)</th>
|
||||
<td className="border px-3 py-2" colSpan={3}>
|
||||
<CadCount label="2D(PDF)" count={Number(row.cu03_cnt ?? 0)} />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="mt-2 text-[11px] text-muted-foreground">
|
||||
CAD Data 첨부 다운로드/미리보기는 DEV-7 (도면업로드) 별 PR 에서 활성화됩니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3 sm:justify-center">
|
||||
{row && onEdit && (
|
||||
<Button onClick={() => onEdit(row.objid)}>
|
||||
<Pencil className="h-4 w-4" /><span className="ml-1">수정</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>닫기</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 보조 컴포넌트 ──────────────────────────────────────────
|
||||
|
||||
function Tr({ children }: { children: React.ReactNode }) {
|
||||
return <tr>{children}</tr>;
|
||||
}
|
||||
function Th({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left align-middle font-medium">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
function Td({ children, colSpan }: { children: React.ReactNode; colSpan?: number }) {
|
||||
return <td className="border px-3 py-1.5" colSpan={colSpan}>{children}</td>;
|
||||
}
|
||||
|
||||
// 운영판 disabled input 의 readonly 박스
|
||||
function Ro({ children, align }: { children: React.ReactNode; align?: "left" | "center" | "right" }) {
|
||||
const cls = align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left";
|
||||
const empty = children == null || children === "";
|
||||
return (
|
||||
<div className={cn(
|
||||
"min-h-9 rounded border bg-muted/30 px-2 py-1.5",
|
||||
cls,
|
||||
)}>
|
||||
{empty ? <span className="text-muted-foreground">—</span> : children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CadCount({ label, count }: { label: string; count: number }) {
|
||||
if (count > 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<FileText className="h-4 w-4 text-blue-600" />
|
||||
<span>{label} 첨부 {count.toLocaleString()}건</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="border-2 border-dashed border-muted-foreground/30 rounded text-muted-foreground py-3 text-center text-xs">
|
||||
첨부된 {label} 파일 없음
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
// 개발관리 PART Excel Import 다이얼로그
|
||||
// wace partMng/openPartExcelImportPopUp.jsp 1:1
|
||||
// - Template Download / Drag & Drop / 파일선택 → 백엔드 파싱 + 검증
|
||||
// - 검증 그리드 22컬럼 + NOTE (에러는 빨강) — wace expenseDetailGrid 1:1
|
||||
// - NOTE 있는 행이 하나라도 있으면 저장 차단 (wace fn_save 1:1)
|
||||
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, Upload, Save, Loader2, FileX } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { devPartApi, PartExcelRow } from "@/lib/api/devPart";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TEMPLATE_DOWNLOAD_URL = "/templates/PART_EXCEL_IMPORT_TEMPLATE.xlsx";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
interface Column {
|
||||
key: keyof PartExcelRow;
|
||||
label: string;
|
||||
width: string;
|
||||
align?: "left" | "center" | "right";
|
||||
showNameFor?: keyof PartExcelRow;
|
||||
}
|
||||
|
||||
const COLUMNS: Column[] = [
|
||||
{ key: "NOTE", label: "결과", width: "min-w-[200px]", align: "left" },
|
||||
{ key: "PART_NO", label: "품번", width: "min-w-[140px]", align: "center" },
|
||||
{ key: "PART_NAME", label: "품명", width: "min-w-[200px]", align: "left" },
|
||||
{ key: "MATERIAL", label: "재료", width: "min-w-[100px]" },
|
||||
{ key: "HEAT_TREATMENT_HARDNESS", label: "열처리경도", width: "min-w-[110px]" },
|
||||
{ key: "HEAT_TREATMENT_METHOD", label: "열처리방법", width: "min-w-[110px]" },
|
||||
{ key: "SURFACE_TREATMENT", label: "표면처리", width: "min-w-[100px]" },
|
||||
{ key: "MAKER", label: "메이커", width: "min-w-[110px]" },
|
||||
{ key: "PART_TYPE", label: "범주", width: "min-w-[90px]", align: "center", showNameFor: "PART_TYPE_NAME" },
|
||||
{ key: "SPEC", label: "규격", width: "min-w-[100px]" },
|
||||
{ key: "ACCTFG", label: "계정구분", width: "min-w-[90px]", align: "center", showNameFor: "ACCTFG_NAME" },
|
||||
{ key: "ODRFG", label: "조달구분", width: "min-w-[90px]", align: "center", showNameFor: "ODRFG_NAME" },
|
||||
{ key: "UNIT_DC", label: "재고단위", width: "min-w-[80px]", align: "center", showNameFor: "UNIT_DC_NAME" },
|
||||
{ key: "UNITMANG_DC", label: "관리단위", width: "min-w-[80px]", align: "center", showNameFor: "UNITMANG_DC_NAME" },
|
||||
{ key: "UNITCHNG_NB", label: "환산수량", width: "min-w-[80px]", align: "right" },
|
||||
{ key: "LOT_FG", label: "LOT구분", width: "min-w-[80px]", align: "center" },
|
||||
{ key: "USE_YN", label: "사용여부", width: "min-w-[80px]", align: "center" },
|
||||
{ key: "QC_FG", label: "검사여부", width: "min-w-[80px]", align: "center" },
|
||||
{ key: "SETITEM_FG", label: "SET품여부", width: "min-w-[90px]", align: "center" },
|
||||
{ key: "REQ_FG", label: "의뢰여부", width: "min-w-[80px]", align: "center" },
|
||||
{ key: "UNIT_LENGTH", label: "개당길이", width: "min-w-[80px]", align: "right" },
|
||||
{ key: "UNIT_QTY", label: "개당소요량", width: "min-w-[90px]", align: "right" },
|
||||
{ key: "REMARK", label: "비고", width: "min-w-[130px]", align: "left" },
|
||||
];
|
||||
|
||||
const LABEL_LOT = { "0": "미사용", "1": "사용" } as Record<string, string>;
|
||||
const LABEL_USE = { "0": "미사용", "1": "사용" } as Record<string, string>;
|
||||
const LABEL_QC = { "0": "무검사", "1": "검사" } as Record<string, string>;
|
||||
const LABEL_YN = { "0": "부", "1": "여" } as Record<string, string>;
|
||||
|
||||
function displayValue(r: PartExcelRow, col: Column): string {
|
||||
if (col.showNameFor) {
|
||||
const name = r[col.showNameFor];
|
||||
if (name) return String(name);
|
||||
return String(r[col.key] ?? "");
|
||||
}
|
||||
const v = String(r[col.key] ?? "");
|
||||
if (col.key === "LOT_FG") return LABEL_LOT[v] ?? v;
|
||||
if (col.key === "USE_YN") return LABEL_USE[v] ?? v;
|
||||
if (col.key === "QC_FG") return LABEL_QC[v] ?? v;
|
||||
if (col.key === "SETITEM_FG" || col.key === "REQ_FG") return LABEL_YN[v] ?? v;
|
||||
return v;
|
||||
}
|
||||
|
||||
export function PartExcelImportDialog({ open, onOpenChange, onSaved }: Props) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [parsedRows, setParsedRows] = useState<PartExcelRow[]>([]);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [fileName, setFileName] = useState<string>("");
|
||||
const [parsing, setParsing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setParsedRows([]);
|
||||
setHasError(false);
|
||||
setFileName("");
|
||||
}, []);
|
||||
|
||||
const handleDialogChange = (v: boolean) => {
|
||||
if (!v) reset();
|
||||
onOpenChange(v);
|
||||
};
|
||||
|
||||
const parseFile = useCallback(async (file: File) => {
|
||||
if (!/\.xlsx?$/i.test(file.name)) {
|
||||
toast.error("xlsx 또는 xls 파일만 업로드 가능합니다.");
|
||||
return;
|
||||
}
|
||||
setParsing(true);
|
||||
setFileName(file.name);
|
||||
try {
|
||||
const data = await devPartApi.excelParse(file);
|
||||
setParsedRows(data.rows ?? []);
|
||||
setHasError(!!data.hasError);
|
||||
if (!data.rows || data.rows.length === 0) {
|
||||
toast.warning("파싱된 데이터가 없습니다. 템플릿 형식을 확인해 주세요.");
|
||||
} else if (data.hasError) {
|
||||
toast.error("엑셀파일 로딩결과가 유효하지 않습니다. 결과메세지를 확인해 주세요.");
|
||||
} else {
|
||||
toast.success(`${data.rows.length}건 파싱 완료`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "엑셀 파싱 실패");
|
||||
reset();
|
||||
} finally {
|
||||
setParsing(false);
|
||||
}
|
||||
}, [reset]);
|
||||
|
||||
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) parseFile(f);
|
||||
e.target.value = "";
|
||||
};
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) parseFile(f);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (parsedRows.length === 0) { toast.error("저장할 데이터가 없습니다."); return; }
|
||||
if (hasError) {
|
||||
toast.error("엑셀파일 로딩결과가 유효하지 않습니다. 결과메세지를 확인해 주세요.");
|
||||
return;
|
||||
}
|
||||
if (!confirm("저장 하시겠습니까?")) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await devPartApi.excelSave(parsedRows);
|
||||
toast.success(`${res.inserted}건이 저장되었습니다.${res.skipped > 0 ? ` (중복 ${res.skipped}건 건너뜀)` : ""}`);
|
||||
onSaved();
|
||||
handleDialogChange(false);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const errorCount = useMemo(() => parsedRows.filter((r) => r.NOTE).length, [parsedRows]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogChange}>
|
||||
<DialogContent className="max-w-[1500px] w-[97vw] max-h-[92vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>PART 등록 Excel upload</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-b pb-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={TEMPLATE_DOWNLOAD_URL} download>
|
||||
<Download className="h-4 w-4" /><span className="ml-1">Template Download</span>
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()} disabled={parsing}>
|
||||
<Upload className="h-4 w-4" /><span className="ml-1">파일 선택</span>
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
className="hidden"
|
||||
onChange={handleFileInput}
|
||||
/>
|
||||
{fileName && (
|
||||
<span className="text-sm text-muted-foreground ml-2 truncate max-w-[300px]">
|
||||
{fileName}
|
||||
</span>
|
||||
)}
|
||||
{parsedRows.length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={reset}>
|
||||
<FileX className="h-4 w-4" /><span className="ml-1">초기화</span>
|
||||
</Button>
|
||||
)}
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
총 {parsedRows.length}건
|
||||
{errorCount > 0 && <span className="ml-2 text-destructive">에러 {errorCount}건</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drop Zone — 파싱 전에만 노출 */}
|
||||
{parsedRows.length === 0 && !parsing && (
|
||||
<div
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded p-10 text-center transition-colors cursor-pointer",
|
||||
dragOver ? "border-primary bg-primary/5" : "border-muted-foreground/30"
|
||||
)}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-10 w-10 mx-auto text-muted-foreground mb-2" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Drag & Drop 또는 클릭하여 엑셀 템플릿 업로드 (.xlsx, .xls)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsing && (
|
||||
<div className="flex items-center justify-center py-10 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" /><span className="ml-2">파싱 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 결과 그리드 */}
|
||||
{parsedRows.length > 0 && !parsing && (
|
||||
<div className="flex-1 min-h-0 overflow-auto border rounded">
|
||||
<table className="text-xs border-collapse w-max min-w-full">
|
||||
<thead className="bg-muted sticky top-0">
|
||||
<tr>
|
||||
<th className="border px-2 py-1 w-[40px] text-center">#</th>
|
||||
{COLUMNS.map((c) => (
|
||||
<th key={c.key as string} className={cn("border px-2 py-1", c.width)}>
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parsedRows.map((r, i) => (
|
||||
<tr key={i} className={r.NOTE ? "bg-destructive/5" : ""}>
|
||||
<td className="border px-2 py-1 text-center">{i + 1}</td>
|
||||
{COLUMNS.map((c) => {
|
||||
const value = displayValue(r, c);
|
||||
const isNote = c.key === "NOTE";
|
||||
return (
|
||||
<td
|
||||
key={c.key as string}
|
||||
className={cn(
|
||||
"border px-2 py-1 whitespace-nowrap",
|
||||
c.align === "right" && "text-right",
|
||||
c.align === "center" && "text-center",
|
||||
isNote && r.NOTE && "text-destructive font-semibold"
|
||||
)}
|
||||
title={value}
|
||||
>
|
||||
{value}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => handleDialogChange(false)}>닫기</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || parsedRows.length === 0 || hasError}
|
||||
>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
<span className="ml-1">저장</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
"use client";
|
||||
|
||||
// 개발관리 > PART 등록/수정 다이얼로그 — wace partMng/partMngFormPopUp.jsp 1:1
|
||||
//
|
||||
// 폼 필드 (운영판 그대로, 그 외 추가 없음):
|
||||
// ① 품번 | 품명
|
||||
// ② 재료 | 열처리경도
|
||||
// ③ 열처리방법 | 표면처리
|
||||
// ④ 메이커 | 범주이름* (PART_TYPE, comm_code 0000062)
|
||||
// ⑤ 규격 (1행)
|
||||
// ⑥ 계정구분* (ACCTFG, comm_code 0900213) | 조달구분* (ODRFG, 0/1/8 하드)
|
||||
// ⑦ 재고단위* (UNIT_DC, comm_code 0001399) | 관리단위* (UNITMANG_DC, 동일)
|
||||
// ⑧ 환산수량* (UNITCHNG_NB, 숫자) | LOT구분* (0=미사용, 1=사용)
|
||||
// ⑨ 사용여부* (0=미사용, 1=사용) | 검사여부* (0=무검사, 1=검사)
|
||||
// ⑩ SET품여부* (0=부, 1=여) | 의뢰여부* (0=부, 1=여)
|
||||
// ⑪ 개당길이 | 개당소요량
|
||||
// ⑫ 비고 (1행)
|
||||
// ⑬ CAD Data: 3D / 2D(Drawing) / 2D(PDF) Drag&Drop — 별 PR(DEV-7) 도면업로드. 본 PR은 UI placeholder
|
||||
//
|
||||
// 신규: POST /api/development/part (운영 폼 22컬럼)
|
||||
// 수정: PUT /api/development/part/:objid (wace updatePartDetail 21컬럼 1:1)
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2, Save, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
||||
import { devPartApi, PartCreateBody, PartUpdateBody, PartRow } from "@/lib/api/devPart";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// comm_code group ids (vexplor_rps DB)
|
||||
const GROUP_PART_TYPE = "0000062";
|
||||
const GROUP_UNIT_DC = "0001399";
|
||||
const GROUP_ACCTFG = "0900213";
|
||||
|
||||
// 운영 1:1 하드코딩 옵션
|
||||
const OPT_ODRFG = [{ v: "0", t: "구매" }, { v: "1", t: "생산" }, { v: "8", t: "Phantom" }];
|
||||
const OPT_LOT_FG = [{ v: "0", t: "미사용" }, { v: "1", t: "사용" }];
|
||||
const OPT_USE_YN = [{ v: "0", t: "미사용" }, { v: "1", t: "사용" }];
|
||||
const OPT_QC_FG = [{ v: "0", t: "무검사" }, { v: "1", t: "검사" }];
|
||||
const OPT_YESNO = [{ v: "0", t: "부" }, { v: "1", t: "여" }];
|
||||
|
||||
interface FormState {
|
||||
part_no: string;
|
||||
part_name: string;
|
||||
material: string;
|
||||
heat_treatment_hardness: string;
|
||||
heat_treatment_method: string;
|
||||
surface_treatment: string;
|
||||
maker: string;
|
||||
part_type: string;
|
||||
spec: string;
|
||||
acctfg: string;
|
||||
odrfg: string;
|
||||
unit_dc: string;
|
||||
unitmang_dc: string;
|
||||
unitchng_nb: string;
|
||||
lot_fg: string;
|
||||
use_yn: string;
|
||||
qc_fg: string;
|
||||
setitem_fg: string;
|
||||
req_fg: string;
|
||||
unit_length: string;
|
||||
unit_qty: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
part_no: "", part_name: "",
|
||||
material: "", heat_treatment_hardness: "", heat_treatment_method: "", surface_treatment: "",
|
||||
maker: "", part_type: "", spec: "",
|
||||
acctfg: "", odrfg: "", unit_dc: "", unitmang_dc: "", unitchng_nb: "",
|
||||
lot_fg: "", use_yn: "", qc_fg: "", setitem_fg: "", req_fg: "",
|
||||
unit_length: "", unit_qty: "", remark: "",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: "create" | "edit";
|
||||
editObjid?: string | null;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }: Props) {
|
||||
const isEdit = mode === "edit";
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const setField = useCallback(
|
||||
<K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value })),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (isEdit && editObjid) loadDetail(editObjid);
|
||||
else setForm(EMPTY_FORM);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const loadDetail = async (objid: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const row = await devPartApi.detail(objid);
|
||||
if (!row) {
|
||||
toast.error("PART를 찾을 수 없습니다.");
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
setForm(rowToForm(row));
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// wace fn_save 1:1 — 모든 required 검증
|
||||
const validate = (): string | null => {
|
||||
if (!form.part_no.trim()) return "품번은 필수입니다.";
|
||||
if (!form.part_name.trim()) return "품명은 필수입니다.";
|
||||
if (!form.part_type) return "범주이름은 필수입니다.";
|
||||
if (!form.acctfg) return "계정구분은 필수입니다.";
|
||||
if (!form.odrfg) return "조달구분은 필수입니다.";
|
||||
if (!form.unit_dc) return "재고단위는 필수입니다.";
|
||||
if (!form.unitmang_dc) return "관리단위는 필수입니다.";
|
||||
if (!form.unitchng_nb) return "환산수량은 필수입니다.";
|
||||
if (!form.lot_fg) return "LOT구분은 필수입니다.";
|
||||
if (!form.use_yn) return "사용여부는 필수입니다.";
|
||||
if (!form.qc_fg) return "검사여부는 필수입니다.";
|
||||
if (!form.setitem_fg) return "SET품여부는 필수입니다.";
|
||||
if (!form.req_fg) return "의뢰여부는 필수입니다.";
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const err = validate();
|
||||
if (err) return toast.error(err);
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit && editObjid) {
|
||||
const body: PartUpdateBody = {
|
||||
part_name: form.part_name,
|
||||
material: form.material,
|
||||
heat_treatment_hardness: form.heat_treatment_hardness,
|
||||
heat_treatment_method: form.heat_treatment_method,
|
||||
surface_treatment: form.surface_treatment,
|
||||
maker: form.maker,
|
||||
part_type: form.part_type,
|
||||
acctfg: form.acctfg,
|
||||
odrfg: form.odrfg,
|
||||
spec: form.spec,
|
||||
unit_dc: form.unit_dc,
|
||||
unitmang_dc: form.unitmang_dc,
|
||||
unitchng_nb: form.unitchng_nb,
|
||||
lot_fg: form.lot_fg,
|
||||
use_yn: form.use_yn,
|
||||
qc_fg: form.qc_fg,
|
||||
setitem_fg: form.setitem_fg,
|
||||
req_fg: form.req_fg,
|
||||
unit_length: form.unit_length,
|
||||
unit_qty: form.unit_qty,
|
||||
remark: form.remark,
|
||||
};
|
||||
await devPartApi.update(editObjid, body);
|
||||
toast.success("PART가 수정되었습니다.");
|
||||
} else {
|
||||
const body: PartCreateBody = {
|
||||
part_no: form.part_no,
|
||||
part_name: form.part_name,
|
||||
part_type: form.part_type,
|
||||
material: form.material,
|
||||
heat_treatment_hardness: form.heat_treatment_hardness,
|
||||
heat_treatment_method: form.heat_treatment_method,
|
||||
surface_treatment: form.surface_treatment,
|
||||
maker: form.maker,
|
||||
spec: form.spec,
|
||||
acctfg: form.acctfg,
|
||||
odrfg: form.odrfg,
|
||||
unit_dc: form.unit_dc,
|
||||
unitmang_dc: form.unitmang_dc,
|
||||
unitchng_nb: form.unitchng_nb,
|
||||
lot_fg: form.lot_fg,
|
||||
use_yn: form.use_yn,
|
||||
qc_fg: form.qc_fg,
|
||||
setitem_fg: form.setitem_fg,
|
||||
req_fg: form.req_fg,
|
||||
unit_length: form.unit_length,
|
||||
unit_qty: form.unit_qty,
|
||||
remark: form.remark,
|
||||
};
|
||||
await devPartApi.create(body);
|
||||
toast.success("PART가 등록되었습니다.");
|
||||
}
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const titleText = isEdit ? "품목 수정" : "품목 등록";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[92vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="bg-blue-600 px-4 py-3">
|
||||
<DialogTitle className="text-white">{titleText}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{/* 운영판 colgroup 1:1 (12% / 12% / 25% / 12% / *)
|
||||
각 행 첫 input 은 colSpan=2 로 양쪽 input 비율 맞춤 */}
|
||||
<table className="w-full border-collapse text-sm table-fixed">
|
||||
<colgroup>
|
||||
<col style={{ width: "12%" }} />
|
||||
<col style={{ width: "12%" }} />
|
||||
<col style={{ width: "25%" }} />
|
||||
<col style={{ width: "12%" }} />
|
||||
<col />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{/* ① */}
|
||||
<Tr>
|
||||
<Th>품번</Th>
|
||||
<Td colSpan={2}><Input value={form.part_no} disabled={isEdit}
|
||||
onChange={(e) => setField("part_no", e.target.value)} /></Td>
|
||||
<Th>품명</Th>
|
||||
<Td><Input value={form.part_name}
|
||||
onChange={(e) => setField("part_name", e.target.value)} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ② */}
|
||||
<Tr>
|
||||
<Th>재료</Th>
|
||||
<Td colSpan={2}><Input value={form.material}
|
||||
onChange={(e) => setField("material", e.target.value)} /></Td>
|
||||
<Th>열처리경도</Th>
|
||||
<Td><Input value={form.heat_treatment_hardness}
|
||||
onChange={(e) => setField("heat_treatment_hardness", e.target.value)} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ③ */}
|
||||
<Tr>
|
||||
<Th>열처리방법</Th>
|
||||
<Td colSpan={2}><Input value={form.heat_treatment_method}
|
||||
onChange={(e) => setField("heat_treatment_method", e.target.value)} /></Td>
|
||||
<Th>표면처리</Th>
|
||||
<Td><Input value={form.surface_treatment}
|
||||
onChange={(e) => setField("surface_treatment", e.target.value)} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ④ */}
|
||||
<Tr>
|
||||
<Th>메이커</Th>
|
||||
<Td colSpan={2}><Input value={form.maker}
|
||||
onChange={(e) => setField("maker", e.target.value)} /></Td>
|
||||
<Th>범주이름</Th>
|
||||
<Td>
|
||||
<CommCodeSelect groupId={GROUP_PART_TYPE} withAll={false}
|
||||
value={form.part_type}
|
||||
onValueChange={(v) => setField("part_type", v)} />
|
||||
</Td>
|
||||
</Tr>
|
||||
|
||||
{/* ⑤ 규격 (colspan=4 — 운영판 colspan=4 1:1) */}
|
||||
<Tr>
|
||||
<Th>규격</Th>
|
||||
<Td colSpan={4}><Input value={form.spec} maxLength={100}
|
||||
onChange={(e) => setField("spec", e.target.value)} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ⑥ */}
|
||||
<Tr>
|
||||
<Th>계정구분<Req /></Th>
|
||||
<Td colSpan={2}>
|
||||
<CommCodeSelect groupId={GROUP_ACCTFG} withAll={false}
|
||||
value={form.acctfg}
|
||||
onValueChange={(v) => setField("acctfg", v)} />
|
||||
</Td>
|
||||
<Th>조달구분<Req /></Th>
|
||||
<Td><BasicSelect value={form.odrfg} options={OPT_ODRFG}
|
||||
onChange={(v) => setField("odrfg", v)} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ⑦ */}
|
||||
<Tr>
|
||||
<Th>재고단위<Req /></Th>
|
||||
<Td colSpan={2}>
|
||||
<CommCodeSelect groupId={GROUP_UNIT_DC} withAll={false}
|
||||
value={form.unit_dc}
|
||||
onValueChange={(v) => setField("unit_dc", v)} />
|
||||
</Td>
|
||||
<Th>관리단위<Req /></Th>
|
||||
<Td>
|
||||
<CommCodeSelect groupId={GROUP_UNIT_DC} withAll={false}
|
||||
value={form.unitmang_dc}
|
||||
onValueChange={(v) => setField("unitmang_dc", v)} />
|
||||
</Td>
|
||||
</Tr>
|
||||
|
||||
{/* ⑧ */}
|
||||
<Tr>
|
||||
<Th>환산수량<Req /></Th>
|
||||
<Td colSpan={2}><Input value={form.unitchng_nb} className="text-right"
|
||||
inputMode="decimal"
|
||||
onChange={(e) => setField("unitchng_nb", e.target.value.replace(/[^0-9.]/g, ""))} /></Td>
|
||||
<Th>LOT구분<Req /></Th>
|
||||
<Td><BasicSelect value={form.lot_fg} options={OPT_LOT_FG}
|
||||
onChange={(v) => setField("lot_fg", v)} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ⑨ */}
|
||||
<Tr>
|
||||
<Th>사용여부<Req /></Th>
|
||||
<Td colSpan={2}><BasicSelect value={form.use_yn} options={OPT_USE_YN}
|
||||
onChange={(v) => setField("use_yn", v)} /></Td>
|
||||
<Th>검사여부<Req /></Th>
|
||||
<Td><BasicSelect value={form.qc_fg} options={OPT_QC_FG}
|
||||
onChange={(v) => setField("qc_fg", v)} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ⑩ */}
|
||||
<Tr>
|
||||
<Th>SET품여부<Req /></Th>
|
||||
<Td colSpan={2}><BasicSelect value={form.setitem_fg} options={OPT_YESNO}
|
||||
onChange={(v) => setField("setitem_fg", v)} /></Td>
|
||||
<Th>의뢰여부<Req /></Th>
|
||||
<Td><BasicSelect value={form.req_fg} options={OPT_YESNO}
|
||||
onChange={(v) => setField("req_fg", v)} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ⑪ */}
|
||||
<Tr>
|
||||
<Th>개당길이</Th>
|
||||
<Td colSpan={2}><Input value={form.unit_length} className="text-right"
|
||||
inputMode="decimal" maxLength={20}
|
||||
onChange={(e) => setField("unit_length", e.target.value.replace(/[^0-9.]/g, ""))} /></Td>
|
||||
<Th>개당소요량</Th>
|
||||
<Td><Input value={form.unit_qty} className="text-right"
|
||||
inputMode="decimal" maxLength={20}
|
||||
onChange={(e) => setField("unit_qty", e.target.value.replace(/[^0-9.]/g, ""))} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ⑫ 비고 (colspan=4) */}
|
||||
<Tr>
|
||||
<Th>비고</Th>
|
||||
<Td colSpan={4}><Input value={form.remark}
|
||||
onChange={(e) => setField("remark", e.target.value)} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ⑬ CAD Data — placeholder (DEV-7 도면업로드 별 PR) */}
|
||||
<tr>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left align-top font-medium" rowSpan={3}>
|
||||
CAD Data
|
||||
</th>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">3D</th>
|
||||
<td className="border px-3 py-2" colSpan={3}>
|
||||
<DropPlaceholder label="3D" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">2D(Drawing)</th>
|
||||
<td className="border px-3 py-2" colSpan={3}>
|
||||
<DropPlaceholder label="2D(Drawing)" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">2D(PDF)</th>
|
||||
<td className="border px-3 py-2" colSpan={3}>
|
||||
<DropPlaceholder label="2D(PDF)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="mt-2 text-[11px] text-muted-foreground">
|
||||
CAD Data 첨부는 DEV-7 (도면업로드) 별 PR 에서 활성화됩니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3 sm:justify-center">
|
||||
<Button onClick={handleSave} disabled={saving || loading}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
<span className="ml-1">저장</span>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 보조 컴포넌트 ──────────────────────────────────────────
|
||||
|
||||
function Tr({ children }: { children: React.ReactNode }) {
|
||||
return <tr>{children}</tr>;
|
||||
}
|
||||
function Th({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left align-middle font-medium">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
function Td({ children, colSpan }: { children: React.ReactNode; colSpan?: number }) {
|
||||
return <td className="border px-3 py-1.5" colSpan={colSpan}>{children}</td>;
|
||||
}
|
||||
function Req() {
|
||||
return <span className="ml-1 text-red-500">*</span>;
|
||||
}
|
||||
function BasicSelect({
|
||||
value, options, onChange,
|
||||
}: {
|
||||
value: string;
|
||||
options: { v: string; t: string }[];
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
className={cn("h-9 w-full rounded-md border bg-background px-2 text-sm")}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{options.map((o) => <option key={o.v} value={o.v}>{o.t}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
function DropPlaceholder({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="border-2 border-dashed border-muted-foreground/30 rounded text-muted-foreground py-6 text-center text-xs">
|
||||
<Upload className="h-5 w-5 mx-auto mb-1" />
|
||||
Drag & Drop Files Here ({label})
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── PartRow → FormState ────────────────────────────────────
|
||||
|
||||
function rowToForm(r: PartRow): FormState {
|
||||
return {
|
||||
part_no: r.part_no ?? "",
|
||||
part_name: r.part_name ?? "",
|
||||
material: r.material ?? "",
|
||||
heat_treatment_hardness: r.heat_treatment_hardness ?? "",
|
||||
heat_treatment_method: r.heat_treatment_method ?? "",
|
||||
surface_treatment: r.surface_treatment ?? "",
|
||||
maker: r.maker ?? "",
|
||||
part_type: r.part_type ?? "",
|
||||
spec: r.spec ?? "",
|
||||
acctfg: r.acctfg ?? "",
|
||||
odrfg: r.odrfg ?? "",
|
||||
unit_dc: r.unit_dc ?? "",
|
||||
unitmang_dc: r.unitmang_dc ?? "",
|
||||
unitchng_nb: r.unitchng_nb != null ? String(r.unitchng_nb) : "",
|
||||
lot_fg: r.lot_fg ?? "",
|
||||
use_yn: r.use_yn ?? "",
|
||||
qc_fg: r.qc_fg ?? "",
|
||||
setitem_fg: r.setitem_fg ?? "",
|
||||
req_fg: r.req_fg ?? "",
|
||||
unit_length: r.unit_length ?? "",
|
||||
unit_qty: r.unit_qty ?? "",
|
||||
remark: r.remark ?? "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
// 개발관리 > 설계변경 리스트 상세 다이얼로그 (read-only).
|
||||
// wace partMngHisDetailPopUp.jsp 1:1 — 모든 필드 disabled.
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { devEoHistoryApi, EoHistoryDetail } from "@/lib/api/devEoHistory";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
objid: string | null;
|
||||
}
|
||||
|
||||
export function PartHisDetailDialog({ open, onOpenChange, objid }: Props) {
|
||||
const [row, setRow] = useState<EoHistoryDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !objid) return;
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
devEoHistoryApi.detail(objid)
|
||||
.then((data) => { if (alive) setRow(data); })
|
||||
.catch((e: any) => {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
||||
onOpenChange(false);
|
||||
})
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [open, objid, onOpenChange]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>설계변경 상세 정보 (PART_MNG_HISTORY)</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading || !row ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[70vh] space-y-4 overflow-y-auto px-1 py-2 text-sm">
|
||||
<Section title="EO 정보">
|
||||
<Row>
|
||||
<V label="EO_NO" value={row.eo_no} />
|
||||
<V label="EO_DATE" value={row.eo_date} align="center" />
|
||||
<V label="실행일" value={row.his_reg_date_title} align="center" />
|
||||
</Row>
|
||||
<Row>
|
||||
<V label="EO구분" value={row.change_type_name} align="center" />
|
||||
<V label="EO사유" value={row.change_option_name} align="center" />
|
||||
<V label="담당자" value={row.writer_name} align="center" />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section title="프로젝트 / 유닛">
|
||||
<Row>
|
||||
<V label="프로젝트번호" value={row.project_no} />
|
||||
<V label="프로젝트명" value={row.project_name} />
|
||||
<V label="유닛명" value={row.unit_name} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section title="PART 정보">
|
||||
<Row>
|
||||
<V label="모품번" value={row.parent_part_info} />
|
||||
<V label="품번" value={row.part_no_disp ?? row.part_no} />
|
||||
<V label="품명" value={row.part_name_disp ?? row.part_name} />
|
||||
</Row>
|
||||
<Row>
|
||||
<V label="범주" value={row.part_type_name} align="center" />
|
||||
<V label="Revision" value={row.revision_disp ?? row.revision} align="center" />
|
||||
<V label="규격" value={row.spec} />
|
||||
</Row>
|
||||
<Row>
|
||||
<V label="재료" value={row.material} />
|
||||
<V label="중량" value={row.weight} align="right" />
|
||||
<V label="메이커" value={row.maker} />
|
||||
</Row>
|
||||
<Row>
|
||||
<V label="열처리경도" value={row.heat_treatment_hardness} />
|
||||
<V label="열처리방법" value={row.heat_treatment_method} />
|
||||
<V label="표면처리" value={row.surface_treatment} />
|
||||
</Row>
|
||||
<Row>
|
||||
<V label="비고" value={row.remark} />
|
||||
<V label="" value="" />
|
||||
<V label="" value="" />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section title="수량 / BOM 상태">
|
||||
<Row>
|
||||
<V label="수량" value={row.qty} align="right" />
|
||||
<V label="변경수량" value={row.qty_temp} align="right" />
|
||||
<V label="BOM 상태" value={row.bom_qty_status} align="center" />
|
||||
</Row>
|
||||
<Row>
|
||||
<V label="BOM 배포일" value={row.bom_deploy_date_title} align="center" />
|
||||
<V label="이력 상태" value={row.his_status} align="center" />
|
||||
<V label="" value="" />
|
||||
</Row>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>닫기</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-md border bg-card p-3">
|
||||
<div className="mb-2 text-sm font-semibold text-foreground">{title}</div>
|
||||
<div className="space-y-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid grid-cols-3 gap-3">{children}</div>;
|
||||
}
|
||||
|
||||
function V({ label, value, align }: { label: string; value: any; align?: "left" | "center" | "right" }) {
|
||||
const cls = align === "right" ? "text-right" : align === "center" ? "text-center" : "";
|
||||
return (
|
||||
<div>
|
||||
{label && <Label className="mb-1 block text-xs text-muted-foreground">{label}</Label>}
|
||||
<div className={`min-h-9 rounded-md border bg-muted/30 px-2 py-2 ${cls}`}>
|
||||
{value != null && value !== "" ? value : <span className="text-muted-foreground">—</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -107,6 +107,11 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_16/sales/quote": dynamic(() => import("@/app/(main)/COMPANY_16/sales/quote/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/project/progress": dynamic(() => import("@/app/(main)/COMPANY_16/project/progress/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/project/wbs-template": dynamic(() => import("@/app/(main)/COMPANY_16/project/wbs-template/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/development/part-regist": dynamic(() => import("@/app/(main)/COMPANY_16/development/part-regist/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/development/part-search": dynamic(() => import("@/app/(main)/COMPANY_16/development/part-search/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/development/ebom-regist": dynamic(() => import("@/app/(main)/COMPANY_16/development/ebom-regist/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/development/ebom-search": dynamic(() => import("@/app/(main)/COMPANY_16/development/ebom-search/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/development/change-list": dynamic(() => import("@/app/(main)/COMPANY_16/development/change-list/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_16/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/production/result": dynamic(() => import("@/app/(main)/COMPANY_16/production/result/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_16/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
// Content-Disposition 의 filename / filename* 파싱 (UTF-8 인코딩 우선)
|
||||
function extractFileName(cd: string | undefined): string | null {
|
||||
if (!cd) return null;
|
||||
const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(cd);
|
||||
if (utf8 && utf8[1]) {
|
||||
try { return decodeURIComponent(utf8[1]); } catch { /* fallthrough */ }
|
||||
}
|
||||
const plain = /filename="?([^";]+)"?/i.exec(cd);
|
||||
if (plain && plain[1]) {
|
||||
try { return decodeURIComponent(plain[1]); } catch { return plain[1]; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 개발관리 E-BOM (M3 등록 / M4 조회) — wace partMng.xml 1:1
|
||||
// 라우트: /api/development/ebom/*, /api/development/ebom-tree/*
|
||||
// ============================================================
|
||||
|
||||
export interface BomReportListFilter {
|
||||
customer_cd?: string;
|
||||
project_name?: string;
|
||||
unit_code?: string;
|
||||
search_unit_name?: string;
|
||||
search_writer?: string;
|
||||
product_cd?: string;
|
||||
search_part_no?: string;
|
||||
search_part_name?: string;
|
||||
search_from_date?: string;
|
||||
search_to_date?: string;
|
||||
status?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export interface BomReportRow {
|
||||
num: number | string;
|
||||
objid: string;
|
||||
customer_objid: string | null;
|
||||
customer_name: string | null;
|
||||
contract_objid: string | null;
|
||||
customer_project_name: string | null;
|
||||
project_no: string | null;
|
||||
unit_code: string | null;
|
||||
unit_name: string | null;
|
||||
status: string | null;
|
||||
status_title: string | null;
|
||||
writer: string | null;
|
||||
dept_name: string | null;
|
||||
user_name: string | null;
|
||||
dept_user_name: string | null;
|
||||
regdate: string | null;
|
||||
reg_date: string | null;
|
||||
deploy_date: string | null;
|
||||
revision: string | null;
|
||||
eo_no: string | null;
|
||||
eo_date: string | null;
|
||||
note: string | null;
|
||||
multi_yn: string | null;
|
||||
multi_master_yn: string | null;
|
||||
multi_break_yn: string | null;
|
||||
multi_master_objid: string | null;
|
||||
bom_cnt: number | string | null;
|
||||
product_cd: string | null;
|
||||
product_name: string | null;
|
||||
part_no: string | null;
|
||||
part_name: string | null;
|
||||
}
|
||||
|
||||
export interface BomReportListResponse {
|
||||
rows: BomReportRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface BomReportStatusBody {
|
||||
product_cd?: string;
|
||||
part_no?: string;
|
||||
part_name?: string;
|
||||
version?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface BomTreeFilter {
|
||||
bom_report_objid?: string;
|
||||
project_name?: string;
|
||||
unit_code?: string;
|
||||
search_part_no?: string;
|
||||
search_part_name?: string;
|
||||
}
|
||||
|
||||
export interface BomTreeRow {
|
||||
bom_report_objid: string | null;
|
||||
objid: string;
|
||||
parent_objid: string | null;
|
||||
child_objid: string | null;
|
||||
part_no: string | null; // bom_part_qty.part_no (= part_mng.objid)
|
||||
qty: string | null;
|
||||
seq: number | string | null;
|
||||
status: string | null;
|
||||
lev: number;
|
||||
path: string[] | null;
|
||||
// part_mng JOIN
|
||||
pm_part_no: string | null;
|
||||
pm_part_name: string | null;
|
||||
spec: string | null;
|
||||
material: string | null;
|
||||
weight: string | null;
|
||||
remark: string | null;
|
||||
edit_date: string | null;
|
||||
eo_no: string | null;
|
||||
revision: string | null;
|
||||
cu01_cnt: number | string | null;
|
||||
cu02_cnt: number | string | null;
|
||||
cu03_cnt: number | string | null;
|
||||
max_level: number | string | null;
|
||||
}
|
||||
|
||||
export interface BomTreeResponse {
|
||||
rows: BomTreeRow[];
|
||||
max_level: number;
|
||||
}
|
||||
|
||||
// 트리 풀 컬럼 (ascendingForExcel 1:1) — BomReportTreeDialog 용
|
||||
export interface BomTreeFullRow {
|
||||
lev: number | string;
|
||||
pm_part_no: string | null;
|
||||
pm_part_name: string | null;
|
||||
qty: string | number | null;
|
||||
p_qty: string | number | null;
|
||||
material: string | null;
|
||||
remark: string | null;
|
||||
heat_treatment_hardness: string | null;
|
||||
heat_treatment_method: string | null;
|
||||
surface_treatment: string | null;
|
||||
maker: string | null;
|
||||
part_type: string | null;
|
||||
part_type_title: string | null;
|
||||
cu01_cnt: number | string | null;
|
||||
cu02_cnt: number | string | null;
|
||||
cu03_cnt: number | string | null;
|
||||
}
|
||||
export interface BomTreeFullResponse {
|
||||
rows: BomTreeFullRow[];
|
||||
max_level: number;
|
||||
}
|
||||
|
||||
// ─── API ─────────────────────────────────────────────────
|
||||
|
||||
export const devBomApi = {
|
||||
// M3 그리드
|
||||
async list(filter: BomReportListFilter = {}): Promise<BomReportListResponse> {
|
||||
const res = await apiClient.get("/development/ebom/list", { params: filter });
|
||||
return res.data?.data as BomReportListResponse;
|
||||
},
|
||||
|
||||
async detail(objid: string): Promise<BomReportRow | null> {
|
||||
const res = await apiClient.get(`/development/ebom/${objid}`);
|
||||
return res.data?.data ?? null;
|
||||
},
|
||||
|
||||
async updateStatus(objid: string, body: BomReportStatusBody) {
|
||||
return (await apiClient.put(`/development/ebom/${objid}/status`, body)).data;
|
||||
},
|
||||
|
||||
async remove(objids: string[]) {
|
||||
const res = await apiClient.delete("/development/ebom", { data: { objids } });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// M4
|
||||
async ascending(filter: BomTreeFilter): Promise<BomTreeResponse> {
|
||||
const res = await apiClient.get("/development/ebom-tree/ascending", { params: filter });
|
||||
return res.data?.data as BomTreeResponse;
|
||||
},
|
||||
|
||||
async descending(filter: BomTreeFilter): Promise<BomTreeResponse> {
|
||||
const res = await apiClient.get("/development/ebom-tree/descending", { params: filter });
|
||||
return res.data?.data as BomTreeResponse;
|
||||
},
|
||||
|
||||
// E-BOM 트리 (풀 컬럼) — M3 그리드 행 클릭 → BomReportTreeDialog
|
||||
async treeFull(filter: BomTreeFilter): Promise<BomTreeFullResponse> {
|
||||
const res = await apiClient.get("/development/ebom-tree/full", { params: filter });
|
||||
return res.data?.data as BomTreeFullResponse;
|
||||
},
|
||||
|
||||
// M4 엑셀 다운로드 (정/역전개) — wace 1:1
|
||||
async excelAscending(filter: BomTreeFilter): Promise<{ blob: Blob; fileName: string }> {
|
||||
const res = await apiClient.get("/development/ebom-tree/ascending/excel", {
|
||||
params: filter, responseType: "blob",
|
||||
});
|
||||
return { blob: res.data as Blob, fileName: extractFileName(res.headers?.["content-disposition"]) ?? "BOM_ascending.xlsx" };
|
||||
},
|
||||
async excelDescending(filter: BomTreeFilter): Promise<{ blob: Blob; fileName: string }> {
|
||||
const res = await apiClient.get("/development/ebom-tree/descending/excel", {
|
||||
params: filter, responseType: "blob",
|
||||
});
|
||||
return { blob: res.data as Blob, fileName: extractFileName(res.headers?.["content-disposition"]) ?? "BOM_descending.xlsx" };
|
||||
},
|
||||
|
||||
// Excel Import
|
||||
async excelParse(file: File): Promise<BomExcelParseResponse> {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await apiClient.post("/development/ebom/excel-parse", fd, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return res.data?.data as BomExcelParseResponse;
|
||||
},
|
||||
|
||||
async excelCheckDuplicate(partNo: string, exclude?: string): Promise<boolean> {
|
||||
const res = await apiClient.get("/development/ebom/excel-check-duplicate", {
|
||||
params: { partNo, exclude },
|
||||
});
|
||||
return !!res.data?.data?.isDuplicate;
|
||||
},
|
||||
|
||||
async excelCopySource(productCd?: string): Promise<BomCopySourceRow[]> {
|
||||
const res = await apiClient.get("/development/ebom/excel-copy-source", {
|
||||
params: productCd ? { productCd } : undefined,
|
||||
});
|
||||
return (res.data?.data as BomCopySourceRow[]) ?? [];
|
||||
},
|
||||
|
||||
async excelCopy(objid: string): Promise<BomCsvRow[]> {
|
||||
const res = await apiClient.get(`/development/ebom/excel-copy/${objid}`);
|
||||
return ((res.data?.data?.rows as BomCsvRow[]) ?? []);
|
||||
},
|
||||
|
||||
async excelSave(input: BomExcelSaveInput): Promise<BomExcelSaveResult> {
|
||||
const res = await apiClient.post("/development/ebom/excel-save", input);
|
||||
return res.data?.data as BomExcelSaveResult;
|
||||
},
|
||||
};
|
||||
|
||||
// ─── CSV Import 타입 (wace parsingCsvFile 1:1) ─────────────
|
||||
|
||||
export interface BomCsvRow {
|
||||
NOTE: string;
|
||||
LEVEL: string;
|
||||
PARENT_PART_NO: string;
|
||||
PART_NO: string;
|
||||
PART_NAME: string;
|
||||
QTY: string;
|
||||
ITEM_QTY: string;
|
||||
MATERIAL: string;
|
||||
HEAT_TREATMENT_HARDNESS: string;
|
||||
HEAT_TREATMENT_METHOD: string;
|
||||
SURFACE_TREATMENT: string;
|
||||
MAKER: string;
|
||||
PART_TYPE: string;
|
||||
PART_TYPE_NAME: string;
|
||||
ACCTFG: string;
|
||||
ODRFG: string;
|
||||
UNIT_DC: string;
|
||||
UNITMANG_DC: string;
|
||||
UNITCHNG_NB: string;
|
||||
LOT_FG: string;
|
||||
USE_YN: string;
|
||||
QC_FG: string;
|
||||
SETITEM_FG: string;
|
||||
REQ_FG: string;
|
||||
}
|
||||
|
||||
// 기존 코드 호환용 별칭 (필요 시 마이그레이션)
|
||||
export type BomExcelRow = BomCsvRow;
|
||||
|
||||
export interface BomExcelParseResponse {
|
||||
rows: BomCsvRow[];
|
||||
hasError: boolean;
|
||||
firstLevel: { part_no: string; part_name: string } | null;
|
||||
encoding: string;
|
||||
}
|
||||
|
||||
export interface BomCopySourceRow {
|
||||
objid: string;
|
||||
part_no: string;
|
||||
part_name: string;
|
||||
revision: string | null;
|
||||
product_cd: string | null;
|
||||
regdate: string | null;
|
||||
}
|
||||
|
||||
export interface BomExcelSaveInput {
|
||||
bomReportObjid?: string;
|
||||
productCd: string;
|
||||
partNo: string;
|
||||
partName: string;
|
||||
version?: string;
|
||||
rows: BomCsvRow[];
|
||||
}
|
||||
|
||||
export interface BomExcelSaveResult {
|
||||
bomReportObjid: string;
|
||||
insertedParts: number;
|
||||
updatedParts: number;
|
||||
bomRows: number;
|
||||
mode: "create" | "update";
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
// ============================================================
|
||||
// 개발관리 설계변경 리스트 (M5, read-only) — wace partMngHistList 1:1
|
||||
// 라우트: /api/development/eo-history/*
|
||||
// ============================================================
|
||||
|
||||
export interface EoHistoryListFilter {
|
||||
Year?: string;
|
||||
contract_objid?: string;
|
||||
unit_code?: string;
|
||||
part_no?: string;
|
||||
part_name?: string;
|
||||
change_option?: string;
|
||||
eo_start_date?: string;
|
||||
eo_end_date?: string;
|
||||
change_type?: string;
|
||||
part_type?: string;
|
||||
writer_id?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export interface EoHistoryRow {
|
||||
objid: string;
|
||||
eo_no: string | null;
|
||||
year: string | null;
|
||||
project_no: string | null;
|
||||
project_name: string | null;
|
||||
unit_name: string | null;
|
||||
parent_part_info: string | null;
|
||||
part_no_disp: string | null;
|
||||
part_name_disp: string | null;
|
||||
part_no: string | null;
|
||||
part_name: string | null;
|
||||
bom_qty_status: string | null;
|
||||
qty: string | null;
|
||||
qty_temp: string | null;
|
||||
change_type: string | null;
|
||||
change_type_name: string | null;
|
||||
change_option: string | null;
|
||||
change_option_name: string | null;
|
||||
revision_disp: string | null;
|
||||
revision: string | null;
|
||||
eo_date: string | null;
|
||||
part_type: string | null;
|
||||
part_type_name: string | null;
|
||||
writer: string | null;
|
||||
writer_name: string | null;
|
||||
his_reg_date_title: string | null;
|
||||
bom_deploy_date: string | null;
|
||||
bom_deploy_date_title: string | null;
|
||||
}
|
||||
|
||||
export interface EoHistoryListResponse {
|
||||
rows: EoHistoryRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface EoHistoryDetail extends EoHistoryRow {
|
||||
// raw PART_MNG_HISTORY 추가 필드
|
||||
product_mgmt_objid?: string | null;
|
||||
upg_no?: string | null;
|
||||
unit?: string | null;
|
||||
spec?: string | null;
|
||||
material?: string | null;
|
||||
weight?: string | null;
|
||||
remark?: string | null;
|
||||
es_spec?: string | null;
|
||||
ms_spec?: string | null;
|
||||
design_apply_point?: string | null;
|
||||
management_flag?: string | null;
|
||||
status?: string | null;
|
||||
reg_date?: string | null;
|
||||
is_last?: string | null;
|
||||
sourcing_code?: string | null;
|
||||
sub_material?: string | null;
|
||||
thickness?: string | null;
|
||||
width?: string | null;
|
||||
height?: string | null;
|
||||
out_diameter?: string | null;
|
||||
in_diameter?: string | null;
|
||||
length?: string | null;
|
||||
supply_code?: string | null;
|
||||
contract_objid?: string | null;
|
||||
maker?: string | null;
|
||||
his_status?: string | null;
|
||||
bom_status?: string | null;
|
||||
heat_treatment_hardness?: string | null;
|
||||
heat_treatment_method?: string | null;
|
||||
surface_treatment?: string | null;
|
||||
customer_project_name?: string | null;
|
||||
}
|
||||
|
||||
export const devEoHistoryApi = {
|
||||
async list(filter: EoHistoryListFilter = {}): Promise<EoHistoryListResponse> {
|
||||
const res = await apiClient.get("/development/eo-history/list", { params: filter });
|
||||
return res.data?.data as EoHistoryListResponse;
|
||||
},
|
||||
async detail(objid: string): Promise<EoHistoryDetail | null> {
|
||||
const res = await apiClient.get(`/development/eo-history/${objid}`);
|
||||
return res.data?.data ?? null;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,309 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
// ============================================================
|
||||
// 개발관리 PART (M1 등록 / M2 조회) — wace partMng.xml 1:1
|
||||
// 라우트: /api/development/part-temp/*, /api/development/part/*
|
||||
// ============================================================
|
||||
|
||||
export interface PartListFilter {
|
||||
search_part_no?: string;
|
||||
search_part_name?: string;
|
||||
search_material?: string;
|
||||
search_spec?: string;
|
||||
search_part_type?: string;
|
||||
writer?: string;
|
||||
status?: string;
|
||||
status_arr?: string[];
|
||||
product_code?: string;
|
||||
upg_no?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
|
||||
// M2 추가 필터
|
||||
search_year?: string;
|
||||
search_design_date_from?: string;
|
||||
search_design_date_to?: string;
|
||||
customer_objid?: string;
|
||||
customer_cd?: string;
|
||||
project_name?: string;
|
||||
unit_code?: string;
|
||||
is_last?: string;
|
||||
eo?: string;
|
||||
}
|
||||
|
||||
/** partMngBaseSimple + M1/M2 추가 컬럼 평탄화 — Postgres는 컬럼명을 소문자로 반환 */
|
||||
export interface PartRow {
|
||||
objid: string;
|
||||
part_no: string | null;
|
||||
part_name: string | null;
|
||||
product_mgmt_objid: string | null;
|
||||
upg_no: string | null;
|
||||
unit: string | null;
|
||||
unit_title: string | null;
|
||||
qty: string | null;
|
||||
spec: string | null;
|
||||
post_processing: string | null;
|
||||
material: string | null;
|
||||
weight: string | null;
|
||||
part_type: string | null;
|
||||
part_type_title: string | null;
|
||||
remark: string | null;
|
||||
es_spec: string | null;
|
||||
ms_spec: string | null;
|
||||
change_type: string | null;
|
||||
design_apply_point: string | null;
|
||||
change_option: string | null;
|
||||
change_option_name: string | null;
|
||||
management_flag: string | null;
|
||||
revision: string | null;
|
||||
status: string | null;
|
||||
reg_date: string | null;
|
||||
part_regdate_title: string | null;
|
||||
edit_date: string | null;
|
||||
writer: string | null;
|
||||
is_last: string | null;
|
||||
is_longd: string | null;
|
||||
eo_date: string | null;
|
||||
eo_no: string | null;
|
||||
eo_temp: string | null;
|
||||
maker: string | null;
|
||||
contract_objid: string | null;
|
||||
thickness: string | null;
|
||||
width: string | null;
|
||||
height: string | null;
|
||||
out_diameter: string | null;
|
||||
in_diameter: string | null;
|
||||
length: string | null;
|
||||
sourcing_code: string | null;
|
||||
supply_code: string | null;
|
||||
supply_name: string | null;
|
||||
sub_material: string | null;
|
||||
parent_part_no: string | null;
|
||||
design_date: string | null;
|
||||
deploy_date: string | null;
|
||||
excel_upload_seq: string | number | null;
|
||||
cu01_cnt: number | string | null;
|
||||
cu02_cnt: number | string | null;
|
||||
cu03_cnt: number | string | null;
|
||||
cu_total_cnt: number | string | null;
|
||||
|
||||
// 추가 15컬럼 + 라벨
|
||||
heat_treatment_hardness: string | null;
|
||||
heat_treatment_method: string | null;
|
||||
surface_treatment: string | null;
|
||||
acctfg: string | null;
|
||||
acctfg_nm: string | null;
|
||||
odrfg: string | null;
|
||||
odrfg_nm: string | null;
|
||||
unit_dc: string | null;
|
||||
unit_dc_nm: string | null;
|
||||
unitmang_dc: string | null;
|
||||
unitmang_dc_nm: string | null;
|
||||
unitchng_nb: string | number | null;
|
||||
lot_fg: string | null;
|
||||
lot_fg_nm: string | null;
|
||||
use_yn: string | null;
|
||||
use_yn_nm: string | null;
|
||||
qc_fg: string | null;
|
||||
qc_fg_nm: string | null;
|
||||
setitem_fg: string | null;
|
||||
setitem_fg_nm: string | null;
|
||||
req_fg: string | null;
|
||||
req_fg_nm: string | null;
|
||||
unit_length: string | null;
|
||||
unit_qty: string | null;
|
||||
|
||||
// M1 전용 부속
|
||||
partner_title?: string | null;
|
||||
parent_part_info?: string | null;
|
||||
bom_report_objid?: string | null;
|
||||
objid_qty?: string | null;
|
||||
child_objid?: string | null;
|
||||
q_qty?: string | null;
|
||||
q_qty_raw?: string | null;
|
||||
qty_temp?: string | null;
|
||||
sort?: string | null;
|
||||
|
||||
// M2 전용 부속
|
||||
num?: number | null;
|
||||
bom_qty?: string | null;
|
||||
}
|
||||
|
||||
export interface PartListResponse {
|
||||
rows: PartRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface PartCreateBody {
|
||||
part_objid?: string;
|
||||
part_no: string;
|
||||
part_name: string;
|
||||
unit?: string;
|
||||
qty?: string;
|
||||
spec?: string;
|
||||
material?: string;
|
||||
thickness?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
out_diameter?: string;
|
||||
in_diameter?: string;
|
||||
length?: string;
|
||||
remark?: string;
|
||||
part_type: string;
|
||||
product_mgmt_objid?: string;
|
||||
supply_code?: string;
|
||||
maker?: string;
|
||||
contract_objid?: string;
|
||||
post_processing?: string;
|
||||
heat_treatment_hardness?: string;
|
||||
heat_treatment_method?: string;
|
||||
surface_treatment?: string;
|
||||
acctfg?: string;
|
||||
odrfg?: string;
|
||||
unit_dc?: string;
|
||||
unitmang_dc?: string;
|
||||
unitchng_nb?: string;
|
||||
lot_fg?: string;
|
||||
use_yn?: string;
|
||||
qc_fg?: string;
|
||||
setitem_fg?: string;
|
||||
req_fg?: string;
|
||||
unit_length?: string;
|
||||
unit_qty?: string;
|
||||
}
|
||||
|
||||
export interface PartUpdateBody {
|
||||
part_name?: string;
|
||||
material?: string;
|
||||
heat_treatment_hardness?: string;
|
||||
heat_treatment_method?: string;
|
||||
surface_treatment?: string;
|
||||
maker?: string;
|
||||
part_type?: string;
|
||||
acctfg?: string;
|
||||
odrfg?: string;
|
||||
spec?: string;
|
||||
unit_dc?: string;
|
||||
unitmang_dc?: string;
|
||||
unitchng_nb?: string;
|
||||
lot_fg?: string;
|
||||
use_yn?: string;
|
||||
qc_fg?: string;
|
||||
setitem_fg?: string;
|
||||
req_fg?: string;
|
||||
unit_length?: string;
|
||||
unit_qty?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface DeployResult {
|
||||
deployed: number;
|
||||
eo_nos: Record<string, string>;
|
||||
}
|
||||
|
||||
// ─── Excel Import ────────────────────────────────────────────
|
||||
|
||||
export interface PartExcelRow {
|
||||
NOTE: string;
|
||||
PART_NO: string;
|
||||
PART_NAME: string;
|
||||
MATERIAL: string;
|
||||
HEAT_TREATMENT_HARDNESS: string;
|
||||
HEAT_TREATMENT_METHOD: string;
|
||||
SURFACE_TREATMENT: string;
|
||||
MAKER: string;
|
||||
PART_TYPE: string;
|
||||
PART_TYPE_NAME?: string;
|
||||
SPEC: string;
|
||||
ACCTFG: string;
|
||||
ACCTFG_NAME?: string;
|
||||
ODRFG: string;
|
||||
ODRFG_NAME?: string;
|
||||
UNIT_DC: string;
|
||||
UNIT_DC_NAME?: string;
|
||||
UNITMANG_DC: string;
|
||||
UNITMANG_DC_NAME?: string;
|
||||
UNITCHNG_NB: string;
|
||||
LOT_FG: string;
|
||||
USE_YN: string;
|
||||
QC_FG: string;
|
||||
SETITEM_FG: string;
|
||||
REQ_FG: string;
|
||||
UNIT_LENGTH: string;
|
||||
UNIT_QTY: string;
|
||||
REMARK: string;
|
||||
}
|
||||
|
||||
export interface ExcelParseResponse {
|
||||
rows: PartExcelRow[];
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export interface ExcelSaveResponse {
|
||||
inserted: number;
|
||||
skipped: number;
|
||||
skippedPartNos: string[];
|
||||
}
|
||||
|
||||
// ─── API ────────────────────────────────────────────────────
|
||||
|
||||
export const devPartApi = {
|
||||
// M1 그리드
|
||||
async listTemp(filter: PartListFilter = {}): Promise<PartListResponse> {
|
||||
const res = await apiClient.get("/development/part-temp/list", { params: filter });
|
||||
return res.data?.data as PartListResponse;
|
||||
},
|
||||
|
||||
// M2 그리드
|
||||
async list(filter: PartListFilter = {}): Promise<PartListResponse> {
|
||||
const res = await apiClient.get("/development/part/list", { params: filter });
|
||||
return res.data?.data as PartListResponse;
|
||||
},
|
||||
|
||||
// 단건 상세
|
||||
async detail(objid: string): Promise<PartRow | null> {
|
||||
const res = await apiClient.get(`/development/part/${objid}`);
|
||||
return res.data?.data ?? null;
|
||||
},
|
||||
|
||||
// 신규 등록 (38 컬럼)
|
||||
async create(body: PartCreateBody): Promise<{ objid: string }> {
|
||||
const res = await apiClient.post("/development/part", body);
|
||||
return res.data?.data;
|
||||
},
|
||||
|
||||
// 상세 수정 (21 컬럼)
|
||||
async update(objid: string, body: PartUpdateBody) {
|
||||
return (await apiClient.put(`/development/part/${objid}`, body)).data;
|
||||
},
|
||||
|
||||
// 확정 (M1→M2): EO_NO 채번 + part_mng_history 이력
|
||||
async deploy(objids: string[]): Promise<DeployResult> {
|
||||
const res = await apiClient.post("/development/part-temp/deploy", { objids });
|
||||
return res.data?.data as DeployResult;
|
||||
},
|
||||
|
||||
// 다중 삭제
|
||||
async remove(objids: string[]) {
|
||||
const res = await apiClient.delete("/development/part", { data: { objids } });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// Excel Import — 파싱 + 검증
|
||||
async excelParse(file: File): Promise<ExcelParseResponse> {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await apiClient.post("/development/part/excel-parse", fd, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return res.data?.data as ExcelParseResponse;
|
||||
},
|
||||
|
||||
// Excel Import — 저장 (신규 PART_NO 만 INSERT)
|
||||
async excelSave(rows: PartExcelRow[]): Promise<ExcelSaveResponse> {
|
||||
const res = await apiClient.post("/development/part/excel-save", { rows });
|
||||
return res.data?.data as ExcelSaveResponse;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
수준,품번,품명,수량,항목수량,재료,열처리경도,열처리방법,표면처리,공급업체,범주이름
|
||||
1,RFX-140,Peak 소성로,1,1,재질1,,,,(주)배관랜드,구매품
|
||||
2,RFX-140-010,치환실 앗세이,1,1,재질2,,,,(주)네온테크,조립품
|
||||
3,RFX-140-010-001,치환실 앗세이_001,1,1,재질3,,,,(주)우리전열,구매품
|
||||
3,RFX-140-010-002,AC전원,1,1,,,,,ABB,구매품
|
||||
2,RFX-140-020,전원부 앗세이,1,1,,,,,,부품
|
||||
|
Binary file not shown.
Reference in New Issue
Block a user