Merge branch 'main' into iipbs/dm/dev
@@ -27,4 +27,4 @@ settings.json
|
||||
.npmrc
|
||||
.cursor
|
||||
|
||||
apps/**/tsconfig.tsbuildinfo
|
||||
apps/**/tsconfig.tsbuildinfo
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
pnpm turbo run build --filter="...[origin/$BRANCH]"
|
||||
@@ -0,0 +1,5 @@
|
||||
# apps/as/shared/config/env.ts 참고
|
||||
NEXT_PUBLIC_PORT=
|
||||
NEXT_PUBLIC_PROXY_HOST=
|
||||
NEXT_PUBLIC_SITE_ID=
|
||||
NEXT_PUBLIC_SERVER_API_URL=
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": false,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
# AS Application
|
||||
|
||||
Next.js 애플리케이션 with FSD(Feature Sliced Design) 아키텍처
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- **프레임워크**: Next.js 15 + TypeScript
|
||||
- **스타일링**: Tailwind CSS
|
||||
- **상태관리**: Zustand
|
||||
- **데이터 페칭**: TanStack Query (React Query)
|
||||
- **폼 관리**: React Hook Form + Zod
|
||||
- **테이블**: TanStack Table
|
||||
- **HTTP 클라이언트**: Axios
|
||||
- **날짜 처리**: Day.js
|
||||
|
||||
## 프로젝트 구조 (FSD)
|
||||
|
||||
이 프로젝트는 **Feature-Sliced Design v2.1**을 따르며, 계층적 아키텍처로 구성되어 있습니다.
|
||||
|
||||
```
|
||||
as/
|
||||
├── app/ # App 레이어 (Next.js App Router)
|
||||
│ ├── (routes)/ # 라우트 구조
|
||||
│ ├── layout.tsx # 루트 레이아웃
|
||||
│ ├── page.tsx # 홈 페이지
|
||||
│ ├── globals.css # 글로벌 스타일
|
||||
│ └── providers.tsx # 전역 providers
|
||||
│
|
||||
├── widgets/ # Widgets 레이어
|
||||
│ └── [widget-name]/ # 큰 독립적 UI 블록 (여러 페이지에서 재사용)
|
||||
│ ├── ui/
|
||||
│ ├── model/
|
||||
│ └── index.ts
|
||||
│
|
||||
├── features/ # Features 레이어
|
||||
│ └── [feature-name]/ # 사용자 상호작용 기능 (여러 페이지에서 재사용)
|
||||
│ ├── ui/
|
||||
│ ├── api/
|
||||
│ ├── model/
|
||||
│ └── index.ts
|
||||
│
|
||||
├── entities/ # Entities 레이어
|
||||
│ └── [entity-name]/ # 비즈니스 엔티티 (User, Product 등)
|
||||
│ ├── model/
|
||||
│ ├── api/
|
||||
│ ├── ui/
|
||||
│ └── index.ts
|
||||
│
|
||||
└── shared/ # Shared 레이어
|
||||
├── ui/ # UI Kit (비즈니스 로직 없는 공통 컴포넌트)
|
||||
├── api/ # API 클라이언트 설정
|
||||
├── lib/ # 내부 라이브러리 (dates, colors 등)
|
||||
├── config/ # 환경 설정, feature flags
|
||||
├── types/ # 공통 타입 정의
|
||||
└── routes/ # 라우트 상수
|
||||
```
|
||||
|
||||
### 계층 구조 이해하기
|
||||
|
||||
FSD는 **3단계 계층**으로 구성됩니다:
|
||||
|
||||
1. **Layers (레이어)**: 책임과 의존성에 따른 수평적 분리 (app, pages, widgets, features, entities, shared)
|
||||
2. **Slices (슬라이스)**: 비즈니스 도메인에 따른 수직적 분리 (user, product, auth-login 등)
|
||||
3. **Segments (세그먼트)**: 기술적 목적에 따른 분리 (ui, api, model, lib 등)
|
||||
|
||||
> 📚 각 레이어의 자세한 설명은 해당 폴더의 README.md를 참고하세요.
|
||||
|
||||
## FSD 레이어 설명
|
||||
|
||||
FSD는 계층적 아키텍처로, 각 레이어는 책임의 범위와 의존성의 정도에 따라 구분됩니다. 7개의 표준화된 레이어가 있으며, 프로젝트에 필요한 레이어만 사용하면 됩니다.
|
||||
|
||||
### 레이어 계층 구조 (위에서 아래로)
|
||||
|
||||
```
|
||||
app/ ↑ 가장 높은 책임, 가장 많은 의존성
|
||||
pages/ |
|
||||
widgets/ |
|
||||
features/ |
|
||||
entities/ |
|
||||
shared/ ↓ 가장 낮은 책임, 의존성 없음
|
||||
```
|
||||
|
||||
### 1. App
|
||||
- **목적**: 앱 전체에 영향을 주는 기술적/비즈니스적 사항
|
||||
- **내용**: Context providers, 글로벌 스타일, 스토어 설정, 라우터 설정, 애널리틱스
|
||||
- **특징**: 슬라이스가 없고 세그먼트로만 구성
|
||||
- **의존성**: 모든 하위 레이어 사용 가능
|
||||
|
||||
### 2. Pages (app 디렉토리)
|
||||
- **목적**: 웹사이트나 애플리케이션을 구성하는 페이지/화면
|
||||
- **내용**: 페이지 UI, 로딩 상태, 에러 바운더리, 데이터 fetching
|
||||
- **특징**: 한 페이지당 한 슬라이스 (유사한 페이지는 그룹화 가능)
|
||||
- **의존성**: `widgets`, `features`, `entities`, `shared` 사용 가능
|
||||
|
||||
> 💡 **FSD v2.1 권장사항**: "pages-first" 접근 방식을 따라, 재사용되지 않는 큰 UI 블록과 비즈니스 로직은 페이지 슬라이스에 유지하세요.
|
||||
|
||||
### 3. Widgets
|
||||
- **목적**: 큰 규모의 독립적인 UI 블록
|
||||
- **내용**: 여러 features와 entities를 조합한 복합 UI
|
||||
- **사용 시기**: 여러 페이지에서 재사용되거나, 페이지에 여러 개의 큰 독립적 블록이 있을 때
|
||||
- **의존성**: `features`, `entities`, `shared` 사용 가능 (다른 `widgets` 불가)
|
||||
|
||||
### 4. Features
|
||||
- **목적**: 사용자가 수행하는 주요 상호작용과 비즈니스 기능
|
||||
- **내용**: 사용자 시나리오 (예: 로그인, 상품 검색, 댓글 작성)
|
||||
- **사용 시기**: 여러 페이지에서 재사용되는 기능
|
||||
- **의존성**: `entities`, `shared` 사용 가능 (다른 `features` 불가)
|
||||
|
||||
### 5. Entities
|
||||
- **목적**: 비즈니스가 다루는 실제 개념 (User, Product, Order 등)
|
||||
- **내용**: 데이터 모델, 타입, API 요청, 엔티티의 시각적 표현
|
||||
- **특징**: 비즈니스 용어로 명명
|
||||
- **의존성**: 다른 `entities`, `shared` 사용 가능
|
||||
|
||||
### 6. Shared
|
||||
- **목적**: 애플리케이션의 기반 제공
|
||||
- **내용**: API 클라이언트, UI Kit, 유틸리티 라이브러리, 글로벌 설정
|
||||
- **특징**: 비즈니스 로직 없음, 슬라이스가 없고 세그먼트로만 구성
|
||||
- **의존성**: 외부 라이브러리만 사용 가능 (다른 레이어 불가)
|
||||
|
||||
## 레이어 간 Import 규칙
|
||||
|
||||
> **핵심 규칙**: 슬라이스 내의 모듈(파일)은 **오직 하위 레이어**에 있는 다른 슬라이스만 import할 수 있습니다.
|
||||
|
||||
예시:
|
||||
- `features/auth-login/ui/form.tsx`는 `entities/user`와 `shared/ui`를 import 가능
|
||||
- 하지만 다른 `features/post-create`나 상위 레이어인 `widgets`는 import 불가
|
||||
- 같은 슬라이스 내부(`features/auth-login/`)의 다른 파일들은 자유롭게 import 가능
|
||||
|
||||
## Public API 규칙
|
||||
|
||||
> **슬라이스 외부의 모듈은 슬라이스의 Public API만 참조할 수 있으며, 내부 파일 구조에 직접 접근할 수 없습니다.**
|
||||
|
||||
모든 슬라이스(및 슬라이스가 없는 레이어의 세그먼트)는 반드시 `index.ts`를 통해 Public API를 정의해야 합니다.
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 import
|
||||
import { LoginForm } from 'features/auth-login';
|
||||
|
||||
// ❌ 잘못된 import (내부 구조 직접 접근)
|
||||
import { LoginForm } from 'features/auth-login/ui/login-form';
|
||||
```
|
||||
|
||||
## 시작하기
|
||||
|
||||
```bash
|
||||
# 의존성 설치
|
||||
pnpm install
|
||||
|
||||
# 개발 서버 실행
|
||||
pnpm run dev
|
||||
|
||||
# 프로덕션 빌드
|
||||
pnpm run build
|
||||
|
||||
# 프로덕션 서버 실행
|
||||
pnpm start
|
||||
```
|
||||
|
||||
## 환경 변수
|
||||
|
||||
`.env.local` 파일 생성:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3000/api
|
||||
```
|
||||
|
||||
## FSD 핵심 규칙
|
||||
|
||||
### 1. Import 규칙
|
||||
|
||||
> **슬라이스 내의 모듈(파일)은 오직 하위 레이어에 있는 슬라이스만 import할 수 있습니다.**
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 import (하위 레이어)
|
||||
// features/auth-login/ui/form.tsx
|
||||
import { User } from 'entities/user';
|
||||
import { Button } from 'shared/ui/button';
|
||||
|
||||
// ❌ 잘못된 import (같은 레이어)
|
||||
// features/auth-login/ui/form.tsx
|
||||
import { PostForm } from 'features/post-create'; // 금지!
|
||||
|
||||
// ❌ 잘못된 import (상위 레이어)
|
||||
// features/auth-login/ui/form.tsx
|
||||
import { Header } from 'widgets/header'; // 금지!
|
||||
```
|
||||
|
||||
### 2. Public API 규칙
|
||||
|
||||
> **슬라이스 외부에서는 Public API(index.ts)를 통해서만 접근해야 합니다.**
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 import (Public API 사용)
|
||||
import { LoginForm } from 'features/auth-login';
|
||||
|
||||
// ❌ 잘못된 import (내부 구조 직접 접근)
|
||||
import { LoginForm } from 'features/auth-login/ui/login-form';
|
||||
```
|
||||
|
||||
**Public API 작성 시 주의사항**:
|
||||
- 와일드카드 re-export(`export * from`) 사용 지양
|
||||
- 필요한 것만 명시적으로 export
|
||||
- 슬라이스의 인터페이스를 명확하게 표현
|
||||
|
||||
```typescript
|
||||
// ❌ 나쁜 예시
|
||||
export * from './ui/Comment';
|
||||
export * from './model/comments';
|
||||
|
||||
// ✅ 좋은 예시
|
||||
export { Comment } from './ui/comment';
|
||||
export { CommentForm } from './ui/comment-form';
|
||||
export { useComments } from './model/use-comments';
|
||||
export type { Comment, CommentFormData } from './model/types';
|
||||
```
|
||||
|
||||
### 3. 슬라이스 독립성
|
||||
|
||||
- **Zero Coupling**: 같은 레이어의 슬라이스들은 서로 독립적이어야 합니다
|
||||
- **High Cohesion**: 슬라이스는 주요 목표와 관련된 대부분의 코드를 포함해야 합니다
|
||||
- 예외: Entities 레이어에서 필요한 경우 `@x` 표기법으로 cross-import 가능
|
||||
|
||||
### 4. 세그먼트 명명 규칙
|
||||
|
||||
세그먼트 이름은 **목적**을 설명해야 하며, **본질**을 설명하면 안 됩니다:
|
||||
|
||||
- ❌ **나쁜 이름**: `components`, `hooks`, `types` (무엇인지만 설명)
|
||||
- ✅ **좋은 이름**: `ui`, `api`, `model`, `lib` (왜 있는지 설명)
|
||||
|
||||
## 코드 규칙
|
||||
|
||||
- TypeScript 사용 (strict mode)
|
||||
- 함수형 컴포넌트 사용
|
||||
- Named exports 선호
|
||||
- Tailwind CSS 사용
|
||||
- ESLint + Prettier 적용
|
||||
- Public API는 명시적으로 정의 (와일드카드 export 지양)
|
||||
|
||||
## 아키텍처 린팅
|
||||
|
||||
코드베이스의 FSD 규칙 준수를 자동으로 검증하려면 [Steiger](https://github.com/feature-sliced/steiger)를 사용하세요.
|
||||
|
||||
```bash
|
||||
# Steiger 설치 및 실행
|
||||
pnpm add -D @feature-sliced/steiger
|
||||
npx steiger src
|
||||
```
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { BaseLayout } from "@common/components";
|
||||
import type { MenuItem } from "@/entities/menu";
|
||||
import { PageLayout } from "@/shared/ui/page-layout";
|
||||
|
||||
export default function WithBaseLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleMenuClick = useCallback(
|
||||
(item: MenuItem) => {
|
||||
router.push(item.urlPath);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
queryClient={queryClient}
|
||||
currentPath={pathname ?? ""}
|
||||
handleMenuClick={handleMenuClick}
|
||||
>
|
||||
<PageLayout>{children}</PageLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
export default function MainPage() {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center py-10">
|
||||
<h1 className="mb-4 text-3xl font-bold">Home</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
# App Layer
|
||||
|
||||
**App**은 애플리케이션 전체에 영향을 미치는 기술적, 비즈니스적 사항을 다루는 최상위 레이어입니다.
|
||||
|
||||
## 목적
|
||||
|
||||
- 애플리케이션의 진입점(entrypoint) 제공
|
||||
- 전역 Context Providers 설정
|
||||
- 라우터 구성
|
||||
- 글로벌 스타일 정의
|
||||
- 전역 상태 저장소 설정
|
||||
- 애널리틱스 및 모니터링 설정
|
||||
|
||||
## 특징
|
||||
|
||||
- **슬라이스 없음**: App 레이어는 Shared처럼 슬라이스가 없고 세그먼트로만 구성됩니다
|
||||
- **전체 앱 관심사**: 특정 비즈니스 도메인이 아닌 앱 전체와 관련된 코드만 포함
|
||||
- **최상위 레이어**: 모든 하위 레이어의 모듈을 자유롭게 import 가능
|
||||
|
||||
## 구조 (Next.js App Router)
|
||||
|
||||
Next.js를 사용하는 경우, App 레이어는 주로 `app/` 디렉토리로 표현됩니다:
|
||||
|
||||
```
|
||||
app/
|
||||
├── (routes)/ # 라우트 그룹 및 페이지
|
||||
│ ├── (auth)/
|
||||
│ │ ├── login/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ └── register/
|
||||
│ │ └── page.tsx
|
||||
│ └── (main)/
|
||||
│ ├── dashboard/
|
||||
│ │ └── page.tsx
|
||||
│ └── settings/
|
||||
│ └── page.tsx
|
||||
├── layout.tsx # 루트 레이아웃
|
||||
├── page.tsx # 홈 페이지
|
||||
├── globals.css # 글로벌 스타일
|
||||
├── providers.tsx # 전역 Providers 설정
|
||||
└── error.tsx # 글로벌 에러 핸들러
|
||||
```
|
||||
|
||||
### 일반적인 FSD 세그먼트 구조
|
||||
|
||||
FSD 표준 세그먼트를 사용하는 경우:
|
||||
|
||||
```
|
||||
app/
|
||||
├── entrypoint/ # 애플리케이션 진입점 (프레임워크별)
|
||||
│ └── index.tsx
|
||||
├── providers/ # Context Providers
|
||||
│ ├── query-provider.tsx
|
||||
│ ├── theme-provider.tsx
|
||||
│ └── index.tsx
|
||||
├── styles/ # 글로벌 스타일
|
||||
│ ├── globals.css
|
||||
│ └── theme.css
|
||||
├── store/ # 글로벌 상태 저장소 설정
|
||||
│ └── index.ts
|
||||
└── routes/ # 라우터 설정
|
||||
└── index.tsx
|
||||
```
|
||||
|
||||
## 예시
|
||||
|
||||
### Next.js Providers 설정
|
||||
|
||||
```typescript
|
||||
// app/providers.tsx
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(() => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// app/layout.tsx
|
||||
import { Providers } from './providers';
|
||||
import './globals.css';
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<body>
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 글로벌 설정
|
||||
|
||||
```typescript
|
||||
// app/config/analytics.ts
|
||||
export function initAnalytics() {
|
||||
if (typeof window !== 'undefined') {
|
||||
// 애널리틱스 초기화
|
||||
}
|
||||
}
|
||||
|
||||
// app/config/monitoring.ts
|
||||
export function initMonitoring() {
|
||||
// 에러 모니터링 설정
|
||||
}
|
||||
```
|
||||
|
||||
## Next.js App Router와 FSD
|
||||
|
||||
Next.js의 `app/` 디렉토리는 FSD의 **App 레이어**와 **Pages 레이어**를 동시에 담당합니다:
|
||||
|
||||
- **루트 레벨 파일들** (`layout.tsx`, `providers.tsx`, `globals.css`): App 레이어
|
||||
- **라우트 폴더들** (`dashboard/`, `login/` 등): Pages 레이어
|
||||
|
||||
각 페이지별 비즈니스 로직은:
|
||||
- 재사용되지 않는 UI는 페이지 슬라이스(`app/dashboard/` 등) 안에 유지
|
||||
- 재사용되는 기능은 `features/` 레이어로 분리
|
||||
- 여러 페이지에서 재사용되는 큰 UI 블록은 `widgets/` 레이어로 분리
|
||||
|
||||
## 레이어 의존성 규칙
|
||||
|
||||
✅ **허용되는 의존성**:
|
||||
- 모든 하위 레이어 (`pages`, `widgets`, `features`, `entities`, `shared`)
|
||||
- 같은 `app` 레이어 내의 다른 세그먼트
|
||||
- 외부 라이브러리
|
||||
|
||||
❌ **금지된 의존성**:
|
||||
- 없음 (App은 최상위 레이어)
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **비대화 방지**: App 레이어가 너무 커지면 관리가 어려워집니다. 대부분의 로직은 하위 레이어에 배치하세요
|
||||
2. **전역 관심사만**: 특정 페이지나 기능에만 관련된 코드는 해당 레이어로 이동
|
||||
3. **라우터 레벨 로직**: 라우터 및 서버 레벨 로직은 이 레이어에 유지
|
||||
4. **Processes 레이어 사용 지양**: Processes 레이어는 deprecated되었으므로 해당 로직은 `app`이나 `features`에 배치
|
||||
|
||||
## FSD v2.1 권장사항
|
||||
|
||||
최신 FSD 버전(v2.1)은 **"pages-first"** 접근 방식을 권장합니다:
|
||||
|
||||
- 더 많은 코드를 페이지 슬라이스에 유지
|
||||
- 큰 UI 블록과 재사용되지 않는 비즈니스 로직은 즉시 추출하지 말고 페이지에 보관
|
||||
- 실제로 재사용이 필요할 때만 `features`나 `widgets`로 분리
|
||||
|
||||
이 접근 방식은 과도한 추상화를 방지하고 코드를 더 쉽게 탐색할 수 있게 합니다.
|
||||
@@ -0,0 +1,15 @@
|
||||
@import "@common/assets/styles/base.css";
|
||||
@import "@common/assets/styles/diveicon.css";
|
||||
@import "@common/components/index.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: var(--font-noto-sans-kr), sans-serif;
|
||||
font-size: 10px;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Noto_Sans_KR } from "next/font/google";
|
||||
import { Providers } from "./providers";
|
||||
import "./globals.css";
|
||||
|
||||
const notoSansKR = Noto_Sans_KR({
|
||||
subsets: ["latin"],
|
||||
weight: ["100", "300", "400", "500", "700", "900"],
|
||||
display: "swap",
|
||||
variable: "--font-noto-sans-kr",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "행정지원시스템",
|
||||
description: "행정지원시스템",
|
||||
};
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
return (
|
||||
<html lang="ko" className={notoSansKR.variable}>
|
||||
<body className={`${notoSansKR.className} antialiased`}>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { queryClient } from "@/shared/lib";
|
||||
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Providers({ children }: ProvidersProps) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
# Entities Layer
|
||||
|
||||
**Entities**는 프로젝트가 다루는 실제 비즈니스 개념을 나타내는 레이어입니다. 비즈니스에서 제품을 설명할 때 사용하는 용어들(예: User, Product, Order)이 주로 엔티티가 됩니다.
|
||||
|
||||
## 목적
|
||||
|
||||
- 비즈니스 엔티티의 데이터 모델 및 타입 정의
|
||||
- 엔티티 관련 API 요청 함수 관리
|
||||
- 엔티티의 시각적 표현 제공 (재사용 가능한 UI)
|
||||
- 여러 페이지에서 동일한 외관을 재사용할 수 있도록 지원
|
||||
|
||||
## 구조
|
||||
|
||||
각 엔티티 슬라이스는 다음 세그먼트로 구성됩니다:
|
||||
|
||||
```
|
||||
entities/
|
||||
└── [entity-name]/
|
||||
├── model/ # 데이터 저장소, 검증 스키마, 상태 관리
|
||||
├── api/ # 엔티티 관련 API 요청 함수
|
||||
├── ui/ # 엔티티 시각적 표현 (선택적)
|
||||
├── lib/ # 엔티티 관련 유틸리티 (선택적)
|
||||
├── @x/ # 다른 엔티티로부터의 cross-import용 Public API (선택적)
|
||||
└── index.ts # Public API
|
||||
```
|
||||
|
||||
### 세그먼트 설명
|
||||
|
||||
- **model**: 데이터 모델, 타입 정의, 상태 저장소 (Zustand 등)
|
||||
- **api**: 백엔드 API 호출 함수
|
||||
- **ui**: 엔티티를 표시하는 UI 컴포넌트 (완전한 UI 블록이 아니라 재사용 가능한 표현)
|
||||
- **lib**: 엔티티 관련 유틸리티 함수
|
||||
- **@x**: 다른 엔티티가 이 엔티티를 import할 때 사용하는 명시적 cross-import API
|
||||
- **index.ts**: 슬라이스의 Public API (외부에서 사용할 수 있는 것만 export)
|
||||
|
||||
## 예시
|
||||
|
||||
```typescript
|
||||
// entities/user/model/user.ts
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'admin' | 'user';
|
||||
}
|
||||
|
||||
// entities/user/api/get-user.ts
|
||||
import type { User } from '../model/user';
|
||||
|
||||
export async function getUser(id: string): Promise<User> {
|
||||
const response = await fetch(`/api/users/${id}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// entities/user/ui/user-card.tsx
|
||||
import type { User } from '../model/user';
|
||||
|
||||
interface UserCardProps {
|
||||
user: User;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function UserCard({ user, onClick }: UserCardProps) {
|
||||
return (
|
||||
<div onClick={onClick}>
|
||||
<h3>{user.name}</h3>
|
||||
<p>{user.email}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// entities/user/index.ts
|
||||
export { type User } from './model/user';
|
||||
export { getUser } from './api/get-user';
|
||||
export { UserCard } from './ui/user-card';
|
||||
```
|
||||
|
||||
## 엔티티 간 관계
|
||||
|
||||
엔티티가 서로 연결되어 있을 때는 `@x` 표기법을 사용하여 명시적으로 cross-import를 관리하는 것이 권장됩니다:
|
||||
|
||||
```typescript
|
||||
// entities/artist/model/artist.ts
|
||||
import type { Song } from 'entities/song/@x/artist';
|
||||
|
||||
export interface Artist {
|
||||
name: string;
|
||||
songs: Array<Song>;
|
||||
}
|
||||
|
||||
// entities/song/@x/artist.ts
|
||||
export type { Song } from '../model/song';
|
||||
```
|
||||
|
||||
> `@x` 폴더를 사용하면 엔티티 간 연결을 명확하게 만들어 함께 리팩터링이 필요한 코드를 쉽게 찾을 수 있습니다.
|
||||
|
||||
## 레이어 의존성 규칙
|
||||
|
||||
✅ **허용되는 의존성**:
|
||||
- `shared` 레이어의 모듈
|
||||
- 같은 엔티티 슬라이스 내의 다른 세그먼트
|
||||
- 다른 엔티티 슬라이스 (필요한 경우, `@x` 표기법 권장)
|
||||
|
||||
❌ **금지된 의존성**:
|
||||
- `features` 레이어
|
||||
- `widgets` 레이어
|
||||
- `pages` 레이어
|
||||
- `app` 레이어
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **UI 컴포넌트는 완전한 비즈니스 로직을 포함하지 않음**: UI는 주로 props나 slots를 통해 다양한 비즈니스 로직을 연결할 수 있도록 설계
|
||||
2. **엔티티 간 비즈니스 로직은 상위 레이어에**: 엔티티 간 상호작용의 비즈니스 로직은 Features나 Pages 레이어에서 처리
|
||||
3. **데이터 관리에 집중**: 복잡한 비즈니스 로직보다는 데이터 모델, 검증, 저장에 집중
|
||||
@@ -0,0 +1 @@
|
||||
export { type MenuItem } from "./model/types";
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface MenuItem {
|
||||
menuCd: number;
|
||||
menuKornNm: string;
|
||||
menuEngNm: string;
|
||||
parentId: number | null;
|
||||
sortSeq: number;
|
||||
iconNm?: string;
|
||||
children: MenuItem[] | null;
|
||||
levelNo: number;
|
||||
urlPath: string;
|
||||
menuLnkgTypeCd: string;
|
||||
menuLnkgSn: number;
|
||||
menuPath: string;
|
||||
qrcd: string;
|
||||
useYn: string;
|
||||
otptYn: string;
|
||||
newWndwYn: string;
|
||||
requiredUrlPath: boolean;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,130 @@
|
||||
# Features Layer
|
||||
|
||||
**Features**는 앱의 주요 사용자 상호작용과 기능을 구현하는 레이어입니다. 사용자가 실제로 수행하는 작업들로, 주로 비즈니스 엔티티와 관련이 있습니다.
|
||||
|
||||
## 목적
|
||||
|
||||
- 사용자가 중요하게 생각하는 주요 상호작용 구현
|
||||
- 재사용 가능한 비즈니스 기능 제공
|
||||
- 여러 페이지에서 사용되는 기능의 중복 제거
|
||||
|
||||
## Feature로 만들어야 하는 경우
|
||||
|
||||
> ⚠️ **중요**: 모든 것을 feature로 만들 필요는 없습니다.
|
||||
|
||||
Feature로 만드는 것이 좋은 지표:
|
||||
- **여러 페이지에서 재사용됨**: 예를 들어, 여러 에디터가 있고 모두 댓글 기능이 있다면, 댓글은 재사용 가능한 feature입니다
|
||||
- **중요한 비즈니스 기능**: 신규 프로젝트 참여자가 Features 폴더를 보고 앱의 주요 기능을 빠르게 파악할 수 있어야 합니다
|
||||
|
||||
Feature가 너무 많으면 중요한 기능이 묻히므로, 신규 참여자의 경험을 최적화하여 결정하세요.
|
||||
|
||||
## 구조
|
||||
|
||||
각 feature 슬라이스는 다음 세그먼트로 구성됩니다:
|
||||
|
||||
```
|
||||
features/
|
||||
└── [feature-name]/
|
||||
├── ui/ # 상호작용을 수행하는 UI (폼, 버튼 등)
|
||||
├── api/ # 액션 수행에 필요한 API 호출 (선택적)
|
||||
├── model/ # 검증 및 내부 상태 관리 (선택적)
|
||||
├── lib/ # 유틸리티 함수 (선택적)
|
||||
├── config/ # feature flags (선택적)
|
||||
└── index.ts # Public API
|
||||
```
|
||||
|
||||
### 세그먼트 설명
|
||||
|
||||
- **ui**: 사용자 상호작용을 위한 UI 컴포넌트 (폼, 모달, 버튼 등)
|
||||
- **api**: feature 실행에 필요한 API 요청 함수
|
||||
- **model**: 폼 검증, 내부 상태, 비즈니스 로직
|
||||
- **lib**: feature 내부에서만 사용되는 유틸리티
|
||||
- **config**: feature flags, feature별 설정값
|
||||
- **index.ts**: 슬라이스의 Public API (외부에서 사용할 수 있는 것만 export)
|
||||
|
||||
## 예시
|
||||
|
||||
```typescript
|
||||
// features/auth-login/ui/login-form.tsx
|
||||
import { useState } from 'react';
|
||||
import { loginUser } from '../api/login-user';
|
||||
import type { LoginCredentials } from '../model/types';
|
||||
|
||||
export function LoginForm() {
|
||||
const [credentials, setCredentials] = useState<LoginCredentials>({
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await loginUser(credentials);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="email"
|
||||
value={credentials.email}
|
||||
onChange={(e) => setCredentials({ ...credentials, email: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={credentials.password}
|
||||
onChange={(e) => setCredentials({ ...credentials, password: e.target.value })}
|
||||
/>
|
||||
<button type="submit">로그인</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// features/auth-login/api/login-user.ts
|
||||
import type { LoginCredentials } from '../model/types';
|
||||
|
||||
export async function loginUser(credentials: LoginCredentials) {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// features/auth-login/model/types.ts
|
||||
export interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// features/auth-login/index.ts
|
||||
export { LoginForm } from './ui/login-form';
|
||||
export { loginUser } from './api/login-user';
|
||||
export type { LoginCredentials } from './model/types';
|
||||
```
|
||||
|
||||
## 레이어 의존성 규칙
|
||||
|
||||
✅ **허용되는 의존성**:
|
||||
- `shared` 레이어의 모듈
|
||||
- `entities` 레이어의 모듈
|
||||
- 같은 feature 슬라이스 내의 다른 세그먼트
|
||||
|
||||
❌ **금지된 의존성**:
|
||||
- 다른 `features` 슬라이스 (feature 간 직접 의존 금지)
|
||||
- `widgets` 레이어
|
||||
- `pages` 레이어
|
||||
- `app` 레이어
|
||||
|
||||
> 💡 **팁**: 여러 features가 공통 기능을 필요로 한다면, 그 기능을 `shared` 레이어로 이동시키거나 `entities` 레이어에서 관리하세요.
|
||||
|
||||
## 명명 규칙
|
||||
|
||||
- 소문자와 대시 사용: `auth-login`, `post-create`, `comment-edit`
|
||||
- 동사 형태 권장: 사용자 액션을 나타내는 이름 사용
|
||||
- 명확하고 구체적으로: 기능의 목적이 명확하게 드러나도록
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **하나의 기능에만 집중**: 각 feature는 단일 사용자 시나리오를 구현해야 합니다
|
||||
2. **재사용성 고려**: 여러 페이지에서 사용될 수 있도록 설계
|
||||
3. **독립성 유지**: 다른 features에 의존하지 않고 독립적으로 작동
|
||||
4. **적절한 추상화**: 너무 많은 feature를 만들지 말고, 중요한 기능 위주로 구성
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ["@common/assets"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "as",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint:fsd": "steiger ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@common/assets": "workspace:*",
|
||||
"@common/components": "workspace:*",
|
||||
"@common/network": "workspace:*",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@tanstack/react-query": "^5.75.5",
|
||||
"@tanstack/react-query-devtools": "^5.75.5",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"axios": "^1.8.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"next": "15.3.8",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "15.4.0",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"steiger": "^0.5.11",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,149 @@
|
||||
# Shared Layer
|
||||
|
||||
**Shared**는 애플리케이션의 기반을 형성하는 레이어입니다. 외부 세계(백엔드, 서드파티 라이브러리, 환경)와의 연결을 생성하고, 높은 응집력을 가진 자체 라이브러리를 정의하는 곳입니다.
|
||||
|
||||
## 목적
|
||||
|
||||
- 외부 시스템 및 라이브러리와의 통합
|
||||
- 프로젝트 전체에서 사용되는 공통 코드 제공
|
||||
- 비즈니스 로직이 없는 재사용 가능한 유틸리티 및 UI 제공
|
||||
- 글로벌 설정 및 환경 변수 관리
|
||||
|
||||
## 특징
|
||||
|
||||
- **슬라이스 없음**: Shared 레이어는 비즈니스 도메인이 없으므로 슬라이스로 나뉘지 않습니다
|
||||
- **세그먼트 구성**: 기술적 목적에 따라 세그먼트로 직접 구성됩니다
|
||||
- **자유로운 import**: Shared 내의 모든 파일은 서로를 참조하고 import할 수 있습니다
|
||||
|
||||
## 구조
|
||||
|
||||
```
|
||||
shared/
|
||||
├── ui/ # 애플리케이션 UI Kit
|
||||
│ ├── button/
|
||||
│ ├── input/
|
||||
│ ├── modal/
|
||||
│ └── index.ts
|
||||
├── api/ # API 클라이언트 및 백엔드 엔드포인트 함수
|
||||
│ ├── client.ts
|
||||
│ └── index.ts
|
||||
├── lib/ # 내부 라이브러리 모음
|
||||
│ ├── dates/ # 날짜 관련 유틸리티
|
||||
│ ├── colors/ # 색상 관련 유틸리티
|
||||
│ ├── text/ # 텍스트 처리 유틸리티
|
||||
│ └── index.ts
|
||||
├── config/ # 환경 변수, 글로벌 feature flags
|
||||
│ ├── env.ts
|
||||
│ └── index.ts
|
||||
├── types/ # 공통 타입 정의
|
||||
│ └── index.ts
|
||||
├── i18n/ # 번역 설정, 글로벌 번역 문자열
|
||||
│ └── index.ts
|
||||
└── routes/ # 라우트 상수 또는 패턴
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
## 세그먼트 설명
|
||||
|
||||
### 📁 ui
|
||||
애플리케이션의 UI Kit로, 비즈니스 로직을 포함하지 않는 재사용 가능한 UI 컴포넌트들입니다.
|
||||
|
||||
- Button, Input, Modal 같은 기본 컴포넌트
|
||||
- 비즈니스 로직은 없지만 UI 로직은 포함 가능 (예: Autocomplete, SearchBar)
|
||||
- 비즈니스 테마 컴포넌트도 허용 (예: 회사 로고, 페이지 레이아웃)
|
||||
|
||||
### 📁 api
|
||||
API 클라이언트 및 특정 백엔드 엔드포인트 요청 함수입니다.
|
||||
|
||||
- HTTP 클라이언트 설정 (Axios, Fetch 등)
|
||||
- 인증 인터셉터
|
||||
- 공통 에러 핸들링
|
||||
|
||||
### 📁 lib
|
||||
내부 라이브러리 모음으로, 각 라이브러리는 **하나의 관심사**에 집중해야 합니다.
|
||||
|
||||
> ⚠️ **중요**: `helpers`나 `utils` 폴더처럼 덤프 폴더로 만들지 마세요. 각 라이브러리는 명확한 초점 영역(예: dates, colors, text)을 가져야 하며, README로 문서화해야 합니다.
|
||||
|
||||
### 📁 config
|
||||
환경 변수, 글로벌 feature flags 및 앱 전체 설정입니다.
|
||||
|
||||
### 📁 types
|
||||
프로젝트 전체에서 사용되는 공통 타입 정의입니다.
|
||||
|
||||
### 📁 i18n
|
||||
번역 설정 코드 및 글로벌 번역 문자열입니다.
|
||||
|
||||
### 📁 routes
|
||||
라우트 상수 또는 라우트 매칭 패턴입니다.
|
||||
|
||||
## 예시
|
||||
|
||||
```typescript
|
||||
// shared/ui/button/button.tsx
|
||||
interface ButtonProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'primary' | 'secondary';
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Button({ children, variant = 'primary', onClick }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={variant === 'primary' ? 'bg-blue-500' : 'bg-gray-500'}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// shared/api/client.ts
|
||||
import axios from 'axios';
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// shared/lib/dates/format-date.ts
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export function formatDate(date: Date, format: string = 'YYYY-MM-DD'): string {
|
||||
return dayjs(date).format(format);
|
||||
}
|
||||
|
||||
// shared/config/env.ts
|
||||
export const ENV = {
|
||||
apiUrl: process.env.NEXT_PUBLIC_API_URL!,
|
||||
isDev: process.env.NODE_ENV === 'development',
|
||||
} as const;
|
||||
```
|
||||
|
||||
## 레이어 의존성 규칙
|
||||
|
||||
✅ **허용되는 의존성**:
|
||||
- 같은 `shared` 레이어 내의 다른 세그먼트 (자유로운 cross-import)
|
||||
- 외부 라이브러리 및 패키지
|
||||
|
||||
❌ **금지된 의존성**:
|
||||
- `entities` 레이어
|
||||
- `features` 레이어
|
||||
- `widgets` 레이어
|
||||
- `pages` 레이어
|
||||
- `app` 레이어
|
||||
|
||||
> 💡 **원칙**: Shared는 가장 낮은 레이어이므로 상위 레이어에 의존하지 않습니다.
|
||||
|
||||
## 세그먼트 명명 규칙
|
||||
|
||||
세그먼트 이름은 **내용의 목적**을 설명해야 하며, **본질**을 설명하면 안 됩니다.
|
||||
|
||||
❌ **나쁜 예시**: `components`, `hooks`, `types` (본질을 설명)
|
||||
✅ **좋은 예시**: `ui`, `api`, `lib/dates`, `config` (목적을 설명)
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **비즈니스 로직 없음**: Shared는 순수한 기술적 코드만 포함
|
||||
2. **높은 재사용성**: 앱 전체에서 사용될 수 있어야 함
|
||||
3. **명확한 라이브러리 구조**: `lib` 내부는 관심사별로 명확하게 분리
|
||||
4. **문서화**: 각 라이브러리는 README로 사용법과 목적을 문서화
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Client } from "@common/network";
|
||||
import { PROXY_HOST, SITE_ID } from "@/shared/config/env";
|
||||
|
||||
const BASE_URL = `${PROXY_HOST}/server`;
|
||||
|
||||
export const apiClient = Client.getInstance({
|
||||
baseURL: BASE_URL,
|
||||
siteId: SITE_ID,
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { apiClient } from "./client";
|
||||
@@ -0,0 +1,11 @@
|
||||
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
|
||||
export const PROXY_HOST =
|
||||
process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
|
||||
export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
|
||||
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "AS";
|
||||
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
|
||||
export const ACCESS_TOKEN =
|
||||
process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
|
||||
export const REFRESH_TOKEN =
|
||||
process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
|
||||
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./env";
|
||||
@@ -0,0 +1,12 @@
|
||||
import dayjs from "dayjs";
|
||||
import "dayjs/locale/ko";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.locale("ko");
|
||||
|
||||
export { dayjs };
|
||||
@@ -0,0 +1,2 @@
|
||||
export { queryClient } from "./query-client";
|
||||
export { dayjs } from "./dayjs";
|
||||
@@ -0,0 +1,11 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 60 * 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface BaseEntity {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface PaginationResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
code?: string;
|
||||
status?: number;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PageLayout } from "./page-layout";
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { PageTitle } from "@common/components";
|
||||
|
||||
const mockCurrentMenuInfo = {
|
||||
menuCd: 0,
|
||||
menuKornNm: "메인",
|
||||
menuEngNm: "Main",
|
||||
parentId: null,
|
||||
sortSeq: 0,
|
||||
children: null,
|
||||
levelNo: 0,
|
||||
urlPath: "/",
|
||||
menuLnkgTypeCd: "",
|
||||
menuLnkgSn: 0,
|
||||
menuPath: "메인",
|
||||
qrcd: "",
|
||||
useYn: "Y",
|
||||
otptYn: "Y",
|
||||
newWndwYn: "N",
|
||||
requiredUrlPath: false,
|
||||
};
|
||||
|
||||
function PageLayout({ children }: { children: React.ReactNode }) {
|
||||
const menuPaths = mockCurrentMenuInfo.menuPath.split(">").map((path) => path.trim());
|
||||
|
||||
return (
|
||||
<div className="bg-container h-auto min-h-full pb-2">
|
||||
<PageTitle title={mockCurrentMenuInfo.menuKornNm} menuPaths={menuPaths} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { PageLayout };
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
import presetConfig from "../../tailwind.config.preset";
|
||||
|
||||
const config: Config = {
|
||||
presets: [presetConfig],
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./shared/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./entities/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./features/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./widgets/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"../../packages/common/components/dist/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "var(--background)",
|
||||
foreground: "var(--foreground)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-noto-sans-kr)", "Noto Sans KR", "sans-serif"],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@/app/*": ["./app/*"],
|
||||
"@/shared/*": ["./shared/*"],
|
||||
"@/entities/*": ["./entities/*"],
|
||||
"@/features/*": ["./features/*"],
|
||||
"@/widgets/*": ["./widgets/*"],
|
||||
"@/pages/*": ["./pages/*"],
|
||||
"@common/components": ["../../packages/common/components/dist/"],
|
||||
"@common/network": ["../../packages/common/network/"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
# Widgets Layer
|
||||
|
||||
**Widgets**는 큰 규모의 독립적인 UI 블록을 위한 레이어입니다. 여러 features와 entities를 조합하여 완전한 기능을 제공하는 복합 컴포넌트를 담습니다.
|
||||
|
||||
## 목적
|
||||
|
||||
- 큰 규모의 독립적인 UI 블록 구성
|
||||
- 여러 features와 entities를 하나의 응집력 있는 UI로 결합
|
||||
- 여러 페이지에서 재사용되는 복잡한 UI 제공
|
||||
- 페이지 레벨 레이아웃 및 섹션 관리
|
||||
|
||||
## Widget으로 만들어야 하는 경우
|
||||
|
||||
Widget은 다음 조건에 해당할 때 사용하세요:
|
||||
|
||||
✅ **Widget으로 만들기 좋은 경우**:
|
||||
- 여러 페이지에서 재사용되는 큰 UI 블록
|
||||
- 페이지에 여러 개의 큰 독립적 블록이 있고, 이것이 그 중 하나인 경우
|
||||
- 페이지 레벨 레이아웃 컴포넌트 (nested routing 시스템 사용 시)
|
||||
|
||||
❌ **Widget으로 만들지 말아야 하는 경우**:
|
||||
- UI 블록이 페이지의 대부분을 차지하고 재사용되지 않는 경우 → 페이지 슬라이스 내부에 직접 배치
|
||||
- 작은 재사용 컴포넌트 → `shared/ui`에 배치
|
||||
|
||||
## 구조
|
||||
|
||||
각 widget 슬라이스는 다음 세그먼트로 구성됩니다:
|
||||
|
||||
```
|
||||
widgets/
|
||||
└── [widget-name]/
|
||||
├── ui/ # 위젯의 메인 UI 컴포넌트
|
||||
├── model/ # 위젯 내부 상태 관리 (선택적)
|
||||
├── api/ # 위젯 전용 API 호출 (선택적)
|
||||
├── lib/ # 위젯 내부 유틸리티 (선택적)
|
||||
└── index.ts # Public API
|
||||
```
|
||||
|
||||
### 세그먼트 설명
|
||||
|
||||
- **ui**: 여러 features와 entities를 조합한 위젯 UI
|
||||
- **model**: 위젯 내부 상태 및 로직 (선택적)
|
||||
- **api**: 위젯이 필요로 하는 데이터 fetching (선택적)
|
||||
- **lib**: 위젯 내부에서만 사용되는 헬퍼 함수 (선택적)
|
||||
- **index.ts**: 슬라이스의 Public API (외부에서 사용할 수 있는 것만 export)
|
||||
|
||||
## 예시
|
||||
|
||||
```typescript
|
||||
// widgets/header/ui/header.tsx
|
||||
import { Logo } from 'shared/ui/logo';
|
||||
import { Navigation } from 'shared/ui/navigation';
|
||||
import { UserMenu } from 'features/user-menu';
|
||||
import { NotificationBell } from 'features/notifications';
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header>
|
||||
<Logo />
|
||||
<Navigation />
|
||||
<NotificationBell />
|
||||
<UserMenu />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// widgets/product-list/ui/product-list.tsx
|
||||
import { useProducts } from '../api/use-products';
|
||||
import { ProductCard } from 'entities/product';
|
||||
import { AddToCartButton } from 'features/cart-add-item';
|
||||
|
||||
export function ProductList() {
|
||||
const { data: products, isLoading } = useProducts();
|
||||
|
||||
if (isLoading) return <div>로딩 중...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{products.map((product) => (
|
||||
<div key={product.id}>
|
||||
<ProductCard product={product} />
|
||||
<AddToCartButton productId={product.id} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// widgets/product-list/api/use-products.ts
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getProducts } from 'entities/product';
|
||||
|
||||
export function useProducts() {
|
||||
return useQuery({
|
||||
queryKey: ['products'],
|
||||
queryFn: getProducts,
|
||||
});
|
||||
}
|
||||
|
||||
// widgets/product-list/index.ts
|
||||
export { ProductList } from './ui/product-list';
|
||||
```
|
||||
|
||||
## 레이어 의존성 규칙
|
||||
|
||||
✅ **허용되는 의존성**:
|
||||
- `shared` 레이어의 모듈
|
||||
- `entities` 레이어의 모듈
|
||||
- `features` 레이어의 모듈
|
||||
- 같은 widget 슬라이스 내의 다른 세그먼트
|
||||
|
||||
❌ **금지된 의존성**:
|
||||
- 다른 `widgets` 슬라이스 (widget 간 직접 의존 금지)
|
||||
- `pages` 레이어
|
||||
- `app` 레이어
|
||||
|
||||
## 명명 규칙
|
||||
|
||||
- 소문자와 대시 사용: `header`, `sidebar`, `product-list`
|
||||
- 명사 형태 권장: UI 블록의 성격을 나타내는 이름 사용
|
||||
- 구체적으로: 위젯의 역할이 명확하게 드러나도록
|
||||
|
||||
## Nested Routing 사용 시
|
||||
|
||||
Remix 같은 nested routing 시스템을 사용한다면:
|
||||
- Widgets 레이어를 flat routing의 Pages 레이어처럼 사용 가능
|
||||
- 완전한 라우터 블록 생성 (데이터 fetching, loading states, error boundaries 포함)
|
||||
- 페이지 레이아웃을 이 레이어에 저장 가능
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **독립성 유지**: 각 위젯은 독립적으로 작동 가능해야 합니다
|
||||
2. **적절한 크기**: 너무 작은 컴포넌트는 `shared/ui`에, 재사용되지 않으면 `pages`에 배치
|
||||
3. **응집력**: 관련된 features와 entities를 의미 있게 조합
|
||||
4. **재사용 최적화**: 여러 페이지에서 동일한 방식으로 사용될 수 있도록 설계
|
||||
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -118,7 +118,7 @@ export const linkFormSchema = z.object({
|
||||
last_link_rslt_cd: z.string().max(4).nullable().optional(),
|
||||
last_link_excn_dt: z.string().max(40).nullable().optional(),
|
||||
next_link_excn_dt: z.string().max(40).nullable().optional(),
|
||||
last_link_err_cd: z.string().max(10).nullable().optional(),
|
||||
last_link_err_cd: z.string().max(50, { message: '마지막 연계 오류 코드는 50자 이하입니다.' }).nullable().optional(),
|
||||
mdfcn_dt: z.string().nullable().optional(),
|
||||
mdfr_id: z.string().nullable().optional(),
|
||||
link_cycle_mi: z.coerce.number().nullable().optional().refine((val) => {
|
||||
|
||||
@@ -1,540 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* CaseTabsCarousel.tsx (FINAL OPTIMIZED)
|
||||
* - 탭별 데이터
|
||||
* - 카드 perPage(1/2/3) 반응형
|
||||
* - 카드 perPage개 = 1그룹, 좌/우 클릭 시 1그룹 단위 이동
|
||||
* - 3그룹 가상화(prev/cur/next)로 DOM 최소화
|
||||
* - px 기반 트랙 이동 + "측정 박스 분리"로 좌/우 비침 방지
|
||||
* - stale state 제거(방향/인덱스 ref 관리)로 슬라이드 안정성 강화
|
||||
* - BUGFIX 1) onTransitionEnd 버블링 방지 (target===currentTarget, transform만 처리)
|
||||
* - BUGFIX 2) 슬라이드 중(busy) clipWidth 변화로 트랙 스냅(-W) 덮어쓰지 않게 분리
|
||||
*/
|
||||
|
||||
type TabKey = "rain" | "reuse" | "sewage" | "global";
|
||||
|
||||
type CaseItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
imageSrc: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
const TABS: { key: TabKey; label: string }[] = [
|
||||
{ key: "rain", label: "빗물 재이용 사례" },
|
||||
{ key: "reuse", label: "중수 재이용 사례" },
|
||||
{ key: "sewage", label: "하수 재이용 사례" },
|
||||
{ key: "global", label: "해외 재이용 사례" },
|
||||
];
|
||||
|
||||
// 실제 데이터로 교체해서 쓰세요(구조 유지)
|
||||
const MOCK: Record<TabKey, CaseItem[]> = {
|
||||
rain: [
|
||||
{
|
||||
id: "rain-0",
|
||||
title: "경기도 수원시 빗물이용과 중수도",
|
||||
desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기…",
|
||||
imageSrc: "/images/landing/rain_01.png",
|
||||
href: "/reuse/business/rain/suwon",
|
||||
},
|
||||
// {
|
||||
// id: "rain-2",
|
||||
// title: "경기도 수원시 빗물이용과 중수도",
|
||||
// desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기…",
|
||||
// imageSrc: "/images/landing/rain_01.png",
|
||||
// href: "#",
|
||||
// },
|
||||
],
|
||||
reuse: Array.from({ length: 6 }).map((_, i) => ({
|
||||
id: `reuse-${i}`,
|
||||
title: `중수 재이용 사례 ${i + 1}`,
|
||||
desc: "설명 문구가 들어갑니다. (최대 2~3줄) …",
|
||||
imageSrc: "/images/landing/sample_case.png",
|
||||
href: "#",
|
||||
})),
|
||||
sewage: [
|
||||
{
|
||||
id: "sewage-1",
|
||||
title: "경기도 파주시 하수 재이용",
|
||||
desc: "운정·금촌하수처리시설의 하수처리수를 막공법(U/F, R/O)으로 재처리하여 파주 LCD 산업단지내 3개 업체*에...",
|
||||
imageSrc: "/images/landing/sewage_01.png",
|
||||
href: "/reuse/business/sewage/paju",
|
||||
},
|
||||
{
|
||||
id: "sewage-2",
|
||||
title: "충남보령 하수재이용",
|
||||
desc: "보령·신보령화력발전소에 하루 1만톤의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업...",
|
||||
imageSrc: "/images/landing/sewage_02.png",
|
||||
href: "/reuse/business/sewage/bolyeong",
|
||||
},
|
||||
{
|
||||
id: "sewage-3",
|
||||
title: "여수시 하수처리수 재이용",
|
||||
desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기 도시 열섬효과 및 도로...",
|
||||
imageSrc: "/images/landing/sewage_03.png",
|
||||
href: "/reuse/business/sewage/yeosu",
|
||||
},
|
||||
{
|
||||
id: "sewage-4",
|
||||
title: "청주시 하수 재이용 시설",
|
||||
desc: "청주시 하수처리수 재이용 민간투자사업(BTO) 청주시 하수처리수 재이용 민간투자사업(BTO) 청주시 하수처리수 재이용 민간투자사업(BTO)...",
|
||||
imageSrc: "/images/landing/sewage_04.png",
|
||||
href: "/reuse/business/sewage/cheongju",
|
||||
},
|
||||
{
|
||||
id: "sewage-5",
|
||||
title: "수원시 황구지천 하천유지용수",
|
||||
desc: "황구지천 공공하수처리시설 하수처리수 재이용사업으로 향후 호매실천 및 금곡천 건천화 방지로...",
|
||||
imageSrc: "/images/landing/sewage_05.png",
|
||||
href: "/reuse/business/sewage/hwanggu",
|
||||
},
|
||||
{
|
||||
id: "sewage-6",
|
||||
title: "구리시 도로자동청소시스템",
|
||||
desc: "고농도 미세먼지 발생 및 폭염의 급증에 따라 공공하수처리시설 발생 재이용수 일부를 도로자동청소시스템에 활용...",
|
||||
imageSrc: "/images/landing/sewage_06.png",
|
||||
href: "/reuse/business/sewage/guri",
|
||||
},
|
||||
...Array.from({ length: 6 }).map((_, i) => ({
|
||||
id: `sewage-x-${i}`,
|
||||
title: `하수 재이용 사례 ${i + 4}`,
|
||||
desc: "설명 문구가 들어갑니다. (최대 2~3줄) …",
|
||||
imageSrc: "/images/landing/sample_case.png",
|
||||
href: "#",
|
||||
})),
|
||||
],
|
||||
global: Array.from({ length: 6 }).map((_, i) => ({
|
||||
id: `global-${i}`,
|
||||
title: `해외 재이용 사례 ${i + 1}`,
|
||||
desc: "설명 문구가 들어갑니다. (최대 2~3줄) …",
|
||||
imageSrc: "/images/landing/sample_case.png",
|
||||
href: "#",
|
||||
})),
|
||||
};
|
||||
|
||||
/* ---------------- utils ---------------- */
|
||||
function chunk<T>(arr: T[], size: number) {
|
||||
const res: T[][] = [];
|
||||
if (size <= 0) return res;
|
||||
for (let i = 0; i < arr.length; i += size) res.push(arr.slice(i, i + size));
|
||||
return res;
|
||||
}
|
||||
function mod(n: number, m: number) {
|
||||
return ((n % m) + m) % m;
|
||||
}
|
||||
function usePrefersReducedMotion() {
|
||||
const [reduced, setReduced] = useState(false);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const onChange = () => setReduced(mq.matches);
|
||||
onChange();
|
||||
mq.addEventListener?.("change", onChange);
|
||||
return () => mq.removeEventListener?.("change", onChange);
|
||||
}, []);
|
||||
return reduced;
|
||||
}
|
||||
|
||||
/**
|
||||
* wrapRef: perPage 계산용
|
||||
* clipRef: "패딩 없는 측정 박스" 폭(px) 계산용
|
||||
*/
|
||||
function useCarouselMeasure() {
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const clipRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [perPage, setPerPage] = useState(3);
|
||||
const [clipWidth, setClipWidth] = useState(0);
|
||||
|
||||
// perPage: container width로 반응형 결정 (rAF로 디바운스)
|
||||
useEffect(() => {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
|
||||
let raf = 0;
|
||||
|
||||
const calc = () => {
|
||||
const w = el.clientWidth;
|
||||
if (w < 640) setPerPage(1);
|
||||
else if (w < 1024) setPerPage(2);
|
||||
else setPerPage(3);
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(calc);
|
||||
});
|
||||
|
||||
calc();
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// clipWidth: 패딩 없는 박스에서 width를 정확히 측정 (sub-pixel 반올림)
|
||||
useEffect(() => {
|
||||
const el = clipRef.current;
|
||||
if (!el) return;
|
||||
|
||||
let raf = 0;
|
||||
|
||||
const calc = () => {
|
||||
const w = Math.round(el.getBoundingClientRect().width);
|
||||
setClipWidth(w);
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(calc);
|
||||
});
|
||||
|
||||
calc();
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { wrapRef, clipRef, perPage, clipWidth };
|
||||
}
|
||||
|
||||
/* ---------------- component ---------------- */
|
||||
export default function CaseTabsCarousel() {
|
||||
const [active, setActive] = useState<TabKey>("rain");
|
||||
|
||||
// 탭별 마지막 그룹 인덱스 기억
|
||||
const tabIndexRef = useRef<Record<TabKey, number>>({
|
||||
rain: 0,
|
||||
reuse: 0,
|
||||
sewage: 0,
|
||||
global: 0,
|
||||
});
|
||||
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const { wrapRef, clipRef, perPage, clipWidth } = useCarouselMeasure();
|
||||
|
||||
const items = useMemo(() => MOCK[active] ?? [], [active]);
|
||||
const groups = useMemo(() => chunk(items, perPage), [items, perPage]);
|
||||
const groupCount = groups.length;
|
||||
|
||||
const [cur, setCur] = useState(0);
|
||||
const curRef = useRef(0);
|
||||
|
||||
// px 기반 트랙
|
||||
const [trackPx, setTrackPx] = useState(0);
|
||||
const [anim, setAnim] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// 방향 ref (onTransitionEnd에서 trackPx 비교하지 않음)
|
||||
const dirRef = useRef<-1 | 1 | 0>(0);
|
||||
|
||||
// cur 최신값 유지
|
||||
useEffect(() => {
|
||||
curRef.current = cur;
|
||||
}, [cur]);
|
||||
|
||||
/**
|
||||
* 리셋 로직 분리:
|
||||
* - 탭/perPage/groupCount 바뀔 때만 "인덱스/상태" 리셋
|
||||
* - clipWidth 변화는 별도 effect에서 "busy 아닐 때만" 중앙(-W)로 배치
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
const saved = tabIndexRef.current[active] ?? 0;
|
||||
const nextCur = groupCount ? mod(saved, groupCount) : 0;
|
||||
|
||||
dirRef.current = 0;
|
||||
setCur(nextCur);
|
||||
setBusy(false);
|
||||
setAnim(false);
|
||||
}, [active, perPage, groupCount]);
|
||||
|
||||
// clipWidth 변화로 트랙 위치를 잡되, 슬라이드 중(busy)엔 덮어쓰지 않음
|
||||
useLayoutEffect(() => {
|
||||
if (busy) return;
|
||||
if (clipWidth > 0) setTrackPx(-clipWidth);
|
||||
else setTrackPx(0);
|
||||
}, [clipWidth, busy]);
|
||||
|
||||
// 3그룹 가상화
|
||||
const viewGroups = useMemo(() => {
|
||||
if (!groupCount) return [[], [], []] as CaseItem[][];
|
||||
return [
|
||||
groups[mod(cur - 1, groupCount)] ?? [],
|
||||
groups[mod(cur, groupCount)] ?? [],
|
||||
groups[mod(cur + 1, groupCount)] ?? [],
|
||||
];
|
||||
}, [groups, cur, groupCount]);
|
||||
|
||||
const canSlide = groupCount > 1 && clipWidth > 0;
|
||||
|
||||
const go = useCallback(
|
||||
(dir: -1 | 1) => {
|
||||
if (!canSlide) return;
|
||||
if (busy) return;
|
||||
|
||||
dirRef.current = dir;
|
||||
|
||||
setBusy(true);
|
||||
setAnim(!reducedMotion);
|
||||
|
||||
// 중앙(-W) 기준: prev=0, next=-2W
|
||||
setTrackPx(dir === 1 ? -2 * clipWidth : 0);
|
||||
|
||||
// reduced motion이면 즉시 확정
|
||||
if (reducedMotion) {
|
||||
const nextCur = mod(curRef.current + dir, groupCount);
|
||||
tabIndexRef.current[active] = nextCur;
|
||||
|
||||
setCur(nextCur);
|
||||
setAnim(false);
|
||||
setTrackPx(-clipWidth);
|
||||
setBusy(false);
|
||||
dirRef.current = 0;
|
||||
}
|
||||
},
|
||||
[active, busy, canSlide, clipWidth, groupCount, reducedMotion]
|
||||
);
|
||||
|
||||
// BUGFIX 1) 자식 transitionend 버블링 방지 + transform만 처리
|
||||
const onTransitionEnd = useCallback(
|
||||
(e: React.TransitionEvent<HTMLDivElement>) => {
|
||||
// 자식 transitionend 버블링 방지
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.propertyName !== "transform") return;
|
||||
|
||||
if (!busy || reducedMotion) return;
|
||||
|
||||
const dir = dirRef.current;
|
||||
if (dir !== 1 && dir !== -1) return;
|
||||
|
||||
const nextCur = mod(curRef.current + dir, groupCount);
|
||||
tabIndexRef.current[active] = nextCur;
|
||||
|
||||
// 1) 먼저 transition을 끄고(중요)
|
||||
setAnim(false);
|
||||
|
||||
// 2) cur를 회전 + 트랙을 즉시 중앙(-W)로 스냅
|
||||
// (이 순간은 "무조건 애니메이션 없이" 처리되어야 함)
|
||||
setCur(nextCur);
|
||||
setTrackPx(-clipWidth);
|
||||
|
||||
// 3) 다음 프레임(2번 rAF)에서 다시 애니메이션 켜고 busy 해제
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setAnim(!reducedMotion);
|
||||
setBusy(false);
|
||||
dirRef.current = 0;
|
||||
});
|
||||
});
|
||||
},
|
||||
[active, busy, clipWidth, groupCount, reducedMotion]
|
||||
);
|
||||
|
||||
const goDot = useCallback(
|
||||
(idx: number) => {
|
||||
if (!groupCount) return;
|
||||
if (busy) return;
|
||||
|
||||
const target = mod(idx, groupCount);
|
||||
tabIndexRef.current[active] = target;
|
||||
|
||||
dirRef.current = 0;
|
||||
setCur(target);
|
||||
setAnim(false);
|
||||
if (clipWidth > 0) setTrackPx(-clipWidth);
|
||||
},
|
||||
[active, busy, clipWidth, groupCount]
|
||||
);
|
||||
|
||||
const trackStyle: React.CSSProperties = useMemo(
|
||||
() => ({
|
||||
transform: `translate3d(${trackPx}px, 0, 0)`,
|
||||
transition: anim ? "transform 700ms cubic-bezier(.22,.61,.36,1)" : "none",
|
||||
willChange: busy ? "transform" : undefined,
|
||||
}),
|
||||
[trackPx, anim, busy]
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="w-full">
|
||||
{/* 탭 바 */}
|
||||
<div className="mx-auto max-w-[1520px] pt-10">
|
||||
<div className="">
|
||||
<div className="grid grid-cols-4">
|
||||
{TABS.map((t) => {
|
||||
const isActive = t.key === active;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setActive(t.key)}
|
||||
className={[
|
||||
"relative h-[74px] w-full text-base font-semibold",
|
||||
"transition-colors",
|
||||
// 기본(비활성): 검정 배경 + 회색 텍스트
|
||||
!isActive ? "text-[#767676] hover:text-black border-b-2 border-sky-400" : "",
|
||||
// 활성: 흰 배경 + 진한 텍스트 + 하늘색 테두리(위/좌/우) + 아래는 열어둠
|
||||
isActive
|
||||
? [
|
||||
"bg-[#eaf4f4] text-black",
|
||||
"border-t-2 border-l-2 border-r-2 border-sky-400",
|
||||
"border-b-0", // 아래는 컨텐츠와 이어지도록 열어둠
|
||||
"z-10", // 테두리가 위로 올라오게
|
||||
].join(" ")
|
||||
: "",
|
||||
].join(" ")}
|
||||
>
|
||||
{t.label}
|
||||
|
||||
{/* 활성 탭 하단에 얇은 라인이 필요하면(이미지처럼) */}
|
||||
{isActive && (
|
||||
<span className="pointer-events-none absolute left-0 right-0 bottom-[-1px] h-[2px] bg-[#eaf4f4]" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 슬라이더 */}
|
||||
<div ref={wrapRef} className="mx-auto max-w-[1520px] px-6 pb-5 pt-10 bg-[#eaf4f4]">
|
||||
<div className="relative">
|
||||
{/* 좌우 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(-1)}
|
||||
disabled={!canSlide || busy}
|
||||
className={[
|
||||
"absolute left-0 top-1/2 z-10 -translate-y-1/2",
|
||||
"w-[80px] h-[80px] rounded-full bg-white shadow-[0_10px_25px_rgba(0,0,0,0.12)]",
|
||||
"grid place-items-center border-2 border-[#C2CAD2]",
|
||||
!canSlide || busy
|
||||
? "opacity-40 cursor-not-allowed"
|
||||
: "hover:brightness-95",
|
||||
].join(" ")}
|
||||
aria-label="이전"
|
||||
>
|
||||
<img
|
||||
src="/images/landing/icon_left_arrow.png"
|
||||
alt="이전"
|
||||
className="w-6 h-6 object-contain"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(1)}
|
||||
disabled={!canSlide || busy}
|
||||
className={[
|
||||
"absolute right-0 top-1/2 z-10 -translate-y-1/2",
|
||||
"w-[80px] h-[80px] rounded-full bg-white shadow-[0_10px_25px_rgba(0,0,0,0.12)]",
|
||||
"grid place-items-center border-2 border-[#C2CAD2]",
|
||||
!canSlide || busy
|
||||
? "opacity-40 cursor-not-allowed"
|
||||
: "hover:brightness-95",
|
||||
].join(" ")}
|
||||
aria-label="다음"
|
||||
>
|
||||
<img
|
||||
src="/images/landing/icon_right_arrow.png"
|
||||
alt="다음"
|
||||
className="w-6 h-6 object-contain"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* 중요: clip(가림)과 measure(측정) 분리 */}
|
||||
<div className="overflow-hidden [clip-path:inset(0)] [contain:paint]">
|
||||
<div className="w-[1250px] overflow-hidden m-auto">
|
||||
{/* 측정은 패딩 없는 박스에서 */}
|
||||
<div ref={clipRef} className="w-full">
|
||||
{/* track */}
|
||||
<div className="flex" style={trackStyle} onTransitionEnd={onTransitionEnd}>
|
||||
{viewGroups.map((group, gi) => (
|
||||
<div
|
||||
key={`pane-${gi}`}
|
||||
className="shrink-0"
|
||||
style={{ width: clipWidth || "100%" }}
|
||||
>
|
||||
{/* 그룹 내부: perPage 컬럼 */}
|
||||
<div
|
||||
className="grid gap-7"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${perPage}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{(group ?? []).map((c) => (
|
||||
<article
|
||||
key={c.id}
|
||||
className="overflow-hidden rounded-[22px] border-2 border-sky-400 bg-white"
|
||||
>
|
||||
<div className="h-[241px] w-full bg-slate-100">
|
||||
<img
|
||||
src={c.imageSrc}
|
||||
alt={c.title}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<h3 className="text-[28px] font-extrabold leading-snug tracking-tight">
|
||||
{c.title}
|
||||
</h3>
|
||||
<p className="mt-4 line-clamp-3 text-[16px] leading-7 text-slate-600">
|
||||
{c.desc}
|
||||
</p>
|
||||
|
||||
<a
|
||||
href={c.href ?? "#"}
|
||||
className="mt-8 inline-flex h-[56px] w-full items-center justify-center gap-3 rounded-md bg-[#02A0D6] text-[18px] font-bold text-white transition hover:brightness-110"
|
||||
>
|
||||
자세히 보기 <span aria-hidden>→</span>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 도트 */}
|
||||
<div className="mt-8 flex items-center justify-center gap-3">
|
||||
{Array.from({ length: Math.max(groupCount, 1) }).map((_, i) => (
|
||||
<button
|
||||
key={`dot-${i}`}
|
||||
type="button"
|
||||
onClick={() => goDot(i)}
|
||||
className={[
|
||||
"h-[10px] w-[10px] rounded-full transition",
|
||||
i === cur
|
||||
? "bg-black opacity-100"
|
||||
: "bg-black opacity-30 hover:opacity-60",
|
||||
].join(" ")}
|
||||
aria-label={`${i + 1}번 그룹`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/_components/ImageSlider.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type Slide = { src: string; alt?: string };
|
||||
|
||||
type Props = {
|
||||
slides: Slide[];
|
||||
className?: string;
|
||||
maxWidthClassName?: string; // 예: "max-w-[760px]"
|
||||
};
|
||||
|
||||
export default function CaseImageSlider({
|
||||
slides,
|
||||
className = "",
|
||||
maxWidthClassName = "max-w-[760px]",
|
||||
}: Props) {
|
||||
const safeSlides = useMemo(() => slides.filter(Boolean), [slides]);
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const startX = useRef(0);
|
||||
const deltaX = useRef(0);
|
||||
|
||||
const total = safeSlides.length;
|
||||
const canSlide = total > 1;
|
||||
|
||||
const go = (dir: number) => {
|
||||
if (!canSlide) return;
|
||||
setIdx((prev) => {
|
||||
const next = prev + dir;
|
||||
if (next < 0) return total - 1;
|
||||
if (next >= total) return 0;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ← → 키보드 지원(선택)
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!canSlide) return;
|
||||
if (e.key === "ArrowLeft") go(-1);
|
||||
if (e.key === "ArrowRight") go(1);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [canSlide, total]);
|
||||
|
||||
// 드래그(마우스/터치)로 넘기기
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (!canSlide) return;
|
||||
setDragging(true);
|
||||
startX.current = e.clientX;
|
||||
deltaX.current = 0;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!dragging) return;
|
||||
deltaX.current = e.clientX - startX.current;
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
if (!dragging) return;
|
||||
setDragging(false);
|
||||
|
||||
const threshold = 60; // 넘김 감도
|
||||
if (deltaX.current > threshold) go(-1);
|
||||
else if (deltaX.current < -threshold) go(1);
|
||||
|
||||
deltaX.current = 0;
|
||||
};
|
||||
|
||||
if (!total) return null;
|
||||
|
||||
return (
|
||||
<section className={className}>
|
||||
<div className={`mx-auto ${maxWidthClassName}`}>
|
||||
<div className="relative">
|
||||
{/* 좌측 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(-1)}
|
||||
className="absolute left-[-60px] top-1/2 -translate-y-1/2 z-10
|
||||
w-[80px] h-[80px] rounded-full border border-black/20 bg-white
|
||||
grid place-items-center shadow-sm hover:bg-black/[0.03]"
|
||||
>
|
||||
<img
|
||||
src="/images/landing/icon_left_arrow.png"
|
||||
alt="이전"
|
||||
className="w-[12px] h-[22px]"
|
||||
/>
|
||||
</button>
|
||||
|
||||
|
||||
{/* 우측 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(1)}
|
||||
className="absolute right-[-60px] top-1/2 -translate-y-1/2 z-10
|
||||
w-[80px] h-[80px] rounded-full border border-black/20 bg-white
|
||||
grid place-items-center shadow-sm hover:bg-black/[0.03]"
|
||||
>
|
||||
<img
|
||||
src="/images/landing/icon_right_arrow.png"
|
||||
alt="다음"
|
||||
className="w-[12px] h-[22px]"
|
||||
/>
|
||||
</button>
|
||||
|
||||
|
||||
{/* 뷰포트 */}
|
||||
<div
|
||||
className={[
|
||||
"overflow-hidden",
|
||||
"select-none touch-pan-y",
|
||||
"cursor-grab active:cursor-grabbing",
|
||||
"w-[583px] h-[400px] mx-auto",
|
||||
].join(" ")}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
<div
|
||||
className="flex transition-transform duration-300 ease-out"
|
||||
style={{ transform: `translateX(-${idx * 100}%)` }}
|
||||
>
|
||||
{safeSlides.map((s, i) => (
|
||||
<div key={`${s.src}-${i}`} className="min-w-full">
|
||||
<img
|
||||
src={s.src}
|
||||
alt={s.alt ?? `slide-${i + 1}`}
|
||||
className="w-full h-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 도트 */}
|
||||
{canSlide && (
|
||||
<div className="mt-3 flex items-center justify-center gap-2">
|
||||
{safeSlides.map((_, i) => {
|
||||
const active = i === idx;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setIdx(i)}
|
||||
aria-label={`${i + 1}번 슬라이드`}
|
||||
className={[
|
||||
"h-2 w-2 rounded-full",
|
||||
active ? "bg-black/70" : "bg-black/20 hover:bg-black/35",
|
||||
].join(" ")}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/_components/Lnb.tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export type LnbItem = {
|
||||
label: string;
|
||||
href?: string; // href 없는 부모(2뎁스 컨테이너)도 허용
|
||||
children?: LnbItem[];
|
||||
};
|
||||
|
||||
function isExactActive(pathname: string, href?: string) {
|
||||
if (!href) return false;
|
||||
return pathname === href;
|
||||
}
|
||||
|
||||
export default function Lnb({ title, items }: { title: string; items: LnbItem[] }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
// ✅ 현재 경로가 속한 2depth 그룹을 기본 open
|
||||
const defaultOpenLabel = useMemo(() => {
|
||||
const found = items.find((it) => it.children?.some((c) => isExactActive(pathname, c.href)));
|
||||
return found?.label ?? null;
|
||||
}, [items, pathname]);
|
||||
|
||||
// ✅ 아코디언: 한 번에 하나만 열리도록 openLabel 1개만 관리
|
||||
const [openLabel, setOpenLabel] = useState<string | null>(defaultOpenLabel);
|
||||
|
||||
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뎁스 active
|
||||
const active1 = isExactActive(pathname, item.href);
|
||||
|
||||
// 2뎁스 active(자식 중 exact)
|
||||
const active2 = item.children?.some((c) => isExactActive(pathname, c.href)) ?? false;
|
||||
|
||||
const active = active1 || active2;
|
||||
|
||||
// ✅ children 있는 경우: 아코디언 헤더 + 패널
|
||||
if (hasChildren && !item.href) {
|
||||
const isOpen = openLabel === item.label;
|
||||
|
||||
return (
|
||||
<li key={item.label}>
|
||||
{/* 아코디언 헤더 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenLabel((prev) => (prev === item.label ? null : item.label))}
|
||||
className={[
|
||||
"w-full px-5 py-6",
|
||||
"flex items-center justify-between",
|
||||
"border-b border-slate-300",
|
||||
"text-center text-base font-semibold",
|
||||
active ? "bg-slate-100 text-slate-900" : "bg-slate-100 text-slate-500",
|
||||
].join(" ")}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span className="w-full text-center">{item.label}</span>
|
||||
|
||||
{/* ✅ 우측 화살표(이미지처럼) */}
|
||||
<span
|
||||
aria-hidden
|
||||
className={[
|
||||
"ml-3 shrink-0 transition-transform duration-200",
|
||||
isOpen ? "rotate-180" : "rotate-0",
|
||||
].join(" ")}
|
||||
>
|
||||
<img
|
||||
src="/images/landing/drop_arrow.svg"
|
||||
alt="드롭다운 화살표 아이콘"
|
||||
className="w-7 h-7"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 패널: height auto 애니메이션(그리드 트릭) */}
|
||||
<div
|
||||
className={[
|
||||
"grid transition-[grid-template-rows] duration-200 ease-out",
|
||||
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="overflow-hidden bg-white">
|
||||
<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 pl-[60px] text-[18px] font-medium",
|
||||
"border-b border-slate-200 transition-colors",
|
||||
childActive
|
||||
? "bg-white text-sky-500 cursor-default"
|
||||
: "bg-slate-50 text-slate-500 hover:bg-white hover:text-sky-500",
|
||||
].join(" ")}
|
||||
>
|
||||
{/* 이미지처럼 불릿 필요하면 */}
|
||||
<span className="mr-2 text-current">
|
||||
•
|
||||
</span>
|
||||
{child.label}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="block px-5 py-4 text-[18px] font-medium border-b border-slate-200 bg-slate-50 text-slate-500">
|
||||
{child.label}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
: "bg-[#F0F0F0] text-[#767676] hover:bg-white hover:text-sky-500",
|
||||
].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>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
"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;
|
||||
wrapClassName?: string;
|
||||
hideFooterLine?: boolean;
|
||||
hideGuideText?: boolean;
|
||||
};
|
||||
|
||||
export default function SubPageHeader({
|
||||
title,
|
||||
crumbs,
|
||||
note = "* 는 필수 값입니다.",
|
||||
underlineWidthClass = "w-40",
|
||||
className = "",
|
||||
wrapClassName = "",
|
||||
hideFooterLine = false,
|
||||
hideGuideText = false,
|
||||
}: Props) {
|
||||
return (
|
||||
<section className={`text-black ${className}`}>
|
||||
<div className={`pb-5 ${wrapClassName}`}>
|
||||
<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-sky-500">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1,465 +0,0 @@
|
||||
// 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/law", label: "물 재이용 소개" },
|
||||
{ href: "/reuse/cases/rain", label: "물 재이용 현황" },
|
||||
{ href: "/reuse/business/rain/suwon", 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();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// 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: "수요신청현황" },
|
||||
];
|
||||
|
||||
export const LNB_INTRO = [
|
||||
{ label: "물 재이용 법", href: "/reuse/intro/law" },
|
||||
{ label: "빗물 이용시설", href: "/reuse/intro/rain" },
|
||||
{ label: "중수도", href: "/reuse/intro/greywater" },
|
||||
{ label: "하수처리수", href: "/reuse/intro/treated" },
|
||||
];
|
||||
|
||||
export const LNB_CASES = [
|
||||
{ label: "빗물이용시설(현황)", href: "/reuse/cases/rain" },
|
||||
{ label: "중수도(현황)", href: "/reuse/cases/greywater" },
|
||||
{ label: "하수처리수 재이용시설(현황)", href: "/reuse/cases/treated" },
|
||||
];
|
||||
|
||||
// 물 재이용 사례처럼 2depth가 있는 메뉴
|
||||
export const LNB_BUSINESS: LnbItem[] = [
|
||||
{
|
||||
label: "빗물 재이용 사례",
|
||||
children: [
|
||||
{ label: "경기도 수원시", href: "/reuse/business/rain/suwon" },
|
||||
// { label: "충청남도 보령시", href: "/reuse/business/rain/bolyeong" },
|
||||
// { label: "충청북도 청주시", href: "/reuse/business/rain/cheongju" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "중수 재이용 사례",
|
||||
children: [{ label: "사례 목록", href: "/reuse/business/graywater" }],
|
||||
},
|
||||
{
|
||||
label: "하수 재이용 사례",
|
||||
children: [
|
||||
{ label: "파주시", href: "/reuse/business/sewage/paju" },
|
||||
{ label: "충청남도 보령시", href: "/reuse/business/sewage/bolyeong" },
|
||||
{ label: "여수", href: "/reuse/business/sewage/yeosu" },
|
||||
{ label: "청주", href: "/reuse/business/sewage/cheongju" },
|
||||
{ label: "수원시 황구지천", href: "/reuse/business/sewage/hwanggu" },
|
||||
{ label: "구리시 도로자동청소시스템", href: "/reuse/business/sewage/guri" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "해외 재이용 사례",
|
||||
children: [
|
||||
{ label: "사례 개요", href: "/reuse/business/globalCase/overview" },
|
||||
{ label: "대만", href: "/reuse/business/globalCase/taiwan" },
|
||||
{ label: "호주", href: "/reuse/business/globalCase/australia" },
|
||||
{ label: "싱가포르", href: "/reuse/business/globalCase/singapore" },
|
||||
{ label: "EU", href: "/reuse/business/globalCase/eu" }
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -1,55 +0,0 @@
|
||||
"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;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/_sections/Cases.tsx
|
||||
"use client";
|
||||
|
||||
export default function Cases() {
|
||||
return (
|
||||
<section id="cases" className="bg-[#EAF6F6]">
|
||||
<div className="mx-auto max-w-[1520px] px-6 h-[870px] flex items-center">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 w-full">
|
||||
|
||||
{/* 좌측 : 카피 영역 */}
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="pl-2 lg:pl-10">
|
||||
<p className="text-[32px] font-extrabold text-slate-900 tracking-[-0.02em]">
|
||||
빗물+중수도+하수도
|
||||
</p>
|
||||
|
||||
<h2 className="mt-6 leading-[1.1] tracking-[-0.03em]">
|
||||
<span className="block text-[64px] font-extrabold text-slate-900">
|
||||
데이터로 보는
|
||||
</span>
|
||||
<span className="mt-3 block text-[64px] font-extrabold text-[#004A94]">
|
||||
물 재이용 현황
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div className="mt-14">
|
||||
<a
|
||||
href="/reuse/cases/rain"
|
||||
className="
|
||||
inline-flex items-center justify-center gap-3
|
||||
w-[260px] h-[52px]
|
||||
bg-[#01ACE5] text-white
|
||||
rounded-sm
|
||||
hover:bg-[#0A78C0]
|
||||
transition-colors
|
||||
"
|
||||
>
|
||||
물 재이용 현황 보러가기
|
||||
<span className="text-lg">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 : 이미지 카드 */}
|
||||
<div className="flex justify-center lg:justify-end">
|
||||
<div
|
||||
className="
|
||||
relative
|
||||
w-full max-w-[720px]
|
||||
"
|
||||
>
|
||||
<img
|
||||
src="/images/landing/section_03.png"
|
||||
alt="물 재이용 현황 데이터 테이블"
|
||||
className="w-full h-auto object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// 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-[120px] 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>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/_sections/IntroCards.tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
type IntroCardItem = {
|
||||
title: string;
|
||||
desc: string;
|
||||
image: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
const INTRO_CARDS: IntroCardItem[] = [
|
||||
{
|
||||
title: "물 재이용 법",
|
||||
desc: "물의 재이용 촉진 및 지원에 관한 법률을 시행하고, 일부 법률은 개정되었습니다.",
|
||||
image: "/images/landing/card_01.jpg",
|
||||
href: "/reuse/intro/law",
|
||||
},
|
||||
{
|
||||
title: "친환경 수자원 빗물",
|
||||
desc: "가뭄을 극복하고 홍수를 예방하기 위해 비가 내릴 때 빗물을 모아두었다가 사용합니다.",
|
||||
image: "/images/landing/card_02.jpg",
|
||||
href: "/reuse/intro/rain",
|
||||
},
|
||||
{
|
||||
title: "알뜰하게 재이용되는 중수",
|
||||
desc: "한번 사용한 물을 중수도 시설을 통해 재생하여 개별적 또는 지역적으로 재이용할 수 있습니다.",
|
||||
image: "/images/landing/card_03.jpg",
|
||||
href: "/reuse/intro/greywater",
|
||||
},
|
||||
{
|
||||
title: "하수처리수 재이용",
|
||||
desc: "사용하고 버려진 공공하수처리장으로 모여진 물을 용도에 맞는 처리 후 재이용 할 수 있습니다.",
|
||||
image: "/images/landing/card_04.jpg",
|
||||
href: "/reuse/intro/treated",
|
||||
},
|
||||
];
|
||||
|
||||
export default function IntroCards() {
|
||||
return (
|
||||
<section id="intro" className="bg-white">
|
||||
<div className="mx-auto max-w-[1520px] px-6 py-[90px] h-[870px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block">
|
||||
<h2 className="text-[40px] font-extrabold tracking-tight text-slate-900 leading-none">물 재이용 소개</h2>
|
||||
<div className="h-2 bg-gradient-to-r from-sky-300 to-green-300"></div>
|
||||
</div>
|
||||
<p className="mt-5 text-xl text-gray-500">
|
||||
지속 가능한 미래를 위한 약속, 깨끗한 물의 새로운 순환을 시작합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="mx-auto max-w-8xl px-4 py-12">
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{INTRO_CARDS.map((card, idx) => (
|
||||
<Link
|
||||
key={idx}
|
||||
href={card.href}
|
||||
aria-label={`${card.title} 상세로 이동`}
|
||||
className="group relative h-[280px] md:h-[320px] lg:h-[500px] overflow-hidden rounded-2xl cursor-pointer focus:outline-none focus:ring-2 focus:ring-sky-400/60"
|
||||
>
|
||||
{/* 배경 이미지 (이미 그라디언트 포함) */}
|
||||
<img
|
||||
src={card.image}
|
||||
alt={card.title}
|
||||
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 hover:scale-105"
|
||||
/>
|
||||
|
||||
{/* 텍스트 영역 */}
|
||||
<div className="absolute bottom-0 z-10 p-6 text-white">
|
||||
<h3 className="text-lg font-semibold tracking-tight">
|
||||
{card.title}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-white/90 line-clamp-3">
|
||||
{card.desc}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/_sections/ProcessFlow.tsx
|
||||
import CaseTabsCarousel from "../_components/CaseTabsCarousel";
|
||||
|
||||
|
||||
export default function ProcessFlow() {
|
||||
return (
|
||||
<section id="processflow" className="bg-white">
|
||||
<div className="mx-auto max-w-[1520px] px-6 py-[50px] h-[870px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block">
|
||||
<h2 className="text-[40px] font-extrabold tracking-tight text-slate-900 leading-none">물 재이용 사례</h2>
|
||||
<div className="h-2 bg-gradient-to-r from-sky-300 to-green-300"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭메뉴 영역 */}
|
||||
<CaseTabsCarousel />
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// 예) 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>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -1,476 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/business/globalCase/overview/page.tsx
|
||||
"use client";
|
||||
|
||||
import SubPageHeader from "../../../_components/SubPageHeader";
|
||||
import CaseImageSlider from "../../../_components/ImageSlider";
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-5 w-[3px] bg-sky-500" />
|
||||
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HrProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Hr({ className = "" }: HrProps) {
|
||||
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
|
||||
}
|
||||
|
||||
type ImgCardProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
function ImgCard({ src, alt }: ImgCardProps) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="block mx-auto max-w-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GreywaterPage() {
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<SubPageHeader
|
||||
title="해외 재이용 사례 개요"
|
||||
crumbs={[
|
||||
{ label: "Home", href: "/reuse" },
|
||||
{ label: "물 재이용 사례" },
|
||||
{ label: "해외 재이용 사례" },
|
||||
{ label: "사례개요", href: "/reuse/business/globalCase/overview" },
|
||||
]}
|
||||
hideFooterLine
|
||||
hideGuideText
|
||||
wrapClassName="!pb-0"
|
||||
/>
|
||||
|
||||
<Hr className="my-7" />
|
||||
|
||||
{/* 슬라이드 이미지 영역 */}
|
||||
<CaseImageSlider
|
||||
className="mt-8"
|
||||
maxWidthClassName="max-w-[760px]"
|
||||
slides={[
|
||||
{ src: "/images/landing/business/globalCase/overview_01.png", alt: "해외 재이용 사례 1" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<section className="mt-6">
|
||||
<div className="rounded-md bg-[#e9f0f1] p-8">
|
||||
<h3 className="text-xl font-bold text-gray-800 leading-relaxed">
|
||||
2024년 하수도 통계 기준 15.6%이며,<br />
|
||||
제2차 물 재이용 기본 계획을 고려한 2030년 목표는 <span className="font-extrabold">17%</span>
|
||||
</h3>
|
||||
|
||||
{/* 진행 바 영역 */}
|
||||
<div className="mt-6 space-y-4">
|
||||
{/* 2024 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">’24년도</span>
|
||||
<div className="relative h-7 rounded-r-lg">
|
||||
<div className="h-7 w-[112px] rounded-r-lg bg-gray-400" />
|
||||
</div>
|
||||
<span className="text-sm text-gray-700">15.6%</span>
|
||||
</div>
|
||||
|
||||
{/* 2030 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">’30년도</span>
|
||||
<div className="relative">
|
||||
<div className="h-10 w-[327px] rounded-r-lg bg-gradient-to-r from-sky-500 to-green-500" />
|
||||
</div>
|
||||
<span className="text-xl font-bold text-gray-900">17%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 설명 문구 */}
|
||||
<ul className="mt-6 space-y-2 text-base text-gray-600">
|
||||
<li>
|
||||
• 우리나라의 하수 처리 재이용률은 <span className="text-sky-600 ">15% 수준</span>
|
||||
</li>
|
||||
<li>
|
||||
• 대만은 가뭄과 공업용수 증가에 대응하여
|
||||
<span className="text-sky-600 ">
|
||||
{" "}현재 4.3% 수준이며, 16.5%까지 확대 계획 수립
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="mt-6">
|
||||
<table className="w-full text-center text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="bg-[#C2CAD2] px-4 py-3 text-black rounded-t-lg">국가</th>
|
||||
<th className="bg-sky-600 px-4 py-3 text-white rounded-t-lg">해외정보</th>
|
||||
<th className="bg-teal-500 px-4 py-3 text-white rounded-t-lg">현재</th>
|
||||
<th className="bg-green-500 px-4 py-3 text-white rounded-t-lg">미래(목표, 예측치)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white border border-t-0">
|
||||
<tr className="border-t">
|
||||
<td className="px-4 py-3 font-medium">한국</td>
|
||||
<td className="px-4 py-3 bg-sky-50 text-gray-700">12.1%</td>
|
||||
<td className="px-4 py-3">15.6%</td>
|
||||
<td className="px-4 py-3 bg-sky-50 text-gray-700">17%</td>
|
||||
</tr>
|
||||
<tr className="border-t">
|
||||
<td className="px-4 py-3 font-medium">대만</td>
|
||||
<td className="px-4 py-3 bg-sky-50 text-gray-700">2.4%</td>
|
||||
<td className="px-4 py-3">4.3%</td>
|
||||
<td className="px-4 py-3 bg-sky-50 text-gray-700">16.5%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 주요 국가의 하수처리수 재이용률 (%) */}
|
||||
<section className="pb-[200px]">
|
||||
<SectionTitle>주요 국가의 하수처리수 재이용률 (%)</SectionTitle>
|
||||
{/* 설명 문구 */}
|
||||
<ul className="mt-6 space-y-2 text-base text-gray-600">
|
||||
<li>
|
||||
• 전 세계적으로 물 재이용은 호주, 유럽, 아프리카, 미국 등 가뭄이 잦거나 심각한 가뭄이 발생한 국가에서 많이 이루어짐
|
||||
</li>
|
||||
<li>
|
||||
• 이스라엘, 쿠웨이트 등 중동 및 북아프리카 지역은 가용 수자원이 제한되어 있어 재이용량 비중도 매우 높은 편
|
||||
</li>
|
||||
</ul>
|
||||
{/* 테이블 (2단 병렬: 좌/우 데이터) */}
|
||||
<div className="mt-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-[900px] w-full text-center text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
{/* 좌측 3컬럼 */}
|
||||
<th className="bg-[#C2CAD2] px-4 py-3 text-black rounded-t-lg">국가명</th>
|
||||
<th className="bg-sky-600 px-4 py-3 text-white rounded-t-lg">지역</th>
|
||||
<th className="bg-green-500 px-4 py-3 text-white rounded-t-lg">재이용률</th>
|
||||
|
||||
{/* 우측 3컬럼 */}
|
||||
<th className="bg-[#C2CAD2] px-4 py-3 text-black rounded-t-lg">국가명</th>
|
||||
<th className="bg-sky-600 px-4 py-3 text-white rounded-t-lg">지역</th>
|
||||
<th className="bg-green-500 px-4 py-3 text-white rounded-t-lg">재이용률</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="bg-white border border-t-0">
|
||||
{[
|
||||
{
|
||||
L: { c: "이스라엘", r: "중동&북아프리카", v: "92.3" },
|
||||
R: { c: "튀르키예", r: "동유럽&중앙아시아", v: "26.6" },
|
||||
},
|
||||
{
|
||||
L: { c: "쿠웨이트", r: "중동&북아프리카", v: "89.3" },
|
||||
R: { c: "독일", r: "서유럽", v: "25.0" },
|
||||
},
|
||||
{
|
||||
L: { c: "호주", r: "동아시아&태평양", v: "49.0" },
|
||||
R: { c: "스페인", r: "서유럽", v: "24.4" },
|
||||
},
|
||||
{
|
||||
L: { c: "싱가포르", r: "동아시아&태평양", v: "44.4" },
|
||||
R: { c: "한국", r: "동아시아&태평양", v: "12.1" },
|
||||
},
|
||||
{
|
||||
L: { c: "그리스", r: "서유럽", v: "39.1" },
|
||||
R: { c: "미국", r: "북아메리카", v: "13.1" },
|
||||
},
|
||||
{
|
||||
L: { c: "사우디아라비아", r: "중동&북아프리카", v: "32.8" },
|
||||
R: { c: "프랑스", r: "서유럽", v: "9.5" },
|
||||
},
|
||||
{
|
||||
L: { c: "이탈리아", r: "서유럽", v: "31.7" },
|
||||
R: { c: "대만", r: "동아시아&태평양", v: "2.4" },
|
||||
},
|
||||
{
|
||||
L: { c: "룩셈부르크", r: "서유럽", v: "29.1" },
|
||||
R: { c: "일본", r: "동아시아&태평양", v: "0.8" },
|
||||
},
|
||||
{
|
||||
L: { c: "포르투갈", r: "서유럽", v: "29.0" },
|
||||
R: { c: "", r: "", v: "" }, // 마지막 우측은 빈칸(이미지처럼)
|
||||
},
|
||||
].map((row, idx) => (
|
||||
<tr key={idx} className="border-t">
|
||||
{/* 좌측 */}
|
||||
<td className="px-4 py-3 font-medium text-gray-700">{row.L.c}</td>
|
||||
<td className="px-4 py-3 bg-sky-50 text-gray-700">{row.L.r}</td>
|
||||
<td className="px-4 py-3 bg-green-50 text-gray-700">{row.L.v}</td>
|
||||
|
||||
{/* 우측 */}
|
||||
<td className="px-4 py-3 font-medium text-gray-700">{row.R.c}</td>
|
||||
<td className="px-4 py-3 bg-sky-50 text-gray-700">{row.R.r}</td>
|
||||
<td className="px-4 py-3 bg-green-50 text-gray-700">{row.R.v}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/business/layout.tsx
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import Lnb from "../_components/Lnb";
|
||||
import { LNB_BUSINESS } from "../_constants/lnb";
|
||||
|
||||
export default function BusinessLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-0">
|
||||
<Lnb title="사례" items={LNB_BUSINESS} />
|
||||
<main className="flex-1 px-[100px] pt-[80px]">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/business/rain/suwon/page.tsx
|
||||
"use client";
|
||||
|
||||
import SubPageHeader from "../../../_components/SubPageHeader";
|
||||
import CaseImageSlider from "../../../_components/ImageSlider";
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-5 w-[3px] bg-sky-500" />
|
||||
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HrProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Hr({ className = "" }: HrProps) {
|
||||
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
|
||||
}
|
||||
|
||||
type ImgCardProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
function ImgCard({ src, alt }: ImgCardProps) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="block mx-auto max-w-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GreywaterPage() {
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<SubPageHeader
|
||||
title="경기도 수원시 빗물이용과 중수도"
|
||||
crumbs={[
|
||||
{ label: "Home", href: "/reuse" },
|
||||
{ label: "물 재이용 사례" },
|
||||
{ label: "빗물 재이용 사례" },
|
||||
{ label: "수원시", href: "/reuse/business/rain/suwon" },
|
||||
]}
|
||||
hideFooterLine
|
||||
hideGuideText
|
||||
wrapClassName="!pb-0"
|
||||
/>
|
||||
|
||||
<Hr className="my-7" />
|
||||
|
||||
{/* 슬라이드 이미지 영역 */}
|
||||
<CaseImageSlider
|
||||
className="mt-8"
|
||||
maxWidthClassName="max-w-[760px]"
|
||||
slides={[
|
||||
{ src: "/images/landing/business/rain/rain_01.png", alt: "수원시 하수 재이용 1" },
|
||||
{ src: "/images/landing/business/rain/rain_02.png", alt: "수원시 하수 재이용 2" },
|
||||
{ src: "/images/landing/business/rain/rain_03.png", alt: "수원시 하수 재이용 3" },
|
||||
{ src: "/images/landing/business/rain/rain_04.png", alt: "수원시 하수 재이용 4" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 사업명 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>사업명</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
경기도 수원시 빗물이용과 중수도
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 사업 방식 */}
|
||||
<section className="pb-[200px]">
|
||||
<SectionTitle>사업 방식</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기 도시 열섬효과 및 도로 비산먼지 저감<br/>
|
||||
※ 시설 가동중 도로 노면 온도의 7.2 ~ 26.3℃ 감소 효과 확인(‘21.7월, 수원시청).<br/>
|
||||
다만, 가뭄기나 강우량이 적을 경우 빗물을 원활히 공급하기 다소 곤란<br/><br/>
|
||||
- (중수도) 수원시청, 장안구청, 월드컵경기장에 빗물 연계 중수도를 화장실·조경용수 등으로 사용하여 수도요금 절감(연 7백~30백만원)
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/business/sewage/bolyeong/page.tsx
|
||||
"use client";
|
||||
|
||||
import SubPageHeader from "../../../_components/SubPageHeader";
|
||||
import CaseImageSlider from "../../../_components/ImageSlider";
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-5 w-[3px] bg-sky-500" />
|
||||
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HrProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Hr({ className = "" }: HrProps) {
|
||||
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
|
||||
}
|
||||
|
||||
type ImgCardProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
function ImgCard({ src, alt }: ImgCardProps) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="block mx-auto max-w-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GreywaterPage() {
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<SubPageHeader
|
||||
title="충남보령 하수 재이용"
|
||||
crumbs={[
|
||||
{ label: "Home", href: "/reuse" },
|
||||
{ label: "물 재이용 사례" },
|
||||
{ label: "하수 재이용 사례" },
|
||||
{ label: "충청남도 보령시", href: "/reuse/business/sewage/bolyeong" },
|
||||
]}
|
||||
hideFooterLine
|
||||
hideGuideText
|
||||
wrapClassName="!pb-0"
|
||||
/>
|
||||
|
||||
<Hr className="my-7" />
|
||||
|
||||
{/* 슬라이드 이미지 영역 */}
|
||||
<CaseImageSlider
|
||||
className="mt-8"
|
||||
maxWidthClassName="max-w-[760px]"
|
||||
slides={[
|
||||
{ src: "/images/landing/business/sewage/bolyeong_01.png", alt: "보령시 하수 재이용 1" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 사업명 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>사업명</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
충남 보령 하수재이용<br/>
|
||||
: 보령·신보령화력발전소에 하루 1만톤의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업 추진
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 사업 방식 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>사업 방식</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
2017년 한국환경공단과 사업대행 위 · 수탁 협약 체결
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 사업 주요 내용 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>사업 주요 내용</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
처리공정 : 전처리 UF + 후처리 RO<br/>
|
||||
시설용량 : 1만m³/일<br/>
|
||||
관로 : 8.7km<br/>
|
||||
사업비 : 315억원<br/>
|
||||
위치: 충청남도 보령시 대청방조제로 322 일원
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 특이사항 */}
|
||||
<section className="pb-[200px]">
|
||||
<SectionTitle>특이사항</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
국내 유일 하수재이용 공업용수 공급 재정사업으로 2016년 충남 서부권 가뭄 시 지역사회 기여 차원에서 기관간 MOU(환경부, 충청남도, 보령시, 한국중부발전㈜)를 바탕으로 추진 되어 경제성보다 공익성을 바탕으로<br/>
|
||||
국가전력 기반시설의 안정적 운영 도모
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/business/sewage/cheongju/page.tsx
|
||||
"use client";
|
||||
|
||||
import SubPageHeader from "../../../_components/SubPageHeader";
|
||||
import CaseImageSlider from "../../../_components/ImageSlider";
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-5 w-[3px] bg-sky-500" />
|
||||
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HrProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Hr({ className = "" }: HrProps) {
|
||||
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
|
||||
}
|
||||
|
||||
type ImgCardProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
function ImgCard({ src, alt }: ImgCardProps) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="block mx-auto max-w-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GreywaterPage() {
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<SubPageHeader
|
||||
title="청주시 하수 재이용 시설"
|
||||
crumbs={[
|
||||
{ label: "Home", href: "/reuse" },
|
||||
{ label: "물 재이용 사례" },
|
||||
{ label: "하수 재이용 사례" },
|
||||
{ label: "청주시", href: "/reuse/business/sewage/cheongju" },
|
||||
]}
|
||||
hideFooterLine
|
||||
hideGuideText
|
||||
wrapClassName="!pb-0"
|
||||
/>
|
||||
|
||||
<Hr className="my-7" />
|
||||
|
||||
{/* 슬라이드 이미지 영역 */}
|
||||
<CaseImageSlider
|
||||
className="mt-8"
|
||||
maxWidthClassName="max-w-[760px]"
|
||||
slides={[
|
||||
{ src: "/images/landing/business/sewage/cheongju_01.png", alt: "청주시 하수 재이용 1" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 사업명 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>사업명</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
청주시 하수 재이용 시설
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 사업 주요 내용 */}
|
||||
<section className="pb-[200px]">
|
||||
<SectionTitle>사업 주요 내용</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
청주시 하수처리수 재이용 민간투자사업(BTO)<br/>
|
||||
공급량 : 35,000㎥/일<br/>
|
||||
공급관로 : 8.3㎞<br/>
|
||||
사업비 : 622억원<br/>
|
||||
수요처 : SK하이닉스 등 일반산업단지<br/>
|
||||
지역 (위치) : 충청북도 청주시 흥덕구 가락리 135-9번지 일원<br/>
|
||||
처리용량 : 35.000(m³/일)<br/>
|
||||
적용기술 : 분리막 (UF : 전처리), (RO : 주처리)
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/business/sewage/guri/page.tsx
|
||||
"use client";
|
||||
|
||||
import SubPageHeader from "../../../_components/SubPageHeader";
|
||||
import CaseImageSlider from "../../../_components/ImageSlider";
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-5 w-[3px] bg-sky-500" />
|
||||
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HrProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Hr({ className = "" }: HrProps) {
|
||||
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
|
||||
}
|
||||
|
||||
type ImgCardProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
function ImgCard({ src, alt }: ImgCardProps) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="block mx-auto max-w-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GreywaterPage() {
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<SubPageHeader
|
||||
title="구리시 도로자동청소시스템"
|
||||
crumbs={[
|
||||
{ label: "Home", href: "/reuse" },
|
||||
{ label: "물 재이용 사례" },
|
||||
{ label: "하수 재이용 사례" },
|
||||
{ label: "구리시", href: "/reuse/business/sewage/guri" },
|
||||
]}
|
||||
hideFooterLine
|
||||
hideGuideText
|
||||
wrapClassName="!pb-0"
|
||||
/>
|
||||
|
||||
<Hr className="my-7" />
|
||||
|
||||
{/* 슬라이드 이미지 영역 */}
|
||||
<CaseImageSlider
|
||||
className="mt-8"
|
||||
maxWidthClassName="max-w-[760px]"
|
||||
slides={[
|
||||
{ src: "/images/landing/business/sewage/guri_01.png", alt: "구리시 도로자동청소시스템 1" },
|
||||
{ src: "/images/landing/business/sewage/guri_02.png", alt: "구리시 도로자동청소시스템 2" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 사업명 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>사업명</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
구리시 도로자동청소시스템 설치사업
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 사업목적 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>사업목적</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
고농도 미세먼지 발생 및 폭염의 급증에 따라 공공하수처리시설 발생 재이용수 일부를 도로자동청소시스템에 활용하여 미세먼지 저감 및 폭염 취약성 완화
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 사업 주요 내용 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>사업 주요 내용</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
총사업비 : 61억원<br/>
|
||||
위 치 : 구리시 경춘로 및 갈매중앙로 일대 등<br/>
|
||||
관 로 : 5.8km<br/>
|
||||
공급계획<br/>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 공급계획 */}
|
||||
<section className="pb-[200px]">
|
||||
<SectionTitle>공급계획</SectionTitle>
|
||||
{/* 데이터 테이블 */}
|
||||
<div className="overflow-x-auto mt-8">
|
||||
<table className="w-full border border-[#E3E7EA] text-center">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="bg-[#01ACE5] text-white font-semibold py-4 border border-gray-300">
|
||||
시설명
|
||||
</th>
|
||||
<th className="bg-[#33B5C8] text-white font-semibold py-4 border border-gray-300">
|
||||
재이용수 용도
|
||||
</th>
|
||||
<th className="bg-[#55BCA2] text-white font-semibold py-4 border border-gray-300">
|
||||
공급량(㎥/일)
|
||||
</th>
|
||||
<th className="bg-[#6DBB4F] text-white font-semibold py-4 border border-gray-300">
|
||||
원수
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
className="bg-white py-6 border border-gray-300 text-gray-700"
|
||||
>
|
||||
도로자동청소시스템
|
||||
</td>
|
||||
<td className="bg-white py-6 border border-gray-300 text-gray-700">
|
||||
세척살수용수
|
||||
</td>
|
||||
<td className="bg-white py-6 border border-gray-300 text-gray-700">
|
||||
178
|
||||
</td>
|
||||
<td className="bg-white py-6 border border-gray-300 text-gray-700">
|
||||
구리 공공하수처리장 재이용수
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/business/sewage/hwanggu/page.tsx
|
||||
"use client";
|
||||
|
||||
import SubPageHeader from "../../../_components/SubPageHeader";
|
||||
import CaseImageSlider from "../../../_components/ImageSlider";
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-5 w-[3px] bg-sky-500" />
|
||||
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HrProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Hr({ className = "" }: HrProps) {
|
||||
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
|
||||
}
|
||||
|
||||
type ImgCardProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
function ImgCard({ src, alt }: ImgCardProps) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="block mx-auto max-w-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GreywaterPage() {
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<SubPageHeader
|
||||
title="수원시 황구지천 하천유지용수"
|
||||
crumbs={[
|
||||
{ label: "Home", href: "/reuse" },
|
||||
{ label: "물 재이용 사례" },
|
||||
{ label: "하수 재이용 사례" },
|
||||
{ label: "수원시 황구지천", href: "/reuse/business/sewage/hwanggu" },
|
||||
]}
|
||||
hideFooterLine
|
||||
hideGuideText
|
||||
wrapClassName="!pb-0"
|
||||
/>
|
||||
|
||||
<Hr className="my-7" />
|
||||
|
||||
{/* 슬라이드 이미지 영역 */}
|
||||
<CaseImageSlider
|
||||
className="mt-8"
|
||||
maxWidthClassName="max-w-[760px]"
|
||||
slides={[
|
||||
{ src: "/images/landing/business/sewage/hwanggu_01.png", alt: "수원시 황구지천 하수 재이용 1" },
|
||||
{ src: "/images/landing/business/sewage/hwanggu_02.png", alt: "수원시 황구지천 하수 재이용 2" },
|
||||
{ src: "/images/landing/business/sewage/hwanggu_03.png", alt: "수원시 황구지천 하수 재이용 3" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 사업명 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>사업명</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
수원시 황수원시 황구지천 하수처리수 재이용사업
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 사업목적 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>사업목적</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
황구지천 공공하수처리시설 하수처리수 재이용사업으로 향후 호매실천 및 금곡천 건천화 방지로 주민들에게 친수 공간 제공 및 하천 생태계 보전
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 사업 주요 내용 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>사업 주요 내용</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
총사업비 : 110억원<br/>
|
||||
위 치 : 수원시 권선구 호매실천, 금곡천<br/>
|
||||
관 로 : 3.9km<br/>
|
||||
공 급 량 : 25,000㎥/일(하수처리시설 용량: 45,000㎥/일)<br/>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 공급계획 */}
|
||||
<section className="pb-[200px]">
|
||||
<SectionTitle>공급계획</SectionTitle>
|
||||
{/* 데이터 테이블 */}
|
||||
<div className="overflow-x-auto mt-8">
|
||||
<table className="w-full border border-[#E3E7EA] text-center">
|
||||
<thead>
|
||||
<tr>
|
||||
{/* 구분 */}
|
||||
<th className="w-1/4 bg-[#E3E7EA] text-black font-semibold py-4 border border-gray-300">
|
||||
구분
|
||||
</th>
|
||||
|
||||
{/* 공급량 */}
|
||||
<th className="w-2/4 bg-[#1FA3C6] text-white font-semibold py-4 border border-gray-300">
|
||||
공급량
|
||||
</th>
|
||||
|
||||
{/* 비고 */}
|
||||
<th className="w-2/4 bg-[#72B84F] text-white font-semibold py-4 border border-gray-300">
|
||||
비고
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr>
|
||||
{/* 좌측 병합 */}
|
||||
<td
|
||||
rowSpan={3}
|
||||
className="bg-white font-medium text-gray-700 border border-gray-300"
|
||||
>
|
||||
하천 등 유지용수
|
||||
</td>
|
||||
|
||||
<td className="bg-white py-6 border border-gray-300 text-gray-700 text-lg font-medium">
|
||||
12,500m³/일
|
||||
</td>
|
||||
|
||||
<td className="bg-white py-6 border border-gray-300 text-gray-700">
|
||||
호매실천 공급
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td className="bg-white py-6 border border-gray-300 text-gray-700 text-lg font-medium">
|
||||
6,250m³/일
|
||||
</td>
|
||||
|
||||
<td className="bg-white py-6 border border-gray-300 text-gray-700">
|
||||
금곡천1 공급
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td className="bg-white py-6 border border-gray-300 text-gray-700 text-lg font-medium">
|
||||
6,250m³/일
|
||||
</td>
|
||||
|
||||
<td className="bg-white py-6 border border-gray-300 text-gray-700">
|
||||
금곡천2 공급
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/business/sewage/paju/page.tsx
|
||||
"use client";
|
||||
|
||||
import SubPageHeader from "../../../_components/SubPageHeader";
|
||||
import CaseImageSlider from "../../../_components/ImageSlider";
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-5 w-[3px] bg-sky-500" />
|
||||
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HrProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Hr({ className = "" }: HrProps) {
|
||||
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
|
||||
}
|
||||
|
||||
type ImgCardProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
function ImgCard({ src, alt }: ImgCardProps) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="block mx-auto max-w-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GreywaterPage() {
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<SubPageHeader
|
||||
title="경기도 파주시 하수 재이용"
|
||||
crumbs={[
|
||||
{ label: "Home", href: "/reuse" },
|
||||
{ label: "물 재이용 사례" },
|
||||
{ label: "하수 재이용 사례" },
|
||||
{ label: "파주시", href: "/reuse/business/sewage/paju" },
|
||||
]}
|
||||
hideFooterLine
|
||||
hideGuideText
|
||||
wrapClassName="!pb-0"
|
||||
/>
|
||||
|
||||
<Hr className="my-7" />
|
||||
|
||||
{/* 슬라이드 이미지 영역 */}
|
||||
<CaseImageSlider
|
||||
className="mt-8"
|
||||
maxWidthClassName="max-w-[760px]"
|
||||
slides={[
|
||||
{ src: "/images/landing/business/sewage/paju_01.png", alt: "파주시 하수 재이용 1" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 사업명 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>사업명</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
경기도 파주시 하수재이용<br/>
|
||||
: 운정·금촌하수처리시설의 하수처리수를 막공법(U/F, R/O)으로 재처리하여 파주 LCD 산업단지내 3개 업체*에 공업용수 공급<br/>
|
||||
*LG디스플레이(P10공장), 희성전자, 엔비텍 등
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 사업 배경 및 목적 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>사업 배경 및 목적</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
민간투자사업(시행사 : 파주그린허브(주), 운영사 : 테크로스)
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 사업 주요 내용 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>사업 주요 내용</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
처리공정 : 전처리 UF + 후처리 RO<br/>
|
||||
공급량 : 4만m³/일<br/>
|
||||
관로 : 13.6km<br/>
|
||||
사업비 : 651억원<br/>
|
||||
위치 : 경기도 파주시 금촌동 714번지 일원<br/>
|
||||
시설용량 : 41,20m³/일
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 특이사항 */}
|
||||
<section className="pb-[200px]">
|
||||
<SectionTitle>특이사항</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
하수처리수 재이용시설 자체 농축폐수 처리공정 개발 및 적용 성공사례
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/business/sewage/yeosu/page.tsx
|
||||
"use client";
|
||||
|
||||
import SubPageHeader from "../../../_components/SubPageHeader";
|
||||
import CaseImageSlider from "../../../_components/ImageSlider";
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-5 w-[3px] bg-sky-500" />
|
||||
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HrProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Hr({ className = "" }: HrProps) {
|
||||
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
|
||||
}
|
||||
|
||||
type ImgCardProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
function ImgCard({ src, alt }: ImgCardProps) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="block mx-auto max-w-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GreywaterPage() {
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<SubPageHeader
|
||||
title="여수시 하수처리수 재이용 민간투자사업(BTO)"
|
||||
crumbs={[
|
||||
{ label: "Home", href: "/reuse" },
|
||||
{ label: "물 재이용 사례" },
|
||||
{ label: "하수 재이용 사례" },
|
||||
{ label: "여수시", href: "/reuse/business/sewage/yeosu" },
|
||||
]}
|
||||
hideFooterLine
|
||||
hideGuideText
|
||||
wrapClassName="!pb-0"
|
||||
/>
|
||||
|
||||
<Hr className="my-7" />
|
||||
|
||||
{/* 슬라이드 이미지 영역 */}
|
||||
<CaseImageSlider
|
||||
className="mt-8"
|
||||
maxWidthClassName="max-w-[760px]"
|
||||
slides={[
|
||||
{ src: "/images/landing/business/sewage/yeosu_01.png", alt: "여수시 하수 재이용 1" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 사업명 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>사업명</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
여수시 하수처리수 재이용 민간 투자사업(BTO)
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 사업 목적 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>사업 목적</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
수요자가 원하는 양질의 공업용수를 저렴하게 곰급하여 한정된 수자원의 효율적 이용 및 지역적 물 부족 해소, 하수재이용을 통한 오염총량 저감 및 방류수역 수질개선
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 사업 주요 내용 */}
|
||||
<section className="pb-[200px]">
|
||||
<SectionTitle>사업 주요 내용</SectionTitle>
|
||||
<p className="mt-8 text-base text-gray-600">
|
||||
총사업비 : GS칼텍스, YNCC(주) 등 750억원<br/>
|
||||
시행사 : 여수그린허브(주)<br/>
|
||||
운영사 : 테크로스<br/>
|
||||
위치: 여수시 신월로 2844일원 (여수 하수처리시설 부지 내)<br/>
|
||||
처리공법 : 전처리시설(MF) + 주처리시설(RO)<br/>
|
||||
시설용량 및 공급량 : 5만m³/일<br/>
|
||||
관로 : 18.6km
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/cases/greywater/page.tsx
|
||||
"use client";
|
||||
|
||||
import SubPageHeader from "../../_components/SubPageHeader";
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-5 w-[3px] bg-sky-500" />
|
||||
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HrProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Hr({ className = "" }: HrProps) {
|
||||
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
|
||||
}
|
||||
|
||||
type ImgCardProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
function ImgCard({ src, alt }: ImgCardProps) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="block mx-auto max-w-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GreywaterPage() {
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<SubPageHeader
|
||||
title="중수도(현황)"
|
||||
crumbs={[
|
||||
{ label: "Home", href: "/reuse" },
|
||||
{ label: "물 재이용 현황", href: "/reuse/cases/rain" },
|
||||
{ label: "중수도(현황)" },
|
||||
]}
|
||||
hideFooterLine
|
||||
hideGuideText
|
||||
wrapClassName="!pb-0"
|
||||
/>
|
||||
|
||||
<Hr className="my-7" />
|
||||
|
||||
{/* 1) 전체 중수도 설치 및 이용 현황 (공장 및 발전시설 제외) */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>전체 중수도 설치 및 이용 현황 (공장 및 발전시설 제외)</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/cases/greywater_overall.png" alt="전체 중수도 설치 및 이용 현황 설명" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 2) 중수공장 및 발전시설 내 중수도 설치 및 이용 현황 */}
|
||||
<section>
|
||||
<SectionTitle>공장 및 발전시설 내 중수도 설치 및 이용 현황</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/cases/greywater_factory.png" alt="공장 및 발전시설 내 중수도 설치 및 이용 현황 설명" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 3) 중수도 건축물별 이용량 현황 */}
|
||||
<section className="pb-16">
|
||||
<SectionTitle>중수도 건축물별 이용량 현황</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/cases/greywater_building.png" alt="중수도 건축물별 이용량 현황 설명" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 4) 중수도 용도별 이용량 현황 */}
|
||||
<section className="pb-[200px]">
|
||||
<SectionTitle>중수도 용도별 이용량 현황</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/cases/greywater_usage.png" alt="중수도 용도별 이용량 현황 설명" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/cases/layout.tsx
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import Lnb from "../_components/Lnb";
|
||||
import { LNB_CASES } from "../_constants/lnb";
|
||||
|
||||
export default function CasesLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-0">
|
||||
<Lnb title="현황" items={LNB_CASES} />
|
||||
<main className="flex-1 px-[100px] pt-[80px]">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/cases/rain/page.tsx
|
||||
"use client";
|
||||
|
||||
import SubPageHeader from "../../_components/SubPageHeader";
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-5 w-[3px] bg-sky-500" />
|
||||
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HrProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Hr({ className = "" }: HrProps) {
|
||||
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
|
||||
}
|
||||
|
||||
type ImgCardProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
function ImgCard({ src, alt }: ImgCardProps) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="block mx-auto max-w-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GreywaterPage() {
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<SubPageHeader
|
||||
title="빗물이용시설(현황)"
|
||||
crumbs={[
|
||||
{ label: "Home", href: "/reuse" },
|
||||
{ label: "물 재이용 현황", href: "/reuse/cases/rain" },
|
||||
{ label: "빗물이용시설(현황)" },
|
||||
]}
|
||||
hideFooterLine
|
||||
hideGuideText
|
||||
wrapClassName="!pb-0"
|
||||
/>
|
||||
|
||||
<Hr className="my-7" />
|
||||
|
||||
{/* 1) 빗물이용시설의 건축물별 빗물이용량 현황 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>빗물이용시설의 건축물별 빗물이용량 현황</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/cases/rainwater_building.png" alt="빗물이용시설의 건축물별 빗물이용량 현황 설명" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 2) 빗물이용시설의 용도별 분포 (2023년 기준) */}
|
||||
<section>
|
||||
<SectionTitle>빗물이용시설의 용도별 분포 (2023년 기준)</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/cases/rainwater_usage.png" alt="빗물이용시설의 용도별 분포 설명" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 3) 빗물이용시설의 용도별 빗물이용량 현황 */}
|
||||
<section className="pb-[200px]">
|
||||
<SectionTitle>빗물이용시설의 용도별 빗물이용량 현황</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/cases/rainwater_usage_amount.png" alt="빗물이용시설의 용도별 빗물이용량 현황 설명" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/cases/treated/page.tsx
|
||||
"use client";
|
||||
|
||||
import SubPageHeader from "../../_components/SubPageHeader";
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-5 w-[3px] bg-sky-500" />
|
||||
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HrProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Hr({ className = "" }: HrProps) {
|
||||
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
|
||||
}
|
||||
|
||||
type ImgCardProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
function ImgCard({ src, alt }: ImgCardProps) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="block mx-auto max-w-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GreywaterPage() {
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<SubPageHeader
|
||||
title="하수처리수 재이용시설(현황)"
|
||||
crumbs={[
|
||||
{ label: "Home", href: "/reuse" },
|
||||
{ label: "물 재이용 현황", href: "/reuse/cases/rain" },
|
||||
{ label: "하수처리수 재이용시설(현황)" },
|
||||
]}
|
||||
hideFooterLine
|
||||
hideGuideText
|
||||
wrapClassName="!pb-0"
|
||||
/>
|
||||
|
||||
<Hr className="my-7" />
|
||||
|
||||
{/* 1) 연도별 하수처리수 재이용 현황 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>연도별 하수처리수 재이용 현황</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/cases/treated_water_year.png" alt="연도별 하수처리수 재이용 현황 설명" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 2) 용도별 하수처리수 재이용 현황 */}
|
||||
<section>
|
||||
<SectionTitle>용도별 하수처리수 재이용 현황</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/cases/treated_water_usage.png" alt="용도별 하수처리수 재이용 현황 설명" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 3) 하수처리시설 설치 및 이용 현황 */}
|
||||
<section>
|
||||
<SectionTitle>하수처리시설 설치 및 이용 현황</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/cases/treated_water_facility.png" alt="하수처리시설 설치 및 이용 현황 설명" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 4) 시도별 하수처리수 재이용 현황 */}
|
||||
<section className="pb-[200px]">
|
||||
<SectionTitle>시도별 하수처리수 재이용 현황</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/cases/treated_water_region.png" alt="시도별 하수처리수 재이용 현황 설명" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/intro/greywater/page.tsx
|
||||
"use client";
|
||||
|
||||
import SubPageHeader from "../../_components/SubPageHeader";
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-5 w-[3px] bg-sky-500" />
|
||||
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HrProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Hr({ className = "" }: HrProps) {
|
||||
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
|
||||
}
|
||||
|
||||
type ImgCardProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
function ImgCard({ src, alt }: ImgCardProps) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="block mx-auto max-w-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GreywaterPage() {
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<SubPageHeader
|
||||
title="중수도"
|
||||
crumbs={[
|
||||
{ label: "Home", href: "/reuse" },
|
||||
{ label: "물 재이용 소개", href: "/reuse/intro/law" },
|
||||
{ label: "중수도" },
|
||||
]}
|
||||
hideFooterLine
|
||||
hideGuideText
|
||||
wrapClassName="!pb-0"
|
||||
/>
|
||||
|
||||
<Hr className="my-7" />
|
||||
|
||||
{/* 1) 중수 재이용 소개 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>중수 재이용 소개</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/intro/greywater_intro.png" alt="중수 재이용 소개" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 2) 중수도 설치 의무 대상 */}
|
||||
<section>
|
||||
<SectionTitle>중수도 설치 의무 대상</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/intro/greywater_mandatory.png" alt="중수도 설치 의무 대상 설명" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 3) 중수도 시설기준 및 관리기준 */}
|
||||
<section className="pb-[200px]">
|
||||
<SectionTitle>중수도 시설기준 및 관리기준</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/intro/greywater_standard.png" alt="중수도 시설기준 및 관리기준 설명" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/intro/law/page.tsx
|
||||
"use client";
|
||||
|
||||
import SubPageHeader from "../../_components/SubPageHeader";
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-5 w-[3px] bg-sky-500" />
|
||||
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HrProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Hr({ className = "" }: HrProps) {
|
||||
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
|
||||
}
|
||||
|
||||
type ImgCardProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
function ImgCard({ src, alt }: ImgCardProps) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="block mx-auto max-w-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WaterReuseLawPage() {
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<SubPageHeader
|
||||
title="물 재이용 법"
|
||||
crumbs={[
|
||||
{ label: "Home", href: "/reuse" },
|
||||
{ label: "물 재이용 소개", href: "/reuse/intro/law" },
|
||||
{ label: "물 재이용 법" },
|
||||
]}
|
||||
hideFooterLine
|
||||
hideGuideText
|
||||
wrapClassName="!pb-0"
|
||||
/>
|
||||
|
||||
<Hr className="my-7" />
|
||||
|
||||
{/* 1) 법제도/계획 수립 타임라인 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>법제도 · 계획 및 기본계획 수립</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/intro/law_timeline.png" alt="법제도 타임라인" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 2) 제2차 계획 대비 실적 현황 (2컬럼) */}
|
||||
<section>
|
||||
<SectionTitle>제 2차 계획 대비 실적 현황</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/intro/performance_left.png" alt="실적 현황 요약" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 3) 국내 관련 현황 */}
|
||||
<section>
|
||||
<SectionTitle>국내 관련 현황</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/intro/domestic_textbox.png" alt="국내 관련 현황 설명" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 4) 정책 변천 추이 */}
|
||||
<section>
|
||||
<SectionTitle>물 재이용 정책 변천 추이</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/intro/policy_timeline.png" alt="정책 변천 타임라인" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 5) 타 법령과의 관계도 */}
|
||||
<section>
|
||||
<SectionTitle>타법령과의 관계도</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/intro/rel_note.png" alt="관계도 설명" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 6) 국내 연계 현황 */}
|
||||
<section className="pb-[200px]">
|
||||
<SectionTitle>국내 연계 현황</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<div className="mx-[20px] mb-8">
|
||||
<p className="text-[24px] font-semibold mb-2">주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요</p>
|
||||
<p className="text-[#4F4F4F]">국가 상위계획의 정책 방향이 기후위기 대응 등 수자원의 공급 안전성을 위한 물 재이용 활성화로 정책 실효성 제고 필요<br />
|
||||
• 물관리 계획(국가수도기본계획, 국가 및 유역하수도종합계획, 하천유역수자원관리계획 등)과의 연계성 확보 → 통합적인 관점에서 물이용 체계 구축 필요
|
||||
</p>
|
||||
</div>
|
||||
<ImgCard src="/images/landing/intro/linkage.png" alt="국내 연계 현황" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/intro/layout.tsx
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import Lnb from "../_components/Lnb";
|
||||
import { LNB_INTRO } from "../_constants/lnb";
|
||||
|
||||
export default function IntroLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-0">
|
||||
<Lnb title="소개" items={LNB_INTRO} />
|
||||
<main className="flex-1 px-[100px] pt-[80px]">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/intro/law/page.tsx
|
||||
"use client";
|
||||
|
||||
import SubPageHeader from "../../_components/SubPageHeader";
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-5 w-[3px] bg-sky-500" />
|
||||
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HrProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Hr({ className = "" }: HrProps) {
|
||||
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
|
||||
}
|
||||
|
||||
type ImgCardProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
function ImgCard({ src, alt }: ImgCardProps) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="block mx-auto max-w-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RainwaterReusePage() {
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<SubPageHeader
|
||||
title="빗물 이용시설"
|
||||
crumbs={[
|
||||
{ label: "Home", href: "/reuse" },
|
||||
{ label: "물 재이용 소개", href: "/reuse/intro/law" },
|
||||
{ label: "빗물 이용시설" },
|
||||
]}
|
||||
hideFooterLine
|
||||
hideGuideText
|
||||
wrapClassName="!pb-0"
|
||||
/>
|
||||
|
||||
<Hr className="my-7" />
|
||||
|
||||
{/* 1) 빗물 재이용 소개 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>빗물 재이용 소개</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/intro/rainreuse_intro.png" alt="빗물 재이용 소개" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 2) 빗물이용시설 설치 관리 법적 의무 대상 */}
|
||||
<section className="pb-[200px]">
|
||||
<SectionTitle>빗물이용시설 설치 관리 법적 의무 대상</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/intro/rainreuse_mandatory.png" alt="법적 의무 대상 설명" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
// apps/dm/app/(landing)/reuse/intro/treated/page.tsx
|
||||
"use client";
|
||||
|
||||
import SubPageHeader from "../../_components/SubPageHeader";
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-5 w-[3px] bg-sky-500" />
|
||||
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HrProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Hr({ className = "" }: HrProps) {
|
||||
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
|
||||
}
|
||||
|
||||
type ImgCardProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
function ImgCard({ src, alt }: ImgCardProps) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="block mx-auto max-w-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TreatedWaterPage() {
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<SubPageHeader
|
||||
title="하수처리수"
|
||||
crumbs={[
|
||||
{ label: "Home", href: "/reuse" },
|
||||
{ label: "물 재이용 소개", href: "/reuse/intro/law" },
|
||||
{ label: "하수처리수" },
|
||||
]}
|
||||
hideFooterLine
|
||||
hideGuideText
|
||||
wrapClassName="!pb-0"
|
||||
/>
|
||||
|
||||
<Hr className="my-7" />
|
||||
|
||||
{/* 1) 하수처리수 */}
|
||||
<section className="mt-6">
|
||||
<SectionTitle>하수처리수</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/intro/treatedwater_intro.png" alt="하수처리수 설명" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Hr className="my-11" />
|
||||
|
||||
{/* 2) 하수처리수 재이용시설 의무 대상 */}
|
||||
<section className="pb-[200px]">
|
||||
<SectionTitle>하수처리수 재이용시설 의무 대상</SectionTitle>
|
||||
<div className="mt-8">
|
||||
<ImgCard src="/images/landing/intro/treatedwater_mandatory.png" alt="하수처리수 재이용시설 의무 대상 설명" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// 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 높이만큼 */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// 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: "물 재이용 소개", // ✅ 상단 메뉴용
|
||||
sideLabel: "소개", // ✅ 사이드 인디게이터용
|
||||
},
|
||||
{
|
||||
id: "cases",
|
||||
label: "물 재이용 현황", // ✅ 상단 메뉴용
|
||||
sideLabel: "현황", // ✅ 사이드 인디게이터용
|
||||
},
|
||||
{
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
/* 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,5 +1,4 @@
|
||||
/* @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;
|
||||
@@ -69,15 +68,3 @@
|
||||
@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,5 +1,4 @@
|
||||
import type { NextConfig } from "next";
|
||||
import path from "path";
|
||||
|
||||
const subDomain =
|
||||
process.env.NEXT_PUBLIC_PROXY_HOST || "http://localhost:3000";
|
||||
@@ -9,17 +8,6 @@ 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: [
|
||||
|
||||
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 257 KiB |
|
Before Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 325 KiB |
|
Before Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 418 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 343 KiB |
|
Before Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 482 KiB |
|
Before Width: | Height: | Size: 348 KiB |
|
Before Width: | Height: | Size: 344 KiB |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 475 B |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 71 KiB |