diff --git a/apps/as/src/entities/instl-sncn/index.ts b/apps/as/src/entities/instl-sncn/index.ts index 875a1e162..9547dd6db 100644 --- a/apps/as/src/entities/instl-sncn/index.ts +++ b/apps/as/src/entities/instl-sncn/index.ts @@ -5,6 +5,7 @@ export { useInstlSncnList, useInstlSncnSidoCombo, useInstlSncnSigunguCombo, + useInstlSncnTechReview, } from "./api/queries"; export { type AdstZoneResponseDto, @@ -26,5 +27,7 @@ export { type InstlSncnMngSaveRequestDto, type InstlSncnMngSearchParams, type InstlSncnRvwOpnnResponseDto, + type InstlSncnRvwOpnnSaveRequestDto, + type InstlSncnSplmntDmndSaveDto, type ReviewStepCode, } from "./model/instl-sncn-interface"; diff --git a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts index c8ab5e56f..378d1e427 100644 --- a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts +++ b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts @@ -114,14 +114,14 @@ export interface InstlSncnMngResponseDto extends InstlSncnMngSaveRequestDto { export interface InstlSncnBzScaleSaveDto { /** 공종구분코드 */ cnstpSeCd: string; - /** 기본계획(A) */ + /** 기본계획수량 */ bscPl: number; - /** 예산반영(B) */ + /** 예산반영수량 */ budgetReflct: number; - /** 금회설계시(C) */ + /** 금회설계수량 */ thtiDsgn: number; - /** 증감(C-B) */ - diff: number; + /** 증감(C-B) — 클라이언트 계산용, API 미전송 */ + diff?: number; } /** 설치인가 신청 - 총사업비 저장 DTO(배열 원소) */ @@ -138,27 +138,92 @@ export interface InstlSncnBizCstSaveRequestDto { /** 설치인가 신청 - 체크리스트 결과 저장 DTO(배열 원소) */ export interface InstlSncnCklstResultSaveDto { + /** 점검표일련번호 */ cklstSn: number; + /** 점검표항목일련번호 */ cklstArtclSn: number; + /** 점검표검토일련번호 */ cklstRvwSn: number; + /** 검토단계공통코드 */ rvwStpComCd: string; + /** 선택값(Y/N/null) */ selected: "Y" | "N" | null; + /** 담당자검토내용(기술검토의견) */ picRvwCn?: string | null; } /** 설치인가 신청 - 필수첨부파일 저장 DTO(배열 원소) */ export interface InstlSncnEsntlAtchFileSaveDto { + /** 필수첨부파일아이디 */ esntlAtchFileId: string; + /** 첨부파일그룹아이디 */ atchFileGroupId: string; } +/** 설치인가검토의견 저장 요청 DTO */ +export interface InstlSncnRvwOpnnSaveRequestDto { + /** 설치인가문서번호 */ + instlSncnDocNo: string; + /** 검토의견일련번호 */ + rvwOpnnSn?: number; + /** 의견자구분공통코드 */ + opnrSeComCd?: string; + /** 의견자명 */ + opnrNm?: string; + /** 검토결과내용 */ + rvwRsltCn?: string; + /** 검토결과이상여부 */ + rvwRsltAnmlyYn?: string; + /** 첨부파일그룹아이디 */ + atchFileGroupId?: string; +} + +/** 설치인가 보완요구 저장 DTO(배열 원소) */ +export interface InstlSncnSplmntDmndSaveDto { + /** 보완요구 일련번호 */ + splmntDmndSn?: number; + /** 보완항목 공통코드 */ + splmntArtclComCd?: string; + /** 보완요구 내용 */ + splmntDmndCn?: string; + /** 보완요구 일시 */ + splmntDmndDt?: string; + /** 보완요구 첨부파일 그룹 ID */ + splmntDmndAtchFileGroupId?: string; + /** 보완 내용 */ + splmntCn?: string; + /** 보완 담당자 명 */ + splmntPicNm?: string; + /** 보완완료 일시 */ + splmntCmptnDt?: string; + /** 보완 첨부파일 그룹 ID */ + splmntAtchFileGroupId?: string; + /** 완료 여부 */ + cmptnYn?: string; +} + /** 설치인가 신청 - 통합 저장 요청 DTO */ export interface InstlSncnApplySaveRequestDto { + /** 기술검토저장구분(SV.저장, CP.검토완료) */ + techReViewSaveCd?: string; + /** 검토단계공통코드(ENV.환경청.EP, KECO.환경공단.TE) */ + rvwStpCd?: string; + /** 설치인가관리 정보 */ instlSncnMng: InstlSncnMngSaveRequestDto; + /** 사업규모 목록 */ bzScaleList: InstlSncnBzScaleSaveDto[]; + /** 총사업비 목록 */ bizCstList: InstlSncnBizCstSaveRequestDto[]; + /** 체크리스트 검토결과 목록 */ cklstResultList: InstlSncnCklstResultSaveDto[]; + /** 체크리스트 검토결과 맵 (rvwStpComCd -> 목록) */ + cklstResultMap?: Record; + /** 필수첨부파일 목록 */ esntlAtchFileList: InstlSncnEsntlAtchFileSaveDto[]; + /** 검토의견 목록 */ + rvwOpnnList?: InstlSncnRvwOpnnSaveRequestDto[]; + /** 보완요구 목록 */ + splmntDmndList?: InstlSncnSplmntDmndSaveDto[]; } export type ReviewStepCode = "LOCAL" | "ENV" | "KECO"; diff --git a/apps/as/src/pages/approval-line-management/ui/elapr-tmplt-form-dialog.tsx b/apps/as/src/pages/approval-line-management/ui/elapr-tmplt-form-dialog.tsx index 3e7f36de1..8aea03c7e 100644 --- a/apps/as/src/pages/approval-line-management/ui/elapr-tmplt-form-dialog.tsx +++ b/apps/as/src/pages/approval-line-management/ui/elapr-tmplt-form-dialog.tsx @@ -92,7 +92,7 @@ function ElaprTmpltFormDialog({
setValue("docSeComCd", v)} placeholder="문서구분 선택" diff --git a/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-form-dialog.tsx b/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-form-dialog.tsx index 0f1faf8a1..16d6fed1c 100644 --- a/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-form-dialog.tsx +++ b/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-form-dialog.tsx @@ -94,7 +94,7 @@ function EsntlAtchFileFormDialog({
setValue("docSeComCd", v, { shouldValidate: true })} placeholder="문서구분 선택" diff --git a/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-list.tsx b/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-list.tsx index 661b3a822..62a728073 100644 --- a/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-list.tsx +++ b/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-list.tsx @@ -36,7 +36,7 @@ function EsntlAtchFileList({ onPaginationChange, }: EsntlAtchFileListProps) { const { confirm } = useConfirm(); - const { data: docSeCodes } = useCodeList("ASDOCSECD"); + const { data: docSeCodes } = useCodeList("ASDOCSE"); const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null); const [editingRow, setEditingRow] = useState(null); diff --git a/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-mng-page.tsx b/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-mng-page.tsx index 425169983..3e5dbe9bb 100644 --- a/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-mng-page.tsx +++ b/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-mng-page.tsx @@ -32,7 +32,7 @@ const DEFAULT_SEARCH_PARAMS = { }; function EsntlAtchFileMngPage() { - const { data: docSeCodes, isPending: isDocSeLoading } = useCodeList("ASDOCSECD"); + const { data: docSeCodes, isPending: isDocSeLoading } = useCodeList("ASDOCSE"); const [docSeComCd, setDocSeComCd] = useState(ESNTL_ATCH_FILE_ALL_DOC_SE); const [pagination, setPagination] = useState({ diff --git a/apps/as/src/pages/instl-sncn/lib/button-config.ts b/apps/as/src/pages/instl-sncn/lib/button-config.ts index de262cf62..6b1a542b7 100644 --- a/apps/as/src/pages/instl-sncn/lib/button-config.ts +++ b/apps/as/src/pages/instl-sncn/lib/button-config.ts @@ -50,7 +50,7 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record(); + const deduped: CklstRvwRsltTreeDto[] = []; + + for (const row of filtered) { + const dedupeKey = `${row.taskSeComCd}_${row.cklstRvwSn}`; + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + deduped.push(row); + } + + return deduped; +} + +export function computeChecklistProgress( + rows: CklstRvwRsltTreeDto[], + results: ChecklistFormState["results"], +): ChecklistProgress { + const activeRows = rows.filter((row) => row.bchckUseYn === "Y"); + const total = activeRows.length; + const completed = activeRows.reduce((acc, row) => { + const key = buildChecklistRowKey(row); + const val = results[key] ?? row.rvwRsltAnmlyYn ?? ""; + return acc + (val ? 1 : 0); + }, 0); + const percent = total > 0 ? Math.round((completed / total) * 100) : 0; + return { total, completed, percent }; +} + +export function filterChecklistRowsByReviewStep( + rows: CklstRvwRsltTreeDto[], + reviewStep: ReviewStepCode, +): CklstRvwRsltTreeDto[] { + return rows.filter((row) => row.rvwStpComCd === reviewStep); +} + +export function computeReviewStepChecklistProgress( + rows: CklstRvwRsltTreeDto[], + results: ChecklistFormState["results"], + reviewStep: ReviewStepCode, +): ChecklistProgress { + const activeRows = getChecklistDisplayRows(rows).filter((row) => row.bchckUseYn === "Y"); + const total = activeRows.length; + const completed = activeRows.reduce((acc, row) => { + const key = buildChecklistRowKey(row, reviewStep); + const val = results[key]; + return acc + (val === "Y" || val === "N" ? 1 : 0); + }, 0); + const percent = total > 0 ? Math.round((completed / total) * 100) : 0; + return { total, completed, percent }; +} + export function buildChecklistFormState(rows: CklstRvwRsltTreeDto[]): ChecklistFormState { const results: ChecklistFormState["results"] = {}; const comments: ChecklistFormState["comments"] = {}; @@ -87,7 +146,57 @@ export function buildChecklistFormState(rows: CklstRvwRsltTreeDto[]): ChecklistF } if (row.picRvwCn) comments[key] = row.picRvwCn; }); - return { results, comments }; + return { + results, + comments, + progress: computeChecklistProgress(rows, results), + }; +} + +export function mergeChecklistFormStateFromServer( + prev: ChecklistFormState, + allRows: CklstRvwRsltTreeDto[], +): ChecklistFormState { + const results = { ...prev.results }; + const comments = { ...prev.comments }; + + for (const row of allRows) { + const step = row.rvwStpComCd as ReviewStepCode | undefined; + if (!step || !REVIEW_STEP_CODES.includes(step)) continue; + + const key = buildChecklistRowKey(row); + if ((row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N") && results[key] === undefined) { + results[key] = row.rvwRsltAnmlyYn; + } + if (row.picRvwCn && !comments[key]) { + comments[key] = row.picRvwCn; + } + } + + return { + results, + comments, + progress: computeChecklistProgress(getChecklistDisplayRows(allRows), results), + }; +} + +export function patchChecklistFormState( + prev: ChecklistFormState, + patch: Partial>, + allRows: CklstRvwRsltTreeDto[], + reviewStep?: ReviewStepCode, +): ChecklistFormState { + const results = patch.results ?? prev.results; + const comments = patch.comments ?? prev.comments; + const progress = reviewStep + ? computeReviewStepChecklistProgress(allRows, results, reviewStep) + : computeChecklistProgress(getChecklistDisplayRows(allRows), results); + + return { + results, + comments, + progress, + }; } export function toCklstSaveDto( @@ -95,20 +204,18 @@ export function toCklstSaveDto( formState: ChecklistFormState, reviewStep: ReviewStepCode, ): InstlSncnCklstResultSaveDto[] { - return rows + return getChecklistDisplayRows(rows) .filter((row) => row.bchckUseYn === "Y") .map((row) => { - const key = buildChecklistRowKey(row); - const selected = - formState.results[key] ?? - (row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N" ? row.rvwRsltAnmlyYn : null); + const key = buildChecklistRowKey(row, reviewStep); + const selected = formState.results[key] ?? null; return { cklstSn: row.cklstSn, cklstArtclSn: row.cklstArtclSn, cklstRvwSn: row.cklstRvwSn, rvwStpComCd: reviewStep, selected, - picRvwCn: formState.comments[key] ?? row.picRvwCn ?? null, + picRvwCn: formState.comments[key] ?? null, }; }); } @@ -169,6 +276,20 @@ export function buildSavePayload( }; } +export function buildTechReviewReplyPayload( + data: InstlSncnMngDetResponse, + checklistFormState: ChecklistFormState, + reviewStep: ReviewStepCode, + prgrsSttsComCd: string, + techReViewSaveCd: string, +): InstlSncnApplySaveRequestDto { + return { + ...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd), + techReViewSaveCd, + rvwStpCd: reviewStep, + }; +} + export function buildTechReviewPayload( det: InstlSncnMngResponseDto, cklstResultList: InstlSncnCklstResultSaveDto[], diff --git a/apps/as/src/pages/instl-sncn/lib/use-checklist-form-state.ts b/apps/as/src/pages/instl-sncn/lib/use-checklist-form-state.ts new file mode 100644 index 000000000..819f4ed82 --- /dev/null +++ b/apps/as/src/pages/instl-sncn/lib/use-checklist-form-state.ts @@ -0,0 +1,57 @@ +import { useCallback, useEffect, useState } from "react"; + +import type { CklstRvwRsltTreeDto, ReviewStepCode } from "@/entities/instl-sncn"; + +import { type ChecklistFormState, DEFAULT_CHECKLIST_PROGRESS } from "../model/view-types"; + +import { mergeChecklistFormStateFromServer, patchChecklistFormState } from "./checklist-utils"; + +const EMPTY_CKLST_RESULT_LIST: CklstRvwRsltTreeDto[] = []; + +function isSameChecklistFormState(a: ChecklistFormState, b: ChecklistFormState): boolean { + if ( + a.progress.total !== b.progress.total || + a.progress.completed !== b.progress.completed || + a.progress.percent !== b.progress.percent + ) { + return false; + } + + const aResultKeys = Object.keys(a.results); + if (aResultKeys.length !== Object.keys(b.results).length) return false; + if (!aResultKeys.every((key) => a.results[key] === b.results[key])) return false; + + const aCommentKeys = Object.keys(a.comments); + if (aCommentKeys.length !== Object.keys(b.comments).length) return false; + + return aCommentKeys.every((key) => a.comments[key] === b.comments[key]); +} + +function useChecklistFormState(cklstResultList: CklstRvwRsltTreeDto[] = EMPTY_CKLST_RESULT_LIST) { + const [formState, setFormState] = useState({ + results: {}, + comments: {}, + progress: DEFAULT_CHECKLIST_PROGRESS, + }); + + useEffect(() => { + setFormState((prev) => { + const next = mergeChecklistFormStateFromServer(prev, cklstResultList); + return isSameChecklistFormState(prev, next) ? prev : next; + }); + }, [cklstResultList]); + + const patchFormState = useCallback( + ( + patch: Partial>, + reviewStep?: ReviewStepCode, + ) => { + setFormState((prev) => patchChecklistFormState(prev, patch, cklstResultList, reviewStep)); + }, + [cklstResultList], + ); + + return { formState, setFormState, patchFormState }; +} + +export { EMPTY_CKLST_RESULT_LIST, useChecklistFormState }; diff --git a/apps/as/src/pages/instl-sncn/model/view-types.ts b/apps/as/src/pages/instl-sncn/model/view-types.ts index 0a555e8a4..a6fc0b08c 100644 --- a/apps/as/src/pages/instl-sncn/model/view-types.ts +++ b/apps/as/src/pages/instl-sncn/model/view-types.ts @@ -26,9 +26,22 @@ export type { export type ChecklistRowKey = string; +export interface ChecklistProgress { + total: number; + completed: number; + percent: number; +} + +export const DEFAULT_CHECKLIST_PROGRESS: ChecklistProgress = { + total: 0, + completed: 0, + percent: 0, +}; + export interface ChecklistFormState { results: Record; comments: Record; + progress: ChecklistProgress; } export type ChecklistStepMap = Record; @@ -48,17 +61,23 @@ export const TASK_CODE_LABELS: Record = { }; export const INSTL_PROGRESS_ORDER = [ + // ROLE_USER05 "SV", - "ST", - "LE", - "ER", - "EE", - "TE", - "TS", - "TR", - "TP", - "EP", + "SL", "TA", + + // ROLE_USER04 + "ST", + "EP", + + // ROLE_USER03 + "LE", + "TP", + + // ROLE_USER01 + "ER", + "TE", + "TB", ] as const; diff --git a/apps/as/src/pages/instl-sncn/ui/components/checklist-task-panel.tsx b/apps/as/src/pages/instl-sncn/ui/components/checklist-task-panel.tsx index 9ba74c0ec..344e1b7c3 100644 --- a/apps/as/src/pages/instl-sncn/ui/components/checklist-task-panel.tsx +++ b/apps/as/src/pages/instl-sncn/ui/components/checklist-task-panel.tsx @@ -12,12 +12,13 @@ import { Textarea, } from "@common/components"; -import { buildChecklistRowKey } from "../../lib/checklist-utils"; +import { buildChecklistRowKey, patchChecklistFormState } from "../../lib/checklist-utils"; import { type ChecklistFormState, ROLE_TO_REVIEW_STEP } from "../../model/view-types"; interface ChecklistTaskPanelProps { selectedReviewStep: ReviewStepCode; selectedRows: CklstRvwRsltTreeDto[]; + allRows: CklstRvwRsltTreeDto[]; formState: ChecklistFormState; setFormState: Dispatch>; userRoleCode?: string; @@ -26,6 +27,7 @@ interface ChecklistTaskPanelProps { function ChecklistTaskPanel({ selectedReviewStep, selectedRows, + allRows, formState, setFormState, userRoleCode, @@ -64,7 +66,7 @@ function ChecklistTaskPanel({ ) : ( selectedRows.map((row) => { - const key = buildChecklistRowKey(row); + const key = buildChecklistRowKey(row, selectedReviewStep); const isFieldVisible = row.bchckUseYn === "Y"; const isLocalReviewStep = selectedReviewStep === "LOCAL"; @@ -74,16 +76,11 @@ function ChecklistTaskPanel({ - {row.treeLevel > 1 ? "└ " : ""} {row.rvwCn} - {!isFieldVisible ? ( - <> - - - - ) : ( + {isFieldVisible && ( <> { if (!isStepEditable) return; - setFormState((prev) => ({ - ...prev, - results: { ...prev.results, [key]: "Y" }, - })); + setFormState((prev) => + patchChecklistFormState( + prev, + { results: { ...prev.results, [key]: "Y" } }, + allRows, + selectedReviewStep, + ), + ); }} className={cn(isStepEditable ? "cursor-pointer" : "cursor-not-allowed")} /> @@ -111,37 +112,43 @@ function ChecklistTaskPanel({ readOnly={!isStepEditable} onChange={() => { if (!isStepEditable) return; - setFormState((prev) => ({ - ...prev, - results: { ...prev.results, [key]: "N" }, - })); + setFormState((prev) => + patchChecklistFormState( + prev, + { results: { ...prev.results, [key]: "N" } }, + allRows, + selectedReviewStep, + ), + ); }} className={cn(isStepEditable ? "cursor-pointer" : "cursor-not-allowed")} /> )} - {!isLocalReviewStep ? ( - - {isFieldVisible && ( -