Compare commits

...
10 Commits
41 changed files with 2393 additions and 2047 deletions
@@ -0,0 +1,22 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { RvwOpnnUpdateRequestDto } from "@/shared/model";
const BASE = ENDPOINT.AS_SERVICE.FNRS_CNSLTN_RVW_OPNN;
async function update(
fnrsCnsltnDocNo: string,
rvwOpnnSn: number,
body: RvwOpnnUpdateRequestDto,
): Promise<void> {
await client.put<void>(`${BASE}/${fnrsCnsltnDocNo}/${rvwOpnnSn}`, body);
}
async function deleteFn(fnrsCnsltnDocNo: string, rvwOpnnSn: number): Promise<void> {
await client.delete<void>(`${BASE}/${fnrsCnsltnDocNo}/${rvwOpnnSn}`);
}
export const fnrsCnsltnRvwOpnnService = {
update,
delete: deleteFn,
};
+66 -65
View File
@@ -1,65 +1,66 @@
export { fnrsCnsltnMngService } from "./api/fnrs-cnsltn-service"; export { fnrsCnsltnRvwOpnnService } from "./api/fnrs-cnsltn-rvw-opnn-service";
export { fnrsCnsltnKeys } from "./api/queries"; export { fnrsCnsltnMngService } from "./api/fnrs-cnsltn-service";
export { export { fnrsCnsltnKeys } from "./api/queries";
useChangePrgrsSttsComCd, export {
useDeleteFnrsCnsltnMng, useChangePrgrsSttsComCd,
useFnrsCnsltnFileVrfcResults, useDeleteFnrsCnsltnMng,
useFnrsCnsltnMng, useFnrsCnsltnFileVrfcResults,
useFnrsCnsltnMngPage, useFnrsCnsltnMng,
useRequestActnReviewFnrsCnsltnMng, useFnrsCnsltnMngPage,
useRequestExamineFnrsCnsltnMng, useRequestActnReviewFnrsCnsltnMng,
useSaveApplyFnrsCnsltnMng, useRequestExamineFnrsCnsltnMng,
useSaveFnrsCnsltnMng, useSaveApplyFnrsCnsltnMng,
useSubmitReqSupplementFnrsCnsltnMng, useSaveFnrsCnsltnMng,
useSubmitSupplementFnrsCnsltnMng, useSubmitReqSupplementFnrsCnsltnMng,
useTechReviewFnrsCnsltnMng, useSubmitSupplementFnrsCnsltnMng,
useUpdateFnrsCnsltnMng, useTechReviewFnrsCnsltnMng,
} from "./api/queries"; useUpdateFnrsCnsltnMng,
export type { } from "./api/queries";
BizCstItem, export type {
BzScaleItem, BizCstItem,
CklstResultItem, BzScaleItem,
EsntlAtchFileItem, CklstResultItem,
FnrsCnsltnApplySaveRequestDto, EsntlAtchFileItem,
FnrsCnsltnMngSaveRequestDto, FnrsCnsltnApplySaveRequestDto,
FnrsCnsltnRvwOpnnSaveRequestDto, FnrsCnsltnMngSaveRequestDto,
FnrsCnsltnSplmntDmndItem, FnrsCnsltnRvwOpnnSaveRequestDto,
FnrsSharItem, FnrsCnsltnSplmntDmndItem,
} from "./model/fnrs-cnsltn-apply-payload-schema"; FnrsSharItem,
export { } from "./model/fnrs-cnsltn-apply-payload-schema";
fnrsCnsltnApplySaveSchema, export {
fnrsCnsltnBizCstItemSchema, fnrsCnsltnApplySaveSchema,
fnrsCnsltnBzScaleItemSchema, fnrsCnsltnBizCstItemSchema,
fnrsCnsltnCklstResultItemSchema, fnrsCnsltnBzScaleItemSchema,
fnrsCnsltnEsntlAtchFileItemSchema, fnrsCnsltnCklstResultItemSchema,
fnrsCnsltnMngSaveSchema, fnrsCnsltnEsntlAtchFileItemSchema,
fnrsCnsltnRvwOpnnSaveSchema, fnrsCnsltnMngSaveSchema,
fnrsCnsltnSplmntDmndItemSchema, fnrsCnsltnRvwOpnnSaveSchema,
fnrsSharItemSchema, fnrsCnsltnSplmntDmndItemSchema,
} from "./model/fnrs-cnsltn-apply-payload-schema"; fnrsSharItemSchema,
export type { } from "./model/fnrs-cnsltn-apply-payload-schema";
AtflMngClientResponseDto, export type {
BizCstTreeDto, AtflMngClientResponseDto,
BizSclTreeDto, BizCstTreeDto,
CklstRvwRsltTreeDto, BizSclTreeDto,
DocPrgrsUpdateRequestDto, CklstRvwRsltTreeDto,
ElaprDocPrgrsClientResponseDto, DocPrgrsUpdateRequestDto,
EsntlAtchFileMngTreeResponseDto, ElaprDocPrgrsClientResponseDto,
FnrsCnsltnFileVrfcRsltResponseDto, EsntlAtchFileMngTreeResponseDto,
FnrsCnsltnMngDetResponseDto, FnrsCnsltnFileVrfcRsltResponseDto,
FnrsCnsltnMngResponseDto, FnrsCnsltnMngDetResponseDto,
FnrsCnsltnMngSearchDto, FnrsCnsltnMngResponseDto,
FnrsCnsltnMngService, FnrsCnsltnMngSearchDto,
FnrsCnsltnMngUpdateRequestDto, FnrsCnsltnMngService,
FnrsCnsltnRvwOpnnResponseDto, FnrsCnsltnMngUpdateRequestDto,
FnrsCnsltnSplmntDmndResponseDto, FnrsCnsltnRvwOpnnResponseDto,
RagIndexingInfoDto, FnrsCnsltnSplmntDmndResponseDto,
} from "./model/fnrs-cnsltn-interface"; RagIndexingInfoDto,
export type { } from "./model/fnrs-cnsltn-interface";
EnvoActnReviewReqDto, export type {
FnrsExamineRequestDto, EnvoActnReviewReqDto,
} from "./model/fnrs-cnsltn-payload-schema"; FnrsExamineRequestDto,
export { } from "./model/fnrs-cnsltn-payload-schema";
envoActnReviewReqSchema, export {
fnrsExamineRequestSchema, envoActnReviewReqSchema,
} from "./model/fnrs-cnsltn-payload-schema"; fnrsExamineRequestSchema,
} from "./model/fnrs-cnsltn-payload-schema";
@@ -0,0 +1,22 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { RvwOpnnUpdateRequestDto } from "@/shared/model";
const BASE = ENDPOINT.AS_SERVICE.INSTL_SNCN_RVW_OPNN;
async function update(
instlSncnDocNo: string,
rvwOpnnSn: number,
body: RvwOpnnUpdateRequestDto,
): Promise<void> {
await client.put<void>(`${BASE}/${instlSncnDocNo}/${rvwOpnnSn}`, body);
}
async function deleteFn(instlSncnDocNo: string, rvwOpnnSn: number): Promise<void> {
await client.delete<void>(`${BASE}/${instlSncnDocNo}/${rvwOpnnSn}`);
}
export const instlSncnRvwOpnnService = {
update,
delete: deleteFn,
};
+1
View File
@@ -1,3 +1,4 @@
export { instlSncnRvwOpnnService } from "./api/instl-sncn-rvw-opnn-service";
export { instlSncnRegionService, instlSncnService } from "./api/instl-sncn-service"; export { instlSncnRegionService, instlSncnService } from "./api/instl-sncn-service";
export { export {
instlSncnKeys, instlSncnKeys,
@@ -0,0 +1,23 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { RvwOpnnUpdateRequestDto } from "@/shared/model";
const BASE = ENDPOINT.AS_SERVICE.SEWER_BSC_PLAN_RVW_OPNNS;
async function update(
sewerBscPlanDocNo: string,
chgCycl: number,
rvwOpnnSn: number,
body: RvwOpnnUpdateRequestDto,
): Promise<void> {
await client.put<void>(`${BASE}/${sewerBscPlanDocNo}/${chgCycl}/${rvwOpnnSn}`, body);
}
async function deleteFn(sewerBscPlanDocNo: string, chgCycl: number, rvwOpnnSn: number): Promise<void> {
await client.delete<void>(`${BASE}/${sewerBscPlanDocNo}/${chgCycl}/${rvwOpnnSn}`);
}
export const sewerBscPlanRvwOpnnService = {
update,
delete: deleteFn,
};
+65 -64
View File
@@ -1,64 +1,65 @@
export { sewerBscPlanKeys } from "./api/queries"; export { sewerBscPlanKeys } from "./api/queries";
export { export {
useDeleteSewerBscPlan, useDeleteSewerBscPlan,
useSaveApplySewerBscPlan, useSaveApplySewerBscPlan,
useSaveSewerBscPlan, useSaveSewerBscPlan,
useSewerBscPlan, useSewerBscPlan,
useSewerBscPlanDetails, useSewerBscPlanDetails,
useSewerBscPlanPage, useSewerBscPlanPage,
useSubmitReqSupplementSewerBscPlan, useSubmitReqSupplementSewerBscPlan,
useSubmitSupplementSewerBscPlan, useSubmitSupplementSewerBscPlan,
useTechReviewSewerBscPlan, useTechReviewSewerBscPlan,
useUpdateSewerBscPlan, useUpdateSewerBscPlan,
} from "./api/queries"; } from "./api/queries";
export { sewerBscPlanService } from "./api/sewer-bsc-plan-service"; export { sewerBscPlanRvwOpnnService } from "./api/sewer-bsc-plan-rvw-opnn-service";
export type { export { sewerBscPlanService } from "./api/sewer-bsc-plan-service";
CklstResultItem, export type {
EsntlAtchFileItem, CklstResultItem,
SewerBscPlanApplySaveRequestDto, EsntlAtchFileItem,
SewerBscPlanBizCstItem, SewerBscPlanApplySaveRequestDto,
SewerBscPlanDtlSaveRequestDto, SewerBscPlanBizCstItem,
SewerBscPlanFnrsCstItem, SewerBscPlanDtlSaveRequestDto,
SewerBscPlanOtlnItem, SewerBscPlanFnrsCstItem,
SewerBscPlanRvwOpnnItem, SewerBscPlanOtlnItem,
SewerBscPlanSaveRequestDto, SewerBscPlanRvwOpnnItem,
SewerBscPlanSplmntDmndItem, SewerBscPlanSaveRequestDto,
SewerBscPlanTmplntDtlItem, SewerBscPlanSplmntDmndItem,
SewerBscPlanTmplntItem, SewerBscPlanTmplntDtlItem,
SewerBscPlanTmplntTmprStrgItem, SewerBscPlanTmplntItem,
} from "./model/sewer-bsc-plan-apply-payload-schema"; SewerBscPlanTmplntTmprStrgItem,
export { } from "./model/sewer-bsc-plan-apply-payload-schema";
sewerBscPlanApplySaveSchema, export {
sewerBscPlanBizCstItemSchema, sewerBscPlanApplySaveSchema,
sewerBscPlanCklstResultItemSchema, sewerBscPlanBizCstItemSchema,
sewerBscPlanDtlSaveSchema, sewerBscPlanCklstResultItemSchema,
sewerBscPlanEsntlAtchFileItemSchema, sewerBscPlanDtlSaveSchema,
sewerBscPlanFnrsCstItemSchema, sewerBscPlanEsntlAtchFileItemSchema,
sewerBscPlanOtlnItemSchema, sewerBscPlanFnrsCstItemSchema,
sewerBscPlanRvwOpnnItemSchema, sewerBscPlanOtlnItemSchema,
sewerBscPlanSaveSchema, sewerBscPlanRvwOpnnItemSchema,
sewerBscPlanSplmntDmndItemSchema, sewerBscPlanSaveSchema,
sewerBscPlanTmplntDtlItemSchema, sewerBscPlanSplmntDmndItemSchema,
sewerBscPlanTmplntItemSchema, sewerBscPlanTmplntDtlItemSchema,
sewerBscPlanTmplntTmprStrgItemSchema, sewerBscPlanTmplntItemSchema,
} from "./model/sewer-bsc-plan-apply-payload-schema"; sewerBscPlanTmplntTmprStrgItemSchema,
export type { } from "./model/sewer-bsc-plan-apply-payload-schema";
AtflMngClientResponseDto, export type {
CklstRvwRsltTreeDto, AtflMngClientResponseDto,
EsntlAtchFileMngTreeResponseDto, CklstRvwRsltTreeDto,
SewerBscPlanApplySaveResponseDto, EsntlAtchFileMngTreeResponseDto,
SewerBscPlanBizCstResponseDto, SewerBscPlanApplySaveResponseDto,
SewerBscPlanDetResponseDto, SewerBscPlanBizCstResponseDto,
SewerBscPlanDtlResponseDto, SewerBscPlanDetResponseDto,
SewerBscPlanFnrsCstResponseDto, SewerBscPlanDtlResponseDto,
SewerBscPlanOtlnResponseDto, SewerBscPlanFnrsCstResponseDto,
SewerBscPlanResponseDto, SewerBscPlanOtlnResponseDto,
SewerBscPlanRvwOpnnResponseDto, SewerBscPlanResponseDto,
SewerBscPlanSearchRequestDto, SewerBscPlanRvwOpnnResponseDto,
SewerBscPlanService, SewerBscPlanSearchRequestDto,
SewerBscPlanSplmntDmndResponseDto, SewerBscPlanService,
SewerBscPlanTmplntDtlResponseDto, SewerBscPlanSplmntDmndResponseDto,
SewerBscPlanTmplntResponseDto, SewerBscPlanTmplntDtlResponseDto,
SewerBscPlanTmplntTmprStrgResponseDto, SewerBscPlanTmplntResponseDto,
SewerBscPlanUpdateRequestDto, SewerBscPlanTmplntTmprStrgResponseDto,
} from "./model/sewer-bsc-plan-interface"; SewerBscPlanUpdateRequestDto,
} from "./model/sewer-bsc-plan-interface";
@@ -1,338 +1,334 @@
"use client"; "use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib"; import { useQueryClient } from "@tanstack/react-query";
import { TextShimmer } from "@/shared/ui";
import { SectionCollapsible } from "@/shared/ui"; import { getDefaultFileKey, useTus, useUserStore } from "@/shared/lib";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field"; import { TextShimmer } from "@/shared/ui";
import { AttachmentDownloadLink, attachService } from "@/entities/attach"; import { SectionCollapsible } from "@/shared/ui";
import type { DesignCnstnRvwOpnnResponseDto } from "@/entities/design-cnstn"; import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { import { attachService } from "@/entities/attach";
Button, import {
RadioGroup, designCnstnKeys,
RadioGroupItem, type DesignCnstnRvwOpnnResponseDto,
Table, designCnstnRvwOpnnService,
TableBody, } from "@/entities/design-cnstn";
TableCell, import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
TableHead, import {
TableHeader, Button,
TableRow, RadioGroup,
Textarea, RadioGroupItem,
} from "@common/components"; Table,
TableCell,
import type { TableHead,
ReviewOpinionFormData, TableHeader,
ReviewOpinionGetFormDataResult, TableRow,
} from "../../lib/review-opinion-utils"; Textarea,
import type { ReviewStepCode } from "../../model/view-types"; } from "@common/components";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
import type {
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::"; ReviewOpinionFormData,
ReviewOpinionGetFormDataResult,
interface TechReviewOpinionSectionProps { } from "../../lib/review-opinion-utils";
rvwList: DesignCnstnRvwOpnnResponseDto[]; import type { ReviewStepCode } from "../../model/view-types";
editableReviewStep?: ReviewStepCode; import { REVIEW_STEP_LABELS } from "../../model/view-types";
atrzSeComCdList?: string[];
isAtrzLoading?: boolean; const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
}
interface TechReviewOpinionSectionProps {
export interface TechReviewOpinionSectionHandle { rvwList: DesignCnstnRvwOpnnResponseDto[];
getFormData: () => ReviewOpinionGetFormDataResult | null; editableReviewStep?: ReviewStepCode;
resetForm: () => void; atrzSeComCdList?: string[];
} isAtrzLoading?: boolean;
}
interface TechReviewOpinionTableProps {
items: DesignCnstnRvwOpnnResponseDto[]; export interface TechReviewOpinionSectionHandle {
} getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
interface UploadedOpinionFile { }
fileName: string;
fileKey: string; interface TechReviewOpinionTableProps {
atflId?: string; items: DesignCnstnRvwOpnnResponseDto[];
atflCd?: string; }
}
interface UploadedOpinionFile {
const EMPTY_FORM_DATA: ReviewOpinionFormData = { fileName: string;
rvwRsltCn: "", fileKey: string;
rvwRsltAnmlyYn: "", atflId?: string;
atchFileGroupId: "", atflCd?: string;
opnrSeComCd: "", }
};
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
function createRvwOpnnUploadKey(file: File) { rvwRsltCn: "",
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`; rvwRsltAnmlyYn: "",
} atchFileGroupId: "",
opnrSeComCd: "",
function hasFormContent(formData: ReviewOpinionFormData) { };
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
} function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
const TechReviewOpinionSection = forwardRef< }
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps function hasFormContent(formData: ReviewOpinionFormData) {
>(function TechReviewOpinionSection( return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false }, }
ref,
) { const TechReviewOpinionSection = forwardRef<
const canWriteOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02"); TechReviewOpinionSectionHandle,
const [formData, setFormData] = useState<ReviewOpinionFormData>({ TechReviewOpinionSectionProps
...EMPTY_FORM_DATA, >(function TechReviewOpinionSection(
opnrSeComCd: editableReviewStep ?? "", { rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
}); ref,
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null); ) {
const canWriteOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
useEffect(() => { const [formData, setFormData] = useState<ReviewOpinionFormData>({
setFormData((prev) => ({ ...EMPTY_FORM_DATA,
...prev, opnrSeComCd: editableReviewStep ?? "",
opnrSeComCd: editableReviewStep ?? "", });
})); const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
}, [editableReviewStep]);
useEffect(() => {
const { uploadFileAsync, isUploading, uploads } = useTus({ setFormData((prev) => ({
onSuccess: (atflCd, atflId, _file, fileKey) => { ...prev,
if (!atflCd || !atflId) return; opnrSeComCd: editableReviewStep ?? "",
}));
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd })); }, [editableReviewStep]);
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
}, const { uploadFileAsync, isUploading, uploads } = useTus({
onError: (_error, _file, fileKey) => { onSuccess: (atflCd, atflId, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev)); if (!atflCd || !atflId) return;
},
}); setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
useImperativeHandle( },
ref, onError: (_error, _file, fileKey) => {
() => ({ setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
getFormData: () => { },
if (!canWriteOpinion || !editableReviewStep || !hasFormContent(formData)) { });
return null;
} useImperativeHandle(
ref,
return { () => ({
formData: { getFormData: () => {
...formData, if (!canWriteOpinion || !editableReviewStep || !hasFormContent(formData)) {
opnrSeComCd: editableReviewStep, return null;
}, }
rvwList,
}; return {
}, formData: {
resetForm: () => { ...formData,
setFormData({ opnrSeComCd: editableReviewStep,
...EMPTY_FORM_DATA, },
opnrSeComCd: editableReviewStep ?? "", rvwList,
}); };
setUploadedFile(null); },
}, resetForm: () => {
}), setFormData({
[canWriteOpinion, editableReviewStep, formData, rvwList], ...EMPTY_FORM_DATA,
); opnrSeComCd: editableReviewStep ?? "",
});
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO"); setUploadedFile(null);
const hasKecoSection = },
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteOpinion); }),
[canWriteOpinion, editableReviewStep, formData, rvwList],
if (!hasKecoSection) { );
return null;
} const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasKecoSection =
async function handleFileUpload(files: FileList | null) { kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteOpinion);
if (!files || files.length === 0) return;
if (!hasKecoSection) {
const file = files[0]; return null;
const fileKey = createRvwOpnnUploadKey(file); }
const existingAtflCd = formData.atchFileGroupId;
async function handleFileUpload(files: FileList | null) {
setUploadedFile({ fileName: file.name, fileKey }); if (!files || files.length === 0) return;
try { const file = files[0];
await uploadFileAsync(file, { const fileKey = createRvwOpnnUploadKey(file);
fileKey, const existingAtflCd = formData.atchFileGroupId;
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
}); setUploadedFile({ fileName: file.name, fileKey });
} catch {
// onError callback handles cleanup try {
} await uploadFileAsync(file, {
} fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
async function handleFileRemove() { });
if (!uploadedFile) return; } catch {
// onError callback handles cleanup
uploads[uploadedFile.fileKey]?.abort(); }
}
if (uploadedFile.atflId) {
try { async function handleFileRemove() {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId); if (!uploadedFile) return;
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error); uploads[uploadedFile.fileKey]?.abort();
}
} if (uploadedFile.atflId) {
try {
setUploadedFile(null); await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" })); } catch (error) {
} console.error("검토의견 첨부파일 삭제 실패:", error);
}
function renderOpinionForm(reviewStep: ReviewStepCode) { }
if (editableReviewStep !== reviewStep || !canWriteOpinion) return null;
setUploadedFile(null);
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false; setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
return (
<div className="space-y-2"> function isReviewTurn(reviewStep: ReviewStepCode) {
<h4 className="text-xl font-semibold">검토의견 작성</h4> return editableReviewStep === reviewStep && canWriteOpinion;
<Table caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공" variant="secondary"> }
<TableHeader>
<TableRow> function renderOpinionForm(reviewStep: ReviewStepCode) {
<TableHead scope="row" className="w-[12rem]">의견내용</TableHead> if (!isReviewTurn(reviewStep)) return null;
<TableCell>
<Textarea const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
value={formData.rvwRsltCn}
onChange={(event) => return (
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value })) <div className="space-y-2">
} <h4 className="text-xl font-semibold">검토의견 작성</h4>
placeholder="의견내용을 입력하세요" <Table
rows={4} caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
className="min-h-[8rem] !text-xl" variant="secondary"
/> >
</TableCell> <TableHeader>
</TableRow> <TableRow>
<TableRow> <TableHead scope="row" className="w-[12rem]">
<TableHead scope="row">검토결과</TableHead> 의견내용
<TableCell> </TableHead>
<FieldSet> <TableCell>
<RadioGroup <Textarea
value={formData.rvwRsltAnmlyYn} value={formData.rvwRsltCn}
onValueChange={(value) => onChange={(event) =>
setFormData((prev) => ({ setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
...prev, }
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"], placeholder="의견내용을 입력하세요"
})) rows={4}
} className="min-h-[8rem] !text-xl"
> />
<div className="flex flex-wrap gap-4"> </TableCell>
<FieldLabel </TableRow>
htmlFor={`rvw-opnn-normal-${reviewStep}`} <TableRow>
className="!w-auto border-none" <TableHead scope="row">검토결과</TableHead>
> <TableCell>
<Field orientation="horizontal" className="w-auto"> <FieldSet>
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} /> <RadioGroup
<span className="font-normal">정상</span> value={formData.rvwRsltAnmlyYn}
</Field> onValueChange={(value) =>
</FieldLabel> setFormData((prev) => ({
<FieldLabel ...prev,
htmlFor={`rvw-opnn-abnormal-${reviewStep}`} rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
className="!w-auto border-none" }))
> }
<Field orientation="horizontal" className="w-auto"> >
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} /> <div className="flex flex-wrap gap-4">
<span className="font-normal">이상</span> <FieldLabel
</Field> htmlFor={`rvw-opnn-normal-${reviewStep}`}
</FieldLabel> className="!w-auto border-none"
</div> >
</RadioGroup> <Field orientation="horizontal" className="w-auto">
</FieldSet> <RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
</TableCell> <span className="font-normal">정상</span>
</TableRow> </Field>
<TableRow> </FieldLabel>
<TableHead scope="row">첨부파일</TableHead> <FieldLabel
<TableCell> htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
{uploadedFile ? ( className="!w-auto border-none"
<div className="flex items-center gap-2"> >
<TextShimmer className="text-xl" active={uploading}> <Field orientation="horizontal" className="w-auto">
{uploadedFile.fileName} <RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
</TextShimmer> <span className="font-normal">이상</span>
<Button </Field>
size="icon" </FieldLabel>
onClick={(e) => { </div>
e.preventDefault(); </RadioGroup>
void handleFileRemove(); </FieldSet>
}} </TableCell>
disabled={uploading} </TableRow>
className="size-8 !bg-destructive" <TableRow>
> <TableHead scope="row">첨부파일</TableHead>
<i className="diveicon di-x text-xs" aria-label="삭제" /> <TableCell>
</Button> {uploadedFile ? (
</div> <div className="flex items-center gap-2">
) : ( <TextShimmer className="text-xl" active={uploading}>
<label> {uploadedFile.fileName}
<input </TextShimmer>
type="file" <Button
className="hidden" size="icon"
onChange={(event) => { onClick={(e) => {
void handleFileUpload(event.target.files); e.preventDefault();
event.target.value = ""; void handleFileRemove();
}} }}
/> disabled={uploading}
<Button size="sm" variant="outline" type="button" asChild> className="size-8 !bg-destructive"
<span>파일 선택</span> >
</Button> <i className="diveicon di-x text-xs" aria-label="삭제" />
</label> </Button>
)} </div>
</TableCell> ) : (
</TableRow> <label>
</TableHeader> <input
</Table> type="file"
</div> className="hidden"
); onChange={(event) => {
} void handleFileUpload(event.target.files);
event.target.value = "";
return ( }}
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}> />
<div className="space-y-6"> <Button size="sm" variant="outline" type="button" asChild>
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null} <span>파일 선택</span>
{renderOpinionForm("KECO")} </Button>
</div> </label>
</SectionCollapsible> )}
); </TableCell>
}); </TableRow>
</TableHeader>
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) { </Table>
return ( </div>
<div className="overflow-x-auto"> );
<Table caption="검토의견 목록으로 의견자명, 의견내용, 검토결과, 첨부파일, 등록일시 정보 제공" variant="secondary"> }
<colgroup>
<col width="10%" /> return (
<col width="55%" /> <SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<col width="5%" /> <div className="space-y-6">
<col width="20%" /> {kecoRvwList.length > 0 ? (
<col width="10%" /> <TechReviewOpinionTable items={kecoRvwList} />
</colgroup> ) : null}
<TableHeader> {renderOpinionForm("KECO")}
<TableRow> </div>
<TableHead>의견자명</TableHead> </SectionCollapsible>
<TableHead>의견내용</TableHead> );
<TableHead>검토결과</TableHead> });
<TableHead>첨부파일</TableHead>
<TableHead>등록일시</TableHead> function getRvwOpnnIds(item: DesignCnstnRvwOpnnResponseDto) {
</TableRow> if (!item.designCnstnDocNo || item.rvwOpnnSn == null) {
</TableHeader> throw new Error("검토의견 식별값이 없습니다.");
<TableBody> }
{items.map((item) => ( return [item.designCnstnDocNo, item.rvwOpnnSn] as const;
<TableRow key={`${item.designCnstnDocNo}_${item.rvwOpnnSn}`}> }
<TableCell align="center">{item.opnrNm}</TableCell>
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell> function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
<TableCell align="center"> const queryClient = useQueryClient();
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"} const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
</TableCell>
<TableCell align="center"> return (
{item.atchFileInfo?.atflCd ? ( <RvwOpnnListTable
<AttachmentDownloadLink items={items}
atflCd={item.atchFileInfo.atflCd} getRowKey={(item) => `${item.designCnstnDocNo}_${item.rvwOpnnSn}`}
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"} currentUserUuid={currentUserUuid}
/> onUpdate={async (item, body) => {
) : ( await designCnstnRvwOpnnService.update(...getRvwOpnnIds(item), body);
"-" await queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
)} }}
</TableCell> onDelete={async (item) => {
<TableCell align="center"> await designCnstnRvwOpnnService.delete(...getRvwOpnnIds(item));
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"} await queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
</TableCell> }}
</TableRow> />
))} );
</TableBody> }
</Table>
</div> export { TechReviewOpinionSection };
);
}
export { TechReviewOpinionSection };
@@ -1,352 +1,345 @@
"use client"; "use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib"; import { useQueryClient } from "@tanstack/react-query";
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field"; import { getDefaultFileKey, useTus, useUserStore } from "@/shared/lib";
import { import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
AttachmentDownloadLink, import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
attachService, import { attachService, useDownloadAttachmentByAtflId } from "@/entities/attach";
useDownloadAttachmentByAtflId, import {
} from "@/entities/attach"; fnrsCnsltnKeys,
import type { FnrsCnsltnRvwOpnnResponseDto } from "@/entities/fnrs-cnsltn"; type FnrsCnsltnRvwOpnnResponseDto,
import { fnrsCnsltnRvwOpnnService,
RadioGroup, } from "@/entities/fnrs-cnsltn";
RadioGroupItem, import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
Table, import {
TableBody, RadioGroup,
TableCell, RadioGroupItem,
TableHead, Table,
TableHeader, TableCell,
TableRow, TableHead,
Textarea, TableHeader,
} from "@common/components"; TableRow,
Textarea,
import type { } from "@common/components";
ReviewOpinionFormData,
ReviewOpinionGetFormDataResult, import type {
} from "../../lib/review-opinion-utils"; ReviewOpinionFormData,
import type { ReviewStepCode } from "../../model/view-types"; ReviewOpinionGetFormDataResult,
import { REVIEW_STEP_LABELS } from "../../model/view-types"; } from "../../lib/review-opinion-utils";
import type { ReviewStepCode } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::"; import { REVIEW_STEP_LABELS } from "../../model/view-types";
interface TechReviewOpinionSectionProps { const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
rvwList: FnrsCnsltnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode; interface TechReviewOpinionSectionProps {
atrzSeComCdList?: string[]; rvwList: FnrsCnsltnRvwOpnnResponseDto[];
isAtrzLoading?: boolean; editableReviewStep?: ReviewStepCode;
} atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
export interface TechReviewOpinionSectionHandle { }
getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void; export interface TechReviewOpinionSectionHandle {
} getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
interface TechReviewOpinionTableProps { }
items: FnrsCnsltnRvwOpnnResponseDto[];
} interface TechReviewOpinionTableProps {
items: FnrsCnsltnRvwOpnnResponseDto[];
interface UploadedOpinionFile { }
fileName: string;
fileKey: string; interface UploadedOpinionFile {
atflId?: string; fileName: string;
atflCd?: string; fileKey: string;
} atflId?: string;
atflCd?: string;
const EMPTY_FORM_DATA: ReviewOpinionFormData = { }
rvwRsltCn: "",
rvwRsltAnmlyYn: "", const EMPTY_FORM_DATA: ReviewOpinionFormData = {
atchFileGroupId: "", rvwRsltCn: "",
opnrSeComCd: "", rvwRsltAnmlyYn: "",
}; atchFileGroupId: "",
opnrSeComCd: "",
function createRvwOpnnUploadKey(file: File) { };
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
} function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
function hasFormContent(formData: ReviewOpinionFormData) { }
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
} function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
const TechReviewOpinionSection = forwardRef< }
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps const TechReviewOpinionSection = forwardRef<
>(function TechReviewOpinionSection( TechReviewOpinionSectionHandle,
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false }, TechReviewOpinionSectionProps
ref, >(function TechReviewOpinionSection(
) { { rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02"); ref,
const [formData, setFormData] = useState<ReviewOpinionFormData>({ ) {
...EMPTY_FORM_DATA, const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
opnrSeComCd: editableReviewStep ?? "", const [formData, setFormData] = useState<ReviewOpinionFormData>({
}); ...EMPTY_FORM_DATA,
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null); opnrSeComCd: editableReviewStep ?? "",
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId(); });
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
useEffect(() => { const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
setFormData((prev) => ({
...prev, useEffect(() => {
opnrSeComCd: editableReviewStep ?? "", setFormData((prev) => ({
})); ...prev,
}, [editableReviewStep]); opnrSeComCd: editableReviewStep ?? "",
}));
const { uploadFileAsync, isUploading, uploads } = useTus({ }, [editableReviewStep]);
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return; const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd })); if (!atflCd || !atflId) return;
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
}, setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
onError: (_error, _file, fileKey) => { setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev)); },
}, onError: (_error, _file, fileKey) => {
}); setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
useImperativeHandle( });
ref,
() => ({ useImperativeHandle(
getFormData: () => { ref,
if (!editableReviewStep || !hasFormContent(formData)) { () => ({
return null; getFormData: () => {
} if (!editableReviewStep || !hasFormContent(formData)) {
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) { return null;
return null; }
} if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
return null;
return { }
formData: {
...formData, return {
opnrSeComCd: editableReviewStep, formData: {
}, ...formData,
rvwList, opnrSeComCd: editableReviewStep,
}; },
}, rvwList,
resetForm: () => { };
setFormData({ },
...EMPTY_FORM_DATA, resetForm: () => {
opnrSeComCd: editableReviewStep ?? "", setFormData({
}); ...EMPTY_FORM_DATA,
setUploadedFile(null); opnrSeComCd: editableReviewStep ?? "",
}, });
}), setUploadedFile(null);
[canWriteKecoOpinion, editableReviewStep, formData, rvwList], },
); }),
[canWriteKecoOpinion, editableReviewStep, formData, rvwList],
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV"); );
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV"; const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasKecoSection = kecoRvwList.length > 0 || editableReviewStep === "KECO";
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
if (!hasEnvSection && !hasKecoSection) { const hasKecoSection = kecoRvwList.length > 0 || editableReviewStep === "KECO";
return null;
} if (!hasEnvSection && !hasKecoSection) {
return null;
async function handleFileUpload(files: FileList | null) { }
if (!files || files.length === 0) return;
async function handleFileUpload(files: FileList | null) {
const file = files[0]; if (!files || files.length === 0) return;
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId; const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
setUploadedFile({ fileName: file.name, fileKey }); const existingAtflCd = formData.atchFileGroupId;
try { setUploadedFile({ fileName: file.name, fileKey });
await uploadFileAsync(file, {
fileKey, try {
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined, await uploadFileAsync(file, {
}); fileKey,
} catch { metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
// onError callback handles cleanup });
} } catch {
} // onError callback handles cleanup
}
async function handleFileRemove() { }
if (!uploadedFile) return;
async function handleFileRemove() {
uploads[uploadedFile.fileKey]?.abort(); if (!uploadedFile) return;
if (uploadedFile.atflId) { uploads[uploadedFile.fileKey]?.abort();
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId); if (uploadedFile.atflId) {
} catch (error) { try {
console.error("검토의견 첨부파일 삭제 실패:", error); await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} } catch (error) {
} console.error("검토의견 첨부파일 삭제 실패:", error);
}
setUploadedFile(null); }
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
} setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
function renderOpinionForm(reviewStep: ReviewStepCode) { }
if (editableReviewStep !== reviewStep) return null;
if (reviewStep === "KECO" && !canWriteKecoOpinion) return null; function isReviewTurn(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep) return false;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false; if (reviewStep === "KECO" && !canWriteKecoOpinion) return false;
return true;
return ( }
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4> function renderOpinionForm(reviewStep: ReviewStepCode) {
<Table caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공" variant="secondary"> if (!isReviewTurn(reviewStep)) return null;
<TableHeader>
<TableRow> const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
<TableHead className="w-[12rem]">의견내용</TableHead>
<TableCell> return (
<Textarea <div className="space-y-2">
value={formData.rvwRsltCn} <h4 className="text-xl font-semibold">검토의견 작성</h4>
onChange={(event) => <Table
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value })) caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
} variant="secondary"
placeholder="의견내용을 입력하세요" >
rows={4} <TableHeader>
className="min-h-[8rem] !text-xl" <TableRow>
/> <TableHead className="w-[12rem]">의견내용</TableHead>
</TableCell> <TableCell>
</TableRow> <Textarea
<TableRow> value={formData.rvwRsltCn}
<TableHead>검토결과</TableHead> onChange={(event) =>
<TableCell> setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
<FieldSet> }
<RadioGroup placeholder="의견내용을 입력하세요"
value={formData.rvwRsltAnmlyYn} rows={4}
onValueChange={(value) => className="min-h-[8rem] !text-xl"
setFormData((prev) => ({ />
...prev, </TableCell>
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"], </TableRow>
})) <TableRow>
} <TableHead>검토결과</TableHead>
> <TableCell>
<div className="flex flex-wrap gap-4"> <FieldSet>
<FieldLabel <RadioGroup
htmlFor={`rvw-opnn-normal-${reviewStep}`} value={formData.rvwRsltAnmlyYn}
className="!w-auto border-none" onValueChange={(value) =>
> setFormData((prev) => ({
<Field orientation="horizontal" className="w-auto"> ...prev,
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} /> rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
<span className="font-normal">정상</span> }))
</Field> }
</FieldLabel> >
<FieldLabel <div className="flex flex-wrap gap-4">
htmlFor={`rvw-opnn-abnormal-${reviewStep}`} <FieldLabel
className="!w-auto border-none" htmlFor={`rvw-opnn-normal-${reviewStep}`}
> className="!w-auto border-none"
<Field orientation="horizontal" className="w-auto"> >
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} /> <Field orientation="horizontal" className="w-auto">
<span className="font-normal">이상</span> <RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
</Field> <span className="font-normal">정상</span>
</FieldLabel> </Field>
</div> </FieldLabel>
</RadioGroup> <FieldLabel
</FieldSet> htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
</TableCell> className="!w-auto border-none"
</TableRow> >
<TableRow> <Field orientation="horizontal" className="w-auto">
<TableHead>첨부파일</TableHead> <RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<TableCell> <span className="font-normal">이상</span>
<AttachmentFileField </Field>
files={ </FieldLabel>
uploadedFile </div>
? [ </RadioGroup>
{ </FieldSet>
fileKey: uploadedFile.fileKey, </TableCell>
fileName: uploadedFile.fileName, </TableRow>
onDownload: uploadedFile.atflId <TableRow>
? () => downloadByAtflId(uploadedFile.atflId!) <TableHead>첨부파일</TableHead>
: undefined, <TableCell>
}, <AttachmentFileField
] files={
: [] uploadedFile
} ? [
inputId={`rvw-opnn-${reviewStep}-file-input`} {
isUploadingByKey={isUploading} fileKey: uploadedFile.fileKey,
multiple={false} fileName: uploadedFile.fileName,
showAddButton={!uploadedFile} onDownload: uploadedFile.atflId
addDisabled={uploading} ? () => downloadByAtflId(uploadedFile.atflId!)
onAdd={(fileList) => void handleFileUpload(fileList)} : undefined,
onRemove={() => void handleFileRemove()} },
/> ]
</TableCell> : []
</TableRow> }
</TableHeader> inputId={`rvw-opnn-${reviewStep}-file-input`}
</Table> isUploadingByKey={isUploading}
</div> multiple={false}
); showAddButton={!uploadedFile}
} addDisabled={uploading}
onAdd={(fileList) => void handleFileUpload(fileList)}
return ( onRemove={() => void handleFileRemove()}
<> />
{hasEnvSection ? ( </TableCell>
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}> </TableRow>
<div className="space-y-6"> </TableHeader>
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null} </Table>
{renderOpinionForm("ENV")} </div>
</div> );
</SectionCollapsible> }
) : null}
{hasKecoSection ? ( return (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}> <>
<div className="space-y-6"> {hasEnvSection ? (
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null} <SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
{/* <div className="space-y-6">
재원협의에서 환경공단 검토의견은 자동 생성 {envRvwList.length > 0 ? (
{renderOpinionForm("KECO")} <TechReviewOpinionTable items={envRvwList} />
*/} ) : null}
</div> {renderOpinionForm("ENV")}
</SectionCollapsible> </div>
) : null} </SectionCollapsible>
</> ) : null}
); {hasKecoSection ? (
}); <SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) { {kecoRvwList.length > 0 ? (
return ( <TechReviewOpinionTable items={kecoRvwList} />
<div className="overflow-x-auto"> ) : null}
<Table caption="검토의견 목록으로 의견자명, 의견내용, 검토결과, 첨부파일, 등록일시 정보 제공" variant="secondary"> {/*
<colgroup> 재원협의에서 환경공단 검토의견은 자동 생성
<col width="10%" /> {renderOpinionForm("KECO")}
<col width="55%" /> */}
<col width="5%" /> </div>
<col width="20%" /> </SectionCollapsible>
<col width="10%" /> ) : null}
</colgroup> </>
<TableHeader> );
<TableRow> });
<TableHead>의견자명</TableHead>
<TableHead>의견내용</TableHead> function getRvwOpnnIds(item: FnrsCnsltnRvwOpnnResponseDto) {
<TableHead>검토결과</TableHead> if (!item.fnrsCnsltnDocNo || item.rvwOpnnSn == null) {
<TableHead>첨부파일</TableHead> throw new Error("검토의견 식별값이 없습니다.");
<TableHead>등록일시</TableHead> }
</TableRow> return [item.fnrsCnsltnDocNo, item.rvwOpnnSn] as const;
</TableHeader> }
<TableBody>
{items.map((item) => ( function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
<TableRow key={`${item.fnrsCnsltnDocNo}_${item.rvwOpnnSn}`}> const queryClient = useQueryClient();
<TableCell align="center">{item.opnrNm}</TableCell> const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
<TableCell align="center"> return (
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"} <RvwOpnnListTable
</TableCell> items={items}
<TableCell align="center"> getRowKey={(item) => `${item.fnrsCnsltnDocNo}_${item.rvwOpnnSn}`}
{item.atchFileInfo?.atflCd ? ( currentUserUuid={currentUserUuid}
<AttachmentDownloadLink onUpdate={async (item, body) => {
atflCd={item.atchFileInfo.atflCd} await fnrsCnsltnRvwOpnnService.update(...getRvwOpnnIds(item), body);
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"} await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
/> }}
) : ( onDelete={async (item) => {
"-" await fnrsCnsltnRvwOpnnService.delete(...getRvwOpnnIds(item));
)} await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
</TableCell> }}
<TableCell align="center"> />
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"} );
</TableCell> }
</TableRow>
))} export { TechReviewOpinionSection };
</TableBody>
</Table>
</div>
);
}
export { TechReviewOpinionSection };
@@ -3,7 +3,6 @@ export const INSTL_SNCN_VIEW_BUTTON_ACTION = {
aply: "aply", aply: "aply",
del: "del", del: "del",
instlSncnAncmnt: "instlSncnAncmnt", instlSncnAncmnt: "instlSncnAncmnt",
envoSbmsn: "envoSbmsn",
rjct: "rjct", rjct: "rjct",
sncnAprv: "sncnAprv", sncnAprv: "sncnAprv",
rvwRply: "rvwRply", rvwRply: "rvwRply",
@@ -33,14 +32,16 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, InstlSncnViewBut
], ],
TA: [INSTL_SNCN_VIEW_BUTTON_ACTION.instlSncnAncmnt], TA: [INSTL_SNCN_VIEW_BUTTON_ACTION.instlSncnAncmnt],
}, },
ROLE_USER04: { // 환경청 단계 제거: 기존 환경청(LE·TP) 기능을 광역자치단체가 수행
ST: [INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn, INSTL_SNCN_VIEW_BUTTON_ACTION.rjct], ROLE_USER04: {
EP: [INSTL_SNCN_VIEW_BUTTON_ACTION.sncnAprv], ST: [
}, INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply,
ROLE_USER03: { INSTL_SNCN_VIEW_BUTTON_ACTION.rqstTechRvw,
LE: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply, INSTL_SNCN_VIEW_BUTTON_ACTION.rqstTechRvw], INSTL_SNCN_VIEW_BUTTON_ACTION.rjct,
TP: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply], ],
}, TP: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply],
EP: [INSTL_SNCN_VIEW_BUTTON_ACTION.sncnAprv],
},
}; };
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE"] as const; const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE"] as const;
@@ -55,16 +55,8 @@ export const instlSncnRegistSchema = z.object({
tstmDesignAmt: z.number(), tstmDesignAmt: z.number(),
}), }),
), ),
cklstResultList: z.array( // 설치인가 화면에서 체크리스트 미사용: 숨겨진 값 때문에 신청·저장 검증이 막히지 않도록 필수값을 두지 않음
instlSncnCklstResultItemSchema.extend({ cklstResultList: z.array(instlSncnCklstResultItemSchema),
cklstSn: z.number(),
cklstArtclSn: z.number(),
cklstRvwSn: z.number(),
rvwStpComCd: z.string(),
selected: z.enum(["Y", "N"]).nullish(),
picRvwCn: z.string().nullish(),
}),
),
esntlAtchFileList: z.array( esntlAtchFileList: z.array(
instlSncnEsntlAtchFileItemSchema.extend({ instlSncnEsntlAtchFileItemSchema.extend({
esntlAtchFileId: z.string(), esntlAtchFileId: z.string(),
@@ -50,7 +50,7 @@ export function getEditableReviewStep(
dtlInstCd?: string, dtlInstCd?: string,
prgrsSttsComCd?: string, prgrsSttsComCd?: string,
): ReviewStepCode | undefined { ): ReviewStepCode | undefined {
if (dtlInstCd === "ROLE_USER03" && (prgrsSttsComCd === "LE" || prgrsSttsComCd === "TP")) { if (dtlInstCd === "ROLE_USER04" && (prgrsSttsComCd === "ST" || prgrsSttsComCd === "TP")) {
return "ENV"; return "ENV";
} }
@@ -50,13 +50,13 @@ export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
export const CHECKLIST_TAB_LABELS: Record<ReviewStepCode, string> = { export const CHECKLIST_TAB_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "자가체크", LOCAL: "자가체크",
ENV: "환경청검토", ENV: "광역자치단체검토",
KECO: "기술검토", KECO: "기술검토",
}; };
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = { export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "지자체", LOCAL: "지자체",
ENV: "환경청", ENV: "광역자치단체",
KECO: "환경공단", KECO: "환경공단",
}; };
@@ -89,7 +89,7 @@ export const INSTL_PROGRESS_ORDER = [
export const ROLE_TO_REVIEW_STEP: Record<string, ReviewStepCode> = { export const ROLE_TO_REVIEW_STEP: Record<string, ReviewStepCode> = {
ROLE_USER05: "LOCAL", ROLE_USER05: "LOCAL",
ROLE_USER03: "ENV", ROLE_USER04: "ENV",
ROLE_USER01: "KECO", ROLE_USER01: "KECO",
}; };
@@ -35,7 +35,6 @@ import {
type InstlSncnViewButtonAction, type InstlSncnViewButtonAction,
needsRoleUser01AtrzResolve, needsRoleUser01AtrzResolve,
} from "../../lib/button-config"; } from "../../lib/button-config";
import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
import { import {
buildSavePayload, buildSavePayload,
buildSupplementReqPayload, buildSupplementReqPayload,
@@ -179,11 +178,6 @@ function FloatingActionButtons({
} }
async function handleAplyClick() { async function handleAplyClick() {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultList ?? [],
checklistFormState.results,
"LOCAL",
);
const payload: InstlSncnApplySaveRequestDto = buildSavePayload( const payload: InstlSncnApplySaveRequestDto = buildSavePayload(
data, data,
checklistFormState, checklistFormState,
@@ -191,11 +185,6 @@ function FloatingActionButtons({
"ST", "ST",
); );
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
return;
}
await executeAction({ await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.aply, action: INSTL_SNCN_VIEW_BUTTON_ACTION.aply,
request: payload, request: payload,
@@ -231,22 +220,6 @@ function FloatingActionButtons({
}); });
} }
async function handleEnvoSbmsnClick() {
const payload: DocPrgrsUpdateRequestDto = {
docNo,
docSeComCd,
prgrsSttsComCd: "LE",
};
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn,
request: payload,
successMessage: "제출되었습니다.",
});
queryClient.invalidateQueries({
queryKey: instlSncnKeys.list(),
});
}
async function handleRjctClick() { async function handleRjctClick() {
const payload: DocPrgrsUpdateRequestDto = { const payload: DocPrgrsUpdateRequestDto = {
docNo, docNo,
@@ -284,16 +257,6 @@ function FloatingActionButtons({
} }
async function handleRvwRplyClick() { async function handleRvwRplyClick() {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultList ?? [],
checklistFormState.results,
"ENV",
);
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 검토결과 회신이 가능합니다.");
return;
}
const payload = buildTechReviewReplyPayload( const payload = buildTechReviewReplyPayload(
data, data,
@@ -369,16 +332,6 @@ function FloatingActionButtons({
} }
async function handleRvwCmptnClick() { async function handleRvwCmptnClick() {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultList ?? [],
checklistFormState.results,
"KECO",
);
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 검토완료가 가능합니다.");
return;
}
const payload = { const payload = {
...buildTechReviewReplyPayload( ...buildTechReviewReplyPayload(
@@ -447,11 +400,6 @@ function FloatingActionButtons({
설치인가 고시 설치인가 고시
</Button> </Button>
)} )}
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn) && (
<Button onClick={handleEnvoSbmsnClick} disabled={isProcessing}>
환경청제출
</Button>
)}
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.rjct) && ( {shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.rjct) && (
<Button color="red" onClick={handleRjctClick} disabled={isProcessing}> <Button color="red" onClick={handleRjctClick} disabled={isProcessing}>
반려 반려
@@ -618,7 +566,6 @@ const buttonActionHandlers: Record<InstlSncnViewButtonAction, ActionHandler> = {
aply: handleSaveApply, aply: handleSaveApply,
del: handleDel, del: handleDel,
instlSncnAncmnt: handleChangePrgrsSttsComCd, instlSncnAncmnt: handleChangePrgrsSttsComCd,
envoSbmsn: handleChangePrgrsSttsComCd,
rjct: handleChangePrgrsSttsComCd, rjct: handleChangePrgrsSttsComCd,
sncnAprv: handleChangePrgrsSttsComCd, sncnAprv: handleChangePrgrsSttsComCd,
rvwRply: handleTechReview, rvwRply: handleTechReview,
@@ -20,9 +20,7 @@ import {
AttachmentsFormSection, AttachmentsFormSection,
BusinessCostFormSection, BusinessCostFormSection,
BusinessScaleFormSection, BusinessScaleFormSection,
ChecklistFormSection,
ContactInfoFormSection, ContactInfoFormSection,
isFormChecklistComplete,
isFormEsntlAtchComplete, isFormEsntlAtchComplete,
PrivacyConsentSection, PrivacyConsentSection,
ProgressStatusSection, ProgressStatusSection,
@@ -154,11 +152,6 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
return; return;
} }
if (!isFormChecklistComplete(data.cklstResultList)) {
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
return;
}
if (!isFormEsntlAtchComplete(essentialFiles, data.esntlAtchFileList)) { if (!isFormEsntlAtchComplete(essentialFiles, data.esntlAtchFileList)) {
alert("필수 첨부파일을 모두 등록해야 신청이 가능합니다."); alert("필수 첨부파일을 모두 등록해야 신청이 가능합니다.");
return; return;
@@ -228,7 +221,6 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
<PrivacyConsentSection /> <PrivacyConsentSection />
<BusinessScaleFormSection /> <BusinessScaleFormSection />
<BusinessCostFormSection /> <BusinessCostFormSection />
<ChecklistFormSection docSeComCd="INSTL" fieldPrefix="instlSncnMng" />
<AttachmentsFormSection <AttachmentsFormSection
docSeComCd="INSTL" docSeComCd="INSTL"
fieldPrefix="instlSncnMng" fieldPrefix="instlSncnMng"
@@ -1,149 +1,141 @@
"use client"; "use client";
import { useMemo, useRef } from "react"; import { useMemo, useRef } from "react";
import { useUserStore } from "@/shared/lib"; import { useUserStore } from "@/shared/lib";
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui"; import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { ElaprDocPrgrsStatus } from "@/entities/elapr"; import { ElaprDocPrgrsStatus } from "@/entities/elapr";
import { useInstlSncnDetail } from "@/entities/instl-sncn"; import { useInstlSncnDetail } from "@/entities/instl-sncn";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw"; import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import { import {
AttachmentsViewSection, AttachmentsViewSection,
ProgressStatusSection, ProgressStatusSection,
SupplementDemandSection, SupplementDemandSection,
} from "@/widgets/pbadms-sprt"; } from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components"; import { Skeleton } from "@common/components";
import { needsRoleUser01AtrzResolve } from "../lib/button-config"; import { needsRoleUser01AtrzResolve } from "../lib/button-config";
import { getEditableReviewStep } from "../lib/review-step-utils"; import { getEditableReviewStep } from "../lib/review-step-utils";
import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state"; import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
import { PROGRESS_STATUS_COLUMNS } from "../model/columns"; import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
import { FloatingActionButtons } from "./components/floating-action-buttons"; import { FloatingActionButtons } from "./components/floating-action-buttons";
import { BasicInfoSection } from "./sections/basic-info-section"; import { BasicInfoSection } from "./sections/basic-info-section";
import { BusinessCostSection } from "./sections/business-cost-section"; import { BusinessCostSection } from "./sections/business-cost-section";
import { BusinessScaleSection } from "./sections/business-scale-section"; import { BusinessScaleSection } from "./sections/business-scale-section";
import { ChecklistSection } from "./sections/checklist-section"; import { ContactInfoSection } from "./sections/contact-info-section";
import { ContactInfoSection } from "./sections/contact-info-section"; import {
import { TechReviewOpinionSection,
TechReviewOpinionSection, type TechReviewOpinionSectionHandle,
type TechReviewOpinionSectionHandle, } from "./sections/tech-review-opinion-section";
} from "./sections/tech-review-opinion-section";
interface InstlViewPageProps {
interface InstlViewPageProps { id: string;
id: string; }
}
function InstlViewPage({ id }: InstlViewPageProps) {
function InstlViewPage({ id }: InstlViewPageProps) { const userInfo = useUserStore((state) => state.userInfo);
const userInfo = useUserStore((state) => state.userInfo); const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null); const { data, isLoading, isError } = useInstlSncnDetail(id);
const { data, isLoading, isError } = useInstlSncnDetail(id); const { formState: checklistFormState } = useChecklistFormState(
const { formState: checklistFormState, setFormState: setChecklistFormState } = data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST,
useChecklistFormState(data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST); );
const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || ""; const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || "";
const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd); const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd);
const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList( const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
{}, {},
{ enabled: needsAtrzResolve }, { enabled: needsAtrzResolve },
); );
const atrzSeComCdList = useMemo( const atrzSeComCdList = useMemo(
() => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [], () => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
[rvwLoginUserData], [rvwLoginUserData],
); );
if (isLoading) { if (isLoading) {
return ( return (
<div className="mt-6 flex flex-col gap-4 px-6"> <div className="mt-6 flex flex-col gap-4 px-6">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" /> <Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[16rem] rounded-xl" /> <Skeleton className="h-[16rem] rounded-xl" />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" /> <Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[16rem] rounded-xl" /> <Skeleton className="h-[16rem] rounded-xl" />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" /> <Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[32rem] rounded-xl" /> <Skeleton className="h-[32rem] rounded-xl" />
</div> </div>
</div> </div>
); );
} }
if (isError || !data) { if (isError || !data) {
return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>; return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>;
} }
const { const {
detDto, detDto,
bizSclList, bizSclList,
bizCstList, bizCstList,
cklstResultList = [], esntlFileList,
esntlFileList, etcFileList,
etcFileList, rvwList = [],
rvwList = [], splmntDmndList = [],
splmntDmndList = [], sttsChgHstryList = [],
sttsChgHstryList = [], } = data;
} = data;
const editableReviewStep = getEditableReviewStep(
const editableReviewStep = getEditableReviewStep( userInfo?.dtlInstCd,
userInfo?.dtlInstCd, detDto.prgrsSttsComCd || "",
detDto.prgrsSttsComCd || "", );
);
return (
return ( <div className="mt-4">
<div className="mt-4"> <SectionCollapsibleGroup>
<SectionCollapsibleGroup> <div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2"> <SectionCollapsibleToggle />
<SectionCollapsibleToggle /> </div>
</div> <div className="space-y-4">
<div className="space-y-4"> <ProgressStatusSection
<ProgressStatusSection columns={PROGRESS_STATUS_COLUMNS}
columns={PROGRESS_STATUS_COLUMNS} sttsChgHstryList={sttsChgHstryList}
sttsChgHstryList={sttsChgHstryList} elaprDocNo={detDto.instlSncnDocNo}
elaprDocNo={detDto.instlSncnDocNo} />
/> <BasicInfoSection det={detDto} />
<BasicInfoSection det={detDto} /> <ContactInfoSection det={detDto} />
<ContactInfoSection det={detDto} /> <ElaprDocPrgrsStatus elaprDocNo={detDto.instlSncnDocNo} />
<ElaprDocPrgrsStatus elaprDocNo={detDto.instlSncnDocNo} /> <BusinessScaleSection bizSclList={bizSclList} />
<BusinessScaleSection bizSclList={bizSclList} /> <BusinessCostSection bizCstList={bizCstList} />
<BusinessCostSection bizCstList={bizCstList} /> <AttachmentsViewSection
<ChecklistSection esntlFileList={esntlFileList ?? []}
cklstResultList={cklstResultList} etcFileList={etcFileList ?? []}
prgrsSttsComCd={detDto.prgrsSttsComCd || ""} />
userRoleCode={userInfo?.dtlInstCd} <TechReviewOpinionSection
formState={checklistFormState} ref={techReviewOpinionRef}
setFormState={setChecklistFormState} rvwList={rvwList}
/> editableReviewStep={editableReviewStep}
<AttachmentsViewSection atrzSeComCdList={atrzSeComCdList}
esntlFileList={esntlFileList ?? []} isAtrzLoading={needsAtrzResolve && isRvwLoginUserLoading}
etcFileList={etcFileList ?? []} />
/> <SupplementDemandSection splmntDmndList={splmntDmndList} />
<TechReviewOpinionSection <FloatingActionButtons
ref={techReviewOpinionRef} data={data}
rvwList={rvwList} docSeComCd="INSTL"
editableReviewStep={editableReviewStep} instlSncnDocNo={detDto.instlSncnDocNo}
atrzSeComCdList={atrzSeComCdList} prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
isAtrzLoading={needsAtrzResolve && isRvwLoginUserLoading} dtlInstCd={userInfo?.dtlInstCd}
/> atrzSeComCdList={atrzSeComCdList}
<SupplementDemandSection splmntDmndList={splmntDmndList} /> isRvwLoginUserLoading={needsAtrzResolve && isRvwLoginUserLoading}
<FloatingActionButtons checklistFormState={checklistFormState}
data={data} techReviewOpinionRef={techReviewOpinionRef}
docSeComCd="INSTL" opnrNm={userInfo?.userName}
instlSncnDocNo={detDto.instlSncnDocNo} />
prgrsSttsComCd={detDto.prgrsSttsComCd || ""} </div>
dtlInstCd={userInfo?.dtlInstCd} </SectionCollapsibleGroup>
atrzSeComCdList={atrzSeComCdList} </div>
isRvwLoginUserLoading={needsAtrzResolve && isRvwLoginUserLoading} );
checklistFormState={checklistFormState} }
techReviewOpinionRef={techReviewOpinionRef}
opnrNm={userInfo?.userName} export { InstlViewPage };
/>
</div>
</SectionCollapsibleGroup>
</div>
);
}
export { InstlViewPage };
@@ -1,350 +1,350 @@
"use client"; "use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib"; import { useQueryClient } from "@tanstack/react-query";
import { TextShimmer } from "@/shared/ui";
import { SectionCollapsible } from "@/shared/ui"; import { getDefaultFileKey, useTus, useUserStore } from "@/shared/lib";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field"; import { TextShimmer } from "@/shared/ui";
import { AttachmentDownloadLink, attachService } from "@/entities/attach"; import { SectionCollapsible } from "@/shared/ui";
import type { InstlSncnRvwOpnnResponseDto, ReviewStepCode } from "@/entities/instl-sncn"; import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { import { attachService } from "@/entities/attach";
Button, import {
RadioGroup, instlSncnKeys,
RadioGroupItem, type InstlSncnRvwOpnnResponseDto,
Table, instlSncnRvwOpnnService,
TableBody, type ReviewStepCode,
TableCell, } from "@/entities/instl-sncn";
TableHead, import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
TableHeader, import {
TableRow, Button,
Textarea, RadioGroup,
} from "@common/components"; RadioGroupItem,
Table,
import type { ReviewOpinionFormData } from "../../lib/review-opinion-utils"; TableCell,
import { REVIEW_STEP_LABELS } from "../../model/view-types"; TableHead,
TableHeader,
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::"; TableRow,
Textarea,
interface TechReviewOpinionSectionProps { } from "@common/components";
rvwList: InstlSncnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode; import type { ReviewOpinionFormData } from "../../lib/review-opinion-utils";
atrzSeComCdList?: string[]; import { REVIEW_STEP_LABELS } from "../../model/view-types";
isAtrzLoading?: boolean;
} const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
export interface TechReviewOpinionSectionHandle { interface TechReviewOpinionSectionProps {
getFormData: () => ReviewOpinionFormData | null; rvwList: InstlSncnRvwOpnnResponseDto[];
resetForm: () => void; editableReviewStep?: ReviewStepCode;
} atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
interface TechReviewOpinionTableProps { }
items: InstlSncnRvwOpnnResponseDto[];
} export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionFormData | null;
interface UploadedOpinionFile { resetForm: () => void;
fileName: string; }
fileKey: string;
atflId?: string; interface TechReviewOpinionTableProps {
atflCd?: string; items: InstlSncnRvwOpnnResponseDto[];
} }
const EMPTY_FORM_DATA: ReviewOpinionFormData = { interface UploadedOpinionFile {
rvwRsltCn: "", fileName: string;
rvwRsltAnmlyYn: "", fileKey: string;
atchFileGroupId: "", atflId?: string;
opnrSeComCd: "", atflCd?: string;
}; }
function createRvwOpnnUploadKey(file: File) { const EMPTY_FORM_DATA: ReviewOpinionFormData = {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`; rvwRsltCn: "",
} rvwRsltAnmlyYn: "",
atchFileGroupId: "",
function hasFormContent(formData: ReviewOpinionFormData) { opnrSeComCd: "",
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId); };
}
function createRvwOpnnUploadKey(file: File) {
const TechReviewOpinionSection = forwardRef< return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
TechReviewOpinionSectionHandle, }
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection( function hasFormContent(formData: ReviewOpinionFormData) {
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false }, return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
ref, }
) {
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02"); const TechReviewOpinionSection = forwardRef<
const [formData, setFormData] = useState<ReviewOpinionFormData>({ TechReviewOpinionSectionHandle,
...EMPTY_FORM_DATA, TechReviewOpinionSectionProps
opnrSeComCd: editableReviewStep ?? "", >(function TechReviewOpinionSection(
}); { rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null); ref,
) {
useEffect(() => { const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
setFormData((prev) => ({ const [formData, setFormData] = useState<ReviewOpinionFormData>({
...prev, ...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "", opnrSeComCd: editableReviewStep ?? "",
})); });
}, [editableReviewStep]); const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
const { uploadFileAsync, isUploading, uploads } = useTus({ useEffect(() => {
onSuccess: (atflCd, atflId, _file, fileKey) => { setFormData((prev) => ({
if (!atflCd || !atflId) return; ...prev,
opnrSeComCd: editableReviewStep ?? "",
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd })); }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev)); }, [editableReviewStep]);
},
onError: (_error, _file, fileKey) => { const { uploadFileAsync, isUploading, uploads } = useTus({
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev)); onSuccess: (atflCd, atflId, _file, fileKey) => {
}, if (!atflCd || !atflId) return;
});
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
useImperativeHandle( setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
ref, },
() => ({ onError: (_error, _file, fileKey) => {
getFormData: () => { setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
if (!editableReviewStep || !hasFormContent(formData)) { },
return null; });
}
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) { useImperativeHandle(
return null; ref,
} () => ({
getFormData: () => {
return { if (!editableReviewStep || !hasFormContent(formData)) {
...formData, return null;
opnrSeComCd: editableReviewStep, }
}; if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
}, return null;
resetForm: () => { }
setFormData({
...EMPTY_FORM_DATA, return {
opnrSeComCd: editableReviewStep ?? "", ...formData,
}); opnrSeComCd: editableReviewStep,
setUploadedFile(null); };
}, },
}), resetForm: () => {
[canWriteKecoOpinion, editableReviewStep, formData], setFormData({
); ...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV"); });
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO"); setUploadedFile(null);
},
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV"; }),
const hasKecoSection = [canWriteKecoOpinion, editableReviewStep, formData],
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion); );
if (!hasEnvSection && !hasKecoSection) { const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
return null; const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
}
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
async function handleFileUpload(files: FileList | null) { const hasKecoSection =
if (!files || files.length === 0) return; kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
const file = files[0]; if (!hasEnvSection && !hasKecoSection) {
const fileKey = createRvwOpnnUploadKey(file); return null;
const existingAtflCd = formData.atchFileGroupId; }
setUploadedFile({ fileName: file.name, fileKey }); async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
try {
await uploadFileAsync(file, { const file = files[0];
fileKey, const fileKey = createRvwOpnnUploadKey(file);
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined, const existingAtflCd = formData.atchFileGroupId;
});
} catch { setUploadedFile({ fileName: file.name, fileKey });
// onError callback handles cleanup
} try {
} await uploadFileAsync(file, {
fileKey,
async function handleFileRemove() { metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
if (!uploadedFile) return; });
} catch {
uploads[uploadedFile.fileKey]?.abort(); // onError callback handles cleanup
}
if (uploadedFile.atflId) { }
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId); async function handleFileRemove() {
} catch (error) { if (!uploadedFile) return;
console.error("검토의견 첨부파일 삭제 실패:", error);
} uploads[uploadedFile.fileKey]?.abort();
}
if (uploadedFile.atflId) {
setUploadedFile(null); try {
setFormData((prev) => ({ ...prev, atchFileGroupId: "" })); await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} } catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
function renderOpinionForm(reviewStep: ReviewStepCode) { }
if (editableReviewStep !== reviewStep) return null; }
if (reviewStep === "KECO" && !canWriteKecoOpinion) return null;
setUploadedFile(null);
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false; setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
return (
<div className="space-y-2"> function isReviewTurn(reviewStep: ReviewStepCode) {
<h4 className="text-xl font-semibold">검토의견 작성</h4> if (editableReviewStep !== reviewStep) return false;
<Table caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공" variant="secondary"> if (reviewStep === "KECO" && !canWriteKecoOpinion) return false;
<TableHeader> return true;
<TableRow> }
<TableHead scope="row" className="w-[12rem]">의견내용</TableHead>
<TableCell> function renderOpinionForm(reviewStep: ReviewStepCode) {
<Textarea if (!isReviewTurn(reviewStep)) return null;
value={formData.rvwRsltCn}
onChange={(event) => const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
} return (
placeholder="의견내용을 입력하세요" <div className="space-y-2">
rows={4} <h4 className="text-xl font-semibold">검토의견 작성</h4>
className="min-h-[8rem] !text-xl" <Table
/> caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
</TableCell> variant="secondary"
</TableRow> >
<TableRow> <TableHeader>
<TableHead scope="row">검토결과</TableHead> <TableRow>
<TableCell> <TableHead scope="row" className="w-[12rem]">
<FieldSet> 의견내용
<RadioGroup </TableHead>
value={formData.rvwRsltAnmlyYn} <TableCell>
onValueChange={(value) => <Textarea
setFormData((prev) => ({ value={formData.rvwRsltCn}
...prev, onChange={(event) =>
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"], setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
})) }
} placeholder="의견내용을 입력하세요"
> rows={4}
<div className="flex flex-wrap gap-4"> className="min-h-[8rem] !text-xl"
<FieldLabel />
htmlFor={`rvw-opnn-normal-${reviewStep}`} </TableCell>
className="!w-auto border-none" </TableRow>
> <TableRow>
<Field orientation="horizontal" className="w-auto"> <TableHead scope="row">검토결과</TableHead>
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} /> <TableCell>
<span className="font-normal">정상</span> <FieldSet>
</Field> <RadioGroup
</FieldLabel> value={formData.rvwRsltAnmlyYn}
<FieldLabel onValueChange={(value) =>
htmlFor={`rvw-opnn-abnormal-${reviewStep}`} setFormData((prev) => ({
className="!w-auto border-none" ...prev,
> rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
<Field orientation="horizontal" className="w-auto"> }))
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} /> }
<span className="font-normal">이상</span> >
</Field> <div className="flex flex-wrap gap-4">
</FieldLabel> <FieldLabel
</div> htmlFor={`rvw-opnn-normal-${reviewStep}`}
</RadioGroup> className="!w-auto border-none"
</FieldSet> >
</TableCell> <Field orientation="horizontal" className="w-auto">
</TableRow> <RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<TableRow> <span className="font-normal">정상</span>
<TableHead scope="row">첨부파일</TableHead> </Field>
<TableCell> </FieldLabel>
{uploadedFile ? ( <FieldLabel
<div className="flex items-center gap-2"> htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
<TextShimmer className="text-xl" active={uploading}> className="!w-auto border-none"
{uploadedFile.fileName} >
</TextShimmer> <Field orientation="horizontal" className="w-auto">
<Button <RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
size="icon" <span className="font-normal">이상</span>
onClick={(e) => { </Field>
e.preventDefault(); </FieldLabel>
void handleFileRemove(); </div>
}} </RadioGroup>
disabled={uploading} </FieldSet>
className="size-8 !bg-destructive" </TableCell>
> </TableRow>
<i className="diveicon di-x text-xs" aria-label="삭제" /> <TableRow>
</Button> <TableHead scope="row">첨부파일</TableHead>
</div> <TableCell>
) : ( {uploadedFile ? (
<label> <div className="flex items-center gap-2">
<input <TextShimmer className="text-xl" active={uploading}>
type="file" {uploadedFile.fileName}
className="hidden" </TextShimmer>
onChange={(event) => { <Button
void handleFileUpload(event.target.files); size="icon"
event.target.value = ""; onClick={(e) => {
}} e.preventDefault();
/> void handleFileRemove();
<Button size="sm" variant="outline" type="button" asChild> }}
<span>파일 선택</span> disabled={uploading}
</Button> className="size-8 !bg-destructive"
</label> >
)} <i className="diveicon di-x text-xs" aria-label="삭제" />
</TableCell> </Button>
</TableRow> </div>
</TableHeader> ) : (
</Table> <label>
</div> <input
); type="file"
} className="hidden"
onChange={(event) => {
return ( void handleFileUpload(event.target.files);
<> event.target.value = "";
{hasEnvSection ? ( }}
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}> />
<div className="space-y-6"> <Button size="sm" variant="outline" type="button" asChild>
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null} <span>파일 선택</span>
{renderOpinionForm("ENV")} </Button>
</div> </label>
</SectionCollapsible> )}
) : null} </TableCell>
{hasKecoSection ? ( </TableRow>
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}> </TableHeader>
<div className="space-y-6"> </Table>
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null} </div>
{renderOpinionForm("KECO")} );
</div> }
</SectionCollapsible>
) : null} return (
</> <>
); {hasEnvSection ? (
}); <SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
<div className="space-y-6">
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) { {envRvwList.length > 0 ? (
return ( <TechReviewOpinionTable items={envRvwList} />
<div className="overflow-x-auto"> ) : null}
<Table caption="검토의견 목록으로 의견자명, 의견내용, 검토결과, 첨부파일, 등록일시 정보 제공" variant="secondary"> {renderOpinionForm("ENV")}
<colgroup> </div>
<col width="10%" /> </SectionCollapsible>
<col width="55%" /> ) : null}
<col width="5%" /> {hasKecoSection ? (
<col width="20%" /> <SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<col width="10%" /> <div className="space-y-6">
</colgroup> {kecoRvwList.length > 0 ? (
<TableHeader> <TechReviewOpinionTable items={kecoRvwList} />
<TableRow> ) : null}
<TableHead>의견자명</TableHead> {renderOpinionForm("KECO")}
<TableHead>의견내용</TableHead> </div>
<TableHead>검토결과</TableHead> </SectionCollapsible>
<TableHead>첨부파일</TableHead> ) : null}
<TableHead>등록일시</TableHead> </>
</TableRow> );
</TableHeader> });
<TableBody>
{items.map((item) => ( function getRvwOpnnIds(item: InstlSncnRvwOpnnResponseDto) {
<TableRow key={`${item.instlSncnDocNo}_${item.rvwOpnnSn}`}> if (!item.instlSncnDocNo || item.rvwOpnnSn == null) {
<TableCell align="center">{item.opnrNm}</TableCell> throw new Error("검토의견 식별값이 없습니다.");
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell> }
<TableCell align="center"> return [item.instlSncnDocNo, item.rvwOpnnSn] as const;
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"} }
</TableCell>
<TableCell align="center"> function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
{item.atchFileInfo?.atflCd ? ( const queryClient = useQueryClient();
<AttachmentDownloadLink const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
atflCd={item.atchFileInfo.atflCd}
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"} return (
/> <RvwOpnnListTable
) : ( items={items}
"-" getRowKey={(item) => `${item.instlSncnDocNo}_${item.rvwOpnnSn}`}
)} currentUserUuid={currentUserUuid}
</TableCell> onUpdate={async (item, body) => {
<TableCell align="center"> await instlSncnRvwOpnnService.update(...getRvwOpnnIds(item), body);
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"} await queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
</TableCell> }}
</TableRow> onDelete={async (item) => {
))} await instlSncnRvwOpnnService.delete(...getRvwOpnnIds(item));
</TableBody> await queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
</Table> }}
</div> />
); );
} }
export { TechReviewOpinionSection }; export { TechReviewOpinionSection };
@@ -1,357 +1,354 @@
"use client"; "use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib"; import { useQueryClient } from "@tanstack/react-query";
import { TextShimmer } from "@/shared/ui";
import { SectionCollapsible } from "@/shared/ui"; import { getDefaultFileKey, useTus, useUserStore } from "@/shared/lib";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field"; import { TextShimmer } from "@/shared/ui";
import { AttachmentDownloadLink, attachService } from "@/entities/attach"; import { SectionCollapsible } from "@/shared/ui";
import type { SewerBscPlanRvwOpnnResponseDto } from "@/entities/sewer-bsc-plan"; import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { import { attachService } from "@/entities/attach";
Button, import {
RadioGroup, sewerBscPlanKeys,
RadioGroupItem, type SewerBscPlanRvwOpnnResponseDto,
Table, sewerBscPlanRvwOpnnService,
TableBody, } from "@/entities/sewer-bsc-plan";
TableCell, import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
TableHead, import {
TableHeader, Button,
TableRow, RadioGroup,
Textarea, RadioGroupItem,
} from "@common/components"; Table,
TableCell,
import type { TableHead,
ReviewOpinionFormData, TableHeader,
ReviewOpinionGetFormDataResult, TableRow,
} from "../../lib/review-opinion-utils"; Textarea,
import type { ReviewStepCode } from "../../model/view-types"; } from "@common/components";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
import type {
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::"; ReviewOpinionFormData,
ReviewOpinionGetFormDataResult,
interface TechReviewOpinionSectionProps { } from "../../lib/review-opinion-utils";
rvwList: SewerBscPlanRvwOpnnResponseDto[]; import type { ReviewStepCode } from "../../model/view-types";
editableReviewStep?: ReviewStepCode; import { REVIEW_STEP_LABELS } from "../../model/view-types";
atrzSeComCdList?: string[];
isAtrzLoading?: boolean; const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
}
interface TechReviewOpinionSectionProps {
export interface TechReviewOpinionSectionHandle { rvwList: SewerBscPlanRvwOpnnResponseDto[];
getFormData: () => ReviewOpinionGetFormDataResult | null; editableReviewStep?: ReviewStepCode;
resetForm: () => void; atrzSeComCdList?: string[];
} isAtrzLoading?: boolean;
}
interface TechReviewOpinionTableProps {
items: SewerBscPlanRvwOpnnResponseDto[]; export interface TechReviewOpinionSectionHandle {
} getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
interface UploadedOpinionFile { }
fileName: string;
fileKey: string; interface TechReviewOpinionTableProps {
atflId?: string; items: SewerBscPlanRvwOpnnResponseDto[];
atflCd?: string; }
}
interface UploadedOpinionFile {
const EMPTY_FORM_DATA: ReviewOpinionFormData = { fileName: string;
rvwRsltCn: "", fileKey: string;
rvwRsltAnmlyYn: "", atflId?: string;
atchFileGroupId: "", atflCd?: string;
opnrSeComCd: "", }
};
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
function createRvwOpnnUploadKey(file: File) { rvwRsltCn: "",
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`; rvwRsltAnmlyYn: "",
} atchFileGroupId: "",
opnrSeComCd: "",
function hasFormContent(formData: ReviewOpinionFormData) { };
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
} function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
const TechReviewOpinionSection = forwardRef< }
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps function hasFormContent(formData: ReviewOpinionFormData) {
>(function TechReviewOpinionSection( return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false }, }
ref,
) { const TechReviewOpinionSection = forwardRef<
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02"); TechReviewOpinionSectionHandle,
const [formData, setFormData] = useState<ReviewOpinionFormData>({ TechReviewOpinionSectionProps
...EMPTY_FORM_DATA, >(function TechReviewOpinionSection(
opnrSeComCd: editableReviewStep ?? "", { rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
}); ref,
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null); ) {
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
useEffect(() => { const [formData, setFormData] = useState<ReviewOpinionFormData>({
setFormData((prev) => ({ ...EMPTY_FORM_DATA,
...prev, opnrSeComCd: editableReviewStep ?? "",
opnrSeComCd: editableReviewStep ?? "", });
})); const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
}, [editableReviewStep]);
useEffect(() => {
const { uploadFileAsync, isUploading, uploads } = useTus({ setFormData((prev) => ({
onSuccess: (atflCd, atflId, _file, fileKey) => { ...prev,
if (!atflCd || !atflId) return; opnrSeComCd: editableReviewStep ?? "",
}));
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd })); }, [editableReviewStep]);
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
}, const { uploadFileAsync, isUploading, uploads } = useTus({
onError: (_error, _file, fileKey) => { onSuccess: (atflCd, atflId, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev)); if (!atflCd || !atflId) return;
},
}); setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
useImperativeHandle( },
ref, onError: (_error, _file, fileKey) => {
() => ({ setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
getFormData: () => { },
if (!editableReviewStep || !hasFormContent(formData)) { });
return null;
} useImperativeHandle(
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) { ref,
return null; () => ({
} getFormData: () => {
if (!editableReviewStep || !hasFormContent(formData)) {
return { return null;
formData: { }
...formData, if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
opnrSeComCd: editableReviewStep, return null;
}, }
rvwList,
}; return {
}, formData: {
resetForm: () => { ...formData,
setFormData({ opnrSeComCd: editableReviewStep,
...EMPTY_FORM_DATA, },
opnrSeComCd: editableReviewStep ?? "", rvwList,
}); };
setUploadedFile(null); },
}, resetForm: () => {
}), setFormData({
[canWriteKecoOpinion, editableReviewStep, formData, rvwList], ...EMPTY_FORM_DATA,
); opnrSeComCd: editableReviewStep ?? "",
});
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV"); setUploadedFile(null);
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO"); },
}),
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV"; [canWriteKecoOpinion, editableReviewStep, formData, rvwList],
const hasKecoSection = );
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
if (!hasEnvSection && !hasKecoSection) { const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
return null;
} const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
const hasKecoSection =
async function handleFileUpload(files: FileList | null) { kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
if (!files || files.length === 0) return;
if (!hasEnvSection && !hasKecoSection) {
const file = files[0]; return null;
const fileKey = createRvwOpnnUploadKey(file); }
const existingAtflCd = formData.atchFileGroupId;
async function handleFileUpload(files: FileList | null) {
setUploadedFile({ fileName: file.name, fileKey }); if (!files || files.length === 0) return;
try { const file = files[0];
await uploadFileAsync(file, { const fileKey = createRvwOpnnUploadKey(file);
fileKey, const existingAtflCd = formData.atchFileGroupId;
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
}); setUploadedFile({ fileName: file.name, fileKey });
} catch {
// onError callback handles cleanup try {
} await uploadFileAsync(file, {
} fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
async function handleFileRemove() { });
if (!uploadedFile) return; } catch {
// onError callback handles cleanup
uploads[uploadedFile.fileKey]?.abort(); }
}
if (uploadedFile.atflId) {
try { async function handleFileRemove() {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId); if (!uploadedFile) return;
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error); uploads[uploadedFile.fileKey]?.abort();
}
} if (uploadedFile.atflId) {
try {
setUploadedFile(null); await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" })); } catch (error) {
} console.error("검토의견 첨부파일 삭제 실패:", error);
}
function renderOpinionForm(reviewStep: ReviewStepCode) { }
if (editableReviewStep !== reviewStep) return null;
if (reviewStep === "KECO" && !canWriteKecoOpinion) return null; setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false; }
return ( function isReviewTurn(reviewStep: ReviewStepCode) {
<div className="space-y-2"> if (editableReviewStep !== reviewStep) return false;
<h4 className="text-xl font-semibold">검토의견 작성</h4> if (reviewStep === "KECO" && !canWriteKecoOpinion) return false;
<Table caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공" variant="secondary"> return true;
<TableHeader> }
<TableRow>
<TableHead className="w-[12rem]">의견내용</TableHead> function renderOpinionForm(reviewStep: ReviewStepCode) {
<TableCell> if (!isReviewTurn(reviewStep)) return null;
<Textarea
value={formData.rvwRsltCn} const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value })) return (
} <div className="space-y-2">
placeholder="의견내용을 입력하세요" <h4 className="text-xl font-semibold">검토의견 작성</h4>
rows={4} <Table
className="min-h-[8rem] !text-xl" caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
/> variant="secondary"
</TableCell> >
</TableRow> <TableHeader>
<TableRow> <TableRow>
<TableHead>검토결과</TableHead> <TableHead className="w-[12rem]">의견내용</TableHead>
<TableCell> <TableCell>
<FieldSet> <Textarea
<RadioGroup value={formData.rvwRsltCn}
value={formData.rvwRsltAnmlyYn} onChange={(event) =>
onValueChange={(value) => setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
setFormData((prev) => ({ }
...prev, placeholder="의견내용을 입력하세요"
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"], rows={4}
})) className="min-h-[8rem] !text-xl"
} />
> </TableCell>
<div className="flex flex-wrap gap-4"> </TableRow>
<FieldLabel <TableRow>
htmlFor={`rvw-opnn-normal-${reviewStep}`} <TableHead>검토결과</TableHead>
className="!w-auto border-none" <TableCell>
> <FieldSet>
<Field orientation="horizontal" className="w-auto"> <RadioGroup
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} /> value={formData.rvwRsltAnmlyYn}
<span className="font-normal">정상</span> onValueChange={(value) =>
</Field> setFormData((prev) => ({
</FieldLabel> ...prev,
<FieldLabel rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
htmlFor={`rvw-opnn-abnormal-${reviewStep}`} }))
className="!w-auto border-none" }
> >
<Field orientation="horizontal" className="w-auto"> <div className="flex flex-wrap gap-4">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} /> <FieldLabel
<span className="font-normal">이상</span> htmlFor={`rvw-opnn-normal-${reviewStep}`}
</Field> className="!w-auto border-none"
</FieldLabel> >
</div> <Field orientation="horizontal" className="w-auto">
</RadioGroup> <RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
</FieldSet> <span className="font-normal">정상</span>
</TableCell> </Field>
</TableRow> </FieldLabel>
<TableRow> <FieldLabel
<TableHead>첨부파일</TableHead> htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
<TableCell> className="!w-auto border-none"
{uploadedFile ? ( >
<div className="flex items-center gap-2"> <Field orientation="horizontal" className="w-auto">
<TextShimmer className="text-xl" active={uploading}> <RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
{uploadedFile.fileName} <span className="font-normal">이상</span>
</TextShimmer> </Field>
<Button </FieldLabel>
size="icon" </div>
onClick={(e) => { </RadioGroup>
e.preventDefault(); </FieldSet>
void handleFileRemove(); </TableCell>
}} </TableRow>
disabled={uploading} <TableRow>
className="size-8 !bg-destructive" <TableHead>첨부파일</TableHead>
> <TableCell>
<i className="diveicon di-x text-xs" aria-label="삭제" /> {uploadedFile ? (
</Button> <div className="flex items-center gap-2">
</div> <TextShimmer className="text-xl" active={uploading}>
) : ( {uploadedFile.fileName}
<label> </TextShimmer>
<input <Button
type="file" size="icon"
className="hidden" onClick={(e) => {
onChange={(event) => { e.preventDefault();
void handleFileUpload(event.target.files); void handleFileRemove();
event.target.value = ""; }}
}} disabled={uploading}
/> className="size-8 !bg-destructive"
<Button size="sm" variant="outline" type="button" asChild> >
<span>파일 선택</span> <i className="diveicon di-x text-xs" aria-label="삭제" />
</Button> </Button>
</label> </div>
)} ) : (
</TableCell> <label>
</TableRow> <input
</TableHeader> type="file"
</Table> className="hidden"
</div> onChange={(event) => {
); void handleFileUpload(event.target.files);
} event.target.value = "";
}}
return ( />
<> <Button size="sm" variant="outline" type="button" asChild>
{hasEnvSection ? ( <span>파일 선택</span>
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}> </Button>
<div className="space-y-6"> </label>
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null} )}
{renderOpinionForm("ENV")} </TableCell>
</div> </TableRow>
</SectionCollapsible> </TableHeader>
) : null} </Table>
{hasKecoSection ? ( </div>
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}> );
<div className="space-y-6"> }
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
{renderOpinionForm("KECO")} return (
</div> <>
</SectionCollapsible> {hasEnvSection ? (
) : null} <SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
</> <div className="space-y-6">
); {envRvwList.length > 0 ? (
}); <TechReviewOpinionTable items={envRvwList} />
) : null}
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) { {renderOpinionForm("ENV")}
return ( </div>
<div className="overflow-x-auto"> </SectionCollapsible>
<Table caption="검토의견 목록으로 의견자명, 의견내용, 검토결과, 첨부파일, 등록일시 정보 제공" variant="secondary"> ) : null}
<colgroup> {hasKecoSection ? (
<col width="10%" /> <SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<col width="55%" /> <div className="space-y-6">
<col width="5%" /> {kecoRvwList.length > 0 ? (
<col width="20%" /> <TechReviewOpinionTable items={kecoRvwList} />
<col width="10%" /> ) : null}
</colgroup> {renderOpinionForm("KECO")}
<TableHeader> </div>
<TableRow> </SectionCollapsible>
<TableHead>의견자명</TableHead> ) : null}
<TableHead>의견내용</TableHead> </>
<TableHead>검토결과</TableHead> );
<TableHead>첨부파일</TableHead> });
<TableHead>등록일시</TableHead>
</TableRow> function getRvwOpnnIds(item: SewerBscPlanRvwOpnnResponseDto) {
</TableHeader> if (!item.sewerBscPlanDocNo || item.chgCycl == null || item.rvwOpnnSn == null) {
<TableBody> throw new Error("검토의견 식별값이 없습니다.");
{items.map((item) => ( }
<TableRow key={`${item.sewerBscPlanDocNo}_${item.rvwOpnnSn}`}> return [item.sewerBscPlanDocNo, item.chgCycl, item.rvwOpnnSn] as const;
<TableCell align="center">{item.opnrNm}</TableCell> }
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
<TableCell align="center"> function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"} const queryClient = useQueryClient();
</TableCell> const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
<TableCell align="center">
{item.atchFileInfo?.atflCd ? ( return (
<AttachmentDownloadLink <RvwOpnnListTable
atflCd={item.atchFileInfo.atflCd} items={items}
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"} getRowKey={(item) => `${item.sewerBscPlanDocNo}_${item.rvwOpnnSn}`}
/> currentUserUuid={currentUserUuid}
) : ( onUpdate={async (item, body) => {
"-" await sewerBscPlanRvwOpnnService.update(...getRvwOpnnIds(item), body);
)} await queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
</TableCell> }}
<TableCell align="center"> onDelete={async (item) => {
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"} await sewerBscPlanRvwOpnnService.delete(...getRvwOpnnIds(item));
</TableCell> await queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
</TableRow> }}
))} />
</TableBody> );
</Table> }
</div>
); export { TechReviewOpinionSection };
}
export { TechReviewOpinionSection };
+3
View File
@@ -12,11 +12,13 @@ export const ENDPOINT = {
RVW_MNPW: "/as-service/api/v1/rvw-mnpw", RVW_MNPW: "/as-service/api/v1/rvw-mnpw",
BNS_IF: "/as-service/api/v1/if", BNS_IF: "/as-service/api/v1/if",
INSTL_SNCN: "/as-service/api/v1/instl-sncn", INSTL_SNCN: "/as-service/api/v1/instl-sncn",
INSTL_SNCN_RVW_OPNN: "/as-service/api/v1/instl-sncn-rvw-opnn",
CKLST: "/as-service/api/v1/cklst", CKLST: "/as-service/api/v1/cklst",
CKLST_ARTCL: "/as-service/api/v1/cklst-artcl", CKLST_ARTCL: "/as-service/api/v1/cklst-artcl",
CKLST_RVW: "/as-service/api/v1/cklst-rvw", CKLST_RVW: "/as-service/api/v1/cklst-rvw",
ADSTDSTR: "/as-service/api/v1/adstdstr", ADSTDSTR: "/as-service/api/v1/adstdstr",
FNRS_CNSLTN_MNG: "/as-service/api/v1/fnrs-cnsltn-mng", FNRS_CNSLTN_MNG: "/as-service/api/v1/fnrs-cnsltn-mng",
FNRS_CNSLTN_RVW_OPNN: "/as-service/api/v1/fnrs-cnsltn-rvw-opnn",
FNRS_SHAR_SE: "/as-service/api/v1/fnrs-shar-se-mng", FNRS_SHAR_SE: "/as-service/api/v1/fnrs-shar-se-mng",
DESIGN_CNSTN: "/as-service/api/v1/design-cnstn", DESIGN_CNSTN: "/as-service/api/v1/design-cnstn",
DESIGN_CNSTN_BIZ_CST: "/as-service/api/v1/design-cnstn-biz-cst", DESIGN_CNSTN_BIZ_CST: "/as-service/api/v1/design-cnstn-biz-cst",
@@ -24,6 +26,7 @@ export const ENDPOINT = {
DESIGN_CNSTN_ESNTL_ATCH_FILE: "/as-service/api/v1/design-cnstn-esntl-atch-file", DESIGN_CNSTN_ESNTL_ATCH_FILE: "/as-service/api/v1/design-cnstn-esntl-atch-file",
DESIGN_CNSTN_RVW_OPNN: "/as-service/api/v1/design-cnstn-rvw-opnn", DESIGN_CNSTN_RVW_OPNN: "/as-service/api/v1/design-cnstn-rvw-opnn",
SEWER_BSC_PLAN: "/as-service/api/v1/sewer-bsc-plans", SEWER_BSC_PLAN: "/as-service/api/v1/sewer-bsc-plans",
SEWER_BSC_PLAN_RVW_OPNNS: "/as-service/api/v1/sewer-bsc-plan-rvw-opnns",
DASHBOARD: "/as-service/api/v1/dashboard", DASHBOARD: "/as-service/api/v1/dashboard",
DCRAG_TO: "/as-service/api/v1/dcrag-to", DCRAG_TO: "/as-service/api/v1/dcrag-to",
AI_AUTO_PRCS_MNG: "/as-service/api/v1/ai-auto-prcs-mng", AI_AUTO_PRCS_MNG: "/as-service/api/v1/ai-auto-prcs-mng",
+1
View File
@@ -46,6 +46,7 @@ export type {
PbadmsMngDetResponseBase, PbadmsMngDetResponseBase,
RvwOpnnResponseExtension, RvwOpnnResponseExtension,
RvwOpnnSaveBase, RvwOpnnSaveBase,
RvwOpnnUpdateRequestDto,
SplmntDmndResponseExtension, SplmntDmndResponseExtension,
SplmntDmndSaveBase, SplmntDmndSaveBase,
} from "./pbadms-sprt"; } from "./pbadms-sprt";
+11
View File
@@ -231,11 +231,22 @@ export interface PbadmsApplySaveHeader {
rvwStpCd?: string; rvwStpCd?: string;
} }
/** 검토의견 수정 공통 요청 DTO */
export interface RvwOpnnUpdateRequestDto {
opnrSeComCd?: string;
opnrNm?: string;
rvwRsltCn?: string;
rvwRsltAnmlyYn?: string;
atchFileGroupId?: string;
}
/** 검토의견 응답 공통 확장 필드 */ /** 검토의견 응답 공통 확장 필드 */
export interface RvwOpnnResponseExtension { export interface RvwOpnnResponseExtension {
opnrSeCdNm?: string; opnrSeCdNm?: string;
atchFileInfo?: AtflMngClientResponseDto; atchFileInfo?: AtflMngClientResponseDto;
regDt?: string; regDt?: string;
rgtr?: string;
rgtrNm?: string;
} }
/** 보완요구 응답 공통 확장 필드 */ /** 보완요구 응답 공통 확장 필드 */
+1
View File
@@ -51,6 +51,7 @@ export {
} from "./ui/pbadms-task-apply-dialog"; } from "./ui/pbadms-task-apply-dialog";
export { PrivacyConsentSection } from "./ui/privacy-consent-section"; export { PrivacyConsentSection } from "./ui/privacy-consent-section";
export { ProgressStatusSection } from "./ui/progress-status-section"; export { ProgressStatusSection } from "./ui/progress-status-section";
export { RvwOpnnListTable, type RvwOpnnListTableProps } from "./ui/rvw-opnn-list-table";
export { export {
SplmntDmndDialog, SplmntDmndDialog,
type SplmntDmndDialogConfirmItem, type SplmntDmndDialogConfirmItem,
@@ -0,0 +1,403 @@
"use client";
import { useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import type { AtflMngClientResponseDto, RvwOpnnUpdateRequestDto } from "@/shared/model";
import { AttachmentFileField } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import {
AttachmentDownloadLink,
attachService,
useDownloadAttachmentByAtflId,
} from "@/entities/attach";
import {
Button,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
const RVW_OPNN_EDIT_FILE_KEY_PREFIX = "rvw-opnn-edit::";
/** 업무별 검토의견 응답 DTO 공통 필드 (업무마다 null 허용 여부가 달라 느슨하게 정의) */
interface RvwOpnnListItem {
opnrSeComCd?: string | null;
opnrNm?: string | null;
rvwRsltCn?: string | null;
rvwRsltAnmlyYn?: string | null;
atchFileGroupId?: string | null;
atchFileInfo?: AtflMngClientResponseDto | null;
regDt?: string | null;
rgtr?: string | null;
}
interface RvwOpnnListTableProps<T extends RvwOpnnListItem> {
items: T[];
getRowKey: (item: T) => string;
/** 로그인 사용자 UUID — 검토의견 등록자(rgtr)와 같을 때만 수정·삭제 버튼 노출 */
currentUserUuid?: string;
onUpdate: (item: T, body: RvwOpnnUpdateRequestDto) => Promise<void>;
onDelete: (item: T) => Promise<void>;
}
interface EditDraft {
rvwRsltCn: string;
rvwRsltAnmlyYn: string;
atchFileGroupId: string;
}
interface EditFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
/** 이번 수정 중 새로 업로드한 파일 여부 (취소 시 정리 대상) */
isNew: boolean;
}
function formatAnmlyYn(value?: string | null) {
if (value === "Y") return "이상";
if (value === "N") return "정상";
return "-";
}
function RvwOpnnListTable<T extends RvwOpnnListItem>({
items,
getRowKey,
currentUserUuid,
onUpdate,
onDelete,
}: RvwOpnnListTableProps<T>) {
const { confirm } = useConfirm();
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
const [editingKey, setEditingKey] = useState<string | null>(null);
const [draft, setDraft] = useState<EditDraft | null>(null);
const [editFile, setEditFile] = useState<EditFile | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setDraft((prev) => (prev ? { ...prev, atchFileGroupId: atflCd } : prev));
setEditFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setEditFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
const editFileUploading = editFile ? (isUploading[editFile.fileKey] ?? false) : false;
function canModify(item: T) {
return Boolean(currentUserUuid) && item.rgtr === currentUserUuid;
}
async function discardNewFile(file: EditFile | null) {
if (!file?.isNew) return;
uploads[file.fileKey]?.abort();
if (file.atflId) {
try {
await attachService.deleteAttachmentByAtflId(file.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
}
function resetEdit() {
setEditingKey(null);
setDraft(null);
setEditFile(null);
}
function startEdit(item: T) {
void discardNewFile(editFile);
setEditingKey(getRowKey(item));
setDraft({
rvwRsltCn: item.rvwRsltCn ?? "",
rvwRsltAnmlyYn: item.rvwRsltAnmlyYn ?? "",
atchFileGroupId: item.atchFileGroupId ?? "",
});
setEditFile(
item.atchFileInfo?.atflCd
? {
fileName: item.atchFileInfo.orgnlFileNm || "첨부파일",
fileKey: `${RVW_OPNN_EDIT_FILE_KEY_PREFIX}${item.atchFileInfo.atflCd}`,
atflId: item.atchFileInfo.atflId,
atflCd: item.atchFileInfo.atflCd,
isNew: false,
}
: null,
);
}
function cancelEdit() {
void discardNewFile(editFile);
resetEdit();
}
async function handleFileAdd(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = `${RVW_OPNN_EDIT_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
setEditFile({ fileName: file.name, fileKey, isNew: true });
try {
await uploadFileAsync(file, { fileKey });
} catch {
// onError callback handles cleanup
}
}
function handleFileRemove() {
// 기존 파일은 저장 시점에 연결만 해제하고, 새로 올린 파일은 즉시 정리
void discardNewFile(editFile);
setEditFile(null);
setDraft((prev) => (prev ? { ...prev, atchFileGroupId: "" } : prev));
}
async function handleSave(item: T) {
if (!draft) return;
if (!draft.rvwRsltCn.trim()) {
alert("의견내용을 입력하세요.");
return;
}
if (!draft.rvwRsltAnmlyYn) {
alert("검토결과를 선택하세요.");
return;
}
if (!(await confirm("검토의견을 수정하시겠습니까?"))) return;
setIsSubmitting(true);
try {
await onUpdate(item, {
opnrSeComCd: item.opnrSeComCd ?? undefined,
opnrNm: item.opnrNm ?? undefined,
rvwRsltCn: draft.rvwRsltCn,
rvwRsltAnmlyYn: draft.rvwRsltAnmlyYn,
atchFileGroupId: draft.atchFileGroupId,
});
alert("수정되었습니다.");
resetEdit();
} catch (error) {
console.error("검토의견 수정 실패:", error);
alert("검토의견 수정 중 오류가 발생했습니다.");
} finally {
setIsSubmitting(false);
}
}
async function handleDelete(item: T) {
if (!(await confirm("검토의견을 삭제하시겠습니까?"))) return;
setIsSubmitting(true);
try {
await onDelete(item);
alert("삭제되었습니다.");
if (editingKey === getRowKey(item)) resetEdit();
} catch (error) {
console.error("검토의견 삭제 실패:", error);
alert("검토의견 삭제 중 오류가 발생했습니다.");
} finally {
setIsSubmitting(false);
}
}
function renderViewRow(item: T) {
return (
<>
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
<TableCell align="center">{formatAnmlyYn(item.rvwRsltAnmlyYn)}</TableCell>
<TableCell align="center">
{item.atchFileInfo?.atflCd ? (
<AttachmentDownloadLink
atflCd={item.atchFileInfo.atflCd}
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"}
/>
) : (
"-"
)}
</TableCell>
</>
);
}
function renderEditRow(rowKey: string, editDraft: EditDraft) {
return (
<>
<TableCell align="left">
<Textarea
value={editDraft.rvwRsltCn}
onChange={(event) =>
setDraft((prev) => (prev ? { ...prev, rvwRsltCn: event.target.value } : prev))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
<TableCell align="center">
<FieldSet>
<RadioGroup
value={editDraft.rvwRsltAnmlyYn}
onValueChange={(value) =>
setDraft((prev) => (prev ? { ...prev, rvwRsltAnmlyYn: value } : prev))
}
>
<div className="flex flex-col gap-2">
<FieldLabel
htmlFor={`rvw-opnn-edit-normal-${rowKey}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-edit-normal-${rowKey}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-edit-abnormal-${rowKey}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-edit-abnormal-${rowKey}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
<TableCell align="center">
<AttachmentFileField
files={
editFile
? [
{
fileKey: editFile.fileKey,
fileName: editFile.fileName,
onDownload: editFile.atflId
? () => downloadByAtflId(editFile.atflId!)
: undefined,
},
]
: []
}
inputId={`rvw-opnn-edit-${rowKey}-file-input`}
isUploadingByKey={isUploading}
multiple={false}
showAddButton={!editFile}
addDisabled={editFileUploading}
onAdd={(fileList) => void handleFileAdd(fileList)}
onRemove={handleFileRemove}
/>
</TableCell>
</>
);
}
return (
<div className="overflow-x-auto">
<Table
caption="검토의견 목록으로 의견자명, 의견내용, 검토결과, 첨부파일, 등록일시, 관리 정보 제공"
variant="secondary"
>
<colgroup>
<col width="10%" />
<col width="47%" />
<col width="6%" />
<col width="17%" />
<col width="10%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>의견자명</TableHead>
<TableHead>의견내용</TableHead>
<TableHead>검토결과</TableHead>
<TableHead>첨부파일</TableHead>
<TableHead>등록일시</TableHead>
<TableHead>관리</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => {
const rowKey = getRowKey(item);
const isEditing = editingKey === rowKey && draft !== null;
return (
<TableRow key={rowKey}>
<TableCell align="center">{item.opnrNm}</TableCell>
{isEditing ? renderEditRow(rowKey, draft) : renderViewRow(item)}
<TableCell align="center">
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
</TableCell>
<TableCell align="center">
{!canModify(item) ? (
"-"
) : isEditing ? (
<div className="flex flex-wrap justify-center gap-1">
<Button
type="button"
size="sm"
onClick={() => void handleSave(item)}
disabled={isSubmitting || editFileUploading}
>
저장
</Button>
<Button
type="button"
size="sm"
color="white"
onClick={cancelEdit}
disabled={isSubmitting}
>
취소
</Button>
</div>
) : (
<div className="flex flex-wrap justify-center gap-1">
<Button
type="button"
size="sm"
color="white"
onClick={() => startEdit(item)}
disabled={isSubmitting}
>
수정
</Button>
<Button
type="button"
size="sm"
color="gray"
onClick={() => void handleDelete(item)}
disabled={isSubmitting}
>
삭제
</Button>
</div>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}
export { RvwOpnnListTable, type RvwOpnnListTableProps };
@@ -784,7 +784,6 @@ const ExcretaTreatmentFacilityInp = () => {
lclgvIdfrTelnoCn: submitInfo.lclgvIdfrTelnoCn, lclgvIdfrTelnoCn: submitInfo.lclgvIdfrTelnoCn,
lclgvIdfrDeptNm: submitInfo.lclgvIdfrDeptNm, lclgvIdfrDeptNm: submitInfo.lclgvIdfrDeptNm,
lclgvIdfrJbgdNm: submitInfo.lclgvIdfrJbgdNm, lclgvIdfrJbgdNm: submitInfo.lclgvIdfrJbgdNm,
...(isAdminInfo ? { superAdmin: "superAdmin" } : {}),
noApplicable, noApplicable,
submitList, submitList,
deleteList: deleteListForSubmit, deleteList: deleteListForSubmit,
@@ -797,7 +797,6 @@ const PrivateSewageFacilityInp = () => {
const activeTabStatus = tabStatusInfoMap.get(tabKey) ?? null; const activeTabStatus = tabStatusInfoMap.get(tabKey) ?? null;
const statsCdForTab = tabStatsCdMap.get(tabKey) ?? tabKey; const statsCdForTab = tabStatsCdMap.get(tabKey) ?? tabKey;
const adminOpt = isAdminInfo ? { superAdmin: "superAdmin" as const } : {};
const submitInfoBase = { const submitInfoBase = {
statsYr: lastSearchedValues.year ?? "", statsYr: lastSearchedValues.year ?? "",
@@ -842,7 +841,6 @@ const PrivateSewageFacilityInp = () => {
...submitInfoBase, ...submitInfoBase,
userUuid: userInfo.userUnqId, userUuid: userInfo.userUnqId,
submitList: buildSewageTreatFactStatusSaveList(currentData), submitList: buildSewageTreatFactStatusSaveList(currentData),
...adminOpt,
}; };
response = response =
await statisticsInputPrivateSewageFacilityApi.submitSewageTreatFactStatus( await statisticsInputPrivateSewageFacilityApi.submitSewageTreatFactStatus(
@@ -853,7 +851,6 @@ const PrivateSewageFacilityInp = () => {
...submitInfoBase, ...submitInfoBase,
userUuid: userInfo.userUnqId, userUuid: userInfo.userUnqId,
submitList: buildSepticTankStatusSaveList(currentData), submitList: buildSepticTankStatusSaveList(currentData),
...adminOpt,
}; };
response = response =
await statisticsInputPrivateSewageFacilityApi.submitSepticTankStatus(param); await statisticsInputPrivateSewageFacilityApi.submitSepticTankStatus(param);
@@ -862,7 +859,6 @@ const PrivateSewageFacilityInp = () => {
...submitInfoBase, ...submitInfoBase,
userUuid: userInfo.userUnqId, userUuid: userInfo.userUnqId,
submitList: buildPrivateSewageDesigStatusSaveList(currentData), submitList: buildPrivateSewageDesigStatusSaveList(currentData),
...adminOpt,
}; };
response = response =
await statisticsInputPrivateSewageFacilityApi.submitPrivateSewageDesigStatus( await statisticsInputPrivateSewageFacilityApi.submitPrivateSewageDesigStatus(
@@ -768,7 +768,6 @@ const PumpingStationInp = () => {
lclgvIdfrTelnoCn: submitInfo.lclgvIdfrTelnoCn, lclgvIdfrTelnoCn: submitInfo.lclgvIdfrTelnoCn,
lclgvIdfrDeptNm: submitInfo.lclgvIdfrDeptNm, lclgvIdfrDeptNm: submitInfo.lclgvIdfrDeptNm,
lclgvIdfrJbgdNm: submitInfo.lclgvIdfrJbgdNm, lclgvIdfrJbgdNm: submitInfo.lclgvIdfrJbgdNm,
...(isAdminInfo ? { superAdmin: "superAdmin" } : {}),
noApplicable, noApplicable,
submitList, submitList,
deleteList, deleteList,
@@ -1410,7 +1410,6 @@ const SewageExcretaRelatedInp = () => {
lclgvIdfrDeptNm: submitInfo.lclgvIdfrDeptNm, lclgvIdfrDeptNm: submitInfo.lclgvIdfrDeptNm,
lclgvIdfrJbgdNm: submitInfo.lclgvIdfrJbgdNm, lclgvIdfrJbgdNm: submitInfo.lclgvIdfrJbgdNm,
...(isPublicInstitutionTab ? { noApplicable } : {}), ...(isPublicInstitutionTab ? { noApplicable } : {}),
...(isAdminInfo ? { superAdmin: "superAdmin" as const } : {}),
}; };
const verificationQuery = { const verificationQuery = {
@@ -1357,11 +1357,6 @@ const SewageExcretaSlurgeInp = () => {
: rowsForSubmit.map((row) => : rowsForSubmit.map((row) =>
buildExcretaSlurgeProcessingStatusSaveItem(row, list), buildExcretaSlurgeProcessingStatusSaveItem(row, list),
); );
const slurgeDeleteList = isSlurgeFacTab
? (deleteListByTab.get(tabKey) ?? [])
.filter((row) => isPersistedArticleRowForTab(tabKey, row))
.map((row) => buildSlurgeProcessingFacStatusSaveItem(row, list))
: [];
const sewageSlurgeFacDeleteList = isSewageSlurgeFacTab const sewageSlurgeFacDeleteList = isSewageSlurgeFacTab
? (deleteListByTab.get(tabKey) ?? []) ? (deleteListByTab.get(tabKey) ?? [])
.filter((row) => isPersistedArticleRowForTab(tabKey, row)) .filter((row) => isPersistedArticleRowForTab(tabKey, row))
@@ -1432,7 +1427,6 @@ const SewageExcretaSlurgeInp = () => {
lclgvIdfrTelnoCn: submitInfo.lclgvIdfrTelnoCn, lclgvIdfrTelnoCn: submitInfo.lclgvIdfrTelnoCn,
lclgvIdfrDeptNm: submitInfo.lclgvIdfrDeptNm, lclgvIdfrDeptNm: submitInfo.lclgvIdfrDeptNm,
lclgvIdfrJbgdNm: submitInfo.lclgvIdfrJbgdNm, lclgvIdfrJbgdNm: submitInfo.lclgvIdfrJbgdNm,
...(isAdminInfo ? { superAdmin: "superAdmin" as const } : {}),
noApplicable, noApplicable,
}; };
@@ -1445,7 +1439,6 @@ const SewageExcretaSlurgeInp = () => {
...submitRequestBase, ...submitRequestBase,
userUuid: userInfo.userUnqId, userUuid: userInfo.userUnqId,
submitList: slurgeSubmitList, submitList: slurgeSubmitList,
deleteList: slurgeDeleteList,
}) })
: isDigestorTab : isDigestorTab
? await statisticsInputSewageExcretaSlurgeApi.submitDigestorOperatingStatus({ ? await statisticsInputSewageExcretaSlurgeApi.submitDigestorOperatingStatus({
@@ -1655,9 +1648,6 @@ const SewageExcretaSlurgeInp = () => {
: rowsForSave.map((row) => : rowsForSave.map((row) =>
buildSlurgeProcessingFacStatusSaveItem(row, list), buildSlurgeProcessingFacStatusSaveItem(row, list),
); );
const deleteList = (deleteListByTab.get(tabKey) ?? [])
.filter((row) => isPersistedArticleRowForTab(tabKey, row))
.map((row) => buildSlurgeProcessingFacStatusSaveItem(row, list));
if (!noApplicable && saveList.length === 0 && buildSubmitOkForToolbar(searchContextByTab.get(tabKey) ?? lastSearchedValues)) { if (!noApplicable && saveList.length === 0 && buildSubmitOkForToolbar(searchContextByTab.get(tabKey) ?? lastSearchedValues)) {
alert("해당사항 없음을 체크해주세요."); alert("해당사항 없음을 체크해주세요.");
@@ -1670,7 +1660,6 @@ const SewageExcretaSlurgeInp = () => {
...baseSaveRequest, ...baseSaveRequest,
...userFields, ...userFields,
saveList, saveList,
deleteList,
}, },
); );
} else if (tabKey === DigestorOperatingStatusInpColumn.tabKey) { } else if (tabKey === DigestorOperatingStatusInpColumn.tabKey) {
@@ -350,7 +350,6 @@ function buildRegionalFactSubmitListFromGrid(
}; };
return currentData.map((d) => ({ return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd), admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm), admdstNm: nullableTrimmedString(d.admdstNm),
goalYr: nullableTrimmedString(d.goalYr), goalYr: nullableTrimmedString(d.goalYr),
wholPplMtncPlanPrlg: formatNumber(d.wholPplMtncPlanPrlg, "wholPplMtncPlanPrlg"), wholPplMtncPlanPrlg: formatNumber(d.wholPplMtncPlanPrlg, "wholPplMtncPlanPrlg"),
@@ -1264,7 +1263,6 @@ const SewagePipeInp = () => {
}; };
const submitList = currentData.map((d) => ({ const submitList = currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd), admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm), admdstNm: nullableTrimmedString(d.admdstNm),
fcltSn: nullableTrimmedString(d.fcltSn), fcltSn: nullableTrimmedString(d.fcltSn),
swtrmtFcltCd: nullableTrimmedString(d.swtrmtFcltCd), swtrmtFcltCd: nullableTrimmedString(d.swtrmtFcltCd),
@@ -1637,7 +1635,6 @@ const SewagePipeInp = () => {
const saveList = currentData.map((d) => ({ const saveList = currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd), admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm), admdstNm: nullableTrimmedString(d.admdstNm),
goalYr: nullableTrimmedString(d.goalYr), goalYr: nullableTrimmedString(d.goalYr),
wholPplMtncPlanPrlg: formatNumber(d.wholPplMtncPlanPrlg, "wholPplMtncPlanPrlg"), wholPplMtncPlanPrlg: formatNumber(d.wholPplMtncPlanPrlg, "wholPplMtncPlanPrlg"),
@@ -1728,7 +1725,6 @@ const SewagePipeInp = () => {
const saveList = currentData.map((d) => ({ const saveList = currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd), admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm), admdstNm: nullableTrimmedString(d.admdstNm),
fcltSn: nullableTrimmedString(d.fcltSn), fcltSn: nullableTrimmedString(d.fcltSn),
swtrmtFcltCd: nullableTrimmedString(d.swtrmtFcltCd), swtrmtFcltCd: nullableTrimmedString(d.swtrmtFcltCd),
@@ -218,7 +218,6 @@ function buildRepairRowList(
return currentData.map((d) => ({ return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd), admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm), admdstNm: nullableTrimmedString(d.admdstNm),
dfrpWholPrlg: formatNumber(d.dfrpWholPrlg, "dfrpWholPrlg"), dfrpWholPrlg: formatNumber(d.dfrpWholPrlg, "dfrpWholPrlg"),
cbsysDfrpPrlg: formatNumber(d.cbsysDfrpPrlg, "cbsysDfrpPrlg"), cbsysDfrpPrlg: formatNumber(d.cbsysDfrpPrlg, "cbsysDfrpPrlg"),
@@ -247,7 +246,6 @@ function buildDredgingRowList(
return currentData.map((d) => ({ return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd), admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm), admdstNm: nullableTrimmedString(d.admdstNm),
wholFcltPrlg: formatNumber(d.wholFcltPrlg, "wholFcltPrlg"), wholFcltPrlg: formatNumber(d.wholFcltPrlg, "wholFcltPrlg"),
wholSwagePpl: formatNumber(d.wholSwagePpl, "wholSwagePpl"), wholSwagePpl: formatNumber(d.wholSwagePpl, "wholSwagePpl"),
@@ -215,7 +215,6 @@ function buildSewerageFinanceSettleSaveList(
return currentData.map((d) => ({ return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd), admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm), admdstNm: nullableTrimmedString(d.admdstNm),
soaGovSprtAmt: formatNumber(d.soaGovSprtAmt, "soaGovSprtAmt"), soaGovSprtAmt: formatNumber(d.soaGovSprtAmt, "soaGovSprtAmt"),
mctpvLcexpAmt: formatNumber(d.mctpvLcexpAmt, "mctpvLcexpAmt"), mctpvLcexpAmt: formatNumber(d.mctpvLcexpAmt, "mctpvLcexpAmt"),
@@ -257,7 +256,6 @@ function buildSewerageChargeSaveList(
return currentData.map((d) => ({ return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd), admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm), admdstNm: nullableTrimmedString(d.admdstNm),
anlLevyQnt: formatNumber(d.anlLevyQnt, "anlLevyQnt"), anlLevyQnt: formatNumber(d.anlLevyQnt, "anlLevyQnt"),
sewerLevyAmt: formatNumber(d.sewerLevyAmt, "sewerLevyAmt"), sewerLevyAmt: formatNumber(d.sewerLevyAmt, "sewerLevyAmt"),
@@ -276,7 +274,6 @@ function buildSewerageUsageFeeSaveList(
return currentData.map((d) => ({ return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd), admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm), admdstNm: nullableTrimmedString(d.admdstNm),
hshusLevyAmt: formatNumber(d.hshusLevyAmt, "hshusLevyAmt"), hshusLevyAmt: formatNumber(d.hshusLevyAmt, "hshusLevyAmt"),
hshusLevyQnt: formatNumber(d.hshusLevyQnt, "hshusLevyQnt"), hshusLevyQnt: formatNumber(d.hshusLevyQnt, "hshusLevyQnt"),
@@ -312,7 +309,6 @@ function buildCauserBurdenSaveList(
return currentData.map((d) => ({ return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd), admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm), admdstNm: nullableTrimmedString(d.admdstNm),
brdnAmtLevyNocs: formatNumber(d.brdnAmtLevyNocs, "brdnAmtLevyNocs"), brdnAmtLevyNocs: formatNumber(d.brdnAmtLevyNocs, "brdnAmtLevyNocs"),
brdnAmtLevyAmt: formatNumber(d.brdnAmtLevyAmt, "brdnAmtLevyAmt"), brdnAmtLevyAmt: formatNumber(d.brdnAmtLevyAmt, "brdnAmtLevyAmt"),
@@ -849,7 +845,6 @@ const SewerageFinanceInp = () => {
const activeTabStatus = tabStatusInfoMap.get(tabKey) ?? null; const activeTabStatus = tabStatusInfoMap.get(tabKey) ?? null;
const statsCdForTab = tabStatsCdMap.get(tabKey) ?? tabKey; const statsCdForTab = tabStatsCdMap.get(tabKey) ?? tabKey;
const adminOpt = isAdminInfo ? { superAdmin: "superAdmin" as const } : {};
const submitInfoBase = { const submitInfoBase = {
statsYr: lastSearchedValues.year ?? "", statsYr: lastSearchedValues.year ?? "",
@@ -894,7 +889,6 @@ const SewerageFinanceInp = () => {
...submitInfoBase, ...submitInfoBase,
userUuid: userInfo.userUnqId, userUuid: userInfo.userUnqId,
submitList: buildSewerageFinanceSettleSaveList(currentData), submitList: buildSewerageFinanceSettleSaveList(currentData),
...adminOpt,
}; };
response = response =
await statisticsInputSewerageFinanceApi.submitSewerageFinanceSettle( await statisticsInputSewerageFinanceApi.submitSewerageFinanceSettle(
@@ -905,7 +899,6 @@ const SewerageFinanceInp = () => {
...submitInfoBase, ...submitInfoBase,
userUuid: userInfo.userUnqId, userUuid: userInfo.userUnqId,
submitList: buildSewerageChargeSaveList(currentData), submitList: buildSewerageChargeSaveList(currentData),
...adminOpt,
}; };
response = await statisticsInputSewerageFinanceApi.submitSewerageCharge(param); response = await statisticsInputSewerageFinanceApi.submitSewerageCharge(param);
} else if (isUsageFeeTab) { } else if (isUsageFeeTab) {
@@ -913,7 +906,6 @@ const SewerageFinanceInp = () => {
...submitInfoBase, ...submitInfoBase,
userUuid: userInfo.userUnqId, userUuid: userInfo.userUnqId,
submitList: buildSewerageUsageFeeSaveList(currentData), submitList: buildSewerageUsageFeeSaveList(currentData),
...adminOpt,
}; };
response = response =
await statisticsInputSewerageFinanceApi.submitSewerageUsageFee(param); await statisticsInputSewerageFinanceApi.submitSewerageUsageFee(param);
@@ -922,7 +914,6 @@ const SewerageFinanceInp = () => {
...submitInfoBase, ...submitInfoBase,
userUuid: userInfo.userUnqId, userUuid: userInfo.userUnqId,
submitList: buildCauserBurdenSaveList(currentData), submitList: buildCauserBurdenSaveList(currentData),
...adminOpt,
}; };
response = await statisticsInputSewerageFinanceApi.submitCauserBurden(param); response = await statisticsInputSewerageFinanceApi.submitCauserBurden(param);
} }
@@ -399,10 +399,6 @@ const SeweragePenetrationRateInp = () => {
statsCd: statsCd, statsCd: statsCd,
}; };
if (isAdminInfo) {
params.superAdmin = 'superAdmin';
}
try { try {
setIsLoading(true); setIsLoading(true);
@@ -498,7 +494,6 @@ const SeweragePenetrationRateInp = () => {
}; };
const convertedData: SewerageSupplyRateSaveItem[] = currentData.map((d) => ({ const convertedData: SewerageSupplyRateSaveItem[] = currentData.map((d) => ({
sggNm: nullableTrimmedString(d.sggNm),
admdstCd: nullableTrimmedString(d.admdstCd), admdstCd: nullableTrimmedString(d.admdstCd),
admdstNm: nullableTrimmedString(d.admdstNm), admdstNm: nullableTrimmedString(d.admdstNm),
statsTrbtrNm: nullableTrimmedString(d.statsTrbtrNm), statsTrbtrNm: nullableTrimmedString(d.statsTrbtrNm),
@@ -677,8 +672,6 @@ const SeweragePenetrationRateInp = () => {
}; };
const convertedData: SewerageSupplyRateSaveItem[] = currentData.map((d) => ({ const convertedData: SewerageSupplyRateSaveItem[] = currentData.map((d) => ({
// 행정구역
sggNm: nullableTrimmedString(d.sggNm), // 시군구
admdstCd: nullableTrimmedString(d.admdstCd), // 행정코드 admdstCd: nullableTrimmedString(d.admdstCd), // 행정코드
admdstNm: nullableTrimmedString(d.admdstNm), // 행정구역명 admdstNm: nullableTrimmedString(d.admdstNm), // 행정구역명
statsTrbtrNm: nullableTrimmedString(d.statsTrbtrNm), // 지류 statsTrbtrNm: nullableTrimmedString(d.statsTrbtrNm), // 지류
@@ -808,13 +808,11 @@ const SewerageServiceLevelInp = () => {
? await statisticsInputSewerageServiceLevelApi.submitComplaintList({ ? await statisticsInputSewerageServiceLevelApi.submitComplaintList({
...submitInfoBase, ...submitInfoBase,
userUuid: userInfo.userUnqId, userUuid: userInfo.userUnqId,
...(isAdminInfo ? { superAdmin: "superAdmin" } : {}),
submitList: complaintSubmitList, submitList: complaintSubmitList,
}) })
: await statisticsInputSewerageServiceLevelApi.submitResidentFriendlyFactList({ : await statisticsInputSewerageServiceLevelApi.submitResidentFriendlyFactList({
...submitInfoBase, ...submitInfoBase,
userUuid: userInfo.userUnqId, userUuid: userInfo.userUnqId,
...(isAdminInfo ? { superAdmin: "superAdmin" } : {}),
noApplicable, noApplicable,
submitList: residentSubmitList, submitList: residentSubmitList,
}); });
@@ -1153,7 +1153,6 @@ const WaterReuseInp = () => {
lclgvIdfrTelnoCn: submitInfo.lclgvIdfrTelnoCn, lclgvIdfrTelnoCn: submitInfo.lclgvIdfrTelnoCn,
lclgvIdfrDeptNm: submitInfo.lclgvIdfrDeptNm, lclgvIdfrDeptNm: submitInfo.lclgvIdfrDeptNm,
lclgvIdfrJbgdNm: submitInfo.lclgvIdfrJbgdNm, lclgvIdfrJbgdNm: submitInfo.lclgvIdfrJbgdNm,
...(isAdminInfo ? { superAdmin: "superAdmin" } : {}),
}; };
let articleSggOptions: Sgg[] | null = null; let articleSggOptions: Sgg[] | null = null;
@@ -1,100 +1,100 @@
export interface DashboardSearchParams { export interface DashboardSearchParams {
statsYr: string; statsYr: string;
dtlInstCd: string; dtlInstCd: string;
enoCd?: string; enoCd?: string;
laeSeCd?: string; laeSeCd?: string;
sewerBzmnCd?: string; sewerBzmnCd?: string;
sido?: string; sido?: string;
sggNm?: string; sggNm?: string;
unSubmitOnly?: boolean; unSubmitOnly?: boolean;
} }
export interface DashboardApiResponse<T> { export interface DashboardApiResponse<T> {
status: string; status: string;
message: string; message: string;
data: T; data: T;
code: string; code: string;
httpStatus: number; httpStatus: number;
} }
export interface VrfcReportResponse { export interface VrfcReportResponse {
typeCounts: Record<string, number>; typeCounts: Record<string, number>;
list: VrfcReportSelect[]; list: VrfcReportSelect[];
} }
export interface VrfcReportSelect { export interface VrfcReportSelect {
vrfcCd: string; vrfcCd: string;
statsNm: string; statsNm: string;
rsnExpln: string; rsnExpln: string;
admdstNm: string; admdstNm: string;
nowVlCn: string; nowCN: string;
compVlCn: string; cmprCn: string | null;
} }
export interface VrfcErrCountResponse { export interface VrfcErrCountResponse {
statsYr: string; statsYr: string;
vrfcErrCount: number; vrfcErrCount: number;
} }
export interface UnSubmitCountResponse { export interface UnSubmitCountResponse {
statsYr: string; statsYr: string;
statusList: UnSubmitStatsResponse[]; statusList: UnSubmitStatsResponse[];
submittedCount: number; submittedCount: number;
totalCount: number; totalCount: number;
unSubmittedCount: number; unSubmittedCount: number;
} }
export interface UnSubmitStatsResponse { export interface UnSubmitStatsResponse {
sbmsnYn: string; sbmsnYn: string;
sidoSggNm: string; sidoSggNm: string;
statsCd: string; statsCd: string;
statsNm: string; statsNm: string;
statusCd: string; statusCd: string;
statusNm: string; statusNm: string;
} }
export interface StatsSubmissionResponse { export interface StatsSubmissionResponse {
totalCount: number; totalCount: number;
submittedCount: number; submittedCount: number;
submissionRate: number; submissionRate: number;
statusLevel: string; statusLevel: string;
} }
export interface EcptReqCountResponse { export interface EcptReqCountResponse {
statsYr: string; statsYr: string;
ecptReqCount: number; ecptReqCount: number;
} }
export interface ThreeStageStatusResponse { export interface ThreeStageStatusResponse {
statsYr: string; statsYr: string;
lclgvSbmsnSttsCd: string; lclgvSbmsnSttsCd: string;
lclgvSbmsnSttsNm: string; lclgvSbmsnSttsNm: string;
lclgvRejected: boolean; lclgvRejected: boolean;
mctpvPicSbmsnSttsCd: string; mctpvPicSbmsnSttsCd: string;
mctpvPicSbmsnSttsNm: string; mctpvPicSbmsnSttsNm: string;
mctpvRejected: boolean; mctpvRejected: boolean;
pbcpSbmsnSttsCd: string; pbcpSbmsnSttsCd: string;
pbcpSbmsnSttsNm: string; pbcpSbmsnSttsNm: string;
pbcpRejected: boolean; pbcpRejected: boolean;
} }
export interface DashboardService { export interface DashboardService {
getVrfcReport( getVrfcReport(
params: DashboardSearchParams, params: DashboardSearchParams,
): Promise<DashboardApiResponse<VrfcReportResponse>>; ): Promise<DashboardApiResponse<VrfcReportResponse>>;
getVrfcErrCount( getVrfcErrCount(
params: DashboardSearchParams, params: DashboardSearchParams,
): Promise<DashboardApiResponse<VrfcErrCountResponse>>; ): Promise<DashboardApiResponse<VrfcErrCountResponse>>;
getUnSubmitCount( getUnSubmitCount(
params: DashboardSearchParams, params: DashboardSearchParams,
): Promise<DashboardApiResponse<UnSubmitCountResponse>>; ): Promise<DashboardApiResponse<UnSubmitCountResponse>>;
getStatsSubmissionRate( getStatsSubmissionRate(
params: DashboardSearchParams, params: DashboardSearchParams,
): Promise<DashboardApiResponse<StatsSubmissionResponse>>; ): Promise<DashboardApiResponse<StatsSubmissionResponse>>;
getEcptReqCount( getEcptReqCount(
params: DashboardSearchParams, params: DashboardSearchParams,
): Promise<DashboardApiResponse<EcptReqCountResponse>>; ): Promise<DashboardApiResponse<EcptReqCountResponse>>;
getThreeStageStatus( getThreeStageStatus(
params: DashboardSearchParams, params: DashboardSearchParams,
): Promise<DashboardApiResponse<ThreeStageStatusResponse>>; ): Promise<DashboardApiResponse<ThreeStageStatusResponse>>;
} }
@@ -117,7 +117,7 @@ export type SlurgeProcessingFacStatusApiResponse =
}; };
/** /**
* ST00110001 저장 — `saveList` / `deleteList` 항목. * ST00110001 저장 — `saveList` 항목.
* `sggNm`·계산필드·검증결과는 저장 요청에서 제외. * `sggNm`·계산필드·검증결과는 저장 요청에서 제외.
*/ */
export type SlurgeProcessingFacStatusSaveItem = Omit< export type SlurgeProcessingFacStatusSaveItem = Omit<
@@ -167,7 +167,6 @@ export interface SlurgeProcessingFacStatusSaveRequest {
superAdmin?: string; superAdmin?: string;
noApplicable?: boolean; noApplicable?: boolean;
saveList: SlurgeProcessingFacStatusSaveItem[]; saveList: SlurgeProcessingFacStatusSaveItem[];
deleteList: SlurgeProcessingFacStatusSaveItem[];
} }
export type SlurgeProcessingFacStatusSaveResponse = ApiResponse<unknown> & { export type SlurgeProcessingFacStatusSaveResponse = ApiResponse<unknown> & {
@@ -194,7 +193,6 @@ export interface SlurgeProcessingFacStatusSubmitRequest {
superAdmin?: string; superAdmin?: string;
noApplicable?: boolean; noApplicable?: boolean;
submitList: SlurgeProcessingFacStatusSubmitItem[]; submitList: SlurgeProcessingFacStatusSubmitItem[];
deleteList: SlurgeProcessingFacStatusSubmitItem[];
} }
export interface SlurgeProcessingFacStatusSubmitResponse { export interface SlurgeProcessingFacStatusSubmitResponse {
@@ -98,7 +98,6 @@ export type SewagePipeMaintainDredgingResponse = ApiResponse<SewagePipeMaintainD
/** ST00030001 하수관로 유지관리 > 개보수 저장·제출 API 행 */ /** ST00030001 하수관로 유지관리 > 개보수 저장·제출 API 행 */
export interface SewagePipeMaintainRepairSaveItem { export interface SewagePipeMaintainRepairSaveItem {
admdstCd: string | null; admdstCd: string | null;
sggNm: string | null;
admdstNm: string | null; admdstNm: string | null;
dfrpWholPrlg: number; dfrpWholPrlg: number;
cbsysDfrpPrlg: number; cbsysDfrpPrlg: number;
@@ -167,7 +166,6 @@ export interface SewagePipeMaintainRepairSubmitResponse {
/** ST00030002 하수관로 유지관리 > 관로준설 저장·제출 API 행 */ /** ST00030002 하수관로 유지관리 > 관로준설 저장·제출 API 행 */
export interface SewagePipeMaintainDredgingSaveItem { export interface SewagePipeMaintainDredgingSaveItem {
admdstCd: string | null; admdstCd: string | null;
sggNm: string | null;
admdstNm: string | null; admdstNm: string | null;
wholFcltPrlg: number; wholFcltPrlg: number;
wholSwagePpl: number; wholSwagePpl: number;
@@ -92,7 +92,6 @@ export interface SewerageFinanceSettleRequestParams {
*/ */
export interface SewerageFinanceSettleSaveItem { export interface SewerageFinanceSettleSaveItem {
admdstCd: string | null; admdstCd: string | null;
sggNm: string | null;
admdstNm: string | null; admdstNm: string | null;
soaGovSprtAmt: number; soaGovSprtAmt: number;
mctpvLcexpAmt: number; mctpvLcexpAmt: number;
@@ -255,7 +254,6 @@ export interface SewerageChargeRequestParams {
*/ */
export interface SewerageChargeSaveItem { export interface SewerageChargeSaveItem {
admdstCd: string | null; admdstCd: string | null;
sggNm: string | null;
admdstNm: string | null; admdstNm: string | null;
anlLevyQnt: number; anlLevyQnt: number;
sewerLevyAmt: number; sewerLevyAmt: number;
@@ -362,7 +360,6 @@ export interface SewerageUsageFeeRequestParams {
*/ */
export interface SewerageUsageFeeSaveItem { export interface SewerageUsageFeeSaveItem {
admdstCd: string | null; admdstCd: string | null;
sggNm: string | null;
admdstNm: string | null; admdstNm: string | null;
hshusLevyAmt: number; hshusLevyAmt: number;
hshusLevyQnt: number; hshusLevyQnt: number;
@@ -472,7 +469,6 @@ export interface CauserBurdenRequestParams {
*/ */
export interface CauserBurdenSaveItem { export interface CauserBurdenSaveItem {
admdstCd: string | null; admdstCd: string | null;
sggNm: string | null;
admdstNm: string | null; admdstNm: string | null;
brdnAmtLevyNocs: number; brdnAmtLevyNocs: number;
brdnAmtLevyAmt: number; brdnAmtLevyAmt: number;
@@ -99,7 +99,6 @@ export type SeweragePipeInputRegionalFactStatusSaveItem = Omit<
"verificationResult" | "admdstCd" | "sggNm" | "admdstNm" | "goalYr" "verificationResult" | "admdstCd" | "sggNm" | "admdstNm" | "goalYr"
> & { > & {
admdstCd: string | null; admdstCd: string | null;
sggNm: string | null;
admdstNm: string | null; admdstNm: string | null;
goalYr: string | null; goalYr: string | null;
}; };
@@ -118,7 +117,6 @@ export type SeweragePipeInputFactInstallStatusSaveItem = Omit<
| "rmrk" | "rmrk"
> & { > & {
admdstCd: string | null; admdstCd: string | null;
sggNm: string | null;
admdstNm: string | null; admdstNm: string | null;
fcltSn: string | null; fcltSn: string | null;
swtrmtFcltCd: string | null; swtrmtFcltCd: string | null;
@@ -86,7 +86,6 @@ export interface SewerageSupplyRateRequestParams {
/** 하수도 보급률 저장 API 요청 body (한 행) */ /** 하수도 보급률 저장 API 요청 body (한 행) */
export interface SewerageSupplyRateSaveItem { export interface SewerageSupplyRateSaveItem {
sggNm: string | null;
admdstCd: string | null; admdstCd: string | null;
admdstNm: string | null; admdstNm: string | null;
statsTrbtrNm: string | null; statsTrbtrNm: string | null;
@@ -1,133 +1,133 @@
"use client"; "use client";
import { useDashboardVrfcReport } from "@/entities/statistics"; import { useDashboardVrfcReport } from "@/entities/statistics";
import type { DashboardSearchParams } from "@/entities/statistics/model/dashboard/dashboard-interface"; import type { DashboardSearchParams } from "@/entities/statistics/model/dashboard/dashboard-interface";
import { import {
Badge, Badge,
type BadgeProps, type BadgeProps,
LoadingWithAnimation, LoadingWithAnimation,
} from "@common/components"; } from "@common/components";
import { useDragHandle } from "../sortable-panel"; import { useDragHandle } from "../sortable-panel";
interface VrfcReportPanelProps { interface VrfcReportPanelProps {
params: DashboardSearchParams | null; params: DashboardSearchParams | null;
} }
const TYPE_CODE_NAME_MAP: Record<string, string> = { const TYPE_CODE_NAME_MAP: Record<string, string> = {
NV: "일반", NV: "일반",
IMR: "I-MR", IMR: "I-MR",
HB: "HB", HB: "HB",
}; };
const TYPE_CODE_COLOR_MAP: Record<string, BadgeProps["color"]> = { const TYPE_CODE_COLOR_MAP: Record<string, BadgeProps["color"]> = {
NV: "gray", NV: "gray",
IMR: "red", IMR: "red",
HB: "blue", HB: "blue",
}; };
export function VrfcReportPanel({ params }: VrfcReportPanelProps) { export function VrfcReportPanel({ params }: VrfcReportPanelProps) {
const { dragHandleProps, isDragging } = useDragHandle(); const { dragHandleProps, isDragging } = useDragHandle();
const { data, isLoading, error } = useDashboardVrfcReport(params); const { data, isLoading, error } = useDashboardVrfcReport(params);
return ( return (
<div className="card card-border !m-0 h-full border-border bg-background text-foreground"> <div className="card card-border !m-0 h-full border-border bg-background text-foreground">
<div <div
className={`card-header cursor-grab border-border active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`} className={`card-header cursor-grab border-border active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
{...dragHandleProps} {...dragHandleProps}
title="헤더를 드래그하여 패널 순서 변경" title="헤더를 드래그하여 패널 순서 변경"
> >
<h3 className="text-2xl font-semibold">이상치 선별보고서</h3> <h3 className="text-2xl font-semibold">이상치 선별보고서</h3>
</div> </div>
<div className="card-body max-h-[calc(100%-3.25rem)] py-2.5"> <div className="card-body max-h-[calc(100%-3.25rem)] py-2.5">
{isLoading && ( {isLoading && (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="relative size-[5rem]"> <div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" /> <LoadingWithAnimation color="primary" />
</div> </div>
</div> </div>
)} )}
{error && ( {error && (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
데이터를 불러올 수 없습니다. 데이터를 불러올 수 없습니다.
</div> </div>
)} )}
{data && ( {data && (
<div className="space-y-2"> <div className="space-y-2">
{/* TBD: typeCounts 표시 방법 (탭? 필터?) */} {/* TBD: typeCounts 표시 방법 (탭? 필터?) */}
{data.typeCounts && {data.typeCounts &&
Object.keys(data.typeCounts).length > 0 && ( Object.keys(data.typeCounts).length > 0 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{Object.entries(data.typeCounts).map( {Object.entries(data.typeCounts).map(
([type, count]) => ( ([type, count]) => (
<Badge <Badge
key={type} key={type}
color={ color={
TYPE_CODE_COLOR_MAP[type] TYPE_CODE_COLOR_MAP[type]
} }
className="rounded-full px-2 py-1 text-lg" className="rounded-full px-2 py-1 text-lg"
> >
{TYPE_CODE_NAME_MAP[type]}:{" "} {TYPE_CODE_NAME_MAP[type]}:{" "}
{count} {count}
</Badge> </Badge>
), ),
)} )}
</div> </div>
)} )}
{/* TBD: 테이블 컬럼 정의, 증감 표시 방법 */} {/* TBD: 테이블 컬럼 정의, 증감 표시 방법 */}
<div className="max-h-[44rem] overflow-y-auto"> <div className="max-h-[44rem] overflow-y-auto">
{data.list.length === 0 ? ( {data.list.length === 0 ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
이상치 항목이 없습니다. 이상치 항목이 없습니다.
</div> </div>
) : ( ) : (
<div className="space-y-2 tracking-tight"> <div className="space-y-2 tracking-tight">
{data.list.map((item, index) => ( {data.list.map((item, index) => (
<div <div
key={index} key={index}
className="flex flex-col rounded-sm border border-border p-3" className="flex flex-col rounded-sm border border-border p-3"
> >
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<span className="font-medium"> <span className="font-medium">
{item.statsNm} {item.statsNm}
</span> </span>
<Badge <Badge
color={ color={
TYPE_CODE_COLOR_MAP[ TYPE_CODE_COLOR_MAP[
item.vrfcCd item.vrfcCd
] ]
} }
className="text-sm" className="text-sm"
> >
{ {
TYPE_CODE_NAME_MAP[ TYPE_CODE_NAME_MAP[
item.vrfcCd item.vrfcCd
] ]
} }
</Badge> </Badge>
</div> </div>
<p className="text-lg text-muted-foreground"> <p className="text-lg text-muted-foreground">
{item.rsnExpln} {item.rsnExpln}
</p> </p>
<div className="flex justify-between text-lg text-muted-foreground"> <div className="flex justify-between text-lg text-muted-foreground">
<span>{item.admdstNm}</span> <span>{item.admdstNm}</span>
<span> <span>
{/* TBD: 증감 표시 방법 (nowVlCn - compVlCn 계산?) */} {/* TBD: 증감 표시 방법 (nowCN - cmprCn 계산?) */}
현재: {item.nowVlCn ?? "-"}{" "} 현재: {item.nowCN ?? "-"}{" "}
/ 비교:{" "} / 비교:{" "}
{item.compVlCn ?? "-"} {item.cmprCn ?? "-"}
</span> </span>
</div> </div>
{/* TBD: 등급/점수/단계/업데이트 시각 필드 미제공 */} {/* TBD: 등급/점수/단계/업데이트 시각 필드 미제공 */}
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </div>
</div> </div>
)} )}
</div> </div>
</div> </div>
); );
} }
@@ -16,6 +16,7 @@ function StatisticsPageLayout({ children }: { children: React.ReactNode }) {
<TabBar /> <TabBar />
<PageTitle <PageTitle
title={pageTitle?.label ?? ""} title={pageTitle?.label ?? ""}
menuCd={pageTitle?.menuItem?.menuCd}
menuPaths={menuPaths ?? []} menuPaths={menuPaths ?? []}
/> />
{children} {children}