Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 6m32s

This commit is contained in:
2026-09-23 09:51:39 +09:00
289 changed files with 12245 additions and 19010 deletions
@@ -2,35 +2,35 @@ import { z } from "zod";
/** 백엔드 DcRagFileInfoDto */
export const dcRagFileInfoSchema = z.object({
fileId: z.string().optional(),
fileGroupId: z.string().optional(),
fileName: z.string().optional(),
physicalPath: z.string().optional(),
downloadUrl: z.string().optional(),
fileSize: z.number().optional(),
esntlAtchFileId: z.string().optional(),
esntlAtchFileNm: z.string().optional(),
fileId: z.string().nullish(),
fileGroupId: z.string().nullish(),
fileName: z.string().nullish(),
physicalPath: z.string().nullish(),
downloadUrl: z.string().nullish(),
fileSize: z.number().nullish(),
esntlAtchFileId: z.string().nullish(),
esntlAtchFileNm: z.string().nullish(),
});
/** 백엔드 DcRagDocumentTransferRequestDto */
export const dcRagDocumentTransferRequestSchema = z.object({
fnrsCnsltnDocNo: z.string(),
taskSeComCd: z.string().optional(),
taskSeCdNm: z.string().optional(),
essentialFiles: z.array(dcRagFileInfoSchema).optional(),
etcFiles: z.array(dcRagFileInfoSchema).optional(),
callbackUrl: z.string().optional(),
taskSeComCd: z.string().nullish(),
taskSeCdNm: z.string().nullish(),
essentialFiles: z.array(dcRagFileInfoSchema).nullish(),
etcFiles: z.array(dcRagFileInfoSchema).nullish(),
callbackUrl: z.string().nullish(),
});
/** 백엔드 DcRagDraftGenerateRequestDto */
export const dcRagDraftGenerateRequestSchema = z.object({
fnrsCnsltnDocNo: z.string(),
pwrkCnsltnDocNo: z.string(),
crtDmndUuid: z.string().optional(),
essentialFiles: z.array(dcRagFileInfoSchema).optional(),
etcFiles: z.array(dcRagFileInfoSchema).optional(),
rqstrUuid: z.string().optional(),
callbackUrl: z.string().optional(),
crtDmndUuid: z.string().nullish(),
essentialFiles: z.array(dcRagFileInfoSchema).nullish(),
etcFiles: z.array(dcRagFileInfoSchema).nullish(),
rqstrUuid: z.string().nullish(),
callbackUrl: z.string().nullish(),
});
/** 백엔드 DcRagCostVerifyRequestDto */
@@ -2,68 +2,68 @@ import { z } from "zod";
/** 백엔드 DesignCnstnMngSaveRequestDto */
export const designCnstnMngSaveSchema = z.object({
designCnstnDocNo: z.string().optional(),
admdstCd: z.string().optional(),
envoSeComCd: z.string().optional(),
taskSeComCd: z.string().optional(),
bizNm: z.string().optional(),
bizBgngYr: z.string().optional(),
bizEndYr: z.string().optional(),
bizCn: z.string().optional(),
aplcntNm: z.string().optional(),
aplcntMblTelno: z.string().optional(),
aplcntOgdpNm: z.string().optional(),
aplcntEmlAddr: z.string().optional(),
dsgnrNm: z.string().optional(),
dsgnrTelno: z.string().optional(),
dsgnrOgdpNm: z.string().optional(),
dsgnrEmlAddr: z.string().optional(),
atchFileGroupId: z.string().optional(),
prgrsSttsComCd: z.string().optional(),
designCnstnDocNo: z.string().nullish(),
admdstCd: z.string().nullish(),
envoSeComCd: z.string().nullish(),
taskSeComCd: z.string().nullish(),
bizNm: z.string().nullish(),
bizBgngYr: z.string().nullish(),
bizEndYr: z.string().nullish(),
bizCn: z.string().nullish(),
aplcntNm: z.string().nullish(),
aplcntMblTelno: z.string().nullish(),
aplcntOgdpNm: z.string().nullish(),
aplcntEmlAddr: z.string().nullish(),
dsgnrNm: z.string().nullish(),
dsgnrTelno: z.string().nullish(),
dsgnrOgdpNm: z.string().nullish(),
dsgnrEmlAddr: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
prgrsSttsComCd: z.string().nullish(),
});
/** 백엔드 DesignCnstnApplySaveRequestDto.BzScaleItem */
export const designCnstnBzScaleItemSchema = z.object({
cnstpSeCd: z.string().optional(),
bscPl: z.number().optional(),
budgetReflct: z.number().optional(),
thtiDsgn: z.number().optional(),
cnstpSeCd: z.string().nullish(),
bscPl: z.number().nullish(),
budgetReflct: z.number().nullish(),
thtiDsgn: z.number().nullish(),
});
/** 백엔드 DesignCnstnApplySaveRequestDto.BizCstItem */
export const designCnstnBizCstItemSchema = z.object({
acntgSeCd: z.string().optional(),
bscPlanAmt: z.number().optional(),
bgtRfltAmt: z.number().optional(),
tstmDesignAmt: z.number().optional(),
acntgSeCd: z.string().nullish(),
bscPlanAmt: z.number().nullish(),
bgtRfltAmt: z.number().nullish(),
tstmDesignAmt: z.number().nullish(),
});
/** 백엔드 DesignCnstnApplySaveRequestDto.EsntlAtchFileItem */
export const designCnstnEsntlAtchFileItemSchema = z.object({
esntlAtchFileId: z.string().optional(),
atchFileGroupId: z.string().optional(),
esntlAtchFileId: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 DesignCnstnRvwOpnnSaveRequestDto */
export const designCnstnRvwOpnnSaveSchema = z.object({
designCnstnDocNo: z.string(),
rvwOpnnSn: z.number().optional(),
opnrSeComCd: z.string().optional(),
opnrNm: z.string().optional(),
rvwRsltCn: z.string().optional(),
rvwRsltAnmlyYn: z.string().optional(),
atchFileGroupId: z.string().optional(),
rvwOpnnSn: z.number().nullish(),
opnrSeComCd: z.string().nullish(),
opnrNm: z.string().nullish(),
rvwRsltCn: z.string().nullish(),
rvwRsltAnmlyYn: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 DesignCnstnApplySaveRequestDto */
export const designCnstnApplySaveSchema = z.object({
techReViewSaveCd: z.string().optional(),
rvwStpCd: z.string().optional(),
techReViewSaveCd: z.string().nullish(),
rvwStpCd: z.string().nullish(),
designCnstnMng: designCnstnMngSaveSchema,
bzScaleList: z.array(designCnstnBzScaleItemSchema).optional(),
bizCstList: z.array(designCnstnBizCstItemSchema).optional(),
esntlAtchFileList: z.array(designCnstnEsntlAtchFileItemSchema).optional(),
rvwOpnnList: z.array(designCnstnRvwOpnnSaveSchema).optional(),
bzScaleList: z.array(designCnstnBzScaleItemSchema).nullish(),
bizCstList: z.array(designCnstnBizCstItemSchema).nullish(),
esntlAtchFileList: z.array(designCnstnEsntlAtchFileItemSchema).nullish(),
rvwOpnnList: z.array(designCnstnRvwOpnnSaveSchema).nullish(),
});
export type DesignCnstnMngSaveRequestDto = z.infer<typeof designCnstnMngSaveSchema>;
+1
View File
@@ -41,6 +41,7 @@ export {
validateAllRowsForSave,
validateAplnRow,
validateAtrzOrder,
validateUniqueAtrzAprvr,
validateUniqueSeqNo,
} from "./lib/apln-validators";
export {
@@ -76,3 +76,16 @@ export function validateUniqueSeqNo(rows: ElaprAplnRow[]): string | null {
}
return null;
}
export function validateUniqueAtrzAprvr(rows: ElaprAplnRow[]): string | null {
const pairSet = new Set<string>();
for (const row of getVisibleRows(rows)) {
const atrz = row.atrzSeComCd?.trim();
const aprvr = row.aprvrUuid?.trim();
if (!atrz || !aprvr) continue;
const key = `${atrz}::${aprvr}`;
if (pairSet.has(key)) return "동일한 결재구분과 결재자가 중복됩니다.";
pairSet.add(key);
}
return null;
}
@@ -7,8 +7,8 @@ export interface ElaprDmndSaveRequestDto {
elaprDocNo: string;
dmndTtl: string;
dmndCn: string;
elaprCycl?: number;
docPrgrsSttsComCd?: string;
elaprCycl?: number | null;
docPrgrsSttsComCd?: string | null;
elaprPrgrsSttsList?: ElaprPrgrsSttsSaveRequestDto[];
}
@@ -17,7 +17,7 @@ import {
} from "@common/components";
import { useElaprAplnPage } from "../api/queries";
import { validateAplnRow } from "../lib/apln-validators";
import { validateAplnRow, validateUniqueAtrzAprvr } from "../lib/apln-validators";
import { useAplnRows } from "../lib/use-apln-rows";
import { useDndSensors } from "../lib/use-dnd-sensors";
import type { ElaprApln } from "../model/elapr-apln-interface";
@@ -110,6 +110,12 @@ function ElaprAplnPickerGrid({
return;
}
const duplicateError = validateUniqueAtrzAprvr(rows);
if (duplicateError) {
alert(duplicateError);
return;
}
commitEdit(row.rowKey);
}
@@ -17,6 +17,7 @@ import {
getVisibleRows,
validateAllRowsForSave,
validateAtrzOrder,
validateUniqueAtrzAprvr,
validateUniqueSeqNo,
} from "../lib/apln-validators";
import { mapDocPrgrsToAplnRows } from "../lib/doc-prgrs-to-apln-rows";
@@ -90,6 +91,12 @@ function ElaprDocPrgrsPickerDialog({
return;
}
const atrzAprvrError = validateUniqueAtrzAprvr(aplnRows);
if (atrzAprvrError) {
alert(atrzAprvrError);
return;
}
const visibleRows = getVisibleRows(aplnRows);
onConfirm?.({ aplnRows: visibleRows });
@@ -17,6 +17,7 @@ import {
getVisibleRows,
validateAllRowsForSave,
validateAtrzOrder,
validateUniqueAtrzAprvr,
validateUniqueSeqNo,
} from "../lib/apln-validators";
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
@@ -106,6 +107,12 @@ function ElaprTmpltPickerDialog({
return;
}
const atrzAprvrError = validateUniqueAtrzAprvr(aplnRows);
if (atrzAprvrError) {
alert(atrzAprvrError);
return;
}
const visibleRows = getVisibleRows(aplnRows);
const payload: ElaprTmpltPickerConfirmPayload = {
@@ -2,52 +2,52 @@ import { z } from "zod";
/** 백엔드 FnrsCnsltnMngSaveRequestDto */
export const fnrsCnsltnMngSaveSchema = z.object({
fnrsCnsltnDocNo: z.string().optional(),
admdstCd: z.string().optional(),
envoSeComCd: z.string().optional(),
taskSeComCd: z.string().optional(),
cfmtnBgtBizNo: z.string().optional(),
bgtAplyYr: z.string().optional(),
bizNm: z.string().optional(),
bizBgngYr: z.string().optional(),
bizEndYr: z.string().optional(),
bizCn: z.string().optional(),
aplcntNm: z.string().optional(),
aplcntMblTelno: z.string().optional(),
aplcntOgdpNm: z.string().optional(),
aplcntEmlAddr: z.string().optional(),
dsgnrNm: z.string().optional(),
dsgnrTelno: z.string().optional(),
dsgnrOgdpNm: z.string().optional(),
dsgnrEmlAddr: z.string().optional(),
atchFileGroupId: z.string().optional(),
sharAtchFileGroupId: z.string().optional(),
prgrsSttsComCd: z.string().optional(),
rtlztnFcltSeComCd: z.string().optional(),
rtlztnUsgSeComCd: z.string().optional(),
fnrsCnsltnDocNo: z.string().nullish(),
admdstCd: z.string().nullish(),
envoSeComCd: z.string().nullish(),
taskSeComCd: z.string().nullish(),
cfmtnBgtBizNo: z.string().nullish(),
bgtAplyYr: z.string().nullish(),
bizNm: z.string().nullish(),
bizBgngYr: z.string().nullish(),
bizEndYr: z.string().nullish(),
bizCn: z.string().nullish(),
aplcntNm: z.string().nullish(),
aplcntMblTelno: z.string().nullish(),
aplcntOgdpNm: z.string().nullish(),
aplcntEmlAddr: z.string().nullish(),
dsgnrNm: z.string().nullish(),
dsgnrTelno: z.string().nullish(),
dsgnrOgdpNm: z.string().nullish(),
dsgnrEmlAddr: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
sharAtchFileGroupId: z.string().nullish(),
prgrsSttsComCd: z.string().nullish(),
rtlztnFcltSeComCd: z.string().nullish(),
rtlztnUsgSeComCd: z.string().nullish(),
});
/** 백엔드 FnrsCnsltnApplySaveRequestDto.BzScaleItem */
export const fnrsCnsltnBzScaleItemSchema = z.object({
cnstpSeCd: z.string().optional(),
bscPl: z.number().optional(),
budgetReflct: z.number().optional(),
thtiDsgn: z.number().optional(),
cnstpSeCd: z.string().nullish(),
bscPl: z.number().nullish(),
budgetReflct: z.number().nullish(),
thtiDsgn: z.number().nullish(),
});
/** 백엔드 FnrsCnsltnApplySaveRequestDto.BizCstItem */
export const fnrsCnsltnBizCstItemSchema = z.object({
acntgSeCd: z.string().optional(),
bscPlanAmt: z.number().optional(),
bgtRfltAmt: z.number().optional(),
tstmDesignAmt: z.number().optional(),
acntgSeCd: z.string().nullish(),
bscPlanAmt: z.number().nullish(),
bgtRfltAmt: z.number().nullish(),
tstmDesignAmt: z.number().nullish(),
});
/** 백엔드 FnrsCnsltnApplySaveRequestDto.CklstResultItem */
export const fnrsCnsltnCklstResultItemSchema = z.object({
cklstRvwSn: z.number().optional(),
cklstArtclSn: z.number().optional(),
cklstSn: z.number().optional(),
cklstRvwSn: z.number().nullish(),
cklstArtclSn: z.number().nullish(),
cklstSn: z.number().nullish(),
rvwStpComCd: z.string().nullish(),
selected: z.enum(["Y", "N"]).nullish(),
picRvwCn: z.string().nullish(),
@@ -55,58 +55,58 @@ export const fnrsCnsltnCklstResultItemSchema = z.object({
/** 백엔드 FnrsCnsltnApplySaveRequestDto.EsntlAtchFileItem */
export const fnrsCnsltnEsntlAtchFileItemSchema = z.object({
esntlAtchFileId: z.string().optional(),
atchFileGroupId: z.string().optional(),
esntlAtchFileId: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 FnrsCnsltnApplySaveRequestDto.FnrsCnsltnSplmntDmndItem */
export const fnrsCnsltnSplmntDmndItemSchema = z.object({
splmntDmndSn: z.number().nullish(),
splmntArtclComCd: z.string().nullish(),
splmntDmndCn: z.string().optional(),
splmntDmndDt: z.string().optional(),
splmntDmndAtchFileGroupId: z.string().optional(),
splmntCn: z.string().optional(),
splmntPicNm: z.string().optional(),
splmntCmptnDt: z.string().optional(),
splmntAtchFileGroupId: z.string().optional(),
cmptnYn: z.string().optional(),
splmntDmndCn: z.string().nullish(),
splmntDmndDt: z.string().nullish(),
splmntDmndAtchFileGroupId: z.string().nullish(),
splmntCn: z.string().nullish(),
splmntPicNm: z.string().nullish(),
splmntCmptnDt: z.string().nullish(),
splmntAtchFileGroupId: z.string().nullish(),
cmptnYn: z.string().nullish(),
});
/** 백엔드 FnrsCnsltnRvwOpnnSaveRequestDto */
export const fnrsCnsltnRvwOpnnSaveSchema = z.object({
fnrsCnsltnDocNo: z.string(),
rvwOpnnSn: z.number().optional(),
opnrSeComCd: z.string().optional(),
opnrNm: z.string().optional(),
rvwRsltCn: z.string().optional(),
rvwRsltAnmlyYn: z.string().optional(),
atchFileGroupId: z.string().optional(),
rvwOpnnSn: z.number().nullish(),
opnrSeComCd: z.string().nullish(),
opnrNm: z.string().nullish(),
rvwRsltCn: z.string().nullish(),
rvwRsltAnmlyYn: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 FnrsCnsltnApplySaveRequestDto.FnrsSharItem */
export const fnrsSharItemSchema = z.object({
fnrsSharSeCd: z.string().optional(),
exstnAmt: z.number().optional(),
chgRqtAmt: z.number().optional(),
ajmtAmt: z.number().optional(),
rmrkCn: z.string().optional(),
envoRmrkCn: z.string().optional(),
fnrsSharSeCd: z.string().nullish(),
exstnAmt: z.number().nullish(),
chgRqtAmt: z.number().nullish(),
ajmtAmt: z.number().nullish(),
rmrkCn: z.string().nullish(),
envoRmrkCn: z.string().nullish(),
});
/** 백엔드 FnrsCnsltnApplySaveRequestDto */
export const fnrsCnsltnApplySaveSchema = z.object({
techReViewSaveCd: z.string().optional(),
rvwStpCd: z.string().optional(),
techReViewSaveCd: z.string().nullish(),
rvwStpCd: z.string().nullish(),
fnrsCnsltnMng: fnrsCnsltnMngSaveSchema,
bzScaleList: z.array(fnrsCnsltnBzScaleItemSchema).optional(),
bizCstList: z.array(fnrsCnsltnBizCstItemSchema).optional(),
cklstResultList: z.array(fnrsCnsltnCklstResultItemSchema).optional(),
cklstResultMap: z.record(z.array(fnrsCnsltnCklstResultItemSchema)).optional(),
esntlAtchFileList: z.array(fnrsCnsltnEsntlAtchFileItemSchema).optional(),
rvwOpnnList: z.array(fnrsCnsltnRvwOpnnSaveSchema).optional(),
splmntDmndList: z.array(fnrsCnsltnSplmntDmndItemSchema).optional(),
fnrsSharList: z.array(fnrsSharItemSchema).optional(),
bzScaleList: z.array(fnrsCnsltnBzScaleItemSchema).nullish(),
bizCstList: z.array(fnrsCnsltnBizCstItemSchema).nullish(),
cklstResultList: z.array(fnrsCnsltnCklstResultItemSchema).nullish(),
cklstResultMap: z.record(z.array(fnrsCnsltnCklstResultItemSchema)).nullish(),
esntlAtchFileList: z.array(fnrsCnsltnEsntlAtchFileItemSchema).nullish(),
rvwOpnnList: z.array(fnrsCnsltnRvwOpnnSaveSchema).nullish(),
splmntDmndList: z.array(fnrsCnsltnSplmntDmndItemSchema).nullish(),
fnrsSharList: z.array(fnrsSharItemSchema).nullish(),
});
export type FnrsCnsltnMngSaveRequestDto = z.infer<typeof fnrsCnsltnMngSaveSchema>;
@@ -2,12 +2,12 @@ import { z } from "zod";
/** 백엔드 FnrsExamineRequestDto */
export const fnrsExamineRequestSchema = z.object({
prgrsSttsComCd: z.string().optional(),
prgrsSttsComCd: z.string().nullish(),
});
/** 백엔드 EnvoActnReviewReqDto */
export const envoActnReviewReqSchema = z.object({
specialRqstCn: z.string().optional(),
specialRqstCn: z.string().nullish(),
});
export type FnrsExamineRequestDto = z.infer<typeof fnrsExamineRequestSchema>;
@@ -2,52 +2,52 @@ import { z } from "zod";
/** 백엔드 InstlSncnMngSaveRequestDto */
export const instlSncnMngSaveSchema = z.object({
instlSncnDocNo: z.string().optional(),
admdstCd: z.string().optional(),
envoSeComCd: z.string().optional(),
sncnSeComCd: z.string().optional(),
taskSeComCd: z.string().optional(),
bgtBizCd: z.string().optional(),
bgtAplyYr: z.string().optional(),
bizNm: z.string().optional(),
bizBgngYr: z.string().optional(),
bizEndYr: z.string().optional(),
bizCn: z.string().optional(),
aplcntNm: z.string().optional(),
aplcntMblTelno: z.string().optional(),
aplcntOgdpNm: z.string().optional(),
aplcntEmlAddr: z.string().optional(),
dsgnrNm: z.string().optional(),
dsgnrTelno: z.string().optional(),
dsgnrOgdpNm: z.string().optional(),
dsgnrEmlAddr: z.string().optional(),
atchFileGroupId: z.string().optional(),
prgrsSttsComCd: z.string().optional(),
rtlztnFcltSeComCd: z.string().optional(),
rtlztnUsgSeComCd: z.string().optional(),
instlSncnDocNo: z.string().nullish(),
admdstCd: z.string().nullish(),
envoSeComCd: z.string().nullish(),
sncnSeComCd: z.string().nullish(),
taskSeComCd: z.string().nullish(),
bgtBizCd: z.string().nullish(),
bgtAplyYr: z.string().nullish(),
bizNm: z.string().nullish(),
bizBgngYr: z.string().nullish(),
bizEndYr: z.string().nullish(),
bizCn: z.string().nullish(),
aplcntNm: z.string().nullish(),
aplcntMblTelno: z.string().nullish(),
aplcntOgdpNm: z.string().nullish(),
aplcntEmlAddr: z.string().nullish(),
dsgnrNm: z.string().nullish(),
dsgnrTelno: z.string().nullish(),
dsgnrOgdpNm: z.string().nullish(),
dsgnrEmlAddr: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
prgrsSttsComCd: z.string().nullish(),
rtlztnFcltSeComCd: z.string().nullish(),
rtlztnUsgSeComCd: z.string().nullish(),
});
/** 백엔드 InstlSncnApplySaveRequestDto.BzScaleItem (diff는 API 미전송) */
export const instlSncnBzScaleItemSchema = z.object({
cnstpSeCd: z.string().optional(),
bscPl: z.number().optional(),
budgetReflct: z.number().optional(),
thtiDsgn: z.number().optional(),
cnstpSeCd: z.string().nullish(),
bscPl: z.number().nullish(),
budgetReflct: z.number().nullish(),
thtiDsgn: z.number().nullish(),
});
/** 백엔드 InstlSncnApplySaveRequestDto.BizCstItem */
export const instlSncnBizCstItemSchema = z.object({
acntgSeCd: z.string().optional(),
bscPlanAmt: z.number().optional(),
bgtRfltAmt: z.number().optional(),
tstmDesignAmt: z.number().optional(),
acntgSeCd: z.string().nullish(),
bscPlanAmt: z.number().nullish(),
bgtRfltAmt: z.number().nullish(),
tstmDesignAmt: z.number().nullish(),
});
/** 백엔드 InstlSncnApplySaveRequestDto.CklstResultItem */
export const instlSncnCklstResultItemSchema = z.object({
cklstRvwSn: z.number().optional(),
cklstArtclSn: z.number().optional(),
cklstSn: z.number().optional(),
cklstRvwSn: z.number().nullish(),
cklstArtclSn: z.number().nullish(),
cklstSn: z.number().nullish(),
rvwStpComCd: z.string().nullish(),
selected: z.enum(["Y", "N"]).nullish(),
picRvwCn: z.string().nullish(),
@@ -55,47 +55,47 @@ export const instlSncnCklstResultItemSchema = z.object({
/** 백엔드 InstlSncnApplySaveRequestDto.EsntlAtchFileItem */
export const instlSncnEsntlAtchFileItemSchema = z.object({
esntlAtchFileId: z.string().optional(),
atchFileGroupId: z.string().optional(),
esntlAtchFileId: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 InstlSncnRvwOpnnSaveRequestDto */
export const instlSncnRvwOpnnSaveSchema = z.object({
instlSncnDocNo: z.string(),
rvwOpnnSn: z.number().optional(),
opnrSeComCd: z.string().optional(),
opnrNm: z.string().optional(),
rvwRsltCn: z.string().optional(),
rvwRsltAnmlyYn: z.string().optional(),
atchFileGroupId: z.string().optional(),
rvwOpnnSn: z.number().nullish(),
opnrSeComCd: z.string().nullish(),
opnrNm: z.string().nullish(),
rvwRsltCn: z.string().nullish(),
rvwRsltAnmlyYn: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 InstlSncnApplySaveRequestDto.InstlSncnSplmntDmndItem */
export const instlSncnSplmntDmndItemSchema = z.object({
splmntDmndSn: z.number().nullish(),
splmntArtclComCd: z.string().nullish(),
splmntDmndCn: z.string().optional(),
splmntDmndDt: z.string().optional(),
splmntDmndAtchFileGroupId: z.string().optional(),
splmntCn: z.string().optional(),
splmntPicNm: z.string().optional(),
splmntCmptnDt: z.string().optional(),
splmntAtchFileGroupId: z.string().optional(),
cmptnYn: z.string().optional(),
splmntDmndCn: z.string().nullish(),
splmntDmndDt: z.string().nullish(),
splmntDmndAtchFileGroupId: z.string().nullish(),
splmntCn: z.string().nullish(),
splmntPicNm: z.string().nullish(),
splmntCmptnDt: z.string().nullish(),
splmntAtchFileGroupId: z.string().nullish(),
cmptnYn: z.string().nullish(),
});
/** 백엔드 InstlSncnApplySaveRequestDto */
export const instlSncnApplySaveSchema = z.object({
techReViewSaveCd: z.string().optional(),
rvwStpCd: z.string().optional(),
techReViewSaveCd: z.string().nullish(),
rvwStpCd: z.string().nullish(),
instlSncnMng: instlSncnMngSaveSchema,
bzScaleList: z.array(instlSncnBzScaleItemSchema).optional(),
bizCstList: z.array(instlSncnBizCstItemSchema).optional(),
cklstResultList: z.array(instlSncnCklstResultItemSchema).optional(),
cklstResultMap: z.record(z.array(instlSncnCklstResultItemSchema)).optional(),
esntlAtchFileList: z.array(instlSncnEsntlAtchFileItemSchema).optional(),
rvwOpnnList: z.array(instlSncnRvwOpnnSaveSchema).optional(),
splmntDmndList: z.array(instlSncnSplmntDmndItemSchema).optional(),
bzScaleList: z.array(instlSncnBzScaleItemSchema).nullish(),
bizCstList: z.array(instlSncnBizCstItemSchema).nullish(),
cklstResultList: z.array(instlSncnCklstResultItemSchema).nullish(),
cklstResultMap: z.record(z.array(instlSncnCklstResultItemSchema)).nullish(),
esntlAtchFileList: z.array(instlSncnEsntlAtchFileItemSchema).nullish(),
rvwOpnnList: z.array(instlSncnRvwOpnnSaveSchema).nullish(),
splmntDmndList: z.array(instlSncnSplmntDmndItemSchema).nullish(),
});
export type InstlSncnMngSaveRequestDto = z.infer<typeof instlSncnMngSaveSchema>;
@@ -4,7 +4,7 @@ import { z } from "zod";
export const pwrkCnsltnDocCrtRsltSaveRequestSchema = z.object({
crtDmndUuid: z.string().uuid(),
pwrkCnsltnDocNo: z.string().min(1).max(100),
atchFileGroupId: z.string().max(30).optional(),
atchFileGroupId: z.string().max(30).nullish(),
});
export type PwrkCnsltnDocCrtRsltSaveRequestDto = z.infer<
@@ -3,10 +3,10 @@ import { z } from "zod";
/** 백엔드 PwrkCnsltnDocEvlSaveRequestDto */
export const pwrkCnsltnDocEvlSaveRequestSchema = z.object({
pwrkCnsltnDocNo: z.string().min(1),
pwrkCnsltnDocEvlUuid: z.string().uuid().optional(),
pwrkCnsltnDocEvlCn: z.string().max(2000).optional(),
pwrkCnsltnDocEvlScr: z.number().optional(),
pwrkCnsltnDocEvlDt: z.string().optional(),
pwrkCnsltnDocEvlUuid: z.string().uuid().nullish(),
pwrkCnsltnDocEvlCn: z.string().max(2000).nullish(),
pwrkCnsltnDocEvlScr: z.number().nullish(),
pwrkCnsltnDocEvlDt: z.string().nullish(),
});
export type PwrkCnsltnDocEvlSaveRequestDto = z.infer<typeof pwrkCnsltnDocEvlSaveRequestSchema>;
@@ -3,18 +3,18 @@ import { z } from "zod";
/** 백엔드 PwrkActnResultSubmitDto.ActnItemDto */
export const pwrkActnResultActnItemSchema = z.object({
rvwOpnnSn: z.number(),
actnRsltCn: z.string().optional(),
rfltYn: z.string().optional(),
atchFileGroupId: z.string().optional(),
actnRsltCn: z.string().nullish(),
rfltYn: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 PwrkActnResultSubmitDto */
export const pwrkActnResultSubmitSchema = z.object({
isSubmit: z.boolean(),
actnList: z.array(pwrkActnResultActnItemSchema).optional(),
totActnSummary: z.string().optional(),
muniDocNo: z.string().optional(),
pwrkAtchFileGroupId: z.string().optional(),
actnList: z.array(pwrkActnResultActnItemSchema).nullish(),
totActnSummary: z.string().nullish(),
muniDocNo: z.string().nullish(),
pwrkAtchFileGroupId: z.string().nullish(),
});
export type PwrkActnResultSubmitDto = z.infer<typeof pwrkActnResultSubmitSchema>;
@@ -2,50 +2,50 @@ import { z } from "zod";
/** 백엔드 SewerBscPlanSaveRequestDto */
export const sewerBscPlanSaveSchema = z.object({
sewerBscPlanDocNo: z.string().optional(),
admdstCd: z.string().optional(),
envoSeComCd: z.string().optional(),
sewerBscPlanSeComCd: z.string().optional(),
sewerBscPlanNm: z.string().optional(),
taskSeComCd: z.string().optional(),
prgrsSttsComCd: z.string().optional(),
sewerBscPlanDocNo: z.string().nullish(),
admdstCd: z.string().nullish(),
envoSeComCd: z.string().nullish(),
sewerBscPlanSeComCd: z.string().nullish(),
sewerBscPlanNm: z.string().nullish(),
taskSeComCd: z.string().nullish(),
prgrsSttsComCd: z.string().nullish(),
});
/** 백엔드 SewerBscPlanDtlSaveRequestDto */
export const sewerBscPlanDtlSaveSchema = z.object({
sewerBscPlanDocNo: z.string().optional(),
chgCycl: z.number().optional(),
aplcntNm: z.string().optional(),
aplcntMblTelno: z.string().optional(),
aplcntOgdpNm: z.string().optional(),
aplcntEmlAddr: z.string().optional(),
dsgnrNm: z.string().optional(),
dsgnrTelno: z.string().optional(),
dsgnrOgdpNm: z.string().optional(),
dsgnrEmlAddr: z.string().optional(),
planYr: z.string().optional(),
crtrYr: z.string().optional(),
bizOtlnCn: z.string().optional(),
frlvlGoalYr: z.string().optional(),
sclvlGoalYr: z.string().optional(),
thlvlGoalYr: z.string().optional(),
folvlGoalYr: z.string().optional(),
atchFileGroupId: z.string().optional(),
sewerBscPlanDocNo: z.string().nullish(),
chgCycl: z.number().nullish(),
aplcntNm: z.string().nullish(),
aplcntMblTelno: z.string().nullish(),
aplcntOgdpNm: z.string().nullish(),
aplcntEmlAddr: z.string().nullish(),
dsgnrNm: z.string().nullish(),
dsgnrTelno: z.string().nullish(),
dsgnrOgdpNm: z.string().nullish(),
dsgnrEmlAddr: z.string().nullish(),
planYr: z.string().nullish(),
crtrYr: z.string().nullish(),
bizOtlnCn: z.string().nullish(),
frlvlGoalYr: z.string().nullish(),
sclvlGoalYr: z.string().nullish(),
thlvlGoalYr: z.string().nullish(),
folvlGoalYr: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanOtlnItem */
export const sewerBscPlanOtlnItemSchema = z.object({
planStpNo: z.number().optional(),
sewerPlanPpltnCnt: z.number().optional(),
sewerPrcsPpltnCnt: z.number().optional(),
sewerPsplyRt: z.number().optional(),
planStpNo: z.number().nullish(),
sewerPlanPpltnCnt: z.number().nullish(),
sewerPrcsPpltnCnt: z.number().nullish(),
sewerPsplyRt: z.number().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.CklstResultItem */
export const sewerBscPlanCklstResultItemSchema = z.object({
cklstRvwSn: z.number().optional(),
cklstArtclSn: z.number().optional(),
cklstSn: z.number().optional(),
cklstRvwSn: z.number().nullish(),
cklstArtclSn: z.number().nullish(),
cklstSn: z.number().nullish(),
rvwStpComCd: z.string(),
selected: z.enum(["Y", "N"]).nullish(),
picRvwCn: z.string().nullish(),
@@ -53,109 +53,109 @@ export const sewerBscPlanCklstResultItemSchema = z.object({
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanFnrsCstItem */
export const sewerBscPlanFnrsCstItemSchema = z.object({
planStpNo: z.number().optional(),
gvexpAmt: z.number().optional(),
lcexpAmt: z.number().optional(),
orgprBdchgAmt: z.number().optional(),
etcAmt: z.number().optional(),
sumAmt: z.number().optional(),
planStpNo: z.number().nullish(),
gvexpAmt: z.number().nullish(),
lcexpAmt: z.number().nullish(),
orgprBdchgAmt: z.number().nullish(),
etcAmt: z.number().nullish(),
sumAmt: z.number().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanSplmntDmndItem */
export const sewerBscPlanSplmntDmndItemSchema = z.object({
splmntDmndSn: z.number().nullish(),
splmntArtclComCd: z.string().nullish(),
splmntDmndCn: z.string().optional(),
splmntDmndDt: z.string().optional(),
splmntDmndAtchFileGroupId: z.string().optional(),
splmntCn: z.string().optional(),
splmntPicNm: z.string().optional(),
splmntCmptnDt: z.string().optional(),
splmntAtchFileGroupId: z.string().optional(),
cmptnYn: z.string().optional(),
splmntDmndCn: z.string().nullish(),
splmntDmndDt: z.string().nullish(),
splmntDmndAtchFileGroupId: z.string().nullish(),
splmntCn: z.string().nullish(),
splmntPicNm: z.string().nullish(),
splmntCmptnDt: z.string().nullish(),
splmntAtchFileGroupId: z.string().nullish(),
cmptnYn: z.string().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanRvwOpnnItem */
export const sewerBscPlanRvwOpnnItemSchema = z.object({
rvwOpnnSn: z.number().optional(),
opnrSeComCd: z.string().optional(),
opnrNm: z.string().optional(),
rvwRsltCn: z.string().optional(),
rvwRsltAnmlyYn: z.string().optional(),
atchFileGroupId: z.string().optional(),
rvwOpnnSn: z.number().nullish(),
opnrSeComCd: z.string().nullish(),
opnrNm: z.string().nullish(),
rvwRsltCn: z.string().nullish(),
rvwRsltAnmlyYn: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.EsntlAtchFileItem */
export const sewerBscPlanEsntlAtchFileItemSchema = z.object({
esntlAtchFileId: z.string().optional(),
atchFileGroupId: z.string().optional(),
esntlAtchFileId: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanBizCstItem */
export const sewerBscPlanBizCstItemSchema = z.object({
planStpNo: z.number().optional(),
acntgSeCd: z.string().optional(),
cstrnAmt: z.number().optional(),
planStpNo: z.number().nullish(),
acntgSeCd: z.string().nullish(),
cstrnAmt: z.number().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanTmplntDtlItem */
export const sewerBscPlanTmplntDtlItemSchema = z.object({
planStpNo: z.number().optional(),
swtrmtFcltCd: z.string().optional(),
prcsZoneArea: z.number().optional(),
swtrmtPpltnCnt: z.number().optional(),
planSwqty: z.number().optional(),
fcltCpct: z.number().optional(),
sprsysDrtwPplLen: z.number().optional(),
sprsysExlnPplLen: z.number().optional(),
cbsysPplLen: z.number().optional(),
icptgPplLen: z.number().optional(),
drgCnt: z.number().optional(),
drtwRlyPumpCnt: z.number().optional(),
drtwPumpCnt: z.number().optional(),
exlnPumpCnt: z.number().optional(),
planStpNo: z.number().nullish(),
swtrmtFcltCd: z.string().nullish(),
prcsZoneArea: z.number().nullish(),
swtrmtPpltnCnt: z.number().nullish(),
planSwqty: z.number().nullish(),
fcltCpct: z.number().nullish(),
sprsysDrtwPplLen: z.number().nullish(),
sprsysExlnPplLen: z.number().nullish(),
cbsysPplLen: z.number().nullish(),
icptgPplLen: z.number().nullish(),
drgCnt: z.number().nullish(),
drtwRlyPumpCnt: z.number().nullish(),
drtwPumpCnt: z.number().nullish(),
exlnPumpCnt: z.number().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanTmplntItem */
export const sewerBscPlanTmplntItemSchema = z.object({
swtrmtFcltCd: z.string().optional(),
atchFileGroupId: z.string().optional(),
sewerBscPlanTmplntDtlList: z.array(sewerBscPlanTmplntDtlItemSchema).optional(),
swtrmtFcltCd: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
sewerBscPlanTmplntDtlList: z.array(sewerBscPlanTmplntDtlItemSchema).nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanTmplntTmprStrgItem */
export const sewerBscPlanTmplntTmprStrgItemSchema = z.object({
dtStrgDt: z.string().optional(),
swtrmtFcltCd: z.string().optional(),
prcsZoneArea: z.number().optional(),
swtrmtPpltnCnt: z.number().optional(),
planSwqty: z.number().optional(),
fcltCpct: z.number().optional(),
sprsysDrtwPplLen: z.number().optional(),
sprsysExlnPplLen: z.number().optional(),
cbsysPplLen: z.number().optional(),
icptgPplLen: z.number().optional(),
drgCnt: z.number().optional(),
drtwRlyPumpCnt: z.number().optional(),
drtwPumpCnt: z.number().optional(),
exlnPumpCnt: z.number().optional(),
dtStrgDt: z.string().nullish(),
swtrmtFcltCd: z.string().nullish(),
prcsZoneArea: z.number().nullish(),
swtrmtPpltnCnt: z.number().nullish(),
planSwqty: z.number().nullish(),
fcltCpct: z.number().nullish(),
sprsysDrtwPplLen: z.number().nullish(),
sprsysExlnPplLen: z.number().nullish(),
cbsysPplLen: z.number().nullish(),
icptgPplLen: z.number().nullish(),
drgCnt: z.number().nullish(),
drtwRlyPumpCnt: z.number().nullish(),
drtwPumpCnt: z.number().nullish(),
exlnPumpCnt: z.number().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto */
export const sewerBscPlanApplySaveSchema = z.object({
techReViewSaveCd: z.string().optional(),
rvwStpCd: z.string().optional(),
techReViewSaveCd: z.string().nullish(),
rvwStpCd: z.string().nullish(),
sewerBscPlan: sewerBscPlanSaveSchema,
sewerBscPlanDtl: sewerBscPlanDtlSaveSchema,
sewerBscPlanOtlnList: z.array(sewerBscPlanOtlnItemSchema).optional(),
cklstResultList: z.array(sewerBscPlanCklstResultItemSchema).optional(),
fnrsCstList: z.array(sewerBscPlanFnrsCstItemSchema).optional(),
splmntDmndList: z.array(sewerBscPlanSplmntDmndItemSchema).optional(),
rvwOpnnList: z.array(sewerBscPlanRvwOpnnItemSchema).optional(),
esntlAtchFileList: z.array(sewerBscPlanEsntlAtchFileItemSchema).optional(),
bizCstList: z.array(sewerBscPlanBizCstItemSchema).optional(),
sewerBscPlanTmplntList: z.array(sewerBscPlanTmplntItemSchema).optional(),
tmplntTmprStrgList: z.array(sewerBscPlanTmplntTmprStrgItemSchema).optional(),
sewerBscPlanOtlnList: z.array(sewerBscPlanOtlnItemSchema).nullish(),
cklstResultList: z.array(sewerBscPlanCklstResultItemSchema).nullish(),
fnrsCstList: z.array(sewerBscPlanFnrsCstItemSchema).nullish(),
splmntDmndList: z.array(sewerBscPlanSplmntDmndItemSchema).nullish(),
rvwOpnnList: z.array(sewerBscPlanRvwOpnnItemSchema).nullish(),
esntlAtchFileList: z.array(sewerBscPlanEsntlAtchFileItemSchema).nullish(),
bizCstList: z.array(sewerBscPlanBizCstItemSchema).nullish(),
sewerBscPlanTmplntList: z.array(sewerBscPlanTmplntItemSchema).nullish(),
tmplntTmprStrgList: z.array(sewerBscPlanTmplntTmprStrgItemSchema).nullish(),
});
export type SewerBscPlanSaveRequestDto = z.infer<typeof sewerBscPlanSaveSchema>;
@@ -23,8 +23,8 @@ export const designCnstnRegistSchema = z.object({
.string()
.min(1, "설계자 이메일은 필수입니다")
.email("올바른 이메일 형식이 아닙니다"),
sido: z.string().optional(),
gugun: z.string().optional(),
sido: z.string().nullish(),
gugun: z.string().nullish(),
}),
bzScaleList: z.array(
designCnstnBzScaleItemSchema.extend({
@@ -32,7 +32,7 @@ export const designCnstnRegistSchema = z.object({
bscPl: z.number(),
budgetReflct: z.number(),
thtiDsgn: z.number(),
diff: z.number().optional(),
diff: z.number().nullish(),
}),
),
esntlAtchFileList: z.array(
@@ -52,7 +52,7 @@ export function formatTaskSeComCdForApi(taskSeComCd: string[]): string {
return taskSeComCd.join(",");
}
export function parseTaskSeComCdFromApi(taskSeComCd?: string): string[] {
export function parseTaskSeComCdFromApi(taskSeComCd?: string | null): string[] {
if (!taskSeComCd) return [];
return taskSeComCd.split(",").filter(Boolean);
}
@@ -72,7 +72,7 @@ function getBadgeColor(state: BadgeState) {
}
}
function formatOptional(value: string | undefined) {
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
@@ -51,14 +51,19 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, FnrsCnsltnViewBu
LE: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw],
TP: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwOpnnRply,
],
RB: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
RA: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstActnRvw,
],
AV: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
EV: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
RK: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
RR: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply],
EP: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
TA: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
},
};
@@ -4,7 +4,6 @@ import {
type ChecklistFormState,
type ChecklistProgress,
type ChecklistRowKey,
type ChecklistStepMap,
REVIEW_STEP_CODES,
type ReviewStepCode,
} from "../model/view-types";
@@ -17,15 +16,6 @@ export function buildChecklistRowKey(
return `${row.taskSeComCd}_${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}_${step}`;
}
export function groupByReviewStep(rows: CklstRvwRsltTreeDto[]): ChecklistStepMap {
const base: ChecklistStepMap = { LOCAL: [], ENV: [], KECO: [] };
rows.forEach((row) => {
const step = (row.rvwStpComCd ?? "LOCAL") as ReviewStepCode;
if (REVIEW_STEP_CODES.includes(step)) base[step].push(row);
});
return base;
}
export function extractTaskCodes(rows: CklstRvwRsltTreeDto[]): string[] {
return Array.from(new Set(rows.map((row) => row.taskSeComCd).filter(Boolean)));
}
@@ -70,13 +60,6 @@ export function computeChecklistProgress(
return { total, completed, percent };
}
export function filterChecklistRowsByReviewStep(
rows: CklstRvwRsltTreeDto[],
reviewStep: ReviewStepCode,
): CklstRvwRsltTreeDto[] {
return rows.filter((row) => row.rvwStpComCd === reviewStep);
}
export function computeReviewStepChecklistProgress(
rows: CklstRvwRsltTreeDto[],
results: ChecklistFormState["results"],
@@ -93,23 +76,6 @@ export function computeReviewStepChecklistProgress(
return { total, completed, percent };
}
export function buildChecklistFormState(rows: CklstRvwRsltTreeDto[]): ChecklistFormState {
const results: ChecklistFormState["results"] = {};
const comments: ChecklistFormState["comments"] = {};
rows.forEach((row) => {
const key = buildChecklistRowKey(row);
if (row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N") {
results[key] = row.rvwRsltAnmlyYn;
}
if (row.picRvwCn) comments[key] = row.picRvwCn;
});
return {
results,
comments,
progress: computeChecklistProgress(rows, results),
};
}
export function mergeChecklistFormStateFromServer(
prev: ChecklistFormState,
allRows: CklstRvwRsltTreeDto[],
@@ -27,8 +27,8 @@ export const fnrsCnsltnRegistSchema = z.object({
.string()
.min(1, "설계자 이메일은 필수입니다")
.email("올바른 이메일 형식이 아닙니다"),
sido: z.string().optional(),
gugun: z.string().optional(),
sido: z.string().nullish(),
gugun: z.string().nullish(),
}),
bzScaleList: z.array(
fnrsCnsltnBzScaleItemSchema.extend({
@@ -36,7 +36,7 @@ export const fnrsCnsltnRegistSchema = z.object({
bscPl: z.number(),
budgetReflct: z.number(),
thtiDsgn: z.number(),
diff: z.number().optional(),
diff: z.number().nullish(),
}),
),
bizCstList: z.array(
@@ -52,8 +52,8 @@ export const fnrsCnsltnRegistSchema = z.object({
fnrsSharSeCd: z.string(),
exstnAmt: z.number(),
chgRqtAmt: z.number(),
ajmtAmt: z.number().optional(),
rmrkCn: z.string().optional(),
ajmtAmt: z.number().nullish(),
rmrkCn: z.string().nullish(),
}),
),
cklstResultList: z.array(
@@ -74,7 +74,7 @@ export const fnrsCnsltnRegistSchema = z.object({
splmntDmndList: z.array(
fnrsCnsltnSplmntDmndItemSchema.extend({
splmntArtclCdNm: z.string().nullish(),
splmntDmndAtchFileNm: z.string().optional(),
splmntDmndAtchFileNm: z.string().nullish(),
}),
),
privacyConsent: z.enum(["Y", "N"], {
@@ -88,7 +88,7 @@ export function formatTaskSeComCdForApi(taskSeComCd: string[]): string {
return taskSeComCd.join(",");
}
export function parseTaskSeComCdFromApi(taskSeComCd?: string): string[] {
export function parseTaskSeComCdFromApi(taskSeComCd?: string | null): string[] {
if (!taskSeComCd) return [];
return taskSeComCd.split(",").filter(Boolean);
}
@@ -15,7 +15,7 @@ export const pwrkCnsltnDocEvlFormSchema = z.object({
required_error: "만족도를 선택해 주세요.",
invalid_type_error: "만족도를 선택해 주세요.",
}),
pwrkCnsltnDocEvlCn: z.string().max(2000).optional(),
pwrkCnsltnDocEvlCn: z.string().max(2000).nullish(),
});
export type PwrkCnsltnDocEvlFormValues = z.infer<typeof pwrkCnsltnDocEvlFormSchema>;
@@ -1,3 +1,5 @@
import type { CklstRvwRsltTreeDto } from "@/entities/fnrs-cnsltn";
import {
PBADMS_PROGRESS_ORDER,
REVIEW_STEP_CODES,
@@ -28,6 +30,25 @@ export function getVisibleReviewSteps(
return REVIEW_STEP_CODES.slice(0, count);
}
export function resolveVisibleReviewSteps(
prgrsSttsComCd: string,
userRoleCode: string | undefined,
cklstResultList: CklstRvwRsltTreeDto[],
): ReviewStepCode[] {
const byProgressAndRole = getVisibleReviewSteps(prgrsSttsComCd, userRoleCode);
const currentStep = byProgressAndRole.at(-1);
const stepsWithData = new Set<ReviewStepCode>();
for (const row of cklstResultList) {
const step = row.rvwStpComCd as ReviewStepCode | undefined;
if (step && REVIEW_STEP_CODES.includes(step)) stepsWithData.add(step);
}
return REVIEW_STEP_CODES.filter(
(step) => stepsWithData.has(step) || step === currentStep,
);
}
export function getEditableReviewStep(
dtlInstCd?: string,
prgrsSttsComCd?: string,
@@ -113,7 +113,7 @@ function getBadgeColor(state: BadgeState) {
}
}
function formatOptional(value: string | undefined) {
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
@@ -44,10 +44,14 @@ export interface ChecklistFormState {
progress: ChecklistProgress;
}
export type ChecklistStepMap = Record<ReviewStepCode, CklstRvwRsltTreeDto[]>;
export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
export const CHECKLIST_TAB_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "자가체크",
ENV: "기술검토",
KECO: "환경청검토",
};
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "지자체",
ENV: "환경청",
@@ -180,7 +180,9 @@ function FloatingActionButtons({
userInfo?.userUnqId,
);
const viewOnlyPwrkCnsltnDoc =
prgrsSttsComCd === "SL" || (prgrsSttsComCd === "RB" && dtlInstCd !== "ROLE_USER05");
prgrsSttsComCd === "SL" ||
(prgrsSttsComCd === "RB" && dtlInstCd !== "ROLE_USER05") ||
(prgrsSttsComCd === "RK" && dtlInstCd === "ROLE_USER03");
const actionButtons = useMemo(() => {
if (needsAtrzResolve && isRvwLoginUserLoading) return [];
@@ -113,7 +113,8 @@ function PwrkCnsltnDocEvlDialog({ open, pwrkCnsltnDocNo, onClose }: PwrkCnsltnDo
<Textarea
id="pwrkCnsltnDocEvlCn"
rows={4}
placeholder="의견을 입력해 주세요 (선택)"
placeholder="의견을 입력해 주세요"
className="!text-xl"
{...register("pwrkCnsltnDocEvlCn")}
/>
</div>
@@ -18,6 +18,7 @@ import {
fnrsCnsltnMngService,
} from "@/entities/fnrs-cnsltn";
import { useFnrsSharSeMngTree } from "@/entities/fnrs-shar-se";
import { findCompletedPwrkDraft, usePwrkCnsltnMngByUpDocNo } from "@/entities/pwrk-cnsltn-mng";
import {
AttachmentsFormSection,
buildAcntgCostItems,
@@ -35,7 +36,10 @@ import {
} from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
import { type FnrsCnsltnRegistFormData, fnrsCnsltnRegistSchema } from "../lib/fnrs-cnsltn-regist-schema";
import {
type FnrsCnsltnRegistFormData,
fnrsCnsltnRegistSchema,
} from "../lib/fnrs-cnsltn-regist-schema";
import {
buildFormPayload,
getCreateDefaultValues,
@@ -68,6 +72,12 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
const isSplmntPrgrsStts = initialData?.detDto?.prgrsSttsComCd === "SL";
const fnrsCnsltnDocNo = initialData?.detDto?.fnrsCnsltnDocNo;
const { data: pwrkCnsltnData } = usePwrkCnsltnMngByUpDocNo(
fnrsCnsltnDocNo,
isSplmntPrgrsStts && Boolean(fnrsCnsltnDocNo),
);
const hasCompletedDraft = Boolean(findCompletedPwrkDraft(pwrkCnsltnData));
const { data: masterSharTree = [] } = useFnrsSharSeMngTree();
const { data: acntgTree = [] } = useAcntgSeMngTree();
const { data: essentialFiles = [] } = useEsntlAtchFileMngTree({ docSeComCd: "FNRS" });
@@ -126,7 +136,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
(item) => !item.splmntCn || !item.splmntCn.trim(),
);
if (hasEmptySplmntCn) {
alert("보완요청내용이 있습니다. 보완 완료내용을 입력해 주세요.");
alert("보완 요청 내용이 있습니다.\n보완 완료 내용을 입력해 주세요.");
return;
}
@@ -174,6 +184,11 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
async function onApply(data: FnrsCnsltnRegistFormData) {
if (isSubmitting) return;
if (data.privacyConsent !== "Y") {
alert("개인정보 수집/이용에 동의해야 신청이 가능합니다.");
return;
}
if (!isFormChecklistComplete(data.cklstResultList)) {
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
return;
@@ -260,7 +275,10 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
initialEsntlFiles={isEditMode ? initialData?.esntlFileList : undefined}
initialEtcFiles={isEditMode ? initialData?.etcFileList : undefined}
/>
<SplmntDmndFormSection readOnly={!isSplmntPrgrsStts} />
<SplmntDmndFormSection
initialData={initialData?.splmntDmndList}
readOnly={!isSplmntPrgrsStts}
/>
<div className="sticky bottom-0 z-40 !-mb-2 border-t bg-white pb-8 pt-6 shadow-[0_-4px_12px_rgba(0,0,0,0.06)]">
<div className="mx-4 flex flex-wrap justify-center gap-3 sm:gap-4">
@@ -300,7 +318,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
</>
) : (
<>
{fnrsCnsltnDocNo ? (
{hasCompletedDraft ? (
<Button
color="orange"
type="button"
@@ -324,7 +342,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
</div>
</SectionCollapsibleGroup>
</form>
{fnrsCnsltnDocNo ? (
{hasCompletedDraft && fnrsCnsltnDocNo ? (
<PwrkCnsltnDocMngDialog
open={isPwrkCnsltnDocMngOpen}
fnrsCnsltnDocNo={fnrsCnsltnDocNo}
@@ -14,7 +14,11 @@ import {
} from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components";
import { getAiPendingMessage, isAiPendingStatus, isRagIndexingInProgress } from "../lib/ai-pending-utils";
import {
getAiPendingMessage,
isAiPendingStatus,
isRagIndexingInProgress,
} from "../lib/ai-pending-utils";
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
import { getEditableReviewStep } from "../lib/review-step-utils";
import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
@@ -11,11 +11,11 @@ import {
extractTaskCodes,
getChecklistDisplayRows,
} from "../../lib/checklist-utils";
import { getVisibleReviewSteps } from "../../lib/review-step-utils";
import { resolveVisibleReviewSteps } from "../../lib/review-step-utils";
import type { ReviewStepCode } from "../../model/view-types";
import {
CHECKLIST_TAB_LABELS,
type ChecklistFormState,
REVIEW_STEP_LABELS,
ROLE_TO_REVIEW_STEP,
TASK_CODE_LABELS,
} from "../../model/view-types";
@@ -37,8 +37,8 @@ function ChecklistSection({
setFormState,
}: ChecklistSectionProps) {
const visibleReviewSteps = useMemo(
() => getVisibleReviewSteps(prgrsSttsComCd, userRoleCode),
[prgrsSttsComCd, userRoleCode],
() => resolveVisibleReviewSteps(prgrsSttsComCd, userRoleCode, cklstResultList),
[prgrsSttsComCd, userRoleCode, cklstResultList],
);
const taskCodes = useMemo(() => extractTaskCodes(cklstResultList), [cklstResultList]);
@@ -87,7 +87,7 @@ function ChecklistSection({
<TabsList>
{visibleReviewSteps.map((stepCode) => (
<TabsTrigger key={stepCode} value={stepCode} className="!px-6 !py-3 text-xl">
{REVIEW_STEP_LABELS[stepCode]}
{CHECKLIST_TAB_LABELS[stepCode]}
</TabsTrigger>
))}
</TabsList>
@@ -68,7 +68,7 @@ export const instlSncnRegistSchema = z.object({
splmntDmndList: z.array(
instlSncnSplmntDmndItemSchema.extend({
splmntArtclCdNm: z.string().nullish(),
splmntDmndAtchFileNm: z.string().optional(),
splmntDmndAtchFileNm: z.string().nullish(),
}),
),
privacyConsent: z.enum(["Y", "N"], {
@@ -82,7 +82,7 @@ export function formatTaskSeComCdForApi(taskSeComCd: string[]): string {
return taskSeComCd.join(",");
}
export function parseTaskSeComCdFromApi(taskSeComCd?: string): string[] {
export function parseTaskSeComCdFromApi(taskSeComCd?: string | null): string[] {
if (!taskSeComCd) return [];
return taskSeComCd.split(",").filter(Boolean);
}
@@ -1,4 +1,4 @@
import type { ReviewStepCode } from "@/entities/instl-sncn";
import type { CklstRvwRsltTreeDto, ReviewStepCode } from "@/entities/instl-sncn";
import {
INSTL_PROGRESS_ORDER,
@@ -27,6 +27,25 @@ export function getVisibleReviewSteps(
return REVIEW_STEP_CODES.slice(0, count);
}
export function resolveVisibleReviewSteps(
prgrsSttsComCd: string,
userRoleCode: string | undefined,
cklstResultList: CklstRvwRsltTreeDto[],
): ReviewStepCode[] {
const byProgressAndRole = getVisibleReviewSteps(prgrsSttsComCd, userRoleCode);
const currentStep = byProgressAndRole.at(-1);
const stepsWithData = new Set<ReviewStepCode>();
for (const row of cklstResultList) {
const step = row.rvwStpComCd as ReviewStepCode | undefined;
if (step && REVIEW_STEP_CODES.includes(step)) stepsWithData.add(step);
}
return REVIEW_STEP_CODES.filter(
(step) => stepsWithData.has(step) || step === currentStep,
);
}
export function getEditableReviewStep(
dtlInstCd?: string,
prgrsSttsComCd?: string,
@@ -9,7 +9,10 @@ import type {
ReviewStepCode,
} from "@/entities/instl-sncn";
import { instlSncnApplySaveSchema } from "@/entities/instl-sncn";
import { type SplmntDmndDialogConfirmPayload, toEsntlAtchFileSaveList } from "@/widgets/pbadms-sprt";
import {
type SplmntDmndDialogConfirmPayload,
toEsntlAtchFileSaveList,
} from "@/widgets/pbadms-sprt";
import type { ChecklistFormState } from "../model/view-types";
@@ -114,7 +114,7 @@ function getBadgeColor(state: BadgeState) {
}
}
function formatOptional(value: string | undefined) {
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
@@ -48,6 +48,12 @@ export type ChecklistStepMap = Record<ReviewStepCode, CklstRvwRsltTreeDto[]>;
export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
export const CHECKLIST_TAB_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "자가체크",
ENV: "기술검토",
KECO: "환경청검토",
};
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "지자체",
ENV: "환경청",
@@ -11,10 +11,10 @@ import {
extractTaskCodes,
getChecklistDisplayRows,
} from "../../lib/checklist-utils";
import { getVisibleReviewSteps } from "../../lib/review-step-utils";
import { resolveVisibleReviewSteps } from "../../lib/review-step-utils";
import {
CHECKLIST_TAB_LABELS,
type ChecklistFormState,
REVIEW_STEP_LABELS,
ROLE_TO_REVIEW_STEP,
TASK_CODE_LABELS,
} from "../../model/view-types";
@@ -36,8 +36,8 @@ function ChecklistSection({
setFormState,
}: ChecklistSectionProps) {
const visibleReviewSteps = useMemo(
() => getVisibleReviewSteps(prgrsSttsComCd, userRoleCode),
[prgrsSttsComCd, userRoleCode],
() => resolveVisibleReviewSteps(prgrsSttsComCd, userRoleCode, cklstResultList),
[prgrsSttsComCd, userRoleCode, cklstResultList],
);
const taskCodes = useMemo(() => extractTaskCodes(cklstResultList), [cklstResultList]);
@@ -86,7 +86,7 @@ function ChecklistSection({
<TabsList>
{visibleReviewSteps.map((stepCode) => (
<TabsTrigger key={stepCode} value={stepCode} className="!px-6 !py-3 text-xl">
{REVIEW_STEP_LABELS[stepCode]}
{CHECKLIST_TAB_LABELS[stepCode]}
</TabsTrigger>
))}
</TabsList>
@@ -1,14 +1,14 @@
import { type CostItemDef, roundAmount } from "@/widgets/pbadms-sprt";
import { type BizCstMatrixItem,getAmt, PLAN_STEPS } from "./biz-cst-matrix-utils";
import { type BizCstMatrixItem, getAmt, PLAN_STEPS } from "./biz-cst-matrix-utils";
export interface FnrsCstItem {
planStpNo: number;
gvexpAmt?: number;
lcexpAmt?: number;
orgprBdchgAmt?: number;
etcAmt?: number;
sumAmt?: number;
gvexpAmt?: number | null;
lcexpAmt?: number | null;
orgprBdchgAmt?: number | null;
etcAmt?: number | null;
sumAmt?: number | null;
}
export interface FnrsCstStepRemaining {
@@ -32,10 +32,7 @@ function getBizCstStepTotal(
function sumFnrsCstLeaf(item: FnrsCstItem): number {
return roundAmount(
(item.gvexpAmt ?? 0) +
(item.lcexpAmt ?? 0) +
(item.orgprBdchgAmt ?? 0) +
(item.etcAmt ?? 0),
(item.gvexpAmt ?? 0) + (item.lcexpAmt ?? 0) + (item.orgprBdchgAmt ?? 0) + (item.etcAmt ?? 0),
);
}
@@ -1,3 +1,5 @@
import type { CklstRvwRsltTreeDto } from "@/entities/sewer-bsc-plan";
import {
PBADMS_PROGRESS_ORDER,
REVIEW_STEP_CODES,
@@ -28,6 +30,25 @@ export function getVisibleReviewSteps(
return REVIEW_STEP_CODES.slice(0, count);
}
export function resolveVisibleReviewSteps(
prgrsSttsComCd: string,
userRoleCode: string | undefined,
cklstResultList: CklstRvwRsltTreeDto[],
): ReviewStepCode[] {
const byProgressAndRole = getVisibleReviewSteps(prgrsSttsComCd, userRoleCode);
const currentStep = byProgressAndRole.at(-1);
const stepsWithData = new Set<ReviewStepCode>();
for (const row of cklstResultList) {
const step = row.rvwStpComCd as ReviewStepCode | undefined;
if (step && REVIEW_STEP_CODES.includes(step)) stepsWithData.add(step);
}
return REVIEW_STEP_CODES.filter(
(step) => stepsWithData.has(step) || step === currentStep,
);
}
export function getEditableReviewStep(
dtlInstCd?: string,
prgrsSttsComCd?: string,
@@ -16,9 +16,9 @@ import {
export const sewerBscPlanRegistSchema = z.object({
sewerBscPlan: sewerBscPlanSaveSchema.extend({
sewerBscPlanNm: z.string().min(1, "기본계획명은 필수입니다"),
sewerBscPlanSeCdNm: z.string().optional(),
taskSeCdNm: z.string().optional(),
sido: z.string().optional(),
sewerBscPlanSeCdNm: z.string().nullish(),
taskSeCdNm: z.string().nullish(),
sido: z.string().nullish(),
gugun: z.string().nullish(),
}),
sewerBscPlanDtl: sewerBscPlanDtlSaveSchema.extend({
@@ -61,7 +61,7 @@ export const sewerBscPlanRegistSchema = z.object({
sewerBscPlanTmplntItemSchema.extend({
swtrmtFcltCd: z.string(),
swtrmtFcltNm: z.string().nullish(),
atchFileNm: z.string().optional(),
atchFileNm: z.string().nullish(),
sewerBscPlanTmplntDtlList: z
.array(
sewerBscPlanTmplntDtlItemSchema.extend({
@@ -69,7 +69,7 @@ export const sewerBscPlanRegistSchema = z.object({
swtrmtFcltCd: z.string(),
}),
)
.optional(),
.nullish(),
}),
),
cklstResultList: z.array(
@@ -78,7 +78,7 @@ export const sewerBscPlanRegistSchema = z.object({
cklstArtclSn: z.number(),
cklstRvwSn: z.number(),
selected: z.enum(["Y", "N"]).nullish(),
picRvwCn: z.string().nullable().optional(),
picRvwCn: z.string().nullish(),
}),
),
esntlAtchFileList: z.array(
@@ -90,9 +90,9 @@ export const sewerBscPlanRegistSchema = z.object({
splmntDmndList: z.array(
sewerBscPlanSplmntDmndItemSchema.extend({
splmntArtclCdNm: z.string().nullish(),
splmntDmndAtchFileNm: z.string().optional(),
splmntAtchFileNm: z.string().optional(),
splmntAtchFileAtflId: z.string().optional(),
splmntDmndAtchFileNm: z.string().nullish(),
splmntAtchFileNm: z.string().nullish(),
splmntAtchFileAtflId: z.string().nullish(),
}),
),
privacyConsent: z.enum(["Y", "N"], {
@@ -42,6 +42,12 @@ export type ChecklistStepMap = Record<ReviewStepCode, CklstRvwRsltTreeDto[]>;
export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
export const CHECKLIST_TAB_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "자가체크",
ENV: "기술검토",
KECO: "환경청검토",
};
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "지자체",
ENV: "환경청",
@@ -31,7 +31,7 @@ interface PreviousPlanLookupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 부모 폼 `sewerBscPlan.sido` 표시명 */
initialSidoNm?: string;
initialSidoNm?: string | null;
/** 부모 폼 `sewerBscPlan.gugun` 표시명 */
initialGugunNm?: string | null;
}
@@ -11,11 +11,11 @@ import {
extractTaskCodes,
getChecklistDisplayRows,
} from "../../lib/checklist-utils";
import { getVisibleReviewSteps } from "../../lib/review-step-utils";
import { resolveVisibleReviewSteps } from "../../lib/review-step-utils";
import type { ReviewStepCode } from "../../model/view-types";
import {
CHECKLIST_TAB_LABELS,
type ChecklistFormState,
REVIEW_STEP_LABELS,
ROLE_TO_REVIEW_STEP,
TASK_CODE_LABELS,
} from "../../model/view-types";
@@ -37,8 +37,8 @@ function ChecklistSection({
setFormState,
}: ChecklistSectionProps) {
const visibleReviewSteps = useMemo(
() => getVisibleReviewSteps(prgrsSttsComCd, userRoleCode),
[prgrsSttsComCd, userRoleCode],
() => resolveVisibleReviewSteps(prgrsSttsComCd, userRoleCode, cklstResultList),
[prgrsSttsComCd, userRoleCode, cklstResultList],
);
const taskCodes = useMemo(() => extractTaskCodes(cklstResultList), [cklstResultList]);
@@ -87,7 +87,7 @@ function ChecklistSection({
<TabsList>
{visibleReviewSteps.map((stepCode) => (
<TabsTrigger key={stepCode} value={stepCode} className="!px-6 !py-3 text-xl">
{REVIEW_STEP_LABELS[stepCode]}
{CHECKLIST_TAB_LABELS[stepCode]}
</TabsTrigger>
))}
</TabsList>
@@ -214,8 +214,8 @@ function TmplntFacilityMatrix({
interface TmplntFacilityAttachmentsProps {
facilityIndex: number;
facilityName?: string;
atchFileGroupId?: string;
atchFileNm?: string;
atchFileGroupId?: string | null;
atchFileNm?: string | null;
}
function TmplntFacilityAttachments({
+8 -8
View File
@@ -205,14 +205,14 @@ export interface EsntlAtchFileSaveBase {
export interface SplmntDmndSaveBase {
splmntDmndSn?: number | null;
splmntArtclComCd?: string | null;
splmntDmndCn?: string;
splmntDmndDt?: string;
splmntDmndAtchFileGroupId?: string;
splmntCn?: string;
splmntPicNm?: string;
splmntCmptnDt?: string;
splmntAtchFileGroupId?: string;
cmptnYn?: string;
splmntDmndCn?: string | null;
splmntDmndDt?: string | null;
splmntDmndAtchFileGroupId?: string | null;
splmntCn?: string | null;
splmntPicNm?: string | null;
splmntCmptnDt?: string | null;
splmntAtchFileGroupId?: string | null;
cmptnYn?: string | null;
}
/** 검토의견 저장 공통 필드 (문서번호 제외) */
@@ -1,8 +1,8 @@
interface CanEditPbadmsDocParams {
docSeComCd: string;
prgrsSttsComCd: string | undefined;
rgtr: string | undefined;
loggedInUserUnqId: string | undefined;
prgrsSttsComCd?: string | null;
rgtr?: string | null;
loggedInUserUnqId?: string | null;
}
function canEditPbadmsDoc({
@@ -17,8 +17,8 @@ export interface FnrsSharListItem {
fnrsSharSeCd: string;
exstnAmt: number;
chgRqtAmt: number;
ajmtAmt?: number;
rmrkCn?: string;
ajmtAmt?: number | null;
rmrkCn?: string | null;
}
export type FnrsSharTreeNode = FnrsSharSeMngTreeDto | FnrsSharBizCstTreeDto;
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useFormContext } from "react-hook-form";
import { useQueryClient } from "@tanstack/react-query";
import { Download } from "lucide-react";
@@ -78,7 +78,7 @@ function AttachmentsFormSection({
initialEsntlFiles,
initialEtcFiles,
}: AttachmentsFormSectionProps) {
const { control, getValues, setValue } = useFormContext<AttachmentsFormData>();
const { getValues, setValue } = useFormContext<AttachmentsFormData>();
const queryClient = useQueryClient();
const [uploadedFiles, setUploadedFiles] = useState<Record<string, UploadedFileItem[]>>({});
const [etcFiles, setEtcFiles] = useState<EtcFileItem[]>([]);
@@ -373,89 +373,83 @@ function AttachmentsFormSection({
return (
<SectionCollapsible title="첨부파일">
<div className="space-y-6">
<Controller
name="esntlAtchFileList"
control={control}
render={() => (
<div className="px-2">
<h3 className="mb-4 text-xl font-semibold">필수 첨부파일</h3>
<Table variant="secondary" className="table-fixed">
<colgroup>
<col width="44%" />
<col width="12%" />
<col width="44%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[30rem]" align="left">
파일명
</TableHead>
<TableHead>파일 확장자</TableHead>
<TableHead>파일첨부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{essentialFiles.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="py-12 text-center text-muted-foreground">
조회된 필수 첨부파일 항목이 없습니다.
<div className="px-2">
<h3 className="mb-4 text-xl font-semibold">필수 첨부파일</h3>
<Table variant="secondary" className="table-fixed">
<colgroup>
<col width="44%" />
<col width="12%" />
<col width="44%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[30rem]" align="left">
파일명
</TableHead>
<TableHead>파일 확장자</TableHead>
<TableHead>파일첨부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{essentialFiles.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="py-12 text-center text-muted-foreground">
조회된 필수 첨부파일 항목이 없습니다.
</TableCell>
</TableRow>
) : (
essentialFiles.map((file) => {
const atflCd = file.atchFileGroupId ?? "";
return (
<TableRow key={file.esntlAtchFileId}>
<TableHead
className="group break-keep font-medium"
data-required={file.esntlAtchYn === "Y"}
align="left"
>
<div className="inline-flex w-[calc(100%-1rem)] items-center justify-between gap-10">
{file.esntlAtchFileNm}
{atflCd ? (
<Button
size="sm"
color="white"
onClick={() => downloadTemplate(atflCd)}
>
<Download className="!size-5 shrink-0" />
양식내려받기
</Button>
) : null}
</div>
</TableHead>
<TableCell align="center">{file.esntlAtchFileRmrk}</TableCell>
<TableCell className="space-y-2">
<AttachmentFileField
files={(uploadedFiles[file.esntlAtchFileId] ?? []).map((item) => ({
fileKey: item.fileKey,
fileName: item.file?.name ?? item.fileName,
onDownload: item.atflId
? () => downloadByAtflId(item.atflId!)
: undefined,
}))}
inputId={`${file.esntlAtchFileId}-file-input`}
isUploadingByKey={isUploading}
accept={toFileAccept(file.esntlAtchFileRmrk) || undefined}
onAdd={(fileList) =>
void handleEssentialFileUpload(file.esntlAtchFileId, fileList)
}
onRemove={(index) =>
void removeEssentialFile(file.esntlAtchFileId, index)
}
/>
</TableCell>
</TableRow>
) : (
essentialFiles.map((file) => {
const atflCd = file.atchFileGroupId ?? "";
return (
<TableRow key={file.esntlAtchFileId}>
<TableHead
className="group break-keep font-medium"
data-required={file.esntlAtchYn === "Y"}
align="left"
>
<div className="inline-flex w-[calc(100%-1rem)] items-center justify-between gap-10">
{file.esntlAtchFileNm}
{atflCd ? (
<Button
size="sm"
color="white"
onClick={() => downloadTemplate(atflCd)}
>
<Download className="!size-5 shrink-0" />
양식내려받기
</Button>
) : null}
</div>
</TableHead>
<TableCell align="center">{file.esntlAtchFileRmrk}</TableCell>
<TableCell className="space-y-2">
<AttachmentFileField
files={(uploadedFiles[file.esntlAtchFileId] ?? []).map((item) => ({
fileKey: item.fileKey,
fileName: item.file?.name ?? item.fileName,
onDownload: item.atflId
? () => downloadByAtflId(item.atflId!)
: undefined,
}))}
inputId={`${file.esntlAtchFileId}-file-input`}
isUploadingByKey={isUploading}
accept={toFileAccept(file.esntlAtchFileRmrk) || undefined}
onAdd={(fileList) =>
void handleEssentialFileUpload(file.esntlAtchFileId, fileList)
}
onRemove={(index) =>
void removeEssentialFile(file.esntlAtchFileId, index)
}
/>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
)}
/>
);
})
)}
</TableBody>
</Table>
</div>
<div className="px-2">
<div className="mb-4 flex items-center justify-between">
@@ -43,12 +43,13 @@ function AttachmentsViewSection({ esntlFileList, etcFileList }: AttachmentsViewS
) : (
<div className="space-y-2">
{files.map((file) => (
<AttachmentDownloadLink
key={file.atflId}
atflId={file.atflId}
fileName={file.orgnlFileNm || "다운로드"}
className="items-center"
/>
<div key={file.atflId}>
<AttachmentDownloadLink
atflId={file.atflId}
fileName={file.orgnlFileNm || "다운로드"}
className="items-center"
/>
</div>
))}
</div>
)}
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useFormContext, useWatch } from "react-hook-form";
import { SectionCollapsible } from "@/shared/ui";
import { Field } from "@/shared/ui/field";
@@ -31,6 +31,16 @@ interface BusinessCostFormData {
type CostFieldKey = "bscPlanAmt" | "bgtRfltAmt" | "tstmDesignAmt";
function isSameBizCstCodes(
current: BusinessCostFormData["bizCstList"],
next: BusinessCostFormData["bizCstList"],
) {
return (
current.length === next.length &&
current.every((item, index) => item.acntgSeCd === next[index]?.acntgSeCd)
);
}
function BusinessCostFormSection() {
const { control, setValue, getValues } = useFormContext<BusinessCostFormData>();
const bizCstList = useWatch({ control, name: "bizCstList" }) ?? [];
@@ -42,22 +52,22 @@ function BusinessCostFormSection() {
useEffect(() => {
if (costItems.length === 0) return;
const existingMap = new Map(getValues("bizCstList").map((item) => [item.acntgSeCd, item]));
const current = getValues("bizCstList") ?? [];
const existingMap = new Map(current.map((item) => [item.acntgSeCd, item]));
const next = costItems.map((item) => {
const existing = existingMap.get(item.code);
if (existing) return existing;
setValue(
"bizCstList",
costItems.map((item) => {
const existing = existingMap.get(item.code);
if (existing) return existing;
return {
acntgSeCd: item.code,
bscPlanAmt: 0,
bgtRfltAmt: 0,
tstmDesignAmt: 0,
};
});
return {
acntgSeCd: item.code,
bscPlanAmt: 0,
bgtRfltAmt: 0,
tstmDesignAmt: 0,
};
}),
);
if (isSameBizCstCodes(current, next)) return;
setValue("bizCstList", next);
}, [costItems, getValues, setValue]);
function handleCostChange(index: number, fieldKey: CostFieldKey, value: string) {
@@ -119,58 +129,37 @@ function BusinessCostFormSection() {
</div>
</TableHead>
<TableCell>
<Controller
name={`bizCstList.${idx}.bgtRfltAmt`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value || ""}
onChange={(e) => handleCostChange(idx, "bgtRfltAmt", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.bgtRfltAmt ?? ""}
onChange={(e) => handleCostChange(idx, "bgtRfltAmt", e.target.value)}
readOnly={item.readonly}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
</TableCell>
<TableCell>
<Controller
name={`bizCstList.${idx}.bscPlanAmt`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value || ""}
onChange={(e) => handleCostChange(idx, "bscPlanAmt", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.bscPlanAmt ?? ""}
onChange={(e) => handleCostChange(idx, "bscPlanAmt", e.target.value)}
readOnly={item.readonly}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
</TableCell>
<TableCell>
<Controller
name={`bizCstList.${idx}.tstmDesignAmt`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value || ""}
onChange={(e) => handleCostChange(idx, "tstmDesignAmt", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.tstmDesignAmt ?? ""}
onChange={(e) => handleCostChange(idx, "tstmDesignAmt", e.target.value)}
readOnly={item.readonly}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
</TableCell>
<TableCell>
<div className="text-right">
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useFormContext, useWatch } from "react-hook-form";
import { SectionCollapsible } from "@/shared/ui";
import { Field } from "@/shared/ui/field";
@@ -17,7 +17,7 @@ import {
TableRow,
} from "@common/components";
import { buildCnstpScaleItems, recalculateBzScaleTotals } from "../lib/business-scale-form-utils";
import { buildCnstpScaleItems, recalculateBzScaleTotals } from "../lib/business-scale-form-utils";
interface BusinessScaleFormData {
bzScaleList: Array<{
@@ -31,6 +31,16 @@ interface BusinessScaleFormData {
type ScaleFieldKey = "bscPl" | "budgetReflct" | "thtiDsgn";
function isSameBzScaleCodes(
current: BusinessScaleFormData["bzScaleList"],
next: BusinessScaleFormData["bzScaleList"],
) {
return (
current.length === next.length &&
current.every((item, index) => item.cnstpSeCd === next[index]?.cnstpSeCd)
);
}
function BusinessScaleFormSection() {
const { control, setValue, getValues } = useFormContext<BusinessScaleFormData>();
const bzScaleList = useWatch({ control, name: "bzScaleList" }) ?? [];
@@ -42,7 +52,8 @@ function BusinessScaleFormSection() {
useEffect(() => {
if (scaleItems.length === 0) return;
const existingMap = new Map(getValues("bzScaleList").map((item) => [item.cnstpSeCd, item]));
const current = getValues("bzScaleList") ?? [];
const existingMap = new Map(current.map((item) => [item.cnstpSeCd, item]));
const next = scaleItems.map((item) => {
const existing = existingMap.get(item.code);
@@ -57,6 +68,7 @@ function BusinessScaleFormSection() {
};
});
if (isSameBzScaleCodes(current, next)) return;
setValue("bzScaleList", recalculateBzScaleTotals(next, scaleItems));
}, [scaleItems, getValues, setValue]);
@@ -112,58 +124,37 @@ function BusinessScaleFormSection() {
</div>
</TableHead>
<TableCell>
<Controller
name={`bzScaleList.${idx}.bscPl`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value || ""}
onChange={(e) => handleScaleChange(idx, "bscPl", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.bscPl ?? ""}
onChange={(e) => handleScaleChange(idx, "bscPl", e.target.value)}
readOnly={item.readonly}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
</TableCell>
<TableCell>
<Controller
name={`bzScaleList.${idx}.budgetReflct`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value || ""}
onChange={(e) => handleScaleChange(idx, "budgetReflct", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.budgetReflct ?? ""}
onChange={(e) => handleScaleChange(idx, "budgetReflct", e.target.value)}
readOnly={item.readonly}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
</TableCell>
<TableCell>
<Controller
name={`bzScaleList.${idx}.thtiDsgn`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value || ""}
onChange={(e) => handleScaleChange(idx, "thtiDsgn", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.thtiDsgn ?? ""}
onChange={(e) => handleScaleChange(idx, "thtiDsgn", e.target.value)}
readOnly={item.readonly}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
</TableCell>
<TableCell>
<div className="text-right">
@@ -1,18 +1,15 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useFormContext, useWatch } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldLabel } from "@/shared/ui/field";
import { cklstMngService, type CklstTreeNode } from "@/entities/cklst";
import { useCodeList } from "@/entities/code";
import {
Button,
RadioGroup,
RadioGroupItem,
Skeleton,
Table,
TableBody,
@@ -125,11 +122,21 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
}
}, [checklistTabs, activeTab]);
function handleSelectChange(resultIdx: number, value: "Y" | "N") {
const currentList = getValues("cklstResultList") as CklstResultListItem[];
const row = currentList[resultIdx];
if (!row) return;
const newList = [...currentList];
newList[resultIdx] = { ...row, selected: value };
setValue("cklstResultList", newList, { shouldDirty: true });
}
function handleSelectAll(tabCode: string, value: "Y" | "N") {
const tab = checklistTabs.find((t) => t.code === tabCode);
if (!tab) return;
const currentList = getValues("cklstResultList");
const currentList = getValues("cklstResultList") as CklstResultListItem[];
const newList = [...currentList];
tab.items
@@ -141,7 +148,7 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
}
});
setValue("cklstResultList", newList);
setValue("cklstResultList", newList, { shouldDirty: true });
}
const hasSelectedTaskCodes = Boolean(taskSeComCdArray?.length);
@@ -234,6 +241,8 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
{tab.items.map((item) => {
const resultIdx = cklstRvwSnIndexMap.get(item.cklstRvwSn) ?? -1;
const showRadios = item.bchckUseYn === "Y";
const selected = cklstResultList[resultIdx]?.selected;
const radioName = `check-${item.cklstRvwSn}`;
return (
<TableRow key={item.cklstRvwSn}>
@@ -248,66 +257,30 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
{item.content}
</TableCell>
{showRadios && resultIdx >= 0 ? (
<Controller
name={`cklstResultList.${resultIdx}.selected`}
control={control}
render={({ field, fieldState }) => (
<>
<TableCell className="text-center">
<RadioGroup
name={field.name}
value={field.value === "Y" ? "Y" : undefined}
onValueChange={field.onChange}
className="flex justify-center"
>
<FieldLabel
htmlFor={`check-${item.cklstRvwSn}-Y`}
className="!w-auto justify-center border-none"
>
<Field
orientation="horizontal"
data-invalid={fieldState.invalid}
className="w-auto"
>
<RadioGroupItem
value="Y"
id={`check-${item.cklstRvwSn}-Y`}
aria-invalid={fieldState.invalid}
aria-label="예"
/>
</Field>
</FieldLabel>
</RadioGroup>
</TableCell>
<TableCell className="text-center">
<RadioGroup
name={field.name}
value={field.value === "N" ? "N" : undefined}
onValueChange={field.onChange}
className="flex justify-center"
>
<FieldLabel
htmlFor={`check-${item.cklstRvwSn}-N`}
className="!w-auto justify-center border-none"
>
<Field
orientation="horizontal"
data-invalid={fieldState.invalid}
className="w-auto"
>
<RadioGroupItem
value="N"
id={`check-${item.cklstRvwSn}-N`}
aria-invalid={fieldState.invalid}
aria-label="아니오"
/>
</Field>
</FieldLabel>
</RadioGroup>
</TableCell>
</>
)}
/>
<>
<TableCell className="text-center">
<input
type="radio"
name={radioName}
value="Y"
checked={selected === "Y"}
onChange={() => handleSelectChange(resultIdx, "Y")}
aria-label="예"
className="cursor-pointer"
/>
</TableCell>
<TableCell className="text-center">
<input
type="radio"
name={radioName}
value="N"
checked={selected === "N"}
onChange={() => handleSelectChange(resultIdx, "N")}
aria-label="아니오"
className="cursor-pointer"
/>
</TableCell>
</>
) : null}
</TableRow>
);
@@ -1,18 +1,17 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useFormContext, useWatch } from "react-hook-form";
import { getDefaultFileKey, useTus } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { SectionCollapsible } from "@/shared/ui";
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
import { Field } from "@/shared/ui/field";
import { useAcntgSeMngTree } from "@/entities/acntg";
import { useDownloadAttachmentByAtflId } from "@/entities/attach";
import type { AtflMngClientResponseDto } from "@/entities/fnrs-cnsltn";
import type { FnrsSharBizCstTreeDto } from "@/entities/fnrs-shar-se";
import { useFnrsSharSeMngTree } from "@/entities/fnrs-shar-se";
import {
Button,
cn,
Input,
Skeleton,
@@ -37,6 +36,7 @@ import { calcDiffPercent } from "../lib/format-utils";
import { BizCstTotalDisplayTable } from "./biz-cst-total-display-table";
const SHAR_FILE_KEY_PREFIX = "fnrs-shar::";
const SHAR_FILE_ACCEPT = ".hwp,.hwpx";
interface FnrsSharFormData {
fnrsSharList: FnrsSharListItem[];
@@ -53,6 +53,12 @@ interface FnrsSharFormData {
type SharFieldKey = "exstnAmt" | "chgRqtAmt" | "rmrkCn";
interface SharUploadedFile {
fileKey: string;
fileName: string;
atflId?: string;
}
interface FnrsSharFormSectionProps {
initialSharTree?: FnrsSharBizCstTreeDto[];
initialSharAtchFiles?: AtflMngClientResponseDto[];
@@ -62,12 +68,30 @@ function createSharUploadKey(file: File) {
return `${SHAR_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function toInitialSharFile(file?: AtflMngClientResponseDto): SharUploadedFile | null {
if (!file?.orgnlFileNm) return null;
return {
fileKey: file.atflId ? `server::${file.atflId}` : `${SHAR_FILE_KEY_PREFIX}${file.atflCd}`,
fileName: file.orgnlFileNm,
atflId: file.atflId,
};
}
function isSameFnrsSharCodes(current: FnrsSharListItem[], next: FnrsSharListItem[]) {
return (
current.length === next.length &&
current.every((item, index) => item.fnrsSharSeCd === next[index]?.fnrsSharSeCd)
);
}
function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsSharFormSectionProps) {
const { control, setValue, getValues } = useFormContext<FnrsSharFormData>();
const fnrsSharList = useWatch({ control, name: "fnrsSharList" }) ?? [];
const [uploadedFileName, setUploadedFileName] = useState(
() => initialSharAtchFiles?.[0]?.orgnlFileNm ?? "",
const [uploadedFile, setUploadedFile] = useState<SharUploadedFile | null>(() =>
toInitialSharFile(initialSharAtchFiles?.[0]),
);
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
const { data: masterTree = [], isLoading: isMasterLoading } = useFnrsSharSeMngTree();
const { data: acntgTree = [] } = useAcntgSeMngTree();
@@ -78,9 +102,13 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
const isLoading = initialSharTree?.length ? false : isMasterLoading;
const { uploadFileAsync, isUploading } = useTus({
onSuccess: (atflCd) => {
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd) return;
setValue("fnrsCnsltnMng.sharAtchFileGroupId", atflCd, { shouldDirty: true });
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
const isSharUploading = Object.values(isUploading).some(Boolean);
@@ -96,6 +124,7 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
: mergeFnrsSharTreeWithForm(sharTree, []);
const withTotals = recalculateFnrsSharTotals(merged, sharItems);
if (isSameFnrsSharCodes(existing, withTotals)) return;
setValue("fnrsSharList", withTotals);
}, [sharItems, sharTree, initialSharTree, getValues, setValue]);
@@ -120,16 +149,32 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
applySharList(draft);
}
async function handleSharFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
async function handleSharFileAdd(files: FileList | null) {
const file = files?.[0];
if (!file) return;
const fileKey = createSharUploadKey(file);
setUploadedFileName(file.name);
await uploadFileAsync(file, { fileKey });
event.target.value = "";
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, { fileKey });
} catch {
// onError callback handles cleanup
}
}
const sharAtchFiles = uploadedFile
? [
{
fileKey: uploadedFile.fileKey,
fileName: uploadedFile.fileName,
onDownload: uploadedFile.atflId
? () => downloadByAtflId(uploadedFile.atflId!)
: undefined,
},
]
: [];
if (isLoading) {
return (
<SectionCollapsible title="재원분담">
@@ -189,40 +234,26 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
{item.label}
</TableHead>
<TableCell>
<Controller
name={`fnrsSharList.${idx}.exstnAmt`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value ?? ""}
onChange={(e) => handleSharChange(idx, "exstnAmt", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={cn("text-right", item.readonly && "bg-muted")}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.exstnAmt ?? ""}
onChange={(e) => handleSharChange(idx, "exstnAmt", e.target.value)}
readOnly={item.readonly}
className={cn("text-right", item.readonly && "bg-muted")}
/>
</Field>
</TableCell>
<TableCell>
<Controller
name={`fnrsSharList.${idx}.chgRqtAmt`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value ?? ""}
onChange={(e) => handleSharChange(idx, "chgRqtAmt", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={cn("text-right", item.readonly && "bg-muted")}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.chgRqtAmt ?? ""}
onChange={(e) => handleSharChange(idx, "chgRqtAmt", e.target.value)}
readOnly={item.readonly}
className={cn("text-right", item.readonly && "bg-muted")}
/>
</Field>
</TableCell>
<TableCell>
<div className="text-right">
@@ -233,19 +264,12 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
<div className="text-right">{calcDiffPercent(diff, data.exstnAmt || 0)}</div>
</TableCell>
<TableCell>
<Controller
name={`fnrsSharList.${idx}.rmrkCn`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
value={field.value ?? ""}
onChange={(e) => handleSharChange(idx, "rmrkCn", e.target.value)}
aria-invalid={fieldState.invalid}
/>
</Field>
)}
/>
<Field>
<Input
value={data.rmrkCn ?? ""}
onChange={(e) => handleSharChange(idx, "rmrkCn", e.target.value)}
/>
</Field>
</TableCell>
</TableRow>
);
@@ -257,31 +281,20 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
<div className="mt-6 px-2 pb-2">
<h3 className="mb-2 text-xl font-semibold">첨부파일</h3>
<div className="flex flex-wrap items-center gap-2">
{isSharUploading && uploadedFileName ? (
<TextShimmer className="max-w-lg text-xl" active>
{uploadedFileName}
</TextShimmer>
) : (
<Input
value={uploadedFileName}
readOnly
placeholder="총사업비 신청서(.hwp) 파일을 등록해주세요."
className="max-w-lg rounded-sm"
/>
)}
<Button type="button" color="white" disabled={isSharUploading} asChild>
<label className="cursor-pointer">
첨부파일
<input
type="file"
accept=".hwp,.hwpx"
className="hidden"
onChange={handleSharFileChange}
/>
</label>
</Button>
</div>
{!uploadedFile ? (
<p className="mb-2 px-1 text-xl text-muted-foreground">
총사업비 신청서(.hwp) 파일을 등록해주세요.
</p>
) : null}
<AttachmentFileField
files={sharAtchFiles}
inputId="fnrs-shar-atch-file-input"
isUploadingByKey={isUploading}
accept={SHAR_FILE_ACCEPT}
multiple={false}
addDisabled={isSharUploading}
onAdd={(fileList) => void handleSharFileAdd(fileList)}
/>
</div>
</SectionCollapsible>
);
@@ -5,7 +5,12 @@ import { Controller, useFormContext, useWatch } from "react-hook-form";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
import { AttachmentDownloadLink, attachService, useDownloadAttachmentByAtflId } from "@/entities/attach";
import {
AttachmentDownloadLink,
attachService,
useDownloadAttachmentByAtflId,
} from "@/entities/attach";
import { FnrsCnsltnSplmntDmndResponseDto } from "@/entities/fnrs-cnsltn";
import {
Badge,
Table,
@@ -46,6 +51,7 @@ interface SplmntDmndFormData {
}
interface SplmntDmndFormSectionProps {
initialData?: FnrsCnsltnSplmntDmndResponseDto[];
readOnly?: boolean;
}
@@ -65,7 +71,7 @@ function getRowKey(item: SplmntDmndFormItem, index: number) {
return item.splmntDmndSn != null ? String(item.splmntDmndSn) : `idx-${index}`;
}
function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps) {
function SplmntDmndFormSection({ initialData, readOnly = false }: SplmntDmndFormSectionProps) {
const formId = useId();
const { control, setValue, getValues } = useFormContext<SplmntDmndFormData>();
const splmntDmndList = useWatch({ control, name: "splmntDmndList" }) ?? [];
@@ -260,6 +266,7 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
const isRowUploading = rowFiles.some((file) => isUploading[file.fileKey] ?? false);
const fileInputId = `${formId}-${rowKey}-file`;
const isRowReadOnly = readOnly || item.cmptnYn === "Y";
const splmntAtchFileInfo = initialData?.[index]?.splmntAtchFileInfo;
return (
<TableRow key={rowKey}>
@@ -282,7 +289,15 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
</TableCell>
<TableCell className="align-top">
{isRowReadOnly ? (
<span className="whitespace-pre-wrap">{item.splmntCn || "-"}</span>
<div className="space-y-2">
<div className="whitespace-pre-wrap">{item.splmntCn || "-"}</div>
{splmntAtchFileInfo?.atflCd ? (
<AttachmentDownloadLink
atflCd={splmntAtchFileInfo?.atflCd}
fileName={splmntAtchFileInfo?.orgnlFileNm}
/>
) : null}
</div>
) : (
<Controller
control={control}
@@ -292,7 +307,7 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
value={field.value ?? ""}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="보완완료 내용을 입력하세요."
placeholder="보완 완료 내용을 입력하세요."
className="min-h-[6rem] !text-xl"
/>
)}
@@ -185,7 +185,9 @@ export default function DemandStatusPage() {
const userData = JSON.parse(user) as SessionUser;
const dmdInfoId = userData.dmdInfoId?.trim();
if (dmdInfoId) {
getSdmInfoStatusList(dmdInfoId);
void getSdmInfoStatusList(dmdInfoId).then((active) => {
if (!active) window.location.href = "/";
});
}
} catch (error) {
console.error("Error parsing user data:", error);
@@ -341,7 +343,9 @@ export default function DemandStatusPage() {
if (res) {
const user = JSON.parse(sessionStorage.getItem("user") || "{}");
if (user?.dmdInfoId) {
getSdmInfoStatusList(user.dmdInfoId);
void getSdmInfoStatusList(user.dmdInfoId).then((active) => {
if (!active) window.location.href = "/";
});
}
setIsEditMode(false);
}
+48 -38
View File
@@ -211,18 +211,32 @@ const selectMiddleRegionData: SelectDataType[] = [
* STORE - API
*/
/** API 선택 목록의 빈 값과 중복 값을 제거하고 전체 항목을 한 번만 추가한다. */
function normalizeSelectData<T extends SelectDataType>(items: T[], allItem: T): T[] {
const uniqueItems = new Map<string, T>();
items.forEach((item) => {
const val = String(item.val ?? '').trim();
if (!val || val === '0' || uniqueItems.has(val)) return;
uniqueItems.set(val, { ...item, val });
});
return [allItem, ...uniqueItems.values()];
}
// 시도 API 호출 함수 추가
const fetchSidoData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch(fetchType.FO, '/addr/city');
const data = res.data;
selectSidoData = [
{ text: "전체", val: '0'},
...data.map((item: sidoDataType) => ({
text: item.sido || '',
val: String(item.adstDstrCd) || ''
}))
];
selectSidoData = normalizeSelectData(
data.map((item: sidoDataType) => ({
text: item.sido || '',
val: String(item.adstDstrCd) || ''
})),
{ text: "전체", val: '0' }
);
return selectSidoData;
} catch (error) {
console.error('시도 데이터를 가져오는데 실패했습니다:', error);
@@ -235,14 +249,13 @@ const fetchSigunData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch(fetchType.FO, '/addr/sgg');
const data = res.data;
selectSigunData = [
{ text: "전체", val: '0' },
...data
.map((item: sigunDataType) => ({
text: item.gugun || '',
val: String(item.adstDstrCd) || ''
}))
];
selectSigunData = normalizeSelectData(
data.map((item: sigunDataType) => ({
text: item.gugun || '',
val: String(item.adstDstrCd) || ''
})),
{ text: "전체", val: '0' }
);
return selectSigunData;
} catch (error) {
console.error('시군구 데이터를 가져오는데 실패했습니다:', error);
@@ -255,13 +268,13 @@ const fetchRegionData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch(fetchType.FO, '/addr/sarea');
const data = res.data;
return [
{ text: "전체", val: '0'},
...data.map((item: regionDataType) => ({
return normalizeSelectData(
data.map((item: regionDataType) => ({
text: item.rvbsn || '',
val: String(item.rvbsnCd) || ''
}))
];
})),
{ text: "전체", val: '0' }
);
} catch (error) {
console.error('권역 데이터를 가져오는데 실패했습니다:', error);
return selectRegionData;
@@ -273,14 +286,13 @@ const fetchMiddleRegionData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch(fetchType.FO, '/addr/midArea');
const data = res.data;
return [
{ text: "전체", val: '0' },
...data
.map((item: middleRegionDataType) => ({
return normalizeSelectData(
data.map((item: middleRegionDataType) => ({
text: item.mdmbsn || '',
val: String(item.rvbsnCd) || ''
}))
];
})),
{ text: "전체", val: '0' }
);
} catch (error) {
console.error('중권역 데이터를 가져오는데 실패했습니다:', error);
return selectMiddleRegionData;
@@ -292,14 +304,13 @@ const fetchUnitWaterShedData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch(fetchType.FO, '/addr/largeArea');
const data = res.data;
return [
{ text: "전체", val: '0' },
...data
.map((item: unitWaterShedDataType) => ({
return normalizeSelectData(
data.map((item: unitWaterShedDataType) => ({
text: item.lgbsn || '',
val: String(item.rvbsnCd) || ''
}))
];
})),
{ text: "전체", val: '0' }
);
} catch (error) {
console.error('단위유역 데이터를 가져오는데 실패했습니다:', error);
return selectUnitWaterShedData;
@@ -311,15 +322,14 @@ const fetchEnvPartData = async (): Promise<envPartSelectDataType[]> => {
try {
const res = await getFetch(fetchType.FO, '/addr/govArea');
const data = res.data;
selectEnvPartData = [
{ text: "전체", val: '0', adstDliStrCd: '' },
...data
.map((item: envPartDataType) => ({
selectEnvPartData = normalizeSelectData(
data.map((item: envPartDataType) => ({
text: item.lclgvrnmntNm || '',
val: String(item.lclgvrnmntCd) || '',
adstDliStrCd: item.adstDliStrCd || ''
}))
];
})),
{ text: "전체", val: '0', adstDliStrCd: '' }
);
return selectEnvPartData;
} catch (error) {
console.error('환경청 데이터를 가져오는데 실패했습니다:', error);
+53 -24
View File
@@ -134,22 +134,23 @@ export type SupplyCandidate = {
export type DemandMatchings = {
splyFcltFk: string | '';
splyFcltFlg: string | '';
pbnstRuseFcltBizFk: number | '';
splyBgngDay: string | '';
splyEndDay: string | '';
splyCpct: number | '';
splyFmYn: string | '';
useUsgCd: string | '';
dmdInfoSn: number | '';
splyCndtList: string | '';
mtchnCmptnYmd: string | '';
rvwCmptnYmd: string | '';
regDt: string | '';
rgtr: string | '';
mdfcnDt: string | '';
mdfr: string | '';
splyFcltFk: string | ''; // 공급시설 외래키
splyFcltFlg: string | ''; // 공급시설 진행상태
pbnstRuseFcltBizFk: number | ''; // 공공하수 재이용시설 사업 외래키
splyBgngDay: string | ''; // 공급시작일
splyEndDay: string | ''; // 공급종료일
splyCpct: number | ''; // 계약 공급량
splyFmYn: string | ''; // 공급 유량계 유무
useUsgCd: string | ''; // 사용용도 코드
dmdInfoSn: number | ''; // 수요정보 일련번호
ruseUtztnFcltNm?: string | ''; // 재이용시설명
splyCndtList: string | ''; // 공급 후보지 목록
mtchnCmptnYmd: string | ''; // 매칭 완료일자
rvwCmptnYmd: string | ''; // 검토 완료일자
regDt: string | ''; // 등록일시
rgtr: string | ''; // 등록자
mdfcnDt: string | ''; // 수정일시
mdfr: string | ''; // 수정자
}
// 유사사례
@@ -503,6 +504,14 @@ const postSdmData = async (data: DemandCompType) => {
// 수요정보 등록
const postSdmInfoData = async (data: SdmInfoFormData): Promise<boolean> => {
try {
const activeDemand = data.dmdInfoId
? await getSdmInfoStatusList(data.dmdInfoId)
: null;
if (!activeDemand?.dmdInfoId) {
sessionStorage.removeItem("user");
alert('사용 중지되었거나 등록되지 않은 수요처입니다. 다시 로그인해 주세요.');
return false;
}
await postFetch("/reuseFc/request/demandInfo", data);
alert('수요 정보 등록에 성공하였습니다.');
return true;
@@ -516,6 +525,14 @@ const postSdmInfoData = async (data: SdmInfoFormData): Promise<boolean> => {
// 수요정보 수정
const fetchUpdateSdmInfoData = async (data: SdmInfoFormData): Promise<boolean> => {
try {
const activeDemand = data.dmdInfoId
? await getSdmInfoStatusList(data.dmdInfoId)
: null;
if (!activeDemand?.dmdInfoId) {
sessionStorage.removeItem("user");
alert('사용 중지되었거나 등록되지 않은 수요처입니다. 다시 로그인해 주세요.');
return false;
}
await postFetch("/reuseFc/request/updateDemandInfo", data);
alert('수요 정보 수정에 성공하였습니다.');
return true;
@@ -584,14 +601,19 @@ const getDemandCompList = async (searchText: string, pageCnt: number) => {
}
}
const getSdmInfoStatusList = async (dmdInfoId: string) => {
type DemandStatusResponse = DemandCompType & {
demandInfo?: DemandCompBizType[];
demandMatchings?: DemandMatchings[];
};
const getSdmInfoStatusList = async (dmdInfoId: string): Promise<DemandStatusResponse | null> => {
try {
const url = '/reuseFc/request/' + dmdInfoId + '?infoListYn=Y';
const res = await getFetch(fetchType.WR, url);
return res?.data || [];
return res?.data ?? null;
} catch (error) {
console.error(error + ' 수요정보 신청현황을 불러오지 못했습니다.');
return [];
return null;
}
}
@@ -620,7 +642,7 @@ export type SdmActions = {
updateSdmInfoData: (data: SdmInfoFormData) => Promise<boolean>;
getDemandCompList: (searchText: string, pageCnt: number) => Promise<void>;
actionLogin: (data: DemandCompType) => Promise<boolean>;
getSdmInfoStatusList: (dmdInfoId: string) => Promise<void>;
getSdmInfoStatusList: (dmdInfoId: string) => Promise<boolean>;
getSimilarCaseList: (part: string, value: number | string) => Promise<void>;
actionLogout: () => Promise<void>;
@@ -736,19 +758,25 @@ export const SdmReducer = (set: any): SdmActions => {
getSdmInfoStatusList: async (dmdInfoId: string) => {
const res = await getSdmInfoStatusList(dmdInfoId);
if (res.length === 0) {
if (!res?.dmdInfoId) {
sessionStorage.removeItem("user");
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
sdmInfoStatusList: [],
successLogin: false,
loginUser: null,
}
}))
return;
toast.error("사용 중지되었거나 등록되지 않은 수요처입니다.");
return false;
}
//const sdmInfoStatusList : DemandCompBizType[] = [];
const sdmInfoStatusList: DemandCompBizType[] = res.demandInfo.map((el: DemandCompBizType, index: number) => {
const matchingInfo = res.demandMatchings.find((matching: DemandMatchings) => matching.dmdInfoSn === el.dmdInfoSn);
const demandInfo = Array.isArray(res.demandInfo) ? res.demandInfo : [];
const demandMatchings = Array.isArray(res.demandMatchings) ? res.demandMatchings : [];
const sdmInfoStatusList: DemandCompBizType[] = demandInfo.map((el: DemandCompBizType, index: number) => {
const matchingInfo = demandMatchings.find((matching: DemandMatchings) => matching.dmdInfoSn === el.dmdInfoSn);
const status = matchingInfo?.splyFcltFlg?.trim() === 'Sub' ? '검토' : matchingInfo?.splyFcltFlg?.trim() === 'matched' ? '매칭완료' : '신청';
const ruseUtztnFcltNm = matchingInfo?.ruseUtztnFcltNm ? matchingInfo.ruseUtztnFcltNm : '';
@@ -781,6 +809,7 @@ export const SdmReducer = (set: any): SdmActions => {
sdmInfoStatusList: sdmInfoStatusList,
}
}))
return true;
},
actionLogin: async (data: DemandCompType): Promise<boolean> => {
+4 -2
View File
@@ -8,8 +8,10 @@ NEXT_PUBLIC_PROXY_HOST = http://localhost:3000
NEXT_PUBLIC_SUB_DOMAIN = http://localhost:13000
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://localhost:3001/auth/login
SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
# FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
FO_SERVER_API_URL = http://localhost:3000/operation-service/api/v1
# WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
WR_SERVER_API_URL = http://localhost:3000/reuse-service/api/v1
CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
+34 -18
View File
@@ -237,6 +237,26 @@ export const COD_YEAR = 2021;
* STORE - API
*/
/** API 선택 목록을 값 기준으로 정규화하고 프런트 표준 전체 항목을 한 번만 추가한다. */
const normalizeSelectData = (items: SelectDataType[]): SelectDataType[] => {
const uniqueItems = new Map<string, SelectDataType>();
items.forEach((item) => {
const val = String(item.val ?? '').trim();
if (!val || val === '0' || uniqueItems.has(val)) return;
uniqueItems.set(val, {
...item,
val,
});
});
return [
{ text: '전체', val: '0' },
...uniqueItems.values(),
];
};
// 처리시설 계획정보 호출 (알에스원)
export const planInfoUrl = '/dc-service/api/v1/nowDmgMtncGnsvy';
@@ -263,13 +283,12 @@ const fetchSidoData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch('fo', '/addr/city');
const data = res.data;
selectSidoData = [
{ text: "전체", val: '0' },
...data.map((item: sidoDataType) => ({
selectSidoData = normalizeSelectData(
data.map((item: sidoDataType) => ({
text: item.sido || '',
val: String(item.adstDstrCd) || ''
}))
];
})),
);
return selectSidoData;
} catch (error) {
console.error('시도 데이터를 가져오는데 실패했습니다:', error);
@@ -282,14 +301,12 @@ const fetchSigunData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch('fo', '/addr/sgg');
const data = res.data;
selectSigunData = [
{ text: "전체", val: '0' },
...data
.map((item: sigunDataType) => ({
text: item.gugun || '',
val: String(item.adstDstrCd) || ''
}))
];
selectSigunData = normalizeSelectData(
data.map((item: sigunDataType) => ({
text: item.gugun || '',
val: String(item.adstDstrCd) || ''
})),
);
return selectSigunData;
} catch (error) {
console.error('시군구 데이터를 가져오는데 실패했습니다:', error);
@@ -302,13 +319,12 @@ const fetchRegionData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch('fo', '/addr/sarea');
const data = res.data;
return [
{ text: "전체", val: '0' },
...data.map((item: regionDataType) => ({
return normalizeSelectData(
data.map((item: regionDataType) => ({
text: item.rvbsn || '',
val: String(item.rvbsnCd) || ''
}))
];
})),
);
} catch (error) {
console.error('권역 데이터를 가져오는데 실패했습니다:', error);
return selectRegionData;
+2
View File
@@ -228,6 +228,8 @@ VSCode나 별도 터미널에서 `sn` Next 개발 서버가 실행 중이면 `ne
### 프론트엔드 실행 프로세스 관리
- 모든 세션·역할·문서/검증 섹션에 공통 D:/workspace/AGENTS.md의 테스트 임시 파일·결과 파일 정리 기준을 적용한다. 일회성 검증 스크립트·캡처·Network/서버 로그·원시 결과 파일은 최종 보고서에 요약 후 삭제한다. 정식 회귀 테스트·최종 산출물·사용자 원본/복구 백업은 유지한다. 사용자 dev server가 쓰는 .next·공유 캐시·node_modules는 임시 검증물이라는 이유로 포괄 삭제하지 않는다.
- Codex 에이전트가 작업 또는 검증을 위해 직접 기동한 `next dev`, `dev:sn`, Turborepo dev와 기타 프론트엔드 장시간 실행 프로세스는 작업 종료 전에 반드시 종료한다.
- 셸, 백그라운드, 숨김 창 등 VSCode 밖에서 에이전트가 기동한 프로세스도 종료 대상이다.
- 사용자가 VSCode 또는 별도 터미널에서 직접 실행한 프론트엔드 개발 서버는 임의로 종료·재시작·교체하지 않는다. 종료가 필요하면 먼저 사용자에게 확인한다.
@@ -7,6 +7,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Input } from "@common/components";
import {
getStandardRowNumber,
StandardDataTable,
StandardPopupLayout,
} from "@common/business_components";
@@ -66,7 +67,15 @@ export default function AgedPipeStatusByCtpvPopupPage() {
const yearOptions = useMemo(() => buildAgedPipeStatusYearOptions(), []);
const columns = useMemo<ColumnDef<AgedPipeStatusByCtpvRow>[]>(
() => [
{ accessorKey: "rowNumber", header: "순번" },
{
id: "rowNumber",
header: "순번",
cell: ({ row }) =>
getStandardRowNumber({
total: rows.length,
rowIndex: row.index,
}),
},
{ accessorKey: "ctpvNm", header: "시도" },
...years.map((year) => ({
id: `year_${year}`,
@@ -87,7 +96,7 @@ export default function AgedPipeStatusByCtpvPopupPage() {
],
})),
],
[years],
[rows.length, years],
);
const pagination: PaginationState = {
@@ -118,7 +127,7 @@ export default function AgedPipeStatusByCtpvPopupPage() {
elapsedYears: String(normalized.elapsedYears),
});
const rawResponse = await getRawJson(
`v1/swagePpl/aged-status-by-ctpv?${queryParams.toString()}`,
`v1/swagePpl/agedStatusByCtpv?${queryParams.toString()}`,
);
const response = normalizeAgedPipeStatusByCtpvResponse(
rawResponse,
@@ -292,7 +301,6 @@ export default function AgedPipeStatusByCtpvPopupPage() {
table={table}
className="!w-full !max-w-full"
title="시도별 노후관로 목록"
total={rows.length}
scrollMode="horizontal"
minTableWidth={years.length > 0 ? "120rem" : "100%"}
actions={
@@ -23,7 +23,7 @@ export const downloadAgedPipeStatusByCtpvExcel = async (
elapsedYears: String(normalized.elapsedYears),
});
const response: any = await getRawJson(
`v1/swagePpl/aged-status-by-ctpv?${params.toString()}`,
`v1/swagePpl/agedStatusByCtpv?${params.toString()}`,
);
const payload = (response?.data ?? response) as AgedPipeStatusByCtpvResponse;
const years = Array.isArray(payload?.years)
@@ -4,7 +4,8 @@
*/
"use client";
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
import { type ComponentType, type ReactNode,useEffect, useState } from "react";
import {
ChartColumnBig,
ChevronUp,
@@ -15,13 +16,15 @@ import {
TriangleAlert,
Waves,
} from "lucide-react";
import { getRawJson } from "@/components/lib/api";
import { createPredictionAnalysisTimeRange } from "@/features/fldngPredcPrst/model/analysisTimeRange";
import {
windowPopupDefinitions,
type WindowPopupKey,
} from "@/features/windowPopup/model/popupDefinitions";
import { getRawJson } from "@/components/lib/api";
type Props = {
onOpenPopup: (popupKey: WindowPopupKey) => Window | null;
onOpenInfoSearch: () => void;
@@ -175,8 +178,8 @@ export default function AnalysisPopupMenu({
type="button"
className={`pointer-events-auto flex h-[40px] w-[40px] shrink-0 items-center justify-center overflow-hidden rounded-[3px] p-0 shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)] ${
isOpen
? "bg-[#0279FA] text-white"
: "bg-white text-[#666666] hover:bg-[#0279FA] hover:text-white"
? "bg-primary text-primary-foreground"
: "bg-background text-label hover:bg-primary hover:text-primary-foreground"
}`}
onClick={() => setIsOpen((value) => !value)}
title={isOpen ? "분석 메뉴 숨기기" : "분석도구"}
@@ -192,14 +195,14 @@ export default function AnalysisPopupMenu({
id="analysis-popup-menu"
className="pointer-events-auto flex w-max min-w-[154px] flex-col overflow-hidden rounded-[6px] bg-background shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)]"
>
<div className="flex h-[36px] shrink-0 items-center justify-between gap-2 border-b border-border bg-white pl-[10px] pr-[4px] text-[13px] font-bold text-foreground">
<div className="flex h-[36px] shrink-0 items-center justify-between gap-2 border-b border-border bg-background pl-[10px] pr-[4px] text-[13px] font-bold text-foreground">
<div className="flex items-center gap-2">
<PanelsTopLeft className="h-4 w-4 text-primary" />
<span>분석도구</span>
</div>
<button
type="button"
className="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-[4px] text-label hover:bg-accent hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#0279FA]"
className="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-[4px] text-label hover:bg-accent hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
onClick={() => setIsOpen(false)}
title="분석 메뉴 숨기기"
aria-label="분석 메뉴 숨기기"
@@ -224,7 +227,7 @@ export default function AnalysisPopupMenu({
<button
key={item.key}
type="button"
className={`relative flex h-[33px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#0279FA] ${
className={`relative flex h-[33px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary ${
isActive
? "border-primary bg-secondary font-bold text-primary before:absolute before:bottom-[7px] before:left-0 before:top-[7px] before:w-[3px] before:rounded-r-[2px] before:bg-primary"
: "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary"
@@ -457,7 +457,7 @@ export default function FldngHstryPopupPage() {
onRowClick={handleRowClick}
rowId={(row, index) => String(row.fid ?? index)}
scrollMode="horizontal"
minTableWidth="220rem"
minTableWidth="140rem"
actions={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
@@ -22,8 +22,8 @@ export const loadFldngHstryDetail = async (
fldngHstryFid: string,
): Promise<FldngHstryDetailCacheValue> => {
const [rainResult, depthResult] = await Promise.allSettled([
getData(`v1/fldng/hstry/rain-anls/${encodeURIComponent(fldngHstryFid)}`),
getData(`v1/fldng/hstry/depth-map/${encodeURIComponent(fldngHstryFid)}`),
getData(`v1/fldng/hstry/rainAnls/${encodeURIComponent(fldngHstryFid)}`),
getData(`v1/fldng/hstry/depthMap/${encodeURIComponent(fldngHstryFid)}`),
]);
if (rainResult.status === "rejected") {
@@ -127,21 +127,21 @@ export const FLDNG_HSTRY_DEPTH_LEGEND_ITEMS = [
{ color: "#f2528b", label: "5.0m 이상" },
] as const;
export const FLDNG_HSTRY_LIST_HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "순번", widthPercent: 3 },
{ id: "floodYearDisplay", title: "침수\n년도", widthPercent: 4 },
{ id: "ctpvNm", title: "시도", widthPercent: 7 },
{ id: "sggNm", title: "시군구", widthPercent: 8 },
{ id: "disasterName", title: "침수재해명", widthPercent: 20 },
{ id: "rainBgngTmDisplay", title: "강우\n시작일시", widthPercent: 9 },
{ id: "rainEndTmDisplay", title: "강우\n종료일시", widthPercent: 9 },
{ id: "rainTotDisplay", title: "총강우량(mm)", widthPercent: 8 },
{ id: "rainStrDisplay", title: "강우강도(mm/hr)", widthPercent: 8 },
{ id: "floodHourDisplay", title: "침수시간", widthPercent: 7 },
{ id: "districtName", title: "침수지구명", widthPercent: 10 },
{ id: "avgFloodDepthDisplay", title: "평균\n침수위", widthPercent: 7 },
{ id: "floodAreaDisplay", title: "침수\n면적(ha)", widthPercent: 7 },
{ id: "exmnYearDisplay", title: "조사년도", widthPercent: 6 },
{ id: "damageAmountDisplay", title: "피해금액", widthPercent: 9 },
{ id: "rowNo", title: "순번" },
{ id: "floodYearDisplay", title: "침수\n년도" },
{ id: "ctpvNm", title: "시도" },
{ id: "sggNm", title: "시군구" },
{ id: "disasterName", title: "침수재해명" },
{ id: "rainBgngTmDisplay", title: "강우\n시작일시" },
{ id: "rainEndTmDisplay", title: "강우\n종료일시" },
{ id: "rainTotDisplay", title: "총강우량(mm)" },
{ id: "rainStrDisplay", title: "강우강도(mm/hr)" },
{ id: "floodHourDisplay", title: "침수시간" },
{ id: "districtName", title: "침수지구명" },
{ id: "avgFloodDepthDisplay", title: "평균\n침수위" },
{ id: "floodAreaDisplay", title: "침수\n면적(ha)" },
{ id: "exmnYearDisplay", title: "조사년도" },
{ id: "damageAmountDisplay", title: "피해금액" },
];
export const FLDNG_HSTRY_DOWNLOAD_HEADERS: Record<string, string> = {
rowNo: "순번",
@@ -155,7 +155,7 @@ export default function FloodDngrMapPopupPage() {
FLOOD_DNGR_MAP_PAGE_SIZE,
);
const response = await getFloodDngrMapJson(
`/sewerage-service/api/v1/fldng/flood-dngr-map?${queryString}`,
`/sewerage-service/api/v1/fldng/floodDngrMap?${queryString}`,
);
const payload = response as {
items?: FloodDngrMapApiItem[];
@@ -190,7 +190,7 @@ export default function FloodDngrMapPopupPage() {
const loadAdministrativeScope = useCallback(async () => {
try {
const response = await getFloodDngrMapJson(
"/sewerage-service/api/v1/fldng/flood-dngr-map/scope",
"/sewerage-service/api/v1/fldng/floodDngrMap/scope",
);
if (!Array.isArray(response)) {
throw new Error(
@@ -224,7 +224,7 @@ export default function FloodDngrMapPopupPage() {
sbmrgFrqExpln: row.sbmrgFrqExpln,
});
const response = await getFloodDngrMapJson(
`/sewerage-service/api/v1/fldng/flood-dngr-map/group/extent?${params.toString()}`,
`/sewerage-service/api/v1/fldng/floodDngrMap/group/extent?${params.toString()}`,
);
const payload = response as FloodDngrMapExtent;
const extent = [
@@ -287,7 +287,7 @@ export default function GisEditPipeSessionPopup({
const fetchMngInstOptions = async () => {
try {
const result = await getData("v1/fcltppl/mngInsts");
const result = await getData("v1/fcltPpl/mngInsts");
if (!isMounted) return;
setMngInstOptions(Array.isArray(result) ? result : []);
} catch {
@@ -351,7 +351,7 @@ export default function GisEditPipeSessionPopup({
const safeFcltMngNo = encodeURIComponent(fcltMngNo);
const safeMngInstCd = encodeURIComponent(mngInstCd);
const result = await getData(
`v1/fcltppl/${safeFcltMngNo}/${safeMngInstCd}`,
`v1/fcltPpl/${safeFcltMngNo}/${safeMngInstCd}`,
);
if (!isMounted || !result) return;
@@ -1,112 +1,2 @@
/** 하수관로 GIS 편집에서 사용하는 도구·세션·변경 데이터 타입을 정의한다. */
export type GisEditTool = "ADD" | "MOVE" | "VERTEX_EDIT" | "DELETE";
export type GisEditGeometryType = "Point" | "LineString" | "Polygon";
export type GisEditPreviewColors = {
pointColor: string;
lineColor: string;
fillColor: string;
};
export type GisEditFeatureRef = {
fcltMngNo?: string;
mngInstCd?: string;
fid?: string;
};
export type GisEditPipeBasicInfo = {
[key: string]: string | null | undefined;
fcltMngNo?: string | null;
mngInstCd?: string | null;
instMngNo?: string | null;
instlYmd?: string | null;
mapshtNo?: string | null;
pdmtVl?: string | null;
prlgVl?: string | null;
usgCd?: string | null;
mqltCd?: string | null;
avgGrdn?: string | null;
drtwMltvArea?: string | null;
exclnMltvArea?: string | null;
tmptDpth?: string | null;
tmptMhlMngNo?: string | null;
scendDpth?: string | null;
scendMhlMngNo?: string | null;
trflnCnt?: string | null;
vrtcLen?: string | null;
wdthLen?: string | null;
instlMbdyCd?: string | null;
swtrmtZoneNm?: string | null;
mltvZoneNm?: string | null;
mltvDtdvNm?: string | null;
lineCd?: string | null;
floCd?: string | null;
layCd?: string | null;
};
export type GisEditLayerInfo = {
layerId: string;
layerName: string;
geometryType: GisEditGeometryType;
editableYn: "Y" | "N";
allowedTools: GisEditTool[];
previewColors: GisEditPreviewColors;
disabledReason?: string;
};
export type GisEditChange = {
changeId: string;
editType: GisEditTool;
layerId: string;
layerName: string;
objectId?: string;
fid?: string;
baseEditSn?: string;
featureRef?: GisEditFeatureRef;
objectLabel: string;
beforeGeometry?: Record<string, unknown> | null;
afterGeometry?: Record<string, unknown> | null;
featureProperties?: Record<string, unknown> | null;
basicInfo?: GisEditPipeBasicInfo | null;
basicInfoSavedAt?: string | null;
attributeOnly?: boolean;
summary: string;
createdAt: string;
source: "MAP_SELECTION" | "MAP_CENTER" | "MAP_DRAW";
};
export type GisEditSavePayload = {
screenId: string;
layerId: string;
layerName: string;
saveTargetType: "VERSION" | "FINAL";
view: {
srs: string;
bbox: string | null;
};
changes: Array<{
changeId: string;
editType: GisEditTool;
objectId?: string;
clientTempId?: string;
fid?: string;
baseEditSn?: string;
featureRef?: GisEditFeatureRef;
attributes?: GisEditPipeBasicInfo | null;
beforeGeometry?: Record<string, unknown> | null;
afterGeometry?: Record<string, unknown> | null;
geometry?: Record<string, unknown> | null;
}>;
};
export type GisEditSessionSnapshot = {
isEditMode: boolean;
selectedLayerName: string;
activeTool: GisEditTool | null;
isSnapEnabled: boolean;
changes: GisEditChange[];
lastSavedPayload: GisEditSavePayload | null;
canUndo: boolean;
focusedChangeId: string | null;
};
/** GIS 편집 모델은 기능이 소유하고 기존 화면 import 경로는 유지한다. */
export * from "@/features/gisEdit/model/types";
@@ -2,484 +2,36 @@
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
import GeoJSON from "ol/format/GeoJSON.js";
import {
type GisEditInteractionRequest,
isActionableChange,
isGeometryChange,
buildPreviewFocusMessage,
FLOW_DIRECTION_SRS,
SCREEN_ID,
buildPipeSaveAttributes,
EMPTY_GIS_EDIT_PIPE_BASIC_INFO,
normalizeGeometryType,
collectEditableLayers,
toGeometryObject,
buildBbox,
getFeatureLayerId,
getFeatureObjectId,
getFeatureObjectLabel,
getFeatureRef,
getFeatureProperties,
normalizePipeGeometry,
createChangeId,
} from "@/features/gisEdit";
export { EMPTY_GIS_EDIT_PIPE_BASIC_INFO } from "@/features/gisEdit";
import type {
GisEditChange,
GisEditFeatureRef,
GisEditGeometryType,
GisEditLayerInfo,
GisEditPipeBasicInfo,
GisEditPreviewColors,
GisEditSavePayload,
GisEditTool,
} from "./gisEditTypes";
type GisEditInteractionRequest = {
changeId: string;
editType: "MOVE" | "VERTEX_EDIT";
};
const isActionableChange = (change: GisEditChange) =>
!change.attributeOnly || Boolean(change.basicInfoSavedAt);
const isGeometryChange = (change: GisEditChange) =>
!change.attributeOnly && change.editType !== "ADD";
const buildPreviewFocusMessage = (
change: Pick<GisEditChange, "editType" | "layerName" | "objectLabel">,
) => {
if (change.editType === "MOVE") {
return `${change.layerName} ${change.objectLabel} 미리보기 객체를 드래그해 위치를 조정하세요.`;
}
if (change.editType === "VERTEX_EDIT") {
return `${change.layerName} ${change.objectLabel}의 주황색 실꼭지점을 드래그하거나, 선분 위 흰색 midpoint를 끌어 새 꼭지점을 추가하세요.`;
}
if (change.editType === "DELETE") {
return `${change.layerName} ${change.objectLabel} 삭제 미리보기를 유지 중입니다.`;
}
return `${change.layerName} ${change.objectLabel} 신규 객체 미리보기를 확인하세요.`;
};
const EDITABLE_LAYER_RULES: Record<
string,
Omit<GisEditLayerInfo, "layerId" | "editableYn">
> = {
tb_ms_swage_ppl: {
layerName: "하수관로",
geometryType: "LineString",
allowedTools: ["ADD", "MOVE", "VERTEX_EDIT", "DELETE"],
previewColors: {
pointColor: "rgba(22, 163, 74, 0.92)",
lineColor: "rgba(22, 163, 74, 0.95)",
fillColor: "rgba(22, 163, 74, 0.18)",
},
},
tb_ms_swage_mhl: {
layerName: "하수맨홀",
geometryType: "Point",
allowedTools: ["ADD", "MOVE", "DELETE"],
previewColors: {
pointColor: "rgba(5, 150, 105, 0.92)",
lineColor: "rgba(5, 150, 105, 0.95)",
fillColor: "rgba(5, 150, 105, 0.18)",
},
},
tb_ms_swage_pmstn: {
layerName: "하수펌프장",
geometryType: "Point",
allowedTools: ["ADD", "MOVE", "DELETE"],
previewColors: {
pointColor: "rgba(59, 130, 246, 0.92)",
lineColor: "rgba(59, 130, 246, 0.95)",
fillColor: "rgba(59, 130, 246, 0.18)",
},
},
tb_il_swage_prcs_fclt: {
layerName: "하수처리시설",
geometryType: "Point",
allowedTools: ["ADD", "MOVE", "DELETE"],
previewColors: {
pointColor: "rgba(168, 85, 247, 0.92)",
lineColor: "rgba(168, 85, 247, 0.95)",
fillColor: "rgba(168, 85, 247, 0.18)",
},
},
tb_ms_swage_prcs_zone: {
layerName: "하수처리구역",
geometryType: "Polygon",
allowedTools: ["ADD", "MOVE", "VERTEX_EDIT", "DELETE"],
previewColors: {
pointColor: "rgba(249, 115, 22, 0.92)",
lineColor: "rgba(249, 115, 22, 0.95)",
fillColor: "rgba(249, 115, 22, 0.22)",
},
},
tb_ms_stofcbr: {
layerName: "우수토실",
geometryType: "Point",
allowedTools: ["ADD", "MOVE", "DELETE"],
previewColors: {
pointColor: "rgba(234, 88, 12, 0.92)",
lineColor: "rgba(234, 88, 12, 0.95)",
fillColor: "rgba(234, 88, 12, 0.18)",
},
},
om_dt_untrt_swg_rain: {
layerName: "중점우수토실",
geometryType: "Point",
allowedTools: ["ADD", "MOVE", "DELETE"],
previewColors: {
pointColor: "rgba(220, 38, 38, 0.92)",
lineColor: "rgba(220, 38, 38, 0.95)",
fillColor: "rgba(220, 38, 38, 0.18)",
},
},
};
const FLOW_DIRECTION_SRS = "EPSG:5179";
const SCREEN_ID = "SN_MN010301";
const GIS_EDIT_ALLOWED_LAYER_IDS = new Set(["tb_ms_swage_ppl"]);
const GIS_EDIT_PIPE_ATTRIBUTE_FIELD_IDS = [
"mngInstCd",
"fcltMngNo",
"instMngNo",
"instlYmd",
"mapshtNo",
"pdmtVl",
"prlgVl",
"usgCd",
"mqltCd",
"avgGrdn",
"drtwMltvArea",
"exclnMltvArea",
"tmptDpth",
"tmptMhlMngNo",
"scendDpth",
"scendMhlMngNo",
"trflnCnt",
"vrtcLen",
"wdthLen",
"instlMbdyCd",
"swtrmtZoneNm",
"mltvZoneNm",
"mltvDtdvNm",
"lineCd",
"floCd",
"layCd",
] as const;
const buildPipeSaveAttributes = (basicInfo: GisEditPipeBasicInfo | null | undefined) => {
if (!basicInfo) return undefined;
return GIS_EDIT_PIPE_ATTRIBUTE_FIELD_IDS.reduce<GisEditPipeBasicInfo>((acc, fieldId) => {
if (!Object.prototype.hasOwnProperty.call(basicInfo, fieldId)) return acc;
const value = basicInfo[fieldId];
const normalizedValue = fieldId === "instlYmd" ? String(value ?? "").replaceAll("-", "") : value;
acc[fieldId] =
typeof normalizedValue === "string" && normalizedValue.trim() === ""
? null
: normalizedValue;
return acc;
}, {});
};
export const EMPTY_GIS_EDIT_PIPE_BASIC_INFO: GisEditPipeBasicInfo = {
mngInstCd: "",
fcltMngNo: "",
instMngNo: "",
instlYmd: "",
usgCd: "",
instlMbdyCd: "",
swtrmtZoneNm: "",
mltvZoneNm: "",
mltvDtdvNm: "",
lineCd: "",
floCd: "",
layCd: "",
};
const normalizeLayerIdCandidate = (value: unknown) => {
const raw = String(value ?? "").trim();
if (!raw) return "";
const withoutNamespace = raw.includes(":") ? raw.split(":").pop() ?? raw : raw;
return withoutNamespace.split(".")[0] ?? withoutNamespace;
};
// 현재 레이어 트리 구조 안에서 실제 편집 대상으로 풀 수 있는 레이어만 추출한다.
const getLayerTypeName = (layerInfo: any) =>
String(layerInfo?.lyrTyNm ?? layerInfo?.lyrTypeNm ?? layerInfo?.lyr_type_nm ?? "").trim();
const getLayerName = (layerInfo: any) =>
String(layerInfo?.lyrNm ?? layerInfo?.name ?? "").trim();
const normalizeGeometryType = (value: unknown): GisEditGeometryType => {
const normalized = String(value ?? "").toLowerCase();
if (normalized.includes("polygon")) return "Polygon";
if (normalized.includes("line")) return "LineString";
return "Point";
};
const getColorString = (value: unknown) => {
const resolved = String(value ?? "").trim();
return resolved || null;
};
const withAlpha = (color: string | null | undefined, alpha: number) => {
const resolvedColor = String(color ?? "").trim();
if (!resolvedColor) return null;
const rgbaMatch = resolvedColor.match(/^rgba?\(([^)]+)\)$/i);
if (rgbaMatch) {
const [red = "40", green = "90", blue = "180"] = (rgbaMatch[1] ?? "")
.split(",")
.map((part) => part.trim());
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
const hexMatch = resolvedColor.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (hexMatch) {
const hex = hexMatch[1] ?? "";
const normalized = hex.length === 3
? hex.split("").map((char) => char + char).join("")
: hex;
const red = parseInt(normalized.slice(0, 2), 16);
const green = parseInt(normalized.slice(2, 4), 16);
const blue = parseInt(normalized.slice(4, 6), 16);
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
return resolvedColor;
};
const resolvePreviewColors = (
layerInfo: any,
fallback: GisEditPreviewColors,
): GisEditPreviewColors => {
const lineColor =
withAlpha(
getColorString(
layerInfo?.lineColr ??
layerInfo?.line_colr ??
layerInfo?.lineColor ??
layerInfo?.fontInsdColr ??
layerInfo?.font_insd_colr,
),
0.95,
) ?? fallback.lineColor;
const pointColor =
withAlpha(
getColorString(
layerInfo?.lineColr ??
layerInfo?.line_colr ??
layerInfo?.fontInsdColr ??
layerInfo?.font_insd_colr ??
layerInfo?.fontExtnlColr ??
layerInfo?.font_extnl_colr,
),
0.92,
) ?? fallback.pointColor;
const fillColor =
withAlpha(
getColorString(
layerInfo?.spacColr ??
layerInfo?.spac_colr ??
layerInfo?.spaceColor ??
layerInfo?.lineColr ??
layerInfo?.line_colr,
),
0.22,
) ?? fallback.fillColor;
return {
pointColor,
lineColor,
fillColor,
};
};
// 현행 운영 기준상 GIS 편집은 하수관로만 다루므로 treeData에서도 해당 레이어만 추린다.
const collectEditableLayers = (treeData: any[]): GisEditLayerInfo[] => {
const results = new Map<string, GisEditLayerInfo>();
const visit = (items: any[]) => {
items.forEach((item) => {
const data = item?.data ?? item;
const layerTypeName = getLayerTypeName(data);
const layerRule = EDITABLE_LAYER_RULES[layerTypeName];
if (
layerRule &&
GIS_EDIT_ALLOWED_LAYER_IDS.has(layerTypeName) &&
!results.has(layerTypeName)
) {
results.set(layerTypeName, {
layerId: layerTypeName,
layerName: layerRule.layerName,
geometryType: layerRule.geometryType,
editableYn: "Y",
allowedTools: layerRule.allowedTools,
previewColors: resolvePreviewColors(data, layerRule.previewColors),
});
}
if (Array.isArray(item?.children)) {
visit(item.children);
}
if (Array.isArray(data?.children)) {
visit(data.children);
}
});
};
visit(Array.isArray(treeData) ? treeData : []);
if (results.size === 0) {
Object.entries(EDITABLE_LAYER_RULES).forEach(([layerId, layerRule]) => {
if (!GIS_EDIT_ALLOWED_LAYER_IDS.has(layerId)) return;
results.set(layerId, {
layerId,
layerName: layerRule.layerName,
geometryType: layerRule.geometryType,
editableYn: "Y",
allowedTools: layerRule.allowedTools,
previewColors: layerRule.previewColors,
});
});
}
return Array.from(results.values());
};
// 지도에서 선택한 OpenLayers Feature의 현재 geometry를 저장 payload 형태로 스냅샷한다.
const toGeometryObject = (featureData: any) => {
if (!featureData || typeof featureData.getGeometry !== "function") return null;
try {
return new GeoJSON().writeGeometryObject(featureData.getGeometry(), {
featureProjection: FLOW_DIRECTION_SRS,
dataProjection: FLOW_DIRECTION_SRS,
}) as Record<string, unknown>;
} catch (error) {
console.error("GIS 편집 geometry 변환 실패:", error);
return null;
}
};
// 저장 시점의 지도 범위를 함께 남겨 BE가 편집 세션 컨텍스트를 해석할 수 있게 한다.
const buildBbox = (map: any) => {
try {
const size = map?.getSize?.();
const view = map?.getView?.();
if (!size || !view?.calculateExtent) return null;
return view.calculateExtent(size).map((value: number) => value.toFixed(3)).join(",");
} catch (error) {
console.error("GIS 편집 BBOX 계산 실패:", error);
return null;
}
};
const getFeatureLayerId = (featureData: any) => {
if (typeof featureData?.get === "function") {
const previewLayerId = normalizeLayerIdCandidate(featureData.get("layerId"));
if (previewLayerId) return previewLayerId;
}
if (typeof featureData?.getId === "function") {
return normalizeLayerIdCandidate(featureData.getId());
}
if (typeof featureData?.id_ === "string") {
return normalizeLayerIdCandidate(featureData.id_);
}
return normalizeLayerIdCandidate(featureData?.lyrTypeNm ?? featureData?.lyrTyNm ?? "");
};
const stripLayerPrefix = (value: unknown) => {
const raw = String(value ?? "").trim();
if (!raw) return "";
const withoutNamespace = raw.includes(":") ? raw.split(":").pop() ?? raw : raw;
if (withoutNamespace.startsWith("tb_ms_swage_ppl.")) {
return withoutNamespace.slice("tb_ms_swage_ppl.".length);
}
return withoutNamespace;
};
const getFeatureFid = (featureData: any) =>
stripLayerPrefix(
featureData?.get?.("fid") ??
featureData?.values_?.fid ??
featureData?.fid ??
featureData?.getId?.() ??
featureData?.id_ ??
"",
);
const getFeatureObjectId = (featureData: any) =>
String(
getFeatureFid(featureData) ||
String(
featureData?.get?.("objectId") ??
featureData?.values_?.fclt_mng_no ??
featureData?.values_?.swtrmt_fclt_cd ??
featureData?.fcltMngNo ??
featureData?.fid ??
"",
),
).trim();
const getFeatureObjectLabel = (featureData: any) => {
const values = featureData?.values_ ?? {};
return String(
featureData?.get?.("fclt_mng_no") ??
values?.fclt_mng_no ??
featureData?.fcltMngNo ??
featureData?.get?.("objectId") ??
values?.swtrmt_fclt_cd ??
getFeatureFid(featureData) ??
"",
).trim();
};
const getFeatureRef = (featureData: any): GisEditFeatureRef | undefined => {
const mapValues = featureData?.values_ ?? {};
const featureRef: GisEditFeatureRef = {
fcltMngNo: String(
featureData?.get?.("fclt_mng_no") ??
mapValues?.fclt_mng_no ??
featureData?.fcltMngNo ??
"",
).trim() || undefined,
mngInstCd: String(
featureData?.get?.("mng_inst_cd") ??
mapValues?.mng_inst_cd ??
featureData?.mngInstCd ??
"",
).trim() || undefined,
fid: getFeatureFid(featureData) || undefined,
};
if (!featureRef.fcltMngNo && !featureRef.mngInstCd && !featureRef.fid) {
return undefined;
}
return featureRef;
};
// 선택한 원본 관로의 속성 snapshot을 보관해 preview 재선택 시 상세정보가 비지 않게 한다.
const getFeatureProperties = (featureData: any): Record<string, unknown> | null => {
const values = featureData?.values_ ?? featureData?.getProperties?.() ?? featureData;
if (!values || typeof values !== "object") return null;
const { geometry, ...properties } = values;
return properties;
};
const normalizePipeGeometry = (
layerId: string,
geometry: Record<string, unknown> | null,
): Record<string, unknown> | null => {
if (layerId !== "tb_ms_swage_ppl" || !geometry) return geometry;
if (geometry.type !== "LineString") return geometry;
const coordinates = geometry.coordinates;
if (!Array.isArray(coordinates)) return geometry;
return {
...geometry,
type: "MultiLineString",
coordinates: [coordinates],
};
};
const createChangeId = (prefix: string, sequence: number) => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return `${prefix}-${crypto.randomUUID()}`;
}
return `${prefix}-${Date.now()}-${sequence}`;
};
export const useGisEdit = (treeData: any[]) => {
const editableLayers = useMemo(() => collectEditableLayers(treeData), [treeData]);
// GIS 편집 모드 on/off는 우측 분석 메뉴의 GIS 편집 버튼과 직접 연결된다.
@@ -10,16 +10,7 @@ import {
StandardPopupLayout,
type UiHeadProps,
} from "@common/business_components";
import {
Button,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { Button, Input } from "@common/components";
import {
getBlobDataWithHeaders,
@@ -29,43 +20,23 @@ import {
} from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import { useAlertStore } from "@/shared/lib";
import { useUserStore } from "@/shared/lib/use-user-store";
import GisUploadPreviewMap from "./GisUploadPreviewMap";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import { useGisManagementCommonCodes } from "./useGisManagementCommonCodes";
import type { UploadFacilityType, MngInstOption, UploadHistoryItem, GisUploadResponse, ValidationErrorItem, ViewMode } from "@/features/gisManagement/model/upload-types";
import { getLocalizedValidationErrorMessage, getFileExtension, formatDateTime, toScopeKey, buildManagementLabel, getErrorMessage } from "@/features/gisManagement/lib/upload-display";
import { createGisDownloadClient, saveBlob } from "@/features/gisManagement/api/gis-download";
import { postRefreshMessageToOpener } from "@/features/gisManagement/lib/popup-refresh";
type UploadFacilityType = {
facilityTypeCode: string;
facilityTypeName: string;
layerId: string;
allowedExtensions: string[];
maxFileSizeMb: number;
requiredDownloadFormat: string;
};
import { createGisUploadRetryGuard, getGisPartialSuccessNotice } from "@/features/gisManagement";
type MngInstOption = {
mngInstCd: string;
lwrInstNm: string;
};
// 기존 전송 경로를 유지하며 다운로드 작업의 비동기 처리는 기능 모듈에 위임한다.
const { downloadGeoPackage, startDownloadJob, waitForDownloadJob } =
createGisDownloadClient({ getData, postRawJson, getBlobDataWithHeaders });
type UploadHistoryItem = {
uploadId: string;
downloadId?: string;
mngInstCd?: string | null;
mngInstNm?: string | null;
fileName: string;
status?: string;
totalCount?: number | null;
changedCount?: number | null;
invalidCount?: number | null;
registeredAt: string;
registeredBy: string;
registeredByName?: string | null;
message?: string | null;
};
type UploadHistoryRow = UploadHistoryItem &
Record<string, unknown> & {
@@ -85,45 +56,23 @@ const GIS_UPLOAD_HISTORY_HEADLIST: UiHeadProps[] = [
{ id: "registeredByLabel", title: "등록자", widthPercent: 11 },
];
type GisUploadResponse = {
uploadId: string;
status: "COMPLETED" | "VALIDATION_FAILED" | string;
mapRefreshRequired?: boolean;
refreshLayerTypeNames?: string[];
validationErrors?: ValidationErrorItem[];
message?: string;
};
type ValidationErrorItem = {
rowNo?: number | null;
fid?: string | null;
errorCode?: string | null;
errorMessage?: string | null;
};
/** 오류 원본 행번호와 공통 표의 역순 순번을 별도 열로 표시한다. */
const GIS_VALIDATION_ERROR_HEADLIST: UiHeadProps[] = [
{ id: "no", title: "순번", widthPercent: 8 },
{ id: "sourceRowNo", title: "행번호", widthPercent: 10 },
{ id: "fid", title: "FID", widthPercent: 22 },
{ id: "errorMessage", title: "오류내용", align: "left", widthPercent: 60 },
];
type ViewMode = "history" | "validationErrors" | "preview";
type DownloadJob = {
jobId: string;
status: "QUEUED" | "RUNNING" | "READY" | "FAILED" | string;
message?: string | null;
errorCode?: string | null;
downloadId?: string | null;
fileName?: string | null;
retryAfterMillis?: number | null;
};
type DownloadJobStatusListener = (job: DownloadJob) => void;
const SCREEN_ID = "SN_MN010302";
const FACILITY_TYPE_CODE = "PPL";
const LAYER_ID = "tb_ms_swage_ppl";
const GIS_UPLOAD_PAGE_SIZE = 10;
const GIS_ERROR_PAGE_SIZE = 10;
const SELECT_MNG_INST_VALUE = "";
// A current/history download is an idempotent GET, so one retry is safe after a transient connection drop.
const DOWNLOAD_NETWORK_RETRY_DELAY_MS = 500;
const DOWNLOAD_JOB_POLL_INTERVAL_MS = 1_000;
const DOWNLOAD_JOB_MAX_WAIT_MS = 30 * 60 * 1_000;
const DEFAULT_FACILITY: UploadFacilityType = {
facilityTypeCode: FACILITY_TYPE_CODE,
facilityTypeName: "하수관로",
@@ -133,175 +82,24 @@ const DEFAULT_FACILITY: UploadFacilityType = {
requiredDownloadFormat: "GPKG",
};
/** 백엔드 검증 코드를 사용자가 이해할 수 있는 한글 오류내용으로 변환한다. */
const GIS_VALIDATION_ERROR_MESSAGES: Record<string, string> = {
GIS_UPLOAD_MNG_INST_MISMATCH:
"선택한 관리기관과 다른 관로가 포함되어 있습니다.",
GIS_UPLOAD_FID_DUPLICATED: "업로드 파일에 중복된 시설물 식별번호가 있습니다.",
GIS_UPLOAD_DELETE_LIMIT_EXCEEDED:
"삭제 대상 건수가 허용 범위를 초과했습니다.",
};
/** 형상 오류는 같은 오류코드 안에서도 누락과 형식 오류를 구분해 안내한다. */
const getLocalizedValidationErrorMessage = (error: ValidationErrorItem) => {
const errorCode = String(error.errorCode ?? "").trim();
const errorMessage = String(error.errorMessage ?? "").trim();
if (errorCode === "GIS_UPLOAD_INVALID_GEOMETRY") {
return errorMessage.toLowerCase().includes("없는 feature")
? "공간정보가 없는 시설물입니다."
: "하수관로는 선 또는 다중선 형식의 공간정보만 업로드할 수 있습니다.";
}
const localizedMessage = GIS_VALIDATION_ERROR_MESSAGES[errorCode];
if (localizedMessage) {
const count = errorMessage.match(/count\s*=\s*(\d+)/i)?.[1];
return count ? `${localizedMessage} (대상: ${count}건)` : localizedMessage;
}
// 서버가 완전한 한글 문구를 제공한 신규 오류만 보존하고 영문이 섞인 문구는 일반 안내로 대체한다.
return /[가-힣]/.test(errorMessage) && !/[A-Za-z]/.test(errorMessage)
? errorMessage
: "업로드 파일 검증 중 오류가 발생했습니다.";
};
const getFileExtension = (fileName: string) => {
const lastDotIndex = fileName.lastIndexOf(".");
if (lastDotIndex < 0) return "";
return fileName.slice(lastDotIndex).toLowerCase();
};
const formatDateTime = (value?: string | null) => {
if (!value) return "-";
return value.replace("T", " ").slice(0, 19);
};
const toScopeKey = (mngInstCd: string) => mngInstCd || "__UNSELECTED__";
const buildManagementLabel = (
mngInstCd?: string | null,
mngInstNm?: string | null,
) => {
if (!mngInstCd) return "-";
return mngInstNm || mngInstCd;
};
// Chrome reports an interrupted fetch as TypeError without an HTTP response.
const isDownloadNetworkError = (error: unknown) => {
if (!(error instanceof TypeError)) return false;
const message = error.message.toLowerCase();
return (
message.includes("failed to fetch") || message.includes("networkerror")
);
};
// Retry only transport failures; HTTP validation and server errors must be shown immediately.
const downloadGeoPackage = async (url: string) => {
try {
return await getBlobDataWithHeaders(url);
} catch (error) {
if (!isDownloadNetworkError(error)) throw error;
console.warn("GIS 다운로드 연결이 중단되어 1회 재시도합니다.", error);
await new Promise((resolve) =>
window.setTimeout(resolve, DOWNLOAD_NETWORK_RETRY_DELAY_MS),
);
return getBlobDataWithHeaders(url);
}
};
// 파일 생성 요청과 상태 조회를 짧은 HTTP 요청으로 분리해 서버의 10초 요청 제한을 넘지 않는다.
const startDownloadJob = async (url: string): Promise<DownloadJob> => {
const response = await postRawJson(url, {});
const job = (response?.data ?? response) as DownloadJob | null;
if (!job?.jobId) {
throw new Error("다운로드 작업 접수 정보를 확인하지 못했습니다.");
}
return job;
};
const waitForDownloadJob = async (
jobId: string,
onStatus?: DownloadJobStatusListener,
): Promise<DownloadJob> => {
const startedAt = Date.now();
while (Date.now() - startedAt < DOWNLOAD_JOB_MAX_WAIT_MS) {
const job = (await getData(
`v1/map/gisDownloadJobs/${encodeURIComponent(jobId)}`,
)) as DownloadJob;
onStatus?.(job);
if (job?.status === "READY") return job;
if (job?.status === "FAILED") {
throw new Error(
job.message || "GeoPackage 다운로드 파일 생성에 실패했습니다.",
);
}
const retryAfter = Math.max(
Number(job?.retryAfterMillis) || DOWNLOAD_JOB_POLL_INTERVAL_MS,
DOWNLOAD_JOB_POLL_INTERVAL_MS,
);
await new Promise((resolve) => window.setTimeout(resolve, retryAfter));
}
throw new Error(
"GeoPackage 파일 생성 대기 시간이 초과되었습니다. 다시 시도하세요.",
);
};
// Keep the object URL alive until Chrome has accepted the synthetic download click.
const saveBlob = (blob: Blob, fileName: string) => {
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => window.URL.revokeObjectURL(url), 1_000);
};
const getErrorMessage = (error: unknown, fallback: string) => {
const apiError = error as any;
return (
apiError?.response?.data?.message ??
apiError?.response?.data?.data?.message ??
apiError?.body?.message ??
apiError?.message ??
fallback
);
};
const postRefreshMessageToOpener = (response: GisUploadResponse) => {
if (
!response.mapRefreshRequired ||
typeof window === "undefined" ||
!window.opener
) {
return;
}
window.opener.postMessage(
{
type: WINDOW_POPUP_MESSAGE_TYPE.GIS_UPLOAD_COMPLETED,
payload: {
refreshLayerTypeNames: JSON.stringify(
response.refreshLayerTypeNames ?? [LAYER_ID],
),
},
},
window.location.origin,
);
};
export default function GisManagementPopupPage() {
const alert = useAlertStore((state) => state.setMessage);
const userInfo = useUserStore((state) => state.userInfo);
// GIS 관리 코드값은 기존 분기에 사용하고 CMS 공통코드명은 화면 안내에만 사용한다.
const {
uploadStatusLabels,
downloadStatusLabels,
facilityTypeLabels,
} = useGisManagementCommonCodes();
const { uploadStatusLabels, downloadStatusLabels, facilityTypeLabels } =
useGisManagementCommonCodes();
// 파일 input은 브라우저 보안 정책상 value 직접 제어가 제한돼 ref로 선택/초기화한다.
const fileInputRef = useRef<HTMLInputElement | null>(null);
// 파일명·기관 선택을 바꿔도 부분 성공 업로드 차단을 같은 팝업 세션에 유지한다.
const uploadRetryGuardRef = useRef<ReturnType<typeof createGisUploadRetryGuard> | null>(null);
if (!uploadRetryGuardRef.current) uploadRetryGuardRef.current = createGisUploadRetryGuard();
const uploadRetryGuard = uploadRetryGuardRef.current;
const [facility, setFacility] =
useState<UploadFacilityType>(DEFAULT_FACILITY);
@@ -317,6 +115,10 @@ export default function GisManagementPopupPage() {
const [validationErrors, setValidationErrors] = useState<
ValidationErrorItem[]
>([]);
// 오류 페이지는 이력 목록의 선택·페이지와 독립적으로 업로드 ID에 고정한다.
const [errorUploadId, setErrorUploadId] = useState("");
const [errorPage, setErrorPage] = useState(1);
const [errorTotal, setErrorTotal] = useState(0);
const [viewMode, setViewMode] = useState<ViewMode>("history");
const [selectedUploadId, setSelectedUploadId] = useState("");
const [currentPage, setCurrentPage] = useState(1);
@@ -324,6 +126,17 @@ export default function GisManagementPopupPage() {
const [isLoading, setIsLoading] = useState(false);
const [notice, setNotice] = useState("");
// 오류 목록도 업로드 이력과 같은 표준 그리드를 사용하며 기존 오류 설명을 그대로 표시한다.
const validationErrorRows = useMemo(
() =>
validationErrors.map((error) => ({
sourceRowNo: error.rowNo === 0 ? "파일 전체" : (error.rowNo ?? "-"),
fid: error.fid || "-",
errorMessage: getLocalizedValidationErrorMessage(error),
})),
[validationErrors],
);
const selectedHistory = useMemo(
() =>
uploadHistory.find((history) => history.uploadId === selectedUploadId) ??
@@ -365,6 +178,9 @@ export default function GisManagementPopupPage() {
facilityTypeLabels[FACILITY_TYPE_CODE] || facility.facilityTypeName;
const latestDownloadId = downloadIdsByScope[selectedScopeKey] ?? "";
const isManagementSelected = Boolean(selectedMngInstCd);
// 기존 notice가 조회·다운로드 안내로 바뀌어도 미해결 부분 성공 경고는 유지한다.
const partialSuccessBlock = uploadRetryGuard.getBlock(selectedScopeKey);
const displayedNotice = partialSuccessBlock ? getGisPartialSuccessNotice(partialSuccessBlock) : notice;
const resetSelectedFile = () => {
setSelectedFile(null);
@@ -393,7 +209,7 @@ export default function GisManagementPopupPage() {
try {
const response = await getData(
`v1/map/gisUploads?${params.toString()}`,
`v1/map/gisUploadHistories?${params.toString()}`,
);
const items = Array.isArray(response?.items) ? response.items : [];
setUploadHistory(items);
@@ -453,7 +269,7 @@ export default function GisManagementPopupPage() {
// 관리기관 목록은 시설유형 조회 결과와 무관하게 즉시 화면 상태에 반영한다.
const loadManagementOptions = async () => {
try {
const managementOptions = await getData("v1/fcltppl/mngInsts");
const managementOptions = await getData("v1/fcltPpl/mngInsts");
if (!mounted) return;
const sortedOptions = Array.isArray(managementOptions)
@@ -590,8 +406,11 @@ export default function GisManagementPopupPage() {
readyJob.fileName ??
`swage_ppl_${selectedScopeKey}.gpkg`,
);
// 새 현재본을 실제 수신한 경우에만 다음 업로드를 허용하고 이전 선택파일을 버린다.
const previousPartialSuccess = uploadRetryGuard.acceptFreshDownload(selectedScopeKey, downloadId);
if (previousPartialSuccess) resetSelectedFile();
if (downloadId) {
setNotice(`${selectedScopeLabel} 기준 GeoPackage를 다운로드했습니다.`);
setNotice(`${selectedScopeLabel} 기준 GeoPackage를 다운로드했습니다.${previousPartialSuccess ? ` 이전 업로드 ${previousPartialSuccess.uploadId || "식별번호 확인 필요"}의 이력 복구는 관리자 확인이 필요합니다.` : ""}`);
}
} catch (error) {
console.error("GIS 다운로드 실패:", error);
@@ -604,6 +423,12 @@ export default function GisManagementPopupPage() {
};
const handleUpload = async () => {
// 버튼 disabled 외에 호출 경로에서도 동일 세션의 재업로드를 차단한다.
const blocked = uploadRetryGuard.getBlock(selectedScopeKey);
if (blocked) {
alert(getGisPartialSuccessNotice(blocked));
return;
}
if (!isManagementSelected) {
alert("관리기관을 선택하세요.");
return;
@@ -628,13 +453,20 @@ export default function GisManagementPopupPage() {
setNotice("");
try {
const response = (await postMutiPart(
"/api/gis-upload",
"/api/gisUpload",
formData,
)) as GisUploadResponse;
if (response.status === "VALIDATION_FAILED") {
await fetchHistories(1, selectedMngInstCd);
setSelectedUploadId(response.uploadId);
setErrorUploadId(response.uploadId);
setErrorPage(1);
setErrorTotal(
response.invalidCount ?? response.validationErrors?.length ?? 0,
);
setValidationErrors(
Array.isArray(response.validationErrors)
? response.validationErrors
? response.validationErrors.slice(0, GIS_ERROR_PAGE_SIZE)
: [],
);
setViewMode("validationErrors");
@@ -658,6 +490,13 @@ export default function GisManagementPopupPage() {
);
} catch (error) {
console.error("GIS 업로드 실패:", error);
const partial = uploadRetryGuard.recordFailure(selectedScopeKey, latestDownloadId, error);
if (partial) {
const message = getGisPartialSuccessNotice(partial);
setNotice(message);
alert(message);
return;
}
alert(
getErrorMessage(error, "GeoPackage 업로드 중 오류가 발생했습니다."),
);
@@ -672,6 +511,12 @@ export default function GisManagementPopupPage() {
alert("다운로드할 업로드 현황 행을 선택하세요.");
return;
}
if (selectedHistory.status === "VALIDATION_FAILED") {
alert(
"검증에 실패한 파일은 반영되지 않았습니다. 오류 확인을 이용해주세요.",
);
return;
}
setIsLoading(true);
setNotice("");
@@ -712,7 +557,10 @@ export default function GisManagementPopupPage() {
}
};
const fetchValidationErrors = async (uploadId = selectedUploadId) => {
const fetchValidationErrors = async (
uploadId = selectedUploadId,
page = 1,
) => {
if (!uploadId) {
alert("오류를 확인할 업로드 현황 행을 선택하세요.");
return;
@@ -722,11 +570,14 @@ export default function GisManagementPopupPage() {
setNotice("");
try {
const response = await getData(
`v1/map/gisUploads/${encodeURIComponent(uploadId)}/validationErrors?page=0&size=500`,
`v1/map/gisUploads/${encodeURIComponent(uploadId)}/validationErrors?page=${page - 1}&size=${GIS_ERROR_PAGE_SIZE}`,
);
setValidationErrors(
Array.isArray(response?.errors) ? response.errors : [],
);
setErrorUploadId(uploadId);
setErrorPage(page);
setErrorTotal(Number(response?.total ?? 0));
setViewMode("validationErrors");
} catch (error) {
console.error("GIS 업로드 오류 조회 실패:", error);
@@ -747,18 +598,66 @@ export default function GisManagementPopupPage() {
alert("미리보기할 업로드 현황 행을 선택하세요.");
return;
}
if (selectedHistory.status === "VALIDATION_FAILED") {
alert(
"검증에 실패한 파일은 반영되지 않았습니다. 오류 확인을 이용해주세요.",
);
return;
}
setViewMode("preview");
};
// 미리보기와 오류 목록의 기존 보조 액션을 같은 버튼 구성으로 유지한다.
const auxiliaryActions = (
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
color="primary"
disabled={!isManagementSelected || isLoading}
onClick={() => void handleRefreshHistories()}
>
목록
</Button>
<Button
type="button"
color="primary"
disabled
onClick={() => void handleDownloadSelectedHistory()}
>
GeoPackage 다운로드
</Button>
<Button
type="button"
color="white"
disabled={
!selectedHistory ||
selectedHistory.status === "VALIDATION_FAILED" ||
isLoading
}
onClick={handleOpenPreview}
>
미리보기
</Button>
<Button
type="button"
color="white"
disabled={!selectedHistory || isLoading}
onClick={() => void fetchValidationErrors()}
>
오류 확인
</Button>
</div>
);
return (
<StandardPopupLayout
title={windowPopupDefinitions.gisManagementPopup.title}
>
<LoadingOverlay isLoading={isLoading} />
{notice ? (
{displayedNotice ? (
<div className="border-b border-border bg-secondary px-5 py-2 text-[1rem] text-label">
{notice}
{displayedNotice}
</div>
) : null}
@@ -829,7 +728,7 @@ export default function GisManagementPopupPage() {
<Button
type="button"
color="primary"
disabled={!isManagementSelected || !selectedFile || isLoading}
disabled={!isManagementSelected || !selectedFile || isLoading || Boolean(partialSuccessBlock)}
onClick={() => void handleUpload()}
>
업로드
@@ -845,47 +744,12 @@ export default function GisManagementPopupPage() {
onChange={handleFileSelected}
/>
{viewMode !== "history" ? (
{viewMode === "preview" ? (
<div className="card-header flex shrink-0 items-center justify-between">
<h3 className="text-2xl font-medium text-label">
{viewMode === "preview"
? "GIS 업로드 미리보기"
: "GIS 업로드 오류 목록"}
GIS 업로드 미리보기
</h3>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
color="primary"
disabled={!isManagementSelected || isLoading}
onClick={() => void handleRefreshHistories()}
>
목록
</Button>
<Button
type="button"
color="primary"
disabled
onClick={() => void handleDownloadSelectedHistory()}
>
GeoPackage 다운로드
</Button>
<Button
type="button"
color="white"
disabled={!selectedHistory || isLoading}
onClick={handleOpenPreview}
>
미리보기
</Button>
<Button
type="button"
color="white"
disabled={!selectedHistory || isLoading}
onClick={() => void fetchValidationErrors()}
>
오류 확인
</Button>
</div>
{auxiliaryActions}
</div>
) : null}
@@ -897,56 +761,34 @@ export default function GisManagementPopupPage() {
/>
</div>
) : viewMode === "validationErrors" ? (
<div className="card !m-0 overflow-x-auto !p-0">
<Table
variant="secondary"
className="w-full min-w-[640px] caption-bottom text-sm"
>
<TableHeader className="font-bold">
<TableRow>
{["순번", "행번호", "FID", "오류내용"].map((head) => (
<TableHead
key={head}
className="border border-border !bg-primary px-4 py-[1.1rem] text-center text-[1.2rem] font-medium !text-primary-foreground"
>
{head}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody className="text-center">
{validationErrors.length > 0 ? (
validationErrors.map((error, index) => (
<TableRow
key={`${error.rowNo ?? index}-${error.fid ?? ""}-${error.errorCode ?? ""}`}
>
<TableCell className="border border-border px-3 py-[1rem] text-[1.15rem]">
{validationErrors.length - index}
</TableCell>
<TableCell className="border border-border px-3 py-[1rem] text-[1.15rem]">
{error.rowNo ?? "-"}
</TableCell>
<TableCell className="border border-border px-3 py-[1rem] text-[1.15rem]">
{error.fid || "-"}
</TableCell>
<TableCell className="border border-border px-3 py-[1rem] text-left text-[1.15rem]">
{getLocalizedValidationErrorMessage(error)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={4}
className="h-[calc(100dvh-28rem)] min-h-96 border border-border text-center text-[1.2rem] text-muted-foreground"
>
표시할 오류가 없습니다.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<MapPopupDataTable
rows={validationErrorRows}
headlist={GIS_VALIDATION_ERROR_HEADLIST}
title="GIS 업로드 오류 목록"
actions={auxiliaryActions}
total={errorTotal}
page={errorPage}
pageSize={GIS_ERROR_PAGE_SIZE}
onPageChange={(page) =>
void fetchValidationErrors(errorUploadId, page)
}
scrollMode="horizontal"
minTableWidth="64rem"
emptyMessage="표시할 오류가 없습니다."
fillAvailableHeight
rowId={(_row, index) => String(index)}
renderCell={(row, headId) =>
headId === "errorMessage" ? (
<span className="block whitespace-normal break-words text-left">
{row.errorMessage}
</span>
) : headId === "fid" ? (
<span className="block whitespace-normal break-all">
{row.fid}
</span>
) : undefined
}
/>
) : (
<MapPopupDataTable
rows={uploadHistoryRows}
@@ -971,7 +813,11 @@ export default function GisManagementPopupPage() {
<Button
type="button"
color="primary"
disabled={!selectedHistory || isLoading}
disabled={
!selectedHistory ||
selectedHistory.status === "VALIDATION_FAILED" ||
isLoading
}
onClick={() => void handleDownloadSelectedHistory()}
>
GeoPackage 다운로드
@@ -979,7 +825,11 @@ export default function GisManagementPopupPage() {
<Button
type="button"
color="white"
disabled={!selectedHistory || isLoading}
disabled={
!selectedHistory ||
selectedHistory.status === "VALIDATION_FAILED" ||
isLoading
}
onClick={handleOpenPreview}
>
미리보기
@@ -390,13 +390,6 @@ export const useInftwtResultMap = ({
pipeLayerRef.current?.setVisible?.(true);
pipeLayerRef.current?.changed?.();
gisMaps.current?.renderSync?.();
console.info("移⑥엯??FE overlay ?뚮뜑 寃곌낵", {
focusTargetType: mapView.focusTargetType,
zoneFeatureCount: zoneFeatures.length,
pipeFeatureCount: pipeFeatures.length,
});
await moveToStableFocus(mapView);
},
[clearLayers, ensurePipeLayer, ensureZoneLayer, gisMaps, moveToStableFocus],
@@ -149,7 +149,7 @@ export default function PipeAnalysisDetailPopupPage() {
if (scope.regionCode) params.set("scopeRegionCode", scope.regionCode);
const response = await getData(
`v1/fclts/ppl-analysis/details?${params.toString()}`,
`v1/fclts/pplAnalysis/details?${params.toString()}`,
);
const responseRows = Array.isArray(response?.data) ? response.data : [];
@@ -395,12 +395,14 @@ export default function PipeAnalysisDetailPopupPage() {
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
관경 시작
관경(mm)
</div>
<Input
className="sn-popup-input !h-[3.2rem] w-[12rem] !text-xl"
aria-label="관경 최소값"
placeholder="입력"
value={filters.diameterFrom}
onChange={(event) =>
setFilters((current) => ({
@@ -412,14 +414,11 @@ export default function PipeAnalysisDetailPopupPage() {
if (event.key === "Enter") handleSearch();
}}
/>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
관경 종료
</div>
<span className="shrink-0 text-xl font-semibold text-foreground">~</span>
<Input
className="sn-popup-input !h-[3.2rem] w-[12rem] !text-xl"
aria-label="관경 최대값"
placeholder="입력"
value={filters.diameterTo}
onChange={(event) =>
setFilters((current) => ({
@@ -431,7 +430,7 @@ export default function PipeAnalysisDetailPopupPage() {
if (event.key === "Enter") handleSearch();
}}
/>
</label>
</div>
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
<Button type="button" color="primary" onClick={handleSearch}>
@@ -5,17 +5,8 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Button,
Table,
TableBody,
TableCell,
TableHeader,
TableHead,
TableRow,
} from "@common/components";
import { Button } from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { getData } from "@/components/lib/api";
import { appendApiSearchParams } from "@/shared/util/util";
@@ -25,7 +16,7 @@ import { useAlertStore } from "@/shared/lib";
import { useUserStore } from "@/shared/lib/use-user-store";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PipeAnalysisSummaryTable from "./PipeAnalysisSummaryTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import {
PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS,
@@ -66,17 +57,6 @@ const toExcelColumnName = (index: number) => {
return result || "A";
};
const formatGridNumber = (value: unknown) => {
const num = Number(value ?? 0);
if (!Number.isFinite(num)) {
return String(value ?? "");
}
return num.toLocaleString("ko-KR", {
minimumFractionDigits: 0,
maximumFractionDigits: 3,
});
};
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
error instanceof Error && error.message.trim()
? error.message
@@ -158,7 +138,7 @@ export default function PipeAnalysisPopupPage() {
params.set("scopeRegionCode", nextFilters.scopeRegionCode);
const response = (await getData(
`v1/fclts/ppl-analysis/summaries?${params.toString()}`,
`v1/fclts/pplAnalysis/summaries?${params.toString()}`,
)) as PipeAnalysisSummaryResponse;
const nextMaterialColumns = Array.isArray(response?.materialColumns)
@@ -359,7 +339,6 @@ export default function PipeAnalysisPopupPage() {
[yearMaterialGroups],
);
const useHorizontalScrollTable = appliedFilters.analysisType !== "YEAR";
const summaryPageOffset =
(summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE;
const pagedRows = useMemo(
@@ -372,15 +351,6 @@ export default function PipeAnalysisPopupPage() {
),
[appliedFilters.analysisType, rows, summaryPageOffset],
);
const clickableHeadIds = useMemo(
() =>
headList
.filter((head) =>
rows.some((row) => Boolean(row.__cellFilters[head.id])),
)
.map((head) => head.id),
[headList, rows],
);
const visibleYearMaterialGroups = useMemo(
() =>
orderedYearMaterialGroups.slice(
@@ -527,165 +497,28 @@ export default function PipeAnalysisPopupPage() {
</div>
</PopupSearchPanel>
{appliedFilters.analysisType === "YEAR_MATERIAL" ? (
<div className="card card-border !m-0 min-w-0 overflow-hidden !p-0">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
{resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 목록
</h3>
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
</div>
<div className="card !m-[1.2rem] overflow-x-auto !p-0">
<Table
variant="secondary"
className="w-max min-w-full caption-bottom text-sm"
>
<TableHeader className="font-bold">
<TableRow>
{headList.map((head) => (
<TableHead
key={head.id}
className="border border-border bg-primary px-4 py-[1.2rem] text-center text-[1.3rem] font-medium text-primary-foreground"
>
{head.id === "rowNo" ? "순번" : head.title}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody className="text-center">
{visibleYearMaterialGroups.length > 0 ? (
visibleYearMaterialGroups.flatMap((group) =>
group.rows.map((yearRow, rowIndex) => (
<TableRow
key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}`}
>
{rowIndex === 0 ? (
<>
<TableCell
rowSpan={group.rows.length}
className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]"
>
{group.rowNo}
</TableCell>
<TableCell
rowSpan={group.rows.length}
className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]"
>
{group.ctpvNm}
</TableCell>
<TableCell
rowSpan={group.rows.length}
className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]"
>
{group.sggNm}
</TableCell>
<TableCell
rowSpan={group.rows.length}
className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]"
>
{group.swtrmtZoneNm}
</TableCell>
<TableCell
rowSpan={group.rows.length}
className="border border-border px-2 py-[1.2rem] text-right text-[1.3rem]"
>
{formatGridNumber(group.totalLengthKm)}
</TableCell>
</>
) : null}
<TableCell className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]">
{yearRow.elapsedYearLabel}
</TableCell>
{materialColumns.map((column) => {
const value = Number(
yearRow.metrics[column.key] ?? 0,
);
const isClickable =
value > 0 &&
Boolean(yearRow.cellFilters[column.key]);
return (
<TableCell
key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}-${column.key}`}
className={`border border-border px-2 py-[1.2rem] text-right text-[1.3rem] ${
isClickable
? "cursor-pointer font-semibold text-primary underline underline-offset-2"
: ""
}`}
onClick={() => {
if (isClickable) {
handleYearMaterialCellClick(
group,
yearRow,
column.key,
);
}
}}
>
{formatGridNumber(value)}
</TableCell>
);
})}
</TableRow>
)),
)
) : (
<TableRow>
<TableCell
colSpan={headList.length}
className="h-24 border border-border text-center text-[1.3rem] text-label"
>
조회 결과가 없습니다.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{orderedYearMaterialGroups.length >
PIPE_ANALYSIS_SUMMARY_PAGE_SIZE ? (
<div className="mb-[1.2rem] flex justify-center">
<UiPageNations
total={orderedYearMaterialGroups.length}
cur={summaryCurrentPage}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
onPageChange={setSummaryCurrentPage}
/>
</div>
) : null}
</div>
) : null}
{appliedFilters.analysisType !== "YEAR_MATERIAL" ? (
<MapPopupDataTable
rows={
pagedRows as Array<
PipeAnalysisSummaryViewRow & Record<string, unknown>
>
}
headlist={headList}
title={`${resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 목록`}
total={rows.length}
page={summaryCurrentPage}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
onPageChange={setSummaryCurrentPage}
scrollMode={useHorizontalScrollTable ? "horizontal" : "none"}
minTableWidth={useHorizontalScrollTable ? "160rem" : "100%"}
clickableHeadIds={clickableHeadIds}
onCellClick={(row, _rowIndex, columnId) =>
handleCellClick(row, columnId)
}
rowId={(row, index) =>
`${String(row.__base.ctpvCd)}-${String(row.__base.sggCd)}-${String(row.__base.swtrmtZoneNm)}-${index}`
}
actions={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
}
/>
) : null}
<PipeAnalysisSummaryTable
rows={pagedRows}
groups={visibleYearMaterialGroups}
grouped={appliedFilters.analysisType === "YEAR_MATERIAL"}
heads={headList}
title={`${resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 목록`}
total={
appliedFilters.analysisType === "YEAR_MATERIAL"
? orderedYearMaterialGroups.length
: rows.length
}
page={summaryCurrentPage}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
onPageChange={setSummaryCurrentPage}
onCellClick={handleCellClick}
onGroupedCellClick={handleYearMaterialCellClick}
actions={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
}
/>
</StandardPopupLayout>
);
}
@@ -0,0 +1,249 @@
"use client";
import { useMemo, type ReactNode } from "react";
import {
getCoreRowModel,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import {
StandardDataTable,
type StandardDataTableLayout,
type UiHeadProps,
} from "@common/business_components";
import type {
PipeAnalysisSummaryViewRow,
PipeAnalysisYearMaterialGroup,
PipeAnalysisYearMaterialGroupRow,
} from "./pipeAnalysisUtils";
type SummaryRow =
| {
kind: "flat";
value: PipeAnalysisSummaryViewRow;
sourceIndex: number;
}
| {
kind: "grouped";
group: PipeAnalysisYearMaterialGroup;
value: PipeAnalysisYearMaterialGroupRow;
groupIndex: number;
groupRowIndex: number;
};
type Props = {
rows: PipeAnalysisSummaryViewRow[];
groups: PipeAnalysisYearMaterialGroup[];
grouped: boolean;
heads: UiHeadProps[];
title: string;
total: number;
page: number;
pageSize: number;
actions: ReactNode;
onPageChange: (page: number) => void;
onCellClick: (row: PipeAnalysisSummaryViewRow, columnId: string) => void;
onGroupedCellClick: (
group: PipeAnalysisYearMaterialGroup,
row: PipeAnalysisYearMaterialGroupRow,
columnId: string,
) => void;
};
/** 지역 식별 열은 가로 스크롤해도 남겨 두고, 모든 집계 방식에 같은 너비를 사용한다. */
const FIXED_WIDTHS: Record<string, number> = {
rowNo: 56,
ctpvNm: 120,
sggNm: 120,
swtrmtZoneNm: 160,
totalLengthKm: 100,
};
/** 공통 그리드가 같은 순서로 왼쪽 고정 위치를 계산할 관로 식별 열이다. */
const PINNED_COLUMN_IDS = Object.keys(FIXED_WIDTHS);
/** 관로분석에서 선택하는 공통 표 레이아웃이며 화면 전용 CSS를 대체한다. */
const PIPE_SUMMARY_LAYOUT: StandardDataTableLayout = {
tableLayout: "fixed",
stickyHeader: true,
headerHeight: "64px",
headerWrap: true,
cellOverflow: "ellipsis",
};
/** 기존 집계와 같은 천 단위 구분 및 소수점 세 자리 표시를 유지한다. */
const formatValue = (value: unknown) => {
if (value === null || value === undefined || value === "") return "-";
return typeof value === "number"
? value.toLocaleString("ko-KR", { maximumFractionDigits: 3 })
: String(value);
};
/** 결과가 있는 셀만 링크형 버튼으로 표시하고 나머지는 전체값 title을 유지한다. */
const renderValue = (value: unknown, onClick?: () => void) => {
const formatted = formatValue(value);
return onClick ? (
<button
type="button"
className="w-full text-inherit text-primary underline underline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
onClick={onClick}
>
{formatted}
</button>
) : (
<span className="block overflow-hidden text-ellipsis" title={formatted}>
{formatted}
</span>
);
};
/** 표준 카드·페이징 안에서 관로 집계의 병합 셀과 고정 열을 렌더링한다. */
export default function PipeAnalysisSummaryTable({
rows,
groups,
grouped,
heads,
title,
total,
page,
pageSize,
actions,
onPageChange,
onCellClick,
onGroupedCellClick,
}: Props) {
// 병합 표도 공통 렌더러가 행을 순회할 수 있도록 처리구역의 4개 경과년도 행만 펼친다.
const data = useMemo<SummaryRow[]>(
() =>
grouped
? groups.flatMap((group, groupIndex) =>
group.rows.map((value, groupRowIndex) => ({
kind: "grouped" as const,
group,
value,
groupIndex,
groupRowIndex,
})),
)
: rows.map((value, sourceIndex) => ({
kind: "flat" as const,
value,
sourceIndex,
})),
[grouped, groups, rows],
);
// 공통 렌더러가 colgroup과 고정 위치에 사용할 열별 픽셀 너비를 한곳에서 계산한다.
const layout = useMemo(() => {
return heads.map((head) => {
const width =
FIXED_WIDTHS[head.id] ??
(head.id === "elapsedYearLabel"
? 112
: String(head.title).length > 10
? 160
: 120);
const fixed = head.id in FIXED_WIDTHS;
return { ...head, width, fixed };
});
}, [heads]);
// 모든 재질 열의 너비 합계를 최소 폭으로 사용해 좁은 팝업에서 열 압축을 막는다.
const tableWidth = layout.reduce((sum, head) => sum + head.width, 0);
// 열은 값·업무 클릭만 정의하고 실제 th/td, 고정 열과 overflow는 공통 렌더러가 소유한다.
const columns = useMemo<ColumnDef<SummaryRow>[]>(
() =>
layout.map((head) => ({
id: head.id,
size: head.width,
minSize: head.width,
maxSize: head.width,
header: () => <span title={String(head.title)}>{head.title}</span>,
cell: ({ row }) => {
const summaryRow = row.original;
if (summaryRow.kind === "flat") {
const value =
head.id === "rowNo"
? total - ((page - 1) * pageSize + summaryRow.sourceIndex)
: summaryRow.value[head.id];
const onClick =
summaryRow.value.__cellFilters[head.id] && Number(value) > 0
? () => onCellClick(summaryRow.value, head.id)
: undefined;
return renderValue(value, onClick);
}
if (head.fixed) {
return renderValue(
summaryRow.group[head.id as keyof PipeAnalysisYearMaterialGroup],
);
}
if (head.id === "elapsedYearLabel") {
return renderValue(summaryRow.value.elapsedYearLabel);
}
const value = Number(summaryRow.value.metrics[head.id] ?? 0);
const onClick =
value > 0 && summaryRow.value.cellFilters[head.id]
? () =>
onGroupedCellClick(
summaryRow.group,
summaryRow.value,
head.id,
)
: undefined;
return renderValue(value, onClick);
},
meta: {
align: head.align === "right" ? "right" : "center",
},
})),
[layout, onCellClick, onGroupedCellClick, page, pageSize, total],
);
// 페이지 단위는 기존과 동일하게 일반 표는 행, 병합 표는 처리구역 그룹이다.
const pagination = { pageIndex: Math.max(0, page - 1), pageSize };
const table = useReactTable({
data,
columns,
state: { pagination },
rowCount: total,
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) =>
row.kind === "flat"
? `flat-${row.sourceIndex}`
: `group-${row.groupIndex}-${row.value.elapsedYearBucket}`,
onPaginationChange: (updater) => {
const next =
typeof updater === "function" ? updater(pagination) : updater;
if (next.pageIndex !== pagination.pageIndex)
onPageChange(next.pageIndex + 1);
},
});
return (
<StandardDataTable
key={`${title}-${page}`}
table={table}
title={title}
flushOuterMargin
scrollMode="both"
maxHeight="max(240px, calc(100dvh - 34rem))"
minTableWidth={`${tableWidth}px`}
layout={PIPE_SUMMARY_LAYOUT}
pinnedColumns={PINNED_COLUMN_IDS}
getCellProps={({ row, columnId }) => {
const summaryRow = row.original;
const isPinnedColumn = PINNED_COLUMN_IDS.includes(columnId);
const isGrouped = summaryRow.kind === "grouped";
return {
hidden: isGrouped && isPinnedColumn && summaryRow.groupRowIndex > 0,
rowSpan:
isGrouped && isPinnedColumn && summaryRow.groupRowIndex === 0
? summaryRow.group.rows.length
: undefined,
className:
isGrouped && summaryRow.groupRowIndex === 0
? "border-t border-border"
: undefined,
};
}}
actions={actions}
/>
);
}
@@ -13,14 +13,14 @@
* - 모달을 통한 저장 확인 및 API 연동
*
* 🔗 사용 API
* - 하수관로 자체입력정보 조회: GET /api/v1/fcltppl/{fcltMngNo}/{mngInstCd} (sewerage-service)
* - 우수토실 자체입력정보 조회: GET /api/v1/fcltstofcbr/{fcltMngNo}/{mngInstCd} (sewerage-service)
* - 하수펌프장 자체입력정보 조회: GET /api/v1/fcltpump/{fcltMngNo}/{mngInstCd} (sewerage-service)
* - 하수맨홀 자체입력정보 조회: GET /api/v1/fcltmhl/{fcltMngNo}/{mngInstCd} (sewerage-service)
* - 하수관로 자체입력정보 저장: POST /api/v1/fcltppl (sewerage-service)
* - 우수토실 자체입력정보 저장: POST /api/v1/fcltstofcbr (sewerage-service)
* - 하수펌프장 자체입력정보 저장: POST /api/v1/fcltpump (sewerage-service)
* - 하수맨홀 자체입력정보 저장: POST /api/v1/fcltmhl (sewerage-service)
* - 하수관로 자체입력정보 조회: GET /api/v1/fcltPpl/{fcltMngNo}/{mngInstCd} (sewerage-service)
* - 우수토실 자체입력정보 조회: GET /api/v1/fcltStofcbr/{fcltMngNo}/{mngInstCd} (sewerage-service)
* - 하수펌프장 자체입력정보 조회: GET /api/v1/fcltPump/{fcltMngNo}/{mngInstCd} (sewerage-service)
* - 하수맨홀 자체입력정보 조회: GET /api/v1/fcltMhl/{fcltMngNo}/{mngInstCd} (sewerage-service)
* - 하수관로 자체입력정보 저장: POST /api/v1/fcltPpl (sewerage-service)
* - 우수토실 자체입력정보 저장: POST /api/v1/fcltStofcbr (sewerage-service)
* - 하수펌프장 자체입력정보 저장: POST /api/v1/fcltPump (sewerage-service)
* - 하수맨홀 자체입력정보 저장: POST /api/v1/fcltMhl (sewerage-service)
*/
"use client";
import { getData, postData } from "@/components/lib/api";
@@ -186,7 +186,7 @@ const FeatureClickEventPopup = ({
const fetchMngInstOptions = async () => {
try {
const result = await getData("v1/fcltppl/mngInsts");
const result = await getData("v1/fcltPpl/mngInsts");
if (!isMounted) return;
const sortedOptions = Array.isArray(result)
? [...result].sort((a, b) =>
@@ -720,13 +720,13 @@ const FeatureClickEventPopup = ({
isValidDetailIdentifier(fcltMngNo) &&
isValidDetailIdentifier(mngInstCd);
if (selectedLayerName === "하수관로")
apiEndpoint = `v1/fcltppl/${fcltMngNo}/${mngInstCd}`;
apiEndpoint = `v1/fcltPpl/${fcltMngNo}/${mngInstCd}`;
else if (selectedLayerName === "우수토실")
apiEndpoint = `v1/fcltstofcbr/${fcltMngNo}/${mngInstCd}`;
apiEndpoint = `v1/fcltStofcbr/${fcltMngNo}/${mngInstCd}`;
else if (selectedLayerName === "하수펌프장")
apiEndpoint = `v1/fcltpump/${fcltMngNo}/${mngInstCd}`;
apiEndpoint = `v1/fcltPump/${fcltMngNo}/${mngInstCd}`;
else if (selectedLayerName === "하수맨홀")
apiEndpoint = `v1/fcltmhl/${fcltMngNo}/${mngInstCd}`;
apiEndpoint = `v1/fcltMhl/${fcltMngNo}/${mngInstCd}`;
// API에서 상세 데이터 가져오기
if (apiEndpoint && canRequestDetail) {
@@ -886,10 +886,10 @@ const FeatureClickEventPopup = ({
let apiEndpoint = "";
let result = null;
if (layerNm === "하수관로") apiEndpoint = "v1/fcltppl";
else if (layerNm === "우수토실") apiEndpoint = "v1/fcltstofcbr";
else if (layerNm === "하수펌프장") apiEndpoint = "v1/fcltpump";
else if (layerNm === "하수맨홀") apiEndpoint = "v1/fcltmhl";
if (layerNm === "하수관로") apiEndpoint = "v1/fcltPpl";
else if (layerNm === "우수토실") apiEndpoint = "v1/fcltStofcbr";
else if (layerNm === "하수펌프장") apiEndpoint = "v1/fcltPump";
else if (layerNm === "하수맨홀") apiEndpoint = "v1/fcltMhl";
bodyData["fcltMngNo"] =
featureData.values_.fclt_mng_no.toString() || null;
@@ -11,7 +11,7 @@
* - 저장 버튼을 통한 데이터 저장 기능
*
* 🔗 사용 API
* - 상류관로 조회: GET /api/v1/fcltmhl/upperPpls/{fcltMngNo} (sewerage-service)
* - 상류관로 조회: GET /api/v1/fcltMhl/upperPpls/{fcltMngNo} (sewerage-service)
*/
import { VerticalTable } from '@/components/ui/verticalTable';
import { type ReactNode, useEffect, useState } from 'react';
@@ -26,7 +26,7 @@ export async function searchUpperPpls(
) {
let data: any = null;
try {
data = await getData(`v1/fcltmhl/upperPpls/${fcltMngNo}`);
data = await getData(`v1/fcltMhl/upperPpls/${fcltMngNo}`);
} catch (error) {
console.error("JSON parsing error:", error);
notify(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
@@ -0,0 +1,163 @@
/** 우측 상세 탭의 표시·분류 모델을 지도 조회 수명에서 분리한다. */
import type { ExmnMapJobType } from "../exmn/exmnMapTypes";
import type { SinkholeDetail, SinkholeNearbyPipe } from "../sinkhole/sinkholeTypes";
export type RightPanelTabKind = "feature" | "sinkhole" | "gisEdit";
export type RightPanelTabCategory =
| "sinkhole"
| "pipe"
| "exmnSurvey"
| "treatmentFacility"
| "treatmentZone"
| "defaultFeature"
| "gisEdit";
export type RightPanelTab = {
id: string;
kind: RightPanelTabKind;
category: RightPanelTabCategory;
displayLabel: string;
surveyJobType?: ExmnMapJobType;
parentTabId?: string | null;
featureData: any;
sinkholeDetail?: SinkholeDetail | null;
nearbyPipes?: SinkholeNearbyPipe[];
gisEditChangeId?: string | null;
lastActiveAt: number;
};
export type RightPanelTabStyle = {
active: string;
inactive: string;
};
export type RightPanelBookmarkTabsProps = {
tabs: RightPanelTab[];
activeTabId: string | null;
onActivate: (tabId: string) => void;
onBack: (tabId: string) => void;
};
// 지도 객체 유형과 조사구분을 식별하는 범례 의미색이므로 테마와 무관한 정적 class를 유지한다.
export const RIGHT_PANEL_TAB_STYLE_BY_CATEGORY: Record<
RightPanelTabCategory,
RightPanelTabStyle
> = {
sinkhole: {
active: "border-[#d97706] bg-[#d97706] text-white",
inactive: "border-[#fdba74] bg-[#fff7ed] text-[#9a3412] hover:bg-[#ffedd5]",
},
pipe: {
active: "border-[#2563eb] bg-[#2563eb] text-white",
inactive: "border-[#93c5fd] bg-[#eff6ff] text-[#1e40af] hover:bg-[#dbeafe]",
},
exmnSurvey: {
active: "border-[#0f766e] bg-[#0f766e] text-white",
inactive: "border-[#5eead4] bg-[#f0fdfa] text-[#115e59] hover:bg-[#ccfbf1]",
},
treatmentFacility: {
active: "border-[#15803d] bg-[#15803d] text-white",
inactive: "border-[#86efac] bg-[#f0fdf4] text-[#166534] hover:bg-[#dcfce7]",
},
treatmentZone: {
active: "border-[#7e22ce] bg-[#7e22ce] text-white",
inactive: "border-[#d8b4fe] bg-[#faf5ff] text-[#6b21a8] hover:bg-[#f3e8ff]",
},
gisEdit: {
active: "border-[#0f766e] bg-[#0f766e] text-white",
inactive: "border-[#5eead4] bg-[#f0fdfa] text-[#115e59] hover:bg-[#ccfbf1]",
},
defaultFeature: {
active: "border-[#475569] bg-[#475569] text-white",
inactive: "border-[#cbd5e1] bg-[#f8fafc] text-[#334155] hover:bg-[#f1f5f9]",
},
};
export const RIGHT_PANEL_EXMN_TAB_STYLE_BY_JOB_TYPE: Record<
ExmnMapJobType,
RightPanelTabStyle
> = {
PPL: {
active: "border-[#eab308] bg-[#facc15] text-[#713f12]",
inactive: "border-[#fde68a] bg-[#fefce8] text-[#854d0e] hover:bg-[#fef9c3]",
},
MNHL: {
active: "border-[#8b5cf6] bg-[#8b5cf6] text-white",
inactive: "border-[#c4b5fd] bg-[#f5f3ff] text-[#5b21b6] hover:bg-[#ede9fe]",
},
};
// 공용 우측 탭은 유형과 활성 상태에 관계없이 먼저 열린 탭부터 제거한다.
export const findRightPanelTabOverflowCandidate = (
tabs: RightPanelTab[],
nextTabId: string,
) => {
return tabs.find((tab) => tab.id !== nextTabId) ?? null;
};
// 부모 지반침하가 제거되면 그 부모에서 열린 연계 관로도 같은 탭 그룹으로 제거한다.
export const removeRightPanelTabGroup = (
tabs: RightPanelTab[],
removedTabId: string,
) =>
tabs.filter(
(tab) => tab.id !== removedTabId && tab.parentTabId !== removedTabId,
);
// 오른쪽 상세 패널에 상세가 2개 이상 쌓였을 때만 책갈피형 전환 버튼을 보여준다.
export const RightPanelBookmarkTabs = ({
tabs,
activeTabId,
onActivate,
onBack,
}: RightPanelBookmarkTabsProps) => {
if (tabs.length < 2) return null;
return (
<div className="pointer-events-auto absolute right-[350px] top-[92px] z-[70] flex w-[112px] flex-col items-end gap-2">
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const style =
tab.category === "exmnSurvey" && tab.surveyJobType
? RIGHT_PANEL_EXMN_TAB_STYLE_BY_JOB_TYPE[tab.surveyJobType]
: RIGHT_PANEL_TAB_STYLE_BY_CATEGORY[tab.category];
const parentTab = tab.parentTabId
? tabs.find((candidate) => candidate.id === tab.parentTabId)
: null;
return (
<div
key={tab.id}
className={[
"flex min-h-[42px] w-[112px] items-center border border-r-0 px-1 py-1 shadow-sm transition-colors",
isActive ? style.active : style.inactive,
].join(" ")}
>
{tab.category === "pipe" && parentTab ? (
<button
type="button"
className="mr-1 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-sm font-bold transition-colors hover:bg-accent focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1"
title={parentTab.displayLabel + "로 돌아가기"}
aria-label={parentTab.displayLabel + "로 돌아가기"}
onClick={() => onBack(parentTab.id)}
>
←
</button>
) : null}
<button
type="button"
className="inline-flex min-w-0 flex-1 items-center justify-center self-stretch px-1 text-center text-[0.82rem] font-semibold leading-tight"
title={tab.displayLabel}
aria-pressed={isActive}
onClick={() => onActivate(tab.id)}
>
<span className="block max-h-[2.5rem] overflow-hidden break-all">
{tab.displayLabel}
</span>
</button>
</div>
);
})}
</div>
);
};
@@ -124,7 +124,7 @@ export default function SbmrgDngrMapPopupPage() {
SBMRG_DNGR_MAP_PAGE_SIZE,
);
const response = await getRawJson(
`/sewerage-service/api/v1/fldng/sbmrg-dngr-map?${queryString}`,
`/sewerage-service/api/v1/fldng/sbmrgDngrMap?${queryString}`,
);
const payload = unwrapPayload<{
items?: SbmrgDngrMapApiItem[];
@@ -160,7 +160,7 @@ export default function SbmrgDngrMapPopupPage() {
SBMRG_DNGR_MAP_SCOPE_SIZE,
);
const response = await getRawJson(
`/sewerage-service/api/v1/fldng/sbmrg-dngr-map?${queryString}`,
`/sewerage-service/api/v1/fldng/sbmrgDngrMap?${queryString}`,
);
const payload = unwrapPayload<{ items?: SbmrgDngrMapApiItem[] }>(
response,
@@ -697,7 +697,10 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
onSortChange={handleSortChange}
actions={
<span className="text-xl font-normal text-muted-foreground">
SWMM 시뮬레이션 기반 통수위험 분석 결과
SWMM 시뮬레이션 기반 통수위험 분석 결과 (GIS 매핑 관로 중
Capacity 0.8 이상이 1개라도 있으면{" "}
<span className="font-bold text-[#ff1c15]">위험</span>, 없으면{" "}
<span className="font-bold text-[#1677ff]">정상</span>)
</span>
}
/>
File diff suppressed because it is too large Load Diff
@@ -81,8 +81,8 @@ export default function useBasicTab() {
page: `${curPage - 1}`,
size: "10",
});
if (basicType === "tb_ms_swage_ppl")
queryParams.set("includeFlowDirectionBasis", "true");
// 현재 서버의 유하방향 배치 보강 옵션은 실패 시 목록 전체를 500으로 중단한다.
// 상세 유하방향 조회와 분리해 기본 정보검색 목록을 우선 정상 제공한다.
if (basicType === "tb_ms_swage_ppl")
cqlFilters = swagePplCqlFilters(); // 하수관로
-1
View File
@@ -1 +0,0 @@
export { default } from "../map/page";
@@ -1,4 +1,9 @@
import { isWindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
import {
resolveWindowPopupTheme,
WINDOW_POPUP_THEME_PARAM,
} from "@/features/windowPopup/model/popupTheme";
import PopupThemeSync from "@/features/windowPopup/ui/PopupThemeSync";
import WindowPopupRenderer from "@/features/windowPopup/ui/WindowPopupRenderer";
type PopupPageProps = {
@@ -14,6 +19,9 @@ export default async function PopupPage({
}: PopupPageProps) {
const { popupKey } = await params;
const resolvedSearchParams = await searchParams;
const popupTheme = resolveWindowPopupTheme(
resolvedSearchParams[WINDOW_POPUP_THEME_PARAM],
);
if (!isWindowPopupKey(popupKey)) {
return (
@@ -25,9 +33,14 @@ export default async function PopupPage({
}
return (
<WindowPopupRenderer
popupKey={popupKey}
searchParams={resolvedSearchParams}
/>
<>
{popupTheme ? (
<PopupThemeSync theme={popupTheme} />
) : null}
<WindowPopupRenderer
popupKey={popupKey}
searchParams={resolvedSearchParams}
/>
</>
);
}
@@ -13,7 +13,7 @@
*
* 🔗 사용 API
* - 사이트 정보 조회: POST /api/v1/inftwtAnls/users/{userId} (sewerage-service)
* - 침입수분석 등록 첨부파일INP 포함: POST /api/v1/rdiiAnls/savewithfile (sewerage-service)
* - 침입수분석 등록 첨부파일INP 포함: POST /api/v1/rdiiAnls/saveWithFile (sewerage-service)
* - 사용자 단 건 조회: GET /api/v1/users/{userUnqId} (user-service)
*/
"use client";
@@ -109,7 +109,7 @@ function FileUpload() {
try {
const response = await axios.post(
"http://localhost:8883/sewerage-service/api/v1/rdiiAnls/savewithfile",
"http://localhost:8883/sewerage-service/api/v1/rdiiAnls/saveWithFile",
formData,
{
headers: {
@@ -12,7 +12,7 @@
* - 로딩 오버레이 및 에러 핸들링 지원
*
* 🔗 사용 API
* - 침입수분석 등록 첨부파일INP 포함: POST /api/v1/inftwtAnls/savewithfile (sewerage-service)
* - 침입수분석 등록 첨부파일INP 포함: POST /api/v1/inftwtAnls/saveWithFile (sewerage-service)
* - 로그인된 사용자의 사용자 정보 조회: GET /api/v1/users/info/me (user-service)
*/
"use client";
@@ -193,7 +193,7 @@ const IrSetting = () => {
};
const registFile = async (analysisData: any) => {
const data = await postMutiPart("v1/inftwtAnls/savewithfile", analysisData);
const data = await postMutiPart("v1/inftwtAnls/saveWithFile", analysisData);
if (!data) {
throw new Error("분석 요청 접수 응답이 비어 있습니다.");
}
@@ -13,7 +13,7 @@
* - 로딩 오버레이 및 에러 핸들링 지원
*
* 🔗 사용 API
* - RDII분석 등록 첨부파일INP 포함: POST /api/v1/rdiiAnls/savewithfile (sewerage-service)
* - RDII분석 등록 첨부파일INP 포함: POST /api/v1/rdiiAnls/saveWithFile (sewerage-service)
* - 로그인된 사용자의 사용자 정보 조회: GET /api/v1/users/info/me (user-service)
*/
"use client";
@@ -223,7 +223,7 @@ export default function RTKForm() {
};
const registFile = async (analysisData: any) => {
const data = await postMutiPart("v1/rdiiAnls/savewithfile", analysisData);
const data = await postMutiPart("v1/rdiiAnls/saveWithFile", analysisData);
if (!data) {
throw new Error("분석 요청 접수 응답이 비어 있습니다.");
}
@@ -1,143 +0,0 @@
"use client";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
type ChartOptions,
} from "chart.js";
import { Bar } from "react-chartjs-2";
import { Card, CardHeader, CardTitle, CardContent } from "@common/business_components/ui/chart/lib/card";
import { Button } from "@common/components";
// ChartJS 컴포넌트 등록
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
);
export default function TestPage() {
// 차트 데이터
const labels = ["한강", "낙동강", "영산강", "금강"];
const datasets = [
{
label: '20-25년',
data: [3859, 1066, 1929, 2828],
backgroundColor: '#3B82F6', // 파란색
borderRadius: 4,
},
{
label: '25년-30년',
data: [3153, 1420, 1204, 2877],
backgroundColor: '#EF4444', // 빨간색
borderRadius: 4,
},
{
label: '30년이상',
data: [9404, 1835, 1821, 8115],
backgroundColor: '#F87171', // 연한 빨간색/분홍색
borderRadius: 4,
},
];
// 각 지역별 총 길이 계산
const totalLengths = [44251, 33786, 11888, 14878];
// 차트 옵션 설정
const options: ChartOptions<"bar"> = {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
grid: {
display: true,
},
},
y: {
beginAtZero: true,
min: 0,
max: 10000,
ticks: {
stepSize: 2000,
callback: function(value) {
return value + 'km';
}
},
grid: {
color: 'rgba(200, 200, 200, 0.3)',
}
}
},
plugins: {
legend: {
position: 'top',
align: 'start',
labels: {
boxWidth: 15,
padding: 15,
usePointStyle: false,
},
},
tooltip: {
callbacks: {
label: function(context) {
return `${context.dataset.label}: ${context.parsed.y}km`;
}
}
},
},
// 총 길이 데이터 표시를 위한 애니메이션 후 함수 설정
animation: {
onComplete: function(animation) {
const chart = animation.chart;
const ctx = chart.ctx;
// 각 데이터셋의 마지막 데이터셋의 인덱스
const lastDatasetIndex = datasets.length - 1;
// 텍스트 스타일 설정
ctx.font = '11px Arial';
ctx.fillStyle = '#333';
ctx.textAlign = 'center';
// 각 지역별로 총 길이 표시
labels.forEach((_, i) => {
const xPos = chart.scales.x ? chart.scales.x.getPixelForTick(i) : 0;
const yPos = chart.chartArea.top; // 차트 영역 최상단 위에 표시
// 막대 위에 총 길이 텍스트 표시
if (totalLengths[i]) ctx.fillText(`총 ${totalLengths[i].toLocaleString()}km`, xPos, yPos);
});
}
}
};
const data = {
labels,
datasets,
};
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-base">노후하수관로현황</CardTitle>
<Button size="sm">더보기</Button>
</CardHeader>
<CardContent className="pb-4">
{/* 너비 400px, 높이 150px로 조정 */}
<div className="w-[400px] h-[150px] mx-auto">
<Bar options={options} data={data} />
</div>
</CardContent>
</Card>
);
}
@@ -1,146 +0,0 @@
// ...existing code...
"use client";
import React from "react";
import { Chart as ChartJS, ArcElement, Tooltip, Legend, type ChartOptions } from "chart.js";
import { Pie } from "react-chartjs-2";
ChartJS.register(ArcElement, Tooltip, Legend);
export default function Page() {
const labels = ["한강", "낙동강", "영산강", "금강"];
const datasets = [
{ label: "20년 미만", data: [27835, 29465, 6934, 1058], color: "rgba(169, 169, 169, 0.8)" },
{ label: "20-25년", data: [3859, 1066, 1929, 2828], color: "rgba(255, 160, 122, 0.8)" },
{ label: "25년-30년", data: [3153, 1420, 1204, 2877], color: "rgba(152, 251, 152, 0.8)" },
{ label: "30년이상", data: [9404, 1835, 1821, 8115], color: "rgba(135, 206, 235, 0.8)" },
];
// 각 지역별 총 길이 계산
const totalLengths = [44251, 33786, 11888, 14878];
// 외부 툴팁을 사용하여 컨테이너 넘침과 관계없이 전체 툴팁을 보여주고,
// 각 항목의 전체에서 차지하는 비율(%)을 표시합니다.
const pieOptions: ChartOptions<"pie"> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
enabled: false, // 기본 캔버스 툴팁 비활성화 (외부 툴팁 사용)
external: function (context) {
const { chart, tooltip } = context;
let tooltipEl = document.getElementById("chartjs-external-tooltip");
// tooltip DOM이 없으면 생성
if (!tooltipEl) {
tooltipEl = document.createElement("div");
tooltipEl.id = "chartjs-external-tooltip";
tooltipEl.style.position = "absolute";
tooltipEl.style.pointerEvents = "auto";
tooltipEl.style.background = "rgba(0,0,0,0.75)";
tooltipEl.style.color = "#fff";
tooltipEl.style.padding = "6px 8px";
tooltipEl.style.borderRadius = "6px";
tooltipEl.style.fontSize = "12px";
tooltipEl.style.zIndex = "9999";
tooltipEl.style.transform = "translate(-50%, -120%)";
document.body.appendChild(tooltipEl);
}
// 사라짐 처리
if (tooltip.opacity === 0) {
tooltipEl.style.opacity = "0";
return;
}
// 툴팁 내용 구성 (파이의 전체 합 대비 비율 표시)
const data = chart.data;
const dataset = data.datasets && data.datasets[0] && data.datasets[0].data
? (data.datasets[0].data as number[])
: [];
const total = dataset.reduce((s, n) => s + (Number(n) || 0), 0);
const innerHtmlParts: string[] = [];
if (tooltip.dataPoints) {
tooltip.dataPoints.forEach((dp) => {
const idx = dp.dataIndex;
const label = (data.labels && data.labels[idx]) || dp.label || "";
const value = Number(dp.raw ?? dp.parsed ?? 0);
const pct = total > 0 ? (value / total) * 100 : 0;
innerHtmlParts.push(
`<div style="white-space:nowrap">
<strong style="display:inline-block;width:8px;height:8px;border-radius:2px;background:${(dp as any).element?.options?.backgroundColor || (dataset as any)[idx] || "#999"};margin-right:6px;vertical-align:middle"></strong>
<span style="vertical-align:middle">${label}: ${value.toLocaleString()}km</span>
<span style="margin-left:8px;color:#ddd">(${pct.toFixed(1)}%)</span>
</div>`
);
});
}
tooltipEl.innerHTML = innerHtmlParts.join("");
tooltipEl.style.opacity = "1";
// 위치: 차트 캔버스 기준 툴팁 caret 위치에 맞춤 (윈도우 스크롤 보정 포함)
const canvasRect = chart.canvas.getBoundingClientRect();
const caretX = tooltip.caretX ?? (canvasRect.left + canvasRect.width / 2);
const caretY = tooltip.caretY ?? (canvasRect.top + canvasRect.height / 2);
tooltipEl.style.left = `${canvasRect.left + window.pageXOffset + caretX}px`;
tooltipEl.style.top = `${canvasRect.top + window.pageYOffset + caretY}px`;
},
},
},
};
// 상단에 공통 범례(연도) 가로로 표시
const SharedLegend = () => (
<div className="flex gap-4 items-center justify-center">
{datasets.map((d) => (
<div key={d.label} className="flex items-center gap-2">
<span style={{ width: 12, height: 12, backgroundColor: d.color, borderRadius: 3, display: "inline-block" }} />
<span className="text-xl text-gray-600">{d.label}</span>
</div>
))}
</div>
);
// 레이블(강) 당 pie 데이터 생성 (레이블은 아래로 이동)
const Charts = () =>
labels.map((river, i) => {
const data = {
labels: datasets.map((d) => d.label),
datasets: [
{
data: datasets.map((d) => d.data[i]),
backgroundColor: datasets.map((d) => d.color),
borderColor: datasets.map(() => "rgba(255,255,255,0.8)"),
borderWidth: 1,
},
],
};
return (
<div key={river} className="flex flex-col items-center gap-2">
<div style={{ width: 80, height: 80 }}>
<Pie data={data} options={pieOptions} />
</div>
{/* 레이블을 차트 하단으로 이동 */}
<div className="text-lg font-medium text-gray-600">{totalLengths[i]?.toLocaleString()} km</div>
<div className="text-xl font-bold text-gray-600">{river}</div>
</div>
);
});
return (
<div className="p-4 w-[400px]">
{/* 상단 공통 범례 */}
<div className="mb-3">
<SharedLegend />
</div>
<div className="flex items-start justify-between gap-6">
<Charts />
</div>
</div>
);
}
// ...existing code...
@@ -686,14 +686,15 @@ const DredgingDetailPopupPage = ({
{index + 1}
</span>
<span className="flex min-w-0 flex-1 items-center gap-2">
{/* 파일명 밑줄과 글자 하단이 잘리지 않도록 글자 크기보다 넉넉한 줄 높이를 사용한다. */}
<strong
className="min-w-0 flex-1 truncate text-sm"
className="min-w-0 flex-1 truncate !text-[1.2rem] !font-medium !leading-[1.8rem]"
title={photo.originalFileName}
>
{photo.originalFileName ||
`준설사진 ${index + 1}`}
</strong>
<span className="shrink-0 whitespace-nowrap text-sm text-muted-foreground">
<span className="shrink-0 whitespace-nowrap !text-[1.2rem] !leading-[1.8rem] text-muted-foreground">
{formatDredgingCapturedDate(photo.capturedAt) ??
"촬영일시 없음"}
</span>
@@ -736,12 +737,12 @@ const DredgingDetailPopupPage = ({
</span>
<span className="flex min-w-0 flex-1 items-center gap-2">
<strong
className="min-w-0 flex-1 truncate text-sm"
className="min-w-0 flex-1 truncate !text-[1.2rem] !font-medium !leading-[1.8rem]"
title={photo.file.name}
>
신규 · {photo.file.name}
</strong>
<span className="shrink-0 whitespace-nowrap text-sm text-muted-foreground">
<span className="shrink-0 whitespace-nowrap !text-[1.2rem] !leading-[1.8rem] text-muted-foreground">
{formatDredgingCapturedDate(
photo.metadata.capturedAt,
) ?? "촬영일시 없음"}
@@ -824,7 +825,7 @@ const DredgingDetailPopupPage = ({
{editable ? (
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
<div className="card-header !m-0 flex-wrap gap-2">
<div className="card-header !m-0 flex-wrap gap-x-2 gap-y-1 !border-b-0">
<div className="min-w-0">
<h2 className="text-2xl font-medium text-label">
위치 및 관로
@@ -866,13 +867,14 @@ const DredgingDetailPopupPage = ({
</Button>
)}
</div>
</div>
<div
className="bg-secondary/30 h-[5.2rem] overflow-y-auto px-4 py-2 text-sm text-muted-foreground"
data-testid="dredging-edit-status"
>
{statusMessage ||
"준설일자·사진·위치와 자동 연결 관로를 확인해주세요."}
{/* 안내와 처리 상태는 위치·관로 카드의 마지막 줄에서 함께 확인한다. */}
<p
className="m-0 min-w-0 basis-full break-words text-sm leading-5 text-muted-foreground"
data-testid="dredging-edit-status"
>
{statusMessage ||
"준설일자·사진·위치와 자동 연결 관로를 확인해주세요."}
</p>
</div>
</section>
) : null}
@@ -6,7 +6,7 @@
import dynamic from "next/dynamic";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { FileUpload } from "@common/business_components/ui/file-upload";
import { DredgingRegistrationBody } from "@/features/dredging/ui/DredgingRegistrationBody";
import { StandardPopupLayout } from "@common/business_components";
import {
Button,
@@ -15,13 +15,6 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
InputDatePicker,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { useAlertStore } from "@/shared/lib";
import { useEffect, useRef, useState } from "react";
@@ -33,10 +26,10 @@ import {
type AttachmentReference,
uploadDredgingPhotos,
} from "../lib/client";
import { formatDredgingCapturedDate } from "../lib/date";
import { readPhotoMetadata } from "../lib/exif";
import { useDredgingCommonCodes } from "../lib/useDredgingCommonCodes";
import { createDredgingClientUuid } from "../lib/uuid";
import { createDredgingRegistrationSession } from "../lib/registration-request";
import {
EMPTY_PHOTO_METADATA,
type DredgingDraftGroup,
@@ -115,6 +108,12 @@ const DredgingRegisterModal = ({
useState("");
const [isSaving, setIsSaving] = useState(false);
const uploadedAttachmentsRef = useRef(new Map<string, AttachmentReference>());
// 재렌더와 실패 재시도 사이에 요청 ID를 유지하며 모달 재진입에서만 새 세션으로 교체한다.
const registrationSessionRef = useRef<ReturnType<
typeof createDredgingRegistrationSession
> | null>(null);
// React의 다음 렌더 전에 발생하는 연속 저장 클릭도 같은 실행으로 제한한다.
const savingRef = useRef(false);
const [statusMessage, setStatusMessage] = useState("");
const commitGroups = (
@@ -127,6 +126,8 @@ const DredgingRegisterModal = ({
useEffect(() => {
if (!open) return;
registrationSessionRef.current = createDredgingRegistrationSession();
uploadedAttachmentsRef.current.clear();
const initial = [createGroup()];
groupsRef.current = initial;
setGroups(initial);
@@ -470,9 +471,12 @@ const DredgingRegisterModal = ({
return saveGroups;
};
// 검증·업로드 후 등록 payload를 확정하며 HTTP 실패 시 세션 ID와 첨부를 유지한다.
const handleSave = async () => {
if (savingRef.current) return;
try {
const saveGroups = validateAndBuildSaveGroups();
savingRef.current = true;
setIsSaving(true);
setStatusMessage(
isDredgingBackendEnabled
@@ -490,8 +494,13 @@ const DredgingRegisterModal = ({
? "준설 결과와 관로 매핑을 저장하고 있습니다."
: "준설 결과 등록을 완료했습니다.",
);
await saveDredgingRegistrations(saveGroups, attachments);
uploadedAttachmentsRef.current.clear();
const session =
registrationSessionRef.current ?? createDredgingRegistrationSession();
registrationSessionRef.current = session;
await saveDredgingRegistrations(
session.prepare(saveGroups, attachments, isDredgingBackendEnabled),
);
// 성공 안내가 닫히기 전 재클릭에도 같은 ID·첨부를 사용한다. 다음 open에서 초기화한다.
onSaved(saveGroups);
alert("준설 결과를 등록했습니다.", () => onOpenChange(false));
} catch (error) {
@@ -502,6 +511,7 @@ const DredgingRegisterModal = ({
setStatusMessage(message);
alert(message);
} finally {
savingRef.current = false;
setIsSaving(false);
}
};
@@ -557,441 +567,35 @@ const DredgingRegisterModal = ({
};
const modalBody = (
<div
className={
presentation === "popup"
? "sn-dredging-register flex h-full min-h-0 flex-col overflow-hidden"
: "sn-dredging-register flex h-[min(72rem,70dvh)] min-h-0 flex-col overflow-hidden"
}
>
<div className="bg-secondary/40 mb-3 flex shrink-0 flex-wrap items-center gap-x-4 gap-y-1 rounded-sm border border-border px-4 py-2 text-sm text-label">
<strong className="text-foreground">등록 안내</strong>
<span>관로별 사진은 최대 5장까지 등록할 수 있습니다.</span>
<span>
모든 등록 건은 하나의 준설작업이며 동일한 준설일자를 사용합니다.
</span>
</div>
{activeGroup ? (
<div
className="grid min-h-0 flex-1 grid-cols-[minmax(48rem,52rem)_minmax(0,1fr)] gap-3"
data-testid={`dredging-group-${
groups.findIndex((group) => group.id === activeGroup.id) + 1
}`}
>
<aside className="flex min-h-0 min-w-0 flex-col gap-3 overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable]">
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
<div className="card-header !m-0">
<h3 className="text-2xl font-medium text-label">등록 정보</h3>
<Button type="button" size="sm" onClick={addGroup}>
관로 추가
</Button>
</div>
<div className="p-3">
<Table variant="secondary">
<colgroup>
<col width="22%" />
<col width="78%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead data-required>준설일자</TableHead>
<TableCell className="border px-4">
<div className="flex flex-wrap items-center gap-3">
<div data-testid="representative-dredging-date">
<InputDatePicker
value={
representativeDredgingDate
? parseLocalDate(representativeDredgingDate)
: null
}
onChange={(date) =>
setRepresentativeDredgingDate(
date ? formatLocalDate(date) : "",
)
}
className="w-[15.5rem]"
/>
</div>
<span className="text-sm text-muted-foreground">
첫 사진 촬영일로 자동 입력되며 직접 수정할 수
있습니다.
</span>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableHead>등록 관로</TableHead>
<TableCell className="border px-4">
<div
className="flex flex-wrap items-center gap-2"
role="tablist"
aria-label="준설 등록 건"
>
{groups.map((group, groupIndex) => {
const active = group.id === activeGroup.id;
return (
<div
key={group.id}
className="inline-flex items-stretch"
>
<Button
type="button"
size="sm"
color={active ? "primary" : "white"}
role="tab"
aria-selected={active}
className={
groupIndex > 0
? "rounded-r-none border-r-0"
: undefined
}
onClick={() => selectGroup(group.id)}
>
등록 건 {groupIndex + 1}
<span
className={`ml-1 inline-block size-2 rounded-full ${
getResolvedLocation(group)
? "bg-emerald-400"
: "bg-amber-400"
}`}
aria-label={
getResolvedLocation(group)
? "위치 등록 완료"
: "위치 등록 필요"
}
/>
</Button>
{groupIndex > 0 ? (
<Button
type="button"
size="icon"
color="red"
variant="destructive"
aria-label={`등록 건 ${groupIndex + 1} 삭제`}
title={`등록 건 ${groupIndex + 1} 삭제`}
className="h-10 min-w-8 rounded-l-none px-2"
onClick={() => removeGroup(group.id)}
>
×
</Button>
) : null}
</div>
);
})}
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</section>
{/* 등록 건별 사진 수가 달라도 목록 카드 높이를 고정해 지도와 하단 정보가 밀리지 않게 한다. */}
<section
className="card card-border !m-0 flex h-[28rem] shrink-0 flex-col overflow-hidden !p-0"
data-testid="dredging-register-photo-panel"
>
<div className="card-header !m-0 flex-wrap gap-2">
<div>
<h3 className="text-2xl font-medium text-label">준설 사진</h3>
<p className="mt-1 text-sm text-muted-foreground">
촬영일과 GPS 정보를 확인합니다.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
color={
activeGroup.registrationMode === "PHOTO"
? "primary"
: "white"
}
aria-pressed={activeGroup.registrationMode === "PHOTO"}
onClick={() =>
void changeRegistrationMode(activeGroup.id, "PHOTO")
}
>
{registrationModeLabels.PHOTO}
</Button>
<Button
type="button"
size="sm"
color={
activeGroup.registrationMode === "MANUAL"
? "primary"
: "white"
}
aria-pressed={activeGroup.registrationMode === "MANUAL"}
onClick={() =>
void changeRegistrationMode(activeGroup.id, "MANUAL")
}
>
{registrationModeLabels.MANUAL}
</Button>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col p-3">
{activeGroup.registrationMode === "PHOTO" ? (
<>
<div className="sn-dredging-photo-upload shrink-0">
<FileUpload
files={activeGroup.photos.map((photo) => photo.file)}
setFiles={(files) => {
void handleFilesChange(activeGroup.id, files);
}}
fileTypes=".jpg,.jpeg,.png"
maxFiles={5}
/>
</div>
{activeGroup.photos.length > 0 ? (
<div
className="mt-2 min-h-0 flex-1 overflow-y-auto overscroll-contain rounded-sm border border-border [scrollbar-gutter:stable]"
data-testid="dredging-register-photo-list-viewport"
>
<Table className="table-fixed" variant="default">
<TableHeader>
<TableRow>
<TableHead className="w-[34%]">사진</TableHead>
<TableHead className="w-[25%]">
촬영일시
</TableHead>
<TableHead className="w-[16%]">위치</TableHead>
<TableHead className="w-[17%]">상태</TableHead>
<TableHead className="w-[8%] whitespace-nowrap px-1">
삭제
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activeGroup.photos.map((photo) => (
<TableRow key={photo.key}>
<TableCell
className="truncate px-3"
title={photo.file.name}
>
{photo.file.name}
</TableCell>
<TableCell
className="truncate px-3"
title={
formatDredgingCapturedDate(
photo.metadata.capturedAt,
) ?? "-"
}
>
{formatDredgingCapturedDate(
photo.metadata.capturedAt,
) ?? "-"}
</TableCell>
<TableCell className="px-3">
{photo.metadata.location
? "GPS"
: "지정 필요"}
</TableCell>
<TableCell className="overflow-hidden px-3">
<span
title={
photoStatusLabels[
photo.metadata.status
] || "수동 입력 필요"
}
className={`inline-block max-w-full truncate rounded-sm px-2 py-1 align-middle text-sm font-medium ${
photo.metadata.status === "COMPLETE"
? "bg-emerald-100 text-emerald-800"
: "bg-amber-100 text-amber-800"
}`}
>
{photoStatusLabels[
photo.metadata.status
] || "수동 입력 필요"}
</span>
</TableCell>
<TableCell className="px-2 text-center">
<Button
type="button"
size="icon"
variant="destructive"
color="red"
aria-label={`${photo.file.name} 삭제`}
title="사진 삭제"
className="size-8 p-0"
onClick={() =>
removePhoto(activeGroup.id, photo.key)
}
>
×
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="bg-secondary/30 mt-2 flex min-h-0 flex-1 items-center justify-center rounded-sm border border-dashed border-border px-3 py-2 text-center text-sm text-muted-foreground">
준설사진을 선택하면 촬영일과 위치정보를 자동으로
확인합니다.
</div>
)}
</>
) : (
<div className="bg-secondary/30 flex min-h-0 flex-1 items-center justify-center rounded-sm border border-dashed border-border px-3 py-3 text-center text-sm text-muted-foreground">
사진 없이 지도에서 준설위치를 직접 지정합니다.
</div>
)}
</div>
</section>
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
<div className="card-header !m-0 flex-wrap gap-2">
<h3 className="text-2xl font-medium text-label">
위치 및 관로
</h3>
<div className="flex shrink-0 flex-wrap justify-end gap-1">
{isEditingActiveLocation ? (
<>
<Button
type="button"
size="sm"
disabled={!locationDraft || activeGroup.isMatching}
onClick={() => {
void applyLocationDraft();
}}
>
{activeGroup.isMatching
? "관로 찾는 중..."
: "선택 위치 적용"}
</Button>
<Button
type="button"
size="sm"
color="white"
onClick={cancelLocationEditing}
>
취소
</Button>
</>
) : (
<Button
type="button"
size="sm"
onClick={() => startLocationEditing(activeGroup)}
>
{activeResolvedLocation ? "위치 수정" : "위치 지정"}
</Button>
)}
</div>
</div>
<div className="p-3">
<Table variant="secondary">
<colgroup>
<col width="22%" />
<col width="78%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>위치등록</TableHead>
<TableCell className="border px-4">
<span
className={`inline-flex rounded-sm px-2 py-1 text-sm font-medium ${
hasUnappliedLocationDraft
? "bg-blue-100 text-blue-800"
: activeGroup.manualLocation
? "bg-violet-100 text-violet-800"
: activeResolvedLocation
? "bg-emerald-100 text-emerald-800"
: "bg-amber-100 text-amber-800"
}`}
>
{hasUnappliedLocationDraft
? "새 위치 선택됨"
: activeGroup.manualLocation
? `${locationSourceLabels.MANUAL} 위치`
: activeResolvedLocation
? `${locationSourceLabels.EXIF} 위치`
: "위치 지정 필요"}
</span>
</TableCell>
</TableRow>
<TableRow>
<TableHead>자동 연결 관로</TableHead>
<TableCell className="border px-4">
<strong className="mr-3 text-foreground">
{activeGroup.isMatching
? "탐색 중"
: hasUnappliedLocationDraft
? "재탐색 필요"
: activeGroup.matchedPipe
? activeGroup.matchedPipe.fcltMngNo
: "미연결"}
</strong>
<span
className={
!hasUnappliedLocationDraft && activeGroup.matchError
? "text-rose-700"
: "text-muted-foreground"
}
>
{hasUnappliedLocationDraft
? "선택 위치를 적용하면 최근접 관로를 다시 찾습니다."
: activeGroup.matchedPipe
? `${activeGroup.matchedPipe.distanceMeters.toFixed(2)}m · ${activeGroup.matchedPipe.swtrmtZoneNm || "처리구역 확인불가"}`
: activeGroup.matchError ||
"위치를 지정하면 50m 이내 최근접 관로를 자동으로 연결합니다."}
</span>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</section>
<div
className="bg-secondary/30 shrink-0 rounded-sm border border-border px-3 py-2 text-sm text-muted-foreground"
data-testid="dredging-registration-status"
>
{statusMessage ||
"사진·준설일자·위치와 자동 연결 관로를 확인해주세요."}
</div>
</aside>
<section className="card card-border !m-0 flex min-h-0 min-w-0 flex-col overflow-hidden !p-0">
<div className="card-header !m-0 shrink-0">
<h3 className="text-2xl font-medium text-label">
위치 선택 지도
</h3>
</div>
<div className="min-h-0 flex-1 p-2">
<DredgingLocationMap
value={
isEditingActiveLocation
? locationDraft
: activeResolvedLocation
}
interactive={isEditingActiveLocation}
matchedPipe={
hasUnappliedLocationDraft ? null : activeGroup.matchedPipe
}
statusText={
isEditingActiveLocation
? hasUnappliedLocationDraft
? "새 위치를 선택했습니다. 관로 다시 찾기를 눌러 적용해주세요."
: "지도에서 새 준설위치를 클릭해주세요."
: activeResolvedLocation
? "등록된 준설위치를 표시하고 있습니다."
: "위치등록 버튼을 누른 뒤 지도에서 준설위치를 선택해주세요."
}
onChange={changeLocationDraft}
height="h-full min-h-0"
/>
</div>
</section>
</div>
) : null}
</div>
<DredgingRegistrationBody
presentation={presentation}
groups={groups}
activeGroup={activeGroup}
representativeDredgingDate={representativeDredgingDate}
setRepresentativeDredgingDate={setRepresentativeDredgingDate}
parseLocalDate={parseLocalDate}
formatLocalDate={formatLocalDate}
getResolvedLocation={getResolvedLocation}
addGroup={addGroup}
removeGroup={removeGroup}
selectGroup={selectGroup}
changeRegistrationMode={changeRegistrationMode}
handleFilesChange={handleFilesChange}
removePhoto={removePhoto}
registrationModeLabels={registrationModeLabels}
photoStatusLabels={photoStatusLabels}
locationSourceLabels={locationSourceLabels}
isEditingActiveLocation={isEditingActiveLocation}
locationDraft={locationDraft}
applyLocationDraft={applyLocationDraft}
cancelLocationEditing={cancelLocationEditing}
startLocationEditing={startLocationEditing}
activeResolvedLocation={activeResolvedLocation}
hasUnappliedLocationDraft={hasUnappliedLocationDraft}
statusMessage={statusMessage}
changeLocationDraft={changeLocationDraft}
LocationMap={DredgingLocationMap}
/>
);
if (presentation === "popup") {
@@ -1,450 +1,4 @@
import {
deleteData,
getBlobData,
getRawJson,
postRawJson,
putData,
} from "@/components/lib/api";
import { handleUpload } from "@/components/lib/utils";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import type { HttpResponse } from "tus-js-client";
import type {
DredgingSaveGroup,
DredgingRecordUpdate,
DredgingPhotoDraft,
DredgingPhoto,
DredgingRecord,
GeoPoint,
PipeMatch,
} from "./types";
import { createDredgingClientUuid } from "./uuid";
type LocationRequest = GeoPoint & {
locationKey: string;
};
export type AttachmentReference = {
atflCd: string;
atflId: string;
};
type NearestPipeResponseItem = {
locationKey: string;
failureCode?: "PIPE_NOT_FOUND" | string | null;
failureMessage?: string | null;
selected?: {
fid: string;
mngInstCd: string;
fcltMngNo: string;
pipeDiameterMm?: number;
pipeLengthM?: number;
pipeMaterial?: string;
installationYear?: number | null;
swtrmtZoneFid?: string | null;
swtrmtZoneNm?: string | null;
distanceMeters: number;
snappedLongitude: number;
snappedLatitude: number;
} | null;
};
const nearestPipeFailureMessage = (item: NearestPipeResponseItem) => {
switch (item.failureCode) {
case "PIPE_NOT_FOUND":
return "50m 이내에 관로가 없습니다. 지도에서 준설위치를 다시 확인해주세요.";
default:
return (
item.failureMessage?.trim() ??
"최근접 관로를 자동으로 연결하지 못했습니다."
);
}
};
export const isDredgingBackendEnabled =
process.env.NEXT_PUBLIC_DREDGING_BACKEND_ENABLED !== "false";
/**
* 백엔드·프록시의 상태코드, HTML 오류 본문과 파싱 문구를 화면에 전달하지 않는다.
* 상세 응답은 브라우저 Network에서 확인하고 화면에는 업무별 재시도 안내만 노출한다.
*/
const toDredgingApiError = (
error: unknown,
fallbackMessage: string,
operation: string,
) => {
const status =
typeof error === "object" && error !== null && "status" in error
? (error as { status?: unknown }).status
: undefined;
console.warn(`[준설관리] ${operation} 실패`, {
status: typeof status === "number" ? status : undefined,
errorType: error instanceof Error ? error.name : typeof error,
});
return new Error(fallbackMessage);
};
type DredgingRecordListResponse = {
items: DredgingRecord[];
total: number;
page: number;
size: number;
};
/** 실제 준설관리 API에서 기간 내 등록 기록을 조회한다. */
export const fetchDredgingRecords = async (params: {
fromDate: string;
toDate: string;
ctpv?: string;
sgg?: string;
page?: number;
size?: number;
}): Promise<DredgingRecordListResponse> => {
if (!isDredgingBackendEnabled) {
return { items: [], total: 0, page: 0, size: params.size ?? 2000 };
}
const search = new URLSearchParams({
fromDate: params.fromDate,
toDate: params.toDate,
page: String(params.page ?? 0),
size: String(params.size ?? 2000),
});
if (params.ctpv?.trim()) search.set("ctpv", params.ctpv.trim());
if (params.sgg?.trim()) search.set("sgg", params.sgg.trim());
try {
const body = await getRawJson(`v1/dredging/records?${search.toString()}`);
return (body?.data ?? body) as DredgingRecordListResponse;
} catch (error) {
throw toDredgingApiError(
error,
"준설현황을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설현황 조회",
);
}
};
/** 조회조건에 맞는 준설기록 전체 페이지를 순회해 목록·Excel 건수를 일치시킨다. */
export const fetchAllDredgingRecords = async (params: {
fromDate: string;
toDate: string;
ctpv?: string;
sgg?: string;
}) =>
fetchAllPages({
fetchPage: (page, size) => fetchDredgingRecords({ ...params, page, size }),
getItems: (response) => response.items ?? [],
getTotal: (response) => Number(response.total ?? 0),
});
/** 등록된 준설사진의 공통 첨부파일 참조를 조회한다. */
export const fetchDredgingPhotos = async (
dredgingJobSn: string | number,
pipeFid: string,
): Promise<DredgingPhoto[]> => {
if (!isDredgingBackendEnabled) return [];
try {
const body = await getRawJson(
`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}/photos?fid=${encodeURIComponent(pipeFid)}`,
);
return (body?.data ?? body ?? []) as DredgingPhoto[];
} catch (error) {
throw toDredgingApiError(
error,
"등록된 준설사진을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설사진 목록 조회",
);
}
};
/** 상세 기반 수정 팝업에서 선택 관로의 일자·위치·사진 구성을 함께 수정한다. */
export const updateDredgingRecord = async (
dredgingJobSn: string | number,
update: DredgingRecordUpdate,
attachments: Map<string, AttachmentReference>,
) => {
if (!isDredgingBackendEnabled) return true;
try {
await putData(`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`, {
dredgingDate: update.dredgingDate,
pipeFid: update.originalPipeFid,
registrationMode: update.registrationMode,
location: {
source: update.locationSource,
longitude: update.location.longitude,
latitude: update.location.latitude,
},
expectedPipe: {
fid: update.pipe.fid,
mngInstCd: update.pipe.mngInstCd,
fcltMngNo: update.pipe.fcltMngNo,
},
retainedPhotoSns: update.retainedPhotoSns,
newPhotos: update.newPhotos.map((photo) => {
const attachment = attachments.get(photo.key);
if (!attachment) {
throw new Error(`${photo.file.name}의 첨부 식별자가 없습니다.`);
}
return {
...attachment,
originalFileName: photo.file.name,
clientCapturedAt: photo.metadata.capturedAt,
clientLongitude: photo.metadata.location?.longitude ?? null,
clientLatitude: photo.metadata.location?.latitude ?? null,
};
}),
});
return true;
} catch (error) {
throw toDredgingApiError(
error,
"준설정보를 수정하지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설작업 수정",
);
}
};
/** 목록에서 선택한 준설작업과 관로·사진 참조를 모두 물리 삭제한다. */
export const deleteDredgingRecord = async (dredgingJobSn: string | number) => {
if (!isDredgingBackendEnabled) return true;
try {
const response = await deleteData(
`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`,
);
if (response instanceof Response && !response.ok) {
throw new Error(`준설작업 삭제 응답 오류: ${response.status}`);
}
return true;
} catch (error) {
throw toDredgingApiError(
error,
"준설정보를 삭제하지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설작업 삭제",
);
}
};
/** 공통 첨부서비스에서 준설사진 원본을 Blob으로 내려받는다. */
export const fetchDredgingPhotoBlob = async (atflId: string) => {
try {
return await getBlobData(
`/attach-service/api/v1/download/${encodeURIComponent(atflId)}`,
);
} catch (error) {
throw toDredgingApiError(
error,
"사진 원본을 불러오지 못했습니다.",
"준설사진 원본 조회",
);
}
};
const mockPipeForLocations = (locations: LocationRequest[]): PipeMatch => {
const first = locations[0];
const longitude = first?.longitude ?? 127.5;
const latitude = first?.latitude ?? 36.5;
const seed = Math.abs(
Math.round(longitude * 100_000) + Math.round(latitude * 100_000),
);
const distanceMeters = Number(((seed % 850) / 100 + 1.2).toFixed(2));
return {
fid: `MOCK-${String(seed % 1_000_000).padStart(6, "0")}`,
mngInstCd: "4173000",
fcltMngNo: `PPL-${String(seed % 1_000_000).padStart(6, "0")}`,
pipeDiameterMm: [450, 600, 700, 900][seed % 4] ?? 450,
pipeLengthM: Number((((seed % 8_000) + 2_000) / 100).toFixed(2)),
pipeMaterial: ["콘크리트", "PVC", "PE"][seed % 3] ?? "콘크리트",
installationYear: 1995 + (seed % 25),
swtrmtZoneFid: "MOCK-ZONE",
swtrmtZoneNm: "모의처리구역",
distanceMeters,
snappedLongitude: longitude + 0.00001,
snappedLatitude: latitude - 0.00001,
isMock: true,
};
};
/**
* 위치 목록을 한 번에 보내고 사진별 최근접 관로를 받는다.
* FE 선행 모드에서는 같은 그룹의 상태 전이를 검증할 수 있는 명시적 모의 관로를 반환한다.
*/
export const findNearestPipeMatches = async (
locations: LocationRequest[],
): Promise<Map<string, PipeMatch>> => {
if (locations.length === 0) return new Map();
if (!isDredgingBackendEnabled) {
const mockPipe = mockPipeForLocations(locations);
return new Map(
locations.map((location) => [location.locationKey, mockPipe]),
);
}
let body: any;
try {
body = await postRawJson("v1/dredging/nearestPipes", { locations });
} catch (error) {
throw toDredgingApiError(
error,
"최근접 관로를 조회하지 못했습니다. 잠시 후 다시 시도해주세요.",
"최근접 관로 조회",
);
}
const items = (body?.data ?? body) as NearestPipeResponseItem[];
const matches = new Map<string, PipeMatch>();
const failureMessages: string[] = [];
for (const item of items ?? []) {
if (!item.selected) {
failureMessages.push(nearestPipeFailureMessage(item));
continue;
}
matches.set(item.locationKey, {
fid: item.selected.fid,
mngInstCd: item.selected.mngInstCd,
fcltMngNo: item.selected.fcltMngNo,
pipeDiameterMm: item.selected.pipeDiameterMm ?? 0,
pipeLengthM: item.selected.pipeLengthM ?? 0,
pipeMaterial: item.selected.pipeMaterial ?? "",
installationYear: item.selected.installationYear ?? null,
swtrmtZoneFid: item.selected.swtrmtZoneFid ?? null,
swtrmtZoneNm: item.selected.swtrmtZoneNm ?? "",
distanceMeters: item.selected.distanceMeters,
snappedLongitude: item.selected.snappedLongitude,
snappedLatitude: item.selected.snappedLatitude,
isMock: false,
});
}
if (failureMessages.length > 0) {
throw new Error(Array.from(new Set(failureMessages)).join(" "));
}
return matches;
};
const uploadOnePhoto = (file: File): Promise<AttachmentReference> =>
new Promise((resolve, reject) => {
const transfer = new DataTransfer();
transfer.items.add(file);
const timeoutId = window.setTimeout(() => {
reject(new Error(`${file.name} 첨부 업로드 응답 시간이 초과되었습니다.`));
}, 90_000);
const handleResponse = (response: HttpResponse) => {
const fileInfo = response.getHeader("X-File-Info");
if (!fileInfo) return;
try {
const parsed = JSON.parse(fileInfo) as Partial<AttachmentReference>;
if (!parsed.atflCd || !parsed.atflId) {
throw new Error("첨부 식별자가 응답에 없습니다.");
}
window.clearTimeout(timeoutId);
resolve({ atflCd: parsed.atflCd, atflId: parsed.atflId });
} catch (error) {
window.clearTimeout(timeoutId);
reject(error);
}
};
try {
handleUpload(transfer.files, { handleResponse });
} catch (error) {
window.clearTimeout(timeoutId);
reject(error);
}
});
/**
* 공통 attach-service TUS 업로드를 순차 실행한다.
* 등록 API가 아직 없는 FE 선행 모드에서는 고아 첨부 생성을 막기 위해 전송하지 않는다.
*/
export const uploadDredgingPhotos = async (
groups: DredgingSaveGroup[],
existingReferences: Map<string, AttachmentReference> = new Map(),
) => {
return uploadDredgingPhotoDrafts(
groups.flatMap((group) => group.photos),
existingReferences,
);
};
/** 등록·수정 화면이 공통으로 사용하는 신규 준설사진 첨부 업로드이다. */
export const uploadDredgingPhotoDrafts = async (
photos: DredgingPhotoDraft[],
existingReferences: Map<string, AttachmentReference> = new Map(),
) => {
const references = new Map(existingReferences);
if (!isDredgingBackendEnabled) return references;
try {
for (const photo of photos) {
// DB 저장만 실패한 재시도에서는 이미 성공한 공통 첨부 업로드를 재사용한다.
if (references.has(photo.key)) continue;
references.set(photo.key, await uploadOnePhoto(photo.file));
}
} catch (error) {
throw toDredgingApiError(
error,
"준설사진을 업로드하지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설사진 업로드",
);
}
return references;
};
export const saveDredgingRegistrations = async (
groups: DredgingSaveGroup[],
attachments: Map<string, AttachmentReference>,
) => {
if (!isDredgingBackendEnabled) {
return {
mock: true,
registeredCount: 1,
clientRequestId: createDredgingClientUuid(),
};
}
const clientRequestId = createDredgingClientUuid();
try {
return await postRawJson("v1/dredging/registrations", {
clientRequestId,
dredgingDate: groups[0]?.dredgingDate,
groups: groups.map((group, groupIndex) => ({
groupSn: groupIndex + 1,
registrationMode: group.registrationMode,
location: {
source: group.locationSource,
longitude: group.location.longitude,
latitude: group.location.latitude,
},
expectedPipe: {
fid: group.pipe.fid,
mngInstCd: group.pipe.mngInstCd,
fcltMngNo: group.pipe.fcltMngNo,
},
photos: group.photos.map((photo) => {
const attachment = attachments.get(photo.key);
if (!attachment) {
throw new Error(`${photo.file.name}의 첨부 식별자가 없습니다.`);
}
return {
...attachment,
originalFileName: photo.file.name,
clientCapturedAt: photo.metadata.capturedAt,
clientLongitude: photo.metadata.location?.longitude ?? null,
clientLatitude: photo.metadata.location?.latitude ?? null,
};
}),
})),
});
} catch (error) {
throw toDredgingApiError(
error,
"준설결과를 저장하지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설결과 저장",
);
}
};
/** 기존 준설 화면의 API import를 유지한다. 구현과 첨부 업로드 책임은 feature로 분리했다. */
export * from "@/features/dredging/api/dredging-api";
export * from "@/features/dredging/api/photo-upload";
export { isDredgingBackendEnabled } from "@/features/dredging/api/backend-mode";
@@ -1,21 +1,2 @@
/** 촬영일시 원본은 유지하고 등록·수정 화면 표출값만 YYYY-MM-DD로 정규화한다. */
export const formatDredgingCapturedDate = (
value: string | null | undefined,
) => {
const normalized = String(value ?? "").trim();
const matched = normalized.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!matched) return null;
const year = Number(matched[1]);
const month = Number(matched[2]);
const day = Number(matched[3]);
const date = new Date(year, month - 1, day);
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return null;
}
return matched[0];
};
/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */
export * from "@/features/dredging/lib/date";
@@ -1,334 +1,2 @@
import type { ExifStatus, GeoPoint, PhotoMetadata } from "./types";
type ByteOrder = "LE" | "BE";
type IfdEntry = {
type: number;
count: number;
valueOffset: number;
entryOffset: number;
};
const JPEG_START = 0xffd8;
const APP1_MARKER = 0xe1;
const EXIF_IFD_POINTER = 0x8769;
const GPS_IFD_POINTER = 0x8825;
const DATE_TIME = 0x0132;
const DATE_TIME_ORIGINAL = 0x9003;
const DATE_TIME_DIGITIZED = 0x9004;
const typeByteLength = (type: number) => {
if (type === 1 || type === 2 || type === 7) return 1;
if (type === 3) return 2;
if (type === 4 || type === 9) return 4;
if (type === 5 || type === 10) return 8;
return 0;
};
/**
* JPEG EXIF TIFF 블록을 읽는 범위 제한 reader이다.
* 손상된 offset이 파일 범위를 벗어나면 예외를 발생시켜 수동 입력 흐름으로 전환한다.
*/
class TiffReader {
private readonly view: DataView;
private readonly tiffStart: number;
private readonly order: ByteOrder;
constructor(buffer: ArrayBuffer, tiffStart: number) {
this.view = new DataView(buffer);
this.tiffStart = tiffStart;
this.ensureRange(tiffStart, 8);
const byteOrder = this.view.getUint16(tiffStart, false);
if (byteOrder === 0x4949) {
this.order = "LE";
} else if (byteOrder === 0x4d4d) {
this.order = "BE";
} else {
throw new Error("지원하지 않는 TIFF 바이트 순서입니다.");
}
if (this.uint16(2) !== 0x002a) {
throw new Error("유효한 TIFF 헤더가 아닙니다.");
}
}
private get littleEndian() {
return this.order === "LE";
}
private absolute(relativeOffset: number) {
return this.tiffStart + relativeOffset;
}
private ensureRange(absoluteOffset: number, byteLength: number) {
if (
absoluteOffset < 0 ||
byteLength < 0 ||
absoluteOffset + byteLength > this.view.byteLength
) {
throw new Error("EXIF offset이 파일 범위를 벗어났습니다.");
}
}
uint16(relativeOffset: number) {
const absoluteOffset = this.absolute(relativeOffset);
this.ensureRange(absoluteOffset, 2);
return this.view.getUint16(absoluteOffset, this.littleEndian);
}
uint32(relativeOffset: number) {
const absoluteOffset = this.absolute(relativeOffset);
this.ensureRange(absoluteOffset, 4);
return this.view.getUint32(absoluteOffset, this.littleEndian);
}
private readIfd(relativeOffset: number) {
const entryCount = this.uint16(relativeOffset);
const entries = new Map<number, IfdEntry>();
for (let index = 0; index < entryCount; index += 1) {
const entryOffset = relativeOffset + 2 + index * 12;
const tag = this.uint16(entryOffset);
const type = this.uint16(entryOffset + 2);
const count = this.uint32(entryOffset + 4);
const valueOffset = this.uint32(entryOffset + 8);
entries.set(tag, { type, count, valueOffset, entryOffset });
}
return entries;
}
private dataRelativeOffset(entry: IfdEntry) {
const byteLength = typeByteLength(entry.type) * entry.count;
if (byteLength <= 4) return entry.entryOffset + 8;
return entry.valueOffset;
}
private ascii(entry: IfdEntry) {
const relativeOffset = this.dataRelativeOffset(entry);
const absoluteOffset = this.absolute(relativeOffset);
const byteLength = typeByteLength(entry.type) * entry.count;
this.ensureRange(absoluteOffset, byteLength);
const bytes = new Uint8Array(this.view.buffer, absoluteOffset, byteLength);
return new TextDecoder("ascii").decode(bytes).replace(/\0+$/g, "").trim();
}
private rational(relativeOffset: number) {
const numerator = this.uint32(relativeOffset);
const denominator = this.uint32(relativeOffset + 4);
if (denominator === 0) return null;
return numerator / denominator;
}
private rationals(entry: IfdEntry) {
const relativeOffset = this.dataRelativeOffset(entry);
const values: number[] = [];
for (let index = 0; index < entry.count; index += 1) {
const value = this.rational(relativeOffset + index * 8);
if (value === null) return null;
values.push(value);
}
return values;
}
private pointer(entry: IfdEntry | undefined) {
if (!entry || entry.type !== 4 || entry.count !== 1) return null;
return entry.valueOffset;
}
private parseDate(value: string | null) {
if (!value) return null;
const match = value.match(
/^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/,
);
if (!match) return null;
const [, year, month, day, hour, minute, second] = match;
if (!year || !month || !day || !hour || !minute || !second) return null;
const monthNumber = Number(month);
const dayNumber = Number(day);
const hourNumber = Number(hour);
const minuteNumber = Number(minute);
const secondNumber = Number(second);
if (
monthNumber < 1 ||
monthNumber > 12 ||
dayNumber < 1 ||
dayNumber > 31 ||
hourNumber > 23 ||
minuteNumber > 59 ||
secondNumber > 59
) {
return null;
}
return {
capturedAt: `${year}-${month}-${day}T${hour}:${minute}:${second}`,
dredgingDate: `${year}-${month}-${day}`,
};
}
private parseGps(entries: Map<number, IfdEntry>): GeoPoint | null {
const latitudeRefEntry = entries.get(0x0001);
const latitudeEntry = entries.get(0x0002);
const longitudeRefEntry = entries.get(0x0003);
const longitudeEntry = entries.get(0x0004);
if (
!latitudeRefEntry ||
!latitudeEntry ||
!longitudeRefEntry ||
!longitudeEntry
) {
return null;
}
const latitudeRef = this.ascii(latitudeRefEntry).toUpperCase();
const longitudeRef = this.ascii(longitudeRefEntry).toUpperCase();
const latitudeDms = this.rationals(latitudeEntry);
const longitudeDms = this.rationals(longitudeEntry);
if (
!latitudeDms ||
latitudeDms.length < 3 ||
!longitudeDms ||
longitudeDms.length < 3
) {
return null;
}
const toDecimal = (values: number[]) =>
(values[0] ?? 0) + (values[1] ?? 0) / 60 + (values[2] ?? 0) / 3600;
let latitude = toDecimal(latitudeDms);
let longitude = toDecimal(longitudeDms);
if (latitudeRef === "S") latitude *= -1;
if (longitudeRef === "W") longitude *= -1;
if (
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
latitude < -90 ||
latitude > 90 ||
longitude < -180 ||
longitude > 180
) {
return null;
}
return { latitude, longitude };
}
parse() {
const ifd0Offset = this.uint32(4);
const ifd0 = this.readIfd(ifd0Offset);
const exifIfdOffset = this.pointer(ifd0.get(EXIF_IFD_POINTER));
const exifIfd = exifIfdOffset === null ? null : this.readIfd(exifIfdOffset);
const gpsIfdOffset = this.pointer(ifd0.get(GPS_IFD_POINTER));
const gpsIfd = gpsIfdOffset === null ? null : this.readIfd(gpsIfdOffset);
const dateValue =
(exifIfd?.get(DATE_TIME_ORIGINAL)
? this.ascii(exifIfd.get(DATE_TIME_ORIGINAL)!)
: null) ??
(exifIfd?.get(DATE_TIME_DIGITIZED)
? this.ascii(exifIfd.get(DATE_TIME_DIGITIZED)!)
: null) ??
(ifd0.get(DATE_TIME) ? this.ascii(ifd0.get(DATE_TIME)!) : null);
return {
date: this.parseDate(dateValue),
location: gpsIfd ? this.parseGps(gpsIfd) : null,
};
}
}
const toMetadata = (
capturedAt: string | null,
dredgingDate: string | null,
location: GeoPoint | null,
unsupported = false,
): PhotoMetadata => {
let status: ExifStatus;
let message: string;
if (unsupported) {
status = "UNSUPPORTED";
message = "JPEG EXIF가 없어 준설일자와 위치를 직접 입력해야 합니다.";
} else if (dredgingDate && location) {
status = "COMPLETE";
message = "촬영일자와 GPS를 확인했습니다.";
} else if (!dredgingDate && !location) {
status = "MISSING_BOTH";
message = "촬영일자와 GPS가 없어 수동 입력이 필요합니다.";
} else if (!dredgingDate) {
status = "MISSING_DATE";
message = "촬영일자가 없어 준설일자를 직접 입력해야 합니다.";
} else {
status = "MISSING_GPS";
message = "GPS가 없어 지도에서 준설위치를 등록해야 합니다.";
}
return { capturedAt, dredgingDate, location, status, message };
};
/**
* 브라우저에서 즉시 보여 줄 JPEG EXIF 미리보기를 만든다.
* 서버는 저장 시 원본 첨부파일을 기준으로 동일 정보를 다시 검증해야 한다.
*/
export const readPhotoMetadata = async (file: File): Promise<PhotoMetadata> => {
if (file.type !== "image/jpeg" && !/\.jpe?g$/i.test(file.name)) {
return toMetadata(null, null, null, true);
}
try {
const buffer = await file.arrayBuffer();
const view = new DataView(buffer);
if (view.byteLength < 4 || view.getUint16(0, false) !== JPEG_START) {
return toMetadata(null, null, null, true);
}
let offset = 2;
while (offset + 4 <= view.byteLength) {
if (view.getUint8(offset) !== 0xff) break;
const marker = view.getUint8(offset + 1);
if (marker === 0xda || marker === 0xd9) break;
const segmentLength = view.getUint16(offset + 2, false);
if (segmentLength < 2 || offset + 2 + segmentLength > view.byteLength) {
break;
}
if (marker === APP1_MARKER) {
const exifStart = offset + 4;
const exifSignature =
exifStart + 6 <= view.byteLength
? String.fromCharCode(
view.getUint8(exifStart),
view.getUint8(exifStart + 1),
view.getUint8(exifStart + 2),
view.getUint8(exifStart + 3),
)
: "";
if (exifSignature === "Exif") {
const parsed = new TiffReader(buffer, exifStart + 6).parse();
return toMetadata(
parsed.date?.capturedAt ?? null,
parsed.date?.dredgingDate ?? null,
parsed.location,
);
}
}
offset += 2 + segmentLength;
}
} catch (error) {
console.warn("준설 사진 EXIF 분석 실패", error);
}
return toMetadata(null, null, null, false);
};
/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */
export * from "@/features/dredging/lib/exif";
@@ -0,0 +1,2 @@
/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */
export * from "@/features/dredging/model/registration-request";
@@ -1,122 +1,2 @@
export type DredgingViewType = "MUNICIPALITY" | "DIAMETER" | "ELAPSED_YEAR";
export type DredgingRegistrationMode = "PHOTO" | "MANUAL";
export type DredgingLocationSource = "EXIF" | "MANUAL";
export type ExifStatus =
| "COMPLETE"
| "MISSING_DATE"
| "MISSING_GPS"
| "MISSING_BOTH"
| "UNSUPPORTED";
export type GeoPoint = {
longitude: number;
latitude: number;
};
export type PhotoMetadata = {
capturedAt: string | null;
dredgingDate: string | null;
location: GeoPoint | null;
status: ExifStatus;
message: string;
};
export type DredgingPhotoDraft = {
key: string;
file: File;
metadata: PhotoMetadata;
};
export type PipeMatch = {
fid: string;
mngInstCd: string;
fcltMngNo: string;
pipeDiameterMm: number;
pipeLengthM: number;
pipeMaterial: string;
installationYear: number | null;
swtrmtZoneFid: string | null;
swtrmtZoneNm: string;
distanceMeters: number;
snappedLongitude: number;
snappedLatitude: number;
isMock: boolean;
};
export type DredgingDraftGroup = {
id: string;
registrationMode: DredgingRegistrationMode;
photos: DredgingPhotoDraft[];
manualLocation: GeoPoint | null;
matchedPipe: PipeMatch | null;
matchError: string | null;
isMatching: boolean;
};
export type DredgingRecord = {
id: string;
dredgingJobSn: number | string;
pipeFid: string;
mngInstCd: string;
ctpv: string;
sgg: string;
treatmentZone: string;
fcltMngNo: string;
pipeDiameterMm: number;
pipeLengthM: number;
pipeMaterial: string;
installationYear: number | null;
dredgingDate: string;
photoCount: number;
registeredDate: string;
longitude: number;
latitude: number;
snappedLongitude: number;
snappedLatitude: number;
registrationMode: DredgingRegistrationMode;
registrationSource: DredgingLocationSource;
matchDistanceMeters: number;
isMock: boolean;
};
export type DredgingPhoto = {
photoSn: number;
pipeFid: string;
atflCd: string;
atflId: string;
originalFileName: string;
capturedAt: string | null;
};
export type DredgingSaveGroup = {
localId: string;
registrationMode: DredgingRegistrationMode;
dredgingDate: string;
location: GeoPoint;
locationSource: DredgingLocationSource;
pipe: PipeMatch;
photos: DredgingPhotoDraft[];
};
/** 상세 기반 수정 팝업에서 선택한 한 관로의 위치·사진을 함께 갱신한다. */
export type DredgingRecordUpdate = {
dredgingDate: string;
originalPipeFid: string;
registrationMode: DredgingRegistrationMode;
location: GeoPoint;
locationSource: DredgingLocationSource;
pipe: PipeMatch;
retainedPhotoSns: number[];
newPhotos: DredgingPhotoDraft[];
};
export const EMPTY_PHOTO_METADATA: PhotoMetadata = {
capturedAt: null,
dredgingDate: null,
location: null,
status: "UNSUPPORTED",
message: "메타정보를 확인하고 있습니다.",
};
/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */
export * from "@/features/dredging/model/types";
@@ -1,135 +1,2 @@
"use client";
import { useEffect, useState } from "react";
import { getCommonCodes } from "@/components/lib/common/apiHelpers";
import type {
DredgingLocationSource,
DredgingRegistrationMode,
ExifStatus,
} from "./types";
type CodeLabelMap = Record<string, string>;
const COMMON_CODE_GROUPS = {
registrationMode: "DRDGNREGMODE",
locationSource: "DRDGNLOCSRC",
photoStatus: "DRDGNPHOTOSTTS",
} as const;
const DEFAULT_REGISTRATION_MODE_LABELS: Record<
DredgingRegistrationMode,
string
> = {
PHOTO: "사진 기반 등록",
MANUAL: "사진 없이 직접 등록",
};
const DEFAULT_LOCATION_SOURCE_LABELS: Record<DredgingLocationSource, string> = {
EXIF: "사진 GPS",
MANUAL: "지도 지정",
};
const DEFAULT_PHOTO_STATUS_LABELS: Record<ExifStatus, string> = {
COMPLETE: "정상",
MISSING_DATE: "촬영일 없음",
MISSING_GPS: "GPS 없음",
MISSING_BOTH: "촬영일·GPS 없음",
UNSUPPORTED: "수동 입력 필요",
};
const cachedLabelMaps = new Map<string, CodeLabelMap>();
const pendingLabelMaps = new Map<string, Promise<CodeLabelMap>>();
/** 같은 브라우저 세션에서는 준설 코드그룹별 CMS 요청을 한 번만 수행한다. */
const loadCodeLabelMap = async (
upCd: string,
fallback: CodeLabelMap,
): Promise<CodeLabelMap> => {
const cached = cachedLabelMaps.get(upCd);
if (cached) return cached;
const pending = pendingLabelMaps.get(upCd);
if (pending) return pending;
const request = getCommonCodes(upCd)
.then((codes) => {
const labels = { ...fallback };
codes.forEach((code) => {
if (code.lwpoCd && code.lwpoCdNm) {
labels[code.lwpoCd] = code.lwpoCdNm;
}
});
cachedLabelMaps.set(upCd, labels);
return labels;
})
.finally(() => {
pendingLabelMaps.delete(upCd);
});
pendingLabelMaps.set(upCd, request);
return request;
};
/** 준설 저장 코드값은 유지하고 버튼·상세·사진상태 표시명만 CMS 기준으로 제공한다. */
export const useDredgingCommonCodes = () => {
const [registrationModeLabels, setRegistrationModeLabels] =
useState<CodeLabelMap>(
cachedLabelMaps.get(COMMON_CODE_GROUPS.registrationMode) ??
DEFAULT_REGISTRATION_MODE_LABELS,
);
const [locationSourceLabels, setLocationSourceLabels] =
useState<CodeLabelMap>(
cachedLabelMaps.get(COMMON_CODE_GROUPS.locationSource) ??
DEFAULT_LOCATION_SOURCE_LABELS,
);
const [photoStatusLabels, setPhotoStatusLabels] = useState<CodeLabelMap>(
cachedLabelMaps.get(COMMON_CODE_GROUPS.photoStatus) ??
DEFAULT_PHOTO_STATUS_LABELS,
);
useEffect(() => {
let cancelled = false;
const applyLabels = (
upCd: string,
fallback: CodeLabelMap,
setter: (labels: CodeLabelMap) => void,
) => {
loadCodeLabelMap(upCd, fallback)
.then((labels) => {
if (!cancelled) setter(labels);
})
.catch((error) => {
console.error(`${upCd} 공통코드 조회에 실패했습니다.`, error);
});
};
applyLabels(
COMMON_CODE_GROUPS.registrationMode,
DEFAULT_REGISTRATION_MODE_LABELS,
setRegistrationModeLabels,
);
applyLabels(
COMMON_CODE_GROUPS.locationSource,
DEFAULT_LOCATION_SOURCE_LABELS,
setLocationSourceLabels,
);
applyLabels(
COMMON_CODE_GROUPS.photoStatus,
DEFAULT_PHOTO_STATUS_LABELS,
setPhotoStatusLabels,
);
return () => {
cancelled = true;
};
}, []);
return {
registrationModeLabels,
locationSourceLabels,
photoStatusLabels,
};
};
/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */
export * from "@/features/dredging/lib/use-dredging-common-codes";
@@ -1,31 +1,2 @@
/**
* 일반 HTTP에서는 `crypto.randomUUID`가 제공되지 않을 수 있어 Web Crypto 난수로 UUID v4를 생성한다.
* 백엔드 `clientRequestId`가 UUID 타입이므로 fallback도 같은 36자 형식을 유지한다.
*/
export const createDredgingClientUuid = () => {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
return crypto.randomUUID();
}
const bytes = new Uint8Array(16);
if (
typeof crypto !== "undefined" &&
typeof crypto.getRandomValues === "function"
) {
crypto.getRandomValues(bytes);
} else {
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, (value) =>
value.toString(16).padStart(2, "0"),
).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
};
/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */
export * from "@/features/dredging/lib/uuid";
@@ -1,85 +0,0 @@
/**
* 📄 HeadMakeFloodPre Component
* 침수예방 테이블 헤더 생성 컴포넌트
*
* - 테이블 상단 헤더 생성 및 렌더링
* - 하위 헤더 목록 기반 동적 헤더 구성
* - rowspan 및 colspan을 통한 테이블 구조 조정
* - 가시성 필터링 및 조건부 렌더링 지원
* - 상위 헤더와 하위 헤더의 계층적 표시
*/
import { TableHead, TableRow } from "@common/components/ui";
export type TableUpperProps = {
id: string;
title: string;
upName?: string;
upSequnce?: number;
edit?: boolean;
visible?: boolean;
rowSpan?: number;
colSpan?: number;
type?: string;
selectData?: string; // type 이 seelct 일경우 selectarray 정하기 위해
row?: number;
mergeCell?: boolean; // 셀을 합칠경우
maxLength?: number;
readonly?: boolean;
};
type chHeadType = {
headNm: string;
text: string;
length: number;
rows?: number;
};
export const HeadMakeFloodPre = (
upHeadList: TableUpperProps[],
makeHeadNm: chHeadType[],
) => {
return (
<>
<TableRow>
{upHeadList
.filter((data) => data.visible != false)
.map((head: TableUpperProps) =>
!head.upSequnce ? (
<TableHead
className="border border-border px-2 py-1"
rowSpan={2}
key={head.id}
>
{head.title}
</TableHead>
) : head.upSequnce === 1 ? (
<TableHead
className="border border-border px-2 py-1"
key={head.id}
rowSpan={1}
colSpan={
makeHeadNm?.find((ch) => ch.headNm === head.upName)?.length
}
>
{makeHeadNm?.find((ch) => ch.headNm === head.upName)?.text}
</TableHead>
) : null,
)}
</TableRow>
<TableRow>
{upHeadList
.filter((data) => data.visible != false)
.map((head: TableUpperProps) =>
makeHeadNm?.find((ch) => ch.headNm === head.upName) ? (
<TableHead
className="border border-border px-2 py-1"
key={head.id}
>
{head.title}
</TableHead>
) : null,
)}
</TableRow>
</>
);
};
@@ -1,213 +0,0 @@
/**
* 📄 InputTableBody Component
* 침수예방 입력 테이블 바디 컴포넌트
*
* - 숫자 입력 및 콤마 포맷팅 기능
* - mergeCell을 통한 셀 병합 처리
* - 편집 가능/불가능 상태 관리
* - 소계/총합계 행 스타일링 및 표시
* - 입력 값 검증 및 변경 핸들링
* - 빈 데이터 상태 처리
*/
import type { TableUpperProps } from "./headMakeFloodPre";
import { Input, TableCell, TableRow } from "@common/components/ui";
// 숫자입력시 , 삽입
const formatCommaNumber = (
value: number | string | undefined | null,
): string => {
if (value === undefined || value === null || value === "") return "";
const num = Number(value);
// 숫자로 변환할 수 없는 값이 들어올 경우 빈 문자열을 반환하여 예상치 못한 "NaN" 출력을 방지합니다.
if (isNaN(num)) {
return "";
}
return num.toLocaleString();
};
// onchange 텍스트를 숫자로 and ,제거
const handleCommaNumberChange = (
e: React.ChangeEvent<HTMLInputElement>,
onChange: (value: number | "") => void,
) => {
const raw = e.target.value.replace(/,/g, "");
if (!/^\d*$/.test(raw)) return;
onChange(raw === "" ? "" : Number(raw));
};
const handleChange = (
rowIndex: number,
field: any,
value: string,
setInputData: (data: any) => void,
) => {
if (Number(value) < 0 || !Number(value)) {
value = "0";
}
setInputData((prevData: any) =>
prevData?.map((row: any, index: number) => {
if (index === rowIndex) {
return {
...row,
[field]: Number(value.replace(/,/g, "")),
};
}
return row;
}),
);
};
export const InputTableBody = (
chgRes: any[] | [],
headList: TableUpperProps[],
setInputData: (data: any[]) => void,
isEditable: boolean = true,
) => {
// mergeCell 처리용
const getRowSpan = (data: any[], key: string, rowIndex: number) => {
if (
rowIndex > 0 &&
data[rowIndex - 1][key] &&
String(data[rowIndex][key]).trim() === ""
) {
return 0; // 이전과 같으면 셀을 그리지 않음
}
let span = 1;
for (let i = rowIndex + 1; i < data.length; i++) {
if (data[i][key] === data[rowIndex][key]) span++;
else break;
}
return span;
};
return (
<>
{chgRes.map((data, dataIndex) => {
// 소계/총합계 행 판별
const isSubTotal = data["용수명"] === "소계";
const isTotal = data["구분"] === 0;
const bgClass = isTotal
? "bg-secondary font-bold"
: isSubTotal
? "bg-muted font-semibold"
: "";
return (
<TableRow key={dataIndex + "row"}>
{headList.map((head, headIndex) => {
const isTextColumn = head.id === "__EMPTY_3";
const textAlignClass = isTextColumn
? "text-center"
: "text-right";
// // 총합계 행이면 "구분"에서 colSpan=2로 병합
// if (
// (data["구분"] === 0 || data["용수명"] === 0) &&
// head.id === "구분"
// ) {
// return (
// <TableCell
// key={head.id + headIndex}
// colSpan={2}
// className="bg-secondary text-center font-bold"
// >
// 총합계
// </TableCell>
// );
// }
// // 총합계(합계) 행이면 "용수명" 컬럼은 렌더링하지 않음
// if (
// (data["구분"] === 0 || data["용수명"] === 0) &&
// head.id === "용수명"
// ) {
// return null;
// }
// // 총합계 행의 나머지 셀에도 색상 적용
// if (data["구분"] === 0 || data["용수명"] === 0) {
// return (
// <TableCell
// key={head.id + headIndex}
// className="bg-secondary text-right font-bold"
// >
// {Number(data[head.id])
// ? formatCommaNumber(data[head.id as keyof any])
// : (data[head.id as keyof any] ?? "")}
// </TableCell>
// );
// }
// mergeCell 처리
if (head.mergeCell === true) {
const rowSpan = getRowSpan(chgRes, head.id, dataIndex);
if (rowSpan === 0) return null;
return (
<TableCell
key={head.id + headIndex}
rowSpan={rowSpan}
className={`w-36 whitespace-nowrap text-center align-middle ${bgClass}`}
>
{Number(data[head.id])
? formatCommaNumber(data[head.id as keyof any])
: (data[head.id as keyof any] ?? "")}
</TableCell>
);
}
// 기본 셀
// return head.edit === true && data.edit !== false ? (
return isEditable && head.edit === true && data.edit !== false ? (
<TableCell
key={head.id + headIndex}
className={`w-36 ${bgClass}`}
>
<Input
// className={`${head.id !== "utztnUsgCd" ? "text-right" : "text-center"} !text-xl h-10 w-full`}
className={`${textAlignClass} h-10 w-full !text-xl`}
onChange={(e) =>
handleCommaNumberChange(e, (val) =>
handleChange(
dataIndex,
head.id,
String(val),
setInputData,
),
)
}
value={formatCommaNumber(data[head.id as keyof any]) ?? ""}
maxLength={12}
/>
</TableCell>
) : (
<TableCell
key={head.id + headIndex}
className={`w-36 ${bgClass}`}
>
<div
// className={`
// ${head.id !== "utztnUsgCd" ? "text-right" : "text-center"}
// !text-xl w-full h-10
// `}
className={`${textAlignClass} h-10 w-full !text-xl`}
>
{Number(data[head.id])
? formatCommaNumber(data[head.id as keyof any])
: (data[head.id as keyof any] ?? "")}
</div>
</TableCell>
);
})}
</TableRow>
);
})}
{chgRes.length === 0 && (
<TableRow>
<TableCell
colSpan={headList.length}
className="h-24 text-center text-muted-foreground"
>
조회된 결과가 없습니다.
</TableCell>
</TableRow>
)}
</>
);
};
@@ -222,21 +222,18 @@ const Page = () => {
};
// 선택 파일 삭제
// 첨부·업무 삭제 중 어느 하나라도 실패하면 성공 안내와 목록 갱신을 실행하지 않는다.
const isDeleteFile = async () => {
if (sbmrgPrevntBizSn === "" || sbmrgPrevntBizSn === undefined) {
alert("삭제할 파일을 선택해주세요");
return false;
}
if (!(await confirm("삭제하시겠습니까?"))) return false;
const data = await deleteData(
`/attach-service/api/v1/atfl/attachments/${atflId}`,
);
if (data) {
const delData = await deleteData(`v1/sbmrgPrevnt/${sbmrgPrevntBizSn}`);
if (delData.status === 200) {
alert("삭제되었습니다.", () => void fetchData());
}
} else {
try {
await deleteData(`/attach-service/api/v1/atfl/attachments/${atflId}`);
await deleteData(`v1/sbmrgPrevnt/${sbmrgPrevntBizSn}`);
alert("삭제되었습니다.", () => void fetchData());
} catch {
alert("삭제에 실패했습니다.");
}
};
@@ -1,28 +0,0 @@
/**
* 📄 Oper Index Configuration
* 운영 관련 테이블 헤더 설정 파일
*
* - 침수예방 및 노후 하수관로 테이블 헤더 정의
* - 일련번호, 파일명, 년도, 분기, 등록 일자, 등록자 필드 포함
* - 테이블 컴포넌트에서 재사용 가능한 헤더 구조 제공
* - 데이터 표시 및 정렬을 위한 표준화된 헤더 포맷
* - 운영 관리 페이지에서 공통으로 사용되는 헤더 리스트
* - 동적 테이블 렌더링을 위한 ID와 타이틀 매핑
*/
export const tableHeadlist = [
{ id: 'rowNumber', title: '일련번호', sortable: true, sortKey: 'sbmrgPrevntBizSn' },
{ id: 'sbmrgPrevntBizNm', title: '파일명', sortable: true },
{ id: 'mngYr', title: '년도', sortable: true },
{ id: 'mngQu', title: '분기', sortable: true },
{ id: 'regDt', title: '등록 일자', sortable: true },
{ id: 'rgtrNm', title: '등록자' }
]
export const tableHeadlistOldPp = [
{ id: 'rowNumber', title: '일련번호', sortable: true, sortKey: 'dcrpPplMtncBizSn' },
{ id: 'dcrpPplMtncBizNm', title: '파일명', sortable: true },
{ id: 'mngYr', title: '년도', sortable: true },
{ id: 'mngQu', title: '분기', sortable: true },
{ id: 'regDt', title: '등록 일자', sortable: true },
{ id: 'rgtrNm', title: '등록자' }
]

Some files were not shown because too many files have changed in this diff Show More