diff --git a/.gitignore b/.gitignore index 80cd476eb..c5082bba3 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,4 @@ settings.json .npmrc .cursor -apps/**/tsconfig.tsbuildinfo \ No newline at end of file +apps/**/tsconfig.tsbuildinfo diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 000000000..82c73e102 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,3 @@ +#!/bin/sh +BRANCH=$(git rev-parse --abbrev-ref HEAD) +pnpm turbo run build --filter="...[origin/$BRANCH]" diff --git a/apps/_template/.env.example b/apps/_template/.env.example new file mode 100644 index 000000000..b2d706024 --- /dev/null +++ b/apps/_template/.env.example @@ -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= diff --git a/apps/_template/.eslintrc.json b/apps/_template/.eslintrc.json new file mode 100644 index 000000000..ea782d70a --- /dev/null +++ b/apps/_template/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/apps/_template/.gitignore b/apps/_template/.gitignore new file mode 100644 index 000000000..fd3dbb571 --- /dev/null +++ b/apps/_template/.gitignore @@ -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 diff --git a/apps/_template/.prettierrc b/apps/_template/.prettierrc new file mode 100644 index 000000000..059b4e33c --- /dev/null +++ b/apps/_template/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "trailingComma": "all", + "singleQuote": false, + "printWidth": 100, + "tabWidth": 2, + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/apps/_template/README.md b/apps/_template/README.md new file mode 100644 index 000000000..7e14528f8 --- /dev/null +++ b/apps/_template/README.md @@ -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 +``` diff --git a/apps/_template/app/(with-base-layout)/layout.tsx b/apps/_template/app/(with-base-layout)/layout.tsx new file mode 100644 index 000000000..1d182a0ee --- /dev/null +++ b/apps/_template/app/(with-base-layout)/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/apps/_template/app/(with-base-layout)/page.tsx b/apps/_template/app/(with-base-layout)/page.tsx new file mode 100644 index 000000000..e118e732e --- /dev/null +++ b/apps/_template/app/(with-base-layout)/page.tsx @@ -0,0 +1,9 @@ +"use client"; + +export default function MainPage() { + return ( +
+

Home

+
+ ); +} diff --git a/apps/_template/app/README.md b/apps/_template/app/README.md new file mode 100644 index 000000000..5ab63ea34 --- /dev/null +++ b/apps/_template/app/README.md @@ -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 ( + + + {children} + + + ); +} + +// app/layout.tsx +import { Providers } from './providers'; +import './globals.css'; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + {children} + + + + ); +} +``` + +### 글로벌 설정 + +```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`로 분리 + +이 접근 방식은 과도한 추상화를 방지하고 코드를 더 쉽게 탐색할 수 있게 합니다. diff --git a/apps/_template/app/globals.css b/apps/_template/app/globals.css new file mode 100644 index 000000000..60bc92fb6 --- /dev/null +++ b/apps/_template/app/globals.css @@ -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; +} diff --git a/apps/_template/app/layout.tsx b/apps/_template/app/layout.tsx new file mode 100644 index 000000000..d152e0da3 --- /dev/null +++ b/apps/_template/app/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/apps/_template/app/providers.tsx b/apps/_template/app/providers.tsx new file mode 100644 index 000000000..bc12c443b --- /dev/null +++ b/apps/_template/app/providers.tsx @@ -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 ( + + {children} + + + ); +} diff --git a/apps/_template/entities/README.md b/apps/_template/entities/README.md new file mode 100644 index 000000000..b28c10790 --- /dev/null +++ b/apps/_template/entities/README.md @@ -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 { + 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 ( +
+

{user.name}

+

{user.email}

