350ddcd3b8
증상:
프로젝트관리 진행관리/WBS 그리드의 모든 행이 회색(bg-accent) 으로 표시.
원인 추적 결과 DataGrid 의 isSelected 평가식 `selectedId === row.id` 가
selectedId 도 row.id 도 둘 다 undefined 일 때 `undefined === undefined` = true
가 되어 모든 행이 selected 상태로 잡힘 (memory: feedback_datagrid_id_mapping
함정의 또 다른 발현 — 영업관리는 항상 id 매핑이 있어 우연히 회피).
수정:
- project/progress/page.tsx, project/wbs-template/page.tsx
- setRows 에서 `id: r.objid ?? "...-${i}"` 매핑 추가
- 부모 wrapper 도 영업관리 패턴 `overflow-hidden` + DataGrid 직접 자식으로 통일
- components/common/DataGrid.tsx
- isSelected 가드 — selectedId/row.id 가 nullish 면 무조건 false 처리.
향후 다른 페이지에서 id 매핑 누락 시에도 그리드 색 폭주는 차단.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
195 lines
7.1 KiB
TypeScript
195 lines
7.1 KiB
TypeScript
"use client";
|
|
|
|
// 프로젝트관리 > 제품구분_WBS관리 (wace wbsTemplateMngList.jsp 1:1 이식)
|
|
// 원본:
|
|
// - JSP: /Users/jhj/wace_plm/WebContent/WEB-INF/view/project/wbsTemplateMngList.jsp (378줄)
|
|
// - 매퍼: wace_plm/src/com/pms/mapper/project.xml:5552 wbsTemplateMngGridList
|
|
// GAP: docs/migration/project/02-wbs-template.md
|
|
//
|
|
// 그리드: 5컬럼 (제품구분 / 제목 / WBS(folder) / 등록자 / 등록일)
|
|
// 검색: 제품구분 단일
|
|
// 등록/수정 통합 다이얼로그: WbsTemplateDialog (wace WBSExcelImportPopUp.jsp 1:1)
|
|
|
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Plus, Trash2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
|
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
|
import { PageHeader } from "@/components/common/PageHeader";
|
|
import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar";
|
|
import { wbsTemplateApi, TemplateRow } from "@/lib/api/wbsTemplate";
|
|
import { WbsTemplateDialog } from "@/components/project/WbsTemplateDialog";
|
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
|
|
|
const PRODUCT_GROUP = "0000001"; // 제품구분
|
|
|
|
const GRID_COLUMNS: DataGridColumn[] = [
|
|
{ key: "product_name", label: "제품구분", width: "w-[200px]", frozen: true },
|
|
{ key: "title", label: "제목", minWidth: "min-w-[260px]" },
|
|
{
|
|
key: "wbs_task_cnt",
|
|
label: "WBS",
|
|
width: "w-[115px]",
|
|
align: "center",
|
|
renderType: "folder", // wace fnc_getFolderIcon
|
|
},
|
|
{ key: "writer_title", label: "등록자", width: "w-[180px]" },
|
|
{ key: "reg_date_title", label: "등록일", width: "w-[140px]", align: "center" },
|
|
];
|
|
|
|
export default function WbsTemplatePage() {
|
|
const [rows, setRows] = useState<TemplateRow[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [filterProduct, setFilterProduct] = useState<string>("");
|
|
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
|
|
|
// 다이얼로그 상태
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [editObjId, setEditObjId] = useState<string | null>(null);
|
|
const [defaultProduct, setDefaultProduct] = useState<string>("");
|
|
|
|
const fetchList = useCallback(async (product?: string) => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await wbsTemplateApi.list(product || undefined);
|
|
// DataGrid row 키 — objid 없을 수 있어 인덱스 fallback (id 누락 시 모든 행이 selected 로 잡힘)
|
|
setRows(data.map((r, i) => ({ ...r, id: r.objid ?? `tpl-${i}` })) as any);
|
|
setCheckedIds([]);
|
|
} catch (e: any) {
|
|
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchList();
|
|
}, [fetchList]);
|
|
|
|
const handleSearch = () => fetchList(filterProduct);
|
|
const handleReset = () => {
|
|
setFilterProduct("");
|
|
fetchList();
|
|
};
|
|
|
|
// 등록 (wace btnRegist click — product 선택 필수)
|
|
const handleRegist = () => {
|
|
if (!filterProduct) {
|
|
toast.error("제품은 필수값입니다. 제품을 선택해 주세요.");
|
|
return;
|
|
}
|
|
setEditObjId(null);
|
|
setDefaultProduct(filterProduct);
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
// 수정 (wace fn_openWBSTaskListPopUp — WBS 폴더 컬럼 클릭)
|
|
const handleOpenEdit = (row: any) => {
|
|
setEditObjId(row.objid);
|
|
setDefaultProduct("");
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
// 삭제 (wace fn_delete — 체크된 행 다건 삭제)
|
|
const handleDelete = async () => {
|
|
if (checkedIds.length === 0) {
|
|
toast.error("선택된 대상이 없습니다.");
|
|
return;
|
|
}
|
|
if (!confirm("삭제하시겠습니까?")) return;
|
|
try {
|
|
const res = await wbsTemplateApi.remove(checkedIds);
|
|
toast.success(res?.msg ?? "삭제하였습니다.");
|
|
fetchList(filterProduct);
|
|
} catch (e: any) {
|
|
toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패");
|
|
}
|
|
};
|
|
|
|
// DataGrid 컬럼에 folder 클릭 핸들러 주입
|
|
const columns: DataGridColumn[] = GRID_COLUMNS.map((c) =>
|
|
c.key === "wbs_task_cnt" ? { ...c, onClick: handleOpenEdit } : c
|
|
);
|
|
|
|
// ─── 하단 통계 ──────────────────────────────────────────────
|
|
// 템플릿 건수 / WBS 작업 합계 / 평균 WBS 작업수
|
|
const templateSummary = useMemo(() => {
|
|
const count = rows.length;
|
|
const taskSum = rows.reduce((acc, r) => acc + Number((r as any).wbs_task_cnt || 0), 0);
|
|
const taskAvg = count === 0 ? 0 : taskSum / count;
|
|
const intFmt = (n: number) => n.toLocaleString();
|
|
return [
|
|
{ label: "템플릿 건수", value: intFmt(count), suffix: "건" },
|
|
{ label: "WBS 작업 합계", value: intFmt(taskSum) },
|
|
{ label: "평균 WBS 작업수", value: taskAvg.toFixed(1) },
|
|
];
|
|
}, [rows]);
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
|
<PageHeader
|
|
loading={loading}
|
|
onSearch={handleSearch}
|
|
onReset={handleReset}
|
|
actions={
|
|
<>
|
|
<Button size="sm" className="h-8 gap-1 text-xs" onClick={handleRegist}>
|
|
<Plus className="h-3.5 w-3.5" />등록
|
|
</Button>
|
|
<Button size="sm" variant="destructive" className="h-8 gap-1 text-xs" onClick={handleDelete} disabled={checkedIds.length === 0}>
|
|
<Trash2 className="h-3.5 w-3.5" />삭제
|
|
</Button>
|
|
</>
|
|
} />
|
|
|
|
<CompactFilterBar totalText={<>총 {rows.length.toLocaleString()}건</>}>
|
|
<CompactFilterField label="제품구분" width={200}>
|
|
<CommCodeSelect
|
|
groupId={PRODUCT_GROUP}
|
|
value={filterProduct}
|
|
onValueChange={setFilterProduct}
|
|
/>
|
|
</CompactFilterField>
|
|
</CompactFilterBar>
|
|
|
|
<DataGrid
|
|
columns={columns}
|
|
data={rows}
|
|
loading={loading}
|
|
showRowNumber
|
|
showCheckbox
|
|
checkedIds={checkedIds}
|
|
onCheckedChange={setCheckedIds}
|
|
emptyMessage="등록된 WBS 템플릿이 없습니다."
|
|
gridId="project-wbs-template"
|
|
showColumnSettings
|
|
paginationStyle="range"
|
|
pageSizeOptions={[10, 15, 20, 50, 100]}
|
|
summaryStats={templateSummary}
|
|
systemColumnKeys={["writer_title", "reg_date_title"]}
|
|
onRefresh={() => fetchList(filterProduct)}
|
|
onDownload={() => {
|
|
if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
|
|
const exportRows = rows.map((r) => {
|
|
const out: Record<string, any> = {};
|
|
GRID_COLUMNS.forEach((col) => { out[col.label] = (r as any)[col.key] ?? ""; });
|
|
return out;
|
|
});
|
|
exportToExcel(exportRows, "WBS_템플릿.xlsx", "WBS_템플릿");
|
|
}}
|
|
showChart
|
|
/>
|
|
|
|
{/* 통합 팝업 */}
|
|
<WbsTemplateDialog
|
|
open={dialogOpen}
|
|
onOpenChange={setDialogOpen}
|
|
templateObjId={editObjId}
|
|
defaultProduct={defaultProduct}
|
|
onSaved={() => fetchList(filterProduct)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|