Files
frontend/apps/dm_new/app/(with-layout)/apply/dmdco/page.tsx
T

578 lines
18 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import SubPageHeader from "@/components/SubPageHeader";
import PostcodeModal from "@/components/PostcodeModal";
import type { AddrItem } from "@/app/store/sdmMng/postcode-service";
import { useDmStore } from "@/app/store";
import { fetchType, getFetch, postFetch, putFetch } from "@/components/ui/api";
import { getCookie } from "@common/components/lib";
type Option = { value: string; label: string };
type SessionUser = {
dmdInfoId?: string;
dmdCoNm?: string;
zip?: string;
daddr?: string;
telno?: string;
ctpvCd?: string;
sggCd?: string;
rgtr?: string;
[key: string]: unknown;
};
const TOKEN_ID_COOKIE = "token-id";
const FALLBACK_UUID = "c475824c-0e82-4a06-a282-2b55a20df265";
const normalizeRegionName = (value: string) => value.replace(/\s+/g, "").trim();
const isSameRegionName = (left: string, right: string) => {
const a = normalizeRegionName(left);
const b = normalizeRegionName(right);
return Boolean(a && b && (a === b || a.includes(b) || b.includes(a)));
};
const formatKoreanPhoneNumber = (value: string) => {
const digits = value.replace(/\D/g, "").slice(0, 11); // 숫자만 추출하고 최대 11자리까지만 사용
if (!digits) return ""; // 입력이 비어 있으면 빈 문자열 반환
if (/^1\d{0,7}$/.test(digits)) { // 15xx/16xx/18xx 같은 대표번호 패턴
if (digits.length <= 4) return digits; // 아직 뒷자리가 부족하면 원문 유지
return `${digits.slice(0, 4)}-${digits.slice(4, 8)}`; // xxxx-xxxx
}
if (digits.startsWith("02")) { // 서울 지역번호(02)
const rest = digits.slice(2); // 국번/가입자번호 구간
if (!rest) return "02"; // 02만 입력된 상태
if (rest.length <= 3) return `02-${rest}`; // 02-xxx
if (rest.length <= 7) return `02-${rest.slice(0, 3)}-${rest.slice(3)}`; // 02-xxx-xxxx
return `02-${rest.slice(0, 4)}-${rest.slice(4, 8)}`; // 02-xxxx-xxxx
}
if (digits.startsWith("0")) { // 02 제외 일반 지역번호/휴대폰(010 포함)
const area = digits.slice(0, 3); // 앞 3자리 식별자
const rest = digits.slice(3); // 나머지 번호
if (!rest) return area; // 식별자만 입력된 상태
if (rest.length <= 3) return `${area}-${rest}`; // 0xx-xxx
if (rest.length <= 7) return `${area}-${rest.slice(0, 3)}-${rest.slice(3)}`; // 0xx-xxx-xxxx
return `${area}-${rest.slice(0, 4)}-${rest.slice(4, 8)}`; // 0xx-xxxx-xxxx
}
if (digits.length <= 4) return digits; // 예외 입력: 앞 4자리까지
if (digits.length <= 8) return `${digits.slice(0, 4)}-${digits.slice(4)}`; // xxxx-xxxx
return `${digits.slice(0, 4)}-${digits.slice(4, 8)}-${digits.slice(8, 11)}`; // xxxx-xxxx-xxx
};
function Divider() {
return <div className="h-px w-full bg-neutral-200" />;
}
function FormRow({
label,
required,
children,
}: {
label: ReactNode;
required?: boolean;
children: 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">
<div className="flex items-center gap-1 text-lg font-semibold text-neutral-800">
<span>{label}</span>
{required ? <span className="text-red-500">*</span> : null}
</div>
</div>
<div className="col-span-12 lg:col-span-10 px-6 py-6">{children}</div>
</div>
);
}
function TextInput({
value,
onChange,
placeholder,
type = "text",
disabled,
inputRef,
className = "",
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
type?: string;
disabled?: boolean;
inputRef?: RefObject<HTMLInputElement | null>;
className?: string;
}) {
return (
<input
ref={inputRef}
type={type}
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={[
"h-11 w-full rounded border border-neutral-300 bg-white px-3 text-sm text-neutral-800",
"placeholder:text-neutral-400 focus:border-neutral-400 focus:outline-none",
disabled ? "bg-neutral-100 text-neutral-400" : "",
className,
].join(" ")}
/>
);
}
function SelectBox({
value,
onChange,
options,
className = "",
}: {
value: string;
onChange: (v: string) => void;
options: Option[];
className?: string;
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={[
"h-11 w-[220px] rounded border border-neutral-300 bg-white px-3 text-sm text-neutral-800",
"focus:border-neutral-400 focus:outline-none",
className,
].join(" ")}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
export default function DemandPlaceRegisterPage() {
const { Common, initializeCommonData } = useDmStore((state) => state);
const { selectSidoData, selectSigunData } = Common;
const [editMode, setEditMode] = useState(false);
const [sessionUser, setSessionUser] = useState<SessionUser | null>(null);
const [sido, setSido] = useState("");
const [sigungu, setSigungu] = useState("");
const [company, setCompany] = useState("");
const [bizNo, setBizNo] = useState("");
const [workerNo, setWorkerNo] = useState("");
const [noWorkerNo, setNoWorkerNo] = useState(false);
const [zip, setZip] = useState("");
const [addr1, setAddr1] = useState("");
const [addr2, setAddr2] = useState("");
const [isPostcodeModalOpen, setIsPostcodeModalOpen] = useState(false);
const [focusDetailAddrOnClose, setFocusDetailAddrOnClose] = useState(false);
const detailAddrRef = useRef<HTMLInputElement | null>(null);
const [tel, setTel] = useState("");
const [pw, setPw] = useState("");
const [pw2, setPw2] = useState("");
useEffect(() => {
initializeCommonData();
}, [initializeCommonData]);
useEffect(() => {
if (typeof window === "undefined") return;
const rawUser = sessionStorage.getItem("user");
if (!rawUser) {
setEditMode(false);
setSessionUser(null);
return;
}
try {
const user = JSON.parse(rawUser) as SessionUser;
setSessionUser(user);
setEditMode(true);
const fullId = String(user.dmdInfoId ?? "").trim();
const bizDigits = fullId.slice(0, 10);
const workerDigits = fullId.slice(10, 14);
setBizNo(bizDigits);
if (workerDigits && workerDigits !== "0000") {
setNoWorkerNo(false);
setWorkerNo(workerDigits);
} else {
setNoWorkerNo(true);
setWorkerNo("");
}
setCompany(String(user.dmdCoNm ?? ""));
setZip(String(user.zip ?? ""));
setAddr1(String(user.daddr ?? ""));
setAddr2("");
setTel(formatKoreanPhoneNumber(String(user.telno ?? "")));
setSido(String(user.ctpvCd ?? ""));
setSigungu(String(user.sggCd ?? ""));
} catch {
setEditMode(false);
setSessionUser(null);
}
}, []);
useEffect(() => {
if (!focusDetailAddrOnClose || isPostcodeModalOpen) return;
if (!addr1.trim()) {
setFocusDetailAddrOnClose(false);
return;
}
const timer = setTimeout(() => {
detailAddrRef.current?.focus();
setFocusDetailAddrOnClose(false);
}, 0);
return () => clearTimeout(timer);
}, [addr1, focusDetailAddrOnClose, isPostcodeModalOpen]);
const sidoOptions = useMemo<Option[]>(() => {
const mapped = selectSidoData
.filter((item) => item.val !== "0")
.map((item) => ({ value: item.val, label: item.text }));
return [{ value: "", label: "선택" }, ...mapped];
}, [selectSidoData]);
const sigunguOptions = useMemo<Option[]>(() => {
if (!sido) return [{ value: "", label: "선택" }];
const mapped = selectSigunData
.filter((item) => item.val.substring(0, 2) === sido)
.map((item) => ({ value: item.val, label: item.text }));
return [{ value: "", label: "선택" }, ...mapped];
}, [sido, selectSigunData]);
const formatBusinessNumber = (value: string) => {
const digitsOnly = value.replace(/\D/g, "").slice(0, 10);
if (digitsOnly.length <= 3) return digitsOnly;
if (digitsOnly.length <= 5) {
return `${digitsOnly.slice(0, 3)}-${digitsOnly.slice(3)}`;
}
return `${digitsOnly.slice(0, 3)}-${digitsOnly.slice(3, 5)}-${digitsOnly.slice(5)}`;
};
const checkDuplicate = async (fullDmdInfoId: string) => {
try {
const res = await getFetch(fetchType.WR, `/reuseFc/request/${fullDmdInfoId}`);
const responseData = (res?.data ?? res) as { dmdInfoId?: string } | undefined;
return Boolean(responseData?.dmdInfoId);
} catch {
return false;
}
};
const onSubmit = async () => {
const bizDigits = bizNo.replace(/\D/g, "").slice(0, 10);
const workerDigits = workerNo.replace(/\D/g, "").slice(0, 4);
const telDigits = tel.replace(/\D/g, "");
const password = pw.trim();
const passwordConfirm = pw2.trim();
if (!sido || sido === "0") return alert("시도/시군구를 선택해 주세요.");
if (!sigungu || sigungu === "0") return alert("시도/시군구를 선택해 주세요.");
if (!company.trim()) return alert("회사명을 입력해 주세요.");
if (company.trim().length > 20) return alert("회사명은 20자 이내로 입력해주세요.");
if (!bizDigits) return alert("사업자등록번호를 입력해 주세요.");
if (!/^\d{10}$/.test(bizDigits)) {
return alert("사업자등록번호는 10자리 숫자만 입력 가능합니다.");
}
if (!noWorkerNo) {
if (!workerDigits) return alert("종사업자등록번호를 입력해 주세요.");
if (!/^\d{4}$/.test(workerDigits)) {
return alert("종사업자등록번호는 4자리 숫자만 입력 가능합니다.");
}
}
const mergedAddress = [addr1.trim(), addr2.trim()].filter(Boolean).join(" ");
if (!mergedAddress) return alert("사업자주소를 입력해 주세요.");
if (mergedAddress.length > 40) return alert("주소는 40자 이내로 입력해주세요.");
if (!telDigits) return alert("전화번호를 입력해 주세요.");
if (!/^\d{8,11}$/.test(telDigits)) {
return alert("전화번호는 8~11자리 숫자만 입력 가능합니다.");
}
if (!editMode) {
if (!password) return alert("비밀번호를 입력해 주세요.");
if (password.length < 8 || password.length > 16) {
return alert("비밀번호는 8자 이상 16자 이내로 입력해주세요.");
}
if (!passwordConfirm) return alert("비밀번호 확인을 입력해 주세요.");
if (password !== passwordConfirm) return alert("비밀번호가 일치하지 않습니다.");
} else if ((password || passwordConfirm) && password !== passwordConfirm) {
return alert("비밀번호가 일치하지 않습니다.");
}
const fullDmdInfoId = bizDigits + (noWorkerNo ? "0000" : workerDigits);
if (!editMode) {
const isDuplicate = await checkDuplicate(fullDmdInfoId);
if (isDuplicate) return alert("이미 등록된 사업자등록번호입니다.");
}
const myUUID = getCookie(TOKEN_ID_COOKIE) || FALLBACK_UUID;
const payload: Record<string, unknown> = {
ctpvCd: sido,
sggCd: sigungu,
dmdCoNm: company.trim(),
dmdInfoId: fullDmdInfoId,
bzmnNo: bizDigits,
zip: zip.trim(),
daddr: mergedAddress,
telno: telDigits,
rgtr: (sessionUser?.rgtr as string) || myUUID,
};
if (password) {
payload.dmdAplyPswd = password;
}
try {
if (editMode) {
payload.mdfr = myUUID;
await putFetch("/reuseFc/request", payload);
if (typeof window !== "undefined") {
const nextSessionUser = {
...(sessionUser ?? {}),
...payload,
};
delete nextSessionUser.dmdAplyPswd;
sessionStorage.setItem("user", JSON.stringify(nextSessionUser));
}
alert("수요처 수정 완료!");
} else {
await postFetch("/reuseFc/request", payload);
alert("수요처 등록 완료!");
}
setPw("");
setPw2("");
} catch (error) {
console.error("Failed to submit demand place", error);
alert(editMode ? "수요처 수정에 실패하였습니다." : "수요처 등록에 실패하였습니다.");
}
};
const handleSelectAddress = (item: AddrItem) => {
setZip(item.zipNo);
setAddr1(item.roadAddr);
setFocusDetailAddrOnClose(true);
const matchedSido = selectSidoData.find(
(region) =>
region.val !== "0" &&
isSameRegionName(region.text ?? "", item.ctpvNm ?? ""),
);
if (matchedSido) {
setSido(matchedSido.val);
const matchedSigungu = selectSigunData.find(
(region) =>
region.val !== "0" &&
region.val.startsWith(matchedSido.val) &&
isSameRegionName(region.text ?? "", item.sggNm ?? ""),
);
setSigungu(matchedSigungu?.val ?? "");
}
setIsPostcodeModalOpen(false);
};
const pageTitle = editMode ? "수요처수정" : "수요처등록";
return (
<main className="bg-white">
<SubPageHeader
title={pageTitle}
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "수요신청", href: "/reuse/apply" },
{ label: pageTitle },
]}
/>
<div className="border border-neutral-200 bg-white">
<FormRow label="시도" required>
<SelectBox
value={sido}
onChange={(v) => {
setSido(v);
setSigungu("");
}}
options={sidoOptions}
/>
</FormRow>
<Divider />
<FormRow label="시군구" required>
<select
value={sigungu}
onChange={(e) => setSigungu(e.target.value)}
className="h-11 w-[220px] rounded border border-neutral-300 bg-white px-3 text-sm text-neutral-800 focus:border-neutral-400 focus:outline-none"
>
{sigunguOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</FormRow>
<Divider />
<FormRow label="회사명" required>
<div className="max-w-[420px]">
<TextInput
value={company}
onChange={setCompany}
placeholder="회사명을 입력해주세요."
/>
</div>
</FormRow>
<Divider />
<FormRow label="사업자등록번호" required>
<div className="max-w-[420px]">
<TextInput
value={formatBusinessNumber(bizNo)}
onChange={(v) => setBizNo(v.replace(/\D/g, "").slice(0, 10))}
placeholder="사업자 등록번호를 입력해주세요."
/>
</div>
</FormRow>
<Divider />
<FormRow label="종사자등록번호">
<div className="flex flex-wrap items-center gap-4">
<div className="w-[420px] max-w-full">
<TextInput
value={workerNo}
onChange={(v) => setWorkerNo(v.replace(/\D/g, "").slice(0, 4))}
placeholder="종사자등록번호를 입력해주세요."
disabled={noWorkerNo}
/>
</div>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input
type="checkbox"
checked={noWorkerNo}
onChange={(e) => {
setNoWorkerNo(e.target.checked);
if (e.target.checked) setWorkerNo("");
}}
className="h-4 w-4 rounded border-neutral-300"
/>
해당없음
</label>
</div>
</FormRow>
<Divider />
<FormRow label="사업자주소" required>
<div className="max-w-[420px] space-y-2">
<div className="flex gap-2">
<div className="w-[180px]">
<TextInput value={zip} onChange={setZip} placeholder="우편번호" />
</div>
<button
type="button"
onClick={() => setIsPostcodeModalOpen(true)}
className="h-11 rounded bg-[#888] px-4 text-base text-white hover:bg-neutral-700"
>
우편번호 검색
</button>
</div>
<TextInput value={addr1} onChange={setAddr1} placeholder="주소" />
<TextInput
value={addr2}
onChange={setAddr2}
placeholder="상세 주소를 입력하세요."
inputRef={detailAddrRef}
/>
</div>
</FormRow>
<Divider />
<FormRow label="전화번호" required>
<div className="max-w-[420px]">
<TextInput
value={tel}
onChange={(v) => setTel(formatKoreanPhoneNumber(v))}
placeholder="전화번호를 입력하세요."
/>
</div>
</FormRow>
<Divider />
<FormRow label="비밀번호" required={!editMode}>
<div className="max-w-[420px]">
<TextInput
value={pw}
onChange={setPw}
placeholder="비밀번호를 입력하세요."
type="password"
/>
</div>
</FormRow>
<Divider />
<FormRow label="비밀번호 확인" required={!editMode}>
<div className="max-w-[420px]">
<TextInput
value={pw2}
onChange={setPw2}
placeholder="비밀번호를 한번 더 입력하세요."
type="password"
/>
</div>
</FormRow>
</div>
<div className="flex justify-center py-12">
<button
type="button"
onClick={onSubmit}
className="h-11 rounded-md bg-[#0B4EA2] px-10 text-lg text-white hover:opacity-90"
>
{editMode ? "수요처 수정" : "수요처 등록"}
</button>
</div>
<PostcodeModal
open={isPostcodeModalOpen}
onClose={() => setIsPostcodeModalOpen(false)}
onSelect={handleSelectAddress}
/>
</main>
);
}