+
+ ); +} + +// 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; +} + +// 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. **데이터 관리에 집중**: 복잡한 비즈니스 로직보다는 데이터 모델, 검증, 저장에 집중 diff --git a/apps/_template/entities/menu/index.ts b/apps/_template/entities/menu/index.ts new file mode 100644 index 000000000..cb55d9a1c --- /dev/null +++ b/apps/_template/entities/menu/index.ts @@ -0,0 +1 @@ +export { type MenuItem } from "./model/types"; diff --git a/apps/_template/entities/menu/model/types.ts b/apps/_template/entities/menu/model/types.ts new file mode 100644 index 000000000..9692821e3 --- /dev/null +++ b/apps/_template/entities/menu/model/types.ts @@ -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; +} diff --git a/apps/_template/eslint.config.mjs b/apps/_template/eslint.config.mjs new file mode 100644 index 000000000..05e726d1b --- /dev/null +++ b/apps/_template/eslint.config.mjs @@ -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; diff --git a/apps/_template/features/README.md b/apps/_template/features/README.md new file mode 100644 index 000000000..2be9a3e17 --- /dev/null +++ b/apps/_template/features/README.md @@ -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({ + email: '', + password: '' + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await loginUser(credentials); + }; + + return ( +
+ setCredentials({ ...credentials, email: e.target.value })} + /> + setCredentials({ ...credentials, password: e.target.value })} + /> + +
+ ); +} + +// 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를 만들지 말고, 중요한 기능 위주로 구성 diff --git a/apps/_template/next.config.ts b/apps/_template/next.config.ts new file mode 100644 index 000000000..8864de0e7 --- /dev/null +++ b/apps/_template/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + reactStrictMode: true, + transpilePackages: ["@common/assets"], +}; + +export default nextConfig; diff --git a/apps/_template/package.json b/apps/_template/package.json new file mode 100644 index 000000000..fb27f8af7 --- /dev/null +++ b/apps/_template/package.json @@ -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" + } +} \ No newline at end of file diff --git a/apps/_template/postcss.config.mjs b/apps/_template/postcss.config.mjs new file mode 100644 index 000000000..1a69fd2a4 --- /dev/null +++ b/apps/_template/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config; diff --git a/apps/_template/shared/README.md b/apps/_template/shared/README.md new file mode 100644 index 000000000..2b247021d --- /dev/null +++ b/apps/_template/shared/README.md @@ -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 ( + + ); +} + +// 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로 사용법과 목적을 문서화 diff --git a/apps/_template/shared/api/client.ts b/apps/_template/shared/api/client.ts new file mode 100644 index 000000000..d7c4a8f1d --- /dev/null +++ b/apps/_template/shared/api/client.ts @@ -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, +}); diff --git a/apps/_template/shared/api/index.ts b/apps/_template/shared/api/index.ts new file mode 100644 index 000000000..c5eb98066 --- /dev/null +++ b/apps/_template/shared/api/index.ts @@ -0,0 +1 @@ +export { apiClient } from "./client"; diff --git a/apps/_template/shared/config/env.ts b/apps/_template/shared/config/env.ts new file mode 100644 index 000000000..4f08c722a --- /dev/null +++ b/apps/_template/shared/config/env.ts @@ -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"; diff --git a/apps/_template/shared/config/index.ts b/apps/_template/shared/config/index.ts new file mode 100644 index 000000000..c76f8cd47 --- /dev/null +++ b/apps/_template/shared/config/index.ts @@ -0,0 +1 @@ +export * from "./env"; diff --git a/apps/_template/shared/lib/dayjs.ts b/apps/_template/shared/lib/dayjs.ts new file mode 100644 index 000000000..28b3f908b --- /dev/null +++ b/apps/_template/shared/lib/dayjs.ts @@ -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 }; diff --git a/apps/_template/shared/lib/index.ts b/apps/_template/shared/lib/index.ts new file mode 100644 index 000000000..51e9c2c5d --- /dev/null +++ b/apps/_template/shared/lib/index.ts @@ -0,0 +1,2 @@ +export { queryClient } from "./query-client"; +export { dayjs } from "./dayjs"; diff --git a/apps/_template/shared/lib/query-client.ts b/apps/_template/shared/lib/query-client.ts new file mode 100644 index 000000000..0da2fb899 --- /dev/null +++ b/apps/_template/shared/lib/query-client.ts @@ -0,0 +1,11 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + staleTime: 60 * 1000, + }, + }, +}); diff --git a/apps/_template/shared/model/index.ts b/apps/_template/shared/model/index.ts new file mode 100644 index 000000000..15ddf4254 --- /dev/null +++ b/apps/_template/shared/model/index.ts @@ -0,0 +1,29 @@ +export interface BaseEntity { + id: string; + createdAt: string; + updatedAt: string; +} + +export interface PaginationParams { + page: number; + limit: number; +} + +export interface PaginationResponse { + data: T[]; + total: number; + page: number; + limit: number; +} + +export interface ApiResponse { + success: boolean; + data: T; + message?: string; +} + +export interface ApiError { + message: string; + code?: string; + status?: number; +} diff --git a/apps/_template/shared/ui/index.ts b/apps/_template/shared/ui/index.ts new file mode 100644 index 000000000..460542995 --- /dev/null +++ b/apps/_template/shared/ui/index.ts @@ -0,0 +1 @@ +export { PageLayout } from "./page-layout"; diff --git a/apps/_template/shared/ui/page-layout.tsx b/apps/_template/shared/ui/page-layout.tsx new file mode 100644 index 000000000..ecefc36bd --- /dev/null +++ b/apps/_template/shared/ui/page-layout.tsx @@ -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 ( +
+ + {children} +
+ ); +} + +export { PageLayout }; diff --git a/apps/_template/tailwind.config.ts b/apps/_template/tailwind.config.ts new file mode 100644 index 000000000..c02405aca --- /dev/null +++ b/apps/_template/tailwind.config.ts @@ -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; diff --git a/apps/_template/tsconfig.json b/apps/_template/tsconfig.json new file mode 100644 index 000000000..15b023415 --- /dev/null +++ b/apps/_template/tsconfig.json @@ -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"] +} diff --git a/apps/_template/widgets/README.md b/apps/_template/widgets/README.md new file mode 100644 index 000000000..cd160abd9 --- /dev/null +++ b/apps/_template/widgets/README.md @@ -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 ( +
+ + + + +
+ ); +} + +// 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
로딩 중...
; + + return ( +
+ {products.map((product) => ( +
+ + +
+ ))} +
+ ); +} + +// 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. **재사용 최적화**: 여러 페이지에서 동일한 방식으로 사용될 수 있도록 설계 diff --git a/apps/as/next-env.d.ts b/apps/as/next-env.d.ts new file mode 100644 index 000000000..1b3be0840 --- /dev/null +++ b/apps/as/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/cs/src/entities/link/model/link-interface.ts b/apps/cs/src/entities/link/model/link-interface.ts index 244bb4c63..67cd75807 100644 --- a/apps/cs/src/entities/link/model/link-interface.ts +++ b/apps/cs/src/entities/link/model/link-interface.ts @@ -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) => { diff --git a/apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx b/apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx deleted file mode 100644 index 44f790366..000000000 --- a/apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx +++ /dev/null @@ -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 = { - 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(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(null); - const clipRef = useRef(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("rain"); - - // 탭별 마지막 그룹 인덱스 기억 - const tabIndexRef = useRef>({ - 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) => { - // 자식 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 ( -
- {/* 탭 바 */} -
-
-
- {TABS.map((t) => { - const isActive = t.key === active; - - return ( - - ); - })} -
-
-
- - {/* 슬라이더 */} -
-
- {/* 좌우 버튼 */} - - - - - {/* 중요: clip(가림)과 measure(측정) 분리 */} -
-
- {/* 측정은 패딩 없는 박스에서 */} -
- {/* track */} -
- {viewGroups.map((group, gi) => ( -
- {/* 그룹 내부: perPage 컬럼 */} -
- {(group ?? []).map((c) => ( - - ))} -
-
- ))} -
-
-
-
- - {/* 도트 */} -
- {Array.from({ length: Math.max(groupCount, 1) }).map((_, i) => ( -
-
-
-
- ); -} diff --git a/apps/dm/app/(landing)/reuse/_components/ImageSlider.tsx b/apps/dm/app/(landing)/reuse/_components/ImageSlider.tsx deleted file mode 100644 index 6c50d5a5c..000000000 --- a/apps/dm/app/(landing)/reuse/_components/ImageSlider.tsx +++ /dev/null @@ -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 ( -
-
-
- {/* 좌측 버튼 */} - - - - {/* 우측 버튼 */} - - - - {/* 뷰포트 */} -
-
- {safeSlides.map((s, i) => ( -
- {s.alt -
- ))} -
-
-
- - {/* 도트 */} - {canSlide && ( -
- {safeSlides.map((_, i) => { - const active = i === idx; - return ( -
- )} -
-
- ); -} diff --git a/apps/dm/app/(landing)/reuse/_components/Lnb.tsx b/apps/dm/app/(landing)/reuse/_components/Lnb.tsx deleted file mode 100644 index c7d834844..000000000 --- a/apps/dm/app/(landing)/reuse/_components/Lnb.tsx +++ /dev/null @@ -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(defaultOpenLabel); - - return ( - - ); -} diff --git a/apps/dm/app/(landing)/reuse/_components/LoginStatus.tsx b/apps/dm/app/(landing)/reuse/_components/LoginStatus.tsx deleted file mode 100644 index 4ddc08812..000000000 --- a/apps/dm/app/(landing)/reuse/_components/LoginStatus.tsx +++ /dev/null @@ -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(null); - - useEffect(() => { - // 예시: 로그인 시 저장한 값 - const name = localStorage.getItem("userName"); - setUserName(name); - }, []); - - const handleLogout = () => { - localStorage.removeItem("accessToken"); - localStorage.removeItem("userName"); - router.refresh(); // 헤더 상태 갱신 - }; - - // 🔹 로그인 전 - if (!userName) { - return ( - - 로그인 - → - - ); - } - - // 🔹 로그인 후 - return ( -
- - {userName}님 환영합니다. - - -
- ); -} diff --git a/apps/dm/app/(landing)/reuse/_components/SideIndicator.tsx b/apps/dm/app/(landing)/reuse/_components/SideIndicator.tsx deleted file mode 100644 index 8f4cea164..000000000 --- a/apps/dm/app/(landing)/reuse/_components/SideIndicator.tsx +++ /dev/null @@ -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(null); - const clearTimerRef = useRef(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 ( - - ); -} diff --git a/apps/dm/app/(landing)/reuse/_components/SubPageHeader.tsx b/apps/dm/app/(landing)/reuse/_components/SubPageHeader.tsx deleted file mode 100644 index fc3bade23..000000000 --- a/apps/dm/app/(landing)/reuse/_components/SubPageHeader.tsx +++ /dev/null @@ -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 ( -
-
-
- {/* 좌측 타이틀 */} -
-

{title}

-
-
- - {/* 우측 브레드크럼 */} - -
- {/* 하단 라인 + 안내문 */} - {!hideGuideText && ( -
- * - {note.replace("*", "")} -
- )} -
-
- ); -} diff --git a/apps/dm/app/(landing)/reuse/_components/TopNav.tsx b/apps/dm/app/(landing)/reuse/_components/TopNav.tsx deleted file mode 100644 index 078d5e0d4..000000000 --- a/apps/dm/app/(landing)/reuse/_components/TopNav.tsx +++ /dev/null @@ -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>>({}); - const [touched, setTouched] = useState>>({}); - 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> = {}; - - 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 ( -
- {/* Backdrop */} - - -

- 수요처 로그인 -

- - {/* title underline gradient bar */} -
-
- - {/* Body */} -
-
- {/* 사업자 등록번호 */} -
- - { - 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 && ( -

- {errors.bizNo} -

- )} -
- - {/* 종사자등록번호 + 해당없음 */} -
-
- - - -
- - { - 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 && ( -

- {errors.workerNo} -

- )} -
- - {/* 비밀번호 */} -
- - { - 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 && ( -

- {errors.pw} -

- )} -
- - {/* 로그인 버튼 */} - - - {/* 비밀번호 초기화 요청 */} - - - {/* 하단 안내 박스 + 수요처등록 버튼 */} -
-
-
- 수요처를 등록하지 않았다면 -
-
- 먼저 수요처등록을 해주세요. -
-
- - -
-
-
-
- - - ); -} - -export default function TopNav() { - const router = useRouter(); - - const [loginOpen, setLoginOpen] = useState(false); - const [userName, setUserName] = useState(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 ( - <> -
- {/* ✅ 좌/중/우 정렬이 되도록 컨테이너 구성 */} -
-
- {/* 좌측: 로고 */} -
- - HASUON - -
- - {/* 중앙: 상단 메뉴 */} - - - {/* ✅ 우측: 로그인 버튼 / 로그인 정보 + 로그아웃 */} -
- {!userName ? ( - - ) : ( - <> - - {userName}님 환영합니다. - - - - )} -
-
-
-
- - {/* ✅ 로그인 모달 */} - 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(); - }} - /> - - ); -} diff --git a/apps/dm/app/(landing)/reuse/_constants/lnb.ts b/apps/dm/app/(landing)/reuse/_constants/lnb.ts deleted file mode 100644 index 49c9ae1ae..000000000 --- a/apps/dm/app/(landing)/reuse/_constants/lnb.ts +++ /dev/null @@ -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" } - ], - }, -]; diff --git a/apps/dm/app/(landing)/reuse/_hooks/useScrollSpy.ts b/apps/dm/app/(landing)/reuse/_hooks/useScrollSpy.ts deleted file mode 100644 index 3a253e95d..000000000 --- a/apps/dm/app/(landing)/reuse/_hooks/useScrollSpy.ts +++ /dev/null @@ -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>(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; -} diff --git a/apps/dm/app/(landing)/reuse/_sections/Cases.tsx b/apps/dm/app/(landing)/reuse/_sections/Cases.tsx deleted file mode 100644 index 077d05cfe..000000000 --- a/apps/dm/app/(landing)/reuse/_sections/Cases.tsx +++ /dev/null @@ -1,65 +0,0 @@ -// apps/dm/app/(landing)/reuse/_sections/Cases.tsx -"use client"; - -export default function Cases() { - return ( -
-
-
- - {/* 좌측 : 카피 영역 */} -
-
-

- 빗물+중수도+하수도 -

- -

- - 데이터로 보는 - - - 물 재이용 현황 - -

- - -
-
- - {/* 우측 : 이미지 카드 */} -
-
- 물 재이용 현황 데이터 테이블 -
-
- -
-
-
- ); -} \ No newline at end of file diff --git a/apps/dm/app/(landing)/reuse/_sections/Hero.tsx b/apps/dm/app/(landing)/reuse/_sections/Hero.tsx deleted file mode 100644 index 8f93bdd72..000000000 --- a/apps/dm/app/(landing)/reuse/_sections/Hero.tsx +++ /dev/null @@ -1,29 +0,0 @@ -// apps/dm/app/(landing)/reuse/_sections/Hero.tsx -import styles from "../reuse.module.css"; - -export default function Hero() { - return ( -
-
-
- -
-
-

