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 && (
+
+
+ )}
+
+ );
+ })
+ )}
+
+
+
+ );
+}
+
+export { ChecklistTaskPanel };
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/components/floating-action-buttons.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/components/floating-action-buttons.tsx
new file mode 100644
index 000000000..d185180cc
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/components/floating-action-buttons.tsx
@@ -0,0 +1,550 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { useRouter } from "next/navigation";
+
+import { useConfirm } from "@frontend-opensource/use-react-hooks";
+import { type QueryClient, useQueryClient } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+import { ENDPOINT } from "@/shared/config";
+import { useAlertStore, useUserStore } from "@/shared/lib";
+import {
+ buildElaprPrgrsSttsListFromAplnRows,
+ ElaprApprovalDialog,
+ type ElaprDmndSaveRequestDto,
+ elaprDmndService,
+ type ElaprTmpltPickerConfirmPayload,
+ ElaprTmpltPickerDialog,
+ hasCurrentUserPendingApprovalRequest,
+ useElaprDocPrgrsByDocNo,
+} from "@/entities/elapr";
+import { instlSncnService } from "@/entities/instl-sncn";
+import {
+ type SewerBscPlanApplySaveRequestDto,
+ sewerBscPlanBaseKey,
+ type SewerBscPlanDetResponseDto,
+ sewerBscPlanService,
+} from "@/entities/sewer-bsc-plan";
+import { SplmntDmndDialog, type SplmntDmndDialogConfirmPayload } from "@/widgets/pbadms-sprt";
+import { Button } from "@common/components";
+
+import {
+ getButtonConfigs,
+ needsRoleUser01AtrzResolve,
+ SEWER_BSC_PLAN_VIEW_BUTTON_ACTION,
+ type SewerBscPlanViewButtonAction,
+} from "../../lib/button-config";
+import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
+import {
+ buildSavePayload,
+ buildSupplementReqPayload,
+ buildTechReviewReplyPayload,
+ toSplmntDmndSaveItem,
+} from "../../lib/save-payload-utils";
+import { type ChecklistFormState } from "../../model/view-types";
+import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
+
+interface FloatingActionButtonsProps {
+ data: SewerBscPlanDetResponseDto;
+ docSeComCd: string;
+ sewerBscPlanDocNo: string;
+ chgCycl: number;
+ prgrsSttsComCd: string;
+ dtlInstCd: string | undefined;
+ atrzSeComCdList: string[];
+ isRvwLoginUserLoading: boolean;
+ checklistFormState: ChecklistFormState;
+ techReviewOpinionRef?: React.RefObject;
+ opnrNm?: string;
+}
+
+type ActiveDialog = "elaprTmplt" | "dmndSplmnt" | "elaprApproval" | null;
+
+function FloatingActionButtons({
+ data,
+ docSeComCd,
+ sewerBscPlanDocNo,
+ chgCycl,
+ prgrsSttsComCd,
+ dtlInstCd,
+ atrzSeComCdList,
+ isRvwLoginUserLoading,
+ checklistFormState,
+ techReviewOpinionRef,
+ opnrNm,
+}: FloatingActionButtonsProps) {
+ const router = useRouter();
+ const { confirm } = useConfirm();
+ const queryClient = useQueryClient();
+ const userInfo = useUserStore((state) => state.userInfo);
+ const alert = useAlertStore((state) => state.setMessage);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [activeDialog, setActiveDialog] = useState(null);
+
+ const docNo = data.detDto?.sewerBscPlanDocNo ?? sewerBscPlanDocNo;
+ const isRegister = data.detDto?.rgtr === userInfo?.userUnqId;
+
+ const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd);
+ const needsTeApprovalCheck = needsAtrzResolve && prgrsSttsComCd === "TE";
+ const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } =
+ useElaprDocPrgrsByDocNo(needsTeApprovalCheck ? docNo : null);
+
+ const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest(
+ elaprDocPrgrsList,
+ userInfo?.userUnqId,
+ );
+
+ const actionButtons = useMemo(() => {
+ if (needsAtrzResolve && isRvwLoginUserLoading) return [];
+ if (needsTeApprovalCheck && isElaprDocPrgrsLoading) return [];
+ return getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList, {
+ hasPendingApprovalRequest: needsTeApprovalCheck ? hasPendingApprovalRequest : undefined,
+ });
+ }, [
+ atrzSeComCdList,
+ dtlInstCd,
+ hasPendingApprovalRequest,
+ isElaprDocPrgrsLoading,
+ isRvwLoginUserLoading,
+ needsAtrzResolve,
+ needsTeApprovalCheck,
+ prgrsSttsComCd,
+ ]);
+
+ const canEdit =
+ isRegister && actionButtons.some((button) => button === SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.strg);
+
+ function handleGoToList() {
+ router.push("/sewer-bsc-plan");
+ }
+
+ function handleEdit() {
+ router.push(`/sewer-bsc-plan/${sewerBscPlanDocNo}/edit?chgCycl=${chgCycl}`);
+ }
+
+ function shouldShowButton(action: SewerBscPlanViewButtonAction) {
+ return actionButtons.some((button) => button === action);
+ }
+
+ async function invalidateDetail() {
+ await queryClient.invalidateQueries({
+ queryKey: [...sewerBscPlanBaseKey, "findDetails", sewerBscPlanDocNo, chgCycl],
+ });
+ await queryClient.invalidateQueries({ queryKey: sewerBscPlanBaseKey });
+ }
+
+ async function executeAction({
+ action,
+ request,
+ successMessage,
+ }: {
+ action: SewerBscPlanViewButtonAction;
+ request?: unknown;
+ successMessage?: string;
+ }) {
+ const handler = buttonActionHandlers[action];
+ if (!handler) return;
+
+ setIsProcessing(true);
+ try {
+ await handler({
+ request,
+ data,
+ chgCycl,
+ router,
+ queryClient,
+ onSuccess: () => {
+ if (successMessage) alert(successMessage);
+ void invalidateDetail();
+ },
+ onError: (error) => {
+ let errorMessage = "";
+ if (error instanceof AxiosError) {
+ errorMessage = error.response?.data?.message ?? "";
+ } else {
+ errorMessage = error.message ?? "처리 중 오류가 발생했습니다.";
+ }
+ alert(errorMessage);
+ },
+ });
+ } finally {
+ setIsProcessing(false);
+ }
+ }
+
+ async function handleStrgClick() {
+ const payload = buildSavePayload(data, checklistFormState, "LOCAL", "SV");
+ await executeAction({
+ action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.strg,
+ request: payload,
+ successMessage: "저장되었습니다.",
+ });
+ }
+
+ async function handleAplyClick() {
+ const { percent } = computeReviewStepChecklistProgress(
+ data.cklstResultRows ?? [],
+ checklistFormState.results,
+ "LOCAL",
+ );
+ const payload = buildSavePayload(data, checklistFormState, "LOCAL", "LE");
+
+ if (percent !== 100) {
+ alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
+ return;
+ }
+
+ await executeAction({
+ action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.aply,
+ request: payload,
+ successMessage: "신청 완료되었습니다.",
+ });
+ }
+
+ async function handleDelClick() {
+ if (!(await confirm("삭제하시겠습니까?"))) return;
+ await executeAction({
+ action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.del,
+ successMessage: "삭제되었습니다.",
+ });
+ router.push("/sewer-bsc-plan");
+ }
+
+ function getReviewOpinionFormData() {
+ return techReviewOpinionRef?.current?.getFormData() ?? null;
+ }
+
+ async function handleRvwRplyClick() {
+ const { percent } = computeReviewStepChecklistProgress(
+ data.cklstResultRows ?? [],
+ checklistFormState.results,
+ "ENV",
+ );
+
+ if (percent !== 100) {
+ alert("모든 체크리스트 항목을 체크해야 검토결과 회신이 가능합니다.");
+ return;
+ }
+
+ const payload = buildTechReviewReplyPayload(
+ data,
+ checklistFormState,
+ "ENV",
+ "EP",
+ "CP",
+ getReviewOpinionFormData(),
+ opnrNm,
+ );
+
+ await executeAction({
+ action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwRply,
+ request: payload,
+ successMessage: "검토결과 회신이 완료되었습니다.",
+ });
+ techReviewOpinionRef?.current?.resetForm();
+ }
+
+ async function handleRqstTechRvwClick() {
+ const payload = buildSavePayload(data, checklistFormState, "LOCAL", "ER");
+ await executeAction({
+ action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rqstTechRvw,
+ request: payload,
+ successMessage: "기술검토 의뢰가 완료되었습니다.",
+ });
+ }
+
+ async function handleElaprTmpltConfirm({ aplnRows }: ElaprTmpltPickerConfirmPayload) {
+ const payload: ElaprDmndSaveRequestDto = {
+ elaprDocNo: docNo,
+ dmndTtl: data.detDto?.sewerBscPlanNm ?? "",
+ dmndCn: data.activeDtlDto?.bizOtlnCn ?? "",
+ elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
+ };
+ await executeAction({
+ action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.dsgntRvwr,
+ request: payload,
+ successMessage: "검토자가 지정되었습니다.",
+ });
+ await queryClient.invalidateQueries({
+ queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", docNo],
+ });
+ }
+
+ async function handleRvwStrgClick() {
+ const payload = buildTechReviewReplyPayload(
+ data,
+ checklistFormState,
+ "KECO",
+ "TE",
+ "SV",
+ getReviewOpinionFormData(),
+ opnrNm,
+ );
+
+ await executeAction({
+ action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwStrg,
+ request: payload,
+ successMessage: "저장되었습니다.",
+ });
+ techReviewOpinionRef?.current?.resetForm();
+ }
+
+ async function handleRvwCmptnClick() {
+ const { percent } = computeReviewStepChecklistProgress(
+ data.cklstResultRows ?? [],
+ checklistFormState.results,
+ "KECO",
+ );
+
+ if (percent !== 100) {
+ alert("모든 체크리스트 항목을 체크해야 검토완료가 가능합니다.");
+ return;
+ }
+
+ const payload = {
+ ...buildTechReviewReplyPayload(
+ data,
+ checklistFormState,
+ "KECO",
+ "TE",
+ "SV",
+ getReviewOpinionFormData(),
+ opnrNm,
+ ),
+ splmntDmndList: (data.splmntDmndList ?? []).map(toSplmntDmndSaveItem),
+ };
+
+ await executeAction({
+ action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwCmptn,
+ request: payload,
+ });
+ techReviewOpinionRef?.current?.resetForm();
+ setActiveDialog("elaprApproval");
+ }
+
+ function handleDmndSplmntClick() {
+ setActiveDialog("dmndSplmnt");
+ }
+
+ async function handleDmndSplmntConfirm(payload: SplmntDmndDialogConfirmPayload) {
+ setActiveDialog(null);
+ const requestPayload = buildSupplementReqPayload(data, payload, checklistFormState);
+ await executeAction({
+ action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.dmndSplmnt,
+ request: requestPayload,
+ successMessage: "보완요청이 처리되었습니다.",
+ });
+ }
+
+ return (
+ <>
+
+
+
+ {canEdit && (
+
+ )}
+ {shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.strg) && (
+
+ )}
+ {shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.aply) && (
+
+ )}
+ {shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.del) && (
+
+ )}
+ {shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwRply) && (
+
+ )}
+ {shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rqstTechRvw) && (
+
+ )}
+ {shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.dsgntRvwr) && (
+
+ )}
+ {shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwStrg) && (
+
+ )}
+ {shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwCmptn) && (
+
+ )}
+ {shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.dmndSplmnt) && (
+
+ )}
+ {shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.atrzAprv) && (
+
+ )}
+ {shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.atrzRply) && (
+
+ )}
+
+
+
+ setActiveDialog(null)}
+ docSeComCd={docSeComCd}
+ onConfirm={handleElaprTmpltConfirm}
+ />
+
+ setActiveDialog(null)}
+ isSubmitting={isProcessing}
+ onConfirm={handleDmndSplmntConfirm}
+ />
+
+ setActiveDialog(open ? "elaprApproval" : null)}
+ elaprDocNo={docNo}
+ currentUserUuid={userInfo?.userUnqId ?? ""}
+ onSuccess={() => {
+ void invalidateDetail();
+ }}
+ />
+ >
+ );
+}
+
+export { FloatingActionButtons };
+
+async function handleSaveApply(
+ context: ActionHandlerContext,
+): Promise {
+ try {
+ await sewerBscPlanService.saveApply(context.request);
+ context.onSuccess?.();
+ } catch (error) {
+ context.onError?.(error as Error);
+ }
+}
+
+async function handleDel(context: ActionHandlerContext): Promise {
+ try {
+ await sewerBscPlanService.delete(context.data.detDto?.sewerBscPlanDocNo ?? "");
+ context.onSuccess?.();
+ } catch (error) {
+ context.onError?.(error as Error);
+ }
+}
+
+async function handleTechReview(
+ context: ActionHandlerContext,
+): Promise {
+ try {
+ await sewerBscPlanService.techReview(
+ context.data.detDto?.sewerBscPlanDocNo ?? "",
+ context.request,
+ );
+ context.onSuccess?.();
+ } catch (error) {
+ context.onError?.(error as Error);
+ }
+}
+
+async function handleRequestTechReview(context: ActionHandlerContext): Promise {
+ try {
+ await instlSncnService.changePrgrsSttsComCd({
+ docNo: context.data.detDto?.sewerBscPlanDocNo ?? "",
+ docSeComCd: "SEWER",
+ prgrsSttsComCd: "ER",
+ });
+ context.onSuccess?.();
+ } catch (error) {
+ context.onError?.(error as Error);
+ }
+}
+
+async function handleDsgntRvwr(
+ context: ActionHandlerContext,
+): Promise {
+ try {
+ await elaprDmndService.save(context.request);
+ context.onSuccess?.();
+ } catch (error) {
+ context.onError?.(error as Error);
+ }
+}
+
+async function handleDmndSplmnt(
+ context: ActionHandlerContext,
+): Promise {
+ try {
+ const docNo = context.data.detDto?.sewerBscPlanDocNo;
+ if (!docNo) throw new Error("문서번호가 없습니다.");
+ const result = await sewerBscPlanService.submitReqSupplement(
+ docNo,
+ context.chgCycl,
+ context.request,
+ );
+ await context.queryClient.invalidateQueries({ queryKey: sewerBscPlanBaseKey });
+ context.router.replace(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}?chgCycl=${result.chgCycl}`);
+ context.onSuccess?.();
+ } catch (error) {
+ context.onError?.(error as Error);
+ }
+}
+
+async function handleAtrzAprv(context: ActionHandlerContext): Promise {
+ context.onSuccess?.();
+}
+
+async function handleAtrzRply(context: ActionHandlerContext): Promise {
+ context.onSuccess?.();
+}
+
+const buttonActionHandlers: Record = {
+ strg: handleSaveApply,
+ aply: handleSaveApply,
+ del: handleDel,
+ rvwRply: handleTechReview,
+ rqstTechRvw: handleRequestTechReview,
+ dsgntRvwr: handleDsgntRvwr,
+ rvwStrg: handleTechReview,
+ rvwCmptn: handleTechReview,
+ dmndSplmnt: handleDmndSplmnt,
+ atrzAprv: handleAtrzAprv,
+ atrzRply: handleAtrzRply,
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+interface ActionHandlerContext {
+ request: T;
+ data: SewerBscPlanDetResponseDto;
+ chgCycl: number;
+ router: ReturnType;
+ queryClient: QueryClient;
+ onSuccess?: () => void;
+ onError?: (error: Error) => void;
+}
+
+type ActionHandler = (context: ActionHandlerContext) => Promise;
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sections/attachments-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sections/attachments-section.tsx
new file mode 100644
index 000000000..b47c27580
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/sections/attachments-section.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { formatFileSize } from "@/shared/lib/file-utils";
+import { useDownloadAttachmentByAtflCd } from "@/entities/attach";
+import type {
+ AtflMngClientResponseDto,
+ EsntlAtchFileMngTreeResponseDto,
+} from "@/entities/sewer-bsc-plan";
+import { SectionCollapsible } from "@/widgets/pbadms-sprt";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
+
+interface AttachmentsSectionProps {
+ esntlFileList: EsntlAtchFileMngTreeResponseDto[];
+ etcFileList: AtflMngClientResponseDto[];
+}
+
+function AttachmentsSection({ esntlFileList, etcFileList }: AttachmentsSectionProps) {
+ const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
+
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export { AttachmentsSection };
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sections/basic-info-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sections/basic-info-section.tsx
new file mode 100644
index 000000000..3cfd3e4a9
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/sections/basic-info-section.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import type {
+ SewerBscPlanDtlResponseDto,
+ SewerBscPlanResponseDto,
+} from "@/entities/sewer-bsc-plan";
+import { SectionCollapsible } from "@/widgets/pbadms-sprt";
+import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
+
+interface BasicInfoSectionProps {
+ det: SewerBscPlanResponseDto;
+ dtl?: SewerBscPlanDtlResponseDto;
+}
+
+function BasicInfoSection({ det, dtl }: BasicInfoSectionProps) {
+ const sewerBscPlanSeLabel = [det.sewerBscPlanSeCdNm, det.taskSeCdNm].filter(Boolean).join(" ");
+ const localGovLabel = [det.sido, det.gugun].filter(Boolean).join(" ");
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ 기본계획명
+ {det.sewerBscPlanNm || "-"}
+
+
+ 계획년도
+ {dtl?.planYr || "-"}
+ 기준년도
+ {dtl?.crtrYr || "-"}
+
+
+ 1단계 목표연도
+ {dtl?.frlvlGoalYr || "-"}
+ 2단계 목표연도
+ {dtl?.sclvlGoalYr || "-"}
+
+
+ 3단계 목표연도
+ {dtl?.thlvlGoalYr || "-"}
+ 4단계 목표연도
+ {dtl?.folvlGoalYr || "-"}
+
+
+ 계획개요
+
+ {dtl?.bizOtlnCn || "-"}
+
+
+
+ 구분
+ {sewerBscPlanSeLabel || "-"}
+ 신청 지자체
+ {localGovLabel || "-"}
+
+
+
+
+ );
+}
+
+export { BasicInfoSection };
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sections/biz-cst-matrix-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sections/biz-cst-matrix-section.tsx
new file mode 100644
index 000000000..ae224238d
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/sections/biz-cst-matrix-section.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { useMemo } from "react";
+
+import { useAcntgSeMngTree } from "@/entities/acntg";
+import type {
+ SewerBscPlanBizCstResponseDto,
+ SewerBscPlanDtlResponseDto,
+} from "@/entities/sewer-bsc-plan";
+import { buildAcntgCostItems, fmtNumber, SectionCollapsible } from "@/widgets/pbadms-sprt";
+import {
+ Skeleton,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@common/components";
+
+import { getAmt } from "../../lib/biz-cst-matrix-utils";
+
+interface BizCstMatrixSectionProps {
+ bizCstList: SewerBscPlanBizCstResponseDto[];
+ dtl?: SewerBscPlanDtlResponseDto;
+}
+
+function BizCstMatrixSection({ bizCstList, dtl }: BizCstMatrixSectionProps) {
+ const { data: acntgTree = [] } = useAcntgSeMngTree();
+ const costItems = useMemo(() => buildAcntgCostItems(acntgTree), [acntgTree]);
+
+ const matrixList = useMemo(
+ () =>
+ bizCstList.map((item) => ({
+ planStpNo: item.planStpNo ?? 1,
+ acntgSeCd: item.acntgSeCd ?? "",
+ cstrnAmt: item.cstrnAmt ?? 0,
+ })),
+ [bizCstList],
+ );
+
+ const stepHeaders = [
+ { planStpNo: 1, label: "1단계", year: dtl?.frlvlGoalYr },
+ { planStpNo: 2, label: "2단계", year: dtl?.sclvlGoalYr },
+ { planStpNo: 3, label: "3단계", year: dtl?.thlvlGoalYr },
+ { planStpNo: 4, label: "4단계", year: dtl?.folvlGoalYr },
+ ];
+
+ if (costItems.length === 0) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
(단위: 백만원)
+
+
+
+
+
+
+
+
+
+
+ 구분
+ {stepHeaders.map((step) => (
+
+ {step.label}
+
+ ({step.year || "-"})
+
+ ))}
+
+
+
+ {costItems.map((item) => {
+ const paddingLeft = `${item.level * 1.6}rem`;
+ const showSubIcon = item.level >= 2;
+
+ return (
+
+
+
+ {showSubIcon && (
+ └
+ )}
+ {item.label}
+
+
+ {stepHeaders.map((step) => (
+
+ {fmtNumber(getAmt(matrixList, item.code, step.planStpNo))}
+
+ ))}
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+export { BizCstMatrixSection };
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sections/checklist-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sections/checklist-section.tsx
new file mode 100644
index 000000000..379bb806a
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/sections/checklist-section.tsx
@@ -0,0 +1,137 @@
+"use client";
+
+import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from "react";
+
+import type { CklstRvwRsltTreeDto } from "@/entities/sewer-bsc-plan";
+import { SectionCollapsible } from "@/widgets/pbadms-sprt";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
+
+import {
+ computeReviewStepChecklistProgress,
+ extractTaskCodes,
+ getChecklistDisplayRows,
+} from "../../lib/checklist-utils";
+import { getVisibleReviewSteps } from "../../lib/review-step-utils";
+import type { ReviewStepCode } from "../../model/view-types";
+import {
+ type ChecklistFormState,
+ REVIEW_STEP_LABELS,
+ ROLE_TO_REVIEW_STEP,
+ TASK_CODE_LABELS,
+} from "../../model/view-types";
+import { ChecklistTaskPanel } from "../components/checklist-task-panel";
+
+interface ChecklistSectionProps {
+ cklstResultList: CklstRvwRsltTreeDto[];
+ prgrsSttsComCd: string;
+ userRoleCode?: string;
+ formState: ChecklistFormState;
+ setFormState: Dispatch>;
+}
+
+function ChecklistSection({
+ cklstResultList,
+ prgrsSttsComCd,
+ userRoleCode,
+ formState,
+ setFormState,
+}: ChecklistSectionProps) {
+ const visibleReviewSteps = useMemo(
+ () => getVisibleReviewSteps(prgrsSttsComCd, userRoleCode),
+ [prgrsSttsComCd, userRoleCode],
+ );
+ const taskCodes = useMemo(() => extractTaskCodes(cklstResultList), [cklstResultList]);
+
+ const defaultReviewStep = ROLE_TO_REVIEW_STEP[userRoleCode ?? ""] ?? "LOCAL";
+ const [selectedReviewStep, setSelectedReviewStep] = useState(defaultReviewStep);
+ const [selectedTaskCode, setSelectedTaskCode] = useState(null);
+
+ useEffect(() => {
+ if (visibleReviewSteps.length === 0) return;
+ if (!visibleReviewSteps.includes(selectedReviewStep)) {
+ setSelectedReviewStep(visibleReviewSteps[visibleReviewSteps.length - 1]);
+ }
+ }, [selectedReviewStep, visibleReviewSteps]);
+
+ useEffect(() => {
+ if (taskCodes.length === 0) {
+ setSelectedTaskCode(null);
+ return;
+ }
+ if (!selectedTaskCode || !taskCodes.includes(selectedTaskCode)) {
+ setSelectedTaskCode(taskCodes[0]);
+ }
+ }, [taskCodes, selectedTaskCode]);
+
+ const progress = useMemo(
+ () =>
+ computeReviewStepChecklistProgress(cklstResultList, formState.results, selectedReviewStep),
+ [cklstResultList, formState.results, selectedReviewStep],
+ );
+
+ const { total, completed, percent } = progress;
+
+ return (
+
+
+ {visibleReviewSteps.length > 0 ? (
+
setSelectedReviewStep(value as ReviewStepCode)}
+ className="w-full"
+ >
+
+ {visibleReviewSteps.map((stepCode) => (
+
+ {REVIEW_STEP_LABELS[stepCode]}
+
+ ))}
+
+
+ ) : null}
+
+ {taskCodes.length > 0 ? (
+
+
+
+ {taskCodes.map((code) => (
+
+ {TASK_CODE_LABELS[code] ?? code}
+
+ ))}
+
+
+ 총합: {total} · 완료: {completed} · 진행률:{" "}
+ {percent}%
+
+
+
+ {taskCodes.map((code) => (
+
+
+
+ ))}
+
+ ) : null}
+
+
+ );
+}
+
+export { ChecklistSection };
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sections/contact-info-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sections/contact-info-section.tsx
new file mode 100644
index 000000000..4abd171c1
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/sections/contact-info-section.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import type { SewerBscPlanDtlResponseDto } from "@/entities/sewer-bsc-plan";
+import { SectionCollapsible } from "@/widgets/pbadms-sprt";
+import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
+
+interface ContactInfoSectionProps {
+ dtl?: SewerBscPlanDtlResponseDto;
+}
+
+function ContactInfoSection({ dtl }: ContactInfoSectionProps) {
+ if (!dtl) return null;
+
+ return (
+
+
+
+ {dtl.aplcntNm ? (
+ <>
+
+ 신청자
+ 이름
+ {dtl.aplcntNm || "-"}
+ 연락처
+ {dtl.aplcntMblTelno || "-"}
+
+
+ 소속
+ {dtl.aplcntOgdpNm || "-"}
+ 이메일
+ {dtl.aplcntEmlAddr || "-"}
+
+ >
+ ) : null}
+ {dtl.dsgnrNm ? (
+ <>
+
+ 설계자
+ 이름
+ {dtl.dsgnrNm || "-"}
+ 연락처
+ {dtl.dsgnrTelno || "-"}
+
+
+ 소속
+ {dtl.dsgnrOgdpNm || "-"}
+ 이메일
+ {dtl.dsgnrEmlAddr || "-"}
+
+ >
+ ) : null}
+
+
+
+ );
+}
+
+export { ContactInfoSection };
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sections/fnrs-cst-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sections/fnrs-cst-section.tsx
new file mode 100644
index 000000000..5ec099fda
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/sections/fnrs-cst-section.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+import { useMemo } from "react";
+
+import { useAcntgSeMngTree } from "@/entities/acntg";
+import type {
+ SewerBscPlanDtlResponseDto,
+ SewerBscPlanFnrsCstResponseDto,
+} from "@/entities/sewer-bsc-plan";
+import { buildAcntgCostItems, fmtNumber, SectionCollapsible } from "@/widgets/pbadms-sprt";
+import {
+ Skeleton,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@common/components";
+
+import { PLAN_STEPS } from "../../lib/biz-cst-matrix-utils";
+
+interface FnrsCstSectionProps {
+ fnrsCstList: SewerBscPlanFnrsCstResponseDto[];
+ dtl?: SewerBscPlanDtlResponseDto;
+}
+
+const FNRS_CST_ROWS = [
+ { key: "sumAmt" as const, label: "총사업비" },
+ { key: "gvexpAmt" as const, label: "국고" },
+ { key: "lcexpAmt" as const, label: "지방비" },
+ { key: "orgprBdchgAmt" as const, label: "원인자부담금" },
+ { key: "etcAmt" as const, label: "기타" },
+];
+
+function FnrsCstSection({ fnrsCstList, dtl }: FnrsCstSectionProps) {
+ const { data: acntgTree = [] } = useAcntgSeMngTree();
+ const costItems = useMemo(() => buildAcntgCostItems(acntgTree), [acntgTree]);
+
+ const matrixFnrsList = useMemo(
+ () =>
+ PLAN_STEPS.map((planStpNo) => {
+ const item = fnrsCstList.find((row) => row.planStpNo === planStpNo);
+ return {
+ planStpNo,
+ gvexpAmt: item?.gvexpAmt ?? 0,
+ lcexpAmt: item?.lcexpAmt ?? 0,
+ orgprBdchgAmt: item?.orgprBdchgAmt ?? 0,
+ etcAmt: item?.etcAmt ?? 0,
+ sumAmt: item?.sumAmt ?? 0,
+ };
+ }),
+ [fnrsCstList],
+ );
+
+ const stepHeaders = [
+ { planStpNo: 1, label: "1단계", year: dtl?.frlvlGoalYr },
+ { planStpNo: 2, label: "2단계", year: dtl?.sclvlGoalYr },
+ { planStpNo: 3, label: "3단계", year: dtl?.thlvlGoalYr },
+ { planStpNo: 4, label: "4단계", year: dtl?.folvlGoalYr },
+ ];
+
+ if (costItems.length === 0) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
세부별 총사업비(①) - 재원분담 총사업비(②)
+ (단위: 백만원)
+
+
+
+
+
+
+
+
+
+
+
+ 구분
+ {stepHeaders.map((step) => (
+
+ {step.label}
+
+ ({step.year || "-"})
+
+ ))}
+
+
+
+ {FNRS_CST_ROWS.map((row) => (
+
+ {row.label}
+ {stepHeaders.map((step) => {
+ const item = matrixFnrsList.find((fnrs) => fnrs.planStpNo === step.planStpNo);
+ return (
+
+ {fmtNumber(item?.[row.key])}
+
+ );
+ })}
+
+ ))}
+
+
+
+
+ );
+}
+
+export { FnrsCstSection };
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sections/outline-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sections/outline-section.tsx
new file mode 100644
index 000000000..87f91fac9
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/sections/outline-section.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import type {
+ SewerBscPlanDtlResponseDto,
+ SewerBscPlanOtlnResponseDto,
+} from "@/entities/sewer-bsc-plan";
+import { fmtNumber, SectionCollapsible } from "@/widgets/pbadms-sprt";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
+
+interface OutlineSectionProps {
+ otlnList: SewerBscPlanOtlnResponseDto[];
+ dtl?: SewerBscPlanDtlResponseDto;
+}
+
+const OTLN_ROWS = [
+ { key: "sewerPlanPpltnCnt" as const, label: "계획인구" },
+ { key: "sewerPrcsPpltnCnt" as const, label: "처리인구" },
+ { key: "sewerPsplyRt" as const, label: "하수도보급율" },
+];
+
+function OutlineSection({ otlnList, dtl }: OutlineSectionProps) {
+ const stepHeaders = [
+ { planStpNo: 1, label: "1단계", year: dtl?.frlvlGoalYr },
+ { planStpNo: 2, label: "2단계", year: dtl?.sclvlGoalYr },
+ { planStpNo: 3, label: "3단계", year: dtl?.thlvlGoalYr },
+ { planStpNo: 4, label: "4단계", year: dtl?.folvlGoalYr },
+ ];
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ 구분
+ {stepHeaders.map((step) => (
+
+ {step.label}
+
+ ({step.year || "-"})
+
+ ))}
+
+
+
+ {OTLN_ROWS.map((row) => (
+
+ {row.label}
+ {stepHeaders.map((step) => {
+ const item = otlnList.find((otln) => otln.planStpNo === step.planStpNo);
+ return (
+
+ {fmtNumber(item?.[row.key])}
+
+ );
+ })}
+
+ ))}
+
+
+
+ );
+}
+
+export { OutlineSection };
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sections/tech-review-opinion-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sections/tech-review-opinion-section.tsx
new file mode 100644
index 000000000..f8e29462c
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/sections/tech-review-opinion-section.tsx
@@ -0,0 +1,359 @@
+"use client";
+
+import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
+
+import { PROXY_HOST } from "@/shared/config";
+import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
+import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
+import { attachService } from "@/entities/attach";
+import type { SewerBscPlanRvwOpnnResponseDto } from "@/entities/sewer-bsc-plan";
+import { SectionCollapsible } from "@/widgets/pbadms-sprt";
+import {
+ Button,
+ RadioGroup,
+ RadioGroupItem,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Textarea,
+} from "@common/components";
+
+import type {
+ ReviewOpinionFormData,
+ ReviewOpinionGetFormDataResult,
+} from "../../lib/review-opinion-utils";
+import type { ReviewStepCode } from "../../model/view-types";
+import { REVIEW_STEP_LABELS } from "../../model/view-types";
+
+const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
+
+interface TechReviewOpinionSectionProps {
+ rvwList: SewerBscPlanRvwOpnnResponseDto[];
+ editableReviewStep?: ReviewStepCode;
+ atrzSeComCdList?: string[];
+ isAtrzLoading?: boolean;
+}
+
+export interface TechReviewOpinionSectionHandle {
+ getFormData: () => ReviewOpinionGetFormDataResult | null;
+ resetForm: () => void;
+}
+
+interface TechReviewOpinionTableProps {
+ items: SewerBscPlanRvwOpnnResponseDto[];
+}
+
+interface UploadedOpinionFile {
+ fileName: string;
+ fileKey: string;
+ atflId?: string;
+ atflCd?: string;
+}
+
+const EMPTY_FORM_DATA: ReviewOpinionFormData = {
+ rvwRsltCn: "",
+ rvwRsltAnmlyYn: "",
+ atchFileGroupId: "",
+ opnrSeComCd: "",
+};
+
+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);
+}
+
+const TechReviewOpinionSection = forwardRef<
+ TechReviewOpinionSectionHandle,
+ TechReviewOpinionSectionProps
+>(function TechReviewOpinionSection(
+ { rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
+ ref,
+) {
+ const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
+ const [formData, setFormData] = useState({
+ ...EMPTY_FORM_DATA,
+ opnrSeComCd: editableReviewStep ?? "",
+ });
+ const [uploadedFile, setUploadedFile] = useState(null);
+
+ useEffect(() => {
+ setFormData((prev) => ({
+ ...prev,
+ opnrSeComCd: editableReviewStep ?? "",
+ }));
+ }, [editableReviewStep]);
+
+ const { uploadFileAsync, isUploading, uploads } = useTus({
+ onSuccess: (atflCd, atflId, _file, fileKey) => {
+ if (!atflCd || !atflId) return;
+
+ setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
+ setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
+ },
+ onError: (_error, _file, fileKey) => {
+ setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
+ },
+ });
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ getFormData: () => {
+ if (!editableReviewStep || !hasFormContent(formData)) {
+ return null;
+ }
+ if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
+ return null;
+ }
+
+ return {
+ formData: {
+ ...formData,
+ opnrSeComCd: editableReviewStep,
+ },
+ rvwList,
+ };
+ },
+ resetForm: () => {
+ setFormData({
+ ...EMPTY_FORM_DATA,
+ opnrSeComCd: editableReviewStep ?? "",
+ });
+ setUploadedFile(null);
+ },
+ }),
+ [canWriteKecoOpinion, editableReviewStep, formData, rvwList],
+ );
+
+ const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
+ const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
+
+ const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
+ const hasKecoSection =
+ kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
+
+ if (!hasEnvSection && !hasKecoSection) {
+ return null;
+ }
+
+ async function handleFileUpload(files: FileList | null) {
+ if (!files || files.length === 0) return;
+
+ const file = files[0];
+ const fileKey = createRvwOpnnUploadKey(file);
+ const existingAtflCd = formData.atchFileGroupId;
+
+ setUploadedFile({ fileName: file.name, fileKey });
+
+ try {
+ await uploadFileAsync(file, {
+ fileKey,
+ metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
+ });
+ } catch {
+ // onError callback handles cleanup
+ }
+ }
+
+ async function handleFileRemove() {
+ if (!uploadedFile) return;
+
+ uploads[uploadedFile.fileKey]?.abort();
+
+ if (uploadedFile.atflId) {
+ try {
+ await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
+ } catch (error) {
+ console.error("검토의견 첨부파일 삭제 실패:", error);
+ }
+ }
+
+ setUploadedFile(null);
+ setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
+ }
+
+ function renderOpinionForm(reviewStep: ReviewStepCode) {
+ if (editableReviewStep !== reviewStep) return null;
+ if (reviewStep === "KECO" && !canWriteKecoOpinion) return null;
+
+ const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
+
+ return (
+
+ );
+ }
+
+ return (
+ <>
+ {hasEnvSection ? (
+
+
+ {envRvwList.length > 0 ? : null}
+ {renderOpinionForm("ENV")}
+
+
+ ) : null}
+ {hasKecoSection ? (
+
+
+ {kecoRvwList.length > 0 ? : null}
+ {renderOpinionForm("KECO")}
+
+
+ ) : null}
+ >
+ );
+});
+
+function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ 의견자명
+ 의견내용
+ 검토결과
+ 첨부파일
+ 등록일시
+
+
+
+ {items.map((item) => (
+
+ {item.opnrNm}
+ {item.rvwRsltCn || "-"}
+
+ {item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"}
+
+
+ {item.atchFileInfo?.atflCd ? (
+
+ {item.atchFileInfo.orgnlFileNm || "다운로드"}
+
+ ) : (
+ "-"
+ )}
+
+
+ {item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
+
+
+ ))}
+
+
+
+ );
+}
+
+export { TechReviewOpinionSection };
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sections/tmplnt-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sections/tmplnt-section.tsx
new file mode 100644
index 000000000..49eb91bfb
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/sections/tmplnt-section.tsx
@@ -0,0 +1,283 @@
+"use client";
+
+import { useMemo, useState } from "react";
+
+import { Paperclip } from "lucide-react";
+
+import { useDownloadAttachmentByAtflCd } from "@/entities/attach";
+import { useFacilityList } from "@/entities/bns-if";
+import { useNowDmgMtncGnsvy } from "@/entities/dc";
+import type {
+ SewerBscPlanDtlResponseDto,
+ SewerBscPlanResponseDto,
+ SewerBscPlanTmplntResponseDto,
+} from "@/entities/sewer-bsc-plan";
+import { fmtNumber, SectionCollapsible } from "@/widgets/pbadms-sprt";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@common/components";
+
+import {
+ getOriginalValue,
+ getOriginalYear,
+ PLAN_STP_NOS,
+ type TmplntFieldKey,
+} from "../../lib/tmplnt-original-mapper";
+
+interface FacilityOption {
+ code: string;
+ name: string;
+}
+
+interface TmplntSectionProps {
+ tmplntList: SewerBscPlanTmplntResponseDto[];
+ det: SewerBscPlanResponseDto;
+ dtl?: SewerBscPlanDtlResponseDto;
+}
+
+function resolveFacilityName(
+ item: SewerBscPlanTmplntResponseDto,
+ facilityOptions: FacilityOption[],
+) {
+ return (
+ item.swtrmtFcltNm || facilityOptions.find((option) => option.code === item.swtrmtFcltCd)?.name
+ );
+}
+
+const TMPLNT_FIELD_ROWS: { key: TmplntFieldKey; label: string }[] = [
+ { key: "prcsZoneArea", label: "처리구역면적(km²)" },
+ { key: "swtrmtPpltnCnt", label: "하수처리인구(명)" },
+ { key: "planSwqty", label: "계획하수량(㎥/일)" },
+ { key: "fcltCpct", label: "시설용량(㎥/일)" },
+ { key: "sprsysDrtwPplLen", label: "분류식 오수(km)" },
+ { key: "sprsysExlnPplLen", label: "분류식 우수(km)" },
+ { key: "cbsysPplLen", label: "합류식(km)" },
+ { key: "icptgPplLen", label: "차집관로(km)" },
+ { key: "drgCnt", label: "배수설비(개소)" },
+ { key: "drtwRlyPumpCnt", label: "오수 중계 펌프(개소)" },
+ { key: "drtwPumpCnt", label: "오수 일반 펌프(개소)" },
+ { key: "exlnPumpCnt", label: "우수 펌프(개소)" },
+];
+
+function getDtlValue(
+ item: SewerBscPlanTmplntResponseDto,
+ planStpNo: number,
+ fieldKey: TmplntFieldKey,
+): number | undefined {
+ return item.sewerBscPlanTmplntDtlList?.find((dtl) => dtl.planStpNo === planStpNo)?.[fieldKey];
+}
+
+interface TmplntFacilityMatrixProps {
+ item: SewerBscPlanTmplntResponseDto;
+ sido?: string;
+ gugun?: string;
+ crtrYr?: string;
+ changeStepHeaders: { planStpNo: number; year?: string }[];
+}
+
+function TmplntFacilityMatrix({
+ item,
+ sido,
+ gugun,
+ crtrYr,
+ changeStepHeaders,
+}: TmplntFacilityMatrixProps) {
+ const searchParams = useMemo(
+ () => ({
+ swtrmtFcltCd: item.swtrmtFcltCd ?? "",
+ ctpvNm: sido ?? "",
+ sggNm: gugun ?? "",
+ }),
+ [item.swtrmtFcltCd, sido, gugun],
+ );
+ const { data } = useNowDmgMtncGnsvy(searchParams);
+ const originalData = Array.isArray(data) ? (data[0] ?? null) : null;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 구분
+
+
+ 당초
+
+
+ 현재
+
+ ({crtrYr || "-"}년)
+
+
+ 변경
+
+
+
+ {PLAN_STP_NOS.map((planStpNo) => (
+
+ {planStpNo}단계
+
+
+ ({getOriginalYear(originalData, planStpNo)})
+
+
+ ))}
+ {changeStepHeaders.map((step) => (
+
+ {step.planStpNo}단계
+
+ ({step.year || "-"})
+
+ ))}
+
+
+
+ {TMPLNT_FIELD_ROWS.map((row) => (
+
+ {row.label}
+ {PLAN_STP_NOS.map((planStpNo) => (
+
+ {getOriginalValue(originalData, row.key, planStpNo)}
+
+ ))}
+ {fmtNumber(getDtlValue(item, 0, row.key))}
+ {changeStepHeaders.map((step) => (
+
+ {fmtNumber(getDtlValue(item, step.planStpNo, row.key))}
+
+ ))}
+
+ ))}
+
+
+ );
+}
+
+function TmplntSection({ tmplntList, det, dtl }: TmplntSectionProps) {
+ const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
+ const [selectedCode, setSelectedCode] = useState(tmplntList[0]?.swtrmtFcltCd ?? "");
+ const { data: facilityListData } = useFacilityList(
+ { sido: det.sido ?? "", gugun: det.gugun ?? "" },
+ { enabled: Boolean(det.sido) },
+ );
+
+ const facilityOptions =
+ facilityListData?.fctList
+ ?.filter((facility) => facility.bscFctCd)
+ .map((facility) => ({
+ code: facility.bscFctCd!,
+ name: facility.bscFctNm ?? facility.bscFctCd!,
+ })) ?? [];
+
+ const changeStepHeaders = [
+ { planStpNo: 1, year: dtl?.frlvlGoalYr },
+ { planStpNo: 2, year: dtl?.sclvlGoalYr },
+ { planStpNo: 3, year: dtl?.thlvlGoalYr },
+ { planStpNo: 4, year: dtl?.folvlGoalYr },
+ ];
+
+ if (tmplntList.length === 0) {
+ return (
+
+ 선택된 처리시설이 없습니다.
+
+ );
+ }
+
+ return (
+
+
+
+ {tmplntList.map((item) => {
+ const facilityName = resolveFacilityName(item, facilityOptions);
+ return (
+
+ {facilityName}
+
+ );
+ })}
+
+
+ {tmplntList.map((item) => {
+ const facilityName = resolveFacilityName(item, facilityOptions);
+ return (
+
+
+
+
+
+ {(item.atchFileGroupId || item.atchFileInfo?.orgnlFileNm) && (
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+
+export { TmplntSection };
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-form-page.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-form-page.tsx
index 93396633a..fb5b1860e 100644
--- a/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-form-page.tsx
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-form-page.tsx
@@ -147,11 +147,11 @@ function SewerBscPlanFormPage({
try {
setIsSubmitting(true);
- const docNo = await submitForm(data, "LE");
- if (!docNo) return;
+ const result = await submitForm(data, "LE");
+ if (!result?.sewerBscPlanDocNo) return;
alert("신청 완료되었습니다.");
- router.push(`/sewer-bsc-plan/${docNo}`);
+ router.push(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}?chgCycl=${result.chgCycl ?? 1}`);
queryClient.invalidateQueries({ queryKey: sewerBscPlanBaseKey });
} catch (error) {
console.error("신청 실패:", error);
@@ -165,10 +165,10 @@ function SewerBscPlanFormPage({
if (isSubmitting) return;
const hasEmptySplmntCn = data.splmntDmndList?.some(
- (item) => !item.splmntCn || !item.splmntCn.trim(),
+ (item) => item.cmptnYn === "N" && (!item.splmntCn || !item.splmntCn.trim()),
);
if (hasEmptySplmntCn) {
- alert("보완요청내용이 있습니다. 보완완료내용을 입력해 주세요.");
+ alert("보완요청 항목이 있습니다.\n보완완료 내용을 입력해 주세요.");
return;
}
@@ -187,7 +187,8 @@ function SewerBscPlanFormPage({
if (!result) return;
alert("보완완료 처리되었습니다.");
- router.push(`/sewer-bsc-plan/${docNo}`);
+ const chgCycl = data.sewerBscPlanDtl.chgCycl ?? initialData?.detDto?.chgCycl ?? 1;
+ router.push(`/sewer-bsc-plan/${docNo}?chgCycl=${chgCycl}`);
queryClient.invalidateQueries({ queryKey: sewerBscPlanBaseKey });
} catch (error) {
console.error("보완완료 실패:", error);
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-list.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-list.tsx
index 21cc997b7..b2f80834c 100644
--- a/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-list.tsx
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-list.tsx
@@ -55,7 +55,7 @@ function SewerBscPlanList({ searchParams, pagination, onPaginationChange }: Sewe
router.push(
canEdit
? `/sewer-bsc-plan/${row.sewerBscPlanDocNo}/edit?chgCycl=${row.chgCycl}`
- : `/sewer-bsc-plan/${row.sewerBscPlanDocNo}`,
+ : `/sewer-bsc-plan/${row.sewerBscPlanDocNo}?chgCycl=${row.chgCycl}`,
);
};
diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-view-page.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-view-page.tsx
new file mode 100644
index 000000000..981d5d233
--- /dev/null
+++ b/apps/as/src/pages/sewer-bsc-plan/ui/sewer-bsc-plan-view-page.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+import { useMemo, useRef } from "react";
+
+import { useUserStore } from "@/shared/lib";
+import { ElaprDocPrgrsStatus } from "@/entities/elapr";
+import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
+import { useSewerBscPlanDetails } from "@/entities/sewer-bsc-plan";
+import { SectionCollapsible, SupplementDemandSection } from "@/widgets/pbadms-sprt";
+import { Skeleton } from "@common/components";
+
+import { needsRoleUser01AtrzResolve } from "../lib/button-config";
+import { getEditableReviewStep } from "../lib/review-step-utils";
+import { SEWER_BSC_PLAN_DOC_SE_COM_CD } from "../lib/sewer-bsc-plan-regist-schema";
+import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
+
+import { FloatingActionButtons } from "./components/floating-action-buttons";
+import { AttachmentsSection } from "./sections/attachments-section";
+import { BasicInfoSection } from "./sections/basic-info-section";
+import { BizCstMatrixSection } from "./sections/biz-cst-matrix-section";
+import { ChecklistSection } from "./sections/checklist-section";
+import { ContactInfoSection } from "./sections/contact-info-section";
+import { FnrsCstSection } from "./sections/fnrs-cst-section";
+import { OutlineSection } from "./sections/outline-section";
+import {
+ TechReviewOpinionSection,
+ type TechReviewOpinionSectionHandle,
+} from "./sections/tech-review-opinion-section";
+import { TmplntSection } from "./sections/tmplnt-section";
+
+interface SewerBscPlanViewPageProps {
+ id: string;
+ propChgCycl?: number;
+}
+
+function SewerBscPlanViewPage({ id, propChgCycl }: SewerBscPlanViewPageProps) {
+ const userInfo = useUserStore((state) => state.userInfo);
+ const techReviewOpinionRef = useRef(null);
+ const chgCycl = propChgCycl ?? 1;
+ const { data, isLoading, isError } = useSewerBscPlanDetails(id, chgCycl);
+ const { formState: checklistFormState, setFormState: setChecklistFormState } =
+ useChecklistFormState(data?.cklstResultRows ?? EMPTY_CKLST_RESULT_LIST);
+
+ const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || "";
+ const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd);
+ const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
+ {},
+ { enabled: needsAtrzResolve },
+ );
+ const atrzSeComCdList = useMemo(
+ () => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
+ [rvwLoginUserData],
+ );
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (isError || !data?.detDto) {
+ return 상세 정보를 찾을 수 없습니다.
;
+ }
+
+ const detDto = data.detDto;
+ const activeDtlDto = data.activeDtlDto;
+ const otlnList = data.otlnList ?? [];
+ const bizCstList = data.bizCstList ?? [];
+ const fnrsCstList = data.fnrsCstList ?? [];
+ const tmplntList = data.tmplntList ?? [];
+ const cklstResultRows = data.cklstResultRows ?? [];
+ const esntlFileList = data.esntlFileList ?? [];
+ const etcFileList = data.etcFileList ?? [];
+ const rvwOpnnList = data.rvwOpnnList ?? [];
+ const splmntDmndList = data.splmntDmndList ?? [];
+
+ const editableReviewStep = getEditableReviewStep(
+ userInfo?.dtlInstCd,
+ detDto.prgrsSttsComCd || "",
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export { SewerBscPlanViewPage };
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-dialog.tsx b/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-dialog.tsx
index 9373285c2..42af130cf 100644
--- a/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-dialog.tsx
+++ b/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-dialog.tsx
@@ -21,7 +21,6 @@ import {
Textarea,
} from "@common/components";
-const SPLMNT_ARTCL_UP_CD = "SPLMNTARTCL";
const SPLMNT_DMND_FILE_KEY_PREFIX = "splmnt-dmnd::";
interface UploadedSplmntFile {
@@ -50,6 +49,7 @@ interface SplmntDmndDialogConfirmPayload {
}
interface SplmntDmndDialogProps {
+ splmntArtclUpCd: string;
open: boolean;
onOpenChange: (open: boolean) => void;
isSubmitting?: boolean;
@@ -86,7 +86,13 @@ function isRowsValid(rows: SplmntDmndRow[]) {
return rows.every((row) => row.splmntArtclComCd.trim() && row.splmntDmndCn.trim());
}
-function SplmntDmndDialog({ open, onOpenChange, isSubmitting, onConfirm }: SplmntDmndDialogProps) {
+function SplmntDmndDialog({
+ splmntArtclUpCd,
+ open,
+ onOpenChange,
+ isSubmitting,
+ onConfirm,
+}: SplmntDmndDialogProps) {
const formId = useId();
const [rows, setRows] = useState([createEmptyRow()]);
@@ -239,7 +245,7 @@ function SplmntDmndDialog({ open, onOpenChange, isSubmitting, onConfirm }: Splmn
updateRow(row.rowKey, { splmntArtclComCd: value })