개발관리>PART 등록·조회 메뉴 신설 (PR-A) — wace partMng 1:1 이식
backend (M1+M2):
- devPartService: listTemp/listRelease/getByObjid/create/update/deploy/removeMany
- partMngBaseSimple SELECT + 추가 15컬럼(acctfg/odrfg/unit_dc/unitmang_dc/lot_fg 등) 라벨/CASE
- deploy 트랜잭션 3단계 (isLastInit → part_mng_history INSERT → partMngDeploy + EO_NO 채번)
- EO_NO 분기: is_longd='1'→EOB{yy}-{seq} / else EO{yy}-{seq}
- objidUtil: wace CommonUtils.createObjId() 1:1 (bigint objid 채번)
- DDL: 9 신규 테이블 + part_mng 15컬럼 ALTER (운영판 1:1 추출)
frontend (M1+M2):
- part-regist (M1) / part-search (M2): 23셀 그리드 + 검색폼 + 액션
- PartFormDialog: 등록/수정 통합 (mode prop, 4 섹션)
- PartDetailDialog: 읽기 전용 + "수정" dispatch
- AdminPageRenderer dynamic 임포트 2건 + menu_info URL spec 정렬
본 PR 제외 (별 PR): 도면 다중 업로드, ERP 업로드, Excel Import, BOM_PART_QTY R/W
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
"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,
|
||||
} 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";
|
||||
|
||||
// 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 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" 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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
"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,
|
||||
} 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";
|
||||
|
||||
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 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>
|
||||
</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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
// 개발관리 > PART 상세 조회 다이얼로그 (read-only).
|
||||
// 행 더블클릭 진입. "수정" 버튼 클릭 시 PartFormDialog(mode='edit')로 전환은
|
||||
// 호출 페이지가 dispatch (open=false → 부모가 form dialog 오픈).
|
||||
|
||||
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, Pencil } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { devPartApi, PartRow } from "@/lib/api/devPart";
|
||||
|
||||
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-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>PART 상세 정보</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="기본정보">
|
||||
<Row>
|
||||
<V label="품번" value={row.part_no} />
|
||||
<V label="품명" value={row.part_name} />
|
||||
<V label="범주" value={row.part_type_title} />
|
||||
</Row>
|
||||
<Row>
|
||||
<V label="단위" value={row.unit_title} />
|
||||
<V label="수량" value={row.qty} align="right" />
|
||||
<V label="규격" value={row.spec} />
|
||||
</Row>
|
||||
<Row>
|
||||
<V label="재료" value={row.material} />
|
||||
<V label="메이커" value={row.maker} />
|
||||
<V label="비고" value={row.remark} />
|
||||
</Row>
|
||||
<Row>
|
||||
<V label="공급업체" value={row.supply_name} />
|
||||
<V label="등록자" value={row.writer} />
|
||||
<V label="등록일" value={row.part_regdate_title} />
|
||||
</Row>
|
||||
<Row>
|
||||
<V label="REVISION" value={row.revision} />
|
||||
<V label="EO_NO" value={row.eo_no} />
|
||||
<V label="STATUS" value={row.status} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section title="크기 / 형상">
|
||||
<Row>
|
||||
<V label="두께" value={row.thickness} align="right" />
|
||||
<V label="너비(W)" value={row.width} align="right" />
|
||||
<V label="높이(H)" value={row.height} align="right" />
|
||||
</Row>
|
||||
<Row>
|
||||
<V label="외경" value={row.out_diameter} align="right" />
|
||||
<V label="내경" value={row.in_diameter} align="right" />
|
||||
<V label="길이(L)" value={row.length} align="right" />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section title="분류 / 단위">
|
||||
<Row>
|
||||
<V label="계정구분" value={row.acctfg_nm} />
|
||||
<V label="조달구분" value={row.odrfg_nm} />
|
||||
<V label="환산수량" value={row.unitchng_nb != null ? String(row.unitchng_nb) : ""} align="right" />
|
||||
</Row>
|
||||
<Row>
|
||||
<V label="재고단위" value={row.unit_dc_nm} />
|
||||
<V label="관리단위" value={row.unitmang_dc_nm} />
|
||||
<V label="개당길이 / 개당수량"
|
||||
value={[row.unit_length, row.unit_qty].filter(Boolean).join(" / ")} align="right" />
|
||||
</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.post_processing} />
|
||||
<V label="첨부 (3D/2D/PDF)"
|
||||
value={`${row.cu01_cnt ?? 0} / ${row.cu02_cnt ?? 0} / ${row.cu03_cnt ?? 0}`} align="center" />
|
||||
<V label="" value="" />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section title="Y/N 플래그">
|
||||
<Row>
|
||||
<V label="LOT구분" value={row.lot_fg_nm} align="center" />
|
||||
<V label="사용여부" value={row.use_yn_nm} align="center" />
|
||||
<V label="검사여부" value={row.qc_fg_nm} align="center" />
|
||||
</Row>
|
||||
<Row>
|
||||
<V label="SET품여부" value={row.setitem_fg_nm} align="center" />
|
||||
<V label="의뢰여부" value={row.req_fg_nm} align="center" />
|
||||
<V label="" value="" />
|
||||
</Row>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{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 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
"use client";
|
||||
|
||||
// 개발관리 > PART 등록/수정 통합 다이얼로그.
|
||||
// wace partMngFormPopUp.jsp + partMngDetailPopUp.jsp 1:1 (mode 분기).
|
||||
//
|
||||
// 신규: POST /api/development/part (38 컬럼)
|
||||
// 수정: PUT /api/development/part/:objid (21 컬럼만 — wace updatePartDetail 1:1)
|
||||
//
|
||||
// 그룹:
|
||||
// ① 기본정보 (필수 ★: part_no, part_name, part_type)
|
||||
// ② 크기/형상
|
||||
// ③ 분류/단위 (comm_code SmartSelect)
|
||||
// ④ Y/N 플래그 (radio '1'/'0')
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, 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 { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
||||
import { devPartApi, PartCreateBody, PartUpdateBody, PartRow } from "@/lib/api/devPart";
|
||||
|
||||
// comm_code 그룹 ID (vexplor_rps DB 실재 그룹)
|
||||
const GROUP_PART_TYPE = "0000062"; // PART TYPE (조립품/부품/구매품)
|
||||
const GROUP_UNIT = "0001399"; // 단위 (m/Set/EA/식/BAG/kg/...)
|
||||
const GROUP_ACCTFG = "0900213"; // 파트_계정구분 (원자재/제품/...)
|
||||
|
||||
// ODRFG: spec '0=구매/1=생산/8=Phantom' — 하드코딩
|
||||
const ODRFG_OPTIONS = [
|
||||
{ code: "0", label: "구매" },
|
||||
{ code: "1", label: "생산" },
|
||||
{ code: "8", label: "Phantom" },
|
||||
];
|
||||
|
||||
interface FormState {
|
||||
// 기본
|
||||
part_no: string;
|
||||
part_name: string;
|
||||
part_type: string;
|
||||
unit: string;
|
||||
qty: string;
|
||||
spec: string;
|
||||
material: string;
|
||||
remark: string;
|
||||
maker: string;
|
||||
// 크기/형상
|
||||
thickness: string;
|
||||
width: string;
|
||||
height: string;
|
||||
out_diameter: string;
|
||||
in_diameter: string;
|
||||
length: string;
|
||||
// 분류/단위
|
||||
acctfg: string;
|
||||
odrfg: string;
|
||||
unit_dc: string;
|
||||
unitmang_dc: string;
|
||||
unitchng_nb: string;
|
||||
unit_length: string;
|
||||
unit_qty: string;
|
||||
// 열처리/표면처리/후가공
|
||||
heat_treatment_hardness: string;
|
||||
heat_treatment_method: string;
|
||||
surface_treatment: string;
|
||||
post_processing: string;
|
||||
// 부속 (신규 시만 표시)
|
||||
product_mgmt_objid: string;
|
||||
supply_code: string;
|
||||
contract_objid: string;
|
||||
// Y/N
|
||||
lot_fg: string;
|
||||
use_yn: string;
|
||||
qc_fg: string;
|
||||
setitem_fg: string;
|
||||
req_fg: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
part_no: "", part_name: "", part_type: "", unit: "", qty: "", spec: "", material: "", remark: "", maker: "",
|
||||
thickness: "", width: "", height: "", out_diameter: "", in_diameter: "", length: "",
|
||||
acctfg: "", odrfg: "", unit_dc: "", unitmang_dc: "", unitchng_nb: "", unit_length: "", unit_qty: "",
|
||||
heat_treatment_hardness: "", heat_treatment_method: "", surface_treatment: "", post_processing: "",
|
||||
product_mgmt_objid: "", supply_code: "", contract_objid: "",
|
||||
lot_fg: "1", use_yn: "1", qc_fg: "0", setitem_fg: "0", req_fg: "0",
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.part_no.trim()) return toast.error("품번은 필수입니다.");
|
||||
if (!form.part_name.trim()) return toast.error("품명은 필수입니다.");
|
||||
if (!isEdit && !form.part_type.trim()) return toast.error("범주(PART TYPE)는 필수입니다.");
|
||||
|
||||
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,
|
||||
unit: form.unit,
|
||||
qty: form.qty,
|
||||
spec: form.spec,
|
||||
material: form.material,
|
||||
thickness: form.thickness,
|
||||
width: form.width,
|
||||
height: form.height,
|
||||
out_diameter: form.out_diameter,
|
||||
in_diameter: form.in_diameter,
|
||||
length: form.length,
|
||||
remark: form.remark,
|
||||
product_mgmt_objid: form.product_mgmt_objid,
|
||||
supply_code: form.supply_code,
|
||||
maker: form.maker,
|
||||
contract_objid: form.contract_objid,
|
||||
post_processing: form.post_processing,
|
||||
heat_treatment_hardness: form.heat_treatment_hardness,
|
||||
heat_treatment_method: form.heat_treatment_method,
|
||||
surface_treatment: form.surface_treatment,
|
||||
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,
|
||||
};
|
||||
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 ? "PART 수정" : "PART 신규 등록";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{titleText}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<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">
|
||||
{/* ① 기본정보 */}
|
||||
<Section title="기본정보">
|
||||
<Row>
|
||||
<Field label="품번" required>
|
||||
<Input value={form.part_no} disabled={isEdit}
|
||||
onChange={(e) => setField("part_no", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="품명" required>
|
||||
<Input value={form.part_name}
|
||||
onChange={(e) => setField("part_name", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="범주(PART TYPE)" required>
|
||||
<CommCodeSelect groupId={GROUP_PART_TYPE} withAll={false}
|
||||
value={form.part_type}
|
||||
onValueChange={(v) => setField("part_type", v)} />
|
||||
</Field>
|
||||
</Row>
|
||||
<Row>
|
||||
<Field label="단위">
|
||||
<CommCodeSelect groupId={GROUP_UNIT} withAll={false}
|
||||
value={form.unit}
|
||||
onValueChange={(v) => setField("unit", v)} />
|
||||
</Field>
|
||||
<Field label="수량">
|
||||
<Input value={form.qty} className="text-right"
|
||||
onChange={(e) => setField("qty", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="규격">
|
||||
<Input value={form.spec}
|
||||
onChange={(e) => setField("spec", e.target.value)} />
|
||||
</Field>
|
||||
</Row>
|
||||
<Row>
|
||||
<Field label="재료">
|
||||
<Input value={form.material}
|
||||
onChange={(e) => setField("material", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="메이커">
|
||||
<Input value={form.maker}
|
||||
onChange={(e) => setField("maker", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="비고">
|
||||
<Input value={form.remark}
|
||||
onChange={(e) => setField("remark", e.target.value)} />
|
||||
</Field>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* ② 크기/형상 */}
|
||||
<Section title="크기 / 형상">
|
||||
<Row>
|
||||
<Field label="두께"><Input value={form.thickness} className="text-right"
|
||||
onChange={(e) => setField("thickness", e.target.value)} /></Field>
|
||||
<Field label="너비(W)"><Input value={form.width} className="text-right"
|
||||
onChange={(e) => setField("width", e.target.value)} /></Field>
|
||||
<Field label="높이(H)"><Input value={form.height} className="text-right"
|
||||
onChange={(e) => setField("height", e.target.value)} /></Field>
|
||||
</Row>
|
||||
<Row>
|
||||
<Field label="외경"><Input value={form.out_diameter} className="text-right"
|
||||
onChange={(e) => setField("out_diameter", e.target.value)} /></Field>
|
||||
<Field label="내경"><Input value={form.in_diameter} className="text-right"
|
||||
onChange={(e) => setField("in_diameter", e.target.value)} /></Field>
|
||||
<Field label="길이(L)"><Input value={form.length} className="text-right"
|
||||
onChange={(e) => setField("length", e.target.value)} /></Field>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* ③ 분류 / 단위 */}
|
||||
<Section title="분류 / 단위">
|
||||
<Row>
|
||||
<Field label="계정구분">
|
||||
<CommCodeSelect groupId={GROUP_ACCTFG} withAll={false}
|
||||
value={form.acctfg}
|
||||
onValueChange={(v) => setField("acctfg", v)} />
|
||||
</Field>
|
||||
<Field label="조달구분">
|
||||
<select className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||||
value={form.odrfg}
|
||||
onChange={(e) => setField("odrfg", e.target.value)}>
|
||||
<option value="">선택</option>
|
||||
{ODRFG_OPTIONS.map((o) =>
|
||||
<option key={o.code} value={o.code}>{o.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="환산수량(UNITCHNG_NB)">
|
||||
<Input value={form.unitchng_nb} className="text-right"
|
||||
onChange={(e) => setField("unitchng_nb", e.target.value)} />
|
||||
</Field>
|
||||
</Row>
|
||||
<Row>
|
||||
<Field label="재고단위">
|
||||
<CommCodeSelect groupId={GROUP_UNIT} withAll={false}
|
||||
value={form.unit_dc}
|
||||
onValueChange={(v) => setField("unit_dc", v)} />
|
||||
</Field>
|
||||
<Field label="관리단위">
|
||||
<CommCodeSelect groupId={GROUP_UNIT} withAll={false}
|
||||
value={form.unitmang_dc}
|
||||
onValueChange={(v) => setField("unitmang_dc", v)} />
|
||||
</Field>
|
||||
<Field label="개당길이 / 개당수량">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input value={form.unit_length} className="text-right"
|
||||
placeholder="개당길이"
|
||||
onChange={(e) => setField("unit_length", e.target.value)} />
|
||||
<span className="text-xs text-muted-foreground">/</span>
|
||||
<Input value={form.unit_qty} className="text-right"
|
||||
placeholder="개당수량"
|
||||
onChange={(e) => setField("unit_qty", e.target.value)} />
|
||||
</div>
|
||||
</Field>
|
||||
</Row>
|
||||
<Row>
|
||||
<Field label="열처리경도">
|
||||
<Input value={form.heat_treatment_hardness}
|
||||
onChange={(e) => setField("heat_treatment_hardness", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="열처리방법">
|
||||
<Input value={form.heat_treatment_method}
|
||||
onChange={(e) => setField("heat_treatment_method", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="표면처리">
|
||||
<Input value={form.surface_treatment}
|
||||
onChange={(e) => setField("surface_treatment", e.target.value)} />
|
||||
</Field>
|
||||
</Row>
|
||||
<Row>
|
||||
<Field label="후가공">
|
||||
<Input value={form.post_processing}
|
||||
onChange={(e) => setField("post_processing", e.target.value)} />
|
||||
</Field>
|
||||
{!isEdit && (
|
||||
<Field label="공급업체 코드">
|
||||
<Input value={form.supply_code}
|
||||
onChange={(e) => setField("supply_code", e.target.value)}
|
||||
placeholder="admin_supply_mng.objid" />
|
||||
</Field>
|
||||
)}
|
||||
{!isEdit && (
|
||||
<Field label="제품 OBJID">
|
||||
<Input value={form.product_mgmt_objid}
|
||||
onChange={(e) => setField("product_mgmt_objid", e.target.value)}
|
||||
placeholder="product_mgmt.objid" />
|
||||
</Field>
|
||||
)}
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* ④ Y/N 플래그 */}
|
||||
<Section title="Y/N 플래그">
|
||||
<Row>
|
||||
<Field label="LOT구분"><YNRadio value={form.lot_fg} onChange={(v) => setField("lot_fg", v)} /></Field>
|
||||
<Field label="사용여부"><YNRadio value={form.use_yn} onChange={(v) => setField("use_yn", v)} /></Field>
|
||||
<Field label="검사여부"><YNRadio value={form.qc_fg} onChange={(v) => setField("qc_fg", v)} /></Field>
|
||||
</Row>
|
||||
<Row>
|
||||
<Field label="SET품여부"><YNRadio value={form.setitem_fg} onChange={(v) => setField("setitem_fg", v)} /></Field>
|
||||
<Field label="의뢰여부"><YNRadio value={form.req_fg} onChange={(v) => setField("req_fg", v)} /></Field>
|
||||
<Field label="" />
|
||||
</Row>
|
||||
</Section>
|
||||
</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">{isEdit ? "수정" : "등록"}</span>
|
||||
</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 Field({ label, required, children }: { label: string; required?: boolean; children?: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
{label && (
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">
|
||||
{label}
|
||||
{required && <span className="ml-1 text-red-500">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function YNRadio({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div className="flex h-9 items-center gap-3 rounded-md border bg-background px-3 text-sm">
|
||||
<label className="flex items-center gap-1">
|
||||
<input type="radio" checked={value === "1"} onChange={() => onChange("1")} />
|
||||
<span>예</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<input type="radio" checked={value === "0"} onChange={() => onChange("0")} />
|
||||
<span>아니오</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── PartRow → FormState 매핑 ──────────────────────────────
|
||||
|
||||
function rowToForm(r: PartRow): FormState {
|
||||
return {
|
||||
part_no: r.part_no ?? "",
|
||||
part_name: r.part_name ?? "",
|
||||
part_type: r.part_type ?? "",
|
||||
unit: r.unit ?? "",
|
||||
qty: r.qty ?? "",
|
||||
spec: r.spec ?? "",
|
||||
material: r.material ?? "",
|
||||
remark: r.remark ?? "",
|
||||
maker: r.maker ?? "",
|
||||
thickness: r.thickness ?? "",
|
||||
width: r.width ?? "",
|
||||
height: r.height ?? "",
|
||||
out_diameter: r.out_diameter ?? "",
|
||||
in_diameter: r.in_diameter ?? "",
|
||||
length: r.length ?? "",
|
||||
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) : "",
|
||||
unit_length: r.unit_length ?? "",
|
||||
unit_qty: r.unit_qty ?? "",
|
||||
heat_treatment_hardness: r.heat_treatment_hardness ?? "",
|
||||
heat_treatment_method: r.heat_treatment_method ?? "",
|
||||
surface_treatment: r.surface_treatment ?? "",
|
||||
post_processing: r.post_processing ?? "",
|
||||
product_mgmt_objid: r.product_mgmt_objid ?? "",
|
||||
supply_code: r.supply_code ?? "",
|
||||
contract_objid: r.contract_objid ?? "",
|
||||
lot_fg: r.lot_fg ?? "1",
|
||||
use_yn: r.use_yn ?? "1",
|
||||
qc_fg: r.qc_fg ?? "0",
|
||||
setitem_fg: r.setitem_fg ?? "0",
|
||||
req_fg: r.req_fg ?? "0",
|
||||
};
|
||||
}
|
||||
@@ -107,6 +107,8 @@ 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/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,249 @@
|
||||
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>;
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user