a1ace22626
· 그리드: 8그룹 18셀 평탄화 (운영판 projectMgmtWbsGridList SQL 1:1) · 검색폼: 11필드 (년도/프로젝트번호/주문유형/고객사/제품구분/요청납기/국내해외/유무상/품번/품명/S/N) · 영업관리 패턴 통일: PartSelect 단일 search_partObjId, customer_mng 단일 LEFT JOIN · client_mng/supply_mng 분기 → customer_mng로 흡수, CODE_NAME() 함수 직접 사용 · 행 클릭 동작은 P1.5 별도 결정 (영업관리 OrderRegistDialog 재사용 후보) · PMS_WBS_TASK/SETUP_WBS_TASK 의존 컬럼은 그리드 표시 컬럼에 없어 P2(WBS관리) 보류 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
239 lines
9.9 KiB
TypeScript
239 lines
9.9 KiB
TypeScript
"use client";
|
|
|
|
// 진행관리 (wace projectMgmtWbsList3.jsp + projectMgmtWbsGridList 1:1 이식)
|
|
// 원본:
|
|
// - JSP: /Users/jhj/wace_plm/WebContent/WEB-INF/view/project/projectMgmtWbsList3.jsp (349줄)
|
|
// - 매퍼: wace_plm/src/com/pms/mapper/project.xml:3854 projectMgmtWbsGridList
|
|
// GAP: docs/migration/project/00-gap.md
|
|
//
|
|
// 그리드: 8그룹 18셀 평탄화 (DataGrid가 그룹 헤더 미지원 → 라벨 prefix로 표현)
|
|
// 검색폼: 11필드 (1행 6 + 2행 5)
|
|
// 행 클릭: P1.5에서 영업관리 OrderRegistDialog 재사용 검토 — 현재 미연결
|
|
|
|
import React, { useCallback, useEffect, 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 { CustomerSelect } from "@/components/common/CustomerSelect";
|
|
import { PartSelect } from "@/components/common/PartSelect";
|
|
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
|
|
import { projectMgmtApi, ProgressListFilter, ProgressRow } from "@/lib/api/projectMgmt";
|
|
|
|
// wace projectMgmtWbsList3.jsp 컬럼 정의 1:1 (8그룹 → 평탄화, 그룹명은 라벨 prefix)
|
|
const GRID_COLUMNS: DataGridColumn[] = [
|
|
{ key: "project_no", label: "프로젝트번호", width: "w-[160px]", frozen: true },
|
|
// 프로젝트정보 그룹
|
|
{ key: "category_name", label: "주문유형", width: "w-[90px]", align: "center" },
|
|
{ key: "product_name", label: "제품구분", width: "w-[100px]", align: "left" },
|
|
{ key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" },
|
|
{ key: "reg_date", label: "접수일", width: "w-[110px]", align: "center" },
|
|
{ key: "customer_name", label: "고객사", width: "w-[140px]" },
|
|
{ key: "free_of_charge", label: "유/무상", width: "w-[80px]", align: "center" },
|
|
{ key: "product_item_code", label: "품번", width: "w-[150px]" },
|
|
{ key: "product_item_name", label: "품명", width: "w-[180px]" },
|
|
{ key: "serial_no", label: "S/N", width: "w-[150px]" },
|
|
{ key: "contract_qty", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true },
|
|
{ key: "req_del_date", label: "요청납기", width: "w-[110px]", align: "center" },
|
|
// 설계
|
|
{ key: "ebom_status", label: "E-BOM", width: "w-[100px]", align: "center" },
|
|
// 생산관리
|
|
{ key: "mbom_status", label: "M-BOM", width: "w-[100px]", align: "center" },
|
|
// 구매
|
|
{ key: "order_date", label: "발주일", width: "w-[110px]", align: "center" },
|
|
{ key: "receiving_rate", label: "입고율", width: "w-[90px]", align: "right" },
|
|
// 생산
|
|
{ key: "production_team_12", label: "제조1,2팀", width: "w-[100px]", align: "center" },
|
|
{ key: "production_team_3", label: "제조3팀", width: "w-[100px]", align: "center" },
|
|
// 장비
|
|
{ key: "assembly", label: "조립", width: "w-[90px]", align: "center" },
|
|
{ key: "verification", label: "검증", width: "w-[90px]", align: "center" },
|
|
// 출하
|
|
{ key: "shipment_date", label: "출하일", width: "w-[110px]", align: "center" },
|
|
];
|
|
|
|
const CATEGORY_GROUP = "0000167"; // 주문유형
|
|
const PRODUCT_GROUP = "0000001"; // 제품구분
|
|
|
|
// wace L229: 시스템년도 ±4 (운영판은 sysYear-4 ~ sysYear). RPS는 sysYear±4로 여유.
|
|
const YEAR_OPTIONS = (() => {
|
|
const cur = new Date().getFullYear();
|
|
const arr: string[] = [];
|
|
for (let y = cur + 4; y >= cur - 4; y--) arr.push(String(y));
|
|
return arr;
|
|
})();
|
|
|
|
const EMPTY_FILTER: ProgressListFilter = {
|
|
Year: "", project_nos: "", category_cd: "", customer_objid: "", product: "",
|
|
contract_start_date: "", contract_end_date: "",
|
|
area_cd: "", free_of_charge: "",
|
|
search_partObjId: "", serial_no: "",
|
|
};
|
|
|
|
export default function ProjectProgressPage() {
|
|
const [rows, setRows] = useState<ProgressRow[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [filter, setFilter] = useState<ProgressListFilter>(EMPTY_FILTER);
|
|
const [projectNoOptions, setProjectNoOptions] = useState<SmartSelectOption[]>([]);
|
|
|
|
const fetchList = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await projectMgmtApi.list(filter);
|
|
setRows(data);
|
|
} catch (e: any) {
|
|
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
|
} finally { setLoading(false); }
|
|
}, [filter]);
|
|
|
|
useEffect(() => { fetchList(); /* eslint-disable-next-line */ }, []);
|
|
|
|
// 프로젝트번호 셀렉트 옵션 (wace common.getCusProjectNoList 대응)
|
|
useEffect(() => {
|
|
projectMgmtApi.projectNoOptions()
|
|
.then((opts) => setProjectNoOptions(opts.map((o) => ({ code: o.value, label: o.label }))))
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
const handleReset = () => {
|
|
setFilter(EMPTY_FILTER);
|
|
setTimeout(() => fetchList(), 0);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* 검색폼 — wace projectMgmtWbsList3.jsp:222-313 활성 11필드 */}
|
|
<div className="border-b bg-card px-4 py-3">
|
|
<div className="grid grid-cols-6 gap-3 text-sm">
|
|
{/* 1행 */}
|
|
<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="프로젝트번호">
|
|
<SmartSelect
|
|
options={projectNoOptions}
|
|
value={filter.project_nos ?? ""}
|
|
onValueChange={(v) => setFilter({ ...filter, project_nos: v })}
|
|
placeholder="전체"
|
|
/>
|
|
</Field>
|
|
<Field label="주문유형">
|
|
<CommCodeSelect
|
|
groupId={CATEGORY_GROUP}
|
|
value={filter.category_cd ?? ""}
|
|
onValueChange={(v) => setFilter({ ...filter, category_cd: v })}
|
|
/>
|
|
</Field>
|
|
<Field label="고객사">
|
|
<CustomerSelect
|
|
value={filter.customer_objid ?? ""}
|
|
onValueChange={(v) => setFilter({ ...filter, customer_objid: v })}
|
|
/>
|
|
</Field>
|
|
<Field label="제품구분">
|
|
<CommCodeSelect
|
|
groupId={PRODUCT_GROUP}
|
|
value={filter.product ?? ""}
|
|
onValueChange={(v) => setFilter({ ...filter, product: v })}
|
|
/>
|
|
</Field>
|
|
<Field label="요청납기일">
|
|
<div className="flex items-center gap-1">
|
|
<Input type="date" value={filter.contract_start_date ?? ""} onChange={(e) => setFilter({ ...filter, contract_start_date: e.target.value })} />
|
|
<span className="text-xs text-muted-foreground">~</span>
|
|
<Input type="date" value={filter.contract_end_date ?? ""} onChange={(e) => setFilter({ ...filter, contract_end_date: e.target.value })} />
|
|
</div>
|
|
</Field>
|
|
|
|
{/* 2행 */}
|
|
<Field label="국내/해외">
|
|
<select
|
|
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
|
value={filter.area_cd ?? ""}
|
|
onChange={(e) => setFilter({ ...filter, area_cd: e.target.value })}
|
|
>
|
|
<option value="">전체</option>
|
|
<option value="국내">국내</option>
|
|
<option value="해외">해외</option>
|
|
</select>
|
|
</Field>
|
|
<Field label="유/무상">
|
|
<select
|
|
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
|
value={filter.free_of_charge ?? ""}
|
|
onChange={(e) => setFilter({ ...filter, free_of_charge: e.target.value })}
|
|
>
|
|
<option value="">전체</option>
|
|
<option value="유상">유상</option>
|
|
<option value="무상">무상</option>
|
|
</select>
|
|
</Field>
|
|
<Field label="품번">
|
|
<PartSelect
|
|
mode="partNo"
|
|
value={filter.search_partObjId ?? ""}
|
|
onValueChange={(v) => setFilter({ ...filter, search_partObjId: v })}
|
|
/>
|
|
</Field>
|
|
<Field label="품명">
|
|
<PartSelect
|
|
mode="partName"
|
|
value={filter.search_partObjId ?? ""}
|
|
onValueChange={(v) => setFilter({ ...filter, search_partObjId: v })}
|
|
/>
|
|
</Field>
|
|
<Field label="S/N">
|
|
<Input
|
|
value={filter.serial_no ?? ""}
|
|
onChange={(e) => setFilter({ ...filter, serial_no: e.target.value })}
|
|
placeholder="S/N LIKE"
|
|
/>
|
|
</Field>
|
|
|
|
{/* 액션 */}
|
|
<div className="flex items-end justify-end gap-2">
|
|
<Button variant="outline" size="sm" onClick={handleReset}>
|
|
<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>
|
|
|
|
{/* 그리드 (8그룹 18셀 평탄화) */}
|
|
<div className="flex-1 min-h-0 p-2">
|
|
<DataGrid
|
|
columns={GRID_COLUMNS}
|
|
data={rows}
|
|
loading={loading}
|
|
showRowNumber
|
|
emptyMessage="조건에 맞는 프로젝트가 없습니다."
|
|
gridId="project-progress-wbslist3"
|
|
/>
|
|
</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>
|
|
);
|
|
}
|