물 재이용

-

- 빗물, 오수, 하수처리수, 폐수처리수, 발전소 온배수를 재이용처리하여
- 생활, 공업, 농업, 조경, 하천유지 등의 용도로 재이용합니다. -

-

- 전국 하수 재이용률 15.6%(2024년 통계 기준),
- 물재이용 활성화를 위한 재이용 수요신청, 매칭을 추진하고 있습니다. -

-
-
-
- - scroll down -
-
- ); -} \ No newline at end of file diff --git a/apps/dm/app/(landing)/reuse/_sections/IntroCards.tsx b/apps/dm/app/(landing)/reuse/_sections/IntroCards.tsx deleted file mode 100644 index 070d9ba91..000000000 --- a/apps/dm/app/(landing)/reuse/_sections/IntroCards.tsx +++ /dev/null @@ -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 ( -
-
-
-
-

물 재이용 소개

-
-
-

- 지속 가능한 미래를 위한 약속, 깨끗한 물의 새로운 순환을 시작합니다. -

-
- -
-
- {INTRO_CARDS.map((card, idx) => ( - - {/* 배경 이미지 (이미 그라디언트 포함) */} - {card.title} - - {/* 텍스트 영역 */} -
-

- {card.title} -

-

- {card.desc} -

-
- - ))} -
-
-
-
- ); -} \ No newline at end of file diff --git a/apps/dm/app/(landing)/reuse/_sections/ProcessFlow.tsx b/apps/dm/app/(landing)/reuse/_sections/ProcessFlow.tsx deleted file mode 100644 index 761a79201..000000000 --- a/apps/dm/app/(landing)/reuse/_sections/ProcessFlow.tsx +++ /dev/null @@ -1,22 +0,0 @@ -// apps/dm/app/(landing)/reuse/_sections/ProcessFlow.tsx -import CaseTabsCarousel from "../_components/CaseTabsCarousel"; - - -export default function ProcessFlow() { - return ( -
-
-
-
-

