Files
frontend/apps/dm_new/app/(landing)/reuse/apply/register/page.tsx
T
2026-03-06 16:42:15 +09:00

818 lines
25 KiB
TypeScript

// apps/dm/app/(landing)/reuse/apply/register/page.tsx (수요정보등록 페이지)
"use client";
import { useMemo, useState, useRef } from "react";
import SubPageHeader from "../../_components/SubPageHeader";
import WaterQualityRefModal from "../modal/WaterQualityRefModal";
import CandidateSiteModal, { type CandidateSite } from "../modal/CandidateSiteModal";
import SimilarCaseModal, { type SimilarCaseRow, type SimilarCaseDetail } from "../modal/SimilarCaseModal";
type PHRange = { min: string; max: string };
export default function DemandRegisterPage() {
const today = useMemo(() => {
const d = new Date();
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}, []);
const [openRef, setOpenRef] = useState(false);
const [openCand, setOpenCand] = useState(false);
const [selectedCands, setSelectedCands] = useState<CandidateSite[]>([]);
// 유사사례 모달
const [openSimilar, setOpenSimilar] = useState(false);
// 유사사례 rows (목업) - API 연동 시 여기만 교체
// const rows: SimilarCaseRow[] = useMemo(
// () => [
// {
// caseId: "C-001",
// projectName: "OO시 물 재이용(빗물, 중수도) 시설 설치사업",
// period: "2020.01. ~ 2025.05.",
// supplyFacilityType: "중수도",
// supplyName: "OO새교복지타운",
// supplyCapacity: 0,
// demandName: "OO새교복지타운",
// demandAddress: "OO시 수청로 192",
// facilityCapacity: 50,
// pipeKm: 0,
// purpose: "청소, 화장실, 조경",
// supplyAmt: 50,
// treatment: "MBLM",
// usePurpose: "청소,화장실",
// },
// {
// caseId: "C-001",
// projectName: "OO시 물 재이용(빗물, 중수도) 시설 설치사업",
// period: "2020.01. ~ 2025.05.",
// supplyFacilityType: "중수도",
// supplyName: "OO스포츠센터",
// supplyCapacity: 0,
// demandName: "OO스포츠센터",
// demandAddress: "OO시 경기동로 33",
// facilityCapacity: 50,
// pipeKm: 0,
// purpose: "청소, 화장실, 조경",
// supplyAmt: 50,
// treatment: "MBLM",
// usePurpose: "청소,화장실",
// },
// {
// caseId: "C-002",
// projectName: "OO 엄성저수지 하수처리수 재이용사업",
// period: "2019.12 ~ 2026.01",
// supplyFacilityType: "하수",
// supplyName: "OO 3,4단계 공공하수처리시설",
// supplyCapacity: 70000,
// demandName: "OO천",
// demandAddress: "-",
// facilityCapacity: 0,
// pipeKm: 3,
// purpose: "하천 등 유지용수",
// supplyAmt: 2300,
// treatment: "탈황,총인, 생물산화과,소독",
// usePurpose: "하천 등 유지용수",
// },
// {
// caseId: "C-002",
// projectName: "OO 엄성저수지 하수처리수 재이용사업",
// period: "2019.12 ~ 2026.01",
// supplyFacilityType: "하수",
// supplyName: "OO 3,4단계 공공하수처리시설",
// supplyCapacity: 70000,
// demandName: "OO저수지",
// demandAddress: "-",
// facilityCapacity: 0,
// pipeKm: 5,
// purpose: "하천 등 유지용수",
// supplyAmt: 7700,
// treatment: "탈황,총인, 생물산화과,소독",
// usePurpose: "하천 등 유지용수",
// },
// ],
// []
// );
// 유사사례 상세(목업) - caseId로 매핑
// const detailsByCaseId: Record<string, SimilarCaseDetail> = useMemo(
// () => ({
// "C-001": {
// coliform: "불검출",
// residualCl: "0.2이상",
// turbidity: "1.5이하",
// bod: "4.0이하",
// odor: "불쾌하지않을것",
// color: "10이하",
// tn: "-",
// tp: "-",
// ph: "5.8~8.5",
// chloride: "-",
// ec: "-",
// note: "-",
// costTotal: 4305,
// costFacility: 3518,
// costPipe: undefined,
// costDesign: 350,
// costSupervision: 417,
// costOther: 12,
// fundingTotal: 4305,
// fundingNational: 3104,
// fundingLocal: 1291,
// fundingSelf: undefined,
// fundingRate: 70.0,
// },
// "C-002": {
// coliform: "불검출",
// residualCl: "0.15이상",
// turbidity: "2.0이하",
// bod: "5.0이하",
// odor: "무",
// color: "10이하",
// tn: "-",
// tp: "-",
// ph: "6.0~8.5",
// chloride: "-",
// ec: "-",
// note: "-",
// costTotal: 9800,
// costFacility: 2100,
// costPipe: 1800,
// costDesign: 300,
// costSupervision: 280,
// costOther: 1200,
// fundingTotal: 9800,
// fundingNational: 4900,
// fundingLocal: 3000,
// fundingSelf: 1900,
// fundingRate: 50,
// },
// }),
// []
// );
// 기본정보
const [reqDate, setReqDate] = useState<string>(today);
const [reqQty, setReqQty] = useState<string>("");
const [usePurpose, setUsePurpose] = useState<string>("");
const [hopePrice, setHopePrice] = useState<string>("");
// 요구수질
const [coliform, setColiform] = useState<string>("");
const [noDetect, setNoDetect] = useState<boolean>(false);
const [tn, setTn] = useState<string>("");
const [residualCl, setResidualCl] = useState<string>("");
const [tp, setTp] = useState<string>("");
const [turbidity, setTurbidity] = useState<string>("");
const [ph, setPh] = useState<PHRange>({ min: "", max: "" });
const [bod, setBod] = useState<string>("");
const [chloride, setChloride] = useState<string>("");
const [odor, setOdor] = useState<string>("불쾌하지 않을 것");
const [ec, setEc] = useState<string>("");
const [color, setColor] = useState<string>("");
const [remark, setRemark] = useState<string>("");
// 공급처 후보지 선택(필수아님)
const [cand1, setCand1] = useState("");
const [cand2, setCand2] = useState("");
const [cand3, setCand3] = useState("");
// 샘플 데이터
const similarRows: SimilarCaseRow[] = [
// ---------------- A (2행) ----------------
{
caseId: "A",
projectName: "OO시 물 재이용(빗물, 중수도) 시설 설치사업",
period: "2020.01.~2025.05.",
supplyFacilityType: "중수도",
supplyName: "OO폐수재이용",
supplyCapacity: 50,
demandName: "OO시체육센터",
demandAddress: "OO시 수성로 192",
facilityCapacity: 50,
pipeKm: 3,
purpose: "조경",
supplyAmt: 50,
treatment: "MBLM",
usePurpose: "청소, 화장실",
},
// A의 공급처 row 추가(=표 1행 추가) → 나머지 컬럼도 같이 채움
{
caseId: "A",
projectName: "OO시 물 재이용(빗물, 중수도) 시설 설치사업",
period: "2020.01.~2025.05.",
supplyFacilityType: "중수도",
supplyName: "OO스포츠센터",
supplyCapacity: 30,
demandName: "OO시체육센터",
demandAddress: "OO시 수성로 192",
facilityCapacity: 50,
pipeKm: 3,
purpose: "조경",
supplyAmt: 50,
treatment: "MBLM",
usePurpose: "청소, 화장실",
},
// ---------------- B (2행) ----------------
{
caseId: "B",
projectName: "OO 염성저수지 하수처리수 재이용사업",
period: "2019.12~2026.1",
supplyFacilityType: "하수",
supplyName: "OO3.4단계 공공하수처리시설",
supplyCapacity: 70000,
demandName: "OO천",
demandAddress: "-",
facilityCapacity: 0,
pipeKm: 3,
purpose: "하천 등 유지용수",
supplyAmt: 2300,
treatment: "탈황, 총인, 생물산화, 소독",
usePurpose: "하천 등 유지용수",
},
// B의 공급처 row 추가(=표 1행 추가)
{
caseId: "B",
projectName: "OO 염성저수지 하수처리수 재이용사업",
period: "2019.12~2026.1",
supplyFacilityType: "하수",
supplyName: "OO3.4단계 공공하수처리시설(2라인)",
supplyCapacity: 70000,
demandName: "OO저수지",
demandAddress: "-",
facilityCapacity: 0,
pipeKm: 5,
purpose: "하천 등 유지용수",
supplyAmt: 7700,
treatment: "탈황, 총인, 생물산화, 소독",
usePurpose: "하천 등 유지용수",
},
// ---------------- C (1행) ----------------
{
caseId: "C",
projectName: "OO 산업단지 공업용수 재이용사업",
period: "2021.03~2024.12",
supplyFacilityType: "하수",
supplyName: "OO공공하수처리장",
supplyCapacity: 15000,
demandName: "OO산업단지",
demandAddress: "OO시 산업로 25",
facilityCapacity: 5000,
pipeKm: 4,
purpose: "공업용수",
supplyAmt: 5000,
treatment: "UF + RO",
usePurpose: "공업용수",
},
// ---------------- D (1행) ----------------
{
caseId: "D",
projectName: "OO 공원 조경용수 재이용사업",
period: "2018.05~2022.11",
supplyFacilityType: "중수도",
supplyName: "OO정수장",
supplyCapacity: 8000,
demandName: "OO중앙공원",
demandAddress: "OO시 공원로 88",
facilityCapacity: 1200,
pipeKm: 2,
purpose: "조경용수",
supplyAmt: 1200,
treatment: "모래여과",
usePurpose: "조경",
},
];
const similarDetailsByCaseId: Record<string, SimilarCaseDetail> = {
A: {
coliform: "불검출",
residualCl: "0.2이상",
turbidity: "1.5이하",
bod: "4.0이하",
odor: "불쾌하지않을것",
color: "10이하",
tn: "-",
tp: "-",
ph: "5.8~8.5",
chloride: "-",
ec: "-",
note: "-",
costTotal: 4305,
costFacility: 3518,
costPipe: 0,
costDesign: 350,
costSupervision: 417,
costOther: 12,
fundingTotal: 4305,
fundingNational: 3104,
fundingLocal: 1291,
fundingSelf: 0,
fundingRate: 70.0,
},
B: {
coliform: "-",
residualCl: "-",
turbidity: "-",
bod: "-",
odor: "-",
color: "-",
tn: "-",
tp: "-",
ph: "-",
chloride: "-",
ec: "-",
note: "-",
costTotal: 0,
costFacility: 0,
costPipe: 0,
costDesign: 0,
costSupervision: 0,
costOther: 0,
fundingTotal: 0,
fundingNational: 0,
fundingLocal: 0,
fundingSelf: 0,
fundingRate: 0,
},
C: {
coliform: "불검출",
residualCl: "0.2이상",
turbidity: "1.5이하",
bod: "4.0이하",
odor: "불쾌하지않을것",
color: "10이하",
tn: "-",
tp: "-",
ph: "5.8~8.5",
chloride: "-",
ec: "-",
note: "-",
costTotal: 4305,
costFacility: 3518,
costPipe: 0,
costDesign: 350,
costSupervision: 417,
costOther: 12,
fundingTotal: 4305,
fundingNational: 3104,
fundingLocal: 1291,
fundingSelf: 0,
fundingRate: 70.0,
},
D: {
coliform: "-",
residualCl: "-",
turbidity: "-",
bod: "-",
odor: "-",
color: "-",
tn: "-",
tp: "-",
ph: "-",
chloride: "-",
ec: "-",
note: "-",
costTotal: 0,
costFacility: 0,
costPipe: 0,
costDesign: 0,
costSupervision: 0,
costOther: 0,
fundingTotal: 0,
fundingNational: 0,
fundingLocal: 0,
fundingSelf: 0,
fundingRate: 0,
},
};
return (
<>
<main className="bg-white">
<SubPageHeader
title="수요정보등록"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "수요신청", href: "/reuse/apply" },
{ label: "수요정보등록" },
]}
/>
<div>
<div className="border border-neutral-200 bg-white">
<FormRow label="요구시기" required>
<DateInput value={reqDate} onChange={setReqDate} />
</FormRow>
<Divider />
<FormRow label="요구수량(m²/d)" required>
<TextInput value={reqQty} onChange={setReqQty} />
</FormRow>
<Divider />
<FormRow label="사용용도" required>
<TextInput value={usePurpose} onChange={setUsePurpose} />
</FormRow>
<Divider />
<FormRow label="희망단가(원)" required>
<TextInput value={hopePrice} onChange={setHopePrice} />
</FormRow>
</div>
<div className="grid grid-cols-12">
<div className="col-span-12 lg:col-span-2 inline-flex items-center justify-center gap-3 px-6 py-6 text-center">
<span className="text-lg font-semibold text-neutral-800">요구수질</span>
<button
type="button"
onClick={() => setOpenRef(true)}
className="rounded border-[2px] border-[#DE3412] px-2 py-3 text-[15px] font-medium text-[#4F4F4F] hover:bg-orange-50"
>
수질기준 참고
</button>
<WaterQualityRefModal
open={openRef}
onClose={() => setOpenRef(false)}
imageSrc="/images/landing/water_quality_ref_1.png"
/>
</div>
</div>
<div className="border border-neutral-200 bg-white">
<QualityRow
leftLabel="총대장균군수(개/mg)"
leftRequired
leftControl={
<div className="flex items-center gap-4">
<TextInput
value={coliform}
onChange={setColiform}
disabled={noDetect}
className={noDetect ? "bg-neutral-100 text-neutral-400" : ""}
/>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input
type="checkbox"
checked={noDetect}
onChange={(e) => {
setNoDetect(e.target.checked);
if (e.target.checked) setColiform("");
}}
className="h-4 w-4 rounded border-neutral-300"
/>
불검출
</label>
</div>
}
rightLabel="총질소(T-N)(mg/L)"
rightControl={<InlineSuffixInput value={tn} onChange={setTn} suffix="이하" />}
/>
<Divider />
<QualityRow
leftLabel="결합잔류염소(mg/L)"
leftRequired
leftControl={<TextInput value={residualCl} onChange={setResidualCl} />}
rightLabel="총인(T-P)(mg/L)"
rightControl={<InlineSuffixInput value={tp} onChange={setTp} suffix="이하" />}
/>
<Divider />
<QualityRow
leftLabel="탁도(NTU)"
leftRequired
leftControl={<InlineSuffixInput value={turbidity} onChange={setTurbidity} suffix="이하" />}
rightLabel="수소이온농도(pH)"
rightControl={
<div className="flex items-center gap-2">
<TextInput value={ph.min} onChange={(v) => setPh((p) => ({ ...p, min: v }))} className="w-32" />
<span className="text-neutral-500">~</span>
<TextInput value={ph.max} onChange={(v) => setPh((p) => ({ ...p, max: v }))} className="w-32" />
</div>
}
/>
<Divider />
<QualityRow
leftLabel={
<>
생물화학적산소요구량
<br />
(BOD)(mg/L)
</>
}
leftRequired
leftControl={<InlineSuffixInput value={bod} onChange={setBod} suffix="이하" />}
rightLabel="염화물(mgCL/L)"
rightControl={<InlineSuffixInput value={chloride} onChange={setChloride} suffix="이하" />}
/>
<Divider />
<QualityRow
leftLabel="냄새"
leftRequired
leftControl={
<select
value={odor}
onChange={(e) => setOdor(e.target.value)}
className="h-11 w-full max-w-[200px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none focus:border-neutral-500"
>
<option>불쾌하지 않을 것</option>
<option>약간 있음</option>
<option>있음</option>
</select>
}
rightLabel="전기전도도(µs/cm)"
rightControl={<InlineSuffixInput value={ec} onChange={setEc} suffix="이하" />}
/>
<Divider />
<QualityRow
leftLabel="색도(도)"
leftRequired
leftControl={<InlineSuffixInput value={color} onChange={setColor} suffix="이하" />}
rightLabel="비고"
rightControl={
<textarea
value={remark}
onChange={(e) => setRemark(e.target.value)}
className="h-11 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus:border-neutral-500"
/>
}
/>
<Divider />
<div className="grid grid-cols-12">
<LabelCell>
공급처 후보지 선택
<br />
(필수아님)
</LabelCell>
<div className="col-span-12 md:col-span-10 p-6">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:gap-4">
<TextInput value={cand1} onChange={setCand1} className="max-w-[220px]" />
<TextInput value={cand2} onChange={setCand2} className="max-w-[220px]" />
<TextInput value={cand3} onChange={setCand3} className="max-w-[220px]" />
<div className="flex items-center gap-3 xl:ml-2">
<button
type="button"
className="h-11 rounded-md bg-[#888] px-5 text-base text-white hover:bg-neutral-700"
onClick={() => setOpenCand(true)}
>
후보지 조회
</button>
<button
type="button"
className="h-11 rounded-md bg-[#888] px-5 text-base text-white hover:bg-neutral-700"
onClick={() => setOpenSimilar(true)}
>
유사사례 조회
</button>
</div>
</div>
</div>
</div>
</div>
<div className="flex justify-center py-12">
<button
type="button"
className="h-11 rounded-md bg-[#0B4EA2] px-10 text-lg text-white hover:opacity-90"
onClick={() => console.log("수요정보 등록")}
>
수요정보 등록
</button>
</div>
</div>
</main>
{/* 후보지 조회 모달 */}
<CandidateSiteModal
open={openCand}
onClose={() => setOpenCand(false)}
initialSelected={selectedCands}
onApply={(list) => {
setSelectedCands(list);
setCand1(list[0]?.name ?? "");
setCand2(list[1]?.name ?? "");
setCand3(list[2]?.name ?? "");
setOpenCand(false);
}}
/>
{/* 유사사례 모달 */}
<SimilarCaseModal
open={openSimilar}
onClose={() => setOpenSimilar(false)}
rows={similarRows}
detailsByCaseId={similarDetailsByCaseId}
/>
</>
);
}
/* ----------------------------- UI helpers ----------------------------- */
function Divider() {
return <div className="h-px w-full bg-neutral-200" />;
}
function LabelCell({
children,
required,
}: {
children: React.ReactNode;
required?: boolean;
}) {
return (
<div className="col-span-12 md:col-span-2 flex items-center justify-center bg-emerald-50/70 px-6 py-6 text-center text-lg font-semibold text-neutral-800">
<span className="inline-flex items-center gap-1">
{children}
{required ? <span className="text-red-500">*</span> : null}
</span>
</div>
);
}
function FormRow({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<div className="grid grid-cols-12">
<LabelCell required={required}>{label}</LabelCell>
<div className="col-span-12 md:col-span-10 p-6">{children}</div>
</div>
);
}
function QualityRow({
leftLabel,
leftRequired,
leftControl,
rightLabel,
rightControl,
}: {
leftLabel: React.ReactNode;
leftRequired?: boolean;
leftControl: React.ReactNode;
rightLabel: React.ReactNode;
rightControl: React.ReactNode;
}) {
return (
<div className="grid grid-cols-12">
<div className="col-span-12 lg:col-span-2 flex items-center justify-center bg-emerald-50/70 px-6 py-6 text-center text-lg font-semibold text-neutral-800">
<span className="inline-flex items-center gap-1">
<span className="leading-6">{leftLabel}</span>
{leftRequired ? <span className="text-red-500">*</span> : null}
</span>
</div>
<div className="col-span-12 lg:col-span-4 p-6">{leftControl}</div>
<div className="col-span-12 lg:col-span-2 flex items-center justify-center bg-emerald-50/70 px-6 py-6 text-center text-lg font-semibold text-neutral-800">
<span className="leading-6">{rightLabel}</span>
</div>
<div className="col-span-12 lg:col-span-4 p-6">{rightControl}</div>
</div>
);
}
function TextInput({
value,
onChange,
placeholder,
disabled,
className,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}) {
return (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
className={[
"h-11 w-full max-w-[200px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none",
"focus:border-neutral-500",
disabled ? "cursor-not-allowed" : "",
className ?? "",
].join(" ")}
/>
);
}
function InlineSuffixInput({
value,
onChange,
suffix,
}: {
value: string;
onChange: (v: string) => void;
suffix: string;
}) {
return (
<div className="flex items-center gap-3">
<TextInput value={value} onChange={onChange} className="max-w-[200px]" />
<span className="text-sm text-neutral-600">{suffix}</span>
</div>
);
}
function DateInput({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const ref = useRef<HTMLInputElement | null>(null);
const openPicker = () => {
const el = ref.current;
if (!el) return;
if (typeof (el as any).showPicker === "function") {
(el as any).showPicker();
return;
}
el.focus();
el.click();
};
return (
<div className="relative w-full max-w-[200px]">
<input
ref={ref}
type="date"
value={value}
onChange={(e) => onChange(e.target.value)}
className={[
"h-11 w-full rounded-md border border-neutral-300 bg-white px-3 pr-10 text-sm outline-none focus:border-neutral-500",
"[&::-webkit-calendar-picker-indicator]:opacity-0",
"[&::-webkit-calendar-picker-indicator]:absolute",
"[&::-webkit-calendar-picker-indicator]:right-0",
"[&::-webkit-calendar-picker-indicator]:w-10",
"[&::-webkit-calendar-picker-indicator]:h-full",
"[&::-webkit-calendar-picker-indicator]:cursor-pointer",
].join(" ")}
/>
<button
type="button"
onClick={openPicker}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-500 hover:text-neutral-700"
aria-label="달력 열기"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M7.14286 7.99104H16.7812M6.48512 2.5625V4.19126M17.3125 2.5625V4.19106M20.5 7.19106L20.5 18.5625C20.5 20.2194 19.1569 21.5625 17.5 21.5625H6.5C4.84315 21.5625 3.5 20.2194 3.5 18.5625V7.19106C3.5 5.53421 4.84315 4.19106 6.5 4.19106H17.5C19.1569 4.19106 20.5 5.53421 20.5 7.19106Z"
stroke="#1A1A1A"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
);
}