818 lines
25 KiB
TypeScript
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>
|
|
);
|
|
} |