Files
frontend/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx
T

285 lines
14 KiB
TypeScript

import { useRef, useState } from "react";
import { Button, LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import SearchForm from "@/components/module/SearchForm";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { statisticsPpltnApi } from "@/entities/statistics/api/statistics-ppltn-service";
import type { DomesticPopulationItem } from "@/entities/statistics/model/ppltn/statistics-domestic-population-interface";
import { usePopulationMappingTab } from "@/components/statistics/management/hooks/usePopulationMappingTab";
import { useAdmdstRenewalTab } from "@/components/statistics/management/hooks/useAdmdstRenewalTab";
import { usePopulationStatusTab } from "@/components/statistics/management/hooks/usePopulationStatusTab";
import { usePopulationVizTab } from "@/components/statistics/management/hooks/usePopulationVizTab";
import DomesticPopulationTab from "./DomesticPopulationTab";
import ForeignPopulationTab from "./ForeignPopulationTab";
import AdmdstRenewalTab from "./AdmdstRenewalTab";
import PopulationStatusTab from "./PopulationStatusTab";
import PopulationVizTab from "./PopulationVizTab";
import type { ForeignPopulationItem } from "@/components/statistics/management/types/population-tab-types";
const TAB_LIST = [
{ key: "domestic", label: "행정인구등록" },
{ key: "foreign", label: "외국인인구등록" },
{ key: "renewal", label: "행정동갱신" },
{ key: "status", label: "행정인구현황" },
{ key: "statusViz", label: "행정인구현황 시각화" },
];
const StatisticsPopulationManagement = () => {
const [activeTab, setActiveTab] = useState("domestic");
/** 검색폼은 모든 탭이 공유. 마지막 조회 조건만 ref로 기억해뒀다가 탭 전환 시 재사용 */
const lastSearchedValuesRef = useRef<SearchFormValues | null>(null);
/* ── 탭별 상태/로직은 전부 훅에서 가져온다. 탭이 화면에 보이지 않아도 훅은 항상 호출되므로
탭을 왔다갔다 해도 데이터가 유지된다 (원본 컴포넌트와 동일한 동작) ── */
const domesticTab = usePopulationMappingTab<DomesticPopulationItem>({
kind: "domestic",
getList: statisticsPpltnApi.getDomesticPopulationList,
uploadExcel: statisticsPpltnApi.uploadDomesticPopulationExcel,
saveFinal: statisticsPpltnApi.saveDomesticPopulationFinal,
getUpperCodesByName: statisticsPpltnApi.getUpperCodesByName,
requiredHeaderKeywords: ["행정기관코드", "행정기관"],
invalidFileMessage: "행정인구(내국인) 전용 파일만 업로드 가능합니다.\n올바른 파일을 선택해주세요.",
});
const foreignTab = usePopulationMappingTab<ForeignPopulationItem>({
kind: "foreign",
getList: async (statsYr, opt1) => {
const res = await statisticsPpltnApi.getForeignPopulationList(statsYr, opt1);
// res.data를 확실하게 ForeignPopulationItem[]로 캐스팅
return { ...res, data: res.data as ForeignPopulationItem[] };
},
uploadExcel: statisticsPpltnApi.uploadForeignPopulationExcel,
saveFinal: statisticsPpltnApi.saveForeignPopulationFinal,
getUpperCodesByName: statisticsPpltnApi.getUpperCodesByName,
requiredHeaderKeywords: ["행정동코드", "행정동명", "외국인수"],
invalidFileMessage: "외국인 인구 전용 파일만 업로드 가능합니다.\n올바른 파일을 선택해주세요.",
});
const renewalTab = useAdmdstRenewalTab();
const statusTab = usePopulationStatusTab();
const vizTab = usePopulationVizTab();
const statsYr = lastSearchedValuesRef.current?.year ?? "";
const isLoading =
(activeTab === "domestic" && domesticTab.isLoading) ||
(activeTab === "foreign" && foreignTab.isLoading) ||
(activeTab === "renewal" && renewalTab.isLoading) ||
(activeTab === "status" && statusTab.isLoading) ||
(activeTab === "statusViz" && vizTab.isLoading);
/* ════════════════ 공통 ════════════════ */
const handleSearch = async (values: SearchFormValues) => {
lastSearchedValuesRef.current = values;
const yr = values.year ?? "";
if (activeTab === "domestic") await domesticTab.search(yr);
else if (activeTab === "foreign") await foreignTab.search(yr);
else if (activeTab === "renewal") await renewalTab.loadAll(yr);
else if (activeTab === "status") await statusTab.search(yr);
else if (activeTab === "statusViz") await vizTab.search(yr);
};
const handleReset = () => {
domesticTab.reset();
foreignTab.reset();
renewalTab.reset();
statusTab.reset();
vizTab.reset();
setActiveTab("domestic");
lastSearchedValuesRef.current = null;
};
const handleActiveTabChange = async (tabKey: string) => {
setActiveTab(tabKey);
const values = lastSearchedValuesRef.current;
if (!values) return;
const yr = values.year ?? "";
if (tabKey === "domestic") await domesticTab.loadOnActivate(yr);
else if (tabKey === "foreign") await foreignTab.loadOnActivate(yr);
else if (tabKey === "renewal") await renewalTab.loadOnActivate(yr);
// status / statusViz는 원본에서도 탭 전환 시 자동 재조회하지 않음 (그대로 유지)
};
return (
<div className="relative">
<input
ref={domesticTab.fileInputRef}
type="file"
accept=".xlsx,.xls,.csv"
className="hidden"
onChange={(e) => void domesticTab.handleFileChange(e, statsYr)}
/>
<input
ref={foreignTab.fileInputRef}
type="file"
accept=".xlsx,.xls,.csv"
className="hidden"
onChange={(e) => void foreignTab.handleFileChange(e, statsYr)}
/>
<div className="relative">
<SearchForm sido={false} gugun={false} onSubmit={handleSearch} onReset={handleReset} />
{activeTab === "status" && (
<div className="absolute top-0 left-[16rem] h-full flex items-center gap-6 px-4">
<label className="text-[1.3rem] font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-label cursor-pointer flex items-center gap-1.5">
<input
type="checkbox"
checked={statusTab.includeOriginMale}
onChange={(e) => statusTab.setIncludeOriginMale(e.target.checked)}
className="size-4"
/>
내국인 남성 포함
</label>
<label className="text-[1.3rem] font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-label cursor-pointer flex items-center gap-1.5">
<input
type="checkbox"
checked={statusTab.includeOriginFemale}
onChange={(e) => statusTab.setIncludeOriginFemale(e.target.checked)}
className="size-4"
/>
내국인 여성 포함
</label>
<label className="text-[1.3rem] font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-label cursor-pointer flex items-center gap-1.5">
<input
type="checkbox"
checked={statusTab.includeForeignMale}
onChange={(e) => statusTab.setIncludeForeignMale(e.target.checked)}
className="size-4"
/>
외국인 남성 포함
</label>
<label className="text-[1.3rem] font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-label cursor-pointer flex items-center gap-1.5">
<input
type="checkbox"
checked={statusTab.includeForeignFemale}
onChange={(e) => statusTab.setIncludeForeignFemale(e.target.checked)}
className="size-4"
/>
외국인 여성 포함
</label>
</div>
)}
{activeTab === "statusViz" && (
<div className="absolute top-0 left-[16rem] h-full flex items-center gap-5 px-4 flex-nowrap">
<label className="text-[1.3rem] font-medium leading-none text-label cursor-pointer flex items-center gap-1.5 shrink-0 whitespace-nowrap">
<input
type="checkbox"
checked={vizTab.includeOriginMale}
onChange={(e) => vizTab.setIncludeOriginMale(e.target.checked)}
className="size-4"
/>
내국인 남성
</label>
<label className="text-[1.3rem] font-medium leading-none text-label cursor-pointer flex items-center gap-1.5 shrink-0 whitespace-nowrap">
<input
type="checkbox"
checked={vizTab.includeOriginFemale}
onChange={(e) => vizTab.setIncludeOriginFemale(e.target.checked)}
className="size-4"
/>
내국인 여성
</label>
<label className="text-[1.3rem] font-medium leading-none text-label cursor-pointer flex items-center gap-1.5 shrink-0 whitespace-nowrap">
<input
type="checkbox"
checked={vizTab.includeForeignMale}
onChange={(e) => vizTab.setIncludeForeignMale(e.target.checked)}
className="size-4"
/>
외국인 남성
</label>
<label className="text-[1.3rem] font-medium leading-none text-label cursor-pointer flex items-center gap-1.5 shrink-0 whitespace-nowrap">
<input
type="checkbox"
checked={vizTab.includeForeignFemale}
onChange={(e) => vizTab.setIncludeForeignFemale(e.target.checked)}
className="size-4"
/>
외국인 여성
</label>
<Button
type="button"
color="primary"
onClick={vizTab.selectAll}
className="px-[1rem] py-[0.5rem] text-[1.2rem] shrink-0 whitespace-nowrap"
>
전체선택
</Button>
<Button
type="button"
color="primary"
onClick={vizTab.deselectAll}
className="px-[1rem] py-[0.5rem] text-[1.2rem] shrink-0 whitespace-nowrap"
>
전체해제(총 인구만 조회)
</Button>
</div>
)}
</div>
{isLoading && (
<div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
<Tabs
value={activeTab}
onValueChange={(v) => void handleActiveTabChange(v)}
className="w-full"
variant="default"
>
<TabsList>
{TAB_LIST.map((tab) => (
<TabsTrigger key={tab.key} value={tab.key}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="domestic">
<DomesticPopulationTab
tab={domesticTab}
statsYr={statsYr}
onFileButtonClick={() => domesticTab.fileInputRef.current?.click()}
/>
</TabsContent>
<TabsContent value="foreign">
<ForeignPopulationTab
tab={foreignTab}
statsYr={statsYr}
onFileButtonClick={() => foreignTab.fileInputRef.current?.click()}
/>
</TabsContent>
<TabsContent value="renewal">
<AdmdstRenewalTab tab={renewalTab} statsYr={statsYr} />
</TabsContent>
<TabsContent value="status">
<PopulationStatusTab tab={statusTab} />
</TabsContent>
<TabsContent value="statusViz">
<PopulationVizTab tab={vizTab} />
</TabsContent>
</Tabs>
</div>
</div>
);
};
export default StatisticsPopulationManagement;