531 lines
17 KiB
TypeScript
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 };
|