물 재이용 사례

-
-
-
- - {/* 탭메뉴 영역 */} - - -
-
- ); -} \ No newline at end of file diff --git a/apps/dm/app/(landing)/reuse/apply/layout.tsx b/apps/dm/app/(landing)/reuse/apply/layout.tsx deleted file mode 100644 index e9e7e5cb0..000000000 --- a/apps/dm/app/(landing)/reuse/apply/layout.tsx +++ /dev/null @@ -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 ( -
- -
{children}
-
- ); -} diff --git a/apps/dm/app/(landing)/reuse/apply/modal/WaterQualityRefModal.tsx b/apps/dm/app/(landing)/reuse/apply/modal/WaterQualityRefModal.tsx deleted file mode 100644 index 877eb7d8c..000000000 --- a/apps/dm/app/(landing)/reuse/apply/modal/WaterQualityRefModal.tsx +++ /dev/null @@ -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 ( -
- {/* Backdrop */} - - -

- {title} -

- -
-
- - {/* Body (스크롤 영역) */} -
-
- {/* 이미지가 길어도 여기서 스크롤 */} - 수질 기준 참고 -
-
-
- - - ); -} diff --git a/apps/dm/app/(landing)/reuse/apply/place/page.tsx b/apps/dm/app/(landing)/reuse/apply/place/page.tsx deleted file mode 100644 index 2deb0334e..000000000 --- a/apps/dm/app/(landing)/reuse/apply/place/page.tsx +++ /dev/null @@ -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 = { - "": [{ 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
; -} - -function FormRow({ - label, - required, - children, -}: { - label: React.ReactNode; - required?: boolean; - children: React.ReactNode; -}) { - return ( -
- {/* 라벨(좌측) */} -
-
- {label} - {required ? * : null} -
-
- - {/* 입력(우측) */} -
- {children} -
-
- ); -} - -function TextInput({ - value, - onChange, - placeholder, - type = "text", - disabled, - className = "", -}: { - value: string; - onChange: (v: string) => void; - placeholder?: string; - type?: string; - disabled?: boolean; - className?: string; -}) { - return ( - onChange(e.target.value)} - placeholder={placeholder} - className={[ - "h-11 w-full rounded border border-neutral-300 bg-white px-3 text-sm text-neutral-800", - "placeholder:text-neutral-400 focus:border-neutral-400 focus:outline-none", - disabled ? "bg-neutral-100 text-neutral-400" : "", - className, - ].join(" ")} - /> - ); -} - -function SelectBox({ - value, - onChange, - options, - className = "", -}: { - value: T; - onChange: (v: T) => void; - options: { value: T; label: string }[]; - className?: string; -}) { - return ( - - ); -} - -export default function DemandPlaceRegisterPage() { - const [sido, setSido] = useState(""); - const [sigungu, setSigungu] = useState(""); - - const [company, setCompany] = useState(""); - const [bizNo, setBizNo] = useState(""); - const [workerNo, setWorkerNo] = useState(""); - const [noWorkerNo, setNoWorkerNo] = useState(false); - - const [zip, setZip] = useState(""); - const [addr1, setAddr1] = useState(""); - const [addr2, setAddr2] = useState(""); - - const [tel, setTel] = useState(""); - const [pw, setPw] = useState(""); - const [pw2, setPw2] = useState(""); - - // ✅ undefined 불가(Record + sido가 SidoValue) - const sigunguOptions = useMemo(() => 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 ( -
- - - {/* register/page.tsx와 동일한 테이블 룩앤필 */} -
- - - value={sido} - onChange={(v) => { - setSido(v); - setSigungu(""); // 시도 변경 시 초기화 - }} - options={SIDO} - /> - - - - - - - - - - - -
- -
-
- - - - -
- -
-
- - - - -
-
- -
- - -
-
- - - - -
-
-
- -
- -
- - - -
-
- - - - -
- -
-
- - - - -
- -
-
- - - - -
- -
-
-
- - {/* register/page.tsx와 동일하게 하단 중앙 버튼 */} -
- -
-
- ); -} diff --git a/apps/dm/app/(landing)/reuse/apply/register/page.tsx b/apps/dm/app/(landing)/reuse/apply/register/page.tsx deleted file mode 100644 index 07a8bf244..000000000 --- a/apps/dm/app/(landing)/reuse/apply/register/page.tsx +++ /dev/null @@ -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(today); - const [reqQty, setReqQty] = useState(""); - const [usePurpose, setUsePurpose] = useState(""); - const [hopePrice, setHopePrice] = useState(""); - - // 요구수질 - const [coliform, setColiform] = useState(""); - const [noDetect, setNoDetect] = useState(false); - - const [tn, setTn] = useState(""); - const [residualCl, setResidualCl] = useState(""); - const [tp, setTp] = useState(""); - const [turbidity, setTurbidity] = useState(""); - const [ph, setPh] = useState({ min: "", max: "" }); - const [bod, setBod] = useState(""); - const [chloride, setChloride] = useState(""); - const [odor, setOdor] = useState("불쾌하지 않을 것"); - const [ec, setEc] = useState(""); - const [color, setColor] = useState(""); - const [remark, setRemark] = useState(""); - - // 공급처 후보지 선택(필수아님) - const [cand1, setCand1] = useState(""); - const [cand2, setCand2] = useState(""); - const [cand3, setCand3] = useState(""); - - return ( - <> - -
- - {/* 서브 타이틀 + 브레드크럼 */} - - - {/* 컨텐츠 영역 */} -
- - {/* 기본정보 테이블 */} -
- - - - - - - - - - - - - - - - - - - - - -
- - {/* 요구수질 섹션 타이틀 바 */} -
-
- 요구수질 - - {/* 수질기준참고 모달 */} - setOpenRef(false)} - imageSrc="/images/landing/water_quality_ref_1.png" - /> -
-
- - {/* 요구수질 테이블 */} -
- {/* 1행 */} - - - -
- } - rightLabel="총질소(T-N)(mg/L)" - rightControl={ - - } - /> - - - {/* 2행 */} - } - rightLabel="총인(T-P)(mg/L)" - rightControl={ - - } - /> - - - {/* 3행 */} - - } - rightLabel="수소이온농도(pH)" - rightControl={ -
- setPh((p) => ({ ...p, min: v }))} - className="w-32" - /> - ~ - setPh((p) => ({ ...p, max: v }))} - className="w-32" - /> -
- } - /> - - - {/* 4행 */} - - 생물화학적산소요구량 -
- (BOD)(mg/L) - - } - leftRequired - leftControl={} - rightLabel="염화물(mgCL/L)" - rightControl={} - /> - - - {/* 5행 */} - 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" - > - - - - - } - rightLabel="전기전도도(µs/cm)" - rightControl={} - /> - - - {/* 6행 */} - } - rightLabel="비고" - rightControl={ -