749 lines
26 KiB
TypeScript
749 lines
26 KiB
TypeScript
// app/(with-layout)/apply/register/page.tsx
|
|
/**
|
|
* 수요정보등록(수정) 화면
|
|
*
|
|
* 🔗 사용 API
|
|
* - 수요정보등록: POST /reuseFc/request/demandInfo
|
|
* - 수요정보조회: GET /reuseFc/request/' + dmdInfoId - getSdmInfoStatusList
|
|
*/
|
|
"use client";
|
|
|
|
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { useDmStore } from "@/app/store";
|
|
import type { DemandCompBizType } from "@/app/store/sdmMng";
|
|
import { putFetch } from "@/components/ui/api";
|
|
import SubPageHeader from "@/components/SubPageHeader";
|
|
import WaterQualityRefModal from "../modal/WaterQualityRefModal";
|
|
|
|
type PHRange = { min: string; max: string };
|
|
type SessionUser = {
|
|
dmdInfoId?: string;
|
|
dmdCoNm?: string;
|
|
ctpvCd?: string;
|
|
sggCd?: string;
|
|
zip?: string;
|
|
daddr?: string;
|
|
telno?: string;
|
|
rgtr?: string;
|
|
};
|
|
|
|
const toNumberOrNull = (value: string, disabled = false) => {
|
|
if (disabled) return null;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
const parsed = Number(trimmed);
|
|
return Number.isNaN(parsed) ? null : parsed;
|
|
};
|
|
|
|
const toNumberOrEmpty = (value: string) => {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return "" as const;
|
|
const parsed = Number(trimmed);
|
|
return Number.isNaN(parsed) ? ("" as const) : parsed;
|
|
};
|
|
|
|
const isNumeric = (value: string) => /^\d+(\.\d+)?$/.test(value.trim());
|
|
|
|
const formatDateForInput = (raw?: string | null) => {
|
|
if (!raw) return "";
|
|
const parsed = new Date(raw);
|
|
if (Number.isNaN(parsed.getTime())) return "";
|
|
const yyyy = parsed.getFullYear();
|
|
const mm = String(parsed.getMonth() + 1).padStart(2, "0");
|
|
const dd = String(parsed.getDate()).padStart(2, "0");
|
|
return `${yyyy}-${mm}-${dd}`;
|
|
};
|
|
|
|
const parsePhRange = (raw?: string | null): PHRange => {
|
|
if (!raw) return { min: "", max: "" };
|
|
const [min = "", max = ""] = raw.split("~").map((v) => v.trim());
|
|
return { min, max };
|
|
};
|
|
|
|
const getSessionUser = (): SessionUser | null => {
|
|
if (typeof window === "undefined") return null;
|
|
const raw = sessionStorage.getItem("user");
|
|
if (!raw) return null;
|
|
try {
|
|
return JSON.parse(raw) as SessionUser;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
function DemandRegisterPageContent() {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const { insertSdmInfoData, Sdm } = useDmStore((state) => state);
|
|
|
|
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 [editMode, setEditMode] = useState(false);
|
|
const [updateAllowed, setUpdateAllowed] = useState(true);
|
|
const [dmdInfoSn, setDmdInfoSn] = useState<number>(0);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
// 기본정보
|
|
const [reqDate, setReqDate] = useState<string>(today); // 요구시기
|
|
const [reqQty, setReqQty] = useState<string>(""); // 요구수량(m²/d)
|
|
const [usePurpose, setUsePurpose] = useState<string>(""); // 사용용도
|
|
const [hopePrice, setHopePrice] = useState<string>(""); // 희망단가
|
|
|
|
|
|
// 요구수질
|
|
const [coliform, setColiform] = useState<string>(""); // 총대장균군수(개/mg)
|
|
const [noDetect, setNoDetect] = useState<boolean>(false); // 총대장균군수 불검출
|
|
|
|
const [tn, setTn] = useState<string>(""); // 총질소(T-N)(mg/L)
|
|
const [residualCl, setResidualCl] = useState<string>(""); // 결합잔류염소(mg/L)
|
|
const [tp, setTp] = useState<string>(""); // 총인(T-P)(mg/L)
|
|
const [turbidity, setTurbidity] = useState<string>(""); //
|
|
const [ph, setPh] = useState<PHRange>({ min: "", max: "" }); // 수소이온농도(pH)
|
|
const [bod, setBod] = useState<string>(""); // 생물화학적산소요구량(BOD)(mg/L)
|
|
const [chloride, setChloride] = useState<string>(""); // 염화물(mgCL/L)
|
|
const [odor, setOdor] = useState<string>("불쾌하지 않을 것"); // 냄새
|
|
const [ec, setEc] = useState<string>(""); // 전기전도도(µs/cm)
|
|
const [color, setColor] = useState<string>(""); // 색도(도)
|
|
const [remark, setRemark] = useState<string>(""); // 비고
|
|
|
|
|
|
// 공급처 후보지 선택(필수아님)
|
|
const [cand1, setCand1] = useState("");
|
|
const [cand2, setCand2] = useState("");
|
|
const [cand3, setCand3] = useState("");
|
|
|
|
useEffect(() => {
|
|
const isEditParam =
|
|
searchParams.get("editMode") === "true" ||
|
|
Boolean(searchParams.get("dmdInfoSn"));
|
|
setEditMode(isEditParam);
|
|
|
|
const status = (searchParams.get("status") || "").trim();
|
|
setUpdateAllowed(!isEditParam || status === "신청");
|
|
|
|
if (!isEditParam) return;
|
|
|
|
const parsedSn = Number(searchParams.get("dmdInfoSn") || "0");
|
|
setDmdInfoSn(Number.isNaN(parsedSn) ? 0 : parsedSn);
|
|
|
|
setReqDate(formatDateForInput(searchParams.get("dmndTmng")) || today);
|
|
setReqQty(searchParams.get("dmndQty") || "");
|
|
setUsePurpose(searchParams.get("useUsgCd") || "");
|
|
setHopePrice(searchParams.get("wntUntprc") || "");
|
|
|
|
const rawColiform = searchParams.get("grssClfg") || "";
|
|
const isNoDetect = rawColiform === "-1";
|
|
setNoDetect(isNoDetect);
|
|
setColiform(isNoDetect ? "" : rawColiform);
|
|
|
|
setTn(searchParams.get("tn") || "");
|
|
setResidualCl(searchParams.get("combResdch") || "");
|
|
setTp(searchParams.get("tph") || "");
|
|
setTurbidity(searchParams.get("trbt") || "");
|
|
setBod(searchParams.get("blgcOxygDmndQnt") || "");
|
|
setChloride(searchParams.get("clIonQnt") || "");
|
|
setEc(searchParams.get("cdtnv") || "");
|
|
setColor(searchParams.get("crmty") || "");
|
|
setRemark(searchParams.get("rmrk") || "");
|
|
|
|
const phRange = parsePhRange(searchParams.get("ph"));
|
|
setPh(phRange);
|
|
setOdor(searchParams.get("smll") || "불쾌하지 않을 것");
|
|
}, [searchParams, today]);
|
|
|
|
const validate = () => {
|
|
const disabledFields = Sdm.disableRules[usePurpose] || [];
|
|
const isDisabled = (field: string) => disabledFields.includes(field);
|
|
|
|
if (!reqDate) {
|
|
alert("요구시기를 입력해주세요.");
|
|
return false;
|
|
}
|
|
if (!reqQty.trim()) {
|
|
alert("요구수량을 입력해주세요.");
|
|
return false;
|
|
}
|
|
if (!/^\d+$/.test(reqQty.trim())) {
|
|
alert("요구수량은 숫자만 입력 가능합니다.");
|
|
return false;
|
|
}
|
|
if (!usePurpose.trim()) {
|
|
alert("사용용도를 입력해주세요.");
|
|
return false;
|
|
}
|
|
if (!hopePrice.trim()) {
|
|
alert("희망단가를 입력해주세요.");
|
|
return false;
|
|
}
|
|
if (!/^\d+$/.test(hopePrice.trim())) {
|
|
alert("희망단가는 숫자만 입력 가능합니다.");
|
|
return false;
|
|
}
|
|
if (!noDetect && coliform.trim() && !isNumeric(coliform)) {
|
|
alert("총대장균군수는 숫자만 입력 가능합니다.");
|
|
return false;
|
|
}
|
|
|
|
const requiredFields: Array<{ key: string; label: string; value: string }> = [
|
|
{ key: "tn", label: "총질소(T-N)(mg/L)", value: tn },
|
|
{ key: "combResdch", label: "결합잔류염소(mg/L)", value: residualCl },
|
|
{ key: "tph", label: "총인(T-P)(mg/L)", value: tp },
|
|
{ key: "trbt", label: "탁도(NTU)", value: turbidity },
|
|
{ key: "bod", label: "생물학적 산소요구량(BOD)(mg/L)", value: bod },
|
|
{ key: "clIonQnt", label: "염화물(mgCL/L)", value: chloride },
|
|
{ key: "smll", label: "냄새", value: odor },
|
|
{ key: "cdtnv", label: "전기전도도(㎲/cm)", value: ec },
|
|
{ key: "crmty", label: "색도(도)", value: color },
|
|
];
|
|
|
|
for (const field of requiredFields) {
|
|
if (isDisabled(field.key)) continue;
|
|
if (!field.value.trim()) {
|
|
alert(`${field.label} 을 입력해주세요.`);
|
|
return false;
|
|
}
|
|
if (field.key !== "smll" && !isNumeric(field.value)) {
|
|
alert(`${field.label} 은 숫자만 입력 가능합니다.`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (!isDisabled("ph") && (!ph.min.trim() || !ph.max.trim())) {
|
|
alert("수소이온농도(pH) 을 입력해주세요.");
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const onSubmit = async () => {
|
|
if (submitting) return;
|
|
if (!validate()) return;
|
|
|
|
if (editMode && !updateAllowed) {
|
|
alert("진행상태가 신청인 경우에만 수정할 수 있습니다.");
|
|
return;
|
|
}
|
|
|
|
const sessionUser = getSessionUser();
|
|
const idFromQuery =
|
|
(searchParams.get("dmdInfoId") || "") + (searchParams.get("bznmTotNo") || "");
|
|
const dmdInfoId = String(sessionUser?.dmdInfoId || idFromQuery || "").trim();
|
|
|
|
if (!dmdInfoId) {
|
|
alert("수요처 정보가 없어 저장할 수 없습니다.");
|
|
return;
|
|
}
|
|
|
|
const disabledFields = Sdm.disableRules[usePurpose] || [];
|
|
const isDisabled = (field: string) => disabledFields.includes(field);
|
|
const parsedPh = isDisabled("ph") ? "" : `${ph.min.trim()}~${ph.max.trim()}`;
|
|
const cands = [cand1, cand2, cand3]
|
|
.map((name) => name.trim())
|
|
.filter(Boolean)
|
|
.map((name) => ({
|
|
ruseUtztnFcltNm: name,
|
|
ruseUtztnFcltCd: "",
|
|
}))
|
|
.filter((item) => item.ruseUtztnFcltNm && item.ruseUtztnFcltCd);
|
|
|
|
const payload: DemandCompBizType = {
|
|
index: 0,
|
|
dmdInfoId,
|
|
dmdInfoFk: dmdInfoId,
|
|
bzmnNo: "",
|
|
dmdCoNm: String(sessionUser?.dmdCoNm || searchParams.get("dmdCoNm") || ""),
|
|
zip: String(sessionUser?.zip || searchParams.get("zip") || ""),
|
|
daddr: String(sessionUser?.daddr || searchParams.get("daddr") || ""),
|
|
telno: String(sessionUser?.telno || searchParams.get("telno") || ""),
|
|
rgtr: String(sessionUser?.rgtr || searchParams.get("rgtr") || ""),
|
|
useUsgCd: usePurpose.trim(),
|
|
ctpvCd: String(sessionUser?.ctpvCd || searchParams.get("ctpvCd") || ""),
|
|
sggCd: String(sessionUser?.sggCd || searchParams.get("sggCd") || ""),
|
|
dmdAplyPswd: "",
|
|
wntUntprc: toNumberOrEmpty(hopePrice),
|
|
dmndTmng: reqDate ? new Date(reqDate) : "",
|
|
dmndQty: toNumberOrEmpty(reqQty),
|
|
grssClfg: noDetect ? -1 : toNumberOrNull(coliform),
|
|
tn: toNumberOrNull(tn, isDisabled("tn")),
|
|
combResdch: toNumberOrNull(residualCl, isDisabled("combResdch")),
|
|
tph: toNumberOrNull(tp, isDisabled("tph")),
|
|
trbt: toNumberOrNull(turbidity, isDisabled("trbt")),
|
|
ph: parsedPh,
|
|
blgcOxygDmndQnt: toNumberOrNull(bod, isDisabled("bod")),
|
|
clIonQnt: toNumberOrNull(chloride, isDisabled("clIonQnt")),
|
|
smll: isDisabled("smll") ? "" : odor.trim(),
|
|
cdtnv: toNumberOrNull(ec, isDisabled("cdtnv")),
|
|
crmty: toNumberOrNull(color, isDisabled("crmty")),
|
|
rmrk: remark.trim(),
|
|
reqDt: "",
|
|
dmdInfoSn: editMode ? dmdInfoSn : 0,
|
|
status: String(searchParams.get("status") || ""),
|
|
splyCndtList: cands,
|
|
};
|
|
|
|
try {
|
|
setSubmitting(true);
|
|
if (editMode) {
|
|
const updatePayload = {
|
|
...payload,
|
|
mdfr: String(sessionUser?.rgtr || payload.rgtr || ""),
|
|
};
|
|
await putFetch("/reuseFc/request/demandInfo", updatePayload);
|
|
alert("수요정보 수정 완료!");
|
|
} else {
|
|
await insertSdmInfoData(payload);
|
|
}
|
|
router.push("/apply/status");
|
|
} catch (error) {
|
|
console.error("Failed to submit demand info", error);
|
|
alert(editMode ? "수요정보 수정에 실패하였습니다." : "수요정보 등록에 실패하였습니다.");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
|
|
<main className="bg-white">
|
|
|
|
{/* 서브 타이틀 + 브레드크럼 */}
|
|
<SubPageHeader
|
|
title="수요정보등록"
|
|
crumbs={[
|
|
{ label: "Home", href: "/main" },
|
|
{ label: "수요신청", href: "/apply/status" },
|
|
{ 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} placeholder="" />
|
|
</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 justify-center items-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">
|
|
{/* 1행 */}
|
|
<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 />
|
|
|
|
{/* 2행 */}
|
|
<QualityRow
|
|
leftLabel="결합잔류염소(mg/L)"
|
|
leftRequired
|
|
leftControl={<TextInput value={residualCl} onChange={setResidualCl} />}
|
|
rightLabel="총인(T-P)(mg/L)"
|
|
rightControl={
|
|
<InlineSuffixInput value={tp} onChange={setTp} suffix="이하" />
|
|
}
|
|
/>
|
|
<Divider />
|
|
|
|
{/* 3행 */}
|
|
<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 />
|
|
|
|
{/* 4행 */}
|
|
<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 />
|
|
|
|
{/* 5행 */}
|
|
<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 />
|
|
|
|
{/* 6행 */}
|
|
<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={() => {
|
|
// TODO: 후보지 조회 모달/검색 연결
|
|
console.log("후보지 조회", { cand1, cand2, cand3 });
|
|
}}
|
|
>
|
|
후보지 조회
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="h-11 rounded-md bg-[#888] px-5 text-base text-white hover:bg-neutral-600"
|
|
onClick={() => {
|
|
// TODO: 유사사례 연결
|
|
console.log("유사사례");
|
|
}}
|
|
>
|
|
유사사례
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 하단 버튼 */}
|
|
<div className="flex justify-center py-12">
|
|
<button
|
|
type="button"
|
|
disabled={submitting}
|
|
className="h-11 rounded-md bg-[#0B4EA2] px-10 text-lg text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-70"
|
|
onClick={onSubmit}
|
|
>
|
|
{editMode ? "수요정보 수정" : "수요정보 등록"}
|
|
</button>
|
|
</div>
|
|
|
|
</div>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function DemandRegisterPage() {
|
|
return (
|
|
<Suspense fallback={<div>Loading...</div>}>
|
|
<DemandRegisterPageContent />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
/* ----------------------------- 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 text-center bg-emerald-50/70 px-6 py-6 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 text-center bg-emerald-50/70 px-6 py-6 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 text-center bg-emerald-50/70 px-6 py-6 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;
|
|
|
|
// Chrome/Edge 등 지원
|
|
if (typeof (el as any).showPicker === "function") {
|
|
(el as any).showPicker();
|
|
return;
|
|
}
|
|
|
|
// fallback (일부 브라우저)
|
|
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",
|
|
// ✅ 기본 달력 아이콘 숨김 (Tailwind arbitrary selector)
|
|
"[&::-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>
|
|
);
|
|
}
|