프로젝트관리 — 진행관리/WBS템플릿에 logicstudio 스타일 DataGrid 적용

영업관리(fc959d88)와 동일 패턴을 프로젝트관리 2개 메뉴로 확장:

공통 DataGrid props:
- gridId 는 기존(project-progress-wbslist3 / project-wbs-template) 그대로 유지
- showColumnSettings, paginationStyle="range", pageSizeOptions=[10,15,20,50,100]
- onRefresh = fetchList(혹은 fetchList(filterProduct)), onDownload = exportToExcel(라벨 매핑)
- showChart

도메인별 summaryStats:
- 진행관리: 프로젝트 건수 / 수주수량 합계 / 입고율 평균(% 문자열 파싱)
- WBS 템플릿: 템플릿 건수 / WBS 작업 합계 / 평균 WBS 작업수

WBS 템플릿 systemColumnKeys: writer_title, reg_date_title (등록자/등록일 시스템 영역)

컬럼 폭 보정:
- ⋮⋮ 드래그 핸들 추가로 좁아진 4글자 한국어/영문 라벨 95~120px 로 확대
- 진행관리: 주문유형·제품구분·국내/해외·요청납기·E-BOM·M-BOM·제조1,2팀·제조3팀 등
- WBS: WBS 컬럼 100→115, 등록일 130→140

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-14 14:56:54 +09:00
parent fc959d8872
commit 6a1813719a
2 changed files with 84 additions and 19 deletions
@@ -22,6 +22,7 @@ import { PageHeader } from "@/components/common/PageHeader";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar"; import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { ProjectInfoDialog, ProjectInfoData } from "@/components/project/ProjectInfoDialog"; import { ProjectInfoDialog, ProjectInfoData } from "@/components/project/ProjectInfoDialog";
import { projectMgmtApi, ProgressListFilter, ProgressRow } from "@/lib/api/projectMgmt"; import { projectMgmtApi, ProgressListFilter, ProgressRow } from "@/lib/api/projectMgmt";
import { exportToExcel } from "@/lib/utils/excelExport";
// 진행관리 row → 정규화된 ProjectInfoData 매핑 // 진행관리 row → 정규화된 ProjectInfoData 매핑
const toProjectInfo = (r: ProgressRow): ProjectInfoData => ({ const toProjectInfo = (r: ProgressRow): ProjectInfoData => ({
@@ -45,32 +46,32 @@ const toProjectInfo = (r: ProgressRow): ProjectInfoData => ({
const GRID_COLUMNS: DataGridColumn[] = [ const GRID_COLUMNS: DataGridColumn[] = [
{ key: "project_no", label: "프로젝트번호", width: "w-[160px]", frozen: true }, { key: "project_no", label: "프로젝트번호", width: "w-[160px]", frozen: true },
// 프로젝트정보 그룹 // 프로젝트정보 그룹
{ key: "category_name", label: "주문유형", width: "w-[90px]", align: "center" }, { key: "category_name", label: "주문유형", width: "w-[115px]", align: "center" },
{ key: "product_name", label: "제품구분", width: "w-[100px]", align: "left" }, { key: "product_name", label: "제품구분", width: "w-[115px]", align: "left" },
{ key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" }, { key: "area_name", label: "국내/해외", width: "w-[115px]", align: "center" },
{ key: "reg_date", label: "접수일", width: "w-[110px]", align: "center" }, { key: "reg_date", label: "접수일", width: "w-[115px]", align: "center" },
{ key: "customer_name", label: "고객사", width: "w-[140px]" }, { key: "customer_name", label: "고객사", width: "w-[140px]" },
{ key: "free_of_charge", label: "유/무상", width: "w-[80px]", align: "center" }, { key: "free_of_charge", label: "유/무상", width: "w-[100px]", align: "center" },
{ key: "product_item_code", label: "품번", width: "w-[150px]" }, { key: "product_item_code", label: "품번", width: "w-[150px]" },
{ key: "product_item_name", label: "품명", width: "w-[180px]" }, { key: "product_item_name", label: "품명", width: "w-[180px]" },
{ key: "serial_no", label: "S/N", width: "w-[150px]" }, { key: "serial_no", label: "S/N", width: "w-[150px]" },
{ key: "contract_qty", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true }, { key: "contract_qty", label: "수주수량", width: "w-[115px]", align: "right", formatNumber: true },
{ key: "req_del_date", label: "요청납기", width: "w-[110px]", align: "center" }, { key: "req_del_date", label: "요청납기", width: "w-[115px]", align: "center" },
// 설계 // 설계
{ key: "ebom_status", label: "E-BOM", width: "w-[100px]", align: "center" }, { key: "ebom_status", label: "E-BOM", width: "w-[115px]", align: "center" },
// 생산관리 // 생산관리
{ key: "mbom_status", label: "M-BOM", width: "w-[100px]", align: "center" }, { key: "mbom_status", label: "M-BOM", width: "w-[115px]", align: "center" },
// 구매 // 구매
{ key: "order_date", label: "발주일", width: "w-[110px]", align: "center" }, { key: "order_date", label: "발주일", width: "w-[115px]", align: "center" },
{ key: "receiving_rate", label: "입고율", width: "w-[90px]", align: "right" }, { key: "receiving_rate", label: "입고율", width: "w-[100px]", align: "right" },
// 생산 // 생산
{ key: "production_team_12", label: "제조1,2팀", width: "w-[100px]", align: "center" }, { key: "production_team_12", label: "제조1,2팀", width: "w-[120px]", align: "center" },
{ key: "production_team_3", label: "제조3팀", width: "w-[100px]", align: "center" }, { key: "production_team_3", label: "제조3팀", width: "w-[115px]", align: "center" },
// 장비 // 장비
{ key: "assembly", label: "조립", width: "w-[90px]", align: "center" }, { key: "assembly", label: "조립", width: "w-[100px]", align: "center" },
{ key: "verification", label: "검증", width: "w-[90px]", align: "center" }, { key: "verification", label: "검증", width: "w-[100px]", align: "center" },
// 출하 // 출하
{ key: "shipment_date", label: "출하일", width: "w-[110px]", align: "center" }, { key: "shipment_date", label: "출하일", width: "w-[115px]", align: "center" },
]; ];
const CATEGORY_GROUP = "0000167"; // 주문유형 const CATEGORY_GROUP = "0000167"; // 주문유형
@@ -145,6 +146,24 @@ export default function ProjectProgressPage() {
setTimeout(() => fetchList(), 0); setTimeout(() => fetchList(), 0);
}; };
// ─── 하단 통계 ──────────────────────────────────────────────
// 프로젝트 건수 / 수주수량 합계 / 입고율 평균
const progressSummary = useMemo(() => {
const count = rows.length;
const qtySum = rows.reduce((acc, r) => acc + Number(r.contract_qty || 0), 0);
// 입고율은 "85%" 같은 문자열 가능 → 숫자만 추출
const rateNums = rows
.map((r) => parseFloat(String((r as any).receiving_rate ?? "").replace(/[^0-9.]/g, "")))
.filter((n) => !Number.isNaN(n));
const rateAvg = rateNums.length === 0 ? 0 : rateNums.reduce((a, b) => a + b, 0) / rateNums.length;
const intFmt = (n: number) => n.toLocaleString();
return [
{ label: "프로젝트 건수", value: intFmt(count), suffix: "건" },
{ label: "수주수량 합계", value: intFmt(qtySum) },
{ label: "입고율 평균", value: rateAvg.toFixed(1), suffix: "%" },
];
}, [rows]);
return ( return (
<div className="flex flex-col h-full gap-2 p-2"> <div className="flex flex-col h-full gap-2 p-2">
<PageHeader <PageHeader
@@ -245,6 +264,21 @@ export default function ProjectProgressPage() {
showRowNumber showRowNumber
emptyMessage="조건에 맞는 프로젝트가 없습니다." emptyMessage="조건에 맞는 프로젝트가 없습니다."
gridId="project-progress-wbslist3" gridId="project-progress-wbslist3"
showColumnSettings
paginationStyle="range"
pageSizeOptions={[10, 15, 20, 50, 100]}
summaryStats={progressSummary}
onRefresh={fetchList}
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, "진행관리.xlsx", "진행관리");
}}
showChart
/> />
</div> </div>
@@ -10,7 +10,7 @@
// 검색: 제품구분 단일 // 검색: 제품구분 단일
// 등록/수정 통합 다이얼로그: WbsTemplateDialog (wace WBSExcelImportPopUp.jsp 1:1) // 등록/수정 통합 다이얼로그: WbsTemplateDialog (wace WBSExcelImportPopUp.jsp 1:1)
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -20,6 +20,7 @@ import { PageHeader } from "@/components/common/PageHeader";
import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar"; import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar";
import { wbsTemplateApi, TemplateRow } from "@/lib/api/wbsTemplate"; import { wbsTemplateApi, TemplateRow } from "@/lib/api/wbsTemplate";
import { WbsTemplateDialog } from "@/components/project/WbsTemplateDialog"; import { WbsTemplateDialog } from "@/components/project/WbsTemplateDialog";
import { exportToExcel } from "@/lib/utils/excelExport";
const PRODUCT_GROUP = "0000001"; // 제품구분 const PRODUCT_GROUP = "0000001"; // 제품구분
@@ -29,12 +30,12 @@ const GRID_COLUMNS: DataGridColumn[] = [
{ {
key: "wbs_task_cnt", key: "wbs_task_cnt",
label: "WBS", label: "WBS",
width: "w-[100px]", width: "w-[115px]",
align: "center", align: "center",
renderType: "folder", // wace fnc_getFolderIcon renderType: "folder", // wace fnc_getFolderIcon
}, },
{ key: "writer_title", label: "등록자", width: "w-[180px]" }, { key: "writer_title", label: "등록자", width: "w-[180px]" },
{ key: "reg_date_title", label: "등록일", width: "w-[130px]", align: "center" }, { key: "reg_date_title", label: "등록일", width: "w-[140px]", align: "center" },
]; ];
export default function WbsTemplatePage() { export default function WbsTemplatePage() {
@@ -110,6 +111,20 @@ export default function WbsTemplatePage() {
c.key === "wbs_task_cnt" ? { ...c, onClick: handleOpenEdit } : 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 ( return (
<div className="flex flex-col h-full gap-2 p-2"> <div className="flex flex-col h-full gap-2 p-2">
<PageHeader <PageHeader
@@ -148,6 +163,22 @@ export default function WbsTemplatePage() {
onCheckedChange={setCheckedIds} onCheckedChange={setCheckedIds}
emptyMessage="등록된 WBS 템플릿이 없습니다." emptyMessage="등록된 WBS 템플릿이 없습니다."
gridId="project-wbs-template" 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
/> />
</div> </div>