프로젝트관리 — DataGrid 행 id 매핑 누락 + selectedId 가드

증상:
프로젝트관리 진행관리/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>
This commit is contained in:
hjjeong
2026-05-14 15:12:34 +09:00
parent 6a1813719a
commit 350ddcd3b8
3 changed files with 60 additions and 59 deletions
@@ -126,7 +126,8 @@ export default function ProjectProgressPage() {
setLoading(true); setLoading(true);
try { try {
const data = await projectMgmtApi.list(filter); const data = await projectMgmtApi.list(filter);
setRows(data); // DataGrid row 키 — objid 없을 수 있어 인덱스 fallback (id 누락 시 모든 행이 selected 로 잡힘)
setRows(data.map((r, i) => ({ ...r, id: r.objid ?? `prog-${i}` })) as any);
} catch (e: any) { } catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally { setLoading(false); } } finally { setLoading(false); }
@@ -165,7 +166,7 @@ export default function ProjectProgressPage() {
}, [rows]); }, [rows]);
return ( return (
<div className="flex flex-col h-full gap-2 p-2"> <div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader <PageHeader
loading={loading} loading={loading}
onSearch={fetchList} onSearch={fetchList}
@@ -256,31 +257,29 @@ export default function ProjectProgressPage() {
</CompactFilterField> </CompactFilterField>
</CompactFilterBar> </CompactFilterBar>
<div className="flex-1 min-h-0"> <DataGrid
<DataGrid columns={columns}
columns={columns} data={rows}
data={rows} loading={loading}
loading={loading} showRowNumber
showRowNumber emptyMessage="조건에 맞는 프로젝트가 없습니다."
emptyMessage="조건에 맞는 프로젝트가 없습니다." gridId="project-progress-wbslist3"
gridId="project-progress-wbslist3" showColumnSettings
showColumnSettings paginationStyle="range"
paginationStyle="range" pageSizeOptions={[10, 15, 20, 50, 100]}
pageSizeOptions={[10, 15, 20, 50, 100]} summaryStats={progressSummary}
summaryStats={progressSummary} onRefresh={fetchList}
onRefresh={fetchList} onDownload={() => {
onDownload={() => { if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } const exportRows = rows.map((r) => {
const exportRows = rows.map((r) => { const out: Record<string, any> = {};
const out: Record<string, any> = {}; GRID_COLUMNS.forEach((col) => { out[col.label] = (r as any)[col.key] ?? ""; });
GRID_COLUMNS.forEach((col) => { out[col.label] = (r as any)[col.key] ?? ""; }); return out;
return out; });
}); exportToExcel(exportRows, "진행관리.xlsx", "진행관리");
exportToExcel(exportRows, "진행관리.xlsx", "진행관리"); }}
}} showChart
showChart />
/>
</div>
<ProjectInfoDialog open={infoOpen} onOpenChange={setInfoOpen} data={infoData} /> <ProjectInfoDialog open={infoOpen} onOpenChange={setInfoOpen} data={infoData} />
</div> </div>
@@ -53,7 +53,8 @@ export default function WbsTemplatePage() {
setLoading(true); setLoading(true);
try { try {
const data = await wbsTemplateApi.list(product || undefined); const data = await wbsTemplateApi.list(product || undefined);
setRows(data); // DataGrid row 키 — objid 없을 수 있어 인덱스 fallback (id 누락 시 모든 행이 selected 로 잡힘)
setRows(data.map((r, i) => ({ ...r, id: r.objid ?? `tpl-${i}` })) as any);
setCheckedIds([]); setCheckedIds([]);
} catch (e: any) { } catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
@@ -126,7 +127,7 @@ export default function WbsTemplatePage() {
}, [rows]); }, [rows]);
return ( return (
<div className="flex flex-col h-full gap-2 p-2"> <div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader <PageHeader
loading={loading} loading={loading}
onSearch={handleSearch} onSearch={handleSearch}
@@ -152,35 +153,33 @@ export default function WbsTemplatePage() {
</CompactFilterField> </CompactFilterField>
</CompactFilterBar> </CompactFilterBar>
<div className="flex-1 min-h-0"> <DataGrid
<DataGrid columns={columns}
columns={columns} data={rows}
data={rows} loading={loading}
loading={loading} showRowNumber
showRowNumber showCheckbox
showCheckbox checkedIds={checkedIds}
checkedIds={checkedIds} onCheckedChange={setCheckedIds}
onCheckedChange={setCheckedIds} emptyMessage="등록된 WBS 템플릿이 없습니다."
emptyMessage="등록된 WBS 템플릿이 없습니다." gridId="project-wbs-template"
gridId="project-wbs-template" showColumnSettings
showColumnSettings paginationStyle="range"
paginationStyle="range" pageSizeOptions={[10, 15, 20, 50, 100]}
pageSizeOptions={[10, 15, 20, 50, 100]} summaryStats={templateSummary}
summaryStats={templateSummary} systemColumnKeys={["writer_title", "reg_date_title"]}
systemColumnKeys={["writer_title", "reg_date_title"]} onRefresh={() => fetchList(filterProduct)}
onRefresh={() => fetchList(filterProduct)} onDownload={() => {
onDownload={() => { if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } const exportRows = rows.map((r) => {
const exportRows = rows.map((r) => { const out: Record<string, any> = {};
const out: Record<string, any> = {}; GRID_COLUMNS.forEach((col) => { out[col.label] = (r as any)[col.key] ?? ""; });
GRID_COLUMNS.forEach((col) => { out[col.label] = (r as any)[col.key] ?? ""; }); return out;
return out; });
}); exportToExcel(exportRows, "WBS_템플릿.xlsx", "WBS_템플릿");
exportToExcel(exportRows, "WBS_템플릿.xlsx", "WBS_템플릿"); }}
}} showChart
showChart />
/>
</div>
{/* 통합 팝업 */} {/* 통합 팝업 */}
<WbsTemplateDialog <WbsTemplateDialog
+4 -1
View File
@@ -896,7 +896,10 @@ export function DataGrid({
</TableCell> </TableCell>
</TableRow> </TableRow>
) : paginatedData.map((row, rowIdx) => { ) : paginatedData.map((row, rowIdx) => {
const isSelected = selectedId === row.id || (showCheckbox && checkedIds.includes(row.id)); // selectedId 또는 row.id 가 nullish 면 비교 결과를 무조건 false 로 — 둘 다 undefined 일 때 `undefined === undefined` 가 true 가 되어 모든 행이 selected 로 잡히는 함정 차단
const isSelected =
(selectedId != null && row.id != null && selectedId === row.id) ||
(showCheckbox && row.id != null && checkedIds.includes(row.id));
// sticky 셀에 alpha 없는 단색 배경 사용 (반투명이면 뒤 셀이 비침). // sticky 셀에 alpha 없는 단색 배경 사용 (반투명이면 뒤 셀이 비침).
// selected → bg-accent / hover(non-selected) → group-hover로 muted 적용 / 기본 → bg-background // selected → bg-accent / hover(non-selected) → group-hover로 muted 적용 / 기본 → bg-background
const stickyBgClass = isSelected ? "bg-accent" : "bg-background group-hover:bg-muted"; const stickyBgClass = isSelected ? "bg-accent" : "bg-background group-hover:bg-muted";