st: feat: 진행상황 정보 표시

This commit is contained in:
2026-02-23 10:03:37 +09:00
parent 4ff9b8e793
commit 103efe79d3
4 changed files with 111 additions and 163 deletions
+12
View File
@@ -21,6 +21,18 @@
@apply !bg-[#e3eaf2];
}
/* 진행상황: 소프트 인라인 요약 (툴바와 자연스럽게 blend) */
.data-grid-status-bubble {
@apply rounded-lg border border-slate-200/80 bg-slate-50/90 px-4 py-2.5;
box-shadow: none;
}
.data-grid-status-bubble span {
@apply text-base text-slate-700;
}
.data-grid-status-bubble span + span {
@apply ml-4 border-l border-slate-200/70 pl-4;
}
[data-theme="black"] {
.rg-grid .rg-header .rg-table .rg-header-group-cell {
@apply !bg-[#3C4043] !text-[#fff];
+70 -157
View File
@@ -86,110 +86,6 @@ function toNum(v: unknown): number {
return parseFloat(String(v).replace(/,/g, "")) || NaN;
}
/** 헤더에 정렬 화살표 표시 (CSS 방식) */
function updateHeaderSortIndicator(
gridView: {
columnByIndex?: (
i: number,
) => { fieldName?: string; name?: string } | undefined;
columnByName?: (
name: string,
) => { fieldName?: string; name?: string } | undefined;
getColumn?: (
i: number,
) => { fieldName?: string; name?: string } | undefined;
},
columns: { fieldName?: string; name: string }[],
sortDir: Record<string, "asc" | "desc" | "none">,
) {
try {
columns.forEach((col) => {
if (!col.fieldName && !col.name) return;
// 컬럼 찾기
let gridCol: { fieldName?: string; name?: string } | undefined;
for (let i = 0; i < 100; i++) {
const c =
gridView.columnByIndex?.(i) ?? gridView.getColumn?.(i);
if (!c) break;
if (
c.fieldName === (col.fieldName || col.name) ||
c.name === (col.fieldName || col.name)
) {
gridCol = c;
break;
}
}
if (!gridCol) {
gridCol = gridView.columnByName?.(col.name);
}
if (!gridCol) return;
const fieldName =
gridCol.fieldName || gridCol.name || col.fieldName || col.name;
const direction = sortDir[fieldName] || sortDir[col.name] || "none";
// DOM에서 헤더 요소 찾기
const gridEl1 = document.querySelector(
"[data-grid-id]",
) as HTMLElement | null;
const gridEl2 = document.querySelector(
".rg-grid",
) as HTMLElement | null;
const gridEl = gridEl1 || gridEl2;
if (!gridEl) return;
// 헤더 셀 찾기 (fieldName 또는 name으로)
const headerCells = gridEl.querySelectorAll(
'.rg-header-cell, [class*="header"]',
);
let targetCell: HTMLElement | null = null;
headerCells.forEach((cell) => {
const el = cell as HTMLElement;
const text = el.textContent?.trim();
const colName = col.name;
const colFieldName = col.fieldName;
if (
text &&
(text.includes(colName) ||
(colFieldName && text.includes(colFieldName)))
) {
targetCell = el;
}
});
if (!targetCell) return;
const headerCell = targetCell as HTMLElement;
// 기존 화살표 제거
const existingIndicator = headerCell.querySelector(
".rg-sort-indicator",
) as HTMLElement | null;
if (existingIndicator) {
existingIndicator.remove();
}
// 화살표 추가
if (direction === "asc" || direction === "desc") {
const indicator = document.createElement("span");
indicator.className = "rg-sort-indicator";
indicator.textContent = direction === "asc" ? "▲" : "▼";
indicator.style.marginLeft = "4px";
indicator.style.fontSize = "10px";
indicator.style.color = "#666";
indicator.style.display = "inline-block";
indicator.style.verticalAlign = "middle";
headerCell.appendChild(indicator);
}
});
} catch (e) {
console.warn("updateHeaderSortIndicator error:", e);
}
}
/** onSorting: mergeCells: true인 컬럼이 있으면 그룹 내 순서 유지 정렬, 없으면 RealGrid 기본 정렬 */
function createOnSortingHandler(
columns: {
@@ -416,6 +312,7 @@ const DataGrid = ({
getGridDataRef,
cellStyleCallback,
onRowClick,
statusInfo,
}: DataGridProps) => {
const { columns, columnLayout } = columnInfo;
const gridRef = useRef<RealGridReact>(null);
@@ -624,10 +521,10 @@ const DataGrid = ({
) {
const cell =
worksheet[
XLSX.utils.encode_cell({
r: rowIndex,
c: col,
})
XLSX.utils.encode_cell({
r: rowIndex,
c: col,
})
];
if (
cell &&
@@ -703,7 +600,7 @@ const DataGrid = ({
const col = range.s.c + colIndex;
const cell =
worksheet[
XLSX.utils.encode_cell({ r: row, c: col })
XLSX.utils.encode_cell({ r: row, c: col })
];
const headerName =
headers[colIndex] || `Column${colIndex + 1}`;
@@ -999,7 +896,7 @@ const DataGrid = ({
) {
(
column.footer[
index
index
] as FooterItemWithCallback
).valueCallback =
footerItem.valueCallback;
@@ -1050,11 +947,11 @@ const DataGrid = ({
getGridDataRef.current = () => {
const gv = gridRef.current?.gridView as
| {
getDataSource?: () => {
getRowCount(): number;
getValue(r: number, f: string): unknown;
};
}
getDataSource?: () => {
getRowCount(): number;
getValue(r: number, f: string): unknown;
};
}
| undefined;
if (!gv?.getDataSource) return [];
const ds = gv.getDataSource();
@@ -1126,6 +1023,22 @@ const DataGrid = ({
onChange={handleFileChange}
/>
<div className="flex items-center justify-end gap-[0.6rem] px-[1.2rem]">
{statusInfo &&
<div className="data-grid-status-bubble flex flex-wrap items-center gap-x-4 gap-y-1">
<span>진행상황: {
statusInfo.locgStatus === null || statusInfo.locgStatus === '0' ?
'미작성'
:
statusInfo.locgStatus === '1' ? '작성중' : statusInfo.locgStatus === '2' ? '제출'
:
statusInfo.locgStatus === '3' ? '승인' : '반려'
}
</span>
<span>제출일: {statusInfo.locgDt ? statusInfo.locgDt : '미제출'}</span>
<span>담당자: {statusInfo.locgCharger ? statusInfo.locgCharger : '-'}</span>
<span>연락처: {statusInfo.locgCttpc ? statusInfo.locgCttpc : '-'}</span>
</div>
}
{isExcelImport && (
<Button
className="h-[3.2rem] w-[96px] bg-gradient-green px-6 hover:bg-gradient-green-hover"
@@ -1148,48 +1061,48 @@ const DataGrid = ({
)}
</div>
<div className="h-[65vh]">
<RealGridReact
ref={gridRef}
autoGenerateField={true}
layout={columnLayout}
rows={rowData}
onInitialized={(grid) => {
if (!grid) return;
(grid as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef);
const dataProvider = gridRef.current?.dataProvider as unknown as
| { setFields?(f: unknown[]): void; setRows?(r: Record<string, string | number>[]): void; getJsonRow?(r: number): Record<string, unknown> }
| undefined;
if (dataProvider?.setFields) {
dataProvider.setFields(buildFieldsFromColumns(columns));
}
if (dataProvider?.setRows && rowData?.length) {
dataProvider.setRows(rowData);
}
const gridView = gridRef.current?.gridView ?? grid;
if (!gridView?.columnByName) return;
setTimeout(() => {
const gv = gridRef.current?.gridView;
if (!gv?.columnByName) return;
columns.forEach((column) => {
const baseStyleName = column.styleName;
const gridCol = gv.columnByName(column.name) as { styleCallback?: (g: unknown, dataCell: { itemIndex?: number; index?: { itemIndex?: number }; dataField?: string; fieldName?: string }) => string } | null;
if (gridCol) {
gridCol.styleCallback = (_g, dataCell) => {
const itemIndex = dataCell?.index?.itemIndex ?? dataCell?.itemIndex ?? -1;
const fieldName = dataCell?.dataField ?? dataCell?.fieldName ?? column.fieldName;
const data = rowDataRef.current;
if (itemIndex < 0 || itemIndex >= data.length) return baseStyleName ?? '';
const row = data[itemIndex] as Record<string, string | number>;
const extra = cellStyleCallbackRef.current?.({ rowIndex: itemIndex, fieldName, row });
if (!extra) return baseStyleName ?? '';
return baseStyleName ? `${extra} ${baseStyleName}` : extra;
};
}
});
gv.refresh();
}, 0);
}}
>
<RealGridReact
ref={gridRef}
autoGenerateField={true}
layout={columnLayout}
rows={rowData}
onInitialized={(grid) => {
if (!grid) return;
(grid as { onSorting?: (g: unknown, f: unknown[], d: unknown[]) => boolean }).onSorting = createOnSortingHandler(columns, setRowData, sortDirRef, originalRowsRef);
const dataProvider = gridRef.current?.dataProvider as unknown as
| { setFields?(f: unknown[]): void; setRows?(r: Record<string, string | number>[]): void; getJsonRow?(r: number): Record<string, unknown> }
| undefined;
if (dataProvider?.setFields) {
dataProvider.setFields(buildFieldsFromColumns(columns));
}
if (dataProvider?.setRows && rowData?.length) {
dataProvider.setRows(rowData);
}
const gridView = gridRef.current?.gridView ?? grid;
if (!gridView?.columnByName) return;
setTimeout(() => {
const gv = gridRef.current?.gridView;
if (!gv?.columnByName) return;
columns.forEach((column) => {
const baseStyleName = column.styleName;
const gridCol = gv.columnByName(column.name) as { styleCallback?: (g: unknown, dataCell: { itemIndex?: number; index?: { itemIndex?: number }; dataField?: string; fieldName?: string }) => string } | null;
if (gridCol) {
gridCol.styleCallback = (_g, dataCell) => {
const itemIndex = dataCell?.index?.itemIndex ?? dataCell?.itemIndex ?? -1;
const fieldName = dataCell?.dataField ?? dataCell?.fieldName ?? column.fieldName;
const data = rowDataRef.current;
if (itemIndex < 0 || itemIndex >= data.length) return baseStyleName ?? '';
const row = data[itemIndex] as Record<string, string | number>;
const extra = cellStyleCallbackRef.current?.({ rowIndex: itemIndex, fieldName, row });
if (!extra) return baseStyleName ?? '';
return baseStyleName ? `${extra} ${baseStyleName}` : extra;
};
}
});
gv.refresh();
}, 0);
}}
>
{columns.map((column) => {
const baseStyleName = column.styleName;
@@ -18,7 +18,8 @@ import type {
SewerageSupplyRateRequestParams,
SewerageSupplyRateSaveItem,
SewerageSupplyRateVerificationResult,
SewerageSupplyRateSaveRequest
SewerageSupplyRateSaveRequest,
SewerageSupplyRateStatusInfo,
} from "@/entities/statistics/model/statistics-input-interface";
import { SeweragePenetrationRateInpColumn } from "@/components/util/column/input/SeweragePenetrationRateInpColumn";
import { toGridRows } from "@/components/util/grid/gridFunction";
@@ -26,6 +27,10 @@ import { getFooterSummary } from "@/components/util/grid/footerSummary";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
type GridRowWithVerification = Record<string, string | number> & {
verificationResult?: SewerageSupplyRateVerificationResult;
};
function getSewerageSupplyRateParams(
values: SearchFormValues,
overrides?: { yr?: string; demoYr?: string; mode?: string }
@@ -47,6 +52,7 @@ const SeweragePenetrationRateInp = () => {
const [searchValues, setSearchValues] = useState<SearchFormValues | null>(null);
const [isEnterable, setIsEnterable] = useState<boolean>(true);
const [lastSearchedYear, setLastSearchedYear] = useState<string | null>(null);
const [statusInfo, setStatusInfo] = useState<SewerageSupplyRateStatusInfo | null>(null);
const { confirm } = useConfirm();
const [footerFromApi, setFooterFromApi] = useState<{ totalPopl?: number; poplSm?: number; frgnrPoplSm?: number } | null>(null);
@@ -77,6 +83,7 @@ const SeweragePenetrationRateInp = () => {
const handleReset = () => {
setRowData([]);
setStatusInfo(null);
setLastSearchedYear(null);
setFooterFromApi(null);
};
@@ -95,12 +102,14 @@ const SeweragePenetrationRateInp = () => {
}));
setRowData(
convertedData.map((d) => {
const row = toGridRows([d])[0] as Record<string, string | number> & { verificationResult?: SewerageSupplyRateVerificationResult };
const row = toGridRows([d])[0] as GridRowWithVerification;
if (d.verificationResult) row.verificationResult = d.verificationResult;
return row;
})
);
setStatusInfo(response.data.statusInfo);
setFooterFromApi(
commonInfo
? {
@@ -116,6 +125,7 @@ const SeweragePenetrationRateInp = () => {
} catch {
alert('데이터 조회 중 오류가 발생했습니다.');
setRowData([]);
setStatusInfo(null);
setFooterFromApi(null);
} finally {
setIsLoading(false);
@@ -164,7 +174,12 @@ const SeweragePenetrationRateInp = () => {
};
const submitFunc = () => {
// 제출 로직
const isError = rowData.filter((d) => d.verificationResult);
if (isError.length > 0) {
alert('검증 오류 데이터가 있습니다.');
return;
}
};
const saveFunc = async () => {
@@ -241,8 +256,13 @@ const SeweragePenetrationRateInp = () => {
}
const response = await statisticsInputAPi.saveSewerageSupplyRate(requestData);
console.log(convertedData)
console.log(response);
if (response.status === 'success') {
fetchSewerageSupplyRate(getSewerageSupplyRateParams(searchValues ?? {}));
alert('저장되었습니다.');
} else {
alert('데이터 저장 중 오류가 발생했습니다.');
}
} else {
// 저장 취소
}
@@ -283,9 +303,10 @@ const SeweragePenetrationRateInp = () => {
fixedColumns={1}
rowData={rowData}
setRowData={setRowData}
statusInfo={statusInfo}
getGridDataRef={getGridDataRef}
cellStyleCallback={({ row, fieldName }) => {
const verification = (row as any).verificationResult as SewerageSupplyRateVerificationResult | undefined;
const verification = (row as GridRowWithVerification).verificationResult;
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
@@ -1,5 +1,6 @@
import React from "react";
import { ValueType } from "realgrid";
import { type SewerageSupplyRateStatusInfo } from "@/entities/statistics/model/statistics-input-interface";
// RealGrid 타입 정의
/** setColumns에 전달하는 컬럼 정보 (footer 배열/단일 객체 포함) */
@@ -230,6 +231,7 @@ export type DataGridProps = {
current: (() => Record<string, string | number>[]) | null;
};
onRowClick?: (rowData: Record<string, string | number>) => void; // 행 클릭 이벤트
statusInfo?: SewerageSupplyRateStatusInfo | null;
};
export type TabDataGridProps = {