대민서비스 물재이용 랜딩 페이지 (수요신청 페이지 추가)
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export type LnbItem = {
|
||||
label: string;
|
||||
href?: string; // ✅ href 없는 부모(2뎁스 컨테이너)도 허용
|
||||
children?: LnbItem[];
|
||||
};
|
||||
|
||||
function isExactActive(pathname: string, href?: string) {
|
||||
if (!href) return false;
|
||||
return pathname === href; // ✅ exact match만 허용 (중요!)
|
||||
}
|
||||
|
||||
export default function Lnb({ title, items }: { title: string; items: LnbItem[] }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="w-[260px]">
|
||||
{/* 타이틀 */}
|
||||
<div
|
||||
className="h-[120px] flex items-center justify-center
|
||||
text-white text-[40px] font-bold
|
||||
bg-gradient-to-r from-cyan-500 to-green-500"
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* 메뉴 */}
|
||||
<ul>
|
||||
{items.map((item) => {
|
||||
const hasChildren = !!item.children?.length;
|
||||
|
||||
// ✅ 1뎁스는 exact match 기준 active
|
||||
const active1 = isExactActive(pathname, item.href);
|
||||
|
||||
// ✅ 2뎁스가 있으면(혹시라도) 자식 exact match도 포함
|
||||
const active2 =
|
||||
item.children?.some((c) => isExactActive(pathname, c.href)) ?? false;
|
||||
|
||||
const active = active1 || active2;
|
||||
|
||||
// ✅ href 없는 부모(2뎁스 컨테이너)
|
||||
if (!item.href && hasChildren) {
|
||||
return (
|
||||
<li key={item.label}>
|
||||
<div
|
||||
className={[
|
||||
"block px-5 py-6 text-center text-base font-semibold",
|
||||
"border-b border-slate-300",
|
||||
active ? "bg-white text-sky-500" : "bg-slate-100 text-slate-500",
|
||||
].join(" ")}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
{item.children!.map((child) => {
|
||||
const childActive = isExactActive(pathname, child.href);
|
||||
|
||||
return (
|
||||
<li key={child.href ?? child.label}>
|
||||
{child.href ? (
|
||||
<Link
|
||||
href={child.href}
|
||||
className={[
|
||||
"block px-5 py-4 text-center text-[18px] font-medium",
|
||||
"border-b border-slate-200 transition-colors",
|
||||
childActive
|
||||
? "bg-white text-sky-500 cursor-default" // ✅ active 고정
|
||||
: "bg-slate-50 text-slate-500 hover:bg-white hover:text-sky-500", // ✅ hover=active
|
||||
].join(" ")}
|
||||
>
|
||||
{child.label}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="block px-5 py-4 text-center text-[18px] font-medium border-b border-slate-200 bg-slate-50 text-slate-500">
|
||||
{child.label}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ 일반 1뎁스 메뉴
|
||||
return (
|
||||
<li key={item.href ?? item.label}>
|
||||
{item.href ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={[
|
||||
"block px-5 py-4 text-center text-base font-semibold",
|
||||
"border-b border-slate-300 transition-colors",
|
||||
active
|
||||
? "bg-white text-sky-500 cursor-default" // ✅ active 고정 (hover 영향 없음)
|
||||
: "bg-slate-100 text-slate-500 hover:bg-white hover:text-sky-500", // ✅ hover=active
|
||||
].join(" ")}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
className={[
|
||||
"block px-5 py-4 text-center text-base font-semibold",
|
||||
"border-b border-slate-300",
|
||||
"bg-slate-100 text-slate-500",
|
||||
].join(" ")}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// apps/dm/app/(landing)/reuse/_components/LoginStatus.tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function LoginStatus() {
|
||||
const router = useRouter();
|
||||
const [userName, setUserName] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 예시: 로그인 시 저장한 값
|
||||
const name = localStorage.getItem("userName");
|
||||
setUserName(name);
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("userName");
|
||||
router.refresh(); // 헤더 상태 갱신
|
||||
};
|
||||
|
||||
// 🔹 로그인 전
|
||||
if (!userName) {
|
||||
return (
|
||||
<Link
|
||||
href="/reuse/login"
|
||||
className="flex items-center gap-2 rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white hover:bg-sky-600"
|
||||
>
|
||||
로그인
|
||||
<span>→</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// 🔹 로그인 후
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-gray-700">
|
||||
<strong>{userName}</strong>님 환영합니다.
|
||||
</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded-md bg-red-500 px-3 py-2 text-white hover:bg-red-600"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import styles from "../reuse.module.css";
|
||||
|
||||
export interface SideIndicatorItem {
|
||||
id: string;
|
||||
label: string; // 상단/공용 라벨
|
||||
sideLabel?: string; // 사이드 인디게이터 전용 라벨(옵션)
|
||||
}
|
||||
|
||||
export default function SideIndicator({
|
||||
items,
|
||||
activeId,
|
||||
}: {
|
||||
items: SideIndicatorItem[];
|
||||
activeId: string;
|
||||
}) {
|
||||
const [manualActiveId, setManualActiveId] = useState<string | null>(null);
|
||||
const clearTimerRef = useRef<number | null>(null);
|
||||
|
||||
const currentId = (manualActiveId ?? activeId ?? "main") || "main";
|
||||
|
||||
// ✅ manualActiveId가 observer(activeId)와 불일치하기 시작하면 즉시 해제 (stuck 방지)
|
||||
useEffect(() => {
|
||||
if (!manualActiveId) return;
|
||||
if (activeId && activeId !== manualActiveId) setManualActiveId(null);
|
||||
}, [activeId, manualActiveId]);
|
||||
|
||||
// ✅ fallback: 스무스 스크롤 시간이 길어도 manual이 오래 남지 않게 타임아웃 해제
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clearTimerRef.current) window.clearTimeout(clearTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scrollTo = (id: string) => {
|
||||
setManualActiveId(id);
|
||||
|
||||
// 기존 타이머 제거 후 새로 설정
|
||||
if (clearTimerRef.current) window.clearTimeout(clearTimerRef.current);
|
||||
clearTimerRef.current = window.setTimeout(() => {
|
||||
setManualActiveId(null);
|
||||
}, 1600);
|
||||
|
||||
if (id === "main") {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById(id)?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={styles.sideIndicator} aria-label="섹션 이동">
|
||||
<div className={styles.sideIndicatorLine} aria-hidden="true" />
|
||||
|
||||
<ul className={styles.sideIndicatorList}>
|
||||
{items.map((it) => {
|
||||
const isActive = it.id === currentId;
|
||||
const isMain = it.id === "main";
|
||||
const displayLabel = it.sideLabel ?? it.label;
|
||||
|
||||
return (
|
||||
<li key={it.id} className={styles.sideIndicatorItem}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollTo(it.id)}
|
||||
className={[
|
||||
styles.indicatorBtn,
|
||||
isActive ? styles.indicatorActive : styles.indicatorDot,
|
||||
isActive
|
||||
? isMain
|
||||
? styles.indicatorMainActive
|
||||
: styles.indicatorSectionActive
|
||||
: "",
|
||||
].join(" ")}
|
||||
aria-current={isActive ? "location" : undefined}
|
||||
aria-label={displayLabel}
|
||||
>
|
||||
{/* ✅ 텍스트 중복 렌더링 제거:
|
||||
- active일 때만 시각적 텍스트 표시
|
||||
- 비활성은 SR-only로만 제공 (CSS에 따라 필요 없으면 제거 가능) */}
|
||||
{isActive ? (
|
||||
<span className={styles.indicatorText}>{displayLabel}</span>
|
||||
) : (
|
||||
<span className="sr-only">{displayLabel}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
export type Crumb = { label: string; href?: string };
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
crumbs: Crumb[];
|
||||
note?: string;
|
||||
underlineWidthClass?: string;
|
||||
className?: string;
|
||||
hideFooterLine?: boolean;
|
||||
hideGuideText?: boolean;
|
||||
};
|
||||
|
||||
export default function SubPageHeader({
|
||||
title,
|
||||
crumbs,
|
||||
note = "* 는 필수 값입니다.",
|
||||
underlineWidthClass = "w-40",
|
||||
className = "",
|
||||
hideFooterLine = false,
|
||||
hideGuideText = false,
|
||||
}: Props) {
|
||||
return (
|
||||
<section className={`text-black ${className}`}>
|
||||
<div className="pb-5">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
{/* 좌측 타이틀 */}
|
||||
<div className="inline-block">
|
||||
<h1 className="text-[28px] font-extrabold tracking-tight text-slate-900 leading-none">{title}</h1>
|
||||
<div className="h-2 bg-gradient-to-r from-sky-300 to-green-300" />
|
||||
</div>
|
||||
|
||||
{/* 우측 브레드크럼 */}
|
||||
<nav className="pt-2 text-sm text-black/70" aria-label="breadcrumb">
|
||||
{crumbs.map((c, i) => (
|
||||
<span key={`${c.label}-${i}`}>
|
||||
{c.href ? (
|
||||
<Link href={c.href} className="hover:text-white">
|
||||
{c.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-black/90">{c.label}</span>
|
||||
)}
|
||||
{i < crumbs.length - 1 && <span className="mx-2">›</span>}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
{/* 하단 라인 + 안내문 */}
|
||||
{!hideGuideText && (
|
||||
<div className="mt-6 border-t border-black/10 pt-4 text-sm text-black/70">
|
||||
<span className="text-red-400">*</span>
|
||||
{note.replace("*", "")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
// apps/dm/app/(landing)/reuse/_components/TopNav.tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/reuse/intro", label: "재이용소개" },
|
||||
{ href: "/reuse/cases", label: "재이용사례" },
|
||||
{ href: "/reuse/business", label: "물재이용사업" },
|
||||
{ href: "/reuse/apply/place", label: "수요신청" },
|
||||
];
|
||||
|
||||
// ✅ 필요 시 실제 “수요처등록” 페이지로 경로만 교체하세요.
|
||||
const REGISTER_HREF = "/reuse/apply/place";
|
||||
|
||||
// ✅ 로그인 정보 저장 키(원하는대로 변경 가능)
|
||||
const LS_TOKEN_KEY = "reuse_accessToken";
|
||||
const LS_USER_KEY = "reuse_userName";
|
||||
|
||||
type LoginModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: (payload: { userName: string; accessToken?: string }) => void;
|
||||
};
|
||||
|
||||
type FieldKey = "bizNo" | "workerNo" | "pw";
|
||||
|
||||
function isBizNoValid(v: string) {
|
||||
// 예: 123-45-67890
|
||||
return /^\d{3}-\d{2}-\d{5}$/.test(v);
|
||||
}
|
||||
|
||||
function inputCls(opts: { hasError?: boolean; disabled?: boolean }) {
|
||||
const base =
|
||||
"w-full h-[48px] 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 focus:border-red-500 ring-1 ring-red-400`;
|
||||
return `${base} border-gray-300 focus:border-sky-400`;
|
||||
}
|
||||
|
||||
function DemandLoginModal({ open, onClose, onSuccess }: LoginModalProps) {
|
||||
const titleId = useId();
|
||||
const router = useRouter();
|
||||
|
||||
const [bizNo, setBizNo] = useState("");
|
||||
const [workerNo, setWorkerNo] = useState("");
|
||||
const [noWorker, setNoWorker] = useState(false);
|
||||
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();
|
||||
};
|
||||
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 { workerNo: _w, ...rest } = p;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
}, [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 = "사업자등록번호 형식이 올바르지 않습니다. (예: 123-45-67890)";
|
||||
|
||||
if (!noWorker && !w) next.workerNo = "종사자등록번호를 입력해주세요.";
|
||||
|
||||
if (!p) next.pw = "비밀번호를 입력해주세요.";
|
||||
|
||||
setErrors(next);
|
||||
return next;
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// ✅ TODO: 실제 로그인 API 연결 지점
|
||||
// const res = await fetch("/api/demand/login", {
|
||||
// method: "POST",
|
||||
// headers: { "Content-Type": "application/json" },
|
||||
// body: JSON.stringify({ bizNo, workerNo: noWorker ? null : workerNo, pw }),
|
||||
// });
|
||||
// if (!res.ok) { ... }
|
||||
// const data = await res.json();
|
||||
// onSuccess?.({ userName: data.userName, accessToken: data.token });
|
||||
|
||||
// --- 데모: 비밀번호가 "1234"면 성공 처리(원하면 제거) ---
|
||||
const serverOk = pw.trim() === "1234";
|
||||
if (!serverOk) {
|
||||
setErrors((p) => ({ ...p, pw: "잘못된 비밀번호입니다. (데모: 1234 입력 시 성공)" }));
|
||||
setTouched((p) => ({ ...p, pw: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
onSuccess?.({ userName: "마인드원", accessToken: "demo_token" });
|
||||
onClose();
|
||||
router.refresh();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 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="text-center text-[32px] font-bold tracking-tight leading-none text-gray-900"
|
||||
>
|
||||
수요처 로그인
|
||||
</h2>
|
||||
|
||||
{/* title underline gradient bar */}
|
||||
<div className="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={bizNo}
|
||||
onChange={(e) => {
|
||||
setBizNo(e.target.value);
|
||||
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);
|
||||
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={() => alert("TODO: 비밀번호 초기화 요청 화면/기능 연결")}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TopNav() {
|
||||
const router = useRouter();
|
||||
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [userName, setUserName] = useState<string | null>(null);
|
||||
|
||||
// ✅ 초기 로드 시 로그인 상태 반영
|
||||
useEffect(() => {
|
||||
const name = localStorage.getItem(LS_USER_KEY);
|
||||
setUserName(name);
|
||||
}, []);
|
||||
|
||||
const openLogin = () => setLoginOpen(true);
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem(LS_TOKEN_KEY);
|
||||
localStorage.removeItem(LS_USER_KEY);
|
||||
setUserName(null);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="fixed top-0 left-0 right-0 z-50 bg-white backdrop-blur border-b shadow-md">
|
||||
{/* ✅ 좌/중/우 정렬이 되도록 컨테이너 구성 */}
|
||||
<div className="px-6">
|
||||
<div className="relative h-[83px] flex items-center justify-between">
|
||||
{/* 좌측: 로고 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/reuse" className="flex items-center">
|
||||
<img
|
||||
src="/images/landing/logo.png"
|
||||
alt="HASUON"
|
||||
className="h-9 w-auto"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 중앙: 상단 메뉴 */}
|
||||
<nav className="absolute left-1/2 -translate-x-1/2 flex items-center gap-[40px] text-sm font-medium">
|
||||
{NAV_ITEMS.map((it) => {
|
||||
// ✅ 수요신청: 로그인 전이면 로그인 모달, 로그인 후면 페이지 이동
|
||||
if (it.label === "수요신청") {
|
||||
return (
|
||||
<button
|
||||
key={it.href}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!userName) openLogin();
|
||||
else router.push(it.href);
|
||||
}}
|
||||
className={[
|
||||
"relative px-3 py-2 text-lg font-bold",
|
||||
"text-gray-800 hover:text-sky-700",
|
||||
].join(" ")}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={it.href}
|
||||
href={it.href}
|
||||
className={[
|
||||
"relative px-3 py-2 text-lg font-bold",
|
||||
"text-gray-800 hover:text-sky-700",
|
||||
].join(" ")}
|
||||
>
|
||||
{it.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* ✅ 우측: 로그인 버튼 / 로그인 정보 + 로그아웃 */}
|
||||
<div className="flex items-center gap-3">
|
||||
{!userName ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openLogin}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-sky-500 px-4 py-2 text-base text-white hover:bg-sky-600"
|
||||
>
|
||||
로그인
|
||||
<span aria-hidden>
|
||||
<img src="/images/landing/icon_login.png" alt="로그인 아이콘" className="h-6 w-6 object-contain ml-1"/>
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-base text-gray-700">
|
||||
<strong className="text-xl text-emerald-600">{userName}</strong>님 환영합니다.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-red-500 px-4 py-2 text-base text-white hover:bg-red-600"
|
||||
>
|
||||
로그아웃
|
||||
<span aria-hidden>
|
||||
<img src="/images/landing/icon_logout.png" alt="로그아웃 아이콘" className="h-6 w-6 object-contain"/>
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ✅ 로그인 모달 */}
|
||||
<DemandLoginModal
|
||||
open={loginOpen}
|
||||
onClose={() => setLoginOpen(false)}
|
||||
onSuccess={({ userName, accessToken }) => {
|
||||
// ✅ 로그인 성공 처리: 저장 + 헤더 갱신
|
||||
localStorage.setItem(LS_USER_KEY, userName);
|
||||
if (accessToken) localStorage.setItem(LS_TOKEN_KEY, accessToken);
|
||||
setUserName(userName);
|
||||
setLoginOpen(false);
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// apps/dm/app/(landing)/reuse/_constants/lnb.ts
|
||||
import type { LnbItem } from "../_components/Lnb";
|
||||
|
||||
export const LNB_DEMAND: LnbItem[] = [
|
||||
{ href: "/reuse/apply/place", label: "수요처등록" },
|
||||
{ href: "/reuse/apply/register", label: "수요정보등록" },
|
||||
{ href: "/reuse/apply/status", label: "수요신청현황" },
|
||||
];
|
||||
|
||||
// (예시) 재이용사례처럼 2depth가 있는 메뉴
|
||||
export const LNB_CASES: LnbItem[] = [
|
||||
{
|
||||
label: "빗물 재이용 사례",
|
||||
children: [
|
||||
{ label: "경기도 파주시", href: "/reuse/cases/rain/paju" },
|
||||
{ label: "경기도 수원시", href: "/reuse/cases/rain/suwon" },
|
||||
{ label: "충청남도 보령시", href: "/reuse/cases/rain/boryeong" },
|
||||
{ label: "충청북도 청주시", href: "/reuse/cases/rain/cheongju" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "중수 재이용 사례",
|
||||
children: [{ label: "사례 목록", href: "/reuse/cases/graywater" }],
|
||||
},
|
||||
{
|
||||
label: "하수 재이용 사례",
|
||||
children: [{ label: "사례 목록", href: "/reuse/cases/sewage" }],
|
||||
},
|
||||
{
|
||||
label: "해외 재이용 사례",
|
||||
children: [{ label: "사례 목록", href: "/reuse/cases/global" }],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function useScrollSpy(sectionIds: string[], options?: IntersectionObserverInit) {
|
||||
const [activeId, setActiveId] = useState(sectionIds[0] ?? "");
|
||||
const ratiosRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const els = sectionIds
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter(Boolean) as HTMLElement[];
|
||||
|
||||
if (!els.length) return;
|
||||
|
||||
const DEFAULT_ROOT_MARGIN = "83px 0px -65% 0px";
|
||||
|
||||
// 초기값 세팅(안정화)
|
||||
ratiosRef.current.clear();
|
||||
els.forEach((el) => ratiosRef.current.set(el.id, 0));
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
const id = (e.target as HTMLElement).id;
|
||||
ratiosRef.current.set(id, e.isIntersecting ? e.intersectionRatio : 0);
|
||||
}
|
||||
|
||||
// ✅ 전체 섹션 중 가장 ratio 큰 항목을 active로
|
||||
let bestId = activeId;
|
||||
let bestRatio = -1;
|
||||
|
||||
for (const [id, ratio] of ratiosRef.current.entries()) {
|
||||
if (ratio > bestRatio) {
|
||||
bestRatio = ratio;
|
||||
bestId = id;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestId) setActiveId(bestId);
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
threshold: [0, 0.01, 0.1, 0.25, 0.5, 0.75],
|
||||
rootMargin: DEFAULT_ROOT_MARGIN,
|
||||
...options,
|
||||
}
|
||||
);
|
||||
|
||||
els.forEach((el) => io.observe(el));
|
||||
return () => io.disconnect();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sectionIds.join("|"), options?.rootMargin, JSON.stringify(options?.threshold)]);
|
||||
|
||||
return activeId;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// apps/dm/app/(landing)/reuse/_sections/Cases.tsx
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type Tab = {
|
||||
key: string;
|
||||
label: string;
|
||||
img: string;
|
||||
};
|
||||
|
||||
const tabs: readonly [Tab, ...Tab[]] = [
|
||||
{ key: "a", label: "경기권역 반도체 사업장 물 재이용", img: "/landing/reuse/case_diagram_01.png" },
|
||||
{ key: "b", label: "경기도 파주시 하수재이용", img: "/landing/reuse/case_diagram_01.png" },
|
||||
{ key: "c", label: "충남 보령 하수재이용", img: "/landing/reuse/case_diagram_01.png" },
|
||||
{ key: "d", label: "수원시 빗물이용과 중수도", img: "/landing/reuse/case_diagram_01.png" },
|
||||
];
|
||||
|
||||
export default function Cases() {
|
||||
const [active, setActive] = useState<string>(() => tabs[0]?.key ?? "");
|
||||
const current = tabs.find((t) => t.key === active) ?? tabs[0];
|
||||
|
||||
if (!current) {
|
||||
return null; // 또는 로딩/빈상태 UI
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="cases" className="bg-white">
|
||||
<div className="mx-auto max-w-[1520px] px-6 py-[90px] h-[800px]">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold">재이용사례</h2>
|
||||
<div className="mx-auto mt-2 h-[4px] w-[90px] bg-sky-600" />
|
||||
</div>
|
||||
|
||||
<div className="mt-10 border-b">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tabs.map((t) => {
|
||||
const on = t.key === active;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setActive(t.key)}
|
||||
className={[
|
||||
"px-4 py-2 text-xs font-semibold rounded-t-lg border",
|
||||
on ? "bg-white border-b-white text-sky-700" : "bg-gray-50 text-gray-700 hover:text-sky-700",
|
||||
].join(" ")}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 lg:grid-cols-[360px_1fr] gap-6 items-start">
|
||||
<aside className="rounded-2xl border bg-gray-50 p-5">
|
||||
<h3 className="font-bold text-sm">사업개요</h3>
|
||||
<ul className="mt-3 text-xs text-gray-600 leading-6 list-disc pl-4">
|
||||
<li>대상/규모/공급구조 등 요약</li>
|
||||
<li>공정/기간/운영 방식</li>
|
||||
<li>정량 효과(예: m³/일, 비용)</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mt-6 font-bold text-sm">사업 추진 및 목적</h3>
|
||||
<p className="mt-2 text-xs text-gray-600 leading-6">
|
||||
탭별 설명 문구를 여기에 넣습니다.
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
<div className="rounded-2xl border bg-white p-4">
|
||||
<img src={current.img} alt="" className="w-full h-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// apps/dm/app/(landing)/reuse/_sections/Hero.tsx
|
||||
import styles from "../reuse.module.css";
|
||||
|
||||
export default function Hero() {
|
||||
return (
|
||||
<section id="main" className={styles.hero}>
|
||||
<div className={styles.heroBg} aria-hidden />
|
||||
<div className={styles.heroOverlay} aria-hidden />
|
||||
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-items-center gap-[40px] mx-auto max-w-[1520px] px-6 text-center text-white">
|
||||
<h1 className="text-5xl font-bold tracking-tight">물재이용</h1>
|
||||
<p className="text-xl font-medium leading-6 text-white/85 max-w-[720px] mx-auto">
|
||||
빗물, 오수, 하수처리수, 폐수처리수, 발전소 온배수를 재이용처리하여<br />
|
||||
생활, 공업, 농업, 조경, 하천유지 등의 용도로 재이용합니다.
|
||||
</p>
|
||||
<p className="text-lg leading-6 text-white/85 max-w-[720px] mx-auto">
|
||||
전국 하수 재이용률 15.6%(2024년 통계 기준),<br />
|
||||
물재이용 활성화를 위한 재이용 수요신청, 매칭을 추진하고 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-20 flex flex-col items-center justify-items-center gap-2 mt-10">
|
||||
<img src="/images/landing/scroll_down.svg" alt="" className="w-[26px] h-[40px]" />
|
||||
<span className="text-white/80 text-base tracking-[.25em]">scroll down</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
|
||||
// apps/dm/app/(landing)/reuse/_sections/IntroCards.tsx
|
||||
const cards = [
|
||||
{ title: "친환경 수자원 확보", img: "/landing/reuse/card_01.jpg" },
|
||||
{ title: "알뜰하게 재이용하는 중수", img: "/landing/reuse/card_02.jpg" },
|
||||
{ title: "하수처리수 재이용", img: "/landing/reuse/card_03.jpg" },
|
||||
{ title: "2030년 물 재이용 목표", img: "/landing/reuse/card_04.jpg" },
|
||||
];
|
||||
|
||||
export default function IntroCards() {
|
||||
return (
|
||||
<section id="intro" className="bg-white">
|
||||
<div className="mx-auto max-w-[1520px] px-6 py-[90px] h-[800px]">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold">재이용소개</h2>
|
||||
<div className="mx-auto mt-2 h-[4px] w-[90px] bg-sky-600" />
|
||||
<p className="mt-3 text-sm text-gray-500">
|
||||
지속 가능한 미래를 위한 약속, 깨끗한 물의 새로운 순환을 시작합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{cards.map((c) => (
|
||||
<article
|
||||
key={c.title}
|
||||
className="group relative overflow-hidden rounded-2xl shadow-sm border bg-gray-50 min-h-[220px]"
|
||||
>
|
||||
<img
|
||||
src={c.img}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-90 group-hover:scale-[1.03] transition"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent" />
|
||||
<div className="relative p-5 h-full flex flex-col justify-end">
|
||||
<h3 className="text-white font-semibold text-sm">{c.title}</h3>
|
||||
|
||||
{/* 예: 첫 카드에만 다운로드 버튼 */}
|
||||
{c.title === "친환경 수자원 확보" && (
|
||||
<button className="mt-3 inline-flex items-center justify-center rounded-lg bg-orange-500 px-4 py-2 text-xs font-semibold text-white w-fit">
|
||||
리플렛 다운로드
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// apps/dm/app/(landing)/reuse/_sections/ProcessFlow.tsx
|
||||
export default function ProcessFlow() {
|
||||
return (
|
||||
<section id="processflow" className="bg-[#f7fbff]">
|
||||
<div className="mx-auto max-w-[1520px] px-6 py-[90px]">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold">물재이용 사업 추진 절차</h2>
|
||||
<div className="mx-auto mt-2 h-[4px] w-[120px] bg-sky-600" />
|
||||
</div>
|
||||
|
||||
<div className="mt-10 rounded-2xl border bg-white p-6 h-[760px]">
|
||||
<img
|
||||
src="/landing/reuse/process_flow.png"
|
||||
alt="물재이용 사업 추진 절차"
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// 예) apps/dm/app/(landing)/reuse/apply/layout.tsx
|
||||
import type { ReactNode } from "react";
|
||||
import Lnb from "../_components/Lnb";
|
||||
import { LNB_DEMAND } from "../_constants/lnb";
|
||||
|
||||
export default function ApplyLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-0">
|
||||
<Lnb title="수요신청" items={LNB_DEMAND} />
|
||||
<main className="flex-1 px-[100px] pt-[80px]">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// apps/dm/app/(landing)/reuse/apply/modal/WaterQualityRefModal.tsx (수질기준참고 모달)
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
imageSrc: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export default function WaterQualityRefModal({
|
||||
open,
|
||||
onClose,
|
||||
imageSrc,
|
||||
title = "수질 기준 참고",
|
||||
}: Props) {
|
||||
const titleId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
// 스크롤 잠금
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[120]">
|
||||
{/* 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 p-4">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className="w-full max-w-[1100px] rounded-[18px] bg-white shadow-2xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="relative px-10 pt-9 pb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
className="absolute right-7 top-7 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="text-left text-[28px] font-bold tracking-tight text-gray-900"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<div className="mt-5 h-px w-full bg-gray-200" />
|
||||
</div>
|
||||
|
||||
{/* Body (스크롤 영역) */}
|
||||
<div className="px-10 pb-10">
|
||||
<div className="max-h-[70vh] overflow-auto bg-white">
|
||||
{/* 이미지가 길어도 여기서 스크롤 */}
|
||||
<Image
|
||||
src={imageSrc}
|
||||
alt="수질 기준 참고"
|
||||
width={851}
|
||||
height={2334}
|
||||
className="block mx-auto max-w-full h-auto"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
// apps/dm/app/(landing)/reuse/apply/place/page.tsx
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import SubPageHeader from "../../_components/SubPageHeader";
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
type SidoValue = "" | "seoul" | "busan" | "daegu" | "incheon" | "gyeonggi";
|
||||
|
||||
const SIDO: { value: SidoValue; label: string }[] = [
|
||||
{ value: "", label: "선택" },
|
||||
{ value: "seoul", label: "서울특별시" },
|
||||
{ value: "busan", label: "부산광역시" },
|
||||
{ value: "daegu", label: "대구광역시" },
|
||||
{ value: "incheon", label: "인천광역시" },
|
||||
{ value: "gyeonggi", label: "경기도" },
|
||||
];
|
||||
|
||||
const SIGUNGU_MAP: Record<SidoValue, Option[]> = {
|
||||
"": [{ value: "", label: "선택" }],
|
||||
seoul: [
|
||||
{ value: "", label: "선택" },
|
||||
{ value: "gangnam", label: "강남구" },
|
||||
{ value: "yeongdeungpo", label: "영등포구" },
|
||||
{ value: "mapo", label: "마포구" },
|
||||
],
|
||||
busan: [
|
||||
{ value: "", label: "선택" },
|
||||
{ value: "haeundae", label: "해운대구" },
|
||||
{ value: "suyeong", label: "수영구" },
|
||||
],
|
||||
daegu: [
|
||||
{ value: "", label: "선택" },
|
||||
{ value: "suseong", label: "수성구" },
|
||||
{ value: "dalseo", label: "달서구" },
|
||||
],
|
||||
incheon: [
|
||||
{ value: "", label: "선택" },
|
||||
{ value: "namdong", label: "남동구" },
|
||||
{ value: "yeonsu", label: "연수구" },
|
||||
],
|
||||
gyeonggi: [
|
||||
{ value: "", label: "선택" },
|
||||
{ value: "bundang", label: "성남시 분당구" },
|
||||
{ value: "suji", label: "용인시 수지구" },
|
||||
{ value: "ilsandong", label: "고양시 일산동구" },
|
||||
],
|
||||
};
|
||||
|
||||
/* ---------------------------
|
||||
* register/page.tsx 스타일과 맞춘 공통 UI (FormRow/Divider)
|
||||
* -------------------------- */
|
||||
|
||||
function Divider() {
|
||||
return <div className="h-px w-full bg-neutral-200" />;
|
||||
}
|
||||
|
||||
function FormRow({
|
||||
label,
|
||||
required,
|
||||
children,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
required?: boolean;
|
||||
children: 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">
|
||||
<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,
|
||||
className = "",
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
type?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
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<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
className = "",
|
||||
}: {
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
options: { value: T; label: string }[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as T)}
|
||||
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 [sido, setSido] = useState<SidoValue>("");
|
||||
const [sigungu, setSigungu] = useState<string>("");
|
||||
|
||||
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 [tel, setTel] = useState("");
|
||||
const [pw, setPw] = useState("");
|
||||
const [pw2, setPw2] = useState("");
|
||||
|
||||
// ✅ undefined 불가(Record<SidoValue,...> + sido가 SidoValue)
|
||||
const sigunguOptions = useMemo<Option[]>(() => SIGUNGU_MAP[sido], [sido]);
|
||||
|
||||
const onSubmit = () => {
|
||||
if (!sido || !sigungu) return alert("시도/시군구를 선택해 주세요.");
|
||||
if (!company) return alert("회사명을 입력해 주세요.");
|
||||
if (!bizNo) return alert("사업자등록번호를 입력해 주세요.");
|
||||
if (!noWorkerNo && !workerNo)
|
||||
return alert("종사자등록번호를 입력하거나 '해당없음'을 선택해 주세요.");
|
||||
if (!addr1) return alert("사업자주소를 입력해 주세요.");
|
||||
if (!tel) return alert("전화번호를 입력해 주세요.");
|
||||
if (!pw) return alert("비밀번호를 입력해 주세요.");
|
||||
if (pw !== pw2) return alert("비밀번호가 일치하지 않습니다.");
|
||||
|
||||
alert("수요처 등록(샘플) 완료!");
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="bg-white">
|
||||
<SubPageHeader
|
||||
title="수요처등록"
|
||||
crumbs={[
|
||||
{ label: "Home", href: "/reuse" },
|
||||
{ label: "수요신청", href: "/reuse/apply" },
|
||||
{ label: "수요처등록" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* register/page.tsx와 동일한 테이블 룩앤필 */}
|
||||
<div className="border border-neutral-200 bg-white">
|
||||
<FormRow label="시도" required>
|
||||
<SelectBox<SidoValue>
|
||||
value={sido}
|
||||
onChange={(v) => {
|
||||
setSido(v);
|
||||
setSigungu(""); // 시도 변경 시 초기화
|
||||
}}
|
||||
options={SIDO}
|
||||
/>
|
||||
</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={bizNo}
|
||||
onChange={setBizNo}
|
||||
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={setWorkerNo}
|
||||
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={() => alert("우편번호 검색(샘플)")}
|
||||
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="상세 주소를 입력하세요."
|
||||
/>
|
||||
</div>
|
||||
</FormRow>
|
||||
|
||||
<Divider />
|
||||
|
||||
<FormRow label="전화번호" required>
|
||||
<div className="max-w-[420px]">
|
||||
<TextInput
|
||||
value={tel}
|
||||
onChange={setTel}
|
||||
placeholder="전화번호를 입력하세요."
|
||||
/>
|
||||
</div>
|
||||
</FormRow>
|
||||
|
||||
<Divider />
|
||||
|
||||
<FormRow label="비밀번호" required>
|
||||
<div className="max-w-[420px]">
|
||||
<TextInput
|
||||
value={pw}
|
||||
onChange={setPw}
|
||||
placeholder="비밀번호를 입력하세요."
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
</FormRow>
|
||||
|
||||
<Divider />
|
||||
|
||||
<FormRow label="비밀번호 확인" required>
|
||||
<div className="max-w-[420px]">
|
||||
<TextInput
|
||||
value={pw2}
|
||||
onChange={setPw2}
|
||||
placeholder="비밀번호를 한번 더 입력하세요."
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
</FormRow>
|
||||
</div>
|
||||
|
||||
{/* register/page.tsx와 동일하게 하단 중앙 버튼 */}
|
||||
<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"
|
||||
>
|
||||
수요처 등록
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
// 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";
|
||||
|
||||
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 [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("");
|
||||
|
||||
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} 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"
|
||||
className="h-11 rounded-md bg-[#0B4EA2] px-10 text-lg text-white hover:opacity-90"
|
||||
onClick={() => {
|
||||
// TODO: 저장 처리
|
||||
console.log("수요정보 등록");
|
||||
}}
|
||||
>
|
||||
수요정보 등록
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------- 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" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
// apps/dm/app/(landing)/reuse/apply/status/page.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import SubPageHeader from "../../_components/SubPageHeader";
|
||||
|
||||
type WaterDetail = {
|
||||
toc: string;
|
||||
bdo: string;
|
||||
tn: string;
|
||||
tp: string;
|
||||
cl: string;
|
||||
conductivity: string;
|
||||
coliform: string;
|
||||
turbidity: string;
|
||||
odor: string;
|
||||
color: string;
|
||||
ph: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
type Row = {
|
||||
id: number;
|
||||
sido: string;
|
||||
sigungu: string;
|
||||
company: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
date: string;
|
||||
status: "진행중" | "진행완료";
|
||||
detail: {
|
||||
reqDate: string;
|
||||
reqQty: string;
|
||||
usePurpose: string;
|
||||
price: string;
|
||||
water: WaterDetail;
|
||||
candidates: [string, string, string];
|
||||
};
|
||||
};
|
||||
|
||||
const MOCK: Row[] = [
|
||||
{
|
||||
id: 1,
|
||||
sido: "서울특별시",
|
||||
sigungu: "금천구",
|
||||
company: "서울워터솔루션(주)",
|
||||
address: "서울특별시 금천구 가산디지털2로 119, 24층(가산동)",
|
||||
phone: "010-0000-0000",
|
||||
date: "2025-01-14",
|
||||
status: "진행중",
|
||||
detail: {
|
||||
reqDate: "2025-02",
|
||||
reqQty: "1,200",
|
||||
usePurpose: "공업용수",
|
||||
price: "350",
|
||||
water: {
|
||||
toc: "4",
|
||||
bdo: "3",
|
||||
tn: "10",
|
||||
tp: "1.0",
|
||||
cl: "120",
|
||||
conductivity: "800",
|
||||
coliform: "0",
|
||||
turbidity: "2",
|
||||
odor: "무",
|
||||
color: "무",
|
||||
ph: "7.2",
|
||||
note: "-",
|
||||
},
|
||||
candidates: ["후보지1", "후보지2", "후보지3"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
sido: "제주특별자치도",
|
||||
sigungu: "서귀포",
|
||||
company: "회사명이 길어도괜찮지",
|
||||
address: "제주특별자치도 서귀포시 천제연로 156, 3층 2호(색달동 1077호)",
|
||||
phone: "010-0000-0000",
|
||||
date: "2025-01-14",
|
||||
status: "진행완료",
|
||||
detail: {
|
||||
reqDate: "2025-03",
|
||||
reqQty: "800",
|
||||
usePurpose: "조경용수",
|
||||
price: "300",
|
||||
water: {
|
||||
toc: "5",
|
||||
bdo: "4",
|
||||
tn: "12",
|
||||
tp: "1.2",
|
||||
cl: "140",
|
||||
conductivity: "900",
|
||||
coliform: "0",
|
||||
turbidity: "3",
|
||||
odor: "무",
|
||||
color: "무",
|
||||
ph: "7.0",
|
||||
note: "현장 협의",
|
||||
},
|
||||
candidates: ["후보지1", "후보지2", "후보지3"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function StatusBadge({ v }: { v: Row["status"] }) {
|
||||
const cls = v === "진행중" ? "text-red-600" : "text-lime-600";
|
||||
return <span className={["font-medium", cls].join(" ")}>{v}</span>;
|
||||
}
|
||||
|
||||
type TopField = { label: string; value: string; required?: boolean };
|
||||
type PairField = { label: string; value: string };
|
||||
|
||||
export default function DemandStatusPage() {
|
||||
const rows = useMemo(() => MOCK, []);
|
||||
|
||||
// ✅ rows가 빈 경우도 대비 (런타임 오류 방지)
|
||||
const [selectedId, setSelectedId] = useState<number | null>(rows[0]?.id ?? null);
|
||||
|
||||
// ✅ 상세영역 스크롤 이동용
|
||||
const detailRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// ✅ selected 계산 안정화
|
||||
const selected = useMemo(() => {
|
||||
if (!rows.length) return null;
|
||||
const found = rows.find((r) => r.id === selectedId);
|
||||
return found ?? rows[0];
|
||||
}, [rows, selectedId]);
|
||||
|
||||
// ✅ rows가 바뀌었는데 selectedId가 null이면 자동 첫번째 선택
|
||||
useEffect(() => {
|
||||
const first = rows[0];
|
||||
if (!first) return; // ✅ rows가 비어있으면 종료 (TS + 런타임 안전)
|
||||
|
||||
if (selectedId == null) setSelectedId(first.id);
|
||||
}, [rows, selectedId]);
|
||||
|
||||
// ✅ 상단 2행(요구시기/요구수량/사용용도/희망단가) - TS 안전하게 object로 구성
|
||||
const topFields: TopField[] = useMemo(() => {
|
||||
if (!selected) return [];
|
||||
return [
|
||||
{ label: "요구시기", value: selected.detail.reqDate, required: true },
|
||||
{ label: "요구수량(㎡/d)", value: selected.detail.reqQty },
|
||||
{ label: "사용용도", value: selected.detail.usePurpose },
|
||||
{ label: "희망단가(원)", value: selected.detail.price },
|
||||
];
|
||||
}, [selected]);
|
||||
|
||||
// ✅ 수질 항목 - TS 안전
|
||||
const waterFields: PairField[] = useMemo(() => {
|
||||
if (!selected) return [];
|
||||
const w = selected.detail.water;
|
||||
return [
|
||||
{ label: "총대장균군수(개/100mℓ)", value: w.coliform },
|
||||
{ label: "결합잔류염소(mg/L)", value: "-" },
|
||||
{ label: "탁도(NTU)", value: w.turbidity },
|
||||
|
||||
{ label: "생물화학적산소요구량(BOD)(mg/L)", value: w.bdo },
|
||||
{ label: "냄새", value: w.odor },
|
||||
{ label: "색도(도)", value: w.color },
|
||||
|
||||
{ label: "총질소(T-N)(mg/L)", value: w.tn },
|
||||
{ label: "총인(T-P)(mg/L)", value: w.tp },
|
||||
{ label: "수소이온농도(pH)", value: w.ph },
|
||||
|
||||
{ label: "염화물(mgCL/L)", value: w.cl },
|
||||
{ label: "전기전도도(µs/cm)", value: w.conductivity },
|
||||
{ label: "비고", value: w.note },
|
||||
];
|
||||
}, [selected]);
|
||||
|
||||
const onSelectRow = (id: number) => {
|
||||
setSelectedId(id);
|
||||
|
||||
// ✅ “행 클릭 → 아래 상세로 보여짐” 체감 확실히: 상세영역으로 이동
|
||||
// (사용자가 이미 아래를 보고 있다면 거의 움직이지 않음)
|
||||
requestAnimationFrame(() => {
|
||||
detailRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="bg-white">
|
||||
{/* 서브 타이틀 + 브레드크럼 */}
|
||||
<SubPageHeader
|
||||
title="수요신청현황"
|
||||
crumbs={[
|
||||
{ label: "Home", href: "/reuse" },
|
||||
{ label: "수요신청", href: "/reuse/apply" },
|
||||
{ label: "수요신청현황" },
|
||||
]}
|
||||
hideFooterLine
|
||||
hideGuideText
|
||||
/>
|
||||
|
||||
{/* 컨텐츠 영역 */}
|
||||
<div>
|
||||
{/* 리스트 테이블 */}
|
||||
<section className="mt-6">
|
||||
<div className="text-2xl font-bold text-slate-700">수요정보 신청현황</div>
|
||||
|
||||
<div className="mt-3 border border-neutral-200">
|
||||
<table className="w-full table-fixed">
|
||||
<thead className="bg-[#E7F4E2]">
|
||||
<tr className="text-sm text-slate-700">
|
||||
<th className="w-[60px] px-3 py-3 text-center">NO.</th>
|
||||
<th className="w-[120px] px-3 py-3 text-center">시도</th>
|
||||
<th className="w-[120px] px-3 py-3 text-center">시군구</th>
|
||||
<th className="w-[180px] px-3 py-3 text-center">회사명</th>
|
||||
<th className="px-3 py-3 text-center">공장주소</th>
|
||||
<th className="w-[140px] px-3 py-3 text-center">전화번호</th>
|
||||
<th className="w-[120px] px-3 py-3 text-center">신청일자</th>
|
||||
<th className="w-[110px] px-3 py-3 text-center">진행상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="bg-white text-base text-slate-700">
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-3 py-10 text-center text-slate-400">
|
||||
조회된 신청내역이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((r) => {
|
||||
const active = r.id === selected?.id;
|
||||
return (
|
||||
<tr
|
||||
key={r.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelectRow(r.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") onSelectRow(r.id);
|
||||
}}
|
||||
className={[
|
||||
"border-t border-neutral-200 outline-none",
|
||||
"cursor-pointer",
|
||||
active ? "bg-slate-50" : "hover:bg-slate-50",
|
||||
"focus-visible:ring-2 focus-visible:ring-slate-300",
|
||||
].join(" ")}
|
||||
aria-selected={active}
|
||||
>
|
||||
<td className="px-3 py-3 text-center">{r.id}</td>
|
||||
<td className="px-3 py-3 text-center">{r.sido}</td>
|
||||
<td className="px-3 py-3 text-center">{r.sigungu}</td>
|
||||
<td className="px-3 py-3 text-center">{r.company}</td>
|
||||
<td className="px-3 py-3 text-center">{r.address}</td>
|
||||
<td className="px-3 py-3 text-center">{r.phone}</td>
|
||||
<td className="px-3 py-3 text-center">{r.date}</td>
|
||||
<td className="px-3 py-3 text-center">
|
||||
<StatusBadge v={r.status} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 상세현황 */}
|
||||
<section className="mt-10 pb-16" ref={detailRef}>
|
||||
<div className="text-2xl font-bold text-slate-700">수요신청 상세현황</div>
|
||||
|
||||
{!selected ? (
|
||||
<div className="mt-3 border border-neutral-200 px-6 py-10 text-center text-sm text-slate-400">
|
||||
상세 정보를 표시할 신청건이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 border border-neutral-200">
|
||||
<div className="bg-sky-50 px-4 py-3 text-sm font-semibold text-slate-700 border-b border-neutral-200">
|
||||
요구수량
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 text-sm">
|
||||
{/* 상단 2행 */}
|
||||
{topFields.map((f) => (
|
||||
<div
|
||||
key={f.label}
|
||||
className="col-span-12 md:col-span-6 grid grid-cols-12 border-b border-neutral-200"
|
||||
>
|
||||
<div className="col-span-4 bg-sky-50 px-4 py-4 flex items-center justify-center text-center font-semibold text-slate-700">
|
||||
{f.label}
|
||||
{f.required ? <span className="ml-1 text-red-500">*</span> : null}
|
||||
</div>
|
||||
<div className="col-span-8 px-4 py-4 flex items-center justify-center text-center">
|
||||
{f.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 수질 */}
|
||||
<div className="col-span-12 bg-sky-50 px-4 py-3 text-sm font-semibold text-slate-700 border-b border-neutral-200">
|
||||
요구수질
|
||||
</div>
|
||||
|
||||
{waterFields.map((f) => (
|
||||
<div
|
||||
key={f.label}
|
||||
className="col-span-12 md:col-span-4 grid grid-cols-12 border-b border-neutral-200"
|
||||
>
|
||||
<div className="col-span-5 bg-sky-50 px-3 py-4 flex items-center justify-center text-center font-semibold text-slate-700">
|
||||
{f.label}
|
||||
</div>
|
||||
<div className="col-span-7 px-3 py-4 flex items-center justify-center text-center">
|
||||
{f.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 공급처 후보지 */}
|
||||
<div className="col-span-12 bg-sky-50 px-4 py-3 text-sm font-semibold text-slate-700 border-b border-neutral-200">
|
||||
공급처 후보지
|
||||
</div>
|
||||
|
||||
{selected.detail.candidates.map((v, i) => (
|
||||
<div
|
||||
key={`candidate-${i}`}
|
||||
className="col-span-12 md:col-span-4 grid grid-cols-12 border-b md:border-b-0 border-neutral-200"
|
||||
>
|
||||
<div className="col-span-5 bg-sky-50 px-3 py-4 flex items-center justify-center text-center font-semibold text-slate-700">
|
||||
후보지{i + 1}
|
||||
</div>
|
||||
<div className="col-span-7 px-3 py-4 flex items-center justify-center text-center">
|
||||
{v}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// apps/dm/app/(landing)/reuse/layout.tsx
|
||||
import type { ReactNode } from "react";
|
||||
import TopNav from "./_components/TopNav";
|
||||
|
||||
export default function ReuseLandingLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<TopNav />
|
||||
<div className="pt-[83px]">{children}</div> {/* TopNav 높이만큼 */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// apps/dm/app/(landing)/reuse/page.tsx
|
||||
"use client";
|
||||
|
||||
import TopNav from "./_components/TopNav";
|
||||
import SideIndicator from "./_components/SideIndicator";
|
||||
import { useScrollSpy } from "./_hooks/useScrollSpy";
|
||||
|
||||
import Hero from "./_sections/Hero";
|
||||
import IntroCards from "./_sections/IntroCards";
|
||||
import Cases from "./_sections/Cases";
|
||||
import ProcessFlow from "./_sections/ProcessFlow";
|
||||
|
||||
const LANDING_SECTIONS = [
|
||||
{ id: "main", label: "Main" },
|
||||
{ id: "intro", label: "재이용소개" },
|
||||
{ id: "cases", label: "재이용사례" },
|
||||
{
|
||||
id: "processflow",
|
||||
label: "물재이용사업", // ✅ 상단 메뉴용
|
||||
sideLabel: "추진절차", // ✅ 사이드 인디게이터용
|
||||
},
|
||||
];
|
||||
|
||||
export default function ReuseLandingPage() {
|
||||
const activeId = useScrollSpy(LANDING_SECTIONS.map((s) => s.id));
|
||||
|
||||
return (
|
||||
<main className="bg-white">
|
||||
|
||||
{/* 헤더 */}
|
||||
<TopNav />
|
||||
|
||||
{/* 사이드 인디게이터 */}
|
||||
<SideIndicator items={LANDING_SECTIONS} activeId={activeId} />
|
||||
|
||||
{/* 컨텐츠 영역 */}
|
||||
<div className="[&>section]:scroll-mt-[83px]">
|
||||
{/* 물 재이용 */}
|
||||
<Hero />
|
||||
{/* 재이용 소개 */}
|
||||
<IntroCards />
|
||||
{/* 재이용 사례 */}
|
||||
<Cases />
|
||||
{/* 물 재이용 사업 */}
|
||||
<ProcessFlow />
|
||||
</div>
|
||||
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/* apps/dm/app/(landing)/reuse/reuse.module.css */
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
font-family: "NotoSansKR", sans-serif;
|
||||
}
|
||||
|
||||
.heroBg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: url("/images/landing/hero_bg.jpg") center/cover no-repeat;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.heroOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.50);
|
||||
}
|
||||
|
||||
/* ===== Side Indicator (첨부 이미지 스타일) ===== */
|
||||
.sideIndicator {
|
||||
position: fixed;
|
||||
right: 28px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 40;
|
||||
|
||||
/* 원형 배지 폭만큼만 */
|
||||
width: 60px;
|
||||
|
||||
/* (디자인처럼) 스크롤 영역 위에 뜨되 클릭 가능 */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 세로 라인: 연한 하늘색 + 점선(도트) 느낌 */
|
||||
.sideIndicatorLine {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -150px;
|
||||
bottom: -150px;
|
||||
width: 2px;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(46, 177, 235, 0.25);
|
||||
background-size: 2px 16px;
|
||||
background-repeat: repeat-y;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* 리스트는 가운데 정렬로 수직 배치 */
|
||||
.sideIndicatorList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
gap: 56px; /* 4개 간격(시안에 맞게 조절) */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.sideIndicatorItem {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 공통 버튼 */
|
||||
.indicatorBtn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
/* ✅ 기본: 작은 점 */
|
||||
.indicatorDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(30, 167, 225, 0.55);
|
||||
border: 1px solid rgba(30, 167, 225, 0.7);
|
||||
}
|
||||
|
||||
/* ✅ 활성: 커지는 큰 원(텍스트 포함) */
|
||||
.indicatorActive {
|
||||
width: 83px;
|
||||
height: 83px;
|
||||
border-radius: 999px;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
/* Main 활성: 흰 배경 + 검정 텍스트 */
|
||||
.indicatorMainActive {
|
||||
background: #ffffff;
|
||||
border: 2px solid rgba(0, 0, 0, 0.15);
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
/* 섹션 활성: 파란 배경 + 흰 텍스트 */
|
||||
.indicatorSectionActive {
|
||||
background: #1ea7e1;
|
||||
border: 2px solid rgba(30, 167, 225, 0.85);
|
||||
color: #ffffff;
|
||||
/* box-shadow: 0 0 0 8px rgba(30, 167, 225, 0.18); */
|
||||
}
|
||||
|
||||
/* 활성 텍스트 */
|
||||
.indicatorText {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
padding: 0 8px;
|
||||
word-break: keep-all;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* 공통 원형 버튼 */
|
||||
.sideIndicatorDot {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 999px;
|
||||
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
/* 라인 위로 올라오게 */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.sideIndicatorDotText {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
padding: 0 8px;
|
||||
|
||||
/* 긴 텍스트도 배지 안에서 줄바꿈 가능 */
|
||||
word-break: keep-all;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Main: 흰색 원형 + 회색 텍스트 */
|
||||
.sideIndicatorDotMain {
|
||||
background: #ffffff;
|
||||
border: 2px solid rgba(0, 0, 0, 0.15);
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
/* 섹션: 파란 원형 + 흰색 텍스트 */
|
||||
.sideIndicatorDotSection {
|
||||
background: #1ea7e1;
|
||||
border: 2px solid rgba(30, 167, 225, 0.85);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 활성 섹션: 더 진한 파랑 + 살짝 글로우 */
|
||||
.sideIndicatorDotActive {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 0 0 8px rgba(30, 167, 225, 0.18);
|
||||
}
|
||||
|
||||
/* hover 효과(이미지 느낌으로 아주 약하게만) */
|
||||
.sideIndicatorDot:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 반응형: 모바일에서는 숨김(원하시면 제거) */
|
||||
@media (max-width: 768px) {
|
||||
.sideIndicator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 중앙 배지 */
|
||||
.sideIndicatorBadge {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Main 배지 */
|
||||
.badgeMain {
|
||||
background: #fff;
|
||||
border: 2px solid rgba(0,0,0,.15);
|
||||
color: rgba(0,0,0,.65);
|
||||
}
|
||||
|
||||
/* Section 배지 */
|
||||
.badgeSection {
|
||||
background: #1ea7e1;
|
||||
border: 2px solid rgba(30, 167, 225, 0.85);
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 8px rgba(30, 167, 225, 0.18);
|
||||
}
|
||||
|
||||
/* 작은 점 */
|
||||
.sideIndicatorDotSmall {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(30,167,225,.6);
|
||||
}
|
||||
|
||||
/* 점 그룹 */
|
||||
.sideIndicatorDotsTop,
|
||||
.sideIndicatorDotsBottom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.sideIndicatorDotsTop { margin-bottom: 18px; }
|
||||
.sideIndicatorDotsBottom { margin-top: 22px; }
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "../../../packages/common/components/src/base.css";
|
||||
/* @import "../../../packages/common/components/src/base.css"; */
|
||||
/* @import "../../../packages/common/components/src/base.css"; */
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@@ -68,3 +69,15 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "NotoSansKR";
|
||||
src: url("/fonts/NotoSansKR-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "NotoSansKR", sans-serif;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
import path from "path";
|
||||
|
||||
const subDomain =
|
||||
process.env.NEXT_PUBLIC_PROXY_HOST || "http://localhost:3000";
|
||||
@@ -8,6 +9,17 @@ const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
proxyTimeout: 600000,
|
||||
},
|
||||
|
||||
// 🔥 반드시 필요
|
||||
webpack(config) {
|
||||
config.resolve.alias = {
|
||||
...(config.resolve.alias || {}),
|
||||
// monorepo 구조 기준
|
||||
"@common": path.resolve(__dirname, "../../packages/common"),
|
||||
};
|
||||
return config;
|
||||
},
|
||||
|
||||
async rewrites() {
|
||||
return {
|
||||
beforeFiles: [
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 475 B |
|
After Width: | Height: | Size: 6.1 MiB |
|
After Width: | Height: | Size: 353 B |
|
After Width: | Height: | Size: 362 B |
|
After Width: | Height: | Size: 9.0 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="26" height="40" viewBox="0 0 26 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="24" height="38" rx="12" stroke="white" stroke-width="2"/>
|
||||
<path d="M13 12C13 13.9526 13 17.0474 13 19" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 288 B |
|
After Width: | Height: | Size: 211 KiB |