[as] feat: 하수도기본계획 > 이전기본계획조회 다이얼로그 구현

This commit is contained in:
2026-07-31 16:58:46 +09:00
parent 2acbeb83d0
commit 3c2ca24dea
7 changed files with 233 additions and 246 deletions
+4 -3
View File
@@ -34,7 +34,7 @@ async function getLvNowDmgMtncGnsvy(
}
async function getClsfSystm(params: ClsfSystmParamsDto): Promise<ClsfSystmResponseDto[]> {
const response = await client.get<ClsfSystmResponseDto[]>(`${ENDPOINT.DC_SERVICE}/clsfSystm`, {
const response = await client.get<ClsfSystmResponseDto[]>(`${ENDPOINT.DC_SERVICE}/clsf`, {
params,
});
return response.data;
@@ -54,9 +54,10 @@ async function getFilePrcs(fileSn: string): Promise<unknown> {
return response.data;
}
async function fileDownload(params: FileDownloadParamsDto): Promise<unknown> {
const response = await client.get<unknown>(`${ENDPOINT.DC_SERVICE}/fileDownload`, {
async function fileDownload(params: FileDownloadParamsDto): Promise<Blob> {
const response = await client.get<Blob>(`${ENDPOINT.DC_SERVICE}/fileDownload`, {
params,
responseType: "blob",
});
return response.data;
}
+2 -2
View File
@@ -13,11 +13,11 @@ import { dcService } from "./dc-service";
export const baseKey = [ENDPOINT.DC_SERVICE] as const;
export function useNowDmgMtncGnsvy(params: NowDmgMtncGnsvySearchDto) {
export function useNowDmgMtncGnsvy(params: NowDmgMtncGnsvySearchDto, enabled?: boolean) {
return useQuery({
queryKey: [...baseKey, "nowDmgMtncGnsvy", params],
queryFn: () => dcService.getNowDmgMtncGnsvy(params),
enabled: Boolean(params.swtrmtFcltCd && params.ctpvNm),
enabled: enabled ?? Boolean(params.swtrmtFcltCd && params.ctpvNm),
});
}
@@ -101,7 +101,8 @@ export interface ClsfSystmResponseDto {
clsfSystmNm: string;
sortSeq: string;
droppAble: boolean;
path: string;
path: string | null;
children: ClsfSystmResponseDto[];
}
export interface DcService {
@@ -110,5 +111,5 @@ export interface DcService {
getClsfSystm(params: ClsfSystmParamsDto): Promise<ClsfSystmResponseDto[]>;
getFile(params: FileParamsDto): Promise<unknown>;
getFilePrcs(fileSn: string): Promise<unknown>;
fileDownload(params: FileDownloadParamsDto): Promise<unknown>;
fileDownload(params: FileDownloadParamsDto): Promise<Blob>;
}
@@ -8,7 +8,7 @@ import {
useFindGugunCombo,
useFindSidoCombo,
} from "@/entities/adstdstr";
import { dcService, type NowDmgMtncGnsvyResponseDto, useLvNowDmgMtncGnsvy } from "@/entities/dc";
import { useLvNowDmgMtncGnsvy, useNowDmgMtncGnsvy } from "@/entities/dc";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import {
Button,
@@ -50,9 +50,8 @@ function PreviousPlanLookupDialog({
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 [searchedClsfSystmNm, setSearchedClsfSystmNm] = useState("");
const { data: sidoData, isPending: isSidoPending } = useFindSidoCombo();
const { data: gugunData, isPending: isGugunPending } = useFindGugunCombo(sidoComCd);
@@ -63,11 +62,15 @@ function PreviousPlanLookupDialog({
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 searchParams = {
swtrmtFcltCd,
ctpvNm,
sggNm,
targetYear: gnsvyYr,
};
const { data, isFetching, refetch } = useNowDmgMtncGnsvy(searchParams, false);
const planData = gnsvyYr && swtrmtFcltCd ? (data?.[0] ?? null) : null;
const { data: yearTree, isPending: isYearPending } = useLvNowDmgMtncGnsvy({
level: "2",
@@ -94,7 +97,7 @@ function PreviousPlanLookupDialog({
setSggComCd("");
setGnsvyYr("");
setSwtrmtFcltCd("");
setPlanData(null);
setSearchedClsfSystmNm("");
setHasPrefillApplied(false);
return;
}
@@ -124,65 +127,36 @@ function PreviousPlanLookupDialog({
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 {
const { data: list, error } = await refetch();
if (error) {
alert("데이터 조회 중 오류가 발생했습니다.");
setPlanData(null);
} finally {
setIsSearching(false);
setSearchedClsfSystmNm("");
return;
}
if (!list?.length) {
alert("조회된 데이터가 없습니다.");
setSearchedClsfSystmNm("");
return;
}
setSearchedClsfSystmNm(sggNm || ctpvNm);
}
return (
@@ -197,7 +171,9 @@ function PreviousPlanLookupDialog({
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
{isSidoPending ? (
<Skeleton className="h-[6rem] w-full" />
<div className="mx-4">
<Skeleton className="h-[6rem] w-full" />
</div>
) : (
<PreviousPlanSearchFilters
sidoComCd={sidoComCd}
@@ -211,7 +187,7 @@ function PreviousPlanLookupDialog({
isGugunPending={isGugunPending}
isYearPending={Boolean(sggNm) && isYearPending}
isFacilityPending={Boolean(gnsvyYr) && isFacilityPending}
isSearching={isSearching}
isSearching={isFetching}
onSidoChange={handleSidoChange}
onGugunChange={handleGugunChange}
onYearChange={handleYearChange}
@@ -220,15 +196,15 @@ function PreviousPlanLookupDialog({
/>
)}
<SectionCollapsible
title="기본계획파일(기본계획의 보고서를 트리에서 클릭하시면 파일을 다운받으실수 있습니다.)"
defaultOpen
>
<PreviousPlanFileSection key={sggNm || "empty"} gugunNm={sggNm} />
<SectionCollapsible title="기본계획파일" defaultOpen>
<PreviousPlanFileSection
key={searchedClsfSystmNm || "empty"}
clsfSystmNm={searchedClsfSystmNm || undefined}
/>
</SectionCollapsible>
<SectionCollapsible title="총설" defaultOpen>
{isSearching ? (
{isFetching ? (
<Skeleton className="h-[8rem] w-full" />
) : (
<PreviousPlanSummaryTable data={planData} />
@@ -236,7 +212,7 @@ function PreviousPlanLookupDialog({
</SectionCollapsible>
<SectionCollapsible title="처리구역별 현황" defaultOpen>
{isSearching ? (
{isFetching ? (
<Skeleton className="h-[20rem] w-full" />
) : (
<PreviousPlanAreaTable data={planData} />
@@ -2,14 +2,24 @@
import { useMemo, useState } from "react";
import { ChevronRight, File, Folder } from "lucide-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[];
}
import {
Button,
cn,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
interface LegacyFileItem {
no?: number | string;
@@ -19,30 +29,7 @@ interface LegacyFileItem {
}
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;
clsfSystmNm?: string;
}
// TODO: T2 — getFile Response DTO 확정 후 정규화 로직 교체 (레거시: no, fileTitle, regDt, fileSn)
@@ -66,55 +53,77 @@ function ClsfTreeItem({
selectedSn,
onSelect,
}: {
item: ClsfTreeNode;
item: ClsfSystmResponseDto;
depth: number;
selectedSn: string;
onSelect: (sn: string) => void;
}) {
const isSelected = selectedSn === item.node.clsfSystmSn;
const children = item.children ?? [];
const isFolder = children.length > 0;
if (isFolder) {
return (
<li>
<Collapsible defaultOpen={depth < 1} className="group/collapsible">
<CollapsibleTrigger
className="flex w-full items-center gap-1.5 rounded px-3 py-1.5 text-left text-xl hover:bg-muted"
style={{ paddingLeft: `${0.5 + depth * 0.75}rem` }}
>
<ChevronRight className="size-4 shrink-0 transition-transform group-data-[state=open]/collapsible:rotate-90" />
<Folder className="size-6 shrink-0" />
<span className="truncate">{item.clsfSystmNm}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<ul className="m-0 ml-3 list-none border-l border-border p-0 pl-1">
{children.map((child) => (
<ClsfTreeItem
key={child.clsfSystmSn}
item={child}
depth={depth + 1}
selectedSn={selectedSn}
onSelect={onSelect}
/>
))}
</ul>
</CollapsibleContent>
</Collapsible>
</li>
);
}
const isSelected = selectedSn === item.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)}
className={cn(
"flex w-full items-center gap-1.5 rounded px-3 py-1.5 text-left text-xl hover:bg-muted",
isSelected && "bg-muted font-semibold",
)}
style={{ paddingLeft: `${0.5 + depth * 1}rem` }}
onClick={() => onSelect(item.clsfSystmSn)}
>
{item.node.clsfSystmNm}
<File className="size-6 shrink-0" />
<span className="truncate">{item.clsfSystmNm}</span>
</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) {
function PreviousPlanFileSection({ clsfSystmNm }: 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: clsfTree, isPending: isClsfPending } = useClsfSystm({
clsfSystmNm,
});
const { data: fileData, isPending: isFilePending } = useFile({
clsfSystmSn: selectedClsfSn || undefined,
});
const tree = useMemo(() => buildClsfTree(clsfList ?? []), [clsfList]);
const tree = clsfTree ?? [];
const files = useMemo(() => normalizeFileList(fileData), [fileData]);
async function handleDownload(fileSn?: string, fallbackName?: string) {
@@ -138,9 +147,15 @@ function PreviousPlanFileSection({ gugunNm }: PreviousPlanFileSectionProps) {
return;
}
// TODO: T3 — blob / Content-Disposition / 게이트웨이 다운로드 UX 확정
await dcService.fileDownload({ filePath, fileName });
alert("다운로드 요청을 전송했습니다. (다운로드 UX TBD)");
const blob = await dcService.fileDownload({ filePath, fileName });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch {
alert("파일 다운로드 중 오류가 발생했습니다.");
} finally {
@@ -148,9 +163,11 @@ function PreviousPlanFileSection({ gugunNm }: PreviousPlanFileSectionProps) {
}
}
if (!gugunNm) {
if (!clsfSystmNm) {
return (
<p className="py-8 text-center text-sm text-muted-foreground">시도와 구군을 선택하세요.</p>
<p className="py-8 text-center text-lg text-muted-foreground">
시도 및 구군을 선택 후 조회하세요.
</p>
);
}
@@ -160,12 +177,12 @@ function PreviousPlanFileSection({ gugunNm }: PreviousPlanFileSectionProps) {
{isClsfPending ? (
<Skeleton className="h-full w-full" />
) : tree.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">파일이 없습니다.</p>
<p className="py-8 text-center text-lg text-muted-foreground">파일이 없습니다.</p>
) : (
<ul className="m-0 list-none p-0">
{tree.map((item) => (
<ClsfTreeItem
key={item.node.clsfSystmSn}
key={item.clsfSystmSn}
item={item}
depth={0}
selectedSn={selectedClsfSn}
@@ -177,30 +194,30 @@ function PreviousPlanFileSection({ gugunNm }: PreviousPlanFileSectionProps) {
</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 className="py-8 text-center text-lg 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>
<p className="py-8 text-center text-lg 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>
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead className="!py-2 !text-xl">번호</TableHead>
<TableHead className="!py-2 !text-xl">파일명</TableHead>
<TableHead className="!py-2 !text-xl">등록일</TableHead>
<TableHead className="!py-2 !text-xl">다운로드</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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">
<TableRow key={file.fileSn ?? `${file.fileTitle}-${index}`}>
<TableCell className="!py-2 text-center">{file.no ?? index + 1}</TableCell>
<TableCell className="!py-2 text-center">{file.fileTitle ?? "-"}</TableCell>
<TableCell className="!py-2 text-center">{file.regDt ?? "-"}</TableCell>
<TableCell className="!py-2 text-center">
<Button
type="button"
color="white"
@@ -210,11 +227,11 @@ function PreviousPlanFileSection({ gugunNm }: PreviousPlanFileSectionProps) {
>
다운로드
</Button>
</td>
</tr>
</TableCell>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
)}
</div>
</div>
@@ -3,16 +3,12 @@
import type { CmCdAdstDstrComboResponseDto } from "@/entities/adstdstr";
import {
Button,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import type { FacilityOption } from "../../lib/previous-plan-table-utils";
@@ -57,99 +53,96 @@ function PreviousPlanSearchFilters({
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>
<section className="card card-border mx-4 bg-background">
<div className="flex flex-wrap items-center gap-4 p-2">
<div className="flex items-center gap-2">
<Label htmlFor="previous-plan-sidoComCd" className="whitespace-nowrap">
시도
</Label>
<Select value={sidoComCd || undefined} onValueChange={onSidoChange}>
<SelectTrigger id="previous-plan-sidoComCd" className="w-[12rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{sidoOptions.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="previous-plan-sggComCd" className="whitespace-nowrap">
구군
</Label>
<Select
key={sidoComCd || "gugun"}
value={sggComCd || undefined}
onValueChange={onGugunChange}
disabled={!sidoComCd || isGugunPending}
>
<SelectTrigger id="previous-plan-sggComCd" className="w-[12rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{gugunOptions.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="previous-plan-gnsvyYr" className="whitespace-nowrap">
연도
</Label>
<Select
key={sggComCd || "year"}
value={gnsvyYr || undefined}
onValueChange={onYearChange}
disabled={!sggComCd || isYearPending}
>
<SelectTrigger id="previous-plan-gnsvyYr" className="w-[12rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{yearOptions.map((year) => (
<SelectItem key={year} value={year}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="previous-plan-swtrmtFcltCd" className="whitespace-nowrap">
처리시설
</Label>
<Select
key={gnsvyYr || "facility"}
value={swtrmtFcltCd || undefined}
onValueChange={onFacilityChange}
disabled={!gnsvyYr || isFacilityPending}
>
<SelectTrigger id="previous-plan-swtrmtFcltCd" className="w-[12rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{facilityOptions.map((option) => (
<SelectItem key={option.code} value={option.code}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="button" onClick={onSearch} disabled={isSearching}>
조회
</Button>
</div>
</section>
);
}
@@ -70,7 +70,6 @@ function OutlineFormSection() {
return (
<SectionCollapsible title="사업개요">
<div className="mb-2 flex justify-end px-2">
{/* TODO: T6 — 부모 폼 수치 반영은 요구 없음(레거시 동일) */}
<Button type="button" color="white" onClick={() => setIsLookupOpen(true)}>
이전기본계획조회
</Button>