diff --git a/apps/st/app/(main)/(with-base-layout)/layout.tsx b/apps/st/app/(main)/(with-base-layout)/layout.tsx index 16baba982..96468f9d5 100644 --- a/apps/st/app/(main)/(with-base-layout)/layout.tsx +++ b/apps/st/app/(main)/(with-base-layout)/layout.tsx @@ -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); diff --git a/apps/st/app/api/proxy/[...url]/route.ts b/apps/st/app/api/proxy/[...url]/route.ts index c9f0d3730..261d6b259 100644 --- a/apps/st/app/api/proxy/[...url]/route.ts +++ b/apps/st/app/api/proxy/[...url]/route.ts @@ -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"); diff --git a/apps/st/package.json b/apps/st/package.json index 7ae6c9f53..08f5bde6b 100644 --- a/apps/st/package.json +++ b/apps/st/package.json @@ -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" diff --git a/apps/st/src/components/button/LastYearDataButton.tsx b/apps/st/src/components/button/LastYearDataButton.tsx index d9020c4ae..c0cc89f3b 100644 --- a/apps/st/src/components/button/LastYearDataButton.tsx +++ b/apps/st/src/components/button/LastYearDataButton.tsx @@ -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 ( - diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index 06e0882eb..c910b104e 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -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[]) => 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[]) => 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 | { diff --git a/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx b/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx index 685cea13c..33461faf7 100644 --- a/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx +++ b/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx @@ -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 | 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, }; diff --git a/apps/st/src/components/statistics/input/SewagePipeInp.tsx b/apps/st/src/components/statistics/input/SewagePipeInp.tsx index d49ba291a..57d3fc8e1 100644 --- a/apps/st/src/components/statistics/input/SewagePipeInp.tsx +++ b/apps/st/src/components/statistics/input/SewagePipeInp.tsx @@ -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, }; diff --git a/apps/st/src/components/statistics/verification/generalVerification/RegistDialog.tsx b/apps/st/src/components/statistics/verification/generalVerification/RegistDialog.tsx index 518f2da32..84c24aec4 100644 --- a/apps/st/src/components/statistics/verification/generalVerification/RegistDialog.tsx +++ b/apps/st/src/components/statistics/verification/generalVerification/RegistDialog.tsx @@ -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 ?? '', diff --git a/apps/st/src/components/type/DataGrid.type.ts b/apps/st/src/components/type/DataGrid.type.ts index ea4769dcb..47d0179fc 100644 --- a/apps/st/src/components/type/DataGrid.type.ts +++ b/apps/st/src/components/type/DataGrid.type.ts @@ -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?: | { diff --git a/apps/st/src/components/util/column/input/PublicSewageFactInpColumn/PublicSewageFactColumn.ts b/apps/st/src/components/util/column/input/PublicSewageFactInpColumn/PublicSewageFactColumn.ts index d9943bbd6..82198b336 100644 --- a/apps/st/src/components/util/column/input/PublicSewageFactInpColumn/PublicSewageFactColumn.ts +++ b/apps/st/src/components/util/column/input/PublicSewageFactInpColumn/PublicSewageFactColumn.ts @@ -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' }, diff --git a/apps/st/src/components/util/column/total/sewerPrivateSewageColumn/SewerPrivateSewageTab1Column.ts b/apps/st/src/components/util/column/total/sewerPrivateSewageColumn/SewerPrivateSewageTab1Column.ts index c4af5d5a6..7d7d6c7ba 100644 --- a/apps/st/src/components/util/column/total/sewerPrivateSewageColumn/SewerPrivateSewageTab1Column.ts +++ b/apps/st/src/components/util/column/total/sewerPrivateSewageColumn/SewerPrivateSewageTab1Column.ts @@ -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 }, diff --git a/apps/st/src/components/util/column/total/sewerageFinancial/SewerageOrgCauseTab4Column.ts b/apps/st/src/components/util/column/total/sewerageFinancial/SewerageOrgCauseTab4Column.ts index af0afc329..279baed23 100644 --- a/apps/st/src/components/util/column/total/sewerageFinancial/SewerageOrgCauseTab4Column.ts +++ b/apps/st/src/components/util/column/total/sewerageFinancial/SewerageOrgCauseTab4Column.ts @@ -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 }, diff --git a/apps/st/src/components/util/column/total/sewerageFinancial/SewerageUsageFeeTab3Column.ts b/apps/st/src/components/util/column/total/sewerageFinancial/SewerageUsageFeeTab3Column.ts index 5bfe56b94..03ce956b5 100644 --- a/apps/st/src/components/util/column/total/sewerageFinancial/SewerageUsageFeeTab3Column.ts +++ b/apps/st/src/components/util/column/total/sewerageFinancial/SewerageUsageFeeTab3Column.ts @@ -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 }, diff --git a/apps/st/src/entities/auth/api/auth-service.ts b/apps/st/src/entities/auth/api/auth-service.ts index 48ead6a84..0c51d6dc3 100644 --- a/apps/st/src/entities/auth/api/auth-service.ts +++ b/apps/st/src/entities/auth/api/auth-service.ts @@ -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 { 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 { 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 { export async function logout(): Promise { tokenManager.clearAuthToken(); - removeToken("site-id-key"); - removeToken("site-theme-key"); - + clearDeviceId(); if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("auth:logout")); } diff --git a/apps/st/src/entities/auth/lib/device-id.ts b/apps/st/src/entities/auth/lib/device-id.ts new file mode 100644 index 000000000..a3b1aa800 --- /dev/null +++ b/apps/st/src/entities/auth/lib/device-id.ts @@ -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); +}