[as] feat: 기본계획 상세 페이지 구현

This commit is contained in:
2026-08-04 17:22:21 +09:00
parent 70136aa65d
commit e105f52fea
30 changed files with 3014 additions and 38 deletions
@@ -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 (
<div className="mt-6 px-4 text-[1.4rem]">
하수도기본계획 상세({id})는 후속 구현 예정입니다.{" "}
<Link className="underline" href={`/sewer-bsc-plan/${id}/edit`}>
수정 화면으로 이동
</Link>
</div>
);
const { chgCycl } = await searchParams;
const propChgCycl = chgCycl ? Number(chgCycl) : undefined;
return <SewerBscPlanViewPage id={id} propChgCycl={propChgCycl} />;
}
@@ -75,8 +75,11 @@ async function submitReqSupplement(
id: string,
chgCycl: number,
body: SewerBscPlanApplySaveRequestDto,
): Promise<string> {
const response = await client.post<string>(`${BASE}/${id}/${chgCycl}/reqsupplement`, body);
): Promise<SewerBscPlanApplySaveResponseDto> {
const response = await client.post<SewerBscPlanApplySaveResponseDto>(
`${BASE}/${id}/${chgCycl}/reqsupplement`,
body,
);
return response.data;
}
@@ -346,6 +346,6 @@ export interface SewerBscPlanService {
id: string,
chgCycl: number,
body: SewerBscPlanApplySaveRequestDto,
): Promise<string>;
): Promise<SewerBscPlanApplySaveResponseDto>;
techReview(id: string, body: SewerBscPlanApplySaveRequestDto): Promise<void>;
}
@@ -498,6 +498,7 @@ function FloatingActionButtons({
/>
<SplmntDmndDialog
splmntArtclUpCd="SPLMNTARTCL"
open={activeDialog === "dmndSplmnt"}
onOpenChange={() => setActiveDialog(null)}
isSubmitting={isProcessing}
@@ -511,6 +511,7 @@ function FloatingActionButtons({
/>
<SplmntDmndDialog
splmntArtclUpCd="SPLMNTARTCL"
open={activeDialog === "dmndSplmnt"}
onOpenChange={() => setActiveDialog(null)}
isSubmitting={isProcessing}
@@ -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";
@@ -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<string, Record<string, SewerBscPlanViewButtonAction[]>> = {
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<string, SewerBscPlanViewButtonAction[]>
> = {
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<SewerBscPlanViewButtonAction>();
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);
}
@@ -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<string>();
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<Pick<ChecklistFormState, "results" | "comments">>,
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,
};
});
}
@@ -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),
}),
@@ -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;
}
@@ -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;
}
@@ -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<string, string>();
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,
};
}
@@ -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<ChecklistFormState>({
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<Pick<ChecklistFormState, "results" | "comments">>,
reviewStep?: ReviewStepCode,
) => {
setFormState((prev) => patchChecklistFormState(prev, patch, cklstResultList, reviewStep));
},
[cklstResultList],
);
return { formState, setFormState, patchFormState };
}
export { EMPTY_CKLST_RESULT_LIST, useChecklistFormState };
@@ -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" },
@@ -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<ChecklistRowKey, "Y" | "N">;
comments: Record<ChecklistRowKey, string>;
progress: ChecklistProgress;
}
export type ChecklistStepMap = Record<ReviewStepCode, CklstRvwRsltTreeDto[]>;
export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "지자체",
ENV: "환경청",
KECO: "환경공단",
};
export const TASK_CODE_LABELS: Record<string, string> = {
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<string, ReviewStepCode> = {
ROLE_USER05: "LOCAL",
ROLE_USER03: "ENV",
ROLE_USER01: "KECO",
};
export const REVIEW_STEP_ROLE_RANK: Record<ReviewStepCode, number> = {
LOCAL: 1,
ENV: 2,
KECO: 3,
};
@@ -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<SetStateAction<ChecklistFormState>>;
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 (
<div className="overflow-x-auto">
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead className="w-[20rem]" rowSpan={2}>
검토항목
</TableHead>
<TableHead rowSpan={2}>주요 검토내용</TableHead>
<TableHead colSpan={2}>검토 결과</TableHead>
{selectedReviewStep !== "LOCAL" ? (
<TableHead rowSpan={2} className="w-[20rem]">
기술검토 의견
</TableHead>
) : null}
</TableRow>
<TableRow>
<TableHead className="w-[12rem]">
<Button
type="button"
size="sm"
disabled={!isStepEditable}
onClick={() => handleSelectAll("Y")}
>
전체 예
</Button>
</TableHead>
<TableHead className="w-[12rem]">
<Button
type="button"
size="sm"
disabled={!isStepEditable}
onClick={() => handleSelectAll("N")}
className="!bg-destructive"
>
전체 아니오
</Button>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedRows.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-12 text-center text-muted-foreground">
조회된 체크리스트 항목이 없습니다.
</TableCell>
</TableRow>
) : (
selectedRows.map((row) => {
const key = buildChecklistRowKey(row, selectedReviewStep);
const isFieldVisible = row.bchckUseYn === "Y";
const isLocalReviewStep = selectedReviewStep === "LOCAL";
return (
<TableRow key={key}>
<TableCell className="text-center text-[1.2rem]">{row.cklstArtclNm}</TableCell>
<TableCell
className="pl-2 !text-left text-[1.2rem] text-gray-600"
style={{ paddingLeft: `${Math.max(0, row.treeLevel - 1) * 16 + 8}px` }}
colSpan={isFieldVisible ? 0 : isLocalReviewStep ? 3 : 4}
>
{row.rvwCn}
</TableCell>
{isFieldVisible && (
<>
<TableCell className="text-center">
<input
type="radio"
name={`chk_${key}`}
value="Y"
checked={formState.results[key] === "Y"}
readOnly={!isStepEditable}
onChange={() => {
if (!isStepEditable) return;
setFormState((prev) =>
patchChecklistFormState(
prev,
{ results: { ...prev.results, [key]: "Y" } },
allRows,
selectedReviewStep,
),
);
}}
className={cn(isStepEditable ? "cursor-pointer" : "cursor-not-allowed")}
/>
</TableCell>
<TableCell className="text-center">
<input
type="radio"
name={`chk_${key}`}
value="N"
checked={formState.results[key] === "N"}
readOnly={!isStepEditable}
onChange={() => {
if (!isStepEditable) return;
setFormState((prev) =>
patchChecklistFormState(
prev,
{ results: { ...prev.results, [key]: "N" } },
allRows,
selectedReviewStep,
),
);
}}
className={cn(isStepEditable ? "cursor-pointer" : "cursor-not-allowed")}
/>
</TableCell>
</>
)}
{!isLocalReviewStep && isFieldVisible && (
<TableCell className="text-left">
<Textarea
className={cn(
"min-h-[6rem] w-[32rem] !text-xl",
isStepEditable ? "cursor-pointer" : "cursor-not-allowed",
)}
value={formState.comments[key] ?? ""}
readOnly={!isStepEditable}
onChange={(event) => {
if (!isStepEditable) return;
setFormState((prev) =>
patchChecklistFormState(
prev,
{ comments: { ...prev.comments, [key]: event.target.value } },
allRows,
selectedReviewStep,
),
);
}}
/>
</TableCell>
)}
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
);
}
export { ChecklistTaskPanel };
@@ -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<TechReviewOpinionSectionHandle | null>;
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<ActiveDialog>(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 (
<>
<div className="sticky bottom-0 z-40 !-mb-2 border-t bg-white pb-8 pt-6 shadow-[0_-4px_12px_rgba(0,0,0,0.06)]">
<div className="mx-4 flex flex-wrap justify-center gap-3 sm:gap-4">
<Button color="white" type="button" onClick={handleGoToList} disabled={isProcessing}>
목록
</Button>
{canEdit && (
<Button color="gray" onClick={handleEdit} disabled={isProcessing}>
수정
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.strg) && (
<Button onClick={handleStrgClick} disabled={isProcessing}>
저장
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.aply) && (
<Button color="green" onClick={handleAplyClick} disabled={isProcessing}>
신청완료
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.del) && (
<Button color="red" onClick={handleDelClick} disabled={isProcessing}>
삭제
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwRply) && (
<Button color="green" onClick={handleRvwRplyClick} disabled={isProcessing}>
검토결과 회신
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rqstTechRvw) && (
<Button onClick={handleRqstTechRvwClick} disabled={isProcessing}>
기술검토 의뢰
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.dsgntRvwr) && (
<Button onClick={() => setActiveDialog("elaprTmplt")} disabled={isProcessing}>
검토자지정
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwStrg) && (
<Button onClick={handleRvwStrgClick} disabled={isProcessing}>
저장
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwCmptn) && (
<Button color="green" onClick={handleRvwCmptnClick} disabled={isProcessing}>
검토완료
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.dmndSplmnt) && (
<Button color="gray" onClick={handleDmndSplmntClick} disabled={isProcessing}>
보완요청
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.atrzAprv) && (
<Button onClick={() => setActiveDialog("elaprApproval")} disabled={isProcessing}>
결재(승인)
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.atrzRply) && (
<Button onClick={() => setActiveDialog("elaprApproval")} disabled={isProcessing}>
결재(회신)
</Button>
)}
</div>
</div>
<ElaprTmpltPickerDialog
open={activeDialog === "elaprTmplt"}
onClose={() => setActiveDialog(null)}
docSeComCd={docSeComCd}
onConfirm={handleElaprTmpltConfirm}
/>
<SplmntDmndDialog
splmntArtclUpCd="SEWERSPLMNTARTCL"
open={activeDialog === "dmndSplmnt"}
onOpenChange={() => setActiveDialog(null)}
isSubmitting={isProcessing}
onConfirm={handleDmndSplmntConfirm}
/>
<ElaprApprovalDialog
open={activeDialog === "elaprApproval"}
onOpenChange={(open) => setActiveDialog(open ? "elaprApproval" : null)}
elaprDocNo={docNo}
currentUserUuid={userInfo?.userUnqId ?? ""}
onSuccess={() => {
void invalidateDetail();
}}
/>
</>
);
}
export { FloatingActionButtons };
async function handleSaveApply(
context: ActionHandlerContext<SewerBscPlanApplySaveRequestDto>,
): Promise<void> {
try {
await sewerBscPlanService.saveApply(context.request);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleDel(context: ActionHandlerContext): Promise<void> {
try {
await sewerBscPlanService.delete(context.data.detDto?.sewerBscPlanDocNo ?? "");
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleTechReview(
context: ActionHandlerContext<SewerBscPlanApplySaveRequestDto>,
): Promise<void> {
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<void> {
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<ElaprDmndSaveRequestDto>,
): Promise<void> {
try {
await elaprDmndService.save(context.request);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleDmndSplmnt(
context: ActionHandlerContext<SewerBscPlanApplySaveRequestDto>,
): Promise<void> {
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<void> {
context.onSuccess?.();
}
async function handleAtrzRply(context: ActionHandlerContext): Promise<void> {
context.onSuccess?.();
}
const buttonActionHandlers: Record<SewerBscPlanViewButtonAction, ActionHandler> = {
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<T = any> {
request: T;
data: SewerBscPlanDetResponseDto;
chgCycl: number;
router: ReturnType<typeof useRouter>;
queryClient: QueryClient;
onSuccess?: () => void;
onError?: (error: Error) => void;
}
type ActionHandler = (context: ActionHandlerContext) => Promise<void>;
@@ -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 (
<>
<SectionCollapsible title="필수 첨부파일">
<div className="overflow-x-auto">
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead>필수 자료명</TableHead>
<TableHead>파일명</TableHead>
<TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{esntlFileList.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-12 text-center text-muted-foreground">
조회된 필수 첨부파일 항목이 없습니다.
</TableCell>
</TableRow>
) : (
esntlFileList.map((file) => (
<TableRow key={file.esntlAtchFileId}>
<TableCell className="pl-2 text-left">
{file.esntlAtchFileNm}
{file.esntlAtchYn === "Y" ? (
<span className="ml-1 text-red-500">*</span>
) : null}
</TableCell>
<TableCell className="text-left">
{file.atflCd ? (
<a
className="cursor-pointer text-blue-600 hover:underline"
onClick={() => downloadAttachment(file.atflCd ?? "")}
target="_blank"
rel="noreferrer"
>
{file.orgnlFileNm || "다운로드"}
</a>
) : (
<span className="text-gray-500">첨부없음</span>
)}
</TableCell>
<TableCell className="text-right">{formatFileSize(file.atflFlieSz)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</SectionCollapsible>
<SectionCollapsible title="기타 첨부파일">
<div className="overflow-x-auto">
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead>파일명</TableHead>
<TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{etcFileList.length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="py-12 text-center text-muted-foreground">
등록된 기타첨부파일이 없습니다.
</TableCell>
</TableRow>
) : (
etcFileList.map((file) => (
<TableRow key={file.atflId}>
<TableCell className="text-left">
{file.atflCd ? (
<a
className="cursor-pointer text-blue-600 hover:underline"
onClick={() => downloadAttachment(file.atflCd)}
target="_blank"
rel="noreferrer"
>
{file.orgnlFileNm || file.physFileNm || file.atflId}
</a>
) : (
<span>{file.orgnlFileNm || file.physFileNm || file.atflId}</span>
)}
</TableCell>
<TableCell className="text-right text-gray-500">
{formatFileSize(file.atflFlieSz)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</SectionCollapsible>
</>
);
}
export { AttachmentsSection };
@@ -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 (
<SectionCollapsible title="기본계획 신청">
<Table variant="secondary">
<colgroup>
<col width="15%" />
<col width="35%" />
<col width="15%" />
<col width="35%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>기본계획명</TableHead>
<TableCell colSpan={3}>{det.sewerBscPlanNm || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>계획년도</TableHead>
<TableCell>{dtl?.planYr || "-"}</TableCell>
<TableHead>기준년도</TableHead>
<TableCell>{dtl?.crtrYr || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>1단계 목표연도</TableHead>
<TableCell>{dtl?.frlvlGoalYr || "-"}</TableCell>
<TableHead>2단계 목표연도</TableHead>
<TableCell>{dtl?.sclvlGoalYr || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>3단계 목표연도</TableHead>
<TableCell>{dtl?.thlvlGoalYr || "-"}</TableCell>
<TableHead>4단계 목표연도</TableHead>
<TableCell>{dtl?.folvlGoalYr || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>계획개요</TableHead>
<TableCell colSpan={3} className="whitespace-pre-wrap">
{dtl?.bizOtlnCn || "-"}
</TableCell>
</TableRow>
<TableRow>
<TableHead>구분</TableHead>
<TableCell>{sewerBscPlanSeLabel || "-"}</TableCell>
<TableHead>신청 지자체</TableHead>
<TableCell>{localGovLabel || "-"}</TableCell>
</TableRow>
</TableHeader>
</Table>
</SectionCollapsible>
);
}
export { BasicInfoSection };
@@ -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 (
<SectionCollapsible title="총사업비">
<div className="space-y-2 p-2">
<Skeleton className="h-[8rem] rounded-xl" />
<Skeleton className="h-[32rem] rounded-xl" />
</div>
</SectionCollapsible>
);
}
return (
<SectionCollapsible title="총사업비">
<div className="overflow-x-auto">
<p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p>
<Table variant="secondary" className="table-fixed">
<colgroup>
<col width="12%" />
<col width="22%" />
<col width="22%" />
<col width="22%" />
<col width="22%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[28rem]">구분</TableHead>
{stepHeaders.map((step) => (
<TableHead key={step.planStpNo} className="text-center">
{step.label}
<br />
<span className="font-normal text-muted-foreground">({step.year || "-"})</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{costItems.map((item) => {
const paddingLeft = `${item.level * 1.6}rem`;
const showSubIcon = item.level >= 2;
return (
<TableRow key={item.code}>
<TableHead className="text-left" style={{ paddingLeft }}>
<div className="relative">
{showSubIcon && (
<span className="absolute -left-6 top-0 text-gray-400">└</span>
)}
{item.label}
</div>
</TableHead>
{stepHeaders.map((step) => (
<TableCell key={step.planStpNo} className="text-right">
{fmtNumber(getAmt(matrixList, item.code, step.planStpNo))}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</SectionCollapsible>
);
}
export { BizCstMatrixSection };
@@ -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<SetStateAction<ChecklistFormState>>;
}
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<ReviewStepCode>(defaultReviewStep);
const [selectedTaskCode, setSelectedTaskCode] = useState<string | null>(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 (
<SectionCollapsible title="체크리스트">
<div className="flex flex-col gap-4 p-2">
{visibleReviewSteps.length > 0 ? (
<Tabs
value={selectedReviewStep}
onValueChange={(value) => setSelectedReviewStep(value as ReviewStepCode)}
className="w-full"
>
<TabsList>
{visibleReviewSteps.map((stepCode) => (
<TabsTrigger key={stepCode} value={stepCode} className="!px-6 !py-3 text-xl">
{REVIEW_STEP_LABELS[stepCode]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
{taskCodes.length > 0 ? (
<Tabs
value={selectedTaskCode ?? taskCodes[0]}
onValueChange={setSelectedTaskCode}
className="w-full"
variant="outline"
>
<div className="flex items-end justify-between gap-4">
<TabsList className="p-0">
{taskCodes.map((code) => (
<TabsTrigger
key={code}
value={code}
className="!px-6 !py-3 text-xl [&:after]:hidden"
>
{TASK_CODE_LABELS[code] ?? code}
</TabsTrigger>
))}
</TabsList>
<div className="shrink-0 pb-1 text-lg text-muted-foreground">
총합: <strong>{total}</strong> · 완료: <strong>{completed}</strong> · 진행률:{" "}
<strong>{percent}%</strong>
</div>
</div>
{taskCodes.map((code) => (
<TabsContent key={code} value={code} className="mt-0 border-0 p-0 shadow-none ring-0">
<ChecklistTaskPanel
selectedReviewStep={selectedReviewStep}
selectedRows={getChecklistDisplayRows(cklstResultList, code)}
allRows={cklstResultList}
formState={formState}
setFormState={setFormState}
userRoleCode={userRoleCode}
/>
</TabsContent>
))}
</Tabs>
) : null}
</div>
</SectionCollapsible>
);
}
export { ChecklistSection };
@@ -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 (
<SectionCollapsible title="담당자 정보">
<Table variant="secondary">
<TableHeader>
{dtl.aplcntNm ? (
<>
<TableRow>
<TableHead rowSpan={2}>신청자</TableHead>
<TableHead>이름</TableHead>
<TableCell>{dtl.aplcntNm || "-"}</TableCell>
<TableHead>연락처</TableHead>
<TableCell>{dtl.aplcntMblTelno || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>소속</TableHead>
<TableCell>{dtl.aplcntOgdpNm || "-"}</TableCell>
<TableHead>이메일</TableHead>
<TableCell>{dtl.aplcntEmlAddr || "-"}</TableCell>
</TableRow>
</>
) : null}
{dtl.dsgnrNm ? (
<>
<TableRow>
<TableHead rowSpan={2}>설계자</TableHead>
<TableHead>이름</TableHead>
<TableCell>{dtl.dsgnrNm || "-"}</TableCell>
<TableHead>연락처</TableHead>
<TableCell>{dtl.dsgnrTelno || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>소속</TableHead>
<TableCell>{dtl.dsgnrOgdpNm || "-"}</TableCell>
<TableHead>이메일</TableHead>
<TableCell>{dtl.dsgnrEmlAddr || "-"}</TableCell>
</TableRow>
</>
) : null}
</TableHeader>
</Table>
</SectionCollapsible>
);
}
export { ContactInfoSection };
@@ -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 (
<SectionCollapsible title="재원분담">
<div className="space-y-2 p-2">
<Skeleton className="h-[8rem] rounded-xl" />
<Skeleton className="h-[32rem] rounded-xl" />
</div>
</SectionCollapsible>
);
}
return (
<SectionCollapsible title="재원분담">
<div className="overflow-x-auto">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-xl font-semibold">세부별 총사업비(①) - 재원분담 총사업비(②)</h3>
<span className="font-normal text-muted-foreground">(단위: 백만원)</span>
</div>
<Table variant="secondary" className="table-fixed">
<colgroup>
<col width="12%" />
<col width="22%" />
<col width="22%" />
<col width="22%" />
<col width="22%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>구분</TableHead>
{stepHeaders.map((step) => (
<TableHead key={step.planStpNo} className="text-center">
{step.label}
<br />
<span className="font-normal text-muted-foreground">({step.year || "-"})</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{FNRS_CST_ROWS.map((row) => (
<TableRow key={row.key}>
<TableHead>{row.label}</TableHead>
{stepHeaders.map((step) => {
const item = matrixFnrsList.find((fnrs) => fnrs.planStpNo === step.planStpNo);
return (
<TableCell key={step.planStpNo} className="text-right">
{fmtNumber(item?.[row.key])}
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</Table>
</div>
</SectionCollapsible>
);
}
export { FnrsCstSection };
@@ -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 (
<SectionCollapsible title="사업개요">
<Table variant="secondary">
<colgroup>
<col width="12%" />
<col width="22%" />
<col width="22%" />
<col width="22%" />
<col width="22%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[20rem]">구분</TableHead>
{stepHeaders.map((step) => (
<TableHead key={step.planStpNo} className="text-center">
{step.label}
<br />
<span className="font-normal text-muted-foreground">({step.year || "-"})</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{OTLN_ROWS.map((row) => (
<TableRow key={row.key}>
<TableHead>{row.label}</TableHead>
{stepHeaders.map((step) => {
const item = otlnList.find((otln) => otln.planStpNo === step.planStpNo);
return (
<TableCell key={step.planStpNo} className="text-right">
{fmtNumber(item?.[row.key])}
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</Table>
</SectionCollapsible>
);
}
export { OutlineSection };
@@ -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<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(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 (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead className="w-[12rem]">의견내용</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell>
{uploadedFile ? (
<div className="flex items-center gap-2">
<span className="text-xl">{uploadedFile.fileName}</span>
<Button
size="icon"
onClick={(e) => {
e.preventDefault();
void handleFileRemove();
}}
disabled={uploading}
className="size-8 !bg-destructive"
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
</div>
) : (
<label>
<input
type="file"
className="hidden"
onChange={(event) => {
void handleFileUpload(event.target.files);
event.target.value = "";
}}
/>
<Button size="sm" variant="outline" type="button" asChild>
<span>파일 선택</span>
</Button>
</label>
)}
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<>
{hasEnvSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
<div className="space-y-6">
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null}
{renderOpinionForm("ENV")}
</div>
</SectionCollapsible>
) : null}
{hasKecoSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
{renderOpinionForm("KECO")}
</div>
</SectionCollapsible>
) : null}
</>
);
});
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
return (
<div className="overflow-x-auto">
<Table variant="secondary">
<colgroup>
<col width="10%" />
<col width="55%" />
<col width="5%" />
<col width="20%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>의견자명</TableHead>
<TableHead>의견내용</TableHead>
<TableHead>검토결과</TableHead>
<TableHead>첨부파일</TableHead>
<TableHead>등록일시</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={`${item.sewerBscPlanDocNo}_${item.rvwOpnnSn}`}>
<TableCell align="center">{item.opnrNm}</TableCell>
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
<TableCell align="center">
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"}
</TableCell>
<TableCell align="center">
{item.atchFileInfo?.atflCd ? (
<a
className="text-blue-600 hover:underline"
href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${item.atchFileInfo.atflCd}`}
target="_blank"
rel="noreferrer"
>
{item.atchFileInfo.orgnlFileNm || "다운로드"}
</a>
) : (
"-"
)}
</TableCell>
<TableCell align="center">
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export { TechReviewOpinionSection };
@@ -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 (
<Table variant="secondary">
<colgroup>
<col width="12%" />
<col width="9.75%" />
<col width="9.75%" />
<col width="9.75%" />
<col width="9.75%" />
<col width="10%" />
<col width="9.75%" />
<col width="9.75%" />
<col width="9.75%" />
<col width="9.75%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[22rem]" rowSpan={2}>
구분
</TableHead>
<TableHead className="text-center" colSpan={4}>
당초
</TableHead>
<TableHead className="text-center" rowSpan={2}>
현재
<br />
<span className="font-normal text-muted-foreground">({crtrYr || "-"}년)</span>
</TableHead>
<TableHead className="text-center" colSpan={4}>
변경
</TableHead>
</TableRow>
<TableRow>
{PLAN_STP_NOS.map((planStpNo) => (
<TableHead key={`original-${planStpNo}`} className="text-center">
{planStpNo}단계
<br />
<span className="font-normal text-muted-foreground">
({getOriginalYear(originalData, planStpNo)})
</span>
</TableHead>
))}
{changeStepHeaders.map((step) => (
<TableHead key={`change-${step.planStpNo}`} className="text-center">
{step.planStpNo}단계
<br />
<span className="font-normal text-muted-foreground">({step.year || "-"})</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{TMPLNT_FIELD_ROWS.map((row) => (
<TableRow key={row.key}>
<TableHead>{row.label}</TableHead>
{PLAN_STP_NOS.map((planStpNo) => (
<TableCell key={`original-${row.key}-${planStpNo}`} className="text-right">
{getOriginalValue(originalData, row.key, planStpNo)}
</TableCell>
))}
<TableCell className="text-right">{fmtNumber(getDtlValue(item, 0, row.key))}</TableCell>
{changeStepHeaders.map((step) => (
<TableCell key={`change-${row.key}-${step.planStpNo}`} className="text-right">
{fmtNumber(getDtlValue(item, step.planStpNo, row.key))}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
}
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 (
<SectionCollapsible title="처리시설별 하수도정비기본계획">
<p className="py-8 text-center text-muted-foreground">선택된 처리시설이 없습니다.</p>
</SectionCollapsible>
);
}
return (
<SectionCollapsible title="처리시설별 하수도정비기본계획">
<Tabs
value={selectedCode || tmplntList[0]?.swtrmtFcltCd}
onValueChange={setSelectedCode}
className="w-full"
>
<TabsList>
{tmplntList.map((item) => {
const facilityName = resolveFacilityName(item, facilityOptions);
return (
<TabsTrigger
key={item.swtrmtFcltCd}
value={item.swtrmtFcltCd ?? ""}
className="!px-6 !py-3 text-xl"
>
{facilityName}
</TabsTrigger>
);
})}
</TabsList>
{tmplntList.map((item) => {
const facilityName = resolveFacilityName(item, facilityOptions);
return (
<TabsContent key={item.swtrmtFcltCd} value={item.swtrmtFcltCd ?? ""}>
<div className="overflow-x-auto">
<TmplntFacilityMatrix
item={item}
sido={det.sido}
gugun={det.gugun}
crtrYr={dtl?.crtrYr}
changeStepHeaders={changeStepHeaders}
/>
</div>
{(item.atchFileGroupId || item.atchFileInfo?.orgnlFileNm) && (
<div className="mt-6 space-y-2">
<h3 className="mb-4 text-xl font-semibold">처리구역 필수 첨부파일</h3>
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead align="left">파일명</TableHead>
<TableHead>파일첨부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>{facilityName} 계획평면도</TableCell>
<TableCell>
{item.atchFileGroupId ? (
<a
className="inline-flex cursor-pointer items-center gap-1 text-blue-600 hover:underline"
onClick={() => downloadAttachment(item.atchFileGroupId ?? "")}
>
<Paperclip className="size-5 shrink-0" />
{item.atchFileInfo?.orgnlFileNm || "다운로드"}
</a>
) : (
"-"
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
)}
</TabsContent>
);
})}
</Tabs>
</SectionCollapsible>
);
}
export { TmplntSection };
@@ -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);
@@ -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}`,
);
};
@@ -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<TechReviewOpinionSectionHandle>(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 (
<div className="mt-6 flex flex-col gap-4 px-6">
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[16rem] rounded-xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[16rem] rounded-xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[32rem] rounded-xl" />
</div>
</div>
);
}
if (isError || !data?.detDto) {
return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>;
}
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 (
<div className="mt-6 space-y-4">
<BasicInfoSection det={detDto} dtl={activeDtlDto} />
<ContactInfoSection dtl={activeDtlDto} />
<SectionCollapsible title="결재현황">
<ElaprDocPrgrsStatus elaprDocNo={detDto.sewerBscPlanDocNo} />
</SectionCollapsible>
<OutlineSection otlnList={otlnList} dtl={activeDtlDto} />
<BizCstMatrixSection bizCstList={bizCstList} dtl={activeDtlDto} />
<FnrsCstSection fnrsCstList={fnrsCstList} dtl={activeDtlDto} />
<TmplntSection tmplntList={tmplntList} det={detDto} dtl={activeDtlDto} />
<ChecklistSection
cklstResultList={cklstResultRows}
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
userRoleCode={userInfo?.dtlInstCd}
formState={checklistFormState}
setFormState={setChecklistFormState}
/>
<AttachmentsSection esntlFileList={esntlFileList} etcFileList={etcFileList} />
<SupplementDemandSection splmntDmndList={splmntDmndList} />
<TechReviewOpinionSection
ref={techReviewOpinionRef}
rvwList={rvwOpnnList}
editableReviewStep={editableReviewStep}
atrzSeComCdList={atrzSeComCdList}
isAtrzLoading={needsAtrzResolve && isRvwLoginUserLoading}
/>
<FloatingActionButtons
data={data}
docSeComCd={SEWER_BSC_PLAN_DOC_SE_COM_CD}
sewerBscPlanDocNo={detDto.sewerBscPlanDocNo}
chgCycl={chgCycl}
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
dtlInstCd={userInfo?.dtlInstCd}
atrzSeComCdList={atrzSeComCdList}
isRvwLoginUserLoading={needsAtrzResolve && isRvwLoginUserLoading}
checklistFormState={checklistFormState}
techReviewOpinionRef={techReviewOpinionRef}
opnrNm={userInfo?.userName}
/>
</div>
);
}
export { SewerBscPlanViewPage };
@@ -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<SplmntDmndRow[]>([createEmptyRow()]);
@@ -239,7 +245,7 @@ function SplmntDmndDialog({ open, onOpenChange, isSubmitting, onConfirm }: Splmn
<TableRow key={row.rowKey}>
<TableCell align="center" className="align-top">
<CodeSelect
upCd={SPLMNT_ARTCL_UP_CD}
upCd={splmntArtclUpCd}
value={row.splmntArtclComCd || undefined}
onValueChange={(value) =>
updateRow(row.rowKey, { splmntArtclComCd: value })