[as] WIP: 하수도기본계획 신청 페이지(이전기본계획조회)

This commit is contained in:
2026-07-30 11:23:44 +09:00
parent b8b0b24b0e
commit e56938f7ff
17 changed files with 612 additions and 164 deletions
@@ -2,9 +2,12 @@ import { SewerBscPlanEditPage } 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 <SewerBscPlanEditPage id={id} />;
const { chgCycl } = await searchParams;
const propChgCycl = chgCycl ? Number(chgCycl) : undefined;
return <SewerBscPlanEditPage id={id} propChgCycl={propChgCycl} />;
}
+28 -2
View File
@@ -2,6 +2,11 @@ import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
ClsfSystmRequestDto,
ClsfSystmResponseDto,
DcService,
LvNowDmgMtncGnsvyResponseDto,
LvNowDmgMtncGnsvySearchDto,
NowDmgMtncGnsvyResponseDto,
NowDmgMtncGnsvySearchDto,
} from "../model/dc-interface";
@@ -10,10 +15,31 @@ async function getNowDmgMtncGnsvy(
params: NowDmgMtncGnsvySearchDto,
): Promise<NowDmgMtncGnsvyResponseDto[]> {
const response = await client.get<NowDmgMtncGnsvyResponseDto[]>(
ENDPOINT.DC_SERVICE.NOW_DMG_MTNC_GNSVY,
`${ENDPOINT.DC_SERVICE}/nowDmgMtncGnsvy`,
{ params },
);
return response.data;
}
export { getNowDmgMtncGnsvy };
async function getLvNowDmgMtncGnsvy(
params: LvNowDmgMtncGnsvySearchDto,
): Promise<LvNowDmgMtncGnsvyResponseDto[]> {
const response = await client.get<LvNowDmgMtncGnsvyResponseDto[]>(
`${ENDPOINT.DC_SERVICE}/lvNowDmgMtncGnsvy`,
{ params },
);
return response.data;
}
async function getClsfSystm(params: ClsfSystmRequestDto): Promise<ClsfSystmResponseDto[]> {
const response = await client.get<ClsfSystmResponseDto[]>(`${ENDPOINT.DC_SERVICE}/clsfSystm`, {
params,
});
return response.data;
}
export const dcService = {
getNowDmgMtncGnsvy,
getLvNowDmgMtncGnsvy,
getClsfSystm,
} satisfies DcService;
+24 -5
View File
@@ -2,16 +2,35 @@ import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type { NowDmgMtncGnsvySearchDto } from "../model/dc-interface";
import type {
ClsfSystmRequestDto,
LvNowDmgMtncGnsvySearchDto,
NowDmgMtncGnsvySearchDto,
} from "../model/dc-interface";
import { getNowDmgMtncGnsvy } from "./dc-service";
import { dcService } from "./dc-service";
export const nowDmgMtncGnsvyBaseKey = [ENDPOINT.DC_SERVICE.NOW_DMG_MTNC_GNSVY] as const;
export const baseKey = [ENDPOINT.DC_SERVICE] as const;
export function useNowDmgMtncGnsvy(params: NowDmgMtncGnsvySearchDto) {
return useQuery({
queryKey: [...nowDmgMtncGnsvyBaseKey, params],
queryFn: () => getNowDmgMtncGnsvy(params),
queryKey: [...baseKey, "nowDmgMtncGnsvy", params],
queryFn: () => dcService.getNowDmgMtncGnsvy(params),
enabled: Boolean(params.swtrmtFcltCd && params.ctpvNm),
});
}
export function useLvNowDmgMtncGnsvy(params: LvNowDmgMtncGnsvySearchDto) {
return useQuery({
queryKey: [...baseKey, "lvNowDmgMtncGnsvy", params],
queryFn: () => dcService.getLvNowDmgMtncGnsvy(params),
enabled: Boolean(params.level && params.ctpvNm && params.sggNm && params.gnsvyYr),
});
}
export function useClsfSystm(params: ClsfSystmRequestDto) {
return useQuery({
queryKey: [...baseKey, "clsfSystm", params],
queryFn: () => dcService.getClsfSystm(params),
});
}
+3 -6
View File
@@ -1,6 +1,3 @@
export { getNowDmgMtncGnsvy } from "./api/dc-service";
export { nowDmgMtncGnsvyBaseKey, useNowDmgMtncGnsvy } from "./api/queries";
export type {
NowDmgMtncGnsvyResponseDto,
NowDmgMtncGnsvySearchDto,
} from "./model/dc-interface";
export { dcService } from "./api/dc-service";
export { baseKey as nowDmgMtncGnsvyBaseKey, useNowDmgMtncGnsvy } from "./api/queries";
export type { NowDmgMtncGnsvyResponseDto, NowDmgMtncGnsvySearchDto } from "./model/dc-interface";
+37 -1
View File
@@ -5,6 +5,18 @@ export interface NowDmgMtncGnsvySearchDto {
targetYear?: string;
}
export interface LvNowDmgMtncGnsvySearchDto {
level?: string;
ctpvNm?: string;
sggNm?: string;
gnsvyYr?: string;
}
export interface ClsfSystmRequestDto {
clsfSystmNm?: string;
upClsfSystmSn?: string;
}
type NullableNumber = number | null;
type NullableString = string | null;
@@ -46,7 +58,6 @@ type StagedMetricFields = {
[K in keyof NowDmgMtncGnsvyMetricFields as `${K & string}${StageSuffix}`]?: NowDmgMtncGnsvyMetricFields[K];
};
/** DC nowDmgMtncGnsvy 응답 DTO */
export interface NowDmgMtncGnsvyResponseDto
extends NowDmgMtncGnsvyMetricFields, StagedMetricFields {
swtrmtFcltCd?: NullableString;
@@ -55,3 +66,28 @@ export interface NowDmgMtncGnsvyResponseDto
ctpvNm?: NullableString;
sggNm?: NullableString;
}
export interface LvNowDmgMtncGnsvyResponseDto {
level: number;
ctpvNm: string;
sggNm: string;
gnsvyYr: string;
swtrmtFcltCd: string;
swtrmtFcltNm: string;
children: LvNowDmgMtncGnsvyResponseDto[];
}
export interface ClsfSystmResponseDto {
clsfSystmSn: string;
upClsfSystmSn: string;
clsfSystmNm: string;
sortSeq: string;
droppAble: boolean;
path: string;
}
export interface DcService {
getNowDmgMtncGnsvy(params: NowDmgMtncGnsvySearchDto): Promise<NowDmgMtncGnsvyResponseDto[]>;
getLvNowDmgMtncGnsvy(params: LvNowDmgMtncGnsvySearchDto): Promise<LvNowDmgMtncGnsvyResponseDto[]>;
getClsfSystm(params: ClsfSystmRequestDto): Promise<ClsfSystmResponseDto[]>;
}
@@ -4,6 +4,7 @@ import type { PageResponse } from "@/shared/model";
import type {
SewerBscPlanApplySaveRequestDto,
SewerBscPlanApplySaveResponseDto,
SewerBscPlanDetResponseDto,
SewerBscPlanResponseDto,
SewerBscPlanSaveRequestDto,
@@ -45,8 +46,10 @@ async function deleteFn(sewerBscPlanDocNo: string): Promise<void> {
await client.delete<void>(`${BASE}/${sewerBscPlanDocNo}`);
}
async function saveApply(body: SewerBscPlanApplySaveRequestDto): Promise<string> {
const response = await client.post<string>(`${BASE}/save-apply`, body);
async function saveApply(
body: SewerBscPlanApplySaveRequestDto,
): Promise<SewerBscPlanApplySaveResponseDto> {
const response = await client.post<SewerBscPlanApplySaveResponseDto>(`${BASE}/save-apply`, body);
return response.data;
}
@@ -19,6 +19,7 @@ export type {
EsntlAtchFileItem,
EsntlAtchFileMngTreeResponseDto,
SewerBscPlanApplySaveRequestDto,
SewerBscPlanApplySaveResponseDto,
SewerBscPlanBizCstItem,
SewerBscPlanBizCstResponseDto,
SewerBscPlanDetResponseDto,
@@ -307,6 +307,12 @@ export interface SewerBscPlanApplySaveRequestDto extends PbadmsApplySaveHeader {
tmplntTmprStrgList?: SewerBscPlanTmplntTmprStrgItem[];
}
/** 백엔드 SewerBscPlanApplySaveResponseDto */
export interface SewerBscPlanApplySaveResponseDto {
sewerBscPlanDocNo: string;
chgCycl: number;
}
/** 백엔드 SewerBscPlanDetResponseDto */
export interface SewerBscPlanDetResponseDto {
detDto?: SewerBscPlanResponseDto;
@@ -333,7 +339,7 @@ export interface SewerBscPlanService {
body: SewerBscPlanUpdateRequestDto,
): Promise<SewerBscPlanResponseDto>;
delete(sewerBscPlanDocNo: string): Promise<void>;
saveApply(body: SewerBscPlanApplySaveRequestDto): Promise<string>;
saveApply(body: SewerBscPlanApplySaveRequestDto): Promise<SewerBscPlanApplySaveResponseDto>;
findDetails(sewerBscPlanDocNo: string, chgCycl: number): Promise<SewerBscPlanDetResponseDto>;
submitSupplement(id: string, body: SewerBscPlanApplySaveRequestDto): Promise<string>;
submitReqSupplement(
@@ -9,6 +9,8 @@ import { recalcFnrsCstSumAmt } from "./fnrs-cst-form-utils";
import { type SewerBscPlanRegistFormData } from "./sewer-bsc-plan-regist-schema";
const PLAN_STEPS = [1, 2, 3, 4] as const;
/** 처리시설 상세: 0=현재, 1~4=변경 단계 */
const TMPLNT_DTL_STEPS = [0, 1, 2, 3, 4] as const;
function createEmptyOtlnList() {
return PLAN_STEPS.map((planStpNo) => ({ planStpNo }));
@@ -38,7 +40,7 @@ function mapEsntlAtchFileList(esntlFileList: SewerBscPlanDetResponseDto["esntlFi
}
function createEmptyTmplntDtlList(swtrmtFcltCd: string) {
return PLAN_STEPS.map((planStpNo) => ({
return TMPLNT_DTL_STEPS.map((planStpNo) => ({
planStpNo,
swtrmtFcltCd,
prcsZoneArea: 0,
@@ -183,6 +185,7 @@ export function mapDetailToFormData(data: SewerBscPlanDetResponseDto): SewerBscP
swtrmtFcltCd: code,
swtrmtFcltNm: item.swtrmtFcltNm,
atchFileGroupId: item.atchFileGroupId,
atchFileNm: item.atchFileInfo?.orgnlFileNm,
sewerBscPlanTmplntDtlList: createEmptyTmplntDtlList(code).map((empty) => {
const dtlItem = dtlMap.get(empty.planStpNo);
if (!dtlItem) return empty;
@@ -257,11 +260,14 @@ export function buildApplyPayload(
sewerBscPlanOtlnList: data.sewerBscPlanOtlnList,
bizCstList: data.bizCstList,
fnrsCstList: data.fnrsCstList.map((item) => recalcFnrsCstSumAmt(item)),
sewerBscPlanTmplntList: data.sewerBscPlanTmplntList.map((item) => ({
swtrmtFcltCd: item.swtrmtFcltCd,
atchFileGroupId: item.atchFileGroupId,
sewerBscPlanTmplntDtlList: item.sewerBscPlanTmplntDtlList,
})),
sewerBscPlanTmplntList: data.sewerBscPlanTmplntList.map(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
({ atchFileNm, swtrmtFcltNm, ...item }) => ({
swtrmtFcltCd: item.swtrmtFcltCd,
atchFileGroupId: item.atchFileGroupId,
sewerBscPlanTmplntDtlList: item.sewerBscPlanTmplntDtlList,
}),
),
cklstResultList: data.cklstResultList.map((item) => ({
...item,
selected: item.selected ?? undefined,
@@ -86,6 +86,7 @@ export const sewerBscPlanRegistSchema = z.object({
swtrmtFcltCd: z.string(),
swtrmtFcltNm: z.string().optional(),
atchFileGroupId: z.string().optional(),
atchFileNm: z.string().optional(),
sewerBscPlanTmplntDtlList: z.array(tmplntDtlSchema).optional(),
}),
),
@@ -0,0 +1,70 @@
import type { NowDmgMtncGnsvyResponseDto } from "@/entities/dc";
/**
* 폼 매트릭스 행 키 ↔ DC 당초(Dc) 필드 prefix.
* API에 대응 필드가 없으면 null (당초 셀 `-`).
*/
const TMPLNT_DC_FIELD_PREFIX: Record<string, string | null> = {
prcsZoneArea: "swtrmtZoneArea",
swtrmtPpltnCnt: "swtrmtPpltnCnt",
planSwqty: "planSwageQntSum",
fcltCpct: "fcltCpctSum",
sprsysDrtwPplLen: "brcPplSum",
sprsysExlnPplLen: "exclnPplSum",
cbsysPplLen: null,
icptgPplLen: "icptgPplSum",
drgCnt: "mltvFclSum",
drtwRlyPumpCnt: null,
drtwPumpCnt: "pmstnSum",
exlnPumpCnt: null,
};
export type TmplntFieldKey =
| "prcsZoneArea"
| "swtrmtPpltnCnt"
| "planSwqty"
| "fcltCpct"
| "sprsysDrtwPplLen"
| "sprsysExlnPplLen"
| "cbsysPplLen"
| "icptgPplLen"
| "drgCnt"
| "drtwRlyPumpCnt"
| "drtwPumpCnt"
| "exlnPumpCnt";
export const PLAN_STP_NOS = [1, 2, 3, 4] as const;
export type PlanStpNo = (typeof PLAN_STP_NOS)[number];
function formatOriginalValue(value: string | number | null | undefined): string {
if (value === undefined || value === null || value === "") return "-";
const num = Number(value);
if (!Number.isNaN(num)) return num.toLocaleString();
return String(value);
}
function getOriginalYear(
data: NowDmgMtncGnsvyResponseDto | null | undefined,
planStpNo: PlanStpNo,
): string {
if (!data) return "-";
const key = `planYrDc${planStpNo}` as keyof NowDmgMtncGnsvyResponseDto;
const value = data[key];
if (value === undefined || value === null || value === "") return "-";
return String(value);
}
function getOriginalValue(
data: NowDmgMtncGnsvyResponseDto | null | undefined,
fieldKey: TmplntFieldKey,
planStpNo: PlanStpNo,
): string {
if (!data) return "-";
const prefix = TMPLNT_DC_FIELD_PREFIX[fieldKey];
if (!prefix) return "-";
const key = `${prefix}Dc${planStpNo}` as keyof NowDmgMtncGnsvyResponseDto;
return formatOriginalValue(data[key] as string | number | null | undefined);
}
export { getOriginalValue, getOriginalYear };
@@ -1,14 +1,19 @@
"use client";
import { useMemo, useState } from "react";
import { useFieldArray, useFormContext, useWatch } from "react-hook-form";
import { useId, useMemo, useState } from "react";
import { Controller, useFieldArray, useFormContext, useWatch } from "react-hook-form";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Paperclip } from "lucide-react";
import { getDefaultFileKey, useTus } from "@/shared/lib";
import { Field } from "@/shared/ui/field";
import { attachService, useDownloadAttachmentByAtflCd } from "@/entities/attach";
import { useFacilityList } from "@/entities/bns-if";
import { useNowDmgMtncGnsvy } from "@/entities/dc";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import {
Button,
Checkbox,
Input,
Table,
@@ -25,66 +30,386 @@ import {
import { createEmptyTmplntDtlList } from "../../lib/form-data-mapper";
import type { SewerBscPlanRegistFormData } from "../../lib/sewer-bsc-plan-regist-schema";
import {
getOriginalValue,
getOriginalYear,
PLAN_STP_NOS,
type TmplntFieldKey,
} from "../../lib/tmplnt-original-mapper";
interface FacilityOption {
code: string;
name: string;
}
const TMPLNT_FIELD_ROWS = [
{ key: "prcsZoneArea" as const, label: "처리구역면적(km²)" },
{ key: "swtrmtPpltnCnt" as const, label: "하수처리인구(명)" },
{ key: "planSwqty" as const, label: "계획하수량(㎥/일)" },
{ key: "fcltCpct" as const, label: "시설용량(㎥/일)" },
{ key: "sprsysDrtwPplLen" as const, label: "분류식 오수(km)" },
{ key: "sprsysExlnPplLen" as const, label: "분류식 우수(km)" },
{ key: "cbsysPplLen" as const, label: "합류식(km)" },
{ key: "icptgPplLen" as const, label: "차집관로(km)" },
{ key: "drgCnt" as const, label: "배수설비(개소)" },
{ key: "drtwRlyPumpCnt" as const, label: "오수 중계 펌프(개소)" },
{ key: "drtwPumpCnt" as const, label: "오수 일반 펌프(개소)" },
{ key: "exlnPumpCnt" as const, label: "우수 펌프(개소)" },
interface ChangeStepHeader {
planStpNo: (typeof PLAN_STP_NOS)[number];
year: string | undefined;
}
interface UploadedTmplntFile {
fileName: string;
fileKey: string;
atflCd?: string;
atflId?: string;
}
const TMPLNT_ATCH_ACCEPT = ".pdf,.png,.jpg,.jpeg";
const TMPLNT_ATCH_FORMAT_LABEL = ".pdf, .png, .jpg, .jpeg";
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: "우수 펌프(개소)" },
];
interface TmplntDtlInputProps {
facilityIndex: number;
planStpNo: number;
fieldKey: TmplntFieldKey;
}
function TmplntDtlInput({ facilityIndex, planStpNo, fieldKey }: TmplntDtlInputProps) {
const { control } = useFormContext<SewerBscPlanRegistFormData>();
const fieldName =
`sewerBscPlanTmplntList.${facilityIndex}.sewerBscPlanTmplntDtlList.${planStpNo}.${fieldKey}` as const;
return (
<Controller
control={control}
name={fieldName}
render={({ field }) => (
<Field>
<Input
type="text"
inputMode="decimal"
className="text-right"
value={field.value ?? ""}
onChange={(e) => {
const numValue = Number(e.target.value.replace(/,/g, "")) || 0;
field.onChange(numValue);
}}
/>
</Field>
)}
/>
);
}
interface TmplntFacilityMatrixProps {
swtrmtFcltCd: string;
sido: string;
gugun: string;
facilityIndex: number;
crtrYr: string | undefined;
changeStepHeaders: ChangeStepHeader[];
}
function TmplntFacilityMatrix({
swtrmtFcltCd,
sido,
gugun,
facilityIndex,
crtrYr,
changeStepHeaders,
}: TmplntFacilityMatrixProps) {
const searchParams = useMemo(
() => ({
swtrmtFcltCd,
ctpvNm: sido,
sggNm: gugun,
}),
[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 key={`current-${row.key}`}>
<TmplntDtlInput facilityIndex={facilityIndex} planStpNo={0} fieldKey={row.key} />
</TableCell>
{changeStepHeaders.map((step) => (
<TableCell key={`change-${row.key}-${step.planStpNo}`}>
<TmplntDtlInput
facilityIndex={facilityIndex}
planStpNo={step.planStpNo}
fieldKey={row.key}
/>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
}
interface TmplntFacilityAttachmentsProps {
facilityIndex: number;
facilityName?: string;
atchFileGroupId?: string;
atchFileNm?: string;
}
function TmplntFacilityAttachments({
facilityIndex,
facilityName,
atchFileGroupId,
atchFileNm,
}: TmplntFacilityAttachmentsProps) {
const formId = useId();
const { setValue } = useFormContext<SewerBscPlanRegistFormData>();
const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
const [uploadedFile, setUploadedFile] = useState<UploadedTmplntFile | null>(() =>
atchFileGroupId && atchFileNm
? {
fileName: atchFileNm,
fileKey: `server::${atchFileGroupId}`,
atflCd: atchFileGroupId,
}
: null,
);
const { uploadFileAsync, isUploading, uploads } = useTus({
accept: TMPLNT_ATCH_ACCEPT,
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setValue(`sewerBscPlanTmplntList.${facilityIndex}.atchFileGroupId`, atflCd, {
shouldDirty: true,
});
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
const fileInputId = `${formId}-tmplnt-atch-${facilityIndex}`;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
const displayFileName = uploadedFile?.fileName ?? atchFileNm ?? "";
const downloadAtflCd = uploadedFile?.atflCd || atchFileGroupId;
const hasFile = Boolean(uploadedFile || atchFileNm);
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = getDefaultFileKey(file);
const existingAtflCd = atchFileGroupId || uploadedFile?.atflCd;
setUploadedFile({ fileName: file.name, fileKey });
setValue(`sewerBscPlanTmplntList.${facilityIndex}.atchFileNm`, file.name, {
shouldDirty: true,
});
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);
setValue(`sewerBscPlanTmplntList.${facilityIndex}.atchFileGroupId`, "", { shouldDirty: true });
setValue(`sewerBscPlanTmplntList.${facilityIndex}.atchFileNm`, "", { shouldDirty: true });
}
return (
<div className="mt-6 space-y-2">
<h3 className="mb-4 text-xl font-semibold">처리구역 필수 첨부파일</h3>
<Table variant="secondary">
<colgroup>
<col width="40%" />
<col width="15%" />
<col width="45%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead align="left">파일명</TableHead>
<TableHead>파일 확장자</TableHead>
<TableHead>파일첨부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>{facilityName} 계획평면도</TableCell>
<TableCell className="text-center">{TMPLNT_ATCH_FORMAT_LABEL}</TableCell>
<TableCell>
{hasFile ? (
<div className="flex items-center gap-2">
{downloadAtflCd ? (
<a
className="inline-flex cursor-pointer items-center gap-1 text-blue-600 hover:underline"
onClick={() => downloadAttachment(downloadAtflCd)}
>
<Paperclip className="size-5 shrink-0" />
{displayFileName}
</a>
) : (
<span className="text-xl">{displayFileName}</span>
)}
<Button
type="button"
size="icon"
className="size-8 shrink-0 !bg-destructive"
disabled={uploading}
onClick={() => void handleFileRemove()}
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
</div>
) : (
<label htmlFor={fileInputId}>
<input
id={fileInputId}
type="file"
className="hidden"
accept={TMPLNT_ATCH_ACCEPT}
onChange={(e) => {
void handleFileUpload(e.target.files);
e.target.value = "";
}}
/>
<Button type="button" size="sm" variant="outline" asChild>
<span>파일선택</span>
</Button>
</label>
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
);
}
function TmplntFormSection() {
const { confirm } = useConfirm();
const { control, setValue, getValues } = useFormContext<SewerBscPlanRegistFormData>();
const { control, getValues } = useFormContext<SewerBscPlanRegistFormData>();
const { fields, append, remove } = useFieldArray({
control,
name: "sewerBscPlanTmplntList",
});
const tmplntList = useWatch({ control, name: "sewerBscPlanTmplntList" }) ?? [];
const sido = useWatch({ control, name: "sewerBscPlan.sido" }) ?? "";
const gugun = useWatch({ control, name: "sewerBscPlan.gugun" }) ?? "";
const frlvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.frlvlGoalYr" });
const sclvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.sclvlGoalYr" });
const thlvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.thlvlGoalYr" });
const folvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.folvlGoalYr" });
const { data: facilityListData } = useFacilityList({ sido, gugun }, { enabled: Boolean(sido) });
const facilityOptions = useMemo(
() =>
(facilityListData?.fctList ?? [])
.filter((facility) => facility.bscFctCd)
.map((facility) => ({
code: facility.bscFctCd!,
name: facility.bscFctNm ?? facility.bscFctCd!,
})),
[facilityListData?.fctList],
const [sido, gugun] = useWatch({
control,
name: ["sewerBscPlan.sido", "sewerBscPlan.gugun"],
});
const [frlvlGoalYr, sclvlGoalYr, thlvlGoalYr, folvlGoalYr, crtrYr] = useWatch({
control,
name: [
"sewerBscPlanDtl.frlvlGoalYr",
"sewerBscPlanDtl.sclvlGoalYr",
"sewerBscPlanDtl.thlvlGoalYr",
"sewerBscPlanDtl.folvlGoalYr",
"sewerBscPlanDtl.crtrYr",
],
});
const { data: facilityListData } = useFacilityList(
{ sido: sido ?? "", gugun: gugun ?? "" },
{ enabled: Boolean(sido) },
);
const facilityOptions =
facilityListData?.fctList
?.filter((facility) => facility.bscFctCd)
.map((facility) => ({
code: facility.bscFctCd!,
name: facility.bscFctNm ?? facility.bscFctCd!,
})) ?? [];
const [activeTab, setActiveTab] = useState("");
const selectedCodes = useMemo(
() => new Set(tmplntList.map((item) => item.swtrmtFcltCd)),
[tmplntList],
);
const selectedCodes = new Set(tmplntList.map((item) => item.swtrmtFcltCd));
const stepHeaders = [
{ planStpNo: 1, label: "변경 1단계", year: frlvlGoalYr },
{ planStpNo: 2, label: "변경 2단계", year: sclvlGoalYr },
{ planStpNo: 3, label: "변경 3단계", year: thlvlGoalYr },
{ planStpNo: 4, label: "변경 4단계", year: folvlGoalYr },
const changeStepHeaders: ChangeStepHeader[] = [
{ planStpNo: 1, year: frlvlGoalYr },
{ planStpNo: 2, year: sclvlGoalYr },
{ planStpNo: 3, year: thlvlGoalYr },
{ planStpNo: 4, year: folvlGoalYr },
];
async function handleFacilityToggle(option: FacilityOption, checked: boolean) {
@@ -93,13 +418,14 @@ function TmplntFormSection() {
swtrmtFcltCd: option.code,
swtrmtFcltNm: option.name,
atchFileGroupId: "",
atchFileNm: "",
sewerBscPlanTmplntDtlList: createEmptyTmplntDtlList(option.code),
});
setActiveTab(option.code);
return;
}
if (!(await confirm(`「${option.name}」을(를) 제거하시겠습니까?`))) return;
if (!(await confirm(`${option.name}을(를) 제거하시겠습니까?`))) return;
const index = getValues("sewerBscPlanTmplntList").findIndex(
(item) => item.swtrmtFcltCd === option.code,
@@ -111,26 +437,6 @@ function TmplntFormSection() {
setActiveTab(remaining[0]?.swtrmtFcltCd ?? "");
}
function handleDtlChange(
facilityIndex: number,
planStpNo: number,
fieldKey: (typeof TMPLNT_FIELD_ROWS)[number]["key"],
value: string,
) {
const numValue = Number(value.replace(/,/g, "")) || 0;
const list = [...getValues("sewerBscPlanTmplntList")];
const facility = list[facilityIndex];
if (!facility) return;
const dtlList = [...(facility.sewerBscPlanTmplntDtlList ?? [])];
const dtlIndex = dtlList.findIndex((item) => item.planStpNo === planStpNo);
if (dtlIndex < 0) return;
dtlList[dtlIndex] = { ...dtlList[dtlIndex], [fieldKey]: numValue };
list[facilityIndex] = { ...facility, sewerBscPlanTmplntDtlList: dtlList };
setValue("sewerBscPlanTmplntList", list, { shouldDirty: true });
}
const resolvedActiveTab =
activeTab && selectedCodes.has(activeTab) ? activeTab : (tmplntList[0]?.swtrmtFcltCd ?? "");
@@ -142,7 +448,7 @@ function TmplntFormSection() {
<p className="text-xl text-muted-foreground">조회된 처리시설이 없습니다.</p>
) : (
facilityOptions.map((option) => (
<label key={option.code} className="flex items-center gap-2 text-xl">
<label key={option.code} className="flex cursor-pointer items-center gap-2 text-xl">
<Checkbox
checked={selectedCodes.has(option.code)}
onCheckedChange={(checked) => handleFacilityToggle(option, checked === true)}
@@ -156,71 +462,46 @@ function TmplntFormSection() {
{fields.length > 0 ? (
<Tabs value={resolvedActiveTab} onValueChange={setActiveTab} variant="underline">
<TabsList className="p-0">
{fields.map((field) => (
<TabsTrigger
key={field.id}
value={field.swtrmtFcltCd}
className="px-6 py-3 text-2xl"
>
{field.swtrmtFcltNm || field.swtrmtFcltCd}
</TabsTrigger>
))}
{fields.map((field) => {
const facilityName =
field.swtrmtFcltNm ||
facilityOptions.find((option) => option.code === field.swtrmtFcltCd)?.name;
return (
<TabsTrigger
key={field.id}
value={field.swtrmtFcltCd}
className="px-6 py-3 text-2xl"
>
{facilityName}
</TabsTrigger>
);
})}
</TabsList>
{fields.map((field, facilityIndex) => {
const dtlList = tmplntList[facilityIndex]?.sewerBscPlanTmplntDtlList ?? [];
const facilityName =
field.swtrmtFcltNm ||
facilityOptions.find((option) => option.code === field.swtrmtFcltCd)?.name;
return (
<TabsContent
key={field.id}
value={field.swtrmtFcltCd}
className="mt-0 border-0 p-0 shadow-none"
>
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead className="w-[22rem]">구분</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>
{TMPLNT_FIELD_ROWS.map((row) => (
<TableRow key={row.key}>
<TableHead>{row.label}</TableHead>
{stepHeaders.map((step) => {
const dtl = dtlList.find((item) => item.planStpNo === step.planStpNo);
return (
<TableCell key={step.planStpNo}>
<Field>
<Input
type="text"
inputMode="decimal"
className="text-right"
value={dtl?.[row.key] ?? 0}
onChange={(e) =>
handleDtlChange(
facilityIndex,
step.planStpNo,
row.key,
e.target.value,
)
}
/>
</Field>
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</Table>
<TmplntFacilityMatrix
swtrmtFcltCd={field.swtrmtFcltCd}
sido={sido ?? ""}
gugun={gugun ?? ""}
facilityIndex={facilityIndex}
crtrYr={crtrYr}
changeStepHeaders={changeStepHeaders}
/>
<TmplntFacilityAttachments
facilityIndex={facilityIndex}
facilityName={facilityName}
atchFileGroupId={tmplntList[facilityIndex]?.atchFileGroupId}
atchFileNm={tmplntList[facilityIndex]?.atchFileNm}
/>
</TabsContent>
);
})}
@@ -1,20 +1,20 @@
"use client";
import { useSewerBscPlan, useSewerBscPlanDetails } from "@/entities/sewer-bsc-plan";
import { useSewerBscPlanDetails } from "@/entities/sewer-bsc-plan";
import { Skeleton } from "@common/components";
import { SewerBscPlanFormPage } from "./sewer-bsc-plan-form-page";
interface SewerBscPlanEditPageProps {
id: string;
propChgCycl?: number;
}
function SewerBscPlanEditPage({ id }: SewerBscPlanEditPageProps) {
const { data: master, isLoading: isMasterLoading } = useSewerBscPlan(id);
const chgCycl = master?.chgCycl ?? 0;
function SewerBscPlanEditPage({ id, propChgCycl }: SewerBscPlanEditPageProps) {
const chgCycl = propChgCycl ?? 1;
const { data, isLoading: isDetailsLoading } = useSewerBscPlanDetails(id, chgCycl);
if (isMasterLoading || isDetailsLoading) {
if (isDetailsLoading) {
return (
<div className="mt-6 space-y-6 px-4">
<div className="space-y-2">
@@ -11,6 +11,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { useAcntgSeMngTree } from "@/entities/acntg";
import {
SewerBscPlanApplySaveResponseDto,
sewerBscPlanBaseKey,
type SewerBscPlanDetResponseDto,
sewerBscPlanService,
@@ -98,7 +99,7 @@ function SewerBscPlanFormPage({
async function submitForm(
data: SewerBscPlanRegistFormData,
prgrsSttsComCd: "SV" | "LE",
): Promise<string | undefined> {
): Promise<SewerBscPlanApplySaveResponseDto | undefined> {
if (!assertFnrsCstBalance(data)) return undefined;
const payload = buildApplyPayload(data, prgrsSttsComCd, {
@@ -113,11 +114,11 @@ function SewerBscPlanFormPage({
try {
setIsSubmitting(true);
const docNo = await submitForm(data, "SV");
if (!docNo) return;
const result = await submitForm(data, "SV");
if (!result?.sewerBscPlanDocNo) return;
alert("저장되었습니다.");
router.push(`/sewer-bsc-plan/${docNo}/edit`);
router.push(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}/edit?chgCycl=${result.chgCycl}`);
if (isEditMode && id) {
queryClient.invalidateQueries({
queryKey: [...sewerBscPlanBaseKey, "findDetails", id],
@@ -54,7 +54,7 @@ function SewerBscPlanList({ searchParams, pagination, onPaginationChange }: Sewe
router.push(
canEdit
? `/sewer-bsc-plan/${row.sewerBscPlanDocNo}/edit`
? `/sewer-bsc-plan/${row.sewerBscPlanDocNo}/edit?chgCycl=${row.chgCycl}`
: `/sewer-bsc-plan/${row.sewerBscPlanDocNo}`,
);
};
+1 -3
View File
@@ -44,9 +44,7 @@ export const ENDPOINT = {
STATVERIFY_SERVICE: {
ST_ANLS_MST: "/statverify-service/api/v1/st-anls-mst",
},
DC_SERVICE: {
NOW_DMG_MTNC_GNSVY: "/dc-service/api/v1/nowDmgMtncGnsvy",
},
DC_SERVICE: "/dc-service/api/v1",
};
export const JWT_EXPIRATION_TIME = 1800000;
@@ -374,9 +374,9 @@ function AttachmentsFormSection({
<h3 className="mb-4 text-xl font-semibold">필수 첨부파일</h3>
<Table variant="secondary">
<colgroup>
<col width="50%" />
<col width="10%" />
<col width="40%" />
<col width="15%" />
<col width="45%" />
</colgroup>
<TableHeader>
<TableRow>
@@ -384,13 +384,13 @@ function AttachmentsFormSection({
파일명
</TableHead>
<TableHead>파일 확장자</TableHead>
<TableHead>업로드된 파일</TableHead>
<TableHead>파일첨부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{essentialFiles.length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="py-12 text-center text-muted-foreground">
<TableCell colSpan={3} className="py-12 text-center text-muted-foreground">
조회된 필수 첨부파일 항목이 없습니다.
</TableCell>
</TableRow>
@@ -446,7 +446,7 @@ function AttachmentsFormSection({
}}
/>
<Button size="sm" variant="outline" asChild>
<span>파일 선택</span>
<span>파일선택</span>
</Button>
</label>
)}