Merge branch 'iipbs/st/dev'

This commit is contained in:
2026-07-16 18:26:24 +09:00
15 changed files with 156 additions and 32 deletions
@@ -187,7 +187,7 @@ function WithBaseLayoutInner({ children }: { children: React.ReactNode }) {
const handleMenuClick = useCallback(
(item: MenuItem) => {
if (item.urlPath.includes("/cms/")) return;
// if (item.urlPath.includes("/cms/")) return;
setCurrentMenuInfo(item);
+7 -2
View File
@@ -1,3 +1,6 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import {
ACCESS_TOKEN,
AUTH_USER_ID,
@@ -6,8 +9,6 @@ import {
SERVER_API_URL,
SITE_ID,
} from "@/shared/config";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
async function handler(
request: Request,
@@ -19,6 +20,10 @@ async function handler(
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
};
const deviceId = request.headers.get("X-Device-Id");
if (deviceId) {
headers["X-Device-Id"] = deviceId;
}
let body = {};
const isRefreshHandler = params.url.includes("refresh");
+1
View File
@@ -57,6 +57,7 @@
"swr": "^2.3.7",
"tailwindcss-animate": "^1.0.7",
"tus-js-client": "^4.3.1",
"uuid": "^14.0.1",
"xlsx": "^0.18.5",
"zod": "^3.24.2",
"zustand": "^5.0.3"
@@ -1,12 +1,21 @@
import { Button } from "@common/components";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
interface LastYearDataButtonProps {
getLastYearDataFunc: () => void;
}
const LastYearDataButton = ({ getLastYearDataFunc }: LastYearDataButtonProps) => {
const { confirm } = useConfirm();
const handleLastYearDataButton = async () => {
if (await confirm('작년 데이터를 불러오시겠습니까?')) {
getLastYearDataFunc();
}
};
return (
<Button color="emerald" className="h-[3.2rem] w-[176px]" onClick={getLastYearDataFunc}>
<Button color="emerald" className="h-[3.2rem] w-[176px]" onClick={handleLastYearDataButton}>
<i className="diveicon di-search" />
<span>지난연도 데이터 가져오기</span>
</Button>
+20 -9
View File
@@ -794,7 +794,24 @@ const DataGrid = ({
onCellEditedRef.current = onCellEdited;
onDataCellClickRef.current = onDataCellClick;
// rowData 변경 시 편집 중인 셀을 commit한 뒤 수동으로 setRows (RealGridReact에 rows prop을 넘기지 않음)
const rowDataMountedRef = useRef(false);
useEffect(() => {
if (!rowDataMountedRef.current) {
rowDataMountedRef.current = true;
return;
}
const gv = gridRef.current?.gridView as
| { commit?: (force?: boolean) => boolean; cancel?: () => void }
| undefined;
const dp = gridRef.current?.dataProvider as
| { setRows?: (rows: Record<string, string | number>[]) => void }
| undefined;
if (!dp?.setRows) return;
try { gv?.commit?.(); } catch { try { gv?.cancel?.(); } catch { /* noop */ } }
dp.setRows(rowData);
}, [rowData]);
// highlightedRowId가 변경될 때 해당 행으로 스크롤 및 선택
useEffect(() => {
if (!gridRef.current?.gridView || highlightedRowId == null) return;
@@ -1673,9 +1690,11 @@ useEffect(() => {
getRowCount(): number;
getValue(r: number, f: string): unknown;
};
commit?: (force?: boolean) => boolean;
}
| undefined;
if (!gv?.getDataSource) return [];
try { gv.commit?.(); } catch { /* ignore */ }
const ds = gv.getDataSource();
if (!ds) return [];
const cnt = ds.getRowCount();
@@ -1835,13 +1854,6 @@ useEffect(() => {
if (!gridRef.current?.gridView || !showHeaderCheckbox) return;
const gridView = gridRef.current.gridView as GridViewWithCheckApi;
const dataProvider = gridRef.current.dataProvider as
| { setRows?: (rows: Record<string, string | number>[]) => void }
| undefined;
if (dataProvider?.setRows) {
dataProvider.setRows(rowData);
}
const hasCheckedColumn = rowData.some((row) =>
Object.prototype.hasOwnProperty.call(row, "__checked"),
@@ -1989,7 +2001,6 @@ useEffect(() => {
ref={gridRef}
autoGenerateField={true}
layout={columnLayout}
rows={rowData}
onRGDataColumnsChanged={(grid) => {
const dp = gridRef.current?.dataProvider as
| {
@@ -148,6 +148,37 @@ const PUBLIC_SEWAGE_FACILITY_COLUMN_GUIDE_TAB_KEYS = Array.from(
new Set(PublicSewageFactInpColumnList.map((tab) => tab.tabKey).filter(Boolean)),
);
/** 위탁계약기간: `yyyymmdd~yyyymmdd` (빈 값 허용). 하이픈 형식은 정규화 */
function normalizePrxyCtrtCn(raw: unknown): string | null {
if (raw === null || raw === undefined) return null;
const s = String(raw).trim().replace(/\s/g, '');
if (s === '' || s === '~') return null;
const dashed = s.match(/^(\d{4})-(\d{2})-(\d{2})~(\d{4})-(\d{2})-(\d{2})$/);
if (dashed) {
return `${dashed[1]}${dashed[2]}${dashed[3]}~${dashed[4]}${dashed[5]}${dashed[6]}`;
}
return s;
}
function isValidPrxyCtrtCn(value: string | null): boolean {
if (value === null) return true;
if (!/^\d{8}~\d{8}$/.test(value)) return false;
const start = value.slice(0, 8);
const end = value.slice(9, 17);
return start <= end;
}
function validatePrxyCtrtCnInRows(rows: Record<string, string | number>[]): string | null {
for (let i = 0; i < rows.length; i++) {
const normalized = normalizePrxyCtrtCn(rows[i]?.prxyCtrtCn);
if (!isValidPrxyCtrtCn(normalized)) {
const facilityNm = String(rows[i]?.swtrmtFcltNm ?? '').trim() || `${i + 1}행`;
return `위탁계약기간은 yyyymmdd~yyyymmdd 형식으로 입력해 주세요.\n(대상: ${facilityNm})`;
}
}
return null;
}
// 하수도 통계 관리 > 06. 공공하수처리시설
const PublicSewageFacilityInp = () => {
const { setMessage: alert } = useAlertStore((state) => state);
@@ -220,6 +251,11 @@ const PublicSewageFacilityInp = () => {
alert('해당사항 없음을 체크해주세요.');
return;
}
const prxyCtrtCnError = validatePrxyCtrtCnInRows(currentData);
if (prxyCtrtCnError) {
alert(prxyCtrtCnError);
return;
}
const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList);
if (verificationBlocked) {
alert(verificationBlocked);
@@ -258,7 +294,7 @@ const PublicSewageFacilityInp = () => {
prcsPhysCpct: toNumber(d.prcsPhysCpct),
prcsBoilCpct: toNumber(d.prcsBoilCpct),
prcsAltdCpct: toNumber(d.prcsAltdCpct),
prxyCtrtCn: d.prxyCtrtCn === '' ? null : toNullableString(d.prxyCtrtCn),
prxyCtrtCn: normalizePrxyCtrtCn(d.prxyCtrtCn),
sftyAcdntEvntcsCnt: toNumber(d.sftyAcdntEvntcsCnt),
rmrk: toNullableString(d.rmrk),
}));
@@ -298,6 +334,12 @@ const PublicSewageFacilityInp = () => {
const currentData =
st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? [];
const prxyCtrtCnError = validatePrxyCtrtCnInRows(currentData);
if (prxyCtrtCnError) {
alert(prxyCtrtCnError);
return;
}
const submitList = treatAsNoApplicable ? [] : buildFacilitySaveList(currentData);
if (!treatAsNoApplicable && submitList.length === 0) {
alert('해당사항 없음을 체크해주세요.');
@@ -541,6 +583,11 @@ const PublicSewageFacilityInp = () => {
const treatAsNoApplicable = noApplicable || isNotApplicable;
const currentData =
st60001GridGetRowsRef.current?.() ?? rowDataMap.get('ST00060001') ?? [];
const prxyCtrtCnError = validatePrxyCtrtCnInRows(currentData);
if (prxyCtrtCnError) {
alert(prxyCtrtCnError);
return;
}
const saveList = treatAsNoApplicable ? [] : buildFacilitySaveList(currentData);
if (!treatAsNoApplicable && saveList.length === 0) {
alert('해당사항 없음을 체크해주세요.');
@@ -556,7 +603,7 @@ const PublicSewageFacilityInp = () => {
sggNm: lastSearchedValues.gugun === 'ALL' ? '' : lastSearchedValues.gugun ?? '',
sewerBzmnCd: lastSearchedValues.bsnmCd ?? '',
userId: userInfo.userUnqId,
superAdmin: isAdminInfo ? 'superAdmin' : '',
...(isAdminInfo ? { superAdmin: 'superAdmin' } : {}),
noApplicable,
saveList,
};
@@ -1670,7 +1670,7 @@ const SewagePipeInp = () => {
sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""),
sewerBzmnCd: values.bsnmCd ?? "",
userId: userInfo.userUnqId,
superAdmin: isAdminInfo ? "superAdmin" : "",
...(isAdminInfo ? { superAdmin: "superAdmin" } : {}),
saveList,
};
@@ -1750,7 +1750,7 @@ const SewagePipeInp = () => {
sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""),
sewerBzmnCd: values.bsnmCd ?? "",
userId: userInfo.userUnqId,
superAdmin: isAdminInfo ? "superAdmin" : "",
...(isAdminInfo ? { superAdmin: "superAdmin" } : {}),
saveList,
};
@@ -1835,7 +1835,7 @@ const SewagePipeInp = () => {
sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""),
sewerBzmnCd: values.bsnmCd ?? "",
userId: userInfo.userUnqId,
superAdmin: isAdminInfo ? "superAdmin" : "",
...(isAdminInfo ? { superAdmin: "superAdmin" } : {}),
saveList,
};
@@ -1898,7 +1898,7 @@ const SewagePipeInp = () => {
sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""),
sewerBzmnCd: values.bsnmCd ?? "",
userId: userInfo.userUnqId,
superAdmin: isAdminInfo ? "superAdmin" : "",
...(isAdminInfo ? { superAdmin: "superAdmin" } : {}),
saveList,
};
@@ -1977,7 +1977,7 @@ const SewagePipeInp = () => {
sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""),
sewerBzmnCd: values.bsnmCd ?? "",
userId: userInfo.userUnqId,
superAdmin: isAdminInfo ? "superAdmin" : "",
...(isAdminInfo ? { superAdmin: "superAdmin" } : {}),
saveList,
};
@@ -81,7 +81,7 @@ const RegistDialog = memo(
trgtStatsCd: detailData?.data.trgtStatsCd ?? '',
trgtStatsNm: detailData?.data.trgtStatsNm ?? '',
dcptDigtCnt: detailData?.data.dcptDigtCnt ?? 0,
dcptPrcsMthCd: detailData?.data.dcptPrcsMthCd ?? '',
dcptPrcsMthCd: detailData?.data.dcptPrcsMthCd ?? null,
useYn: detailData?.data.useYn ?? 'Y',
rgtrId: detailData?.data.rgtrId ?? '',
regDt: detailData?.data.regDt ?? '',
+13 -1
View File
@@ -233,7 +233,7 @@ export type ColumnConfig = {
datetimeFormat?: string;
/** RealGrid 셀 편집기 */
editor?: {
type?: "dropdown" | "list" | "date";
type?: "dropdown" | "list" | "date" | "text" | "line";
domainOnly?: boolean;
textReadOnly?: boolean;
commitOnSelect?: boolean;
@@ -242,6 +242,18 @@ export type ColumnConfig = {
datetimeFormat?: string;
values?: string[];
labels?: string[];
/** RealGrid 입력 마스크 (예: 위탁계약기간 `yyyymmdd~yyyymmdd`) */
mask?:
| string
| {
editMask?: string;
includedFormat?: boolean;
allowEmpty?: boolean;
placeHolder?: string;
overWrite?: boolean;
showInvalidFormatMessage?: boolean;
invalidFormatMessage?: string;
};
};
footer?:
| {
@@ -145,7 +145,20 @@ export const PublicSewageFactColumn = {
// 위탁현황
{ name: 'prxyBzentyNmCn', fieldName: 'prxyBzentyNmCn', header: { text: '위탁업체명' }, editable: false, styleName: 'rg-cell-readonly' },
{ name: 'cnsgnCstVl', fieldName: 'cnsgnCstVl', header: { text: '위탁비용(백만원/년)' }, editable: false, footer: footerDecimal1, valueType: ValueType.NUMBER, dataFormat: '#,##0.0', styleName: 'rg-cell-readonly' },
{ name: 'prxyCtrtCn', fieldName: 'prxyCtrtCn', header: { text: '위탁계약기간' } },
{
name: 'prxyCtrtCn',
fieldName: 'prxyCtrtCn',
header: { text: '위탁계약기간\n(yyyymmdd~yyyymmdd)' },
editor: {
mask: {
editMask: '00000000~00000000',
includedFormat: true,
allowEmpty: true,
placeHolder: 'yyyymmdd~yyyymmdd',
overWrite: true,
},
},
},
// 기타
{ name: 'intgPrcsFcltCn', fieldName: 'intgPrcsFcltCn', header: { text: '중심처리시설' }, editable: false, styleName: 'rg-cell-readonly' },
@@ -4,7 +4,7 @@ export const PrivateSewageTab1ColumnConfig = {
tabKey: "ST00080001",
tabLabel: "처리용량별 오수처리시설",
rowStartNum: 3,
fixedColumns: 3,
fixedColumns: 2,
columns: [
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false },
@@ -4,7 +4,7 @@ export const SewerageOrgCauseTab4ColumnConfig = {
tabKey: "ST00090004",
tabLabel: "원인자부담금",
rowStartNum: 3,
fixedColumns: 3,
fixedColumns: 1,
columns: [
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false },
@@ -4,7 +4,7 @@ export const SewerageUsageFeeTab3ColumnConfig = {
tabKey: "ST00090003",
tabLabel: "하수도 사용료",
rowStartNum: 3,
fixedColumns: 3,
fixedColumns: 1,
columns: [
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false },
+12 -6
View File
@@ -11,9 +11,9 @@ import {
getRemainingMs,
} from "@common/auth";
import { clearDeviceId, getOrCreateDeviceId } from "../lib/device-id";
import {
clearRefreshTimer,
removeToken,
setToken,
setupRefreshTimer,
tokenManager,
@@ -24,7 +24,10 @@ import { clearTabStorePersist } from "@/store/tab-store";
async function login(payload: AuthPayload): Promise<void> {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
"X-Device-Id": getOrCreateDeviceId(),
},
body: JSON.stringify(payload),
});
@@ -56,7 +59,12 @@ async function silentRefresh(): Promise<void> {
try {
const response = await fetch(
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
{ method: "PUT" },
{
method: "PUT",
headers: {
"X-Device-Id": getOrCreateDeviceId(),
},
},
);
const data = await response.json();
@@ -93,9 +101,7 @@ async function silentRefresh(): Promise<void> {
export async function logout(): Promise<void> {
tokenManager.clearAuthToken();
removeToken("site-id-key");
removeToken("site-theme-key");
clearDeviceId();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:logout"));
}
@@ -0,0 +1,20 @@
import { v4 as uuidv4 } from "uuid";
const DEVICE_ID_KEY = "x-device-id";
export function getOrCreateDeviceId(): string {
if (typeof window === "undefined") return "";
const existing = localStorage.getItem(DEVICE_ID_KEY);
if (existing) return existing;
const id = uuidv4();
localStorage.setItem(DEVICE_ID_KEY, id);
return id;
}
export function clearDeviceId(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(DEVICE_ID_KEY);
}