Files
frontend/apps/st/src/components/module/SearchForm.tsx
T

577 lines
23 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import useSWR from "swr";
import { useAlertStore, useUserStore } from "@/shared/lib";
import {
fetchSearchCondition,
fetchSearchConditionInput,
SEARCH_CONDITION_SWR_KEY,
} from "@/shared/lib/use-search-condition-sido-list";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type {
ApiResponse,
SearchCondition,
Sgg,
} from "@/entities/statistics/model/statistics-interface";
import {
Button,
Checkbox,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import { doList, doNameList } from "@/components/util/column/input/DoList";
import {
defaultSearchFormProps,
type SearchFormClsfCd,
type SearchFormProps,
type SearchFormValues,
} from "../type/SearchForm.type";
function isDoListBsnm(bsnmCd: string | undefined): boolean {
return Boolean(bsnmCd && doList.includes(bsnmCd));
}
function isDoSido(sido?: { bsnmCd?: string; sido?: string }): boolean {
if (!sido) return false;
if (isDoListBsnm(sido.bsnmCd)) return true;
return Boolean(sido.sido && doNameList.includes(sido.sido));
}
function isAllGugunValue(gugun: string | undefined): boolean {
const value = (gugun ?? "").trim();
return !value || value === "ALL" || value === "전체";
}
function firstConcreteSgg(list: Sgg[] | undefined): Sgg | undefined {
return (list ?? []).find((sgg) => !isAllGugunValue(sgg.gugun));
}
const getSggListFetcher = async (
key: [string, string, string, "Y" | "N"],
): Promise<ApiResponse<Sgg[]>> => {
const [kind, sidoName, instSeCd, gugunSkipYn] = key;
const params = { instSeCd, gugunSkipYn };
if (kind === "sgg-all") {
return statisticsApi.getSggAllList(sidoName, params);
}
return statisticsApi.getSggList(sidoName, params);
};
function buildSearchFormValues(
props: SearchFormProps,
sidoList: ApiResponse<SearchCondition> | undefined,
defaultYear: number,
): SearchFormValues {
const d = props.defaultValues;
const initialClsfCd: SearchFormClsfCd =
d?.clsfCd === "ALL" || d?.clsfCd === "INPT" || d?.clsfCd === "VRFC"
? d.clsfCd
: "ALL";
if (d?.sido) {
const sidoItem = sidoList?.data?.sidoList?.find(
(item) => item.sido === d.sido,
);
const gugun =
d.gugun ||
(sidoItem && !isDoSido(sidoItem) ? "ALL" : "");
return {
sido: d.sido,
gugun,
year: d.year || defaultYear.toString(),
order: d.order || "",
fctName: d.fctName || "",
bsnmCd: d.bsnmCd || "",
isDetail: false,
isSummary: d.isSummary || "",
clsfCd: initialClsfCd,
capacityGbn: d.capacityGbn || "ALL",
};
}
if (props.defaultRegionAll) {
return {
sido: "ALL",
gugun: "ALL",
year: d?.year || defaultYear.toString(),
order: d?.order || "",
fctName: d?.fctName || "",
bsnmCd: "",
isDetail: false,
isSummary: d?.isSummary || "",
clsfCd: initialClsfCd,
capacityGbn: d?.capacityGbn || "ALL",
};
}
const first = sidoList?.data?.sidoList?.[0];
const useFirstSido = (props.sido ?? defaultSearchFormProps.sido) && first;
return {
sido: useFirstSido ? first.sido : "",
gugun:
useFirstSido && first && !isDoSido(first) ? "ALL" : "",
year: d?.year || defaultYear.toString(),
order: d?.order || "",
fctName: d?.fctName || "",
bsnmCd: useFirstSido ? first.bsnmCd : "",
isDetail: false,
isSummary: d?.isSummary || "",
clsfCd: initialClsfCd,
capacityGbn: d?.capacityGbn || "ALL",
};
}
const SearchForm = (props: SearchFormProps = {}) => {
const { setMessage: alert } = useAlertStore((state) => state);
const userInfo = useUserStore((state) => state.userInfo);
const mergedProps = { ...defaultSearchFormProps, ...props };
const instSeCd = userInfo?.instSeCd ?? "";
const sidoSelectText =
props.sidoSelectText && props.sidoSelectText.trim() !== ""
? props.sidoSelectText
: "선택";
const currentYear = new Date().getFullYear();
const defaultYear = currentYear - 1;
const isInput = Boolean(props.isInput);
const isDetailInputId = props.tabKey
? `${props.tabKey}-isDetail`
: "isDetail";
const sidoListKind = isInput ? "input" : "default";
const { data: sidoList } = useSWR<ApiResponse<SearchCondition>>(
SEARCH_CONDITION_SWR_KEY(instSeCd, sidoListKind),
isInput ? fetchSearchConditionInput : fetchSearchCondition,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 2000,
shouldRetryOnError: false,
onSuccess: (data) => {
if (data.status !== "success") {
alert(`시도 목록을 불러오는데 실패했습니다.
새로고침 후 다시 시도해주세요.`);
}
},
onError: () => {
alert(`시도 목록을 불러오는데 실패했습니다.
새로고침 후 다시 시도해주세요.`);
},
},
);
// 마운트 시 SWR 캐시의 시도 목록으로 초기값 설정 (탭 닫았다 열 때 API 재호출 없이 동작)
const [values, setValues] = useState<SearchFormValues>(() =>
buildSearchFormValues(props, sidoList, defaultYear),
);
// 최초 진입만 시도 API가 늦게 오는 경우
useEffect(() => {
if (props.defaultRegionAll) return;
if (props.defaultValues?.sido) return;
const first = sidoList?.data?.sidoList?.[0];
if (!first || !mergedProps.sido) return;
setValues((prev) =>
prev.sido
? prev
: {
...prev,
sido: first.sido,
gugun: isDoSido(first) ? "" : "ALL",
bsnmCd: first.bsnmCd,
},
);
}, [
sidoList,
props.defaultValues?.sido,
props.defaultRegionAll,
mergedProps.sido,
]);
const selectedSido = sidoList?.data?.sidoList?.find(
(item) => item.sido === values.sido,
);
const gugunSkipYn = selectedSido?.gugunSkipYn ?? "Y";
const useSggAllList =
gugunSkipYn === "N" && !isDoListBsnm(selectedSido?.bsnmCd);
const sggKey: [string, string, string, "Y" | "N"] | null =
values.sido && values.sido !== "ALL" && sidoList?.data
? [
useSggAllList ? "sgg-all" : "sgg-list",
values.sido,
instSeCd,
gugunSkipYn,
]
: null;
const { data: sggList } = useSWR<ApiResponse<Sgg[]>>(
sggKey,
getSggListFetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 2000,
shouldRetryOnError: false,
onSuccess: (data) => {
if (data.status !== "success") {
alert(`시군구 목록을 불러오는데 실패했습니다.
새로고침 후 다시 시도해주세요.`);
}
},
onError: () => {
alert(`시군구 목록을 불러오는데 실패했습니다.
새로고침 후 다시 시도해주세요.`);
},
},
);
const sggOptions = sggList?.data ?? [];
const isDoSelected = isDoSido(selectedSido);
const firstConcreteSggOption = firstConcreteSgg(sggOptions);
// 도: 빈 값일 때만 첫 시군구로 보이게. ALL은 사용자가 고른 전체 유지
const gugunSelectValue =
isDoSelected && !values.gugun
? (firstConcreteSggOption?.gugun ?? undefined)
: values.gugun || undefined;
// 도: 초기/시도 변경(빈 값)만 첫 시군구. 전체(ALL)는 유지. 그 외: 전체
useEffect(() => {
if (!mergedProps.gugun) return;
if (props.defaultRegionAll) return;
if (!values.sido || values.sido === "ALL") return;
if (!selectedSido) return;
if (!isDoSelected) {
setValues((prev) => {
if (prev.gugun) return prev;
return {
...prev,
gugun: "ALL",
bsnmCd: selectedSido.bsnmCd || prev.bsnmCd,
};
});
return;
}
if (!firstConcreteSggOption) return;
setValues((prev) => {
if (prev.gugun) return prev;
return {
...prev,
gugun: firstConcreteSggOption.gugun,
bsnmCd: firstConcreteSggOption.bsnmCd,
};
});
}, [
firstConcreteSggOption,
isDoSelected,
mergedProps.gugun,
props.defaultRegionAll,
selectedSido,
values.sido,
]);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
props.onSubmit?.(values);
};
const handleReset = () => {
setValues(buildSearchFormValues(props, sidoList, defaultYear));
props.onReset?.();
};
const handleSidoChange = (value: string) => {
const selected =
value === "ALL"
? undefined
: sidoList?.data?.sidoList?.find((v) => v.sido === value);
const selectedBsnm = selected?.bsnmCd || "";
const gugunAll =
props.defaultRegionAll || value === "ALL" || !isDoSido(selected);
setValues((prev) => ({
...prev,
sido: value,
gugun: gugunAll ? "ALL" : "",
bsnmCd: selectedBsnm,
}));
};
const handleSggChange = (value: string) => {
const listForBsnm = sggList?.data ?? [];
const selectedBsnm =
value === "ALL"
? sidoList?.data?.sidoList?.find((v) => v.sido === values.sido)
?.bsnmCd || ""
: listForBsnm.find((v) => v.gugun === value)?.bsnmCd || "";
setValues((prev) => ({
...prev,
gugun: value,
bsnmCd: selectedBsnm,
}));
};
useEffect(() => {
props.onValuesChange?.(values);
}, [values]);
return (
<div className="card card-border">
<form onSubmit={handleSubmit}>
<div className="flex items-center gap-8 p-2">
{mergedProps.sido && (
<div className="flex items-center gap-2">
<Label className="text-label">시도</Label>
<Select
value={values.sido}
onValueChange={handleSidoChange}
>
<SelectTrigger className="w-[12rem]">
<SelectValue
placeholder={sidoSelectText}
></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">
{sidoSelectText}
</SelectItem>
{sidoList?.data?.sidoList?.map((sido) => (
<SelectItem
key={sido.adstDstrCd}
value={sido.sido}
>
{sido.sido}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{mergedProps.gugun && (
<div className="flex items-center gap-2">
<Label className="text-label">시군구</Label>
<Select
value={gugunSelectValue}
onValueChange={handleSggChange}
>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="전체"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
{sggOptions?.map((sgg) => (
<SelectItem
key={sgg.adstDstrCd}
value={sgg.gugun}
>
{sgg.gugun}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{mergedProps.year && (
<div className="flex items-center gap-2">
<Label className="text-label">년도</Label>
<Select
value={values.year}
onValueChange={(value) =>
setValues({ ...values, year: value })
}
>
<SelectTrigger className="w-[10rem]">
<SelectValue placeholder="선택"></SelectValue>
</SelectTrigger>
<SelectContent>
{sidoList?.data?.yearList.map((year) => (
<SelectItem
key={year}
value={String(year)}
>
{year}년
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{mergedProps.clsf && (
<div className="flex items-center gap-2">
<Label className="text-label">분류</Label>
<Select
value={values.clsfCd ?? "ALL"}
onValueChange={(value) =>
setValues({
...values,
clsfCd: value as SearchFormClsfCd,
})
}
>
<SelectTrigger className="w-[10rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
<SelectItem value="INPT">입력</SelectItem>
<SelectItem value="VRFC">검증</SelectItem>
</SelectContent>
</Select>
</div>
)}
{mergedProps.fctName && (
<div className="flex items-center gap-2">
<Label
htmlFor="fctNmSearchInput"
className="mr-2 text-label"
>
시설명
</Label>
<Input
className="mr-4 h-[3.2rem] w-[14rem] rounded-sm !text-[1.3rem]"
id="fctNmSearchInput"
value={values.fctName}
onChange={(e) =>
setValues({
...values,
fctName: e.target.value,
})
}
autoComplete="off"
/>
</div>
)}
{mergedProps.capacityGbn && (
<div className="flex items-center gap-2">
<Label className="text-label">구분</Label>
<Select
value={values.capacityGbn}
onValueChange={(value) =>
setValues({ ...values, capacityGbn: value })
}
>
<SelectTrigger className="w-[10rem]">
<SelectValue placeholder="전체"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
<SelectItem value="500톤이상">
500톤이상
</SelectItem>
<SelectItem value="500톤미만">
500톤미만
</SelectItem>
</SelectContent>
</Select>
</div>
)}
{mergedProps.isDetail && (
<div className="flex items-center gap-2">
<Label
className="cursor-pointer text-label"
htmlFor={isDetailInputId}
>
세부 데이터 포함
</Label>
<Checkbox
id={isDetailInputId}
checked={values.isDetail}
onCheckedChange={(value) =>
setValues({
...values,
isDetail: value === true,
})
}
/>
</div>
)}
{mergedProps.isSummary && (
<div className="flex items-center gap-2">
<Label
className="cursor-pointer text-label"
title="시·군·구 및 읍·면·동 변경에 따라 인구 및 관로 등 공표된 통계수치와 일부 상이할 수 있으니 유의 바랍니다."
>
<i className="diveicon di-exclamation-circle mr-1" />
요약표 포함
</Label>
<Select
value={values.isSummary}
onValueChange={(value) =>
setValues({ ...values, isSummary: value })
}
>
<SelectTrigger className="w-[10rem]">
<SelectValue placeholder="선택 안함"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">
선택 안함
</SelectItem>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(
(i) => (
<SelectItem
value={i.toString()}
key={i.toString()}
>{`${i}개년`}</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
)}
{mergedProps.order && (
<div className="flex items-center gap-2">
<Label className="text-label">차수</Label>
<Select
value={values.order}
onValueChange={(value) =>
setValues({ ...values, order: value })
}
>
<SelectTrigger className="w-[10rem]">
<SelectValue placeholder="전체"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="ml-auto flex items-center gap-2">
{mergedProps.isReset && (
<Button
color="white"
className="h-[3.2rem] w-[96px]"
type="button"
onClick={handleReset}
>
초기화
</Button>
)}
<Button className="h-[3.2rem] w-[96px]" type="submit">
{props.searchButtonText
? props.searchButtonText
: "조회"}
</Button>
</div>
</div>
</form>
</div>
);
};
export default SearchForm;