This commit is contained in:
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
+1
-1
@@ -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<
|
||||
|
||||
+4
-4
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 또는 별도 터미널에서 직접 실행한 프론트엔드 개발 서버는 임의로 종료·재시작·교체하지 않는다. 종료가 필요하면 먼저 사용자에게 확인한다.
|
||||
|
||||
+12
-4
@@ -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={
|
||||
|
||||
+1
-1
@@ -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 편집 버튼과 직접 연결된다.
|
||||
|
||||
+175
-325
@@ -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],
|
||||
|
||||
+9
-10
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
+104
-1864
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 +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...
|
||||
+14
-12
@@ -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}
|
||||
|
||||
+50
-446
@@ -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
Reference in New Issue
Block a user