Files
frontend/apps/as/src/pages/sewer-bsc-plan/ui/sections/tmplnt-form-section.tsx
T
2026-10-06 14:32:26 +09:00

531 lines
17 KiB
TypeScript

"use client";
import { useId, useMemo, useState } from "react";
import { Controller, useFieldArray, useFormContext, useWatch } from "react-hook-form";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
getDefaultFileKey,
NON_NEGATIVE_INPUT_PROPS,
sanitizeNumberInput,
useTus,
} from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { SectionCollapsible } from "@/shared/ui";
import { Field } from "@/shared/ui/field";
import { attachService } from "@/entities/attach";
import { useFacilityList } from "@/entities/bns-if";
import { useNowDmgMtncGnsvy } from "@/entities/dc";
import { truncateDecimals } from "@/widgets/pbadms-sprt";
import {
Button,
Checkbox,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@common/components";
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;
}
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_DECIMAL_PLACES = 3;
const TMPLNT_FIELD_ROWS: { key: TmplntFieldKey; label: string; decimals?: number }[] = [
{ key: "prcsZoneArea", label: "처리구역면적(km²)", decimals: TMPLNT_DECIMAL_PLACES },
{ key: "swtrmtPpltnCnt", label: "하수처리인구(명)" },
{ key: "planSwqty", label: "계획하수량(㎥/일)" },
{ key: "fcltCpct", label: "시설용량(㎥/일)" },
{ key: "sprsysDrtwPplLen", label: "분류식 오수(km)", decimals: TMPLNT_DECIMAL_PLACES },
{ key: "sprsysExlnPplLen", label: "분류식 우수(km)", decimals: TMPLNT_DECIMAL_PLACES },
{ key: "cbsysPplLen", label: "합류식(km)", decimals: TMPLNT_DECIMAL_PLACES },
{ key: "icptgPplLen", label: "차집관로(km)", decimals: TMPLNT_DECIMAL_PLACES },
{ key: "drgCnt", label: "배수설비(개소)" },
{ key: "drtwRlyPumpCnt", label: "오수 중계 펌프(개소)" },
{ key: "drtwPumpCnt", label: "오수 일반 펌프(개소)" },
{ key: "exlnPumpCnt", label: "우수 펌프(개소)" },
];
interface TmplntDtlInputProps {
facilityIndex: number;
planStpNo: number;
fieldKey: TmplntFieldKey;
decimals?: number;
}
function TmplntDtlInput({ facilityIndex, planStpNo, fieldKey, decimals }: TmplntDtlInputProps) {
const { control } = useFormContext<SewerBscPlanRegistFormData>();
const fieldName =
`sewerBscPlanTmplntList.${facilityIndex}.sewerBscPlanTmplntDtlList.${planStpNo}.${fieldKey}` as const;
const hasDecimals = decimals !== undefined;
return (
<Controller
control={control}
name={fieldName}
render={({ field }) => (
<Field>
<Input
{...NON_NEGATIVE_INPUT_PROPS}
type="number"
inputMode="decimal"
step={hasDecimals ? Number(`1e-${decimals}`) : undefined}
className="text-right"
value={field.value ?? ""}
onChange={(e) => {
const value = sanitizeNumberInput(e.target.value.replace(/,/g, ""));
const normalized = hasDecimals ? truncateDecimals(value, decimals) : value;
field.onChange(Number(normalized) || 0);
}}
/>
</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 caption="처리시설별 단계별 계획 입력" 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 scope="row">{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}
decimals={row.decimals}
/>
</TableCell>
{changeStepHeaders.map((step) => (
<TableCell key={`change-${row.key}-${step.planStpNo}`}>
<TmplntDtlInput
facilityIndex={facilityIndex}
planStpNo={step.planStpNo}
fieldKey={row.key}
decimals={row.decimals}
/>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
}
interface TmplntFacilityAttachmentsProps {
facilityIndex: number;
facilityName?: string;
atchFileGroupId?: string | null;
atchFileNm?: string | null;
}
function TmplntFacilityAttachments({
facilityIndex,
facilityName,
atchFileGroupId,
atchFileNm,
}: TmplntFacilityAttachmentsProps) {
const formId = useId();
const { setValue } = useFormContext<SewerBscPlanRegistFormData>();
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 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 caption="처리구역 필수 첨부파일" 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>
<TableHead scope="row" data-required align="left">
{facilityName} 계획평면도
</TableHead>
<TableCell className="text-center">{TMPLNT_ATCH_FORMAT_LABEL}</TableCell>
<TableCell>
{hasFile ? (
<div className="flex items-center gap-2">
<TextShimmer className="text-xl" active={uploading}>
{displayFileName}
</TextShimmer>
<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, getValues } = useFormContext<SewerBscPlanRegistFormData>();
const { fields, append, remove } = useFieldArray({
control,
name: "sewerBscPlanTmplntList",
});
const tmplntList = useWatch({ control, name: "sewerBscPlanTmplntList" }) ?? [];
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 = new Set(tmplntList.map((item) => item.swtrmtFcltCd));
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) {
if (checked) {
append({
swtrmtFcltCd: option.code,
swtrmtFcltNm: option.name,
atchFileGroupId: "",
atchFileNm: "",
sewerBscPlanTmplntDtlList: createEmptyTmplntDtlList(option.code),
});
setActiveTab(option.code);
return;
}
if (!(await confirm(`${option.name}을(를) 제거하시겠습니까?`))) return;
const index = getValues("sewerBscPlanTmplntList").findIndex(
(item) => item.swtrmtFcltCd === option.code,
);
if (index < 0) return;
remove(index);
const remaining = getValues("sewerBscPlanTmplntList");
setActiveTab(remaining[0]?.swtrmtFcltCd ?? "");
}
const resolvedActiveTab =
activeTab && selectedCodes.has(activeTab) ? activeTab : (tmplntList[0]?.swtrmtFcltCd ?? "");
return (
<SectionCollapsible title="처리시설별 하수도정비기본계획">
<div className="space-y-4 p-2">
<div className="flex flex-wrap gap-4">
{facilityOptions.length === 0 ? (
<p className="text-xl text-muted-foreground">조회된 처리시설이 없습니다.</p>
) : (
facilityOptions.map((option) => (
<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)}
/>
<span>{option.name}</span>
</label>
))
)}
</div>
{fields.length > 0 ? (
<Tabs value={resolvedActiveTab} onValueChange={setActiveTab} variant="underline">
<TabsList className="p-0">
{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 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"
>
<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>
);
})}
</Tabs>
) : (
<p className="text-xl text-muted-foreground">
처리시설을 선택하면 시설별 입력 탭이 표시됩니다.
</p>
)}
</div>
</SectionCollapsible>
);
}
export { TmplntFormSection };