342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useId, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { useDmStore } from "@/app/store";
|
||
import type { DemandCompType } from "@common/business_components/ui/sdmInsertForm/form";
|
||
import { sha512 } from 'js-sha512';
|
||
|
||
type LoginModalProps = {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onSuccess?: (payload: { userName: string; accessToken?: string }) => void;
|
||
};
|
||
|
||
type FieldKey = "bizNo" | "workerNo" | "pw";
|
||
|
||
const REGISTER_HREF = "/apply/dmdco";
|
||
|
||
function isBizNoValid(v: string) {
|
||
return /^\d{10}$/.test(v);
|
||
}
|
||
|
||
function inputCls(opts: { hasError?: boolean; disabled?: boolean }) {
|
||
const base = "h-[48px] w-full rounded-[4px] border px-5 text-[18px] outline-none";
|
||
if (opts.disabled) return `${base} border-gray-200 bg-gray-50 text-gray-400`;
|
||
if (opts.hasError) return `${base} border-red-500 ring-1 ring-red-400 focus:border-red-500`;
|
||
return `${base} border-gray-300 focus:border-sky-400`;
|
||
}
|
||
|
||
export default function LoginModal({ open, onClose, onSuccess }: LoginModalProps) {
|
||
const titleId = useId();
|
||
const router = useRouter();
|
||
const { actionLogin } = useDmStore((state) => state);
|
||
|
||
const [bizNo, setBizNo] = useState(""); // 사업자번호
|
||
const [workerNo, setWorkerNo] = useState(""); // 종사업자번호
|
||
const [noWorker, setNoWorker] = useState(true); // 종사업자번호 해당없음 checked
|
||
const [pw, setPw] = useState("");
|
||
|
||
// ✅ 유효성/서버 에러 표시용
|
||
const [errors, setErrors] = useState<Partial<Record<FieldKey, string>>>({});
|
||
const [touched, setTouched] = useState<Partial<Record<FieldKey, boolean>>>({});
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const onKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") onClose(); // ESC 키로 모달 닫기
|
||
};
|
||
window.addEventListener("keydown", onKeyDown);
|
||
|
||
// 스크롤 잠금
|
||
const prev = document.body.style.overflow;
|
||
document.body.style.overflow = "hidden"; // 모달 오픈 시 배경 스크롤 방지
|
||
|
||
// 모달 오픈 시 에러/터치 초기화
|
||
setErrors({});
|
||
setTouched({});
|
||
|
||
return () => {
|
||
window.removeEventListener("keydown", onKeyDown);
|
||
document.body.style.overflow = prev;
|
||
};
|
||
}, [open, onClose]);
|
||
|
||
useEffect(() => {
|
||
if (noWorker) {
|
||
setWorkerNo("");
|
||
setErrors((p) => {
|
||
const nextErrors = { ...p };
|
||
delete nextErrors.workerNo;
|
||
return nextErrors;
|
||
});
|
||
}
|
||
}, [noWorker]);
|
||
|
||
if (!open) return null;
|
||
|
||
const validate = () => {
|
||
const next: Partial<Record<FieldKey, string>> = {};
|
||
|
||
const b = bizNo.trim();
|
||
const w = workerNo.trim();
|
||
const p = pw.trim();
|
||
|
||
if (!b) next.bizNo = "사업자등록번호를 입력해주세요.";
|
||
else if (!isBizNoValid(b))
|
||
next.bizNo = "사업자등록번호는 10자리 숫자만 입력 가능합니다.";
|
||
|
||
if (!noWorker && !w) next.workerNo = "종사업자등록번호를 입력해주세요.";
|
||
else if (!noWorker && !/^\d{4}$/.test(w))
|
||
next.workerNo = "종사업자등록번호는 4자리 숫자만 입력 가능합니다.";
|
||
|
||
if (!p) next.pw = "비밀번호를 입력해주세요.";
|
||
else if (p.length < 8) next.pw = "비밀번호는 8자 이상 입력해주세요.";
|
||
else if (p.length > 16) next.pw = "비밀번호는 16자 이내로 입력해주세요.";
|
||
|
||
setErrors(next);
|
||
return next;
|
||
};
|
||
|
||
const formatBusinessNumber = (value: string) => {
|
||
// 숫자만 추출
|
||
const digitsOnly = value.replace(/\D/g, "");
|
||
|
||
// 10자리까지만 처리
|
||
const limitedDigits = digitsOnly.slice(0, 10);
|
||
|
||
// 3-2-5 형식으로 포맷팅
|
||
if (limitedDigits.length <= 3) {
|
||
return limitedDigits;
|
||
} else if (limitedDigits.length <= 5) {
|
||
return `${limitedDigits.slice(0, 3)}-${limitedDigits.slice(3)}`;
|
||
} else {
|
||
return `${limitedDigits.slice(0, 3)}-${limitedDigits.slice(3, 5)}-${limitedDigits.slice(5)}`;
|
||
}
|
||
};
|
||
|
||
const markTouched = (key: FieldKey) =>
|
||
setTouched((p) => ({ ...p, [key]: true }));
|
||
|
||
const markAllTouched = () =>
|
||
setTouched({ bizNo: true, workerNo: true, pw: true });
|
||
|
||
const submit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
markAllTouched();
|
||
|
||
const v = validate();
|
||
if (Object.keys(v).length) return;
|
||
|
||
try {
|
||
setSubmitting(true);
|
||
const param = {
|
||
dmdInfoId: bizNo + (noWorker ? "0000" : workerNo), // 종사업자번호 해당없음 "0000"으로 채움
|
||
dmdAplyPswd: sha512.hex(pw),
|
||
};
|
||
await actionLogin(param as DemandCompType);
|
||
|
||
const sessionUser = sessionStorage.getItem("user");
|
||
if (sessionUser) {
|
||
const parsed = JSON.parse(sessionUser) as { dmdCoNm?: string };
|
||
onSuccess?.({ userName: parsed.dmdCoNm ?? "수요처" });
|
||
onClose();
|
||
router.refresh();
|
||
}
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const initPassword = () => {
|
||
alert("관리자(031-0000-0000)에게 비밀번호 초기화 요청을 유선으로 하시기 바랍니다.");
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-[100]">
|
||
{/* Backdrop */}
|
||
<button
|
||
type="button"
|
||
aria-label="닫기"
|
||
onClick={onClose}
|
||
className="absolute inset-0 bg-black/40"
|
||
/>
|
||
|
||
{/* Dialog */}
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby={titleId}
|
||
className="w-full max-w-[600px] rounded-[16px] bg-white shadow-2xl overflow-hidden"
|
||
>
|
||
{/* Header */}
|
||
<div className="relative px-10 pt-10">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
aria-label="닫기"
|
||
className="absolute right-8 top-8 z-40 h-10 w-10 grid place-items-center rounded-full hover:bg-gray-100"
|
||
>
|
||
<span className="text-2xl leading-none">×</span>
|
||
</button>
|
||
|
||
<h2
|
||
id={titleId}
|
||
className="relative z-10 px-2 text-center text-[32px] font-bold tracking-tight leading-none text-gray-900"
|
||
>
|
||
수요처 로그인
|
||
</h2>
|
||
|
||
{/* title underline gradient bar */}
|
||
<div className="relative top-[-7px] -z-1 mx-auto h-[10px] w-[240px] bg-gradient-to-r from-sky-300 to-green-300" />
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<form onSubmit={submit} className="px-14 pb-12 pt-10">
|
||
<div className="space-y-7">
|
||
{/* 사업자 등록번호 */}
|
||
<div>
|
||
<label className="block text-[18px] font-bold text-gray-900 mb-3">
|
||
사업자 등록번호
|
||
</label>
|
||
<input
|
||
value={formatBusinessNumber(bizNo)}
|
||
onChange={(e) => {
|
||
setBizNo(e.target.value.replace(/\D/g, "").slice(0, 10));
|
||
setErrors((p) => ({ ...p, bizNo: undefined }));
|
||
}}
|
||
onBlur={() => {
|
||
markTouched("bizNo");
|
||
validate();
|
||
}}
|
||
placeholder="사업자등록번호를 입력하세요. (예: 123-45-67890)"
|
||
className={inputCls({ hasError: !!(touched.bizNo && errors.bizNo) })}
|
||
/>
|
||
{touched.bizNo && errors.bizNo && (
|
||
<p className="mt-3 text-[14px] font-medium text-red-500">
|
||
{errors.bizNo}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 종사자등록번호 + 해당없음 */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<label className="block text-[18px] font-bold text-gray-900">
|
||
종사업자 등록번호
|
||
</label>
|
||
|
||
<label className="flex items-center gap-1 text-[16px] text-[#4F4F4F] select-none">
|
||
<input
|
||
type="checkbox"
|
||
checked={noWorker}
|
||
onChange={(e) => setNoWorker(e.target.checked)}
|
||
className="h-4 w-4 accent-[#004A94]"
|
||
/>
|
||
해당없음
|
||
</label>
|
||
</div>
|
||
|
||
<input
|
||
value={workerNo}
|
||
onChange={(e) => {
|
||
setWorkerNo(e.target.value.replace(/\D/g, "").slice(0, 4));
|
||
setErrors((p) => ({ ...p, workerNo: undefined }));
|
||
}}
|
||
onBlur={() => {
|
||
markTouched("workerNo");
|
||
validate();
|
||
}}
|
||
disabled={noWorker}
|
||
placeholder="종사자등록번호를 입력하세요."
|
||
className={inputCls({
|
||
disabled: noWorker,
|
||
hasError: !noWorker && !!(touched.workerNo && errors.workerNo),
|
||
})}
|
||
/>
|
||
|
||
{!noWorker && touched.workerNo && errors.workerNo && (
|
||
<p className="mt-3 text-[14px] font-medium text-red-500">
|
||
{errors.workerNo}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 비밀번호 */}
|
||
<div>
|
||
<label className="block text-[18px] font-bold text-gray-900 mb-3">
|
||
비밀번호
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={pw}
|
||
onChange={(e) => {
|
||
setPw(e.target.value);
|
||
setErrors((p) => ({ ...p, pw: undefined }));
|
||
}}
|
||
onBlur={() => {
|
||
markTouched("pw");
|
||
validate();
|
||
}}
|
||
placeholder="비밀번호를 입력하세요."
|
||
className={inputCls({ hasError: !!(touched.pw && errors.pw) })}
|
||
/>
|
||
{touched.pw && errors.pw && (
|
||
<p className="mt-3 text-[14px] font-medium text-red-500">
|
||
{errors.pw}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 로그인 버튼 */}
|
||
<button
|
||
type="submit"
|
||
disabled={submitting}
|
||
className={[
|
||
"mt-2 w-full h-[60px] rounded-[4px] text-white text-[18px]",
|
||
submitting
|
||
? "bg-sky-300 cursor-not-allowed"
|
||
: "bg-sky-500 hover:bg-sky-600 active:bg-sky-700",
|
||
].join(" ")}
|
||
>
|
||
{submitting ? "로그인 중..." : "로그인"}
|
||
</button>
|
||
|
||
{/* 비밀번호 초기화 요청 */}
|
||
<button
|
||
type="button"
|
||
onClick={initPassword}
|
||
className="mx-auto block text-[14px] text-[#767676] hover:text-gray-900"
|
||
>
|
||
비밀번호 초기화 요청
|
||
</button>
|
||
|
||
{/* 하단 안내 박스 + 수요처등록 버튼 */}
|
||
<div className="mt-6 border border-[#004A94] bg-[#EEF3F9] px-4 py-4 flex items-center justify-between gap-6">
|
||
<div className="leading-snug">
|
||
<div className="text-[16px] text-[#8698AA]">
|
||
수요처를 등록하지 않았다면
|
||
</div>
|
||
<div className="text-[18px] text-[#004A94]">
|
||
먼저 수요처등록을 해주세요.
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => router.push(REGISTER_HREF)}
|
||
className="shrink-0 rounded-[4px] h-[48px] px-10 bg-[#004A94] text-white text-[18px] hover:opacity-95"
|
||
>
|
||
수요처등록
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|