From e105f52fea89b0fae6e5b181a304bee41c4c7389 Mon Sep 17 00:00:00 2001 From: bicochan Date: Tue, 4 Aug 2026 17:22:21 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[as]=20feat:=20=EA=B8=B0=EB=B3=B8=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=20=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sewer-bsc-plan/[id]/page.tsx | 16 +- .../api/sewer-bsc-plan-service.ts | 7 +- .../model/sewer-bsc-plan-interface.ts | 2 +- .../ui/components/floating-action-buttons.tsx | 1 + .../ui/components/floating-action-buttons.tsx | 1 + apps/as/src/pages/sewer-bsc-plan/index.ts | 1 + .../pages/sewer-bsc-plan/lib/button-config.ts | 129 ++++ .../sewer-bsc-plan/lib/checklist-utils.ts | 178 ++++++ .../sewer-bsc-plan/lib/form-data-mapper.ts | 11 +- .../lib/review-opinion-utils.ts | 87 +++ .../sewer-bsc-plan/lib/review-step-utils.ts | 44 ++ .../sewer-bsc-plan/lib/save-payload-utils.ts | 178 ++++++ .../lib/use-checklist-form-state.ts | 61 ++ .../pages/sewer-bsc-plan/model/columns.tsx | 14 +- .../pages/sewer-bsc-plan/model/view-types.ts | 80 +++ .../ui/components/checklist-task-panel.tsx | 195 +++++++ .../ui/components/floating-action-buttons.tsx | 550 ++++++++++++++++++ .../ui/sections/attachments-section.tsx | 118 ++++ .../ui/sections/basic-info-section.tsx | 69 +++ .../ui/sections/biz-cst-matrix-section.tsx | 114 ++++ .../ui/sections/checklist-section.tsx | 137 +++++ .../ui/sections/contact-info-section.tsx | 58 ++ .../ui/sections/fnrs-cst-section.tsx | 122 ++++ .../ui/sections/outline-section.tsx | 71 +++ .../sections/tech-review-opinion-section.tsx | 359 ++++++++++++ .../ui/sections/tmplnt-section.tsx | 283 +++++++++ .../ui/sewer-bsc-plan-form-page.tsx | 13 +- .../sewer-bsc-plan/ui/sewer-bsc-plan-list.tsx | 2 +- .../ui/sewer-bsc-plan-view-page.tsx | 139 +++++ .../pbadms-sprt/ui/splmnt-dmnd-dialog.tsx | 12 +- 30 files changed, 3014 insertions(+), 38 deletions(-) create mode 100644 apps/as/src/pages/sewer-bsc-plan/lib/button-config.ts create mode 100644 apps/as/src/pages/sewer-bsc-plan/lib/checklist-utils.ts create mode 100644 apps/as/src/pages/sewer-bsc-plan/lib/review-opinion-utils.ts create mode 100644 apps/as/src/pages/sewer-bsc-plan/lib/review-step-utils.ts create mode 100644 apps/as/src/pages/sewer-bsc-plan/lib/save-payload-utils.ts create mode 100644 apps/as/src/pages/sewer-bsc-plan/lib/use-checklist-form-state.ts create mode 100644 apps/as/src/pages/sewer-bsc-plan/model/view-types.ts create mode 100644 apps/as/src/pages/sewer-bsc-plan/ui/components/checklist-task-panel.tsx create mode 100644 apps/as/src/pages/sewer-bsc-plan/ui/components/floating-action-buttons.tsx create mode 100644 apps/as/src/pages/sewer-bsc-plan/ui/sections/attachments-section.tsx create mode 100644 apps/as/src/pages/sewer-bsc-plan/ui/sections/basic-info-section.tsx create mode 100644 apps/as/src/pages/sewer-bsc-plan/ui/sections/biz-cst-matrix-section.tsx create mode 100644 apps/as/src/pages/sewer-bsc-plan/ui/sections/checklist-section.tsx create mode 100644 apps/as/src/pages/sewer-bsc-plan/ui/sections/contact-info-section.tsx create mode 100644 apps/as/src/pages/sewer-bsc-plan/ui/sections/fnrs-cst-section.tsx create mode 100644 apps/as/src/pages/sewer-bsc-plan/ui/sections/outline-section.tsx create mode 100644 apps/as/src/pages/sewer-bsc-plan/ui/sections/tech-review-opinion-section.tsx create mode 100644 apps/as/src/pages/sewer-bsc-plan/ui/sections/tmplnt-section.tsx create mode 100644 apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-view-page.tsx diff --git a/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/[id]/page.tsx b/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/[id]/page.tsx index 17894197e..1b864a7c0 100644 --- a/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/[id]/page.tsx +++ b/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/[id]/page.tsx @@ -1,17 +1,13 @@ -import Link from "next/link"; +import { SewerBscPlanViewPage } from "@/pages/sewer-bsc-plan"; interface PageProps { params: Promise<{ id: string }>; + searchParams: Promise<{ chgCycl?: string }>; } -export default async function Page({ params }: PageProps) { +export default async function Page({ params, searchParams }: PageProps) { const { id } = await params; - return ( -
- 하수도기본계획 상세({id})는 후속 구현 예정입니다.{" "} - - 수정 화면으로 이동 - -
- ); + const { chgCycl } = await searchParams; + const propChgCycl = chgCycl ? Number(chgCycl) : undefined; + return ; } diff --git a/apps/as/src/entities/sewer-bsc-plan/api/sewer-bsc-plan-service.ts b/apps/as/src/entities/sewer-bsc-plan/api/sewer-bsc-plan-service.ts index 013b1d384..49c770d85 100644 --- a/apps/as/src/entities/sewer-bsc-plan/api/sewer-bsc-plan-service.ts +++ b/apps/as/src/entities/sewer-bsc-plan/api/sewer-bsc-plan-service.ts @@ -75,8 +75,11 @@ async function submitReqSupplement( id: string, chgCycl: number, body: SewerBscPlanApplySaveRequestDto, -): Promise { - const response = await client.post(`${BASE}/${id}/${chgCycl}/reqsupplement`, body); +): Promise { + const response = await client.post( + `${BASE}/${id}/${chgCycl}/reqsupplement`, + body, + ); return response.data; } diff --git a/apps/as/src/entities/sewer-bsc-plan/model/sewer-bsc-plan-interface.ts b/apps/as/src/entities/sewer-bsc-plan/model/sewer-bsc-plan-interface.ts index cc5f533d8..6c7b9360c 100644 --- a/apps/as/src/entities/sewer-bsc-plan/model/sewer-bsc-plan-interface.ts +++ b/apps/as/src/entities/sewer-bsc-plan/model/sewer-bsc-plan-interface.ts @@ -346,6 +346,6 @@ export interface SewerBscPlanService { id: string, chgCycl: number, body: SewerBscPlanApplySaveRequestDto, - ): Promise; + ): Promise; techReview(id: string, body: SewerBscPlanApplySaveRequestDto): Promise; } diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx index b4461ab1b..d776aed53 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx @@ -498,6 +498,7 @@ function FloatingActionButtons({ /> setActiveDialog(null)} isSubmitting={isProcessing} diff --git a/apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx b/apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx index e3c0d8119..a7610e355 100644 --- a/apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx +++ b/apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx @@ -511,6 +511,7 @@ function FloatingActionButtons({ /> setActiveDialog(null)} isSubmitting={isProcessing} diff --git a/apps/as/src/pages/sewer-bsc-plan/index.ts b/apps/as/src/pages/sewer-bsc-plan/index.ts index 39f824683..c77a4da99 100644 --- a/apps/as/src/pages/sewer-bsc-plan/index.ts +++ b/apps/as/src/pages/sewer-bsc-plan/index.ts @@ -2,3 +2,4 @@ export { SewerBscPlanEditPage } from "./ui/sewer-bsc-plan-edit-page"; export { SewerBscPlanFormPage } from "./ui/sewer-bsc-plan-form-page"; export { SewerBscPlanListPage } from "./ui/sewer-bsc-plan-list-page"; export { SewerBscPlanRegistPage } from "./ui/sewer-bsc-plan-regist-page"; +export { SewerBscPlanViewPage } from "./ui/sewer-bsc-plan-view-page"; diff --git a/apps/as/src/pages/sewer-bsc-plan/lib/button-config.ts b/apps/as/src/pages/sewer-bsc-plan/lib/button-config.ts new file mode 100644 index 000000000..062dcf7e1 --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/lib/button-config.ts @@ -0,0 +1,129 @@ +export const SEWER_BSC_PLAN_VIEW_BUTTON_ACTION = { + strg: "strg", + aply: "aply", + del: "del", + rvwRply: "rvwRply", + rqstTechRvw: "rqstTechRvw", + dsgntRvwr: "dsgntRvwr", + rvwStrg: "rvwStrg", + rvwCmptn: "rvwCmptn", + dmndSplmnt: "dmndSplmnt", + atrzAprv: "atrzAprv", + atrzRply: "atrzRply", +} as const; + +export type SewerBscPlanViewButtonAction = + (typeof SEWER_BSC_PLAN_VIEW_BUTTON_ACTION)[keyof typeof SEWER_BSC_PLAN_VIEW_BUTTON_ACTION]; + +const ROLE_STATUS_BUTTON_ACTIONS: Record> = { + ROLE_USER05: { + "": [ + SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.strg, + SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.aply, + SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.del, + ], + SV: [ + SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.strg, + SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.aply, + SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.del, + ], + }, + ROLE_USER03: { + LE: [SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwRply, SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rqstTechRvw], + TP: [SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwRply], + }, +}; + +const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE"] as const; + +const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record< + string, + Record +> = { + ER: { + ATRZ01: [SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.dsgntRvwr], + }, + TE: { + ATRZ02: [ + SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwStrg, + SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwCmptn, + SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.dmndSplmnt, + ], + ATRZ03: [SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.atrzAprv], + ATRZ99: [SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.atrzRply], + }, +}; + +function resolveButtonConfigs( + actions: SewerBscPlanViewButtonAction[], +): SewerBscPlanViewButtonAction[] { + return actions.map((action) => SEWER_BSC_PLAN_VIEW_BUTTON_ACTION[action]); +} + +interface GetButtonConfigsOptions { + hasPendingApprovalRequest?: boolean; +} + +function resolveRoleUser01AtrzButtonActions( + prgrsSttsComCd: string, + atrzSeComCdList: string[], + options?: GetButtonConfigsOptions, +): SewerBscPlanViewButtonAction[] { + if (prgrsSttsComCd === "TE" && options?.hasPendingApprovalRequest === false) { + return []; + } + + const atrzStatusMap = ROLE_USER01_ATRZ_STATUS_ACTIONS[prgrsSttsComCd]; + if (!atrzStatusMap || atrzSeComCdList.length === 0) return []; + + const seen = new Set(); + const actions: SewerBscPlanViewButtonAction[] = []; + + for (const atrzSeComCd of atrzSeComCdList) { + const mappedActions = atrzStatusMap[atrzSeComCd]; + if (!mappedActions) continue; + + for (const action of mappedActions) { + if (seen.has(action)) continue; + seen.add(action); + actions.push(action); + } + } + + return actions; +} + +export function needsRoleUser01AtrzResolve( + dtlInstCd: string | undefined, + prgrsSttsComCd: string, +): boolean { + return ( + dtlInstCd === "ROLE_USER01" && + ROLE_USER01_ATRZ_STATUS_CODES.includes( + prgrsSttsComCd as (typeof ROLE_USER01_ATRZ_STATUS_CODES)[number], + ) + ); +} + +export function getButtonConfigs( + dtlInstCd: string | undefined, + prgrsSttsComCd: string, + atrzSeComCdList?: string[], + options?: GetButtonConfigsOptions, +): SewerBscPlanViewButtonAction[] { + if (!dtlInstCd) return []; + + if (needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd)) { + return resolveButtonConfigs( + resolveRoleUser01AtrzButtonActions(prgrsSttsComCd, atrzSeComCdList ?? [], options), + ); + } + + const statusMap = ROLE_STATUS_BUTTON_ACTIONS[dtlInstCd]; + if (!statusMap) return []; + + const actions = statusMap[prgrsSttsComCd]; + if (!actions) return []; + + return resolveButtonConfigs(actions); +} diff --git a/apps/as/src/pages/sewer-bsc-plan/lib/checklist-utils.ts b/apps/as/src/pages/sewer-bsc-plan/lib/checklist-utils.ts new file mode 100644 index 000000000..89e82a3d8 --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/lib/checklist-utils.ts @@ -0,0 +1,178 @@ +import type { CklstResultItem, CklstRvwRsltTreeDto } from "@/entities/sewer-bsc-plan"; + +import { + type ChecklistFormState, + type ChecklistProgress, + type ChecklistRowKey, + type ChecklistStepMap, + REVIEW_STEP_CODES, + type ReviewStepCode, +} from "../model/view-types"; + +export function buildChecklistRowKey( + row: CklstRvwRsltTreeDto, + reviewStep?: ReviewStepCode, +): ChecklistRowKey { + const step = reviewStep ?? (row.rvwStpComCd as ReviewStepCode) ?? "LOCAL"; + return `${row.taskSeComCd}_${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}_${step}`; +} + +export function groupByReviewStep(rows: CklstRvwRsltTreeDto[]): ChecklistStepMap { + const base: ChecklistStepMap = { LOCAL: [], ENV: [], KECO: [] }; + rows.forEach((row) => { + const step = (row.rvwStpComCd ?? "LOCAL") as ReviewStepCode; + if (REVIEW_STEP_CODES.includes(step)) base[step].push(row); + }); + return base; +} + +export function extractTaskCodes(rows: CklstRvwRsltTreeDto[]): string[] { + return Array.from(new Set(rows.map((row) => row.taskSeComCd).filter(Boolean))) as string[]; +} + +export function filterChecklistRows( + rows: CklstRvwRsltTreeDto[], + taskCode: string | null, +): CklstRvwRsltTreeDto[] { + return rows.filter((row) => (taskCode ? row.taskSeComCd === taskCode : true)).slice(); +} + +export function getChecklistDisplayRows( + rows: CklstRvwRsltTreeDto[], + taskCode?: string | null, +): CklstRvwRsltTreeDto[] { + const filtered = filterChecklistRows(rows, taskCode ?? null); + const seen = new Set(); + 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) : 100; + return { total, completed, percent }; +} + +export function buildChecklistFormState(rows: CklstRvwRsltTreeDto[]): ChecklistFormState { + const results: ChecklistFormState["results"] = {}; + const comments: ChecklistFormState["comments"] = {}; + rows.forEach((row) => { + const key = buildChecklistRowKey(row); + if (row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N") { + results[key] = row.rvwRsltAnmlyYn; + } + if (row.picRvwCn) comments[key] = row.picRvwCn; + }); + return { + results, + comments, + progress: computeChecklistProgress(rows, results), + }; +} + +export function mergeChecklistFormStateFromServer( + prev: ChecklistFormState, + allRows: CklstRvwRsltTreeDto[], +): 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( + rows: CklstRvwRsltTreeDto[], + formState: ChecklistFormState, + reviewStep: ReviewStepCode, +): CklstResultItem[] { + return getChecklistDisplayRows(rows) + .filter((row) => row.bchckUseYn === "Y") + .map((row) => { + const key = buildChecklistRowKey(row, reviewStep); + const selected = formState.results[key] ?? null; + return { + cklstSn: row.cklstSn, + cklstArtclSn: row.cklstArtclSn, + cklstRvwSn: row.cklstRvwSn, + rvwStpComCd: reviewStep, + selected: selected ?? undefined, + picRvwCn: formState.comments[key] ?? null, + }; + }); +} diff --git a/apps/as/src/pages/sewer-bsc-plan/lib/form-data-mapper.ts b/apps/as/src/pages/sewer-bsc-plan/lib/form-data-mapper.ts index 7a6b1f0ac..25a39aab0 100644 --- a/apps/as/src/pages/sewer-bsc-plan/lib/form-data-mapper.ts +++ b/apps/as/src/pages/sewer-bsc-plan/lib/form-data-mapper.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ import type { IUserInfo } from "@/shared/store"; import type { SewerBscPlanApplySaveRequestDto, @@ -261,7 +262,6 @@ export function buildApplyPayload( bizCstList: data.bizCstList, fnrsCstList: data.fnrsCstList.map((item) => recalcFnrsCstSumAmt(item)), sewerBscPlanTmplntList: data.sewerBscPlanTmplntList.map( - // eslint-disable-next-line @typescript-eslint/no-unused-vars ({ atchFileNm, swtrmtFcltNm, ...item }) => ({ swtrmtFcltCd: item.swtrmtFcltCd, atchFileGroupId: item.atchFileGroupId, @@ -274,8 +274,13 @@ export function buildApplyPayload( })), esntlAtchFileList: data.esntlAtchFileList, splmntDmndList: data.splmntDmndList.map( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - ({ splmntAtchFileNm, splmntAtchFileAtflId, splmntDmndAtchFileNm, ...item }) => ({ + ({ + splmntArtclCdNm, + splmntAtchFileNm, + splmntAtchFileAtflId, + splmntDmndAtchFileNm, + ...item + }) => ({ ...item, ...(prgrsSttsComCd === "TE" ? { cmptnYn: "Y" } : null), }), diff --git a/apps/as/src/pages/sewer-bsc-plan/lib/review-opinion-utils.ts b/apps/as/src/pages/sewer-bsc-plan/lib/review-opinion-utils.ts new file mode 100644 index 000000000..91b11b67c --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/lib/review-opinion-utils.ts @@ -0,0 +1,87 @@ +import type { + SewerBscPlanDetResponseDto, + SewerBscPlanRvwOpnnItem, + SewerBscPlanRvwOpnnResponseDto, +} from "@/entities/sewer-bsc-plan"; + +export interface ReviewOpinionFormData { + rvwRsltCn: string; + rvwRsltAnmlyYn: "Y" | "N" | ""; + atchFileGroupId: string; + opnrSeComCd: string; + rvwOpnnSn?: number; +} + +export interface ReviewOpinionGetFormDataResult { + formData: ReviewOpinionFormData; + rvwList: SewerBscPlanRvwOpnnResponseDto[]; +} + +function hasReviewOpinionContent(formData: ReviewOpinionFormData) { + return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId); +} + +function mapServerItemToSaveDto(item: SewerBscPlanRvwOpnnResponseDto): SewerBscPlanRvwOpnnItem | null { + if (item.rvwOpnnSn == null) return null; + + return { + rvwOpnnSn: item.rvwOpnnSn, + opnrSeComCd: item.opnrSeComCd, + opnrNm: item.opnrNm, + rvwRsltCn: item.rvwRsltCn, + rvwRsltAnmlyYn: item.rvwRsltAnmlyYn || undefined, + atchFileGroupId: item.atchFileGroupId || undefined, + }; +} + +function mapFormDataToSaveDto( + formData: ReviewOpinionFormData, + opnrNm?: string, +): SewerBscPlanRvwOpnnItem { + const base: SewerBscPlanRvwOpnnItem = { + opnrSeComCd: formData.opnrSeComCd, + opnrNm: opnrNm ?? "", + rvwRsltCn: formData.rvwRsltCn, + rvwRsltAnmlyYn: formData.rvwRsltAnmlyYn || undefined, + atchFileGroupId: formData.atchFileGroupId || undefined, + }; + + if (formData.rvwOpnnSn != null) { + return { ...base, rvwOpnnSn: formData.rvwOpnnSn }; + } + + return base; +} + +export function toRvwOpnnSaveDto( + data: SewerBscPlanDetResponseDto, + result: ReviewOpinionGetFormDataResult | null | undefined, + opnrNm?: string, +): SewerBscPlanRvwOpnnItem[] | undefined { + if (!result || !data.detDto) return undefined; + + const { formData, rvwList } = result; + + const list: SewerBscPlanRvwOpnnItem[] = rvwList + .map((item) => mapServerItemToSaveDto(item)) + .filter((item): item is SewerBscPlanRvwOpnnItem => item != null); + + if (!hasReviewOpinionContent(formData)) { + return list.length > 0 ? list : undefined; + } + + const formDto = mapFormDataToSaveDto(formData, opnrNm); + + if (formData.rvwOpnnSn != null) { + const existingIndex = list.findIndex((item) => item.rvwOpnnSn === formData.rvwOpnnSn); + if (existingIndex >= 0) { + list[existingIndex] = formDto; + } else { + list.push(formDto); + } + } else { + list.push(formDto); + } + + return list.length > 0 ? list : undefined; +} diff --git a/apps/as/src/pages/sewer-bsc-plan/lib/review-step-utils.ts b/apps/as/src/pages/sewer-bsc-plan/lib/review-step-utils.ts new file mode 100644 index 000000000..bce365857 --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/lib/review-step-utils.ts @@ -0,0 +1,44 @@ +import { + PBADMS_PROGRESS_ORDER, + REVIEW_STEP_CODES, + REVIEW_STEP_ROLE_RANK, + type ReviewStepCode, + ROLE_TO_REVIEW_STEP, +} from "../model/view-types"; + +export function getProgressStepCount(prgrsSttsComCd?: string): number { + const idx = PBADMS_PROGRESS_ORDER.indexOf( + prgrsSttsComCd as (typeof PBADMS_PROGRESS_ORDER)[number], + ); + if (idx <= PBADMS_PROGRESS_ORDER.indexOf("SV")) return 1; + if (idx <= PBADMS_PROGRESS_ORDER.indexOf("LE")) return 2; + return 3; +} + +export function getRoleStepCount(dtlInstCd?: string): number { + const mapped = dtlInstCd ? ROLE_TO_REVIEW_STEP[dtlInstCd] : undefined; + return mapped ? REVIEW_STEP_ROLE_RANK[mapped] : 0; +} + +export function getVisibleReviewSteps( + prgrsSttsComCd: string, + userRoleCode?: string, +): ReviewStepCode[] { + const count = Math.min(getProgressStepCount(prgrsSttsComCd), getRoleStepCount(userRoleCode)); + return REVIEW_STEP_CODES.slice(0, count); +} + +export function getEditableReviewStep( + dtlInstCd?: string, + prgrsSttsComCd?: string, +): ReviewStepCode | undefined { + if (dtlInstCd === "ROLE_USER03" && (prgrsSttsComCd === "LE" || prgrsSttsComCd === "TP")) { + return "ENV"; + } + + if (dtlInstCd === "ROLE_USER01" && prgrsSttsComCd === "TE") { + return "KECO"; + } + + return undefined; +} diff --git a/apps/as/src/pages/sewer-bsc-plan/lib/save-payload-utils.ts b/apps/as/src/pages/sewer-bsc-plan/lib/save-payload-utils.ts new file mode 100644 index 000000000..e2fc31f0a --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/lib/save-payload-utils.ts @@ -0,0 +1,178 @@ +import type { + EsntlAtchFileItem, + EsntlAtchFileMngTreeResponseDto, + SewerBscPlanApplySaveRequestDto, + SewerBscPlanDetResponseDto, + SewerBscPlanSplmntDmndItem, +} from "@/entities/sewer-bsc-plan"; +import type { SplmntDmndDialogConfirmPayload } from "@/widgets/pbadms-sprt"; + +import type { ChecklistFormState, ReviewStepCode } from "../model/view-types"; + +import { toCklstSaveDto } from "./checklist-utils"; +import { recalcFnrsCstSumAmt } from "./fnrs-cst-form-utils"; +import { type ReviewOpinionGetFormDataResult, toRvwOpnnSaveDto } from "./review-opinion-utils"; + +export function toEsntlAtchFileSaveDto( + esntlFileList: EsntlAtchFileMngTreeResponseDto[], +): EsntlAtchFileItem[] { + const esntlMap = new Map(); + + for (const item of esntlFileList) { + if (!item.esntlAtchFileId || !item.atflCd) continue; + esntlMap.set(item.esntlAtchFileId, item.atflCd); + } + + return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({ + esntlAtchFileId, + atchFileGroupId, + })); +} + +export function buildSavePayload( + data: SewerBscPlanDetResponseDto, + checklistFormState: ChecklistFormState, + reviewStep: ReviewStepCode, + prgrsSttsComCd: string, +): SewerBscPlanApplySaveRequestDto { + const detDto = data.detDto; + const dtl = data.activeDtlDto; + if (!detDto) { + throw new Error("상세 정보(detDto)가 없습니다."); + } + + const cklstResultRows = data.cklstResultRows ?? []; + const esntlFileList = data.esntlFileList ?? []; + + return { + sewerBscPlan: { + sewerBscPlanDocNo: detDto.sewerBscPlanDocNo, + admdstCd: detDto.admdstCd, + envoSeComCd: detDto.envoSeComCd, + sewerBscPlanSeComCd: detDto.sewerBscPlanSeComCd, + sewerBscPlanNm: detDto.sewerBscPlanNm, + taskSeComCd: detDto.taskSeComCd, + prgrsSttsComCd, + }, + sewerBscPlanDtl: { + sewerBscPlanDocNo: detDto.sewerBscPlanDocNo, + chgCycl: dtl?.chgCycl ?? detDto.chgCycl, + aplcntNm: dtl?.aplcntNm, + aplcntMblTelno: dtl?.aplcntMblTelno, + aplcntOgdpNm: dtl?.aplcntOgdpNm, + aplcntEmlAddr: dtl?.aplcntEmlAddr, + dsgnrNm: dtl?.dsgnrNm, + dsgnrTelno: dtl?.dsgnrTelno, + dsgnrOgdpNm: dtl?.dsgnrOgdpNm, + dsgnrEmlAddr: dtl?.dsgnrEmlAddr, + planYr: dtl?.planYr, + crtrYr: dtl?.crtrYr, + bizOtlnCn: dtl?.bizOtlnCn, + frlvlGoalYr: dtl?.frlvlGoalYr, + sclvlGoalYr: dtl?.sclvlGoalYr, + thlvlGoalYr: dtl?.thlvlGoalYr, + folvlGoalYr: dtl?.folvlGoalYr, + atchFileGroupId: dtl?.atchFileGroupId, + }, + sewerBscPlanOtlnList: (data.otlnList ?? []).map((item) => ({ + planStpNo: item.planStpNo, + sewerPlanPpltnCnt: item.sewerPlanPpltnCnt, + sewerPrcsPpltnCnt: item.sewerPrcsPpltnCnt, + sewerPsplyRt: item.sewerPsplyRt, + })), + bizCstList: (data.bizCstList ?? []).map((item) => ({ + planStpNo: item.planStpNo, + acntgSeCd: item.acntgSeCd, + cstrnAmt: item.cstrnAmt, + })), + fnrsCstList: (data.fnrsCstList ?? []).map((item) => + recalcFnrsCstSumAmt({ + planStpNo: item.planStpNo ?? 1, + gvexpAmt: item.gvexpAmt ?? 0, + lcexpAmt: item.lcexpAmt ?? 0, + orgprBdchgAmt: item.orgprBdchgAmt ?? 0, + etcAmt: item.etcAmt ?? 0, + sumAmt: item.sumAmt ?? 0, + }), + ), + sewerBscPlanTmplntList: (data.tmplntList ?? []).map((item) => ({ + swtrmtFcltCd: item.swtrmtFcltCd, + atchFileGroupId: item.atchFileGroupId, + sewerBscPlanTmplntDtlList: (item.sewerBscPlanTmplntDtlList ?? []).map((dtlItem) => ({ + planStpNo: dtlItem.planStpNo, + swtrmtFcltCd: dtlItem.swtrmtFcltCd ?? item.swtrmtFcltCd, + prcsZoneArea: dtlItem.prcsZoneArea, + swtrmtPpltnCnt: dtlItem.swtrmtPpltnCnt, + planSwqty: dtlItem.planSwqty, + fcltCpct: dtlItem.fcltCpct, + sprsysDrtwPplLen: dtlItem.sprsysDrtwPplLen, + sprsysExlnPplLen: dtlItem.sprsysExlnPplLen, + cbsysPplLen: dtlItem.cbsysPplLen, + icptgPplLen: dtlItem.icptgPplLen, + drgCnt: dtlItem.drgCnt, + drtwRlyPumpCnt: dtlItem.drtwRlyPumpCnt, + drtwPumpCnt: dtlItem.drtwPumpCnt, + exlnPumpCnt: dtlItem.exlnPumpCnt, + })), + })), + cklstResultList: toCklstSaveDto(cklstResultRows, checklistFormState, reviewStep), + esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList), + }; +} + +export function buildTechReviewReplyPayload( + data: SewerBscPlanDetResponseDto, + checklistFormState: ChecklistFormState, + reviewStep: ReviewStepCode, + prgrsSttsComCd: string, + techReViewSaveCd: string, + reviewOpinionFormData?: ReviewOpinionGetFormDataResult | null, + opnrNm?: string, +): SewerBscPlanApplySaveRequestDto { + return { + ...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd), + techReViewSaveCd, + rvwStpCd: reviewStep, + rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm), + }; +} + +export function buildSupplementReqPayload( + data: SewerBscPlanDetResponseDto, + payload: SplmntDmndDialogConfirmPayload, + checklistFormState: ChecklistFormState, +): SewerBscPlanApplySaveRequestDto { + const existingItems: SewerBscPlanSplmntDmndItem[] = (data.splmntDmndList ?? []).map((item) => + toSplmntDmndSaveItem(item), + ); + + const nextSn = existingItems.reduce((max, item) => Math.max(max, item.splmntDmndSn ?? 0), 0) + 1; + + const newItems: SewerBscPlanSplmntDmndItem[] = payload.items.map((item, index) => ({ + splmntDmndSn: nextSn + index, + splmntArtclComCd: item.splmntArtclComCd, + splmntDmndCn: item.splmntDmndCn, + splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId, + cmptnYn: "N", + })); + + return { + ...buildSavePayload(data, checklistFormState, "LOCAL", "SL"), + splmntDmndList: [...existingItems, ...newItems], + }; +} + +export function toSplmntDmndSaveItem(item: SewerBscPlanSplmntDmndItem): SewerBscPlanSplmntDmndItem { + return { + splmntDmndSn: item.splmntDmndSn, + splmntArtclComCd: item.splmntArtclComCd, + splmntDmndCn: item.splmntDmndCn, + splmntDmndDt: item.splmntDmndDt, + splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId, + splmntCn: item.splmntCn, + splmntPicNm: item.splmntPicNm, + splmntCmptnDt: item.splmntCmptnDt, + splmntAtchFileGroupId: item.splmntAtchFileGroupId, + cmptnYn: item.cmptnYn, + }; +} diff --git a/apps/as/src/pages/sewer-bsc-plan/lib/use-checklist-form-state.ts b/apps/as/src/pages/sewer-bsc-plan/lib/use-checklist-form-state.ts new file mode 100644 index 000000000..2bfbb58f7 --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/lib/use-checklist-form-state.ts @@ -0,0 +1,61 @@ +import { useCallback, useEffect, useState } from "react"; + +import type { CklstRvwRsltTreeDto } from "@/entities/sewer-bsc-plan"; + +import { + type ChecklistFormState, + DEFAULT_CHECKLIST_PROGRESS, + type ReviewStepCode, +} 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/sewer-bsc-plan/model/columns.tsx b/apps/as/src/pages/sewer-bsc-plan/model/columns.tsx index 4a9b03a9a..dfe5aebd3 100644 --- a/apps/as/src/pages/sewer-bsc-plan/model/columns.tsx +++ b/apps/as/src/pages/sewer-bsc-plan/model/columns.tsx @@ -19,7 +19,7 @@ const MAIN_STEP_LABELS = { supplement: "보완요청", } as const; -const TECH_REVIEW_STATUS_CODES = new Set(["EE", "ER", "TE", "TS", "TR", "TP"]); +const TECH_REVIEW_STATUS_CODES = new Set(["ER", "TE", "TP"]); type BadgeState = "completed" | "current" | "pending" | "action"; @@ -42,16 +42,6 @@ function buildProgressBadges(prgrsSttsComCd: string, prgrsSttsCdNm: string): Pro ]; } - if (prgrsSttsComCd === "ST") { - return [ - { label: MAIN_STEP_LABELS.save, state: "completed" }, - { label: MAIN_STEP_LABELS.apply, state: "completed" }, - { label: MAIN_STEP_LABELS.envoReview, state: "action" }, - { label: MAIN_STEP_LABELS.techReview, state: "pending" }, - { label: MAIN_STEP_LABELS.reply, state: "pending" }, - ]; - } - if (prgrsSttsComCd === "LE") { return [ { label: MAIN_STEP_LABELS.save, state: "completed" }, @@ -91,7 +81,7 @@ function buildProgressBadges(prgrsSttsComCd: string, prgrsSttsCdNm: string): Pro ]; } - if (prgrsSttsComCd === "TA" || prgrsSttsComCd === "TB") { + if (prgrsSttsComCd === "TA") { return [ { label: MAIN_STEP_LABELS.save, state: "completed" }, { label: MAIN_STEP_LABELS.apply, state: "completed" }, diff --git a/apps/as/src/pages/sewer-bsc-plan/model/view-types.ts b/apps/as/src/pages/sewer-bsc-plan/model/view-types.ts new file mode 100644 index 000000000..61ea80632 --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/model/view-types.ts @@ -0,0 +1,80 @@ +import type { + AtflMngClientResponseDto, + CklstRvwRsltTreeDto, + EsntlAtchFileMngTreeResponseDto, + SewerBscPlanDetResponseDto, + SewerBscPlanResponseDto, + SewerBscPlanRvwOpnnResponseDto, +} from "@/entities/sewer-bsc-plan"; + +export type ReviewStepCode = "LOCAL" | "ENV" | "KECO"; + +export type { + AtflMngClientResponseDto, + CklstRvwRsltTreeDto, + EsntlAtchFileMngTreeResponseDto, + SewerBscPlanDetResponseDto, + SewerBscPlanResponseDto, + SewerBscPlanRvwOpnnResponseDto, +}; + +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; + +export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"]; + +export const REVIEW_STEP_LABELS: Record = { + LOCAL: "지자체", + ENV: "환경청", + KECO: "환경공단", +}; + +export const TASK_CODE_LABELS: Record = { + RE: "재이용수", + SP: "하수관로", + SF: "하수처리시설", + MP: "변경", + CP: "신규", +}; + +export const PBADMS_PROGRESS_ORDER = [ + "SV", + "SL", + "TA", + "EP", + "LE", + "TP", + "ER", + "TE", +] as const; + +export const ROLE_TO_REVIEW_STEP: Record = { + ROLE_USER05: "LOCAL", + ROLE_USER03: "ENV", + ROLE_USER01: "KECO", +}; + +export const REVIEW_STEP_ROLE_RANK: Record = { + LOCAL: 1, + ENV: 2, + KECO: 3, +}; diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/components/checklist-task-panel.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/components/checklist-task-panel.tsx new file mode 100644 index 000000000..f92bae975 --- /dev/null +++ b/apps/as/src/pages/sewer-bsc-plan/ui/components/checklist-task-panel.tsx @@ -0,0 +1,195 @@ +import type { Dispatch, SetStateAction } from "react"; + +import type { CklstRvwRsltTreeDto } from "@/entities/sewer-bsc-plan"; +import { + Button, + cn, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Textarea, +} from "@common/components"; + +import { buildChecklistRowKey, patchChecklistFormState } from "../../lib/checklist-utils"; +import type { ReviewStepCode } from "../../model/view-types"; +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; +} + +function ChecklistTaskPanel({ + selectedReviewStep, + selectedRows, + allRows, + formState, + setFormState, + userRoleCode, +}: ChecklistTaskPanelProps) { + const currentUserReviewStep = userRoleCode ? ROLE_TO_REVIEW_STEP[userRoleCode] : undefined; + const isStepEditable = currentUserReviewStep === selectedReviewStep; + + function handleSelectAll(value: "Y" | "N") { + if (!isStepEditable) return; + setFormState((prev) => { + const nextResults = { ...prev.results }; + selectedRows + .filter((row) => row.bchckUseYn === "Y") + .forEach((row) => { + nextResults[buildChecklistRowKey(row, selectedReviewStep)] = value; + }); + return patchChecklistFormState(prev, { results: nextResults }, allRows, selectedReviewStep); + }); + } + + return ( +
+ + + + + 검토항목 + + 주요 검토내용 + 검토 결과 + {selectedReviewStep !== "LOCAL" ? ( + + 기술검토 의견 + + ) : null} + + + + + + + + + + + + {selectedRows.length === 0 ? ( + + + 조회된 체크리스트 항목이 없습니다. + + + ) : ( + selectedRows.map((row) => { + const key = buildChecklistRowKey(row, selectedReviewStep); + const isFieldVisible = row.bchckUseYn === "Y"; + const isLocalReviewStep = selectedReviewStep === "LOCAL"; + + return ( + + {row.cklstArtclNm} + + {row.rvwCn} + + {isFieldVisible && ( + <> + + { + if (!isStepEditable) return; + setFormState((prev) => + patchChecklistFormState( + prev, + { results: { ...prev.results, [key]: "Y" } }, + allRows, + selectedReviewStep, + ), + ); + }} + className={cn(isStepEditable ? "cursor-pointer" : "cursor-not-allowed")} + /> + + + { + if (!isStepEditable) return; + setFormState((prev) => + patchChecklistFormState( + prev, + { results: { ...prev.results, [key]: "N" } }, + allRows, + selectedReviewStep, + ), + ); + }} + className={cn(isStepEditable ? "cursor-pointer" : "cursor-not-allowed")} + /> + + + )} + {!isLocalReviewStep && isFieldVisible && ( + +