[as] WIP: 기본계획조회 다이얼로그 구현

This commit is contained in:
2026-07-31 09:30:09 +09:00
parent e56938f7ff
commit e67492853c
12 changed files with 1488 additions and 17 deletions
+28 -2
View File
@@ -2,9 +2,11 @@ import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
ClsfSystmRequestDto,
ClsfSystmParamsDto,
ClsfSystmResponseDto,
DcService,
FileDownloadParamsDto,
FileParamsDto,
LvNowDmgMtncGnsvyResponseDto,
LvNowDmgMtncGnsvySearchDto,
NowDmgMtncGnsvyResponseDto,
@@ -31,15 +33,39 @@ async function getLvNowDmgMtncGnsvy(
return response.data;
}
async function getClsfSystm(params: ClsfSystmRequestDto): Promise<ClsfSystmResponseDto[]> {
async function getClsfSystm(params: ClsfSystmParamsDto): Promise<ClsfSystmResponseDto[]> {
const response = await client.get<ClsfSystmResponseDto[]>(`${ENDPOINT.DC_SERVICE}/clsfSystm`, {
params,
});
return response.data;
}
async function getFile(params: FileParamsDto): Promise<unknown> {
const response = await client.get<unknown>(`${ENDPOINT.DC_SERVICE}/file`, {
params,
});
return response.data;
}
async function getFilePrcs(fileSn: string): Promise<unknown> {
const response = await client.get<unknown>(`${ENDPOINT.DC_SERVICE}/fileprcs`, {
params: { fileSn },
});
return response.data;
}
async function fileDownload(params: FileDownloadParamsDto): Promise<unknown> {
const response = await client.get<unknown>(`${ENDPOINT.DC_SERVICE}/fileDownload`, {
params,
});
return response.data;
}
export const dcService = {
getNowDmgMtncGnsvy,
getLvNowDmgMtncGnsvy,
getClsfSystm,
getFile,
getFilePrcs,
fileDownload,
} satisfies DcService;
+32 -3
View File
@@ -3,7 +3,8 @@ import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type {
ClsfSystmRequestDto,
ClsfSystmParamsDto,
FileParamsDto,
LvNowDmgMtncGnsvySearchDto,
NowDmgMtncGnsvySearchDto,
} from "../model/dc-interface";
@@ -20,17 +21,45 @@ export function useNowDmgMtncGnsvy(params: NowDmgMtncGnsvySearchDto) {
});
}
function isLvNowDmgMtncGnsvyEnabled(params: LvNowDmgMtncGnsvySearchDto): boolean {
if (!params.level || !params.ctpvNm) return false;
if (params.level === "1") return true;
if (params.level === "2") return Boolean(params.sggNm);
if (params.level === "3") return Boolean(params.sggNm && params.gnsvyYr);
return false;
}
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),
enabled: isLvNowDmgMtncGnsvyEnabled(params),
});
}
export function useClsfSystm(params: ClsfSystmRequestDto) {
export function useClsfSystm(params: ClsfSystmParamsDto) {
return useQuery({
queryKey: [...baseKey, "clsfSystm", params],
queryFn: () => dcService.getClsfSystm(params),
// TODO: T4 — upClsfSystmSn 점진 로드 여부 확정 후 enabled/파라미터 조정
enabled: Boolean(params.clsfSystmNm),
});
}
export function useFile(params: FileParamsDto) {
return useQuery({
queryKey: [...baseKey, "file", params],
queryFn: () => dcService.getFile(params),
// TODO: T2 — getFile Response DTO 확정 후 타입 교체
enabled: Boolean(params.clsfSystmSn),
});
}
export function useFilePrcs(fileSn: string) {
return useQuery({
queryKey: [...baseKey, "filePrcs", fileSn],
queryFn: () => dcService.getFilePrcs(fileSn),
// TODO: T2 — getFilePrcs Response DTO 확정 후 타입 교체
enabled: Boolean(fileSn),
});
}
+18 -2
View File
@@ -1,3 +1,19 @@
export { dcService } from "./api/dc-service";
export { baseKey as nowDmgMtncGnsvyBaseKey, useNowDmgMtncGnsvy } from "./api/queries";
export type { NowDmgMtncGnsvyResponseDto, NowDmgMtncGnsvySearchDto } from "./model/dc-interface";
export {
baseKey as nowDmgMtncGnsvyBaseKey,
useClsfSystm,
useFile,
useFilePrcs,
useLvNowDmgMtncGnsvy,
useNowDmgMtncGnsvy,
} from "./api/queries";
export type {
ClsfSystmParamsDto,
ClsfSystmResponseDto,
FileDownloadParamsDto,
FileParamsDto,
LvNowDmgMtncGnsvyResponseDto,
LvNowDmgMtncGnsvySearchDto,
NowDmgMtncGnsvyResponseDto,
NowDmgMtncGnsvySearchDto,
} from "./model/dc-interface";
+30 -9
View File
@@ -12,11 +12,22 @@ export interface LvNowDmgMtncGnsvySearchDto {
gnsvyYr?: string;
}
export interface ClsfSystmRequestDto {
export interface ClsfSystmParamsDto {
clsfSystmNm?: string;
upClsfSystmSn?: string;
}
export interface FileParamsDto {
sort?: string;
clsfSystmSn?: string;
vetPrcsYn?: string;
}
export interface FileDownloadParamsDto {
filePath?: string;
fileName?: string;
}
type NullableNumber = number | null;
type NullableString = string | null;
@@ -67,14 +78,21 @@ export interface NowDmgMtncGnsvyResponseDto
sggNm?: NullableString;
}
/**
* lvNowDmgMtncGnsvy 계층 노드.
* - level 1: ctpvNm + children(구군)
* - level 2: sggNm + children(연도)
* - level 3: gnsvyYr + children(처리시설)
* - 시설 리프: swtrmtFcltCd / swtrmtFcltNm (level 없음)
*/
export interface LvNowDmgMtncGnsvyResponseDto {
level: number;
ctpvNm: string;
sggNm: string;
gnsvyYr: string;
swtrmtFcltCd: string;
swtrmtFcltNm: string;
children: LvNowDmgMtncGnsvyResponseDto[];
level?: number;
ctpvNm?: string;
sggNm?: string;
gnsvyYr?: string;
swtrmtFcltCd?: string;
swtrmtFcltNm?: string;
children?: LvNowDmgMtncGnsvyResponseDto[];
}
export interface ClsfSystmResponseDto {
@@ -89,5 +107,8 @@ export interface ClsfSystmResponseDto {
export interface DcService {
getNowDmgMtncGnsvy(params: NowDmgMtncGnsvySearchDto): Promise<NowDmgMtncGnsvyResponseDto[]>;
getLvNowDmgMtncGnsvy(params: LvNowDmgMtncGnsvySearchDto): Promise<LvNowDmgMtncGnsvyResponseDto[]>;
getClsfSystm(params: ClsfSystmRequestDto): Promise<ClsfSystmResponseDto[]>;
getClsfSystm(params: ClsfSystmParamsDto): Promise<ClsfSystmResponseDto[]>;
getFile(params: FileParamsDto): Promise<unknown>;
getFilePrcs(fileSn: string): Promise<unknown>;
fileDownload(params: FileDownloadParamsDto): Promise<unknown>;
}
@@ -0,0 +1,105 @@
import type { LvNowDmgMtncGnsvyResponseDto, NowDmgMtncGnsvyResponseDto } from "@/entities/dc";
type StageKey = "Dt1" | "Dt2" | "Dt3" | "Dt4" | "" | "Dc1" | "Dc2" | "Dc3" | "Dc4";
const STAGE_KEYS: StageKey[] = ["Dt1", "Dt2", "Dt3", "Dt4", "", "Dc1", "Dc2", "Dc3", "Dc4"];
function formatPlanCell(value: string | number | null | undefined, asYear = false): string {
if (value === null || value === undefined || value === "") return "-";
if (asYear) return `${value}년`;
if (typeof value === "number") return value.toLocaleString();
const num = Number(value);
if (!Number.isNaN(num) && value.trim() !== "" && /^-?\d+(\.\d+)?$/.test(value.trim()))
return num.toLocaleString();
return String(value);
}
function getStagedValue(
data: NowDmgMtncGnsvyResponseDto | null | undefined,
fieldPrefix: string,
stage: StageKey,
): string | number | null | undefined {
if (!data) return undefined;
const key = `${fieldPrefix}${stage}` as keyof NowDmgMtncGnsvyResponseDto;
return data[key] as string | number | null | undefined;
}
function getPlanYearCells(data: NowDmgMtncGnsvyResponseDto | null | undefined): string[] {
return STAGE_KEYS.map((stage) => formatPlanCell(getStagedValue(data, "planYr", stage), true));
}
function getMetricCells(
data: NowDmgMtncGnsvyResponseDto | null | undefined,
fieldPrefix: string,
): string[] {
return STAGE_KEYS.map((stage) => formatPlanCell(getStagedValue(data, fieldPrefix, stage)));
}
function collectNodesByLevel(
nodes: LvNowDmgMtncGnsvyResponseDto[] | undefined,
targetLevel: number,
acc: LvNowDmgMtncGnsvyResponseDto[] = [],
): LvNowDmgMtncGnsvyResponseDto[] {
if (!nodes) return acc;
for (const node of nodes) {
if (node.level === targetLevel) acc.push(node);
if (node.children?.length) collectNodesByLevel(node.children, targetLevel, acc);
}
return acc;
}
function extractYearOptions(tree: LvNowDmgMtncGnsvyResponseDto[] | undefined): string[] {
const years = collectNodesByLevel(tree, 3)
.map((node) => node.gnsvyYr)
.filter((year): year is string => Boolean(year));
return [...new Set(years)];
}
interface FacilityOption {
code: string;
name: string;
}
function extractFacilityOptions(
tree: LvNowDmgMtncGnsvyResponseDto[] | undefined,
gnsvyYr: string,
): FacilityOption[] {
const yearNodes = collectNodesByLevel(tree, 3).filter((node) => node.gnsvyYr === gnsvyYr);
const options: FacilityOption[] = [];
function pushFacility(node: LvNowDmgMtncGnsvyResponseDto) {
if (!node.swtrmtFcltCd) return;
options.push({
code: node.swtrmtFcltCd,
name: node.swtrmtFcltNm ?? node.swtrmtFcltCd,
});
}
if (yearNodes.length > 0) {
for (const yearNode of yearNodes) {
for (const child of yearNode.children ?? []) pushFacility(child);
}
return options;
}
// level=3 응답이 시설 리프만 내려주는 경우
function walk(nodes: LvNowDmgMtncGnsvyResponseDto[] | undefined) {
if (!nodes) return;
for (const node of nodes) {
pushFacility(node);
walk(node.children);
}
}
walk(tree);
return options;
}
export {
extractFacilityOptions,
extractYearOptions,
formatPlanCell,
getMetricCells,
getPlanYearCells,
STAGE_KEYS,
};
export type { FacilityOption };
@@ -0,0 +1,257 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import {
type CmCdAdstDstrComboResponseDto,
useFindGugunCombo,
useFindSidoCombo,
} from "@/entities/adstdstr";
import { dcService, type NowDmgMtncGnsvyResponseDto, useLvNowDmgMtncGnsvy } from "@/entities/dc";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Skeleton,
} from "@common/components";
import { extractFacilityOptions, extractYearOptions } from "../lib/previous-plan-table-utils";
import { PreviousPlanAreaTable } from "./previous-plan/previous-plan-area-table";
import { PreviousPlanFileSection } from "./previous-plan/previous-plan-file-section";
import { PreviousPlanSearchFilters } from "./previous-plan/previous-plan-search-filters";
import { PreviousPlanSummaryTable } from "./previous-plan/previous-plan-summary-table";
interface PreviousPlanLookupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 부모 폼 `sewerBscPlan.sido` 표시명 */
initialSidoNm?: string;
/** 부모 폼 `sewerBscPlan.gugun` 표시명 */
initialGugunNm?: string;
}
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
function PreviousPlanLookupDialog({
open,
onOpenChange,
initialSidoNm = "",
initialGugunNm = "",
}: PreviousPlanLookupDialogProps) {
const alert = useAlertStore((state) => state.setMessage);
const [sidoComCd, setSidoComCd] = useState("");
const [sggComCd, setSggComCd] = useState("");
const [gnsvyYr, setGnsvyYr] = useState("");
const [swtrmtFcltCd, setSwtrmtFcltCd] = useState("");
const [planData, setPlanData] = useState<NowDmgMtncGnsvyResponseDto | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [hasPrefillApplied, setHasPrefillApplied] = useState(false);
const { data: sidoData, isPending: isSidoPending } = useFindSidoCombo();
const { data: gugunData, isPending: isGugunPending } = useFindGugunCombo(sidoComCd);
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
const ctpvNm = sidoOptions.find((option) => option.adstDstrCd === sidoComCd)?.adstDstrNm ?? "";
const sggNm = gugunOptions.find((option) => option.adstDstrCd === sggComCd)?.adstDstrNm ?? "";
// TODO: T7 — level=1 응답 UI 활용 여부(현재는 캐시·호출만, 구군은 adstdstr)
useLvNowDmgMtncGnsvy({
level: "1",
ctpvNm: ctpvNm || undefined,
});
const { data: yearTree, isPending: isYearPending } = useLvNowDmgMtncGnsvy({
level: "2",
ctpvNm: ctpvNm || undefined,
sggNm: sggNm || undefined,
});
const { data: facilityTree, isPending: isFacilityPending } = useLvNowDmgMtncGnsvy({
level: "3",
ctpvNm: ctpvNm || undefined,
sggNm: sggNm || undefined,
gnsvyYr: gnsvyYr || undefined,
});
const yearOptions = useMemo(() => extractYearOptions(yearTree), [yearTree]);
const facilityOptions = useMemo(
() => extractFacilityOptions(facilityTree, gnsvyYr),
[facilityTree, gnsvyYr],
);
useEffect(() => {
if (!open) {
setSidoComCd("");
setSggComCd("");
setGnsvyYr("");
setSwtrmtFcltCd("");
setPlanData(null);
setHasPrefillApplied(false);
return;
}
}, [open]);
useEffect(() => {
if (!open || hasPrefillApplied || isSidoPending || !sidoOptions.length) return;
if (!initialSidoNm) {
setHasPrefillApplied(true);
return;
}
const matchedSido = sidoOptions.find((option) => option.adstDstrNm === initialSidoNm);
if (matchedSido) setSidoComCd(matchedSido.adstDstrCd);
setHasPrefillApplied(true);
}, [open, hasPrefillApplied, isSidoPending, sidoOptions, initialSidoNm]);
useEffect(() => {
if (!open || !sidoComCd || isGugunPending || !initialGugunNm) return;
if (sggComCd) return;
const matchedGugun = gugunOptions.find((option) => option.adstDstrNm === initialGugunNm);
if (matchedGugun) setSggComCd(matchedGugun.adstDstrCd);
}, [open, sidoComCd, isGugunPending, gugunOptions, initialGugunNm, sggComCd]);
function handleSidoChange(value: string) {
setSidoComCd(value);
setSggComCd("");
setGnsvyYr("");
setSwtrmtFcltCd("");
setPlanData(null);
}
function handleGugunChange(value: string) {
setSggComCd(value);
setGnsvyYr("");
setSwtrmtFcltCd("");
setPlanData(null);
}
function handleYearChange(value: string) {
setGnsvyYr(value);
setSwtrmtFcltCd("");
setPlanData(null);
}
function handleFacilityChange(value: string) {
setSwtrmtFcltCd(value);
setPlanData(null);
}
async function handleSearch() {
if (!ctpvNm) {
alert("시도를 선택해주세요.");
return;
}
if (!sggNm) {
alert("구군을 선택해주세요.");
return;
}
if (!gnsvyYr) {
alert("연도를 선택해주세요.");
return;
}
if (!swtrmtFcltCd) {
alert("공공하수처리시설을 선택해주세요.");
return;
}
setIsSearching(true);
try {
const list = await dcService.getNowDmgMtncGnsvy({
swtrmtFcltCd,
ctpvNm,
sggNm,
targetYear: gnsvyYr,
});
if (!list.length) {
alert("조회된 데이터가 없습니다.");
setPlanData(null);
return;
}
setPlanData(list[0] ?? null);
} catch {
alert("데이터 조회 중 오류가 발생했습니다.");
setPlanData(null);
} finally {
setIsSearching(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex max-h-[90vh] flex-col overflow-hidden sm:max-w-[90rem]"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>기본 계획 찾기</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
{isSidoPending ? (
<Skeleton className="h-[6rem] w-full" />
) : (
<PreviousPlanSearchFilters
sidoComCd={sidoComCd}
sggComCd={sggComCd}
gnsvyYr={gnsvyYr}
swtrmtFcltCd={swtrmtFcltCd}
sidoOptions={sidoOptions}
gugunOptions={gugunOptions}
yearOptions={yearOptions}
facilityOptions={facilityOptions}
isGugunPending={isGugunPending}
isYearPending={Boolean(sggNm) && isYearPending}
isFacilityPending={Boolean(gnsvyYr) && isFacilityPending}
isSearching={isSearching}
onSidoChange={handleSidoChange}
onGugunChange={handleGugunChange}
onYearChange={handleYearChange}
onFacilityChange={handleFacilityChange}
onSearch={handleSearch}
/>
)}
<SectionCollapsible
title="기본계획파일(기본계획의 보고서를 트리에서 클릭하시면 파일을 다운받으실수 있습니다.)"
defaultOpen
>
<PreviousPlanFileSection key={sggNm || "empty"} gugunNm={sggNm} />
</SectionCollapsible>
<SectionCollapsible title="총설" defaultOpen>
{isSearching ? (
<Skeleton className="h-[8rem] w-full" />
) : (
<PreviousPlanSummaryTable data={planData} />
)}
</SectionCollapsible>
<SectionCollapsible title="처리구역별 현황" defaultOpen>
{isSearching ? (
<Skeleton className="h-[20rem] w-full" />
) : (
<PreviousPlanAreaTable data={planData} />
)}
</SectionCollapsible>
</div>
<DialogFooter>
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
닫기
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { PreviousPlanLookupDialog };
@@ -0,0 +1,103 @@
"use client";
import type { NowDmgMtncGnsvyResponseDto } from "@/entities/dc";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
import { getMetricCells, getPlanYearCells } from "../../lib/previous-plan-table-utils";
const AREA_ROWS = [
{ label: "하수처리인구(인)", field: "swtrmtPpltnCnt" },
{ label: "자연적하수처리인구수(인)", field: "natrSwtrmtPpltnCnt" },
{ label: "사회적하수처리인구수(인)", field: "scilSwtrmtPpltnCnt" },
{ label: "하수처리구역면적", field: "swtrmtZoneArea" },
{ label: "물사용량원단위", field: "wtrUsqtyOrgnUnit" },
{ label: "오수전환율(%)", field: "drtwSwtcRt" },
{ label: "변동부하율(%)", field: "flctnLodRt" },
{ label: "오수원단위", field: "drtwOrgnUnit" },
{ label: "계획하수량합계(㎥/일)", field: "planSwageQntSum" },
{ label: "생활오수(㎥/일)", field: "lvngDrtwSum" },
{ label: "지하수(㎥/일)", field: "ugwtSum" },
{ label: "공장폐수(㎥/일)", field: "fctryWwtSum" },
{ label: "관광오수(㎥/일)", field: "toursmDrtwSum" },
{ label: "군부대(㎥/일)", field: "mtbsSum" },
{ label: "연계처리수(㎥/일)", field: "linkPrcsCntSum" },
{ label: "기타오수(㎥/일)", field: "etcDrtwSum" },
{ label: "지하수사용량(㎥/일)", field: "ugwtUseSum" },
{ label: "시설용량(㎥/일)", field: "fcltCpctSum" },
{ label: "배제방식", field: "exclsMethExpln" },
] as const;
interface PreviousPlanAreaTableProps {
data: NowDmgMtncGnsvyResponseDto | null;
}
function PreviousPlanAreaTable({ data }: PreviousPlanAreaTableProps) {
const yearCells = getPlanYearCells(data);
return (
<Table variant="secondary">
<colgroup>
<col width="16%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
<col width="9.3%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead rowSpan={3} className="text-center">
구분
</TableHead>
<TableHead colSpan={4} className="text-center">
당초
</TableHead>
<TableHead rowSpan={2} className="text-center">
기준
</TableHead>
<TableHead colSpan={4} className="text-center">
변경
</TableHead>
</TableRow>
<TableRow>
<TableHead className="text-center">1단계</TableHead>
<TableHead className="text-center">2단계</TableHead>
<TableHead className="text-center">3단계</TableHead>
<TableHead className="text-center">4단계</TableHead>
<TableHead className="text-center">1단계</TableHead>
<TableHead className="text-center">2단계</TableHead>
<TableHead className="text-center">3단계</TableHead>
<TableHead className="text-center">4단계</TableHead>
</TableRow>
<TableRow>
{yearCells.map((cell, index) => (
<TableHead key={index} className="text-center font-normal">
{cell}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{AREA_ROWS.map((row) => {
const cells = getMetricCells(data, row.field);
return (
<TableRow key={row.field}>
<TableHead>{row.label}</TableHead>
{cells.map((cell, index) => (
<TableCell key={index} className="text-right">
{cell}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
);
}
export { PreviousPlanAreaTable };
@@ -0,0 +1,224 @@
"use client";
import { useMemo, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { type ClsfSystmResponseDto, dcService, useClsfSystm, useFile } from "@/entities/dc";
import { Button, Skeleton } from "@common/components";
interface ClsfTreeNode {
node: ClsfSystmResponseDto;
children: ClsfTreeNode[];
}
interface LegacyFileItem {
no?: number | string;
fileTitle?: string;
regDt?: string;
fileSn?: string;
}
interface PreviousPlanFileSectionProps {
gugunNm: string;
}
function buildClsfTree(list: ClsfSystmResponseDto[]): ClsfTreeNode[] {
const byId = new Map<string, ClsfTreeNode>();
for (const item of list) {
byId.set(item.clsfSystmSn, { node: item, children: [] });
}
const roots: ClsfTreeNode[] = [];
for (const item of list) {
const current = byId.get(item.clsfSystmSn);
if (!current) continue;
const parent = item.upClsfSystmSn ? byId.get(item.upClsfSystmSn) : undefined;
if (parent) parent.children.push(current);
else roots.push(current);
}
function sortNodes(nodes: ClsfTreeNode[]) {
nodes.sort((a, b) => Number(a.node.sortSeq) - Number(b.node.sortSeq));
nodes.forEach((node) => sortNodes(node.children));
}
sortNodes(roots);
return roots;
}
// TODO: T2 — getFile Response DTO 확정 후 정규화 로직 교체 (레거시: no, fileTitle, regDt, fileSn)
function normalizeFileList(data: unknown): LegacyFileItem[] {
if (!Array.isArray(data)) return [];
return data.map((item) => {
if (!item || typeof item !== "object") return {};
const row = item as Record<string, unknown>;
return {
no: row.no as number | string | undefined,
fileTitle: (row.fileTitle ?? row.fileName ?? row.fileNm) as string | undefined,
regDt: row.regDt as string | undefined,
fileSn: (row.fileSn ?? row.fileId) as string | undefined,
};
});
}
function ClsfTreeItem({
item,
depth,
selectedSn,
onSelect,
}: {
item: ClsfTreeNode;
depth: number;
selectedSn: string;
onSelect: (sn: string) => void;
}) {
const isSelected = selectedSn === item.node.clsfSystmSn;
return (
<li>
<button
type="button"
className={`w-full rounded px-2 py-1 text-left text-sm hover:bg-muted ${
isSelected ? "bg-muted font-semibold" : ""
}`}
style={{ paddingLeft: `${0.5 + depth * 0.75}rem` }}
onClick={() => onSelect(item.node.clsfSystmSn)}
>
{item.node.clsfSystmNm}
</button>
{item.children.length > 0 && (
<ul className="m-0 list-none p-0">
{item.children.map((child) => (
<ClsfTreeItem
key={child.node.clsfSystmSn}
item={child}
depth={depth + 1}
selectedSn={selectedSn}
onSelect={onSelect}
/>
))}
</ul>
)}
</li>
);
}
function PreviousPlanFileSection({ gugunNm }: PreviousPlanFileSectionProps) {
const alert = useAlertStore((state) => state.setMessage);
const [selectedClsfSn, setSelectedClsfSn] = useState("");
const [isDownloading, setIsDownloading] = useState(false);
const { data: clsfList, isPending: isClsfPending } = useClsfSystm({
clsfSystmNm: gugunNm || undefined,
});
const { data: fileData, isPending: isFilePending } = useFile({
clsfSystmSn: selectedClsfSn || undefined,
});
const tree = useMemo(() => buildClsfTree(clsfList ?? []), [clsfList]);
const files = useMemo(() => normalizeFileList(fileData), [fileData]);
async function handleDownload(fileSn?: string, fallbackName?: string) {
if (!fileSn) {
alert("파일 식별자가 없습니다.");
return;
}
setIsDownloading(true);
try {
// TODO: T2 — getFilePrcs Response DTO 확정 후 타입 가드
const prcsRaw = await dcService.getFilePrcs(fileSn);
const fileInfo = (Array.isArray(prcsRaw) ? prcsRaw[0] : prcsRaw) as
| { filePath?: string; fileTitle?: string; fileName?: string }
| undefined;
const filePath = fileInfo?.filePath;
const fileName = fileInfo?.fileTitle ?? fileInfo?.fileName ?? fallbackName;
if (!filePath || !fileName) {
alert("파일 정보를 가져올 수 없습니다.");
return;
}
// TODO: T3 — blob / Content-Disposition / 게이트웨이 다운로드 UX 확정
await dcService.fileDownload({ filePath, fileName });
alert("다운로드 요청을 전송했습니다. (다운로드 UX TBD)");
} catch {
alert("파일 다운로드 중 오류가 발생했습니다.");
} finally {
setIsDownloading(false);
}
}
if (!gugunNm) {
return (
<p className="py-8 text-center text-sm text-muted-foreground">시도와 구군을 선택하세요.</p>
);
}
return (
<div className="flex h-[40rem] gap-2">
<div className="w-[40%] overflow-y-auto rounded border p-2">
{isClsfPending ? (
<Skeleton className="h-full w-full" />
) : tree.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">파일이 없습니다.</p>
) : (
<ul className="m-0 list-none p-0">
{tree.map((item) => (
<ClsfTreeItem
key={item.node.clsfSystmSn}
item={item}
depth={0}
selectedSn={selectedClsfSn}
onSelect={(sn) => setSelectedClsfSn(sn)}
/>
))}
</ul>
)}
</div>
<div className="w-[60%] overflow-y-auto rounded border p-2">
{!selectedClsfSn ? (
<p className="py-8 text-center text-sm text-muted-foreground">
트리에서 항목을 선택하세요.
</p>
) : isFilePending ? (
<Skeleton className="h-full w-full" />
) : files.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">파일이 없습니다.</p>
) : (
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-muted/40">
<th className="border px-2 py-1">번호</th>
<th className="border px-2 py-1 text-left">파일명</th>
<th className="border px-2 py-1">등록일</th>
<th className="border px-2 py-1">다운로드</th>
</tr>
</thead>
<tbody>
{files.map((file, index) => (
<tr key={file.fileSn ?? `${file.fileTitle}-${index}`}>
<td className="border px-2 py-1 text-center">{file.no ?? index + 1}</td>
<td className="border px-2 py-1">{file.fileTitle ?? "-"}</td>
<td className="border px-2 py-1 text-center">{file.regDt ?? "-"}</td>
<td className="border px-2 py-1 text-center">
<Button
type="button"
color="white"
size="sm"
disabled={isDownloading}
onClick={() => handleDownload(file.fileSn, file.fileTitle)}
>
다운로드
</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
export { PreviousPlanFileSection };
@@ -0,0 +1,156 @@
"use client";
import type { CmCdAdstDstrComboResponseDto } from "@/entities/adstdstr";
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import type { FacilityOption } from "../../lib/previous-plan-table-utils";
interface PreviousPlanSearchFiltersProps {
sidoComCd: string;
sggComCd: string;
gnsvyYr: string;
swtrmtFcltCd: string;
sidoOptions: CmCdAdstDstrComboResponseDto[];
gugunOptions: CmCdAdstDstrComboResponseDto[];
yearOptions: string[];
facilityOptions: FacilityOption[];
isGugunPending: boolean;
isYearPending: boolean;
isFacilityPending: boolean;
isSearching: boolean;
onSidoChange: (value: string) => void;
onGugunChange: (value: string) => void;
onYearChange: (value: string) => void;
onFacilityChange: (value: string) => void;
onSearch: () => void;
}
function PreviousPlanSearchFilters({
sidoComCd,
sggComCd,
gnsvyYr,
swtrmtFcltCd,
sidoOptions,
gugunOptions,
yearOptions,
facilityOptions,
isGugunPending,
isYearPending,
isFacilityPending,
isSearching,
onSidoChange,
onGugunChange,
onYearChange,
onFacilityChange,
onSearch,
}: PreviousPlanSearchFiltersProps) {
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<Table variant="secondary" className="flex-1">
<colgroup>
<col width="12%" />
<col width="13%" />
<col width="12%" />
<col width="13%" />
<col width="12%" />
<col width="13%" />
<col width="12%" />
<col width="13%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead data-required>시도</TableHead>
<TableCell>
<Select value={sidoComCd || undefined} onValueChange={onSidoChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{sidoOptions.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableHead data-required>구군</TableHead>
<TableCell>
<Select
value={sggComCd || undefined}
onValueChange={onGugunChange}
disabled={!sidoComCd || isGugunPending}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{gugunOptions.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableHead data-required>연도</TableHead>
<TableCell>
<Select
value={gnsvyYr || undefined}
onValueChange={onYearChange}
disabled={!sggComCd || isYearPending}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{yearOptions.map((year) => (
<SelectItem key={year} value={year}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableHead data-required>처리시설</TableHead>
<TableCell>
<Select
value={swtrmtFcltCd || undefined}
onValueChange={onFacilityChange}
disabled={!gnsvyYr || isFacilityPending}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{facilityOptions.map((option) => (
<SelectItem key={option.code} value={option.code}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
</TableRow>
</TableHeader>
</Table>
<Button type="button" className="shrink-0" onClick={onSearch} disabled={isSearching}>
조회
</Button>
</div>
);
}
export { PreviousPlanSearchFilters };
@@ -0,0 +1,69 @@
"use client";
import type { NowDmgMtncGnsvyResponseDto } from "@/entities/dc";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
import { getPlanYearCells } from "../../lib/previous-plan-table-utils";
interface PreviousPlanSummaryTableProps {
data: NowDmgMtncGnsvyResponseDto | null;
}
function PreviousPlanSummaryTable({ data }: PreviousPlanSummaryTableProps) {
const yearCells = getPlanYearCells(data);
return (
<Table variant="secondary">
<colgroup>
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead rowSpan={2} className="text-center">
구분
</TableHead>
<TableHead colSpan={4} className="text-center">
당초
</TableHead>
<TableHead rowSpan={2} className="text-center">
기준
</TableHead>
<TableHead colSpan={4} className="text-center">
변경
</TableHead>
</TableRow>
<TableRow>
<TableHead className="text-center">1단계</TableHead>
<TableHead className="text-center">2단계</TableHead>
<TableHead className="text-center">3단계</TableHead>
<TableHead className="text-center">4단계</TableHead>
<TableHead className="text-center">1단계</TableHead>
<TableHead className="text-center">2단계</TableHead>
<TableHead className="text-center">3단계</TableHead>
<TableHead className="text-center">4단계</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableHead>계획목표년도</TableHead>
{yearCells.map((cell, index) => (
<TableCell key={index} className="text-center">
{cell}
</TableCell>
))}
</TableRow>
</TableBody>
</Table>
);
}
export { PreviousPlanSummaryTable };
@@ -1,6 +1,8 @@
"use client";
import { useState } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import dynamic from "next/dynamic";
import { Field } from "@/shared/ui/field";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
@@ -17,6 +19,14 @@ import {
import type { SewerBscPlanRegistFormData } from "../../lib/sewer-bsc-plan-regist-schema";
const PreviousPlanLookupDialog = dynamic(
() =>
import("../previous-plan-lookup-dialog").then((mod) => ({
default: mod.PreviousPlanLookupDialog,
})),
{ ssr: false },
);
const OTLN_ROWS = [
{ key: "sewerPlanPpltnCnt" as const, label: "계획인구" },
{ key: "sewerPrcsPpltnCnt" as const, label: "처리인구" },
@@ -25,11 +35,17 @@ const OTLN_ROWS = [
function OutlineFormSection() {
const { control, setValue, getValues } = useFormContext<SewerBscPlanRegistFormData>();
const [isLookupOpen, setIsLookupOpen] = useState(false);
const otlnList = useWatch({ control, name: "sewerBscPlanOtlnList" }) ?? [];
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 [sido, gugun] = useWatch({
control,
name: ["sewerBscPlan.sido", "sewerBscPlan.gugun"],
});
const stepHeaders = [
{ label: "1단계", year: frlvlGoalYr },
@@ -54,7 +70,8 @@ function OutlineFormSection() {
return (
<SectionCollapsible title="사업개요">
<div className="mb-2 flex justify-end px-2">
<Button type="button" color="white" disabled>
{/* TODO: T6 — 부모 폼 수치 반영은 요구 없음(레거시 동일) */}
<Button type="button" color="white" onClick={() => setIsLookupOpen(true)}>
이전기본계획조회
</Button>
</div>
@@ -102,6 +119,13 @@ function OutlineFormSection() {
))}
</TableBody>
</Table>
<PreviousPlanLookupDialog
open={isLookupOpen}
onOpenChange={setIsLookupOpen}
initialSidoNm={sido}
initialGugunNm={gugun}
/>
</SectionCollapsible>
);
}