diff --git a/apps/_template/.env.example b/apps/_template/.env.example index f9794a6f8..318a30511 100644 --- a/apps/_template/.env.example +++ b/apps/_template/.env.example @@ -1,8 +1,8 @@ -# apps/as/shared/config/env.ts 참고 +# src/shared/config/env.ts 참고 NEXT_PUBLIC_PORT= NEXT_PUBLIC_PROXY_HOST= NEXT_PUBLIC_SITE_ID= -NEXT_PUBLIC_SERVER_API_URL= +SERVER_API_URL= # 401 시 브라우저 리다이렉트 대상 NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL=/auth/login diff --git a/apps/_template/README.md b/apps/_template/README.md index 7e14528f8..c48c1534a 100644 --- a/apps/_template/README.md +++ b/apps/_template/README.md @@ -1,4 +1,4 @@ -# AS Application +# Template Application Next.js 애플리케이션 with FSD(Feature Sliced Design) 아키텍처 @@ -13,46 +13,40 @@ Next.js 애플리케이션 with FSD(Feature Sliced Design) 아키텍처 - **HTTP 클라이언트**: Axios - **날짜 처리**: Day.js -## 프로젝트 구조 (FSD) +## 프로젝트 구조 (FSD + Next.js App Router) -이 프로젝트는 **Feature-Sliced Design v2.1**을 따르며, 계층적 아키텍처로 구성되어 있습니다. +이 프로젝트는 **Feature-Sliced Design v2.1**과 **FSD 공식 Next.js 가이드**에 맞춰 구성되어 있습니다. Next.js `app/`은 라우트만 담당하고, 실제 레이어는 `src/` 아래에 있습니다. ``` -as/ -├── app/ # App 레이어 (Next.js App Router) -│ ├── (routes)/ # 라우트 구조 -│ ├── layout.tsx # 루트 레이아웃 -│ ├── page.tsx # 홈 페이지 -│ ├── globals.css # 글로벌 스타일 -│ └── providers.tsx # 전역 providers +_template/ +├── app/ # Next.js App Router (루트) — 라우트·레이아웃 진입점만 +│ ├── layout.tsx # 루트 레이아웃 (src/app에서 providers, styles import) +│ └── (with-base-layout)/ +│ ├── layout.tsx # BaseLayout 래퍼 (src/app/layouts에서 import) +│ └── page.tsx # re-export: export { MainPage as default } from '@/pages/home' │ -├── widgets/ # Widgets 레이어 -│ └── [widget-name]/ # 큰 독립적 UI 블록 (여러 페이지에서 재사용) -│ ├── ui/ -│ ├── model/ -│ └── index.ts +├── pages/ # 빈 폴더 + README (Next.js가 src/pages를 Pages Router로 쓰지 않도록) +│ └── README.md │ -├── 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/ # 라우트 상수 +└── src/ # FSD 레이어 (실제 코드) + ├── app/ # App 레이어 (슬라이스 없음, 세그먼트만) + │ ├── providers/ # 전역 Providers (QueryClient 등) + │ ├── styles/ # 글로벌 스타일 (globals.css) + │ └── layouts/ # 공통 레이아웃 컴포넌트 (BaseLayoutWrapper) + │ + ├── pages/ # Pages 레이어 (페이지 슬라이스) + │ └── home/ + │ ├── ui/ + │ └── index.ts # Public API + │ + ├── widgets/ # Widgets 레이어 + ├── features/ # Features 레이어 + ├── entities/ # Entities 레이어 + └── shared/ # Shared 레이어 + ├── ui/ + ├── api/ + ├── lib/ + └── config/ ``` ### 계층 구조 이해하기 @@ -86,10 +80,10 @@ shared/ ↓ 가장 낮은 책임, 의존성 없음 - **특징**: 슬라이스가 없고 세그먼트로만 구성 - **의존성**: 모든 하위 레이어 사용 가능 -### 2. Pages (app 디렉토리) +### 2. Pages (src/pages) - **목적**: 웹사이트나 애플리케이션을 구성하는 페이지/화면 - **내용**: 페이지 UI, 로딩 상태, 에러 바운더리, 데이터 fetching -- **특징**: 한 페이지당 한 슬라이스 (유사한 페이지는 그룹화 가능) +- **특징**: 한 페이지당 한 슬라이스 (유사한 페이지는 그룹화 가능). Next.js `app/`의 route 파일에서 re-export - **의존성**: `widgets`, `features`, `entities`, `shared` 사용 가능 > 💡 **FSD v2.1 권장사항**: "pages-first" 접근 방식을 따라, 재사용되지 않는 큰 UI 블록과 비즈니스 로직은 페이지 슬라이스에 유지하세요. @@ -118,29 +112,6 @@ shared/ ↓ 가장 낮은 책임, 의존성 없음 - **특징**: 비즈니스 로직 없음, 슬라이스가 없고 세그먼트로만 구성 - **의존성**: 외부 라이브러리만 사용 가능 (다른 레이어 불가) -## 레이어 간 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 @@ -174,16 +145,16 @@ NEXT_PUBLIC_API_URL=http://localhost:3000/api ```typescript // ✅ 올바른 import (하위 레이어) // features/auth-login/ui/form.tsx -import { User } from 'entities/user'; -import { Button } from 'shared/ui/button'; +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 { PostForm } from "features/post-create"; // 금지! // ❌ 잘못된 import (상위 레이어) // features/auth-login/ui/form.tsx -import { Header } from 'widgets/header'; // 금지! +import { Header } from "widgets/header"; // 금지! ``` ### 2. Public API 규칙 @@ -192,10 +163,10 @@ import { Header } from 'widgets/header'; // 금지! ```typescript // ✅ 올바른 import (Public API 사용) -import { LoginForm } from 'features/auth-login'; +import { LoginForm } from "features/auth-login"; // ❌ 잘못된 import (내부 구조 직접 접근) -import { LoginForm } from 'features/auth-login/ui/login-form'; +import { LoginForm } from "features/auth-login/ui/login-form"; ``` **Public API 작성 시 주의사항**: @@ -205,44 +176,12 @@ import { LoginForm } from 'features/auth-login/ui/login-form'; ```typescript // ❌ 나쁜 예시 -export * from './ui/Comment'; -export * from './model/comments'; +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 +export { Comment } from "./ui/comment"; +export { CommentForm } from "./ui/comment-form"; +export { useComments } from "./model/use-comments"; +export type { Comment, CommentFormData } from "./model/types"; ``` diff --git a/apps/_template/app/(with-base-layout)/layout.tsx b/apps/_template/app/(with-base-layout)/layout.tsx index 1d182a0ee..1ff36f8ec 100644 --- a/apps/_template/app/(with-base-layout)/layout.tsx +++ b/apps/_template/app/(with-base-layout)/layout.tsx @@ -1,31 +1,10 @@ -"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} - - ); -} +import type { ReactNode } from "react"; +import { BaseLayoutWrapper } from "@/app/layouts/base-layout"; + +interface WithBaseLayoutProps { + children: ReactNode; +} + +export default function WithBaseLayout({ children }: WithBaseLayoutProps) { + return {children}; +} diff --git a/apps/_template/app/(with-base-layout)/page.tsx b/apps/_template/app/(with-base-layout)/page.tsx index e118e732e..82a918b59 100644 --- a/apps/_template/app/(with-base-layout)/page.tsx +++ b/apps/_template/app/(with-base-layout)/page.tsx @@ -1,9 +1 @@ -"use client"; - -export default function MainPage() { - return ( -
-

Home

-
- ); -} +export { MainPage as default } from "@/pages/home"; diff --git a/apps/_template/app/layout.tsx b/apps/_template/app/layout.tsx index d152e0da3..a6eecfc27 100644 --- a/apps/_template/app/layout.tsx +++ b/apps/_template/app/layout.tsx @@ -1,14 +1,7 @@ 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", -}); +import { cookies } from "next/headers"; +import { Providers } from "@/app/providers"; +import "@/app/styles/globals.css"; export const metadata: Metadata = { title: "행정지원시스템", @@ -19,10 +12,12 @@ interface RootLayoutProps { children: React.ReactNode; } -export default function RootLayout({ children }: RootLayoutProps) { +export default async function RootLayout({ children }: RootLayoutProps) { + const theme = (await cookies()).get("site-theme-key"); + return ( - - + + {children} diff --git a/apps/_template/middleware.ts b/apps/_template/middleware.ts new file mode 100644 index 000000000..e6bbba40a --- /dev/null +++ b/apps/_template/middleware.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PORT, PROXY_HOST, SERVER_API_URL, SITE_ID } from "@/shared/config"; + +const serverApiUrl = process.env.SERVER_API_URL ?? "http://localhost:3000"; + +// CSP 헤더 설정 함수 (img-src만) +function addSecurityHeaders(response: NextResponse): NextResponse { + response.headers.set("Content-Security-Policy", "img-src 'self' data: https: blob:;"); + return response; +} + +export function middleware(req: NextRequest) { + const pathname = req.nextUrl.pathname; + const searchParams = req.nextUrl.searchParams; + + const logMessages: string[] = [ + `[Middleware Request] ${new Date().toISOString()}`, + `[Environment]\n Mode: ${process.env.NODE_ENV}\n Port: ${PORT}\n Proxy Host: ${PROXY_HOST}\n Server API URL: ${SERVER_API_URL}\n Site ID: ${SITE_ID}`, + `[Request]\n Original Pathname: ${pathname}`, + ]; + + if (pathname.startsWith("/server")) { + const queryParams = searchParams.toString(); + const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${ + queryParams ? `?${queryParams}` : "" + }`; + + logMessages.push(` Rewritten URL: ${newUrl}`); + logMessages.push("\n--------------------------------\n"); + console.log(logMessages.join("\n")); + + const response = NextResponse.rewrite(new URL(newUrl)); + return addSecurityHeaders(response); + } + + // if (pathname === "/") { + // const response = NextResponse.redirect(new URL("/dashboard", req.url)); + // return addSecurityHeaders(response); + // } + + logMessages.push("\n--------------------------------\n"); + console.log(logMessages.join("\n")); + + const response = NextResponse.next(); + return addSecurityHeaders(response); +} + +export const config = { + matcher: ["/server/:path*", "/"], +}; diff --git a/apps/_template/package.json b/apps/_template/package.json index 17b53990f..9227dcf64 100644 --- a/apps/_template/package.json +++ b/apps/_template/package.json @@ -1,5 +1,5 @@ { - "name": "as", + "name": "template", "version": "0.1.0", "private": true, "scripts": { diff --git a/apps/_template/pages/README.md b/apps/_template/pages/README.md new file mode 100644 index 000000000..e011944c3 --- /dev/null +++ b/apps/_template/pages/README.md @@ -0,0 +1,9 @@ +# 이 폴더는 비워 둡니다 (FSD + Next.js App Router) + +Next.js가 `src/pages`를 **Pages Router**용 `pages` 디렉터리로 인식하지 않도록, +프로젝트 루트에 **빈 `pages` 폴더**를 두는 것이 FSD 공식 가이드 권장사항입니다. + +- 실제 페이지(FSD Pages 레이어)는 **`src/pages`**에 있습니다. +- Next.js 라우트는 **`app/`** 에서 `src/pages`를 re-export 하는 방식으로 연결됩니다. + +[Feature-Sliced Design - Usage with Next.js (App Router)](https://feature-sliced.design/docs/guides/tech/with-nextjs) diff --git a/apps/_template/shared/config/env.ts b/apps/_template/shared/config/env.ts deleted file mode 100644 index 4f08c722a..000000000 --- a/apps/_template/shared/config/env.ts +++ /dev/null @@ -1,11 +0,0 @@ -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/src/app/layouts/base-layout.tsx b/apps/_template/src/app/layouts/base-layout.tsx new file mode 100644 index 000000000..1081134e0 --- /dev/null +++ b/apps/_template/src/app/layouts/base-layout.tsx @@ -0,0 +1,35 @@ +"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"; + +interface BaseLayoutWrapperProps { + children: React.ReactNode; +} + +export function BaseLayoutWrapper({ children }: BaseLayoutWrapperProps) { + 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/providers.tsx b/apps/_template/src/app/providers/index.tsx similarity index 96% rename from apps/_template/app/providers.tsx rename to apps/_template/src/app/providers/index.tsx index bc12c443b..05bac42cc 100644 --- a/apps/_template/app/providers.tsx +++ b/apps/_template/src/app/providers/index.tsx @@ -1,18 +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} - - - ); -} +"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/app/globals.css b/apps/_template/src/app/styles/globals.css similarity index 84% rename from apps/_template/app/globals.css rename to apps/_template/src/app/styles/globals.css index 60bc92fb6..e557f947b 100644 --- a/apps/_template/app/globals.css +++ b/apps/_template/src/app/styles/globals.css @@ -10,6 +10,6 @@ html, body { color: var(--foreground); background: var(--background); - font-family: var(--font-noto-sans-kr), sans-serif; + font-family: "Noto Sans KR", sans-serif; font-size: 10px; } diff --git a/apps/_template/entities/README.md b/apps/_template/src/entities/README.md similarity index 100% rename from apps/_template/entities/README.md rename to apps/_template/src/entities/README.md diff --git a/apps/_template/entities/menu/index.ts b/apps/_template/src/entities/menu/index.ts similarity index 100% rename from apps/_template/entities/menu/index.ts rename to apps/_template/src/entities/menu/index.ts diff --git a/apps/_template/entities/menu/model/types.ts b/apps/_template/src/entities/menu/model/types.ts similarity index 100% rename from apps/_template/entities/menu/model/types.ts rename to apps/_template/src/entities/menu/model/types.ts diff --git a/apps/_template/features/README.md b/apps/_template/src/features/README.md similarity index 100% rename from apps/_template/features/README.md rename to apps/_template/src/features/README.md diff --git a/apps/_template/src/pages/home/index.ts b/apps/_template/src/pages/home/index.ts new file mode 100644 index 000000000..97a463408 --- /dev/null +++ b/apps/_template/src/pages/home/index.ts @@ -0,0 +1 @@ +export { MainPage } from "./ui/main-page"; diff --git a/apps/_template/src/pages/home/ui/main-page.tsx b/apps/_template/src/pages/home/ui/main-page.tsx new file mode 100644 index 000000000..f757224dc --- /dev/null +++ b/apps/_template/src/pages/home/ui/main-page.tsx @@ -0,0 +1,9 @@ +"use client"; + +export function MainPage() { + return ( +
+

Home

+
+ ); +} diff --git a/apps/_template/shared/README.md b/apps/_template/src/shared/README.md similarity index 100% rename from apps/_template/shared/README.md rename to apps/_template/src/shared/README.md diff --git a/apps/_template/shared/api/client.ts b/apps/_template/src/shared/api/client.ts similarity index 100% rename from apps/_template/shared/api/client.ts rename to apps/_template/src/shared/api/client.ts diff --git a/apps/_template/shared/api/index.ts b/apps/_template/src/shared/api/index.ts similarity index 100% rename from apps/_template/shared/api/index.ts rename to apps/_template/src/shared/api/index.ts diff --git a/apps/_template/src/shared/config/env.ts b/apps/_template/src/shared/config/env.ts new file mode 100644 index 000000000..a1825f415 --- /dev/null +++ b/apps/_template/src/shared/config/env.ts @@ -0,0 +1,8 @@ +export const PORT = process.env.NEXT_PUBLIC_PORT || "3000"; +export const PROXY_HOST = process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`; +export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "AS"; +export const SERVER_API_URL = process.env.SERVER_API_URL; +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/src/shared/config/index.ts similarity index 100% rename from apps/_template/shared/config/index.ts rename to apps/_template/src/shared/config/index.ts diff --git a/apps/_template/shared/lib/dayjs.ts b/apps/_template/src/shared/lib/dayjs.ts similarity index 100% rename from apps/_template/shared/lib/dayjs.ts rename to apps/_template/src/shared/lib/dayjs.ts diff --git a/apps/_template/shared/lib/index.ts b/apps/_template/src/shared/lib/index.ts similarity index 100% rename from apps/_template/shared/lib/index.ts rename to apps/_template/src/shared/lib/index.ts diff --git a/apps/_template/shared/lib/query-client.ts b/apps/_template/src/shared/lib/query-client.ts similarity index 100% rename from apps/_template/shared/lib/query-client.ts rename to apps/_template/src/shared/lib/query-client.ts diff --git a/apps/_template/shared/model/index.ts b/apps/_template/src/shared/model/index.ts similarity index 100% rename from apps/_template/shared/model/index.ts rename to apps/_template/src/shared/model/index.ts diff --git a/apps/_template/shared/ui/index.ts b/apps/_template/src/shared/ui/index.ts similarity index 100% rename from apps/_template/shared/ui/index.ts rename to apps/_template/src/shared/ui/index.ts diff --git a/apps/_template/shared/ui/page-layout.tsx b/apps/_template/src/shared/ui/page-layout.tsx similarity index 100% rename from apps/_template/shared/ui/page-layout.tsx rename to apps/_template/src/shared/ui/page-layout.tsx diff --git a/apps/_template/widgets/README.md b/apps/_template/src/widgets/README.md similarity index 100% rename from apps/_template/widgets/README.md rename to apps/_template/src/widgets/README.md diff --git a/apps/_template/tailwind.config.ts b/apps/_template/tailwind.config.ts index c02405aca..d19dec7ed 100644 --- a/apps/_template/tailwind.config.ts +++ b/apps/_template/tailwind.config.ts @@ -18,9 +18,6 @@ const config: Config = { background: "var(--background)", foreground: "var(--foreground)", }, - fontFamily: { - sans: ["var(--font-noto-sans-kr)", "Noto Sans KR", "sans-serif"], - }, }, }, plugins: [], diff --git a/apps/_template/tsconfig.json b/apps/_template/tsconfig.json index 15b023415..dab9b0b8e 100644 --- a/apps/_template/tsconfig.json +++ b/apps/_template/tsconfig.json @@ -19,13 +19,7 @@ } ], "paths": { - "@/*": ["./*"], - "@/app/*": ["./app/*"], - "@/shared/*": ["./shared/*"], - "@/entities/*": ["./entities/*"], - "@/features/*": ["./features/*"], - "@/widgets/*": ["./widgets/*"], - "@/pages/*": ["./pages/*"], + "@/*": ["./src/*"], "@common/components": ["../../packages/common/components/dist/"], "@common/network": ["../../packages/common/network/"] } diff --git a/apps/as/.eslintrc.json b/apps/as/.eslintrc.json new file mode 100644 index 000000000..ea782d70a --- /dev/null +++ b/apps/as/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/apps/as/.gitignore b/apps/as/.gitignore new file mode 100644 index 000000000..e43ec0981 --- /dev/null +++ b/apps/as/.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.* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/as/.gitkeep b/apps/as/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/as/.prettierrc b/apps/as/.prettierrc new file mode 100644 index 000000000..059b4e33c --- /dev/null +++ b/apps/as/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "trailingComma": "all", + "singleQuote": false, + "printWidth": 100, + "tabWidth": 2, + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/apps/as/README.md b/apps/as/README.md new file mode 100644 index 000000000..c48c1534a --- /dev/null +++ b/apps/as/README.md @@ -0,0 +1,187 @@ +# Template 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 + Next.js App Router) + +이 프로젝트는 **Feature-Sliced Design v2.1**과 **FSD 공식 Next.js 가이드**에 맞춰 구성되어 있습니다. Next.js `app/`은 라우트만 담당하고, 실제 레이어는 `src/` 아래에 있습니다. + +``` +_template/ +├── app/ # Next.js App Router (루트) — 라우트·레이아웃 진입점만 +│ ├── layout.tsx # 루트 레이아웃 (src/app에서 providers, styles import) +│ └── (with-base-layout)/ +│ ├── layout.tsx # BaseLayout 래퍼 (src/app/layouts에서 import) +│ └── page.tsx # re-export: export { MainPage as default } from '@/pages/home' +│ +├── pages/ # 빈 폴더 + README (Next.js가 src/pages를 Pages Router로 쓰지 않도록) +│ └── README.md +│ +└── src/ # FSD 레이어 (실제 코드) + ├── app/ # App 레이어 (슬라이스 없음, 세그먼트만) + │ ├── providers/ # 전역 Providers (QueryClient 등) + │ ├── styles/ # 글로벌 스타일 (globals.css) + │ └── layouts/ # 공통 레이아웃 컴포넌트 (BaseLayoutWrapper) + │ + ├── pages/ # Pages 레이어 (페이지 슬라이스) + │ └── home/ + │ ├── ui/ + │ └── index.ts # Public API + │ + ├── widgets/ # Widgets 레이어 + ├── features/ # Features 레이어 + ├── entities/ # Entities 레이어 + └── shared/ # Shared 레이어 + ├── ui/ + ├── api/ + ├── lib/ + └── config/ +``` + +### 계층 구조 이해하기 + +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 (src/pages) +- **목적**: 웹사이트나 애플리케이션을 구성하는 페이지/화면 +- **내용**: 페이지 UI, 로딩 상태, 에러 바운더리, 데이터 fetching +- **특징**: 한 페이지당 한 슬라이스 (유사한 페이지는 그룹화 가능). Next.js `app/`의 route 파일에서 re-export +- **의존성**: `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, 유틸리티 라이브러리, 글로벌 설정 +- **특징**: 비즈니스 로직 없음, 슬라이스가 없고 세그먼트로만 구성 +- **의존성**: 외부 라이브러리만 사용 가능 (다른 레이어 불가) + +## 시작하기 + +```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"; +``` diff --git a/apps/as/app/(with-base-layout)/layout.tsx b/apps/as/app/(with-base-layout)/layout.tsx new file mode 100644 index 000000000..71dd294a8 --- /dev/null +++ b/apps/as/app/(with-base-layout)/layout.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; + +import { BaseLayoutWrapper } from "@/app/layouts/base-layout"; + +interface WithBaseLayoutProps { + children: ReactNode; +} + +export default function WithBaseLayout({ children }: WithBaseLayoutProps) { + return {children}; +} diff --git a/apps/as/app/(with-base-layout)/page.tsx b/apps/as/app/(with-base-layout)/page.tsx new file mode 100644 index 000000000..82a918b59 --- /dev/null +++ b/apps/as/app/(with-base-layout)/page.tsx @@ -0,0 +1 @@ +export { MainPage as default } from "@/pages/home"; diff --git a/apps/as/app/README.md b/apps/as/app/README.md new file mode 100644 index 000000000..5ab63ea34 --- /dev/null +++ b/apps/as/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/as/app/api/proxy/[...url]/route.ts b/apps/as/app/api/proxy/[...url]/route.ts new file mode 100644 index 000000000..36ec17557 --- /dev/null +++ b/apps/as/app/api/proxy/[...url]/route.ts @@ -0,0 +1,94 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +import { + ACCESS_TOKEN, + AUTH_USER_ID, + CLAIM_NAME, + REFRESH_TOKEN, + SERVER_API_URL, + SITE_ID, +} from "@/shared/config"; + +async function handler( + request: Request, + params: { url: string[] }, + method: "GET" | "POST" | "PUT" | "DELETE", +) { + const cookieStore = await cookies(); + const headers: Record = { + "Content-Type": "application/json", + "X-Site-Id": SITE_ID, + }; + let body = {}; + const isRefreshHandler = params.url.includes("refresh"); + + // silent refresh + if (isRefreshHandler) { + const refreshToken = cookieStore.get(REFRESH_TOKEN); + headers[CLAIM_NAME] = refreshToken?.value ?? ""; + + if (!headers[CLAIM_NAME]) { + return NextResponse.json({ message: "Can't refresh" }, { status: 401 }); + } + } + + try { + if (!isRefreshHandler) { + body = await request.json(); + } + + const result = await fetch(`${SERVER_API_URL}/${params.url.join("/")}`, { + method, + headers, + body: JSON.stringify(body), + }); + + if (!result.ok) { + return NextResponse.json({ message: "Invalid Credentials" }, { status: 401 }); + } + + const accessToken = result.headers.get(ACCESS_TOKEN); + const refreshToken = result.headers.get(REFRESH_TOKEN); + const userId = result.headers.get(AUTH_USER_ID); + + if (accessToken && userId) { + const response = NextResponse.json({ + [ACCESS_TOKEN]: accessToken, + [AUTH_USER_ID]: userId, + }); + + const forwardedHost = request.headers.get("x-forwarded-host"); + const host = request.headers.get("host"); + const actualHost = forwardedHost || host || new URL(request.url).hostname.toString(); + + // 운영 환경에서 domain 설정 + const domain = actualHost.includes("hasuon.or.kr") ? ".hasuon.or.kr" : actualHost; + + if (refreshToken) { + cookieStore.set(REFRESH_TOKEN, refreshToken, { + httpOnly: true, + sameSite: "strict", + domain: domain, + path: "/", + }); + } + + return response; + } + + return NextResponse.json({ message: "Invalid Credentials" }, { status: 401 }); + } catch (error) { + return NextResponse.json({ message: "Server Error", error }, { status: 500 }); + } +} + +export async function POST(request: Request, props: { params: Promise<{ url: string[] }> }) { + const params = await props.params; + return handler(request, params, "POST"); +} + +export async function PUT(request: Request, props: { params: Promise<{ url: string[] }> }) { + const params = await props.params; + return handler(request, params, "PUT"); +} diff --git a/apps/as/app/auth/login/page.tsx b/apps/as/app/auth/login/page.tsx new file mode 100644 index 000000000..8f9e6adc6 --- /dev/null +++ b/apps/as/app/auth/login/page.tsx @@ -0,0 +1,2 @@ +import { LoginPage } from "@/pages/auth/login"; +export default LoginPage; diff --git a/apps/as/app/layout.tsx b/apps/as/app/layout.tsx new file mode 100644 index 000000000..3b64ab52f --- /dev/null +++ b/apps/as/app/layout.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from "next"; +import { cookies } from "next/headers"; + +import { Providers } from "@/app/providers"; + +import "@/app/styles/globals.css"; + +export const metadata: Metadata = { + title: "행정지원시스템", + description: "행정지원시스템", +}; + +interface RootLayoutProps { + children: React.ReactNode; +} + +export default async function RootLayout({ children }: RootLayoutProps) { + const theme = (await cookies()).get("site-theme-key"); + + return ( + + + {children} + + + ); +} diff --git a/apps/as/docs/approval-line-management/SPEC.md b/apps/as/docs/approval-line-management/SPEC.md new file mode 100644 index 000000000..8528f8241 --- /dev/null +++ b/apps/as/docs/approval-line-management/SPEC.md @@ -0,0 +1,358 @@ +# 화면설계서: 결재선 관리 + +## 1. 기본 정보 + +| 항목 | 내용 | +| ---------- | -------------------------------------------- | +| 버전 | 0.1 (초안) | +| 프로세스명 | UI설계 | +| 화면명 | 결재선 관리 | +| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 | +| 메뉴 경로 | 디지털행정지원 > 시스템관리 > 결재선 관리 | + +--- + +## 2. 화면 목적 + +전자결재 처리에 사용할 **결재서식**(템플릿)을 등록·관리하고, 각 서식별로 **결재선**(접수 -> 검토 -> 승인 -> 최종승인 단계)을 구성한다. 상단의 결재서식 목록에서 하나를 선택하면 하단 영역에 해당 서식의 결재 단계(결재자/순서/구분)가 표시되며, 사용자는 결재자 추가·수정·삭제와 결재선 저장을 수행한다. + +--- + +## 3. 화면 구성 + +### 3-1. 전체 레이아웃 + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ 페이지 타이틀: 결재선 관리 브레드크럼: 디지털행정지원 > … > │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ [영역 A] 전자결재선 관리 목록 [서식 등록] │ +│ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ 전자결재서식아이디 | 결재서식명 | 문서구분 | 등록일시 | 관리 │ │ +│ ├──────────────────────────────────────────────────────────────────────┤ │ +│ │ TMPL-0001 (선택 행 강조) … [수정][삭제] │ │ +│ │ TMPL-0002 … [수정][삭제] │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ [영역 B] 결재선 관리 선택된 서식: TMPL-0001 │ +│ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ [영역 B-1] 결재자 입력 폼 │ │ +│ │ 결재선아이디(자동발급) | 결재구분 ▾ | 결재자명 [검토인력 선택] │ │ +│ │ 결재순서번호 [결재자 추가] [결재선 저장] [초기화] │ │ +│ ├──────────────────────────────────────────────────────────────────────┤ │ +│ │ [영역 B-2] 결재선 그리드 │ │ +│ │ 결재선아이디 | 결재순서번호 | 결재구분 | 결재자명 | 등록일시 | 관리 │ │ +│ │ APLN-0001 | 1 | 접수 | 김민수 | … |[수정][삭제] │ +│ │ APLN-0002 | 2 | 검토 | 박지훈 | … |[수정][삭제] │ +│ │ APLN-0003 | 3 | 최종승인 | 이서연 | … |[수정][삭제] │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +### 3-2. 화면 데이터 흐름 + +``` +BEGIN + + // 최초 진입 + CALL TBD.결재서식목록조회() + DISPLAY 결재서식 그리드 (영역 A) + DISPLAY 빈 상태 안내 (영역 B) // 서식 미선택 시 폼·그리드 비활성 + + // 영역 A: 서식 행 선택 + ON 영역 A 행 CLICK DO + SET 선택된 서식 = 행.전자결재서식아이디 // 영역 B 상단 라벨에 표시 + CALL TBD.결재선목록조회(서식아이디) + DISPLAY 결재선 그리드 (영역 B-2) + RESET 결재자 입력 폼 (영역 B-1) + END ON + + // 영역 A: 신규 서식 등록 + ON [서식 등록] 버튼 CLICK DO + OPEN 결재서식 등록 다이얼로그 + ON 확인 DO + CALL TBD.결재서식등록(서식정보) + CALL TBD.결재서식목록조회() // → 영역 A 갱신 + END ON + END ON + + // 영역 A: 서식 수정·삭제 + ON 영역 A [수정] 버튼 CLICK DO + OPEN 결재서식 수정 다이얼로그(선택 행) + ON 확인 DO + CALL TBD.결재서식수정(서식아이디, 서식정보) + CALL TBD.결재서식목록조회() + END ON + END ON + + ON 영역 A [삭제] 버튼 CLICK DO + CONFIRM "선택한 결재서식을 삭제하시겠습니까?" + CALL TBD.결재서식삭제(서식아이디) + CALL TBD.결재서식목록조회() + IF 삭제된 서식이 현재 선택된 서식 THEN + RESET 영역 B (그리드 비우기, 폼 비활성) + END IF + END ON + + // 영역 B-1: 결재자 입력 폼 + ON [검토인력 선택] 버튼 CLICK DO + VALIDATE 서식 선택됨 // 미선택 시 안내 후 종료 + OPEN 검토인력 선택 다이얼로그 + ON 선택 DO + SET 결재자명 = 선택인력.성명 + SET 결재자ID = 선택인력.사용자ID // 내부 상태로 보관 + END ON + END ON + + ON 결재구분 SELECT CHANGE DO + SET 결재구분 = 선택값 // 접수 / 검토 / 최종승인 + END ON + + ON 결재순서번호 INPUT CHANGE DO + SET 결재순서번호 = 값(숫자) + END ON + + // 영역 B-1: 결재자 추가 (그리드에 행 추가, 미저장 상태) + ON [결재자 추가] 버튼 CLICK DO + VALIDATE 서식 선택됨 + · 결재구분 입력 + · 결재자 선택됨 + · 결재순서번호 > 0 (중복 시 안내) + ADD 입력 폼 값을 영역 B-2 그리드에 임시 행으로 추가 + RESET 결재자 입력 폼 (영역 B-1) + // 결재선아이디는 저장 시점에 서버에서 자동 발급되므로 + // 임시 행은 클라이언트 임시 키로 식별 (예: APLN-0007 같은 미발급 표기 또는 임시 ID) + END ON + + // 영역 B-1: 결재선 저장 (그리드 전체 일괄 저장) + ON [결재선 저장] 버튼 CLICK DO + VALIDATE 서식 선택됨 · 결재선 1건 이상 · 순서번호 중복 없음 + CALL TBD.결재선일괄저장(서식아이디, 결재선목록) + CALL TBD.결재선목록조회(서식아이디) // → 영역 B-2 갱신 + TOAST "결재선이 저장되었습니다." + END ON + + // 영역 B-1: 초기화 + ON [초기화] 버튼 CLICK DO + RESET 결재자 입력 폼 (영역 B-1) → 기본값 + (결재구분=접수, 결재자명="", 결재순서번호=1) + END ON + + // 영역 B-2: 그리드 행 수정·삭제 + ON 영역 B-2 [수정] 버튼 CLICK DO + OPEN 결재자 수정 다이얼로그(선택 행) + ON 확인 DO + CALL TBD.결재선수정(결재선아이디, 결재선정보) + CALL TBD.결재선목록조회(서식아이디) + END ON + END ON + + ON 영역 B-2 [삭제] 버튼 CLICK DO + CONFIRM "선택한 결재자를 삭제하시겠습니까?" + CALL TBD.결재선삭제(결재선아이디) + CALL TBD.결재선목록조회(서식아이디) + END ON + +END +``` + +--- + +## 4. 영역 A: 전자결재선 관리 목록 + +선택된 결재서식이 영역 B의 작업 대상이 된다. 행 클릭으로 선택 상태를 표시(좌측 컬러 바·배경 강조)한다. + +### 4-1. 헤더 / 툴바 + +| 항목 | 내용 | +| ----------- | ----------------------------------------------- | +| 섹션 타이틀 | 전자결재선 관리 목록 | +| 우측 버튼 | **[서식 등록]** — 결재서식 등록 다이얼로그 오픈 | + +### 4-2. 그리드 컬럼 + +| 컬럼명 | Field | 타입 | 비고 | +| ------------------ | ----------------- | ------ | --------------------------------------------- | +| 전자결재서식아이디 | `elctAtrzTmpltId` | string | 예) `TMPL-0001` (서버 자동 발급, TBD) | +| 결재서식명 | `atrzTmpltNm` | string | 예) `설치인가 검토 결재` | +| 문서구분 | `docSeNm` | string | 예) `설치인가`, `기본계획` (코드/명 매핑 TBD) | +| 등록일시 | `regDt` | string | `YYYY-MM-DD HH:mm:ss` | +| 관리 | — | — | 행별 **[수정] · [삭제]** 버튼 | + +- 행 클릭 시: 해당 서식을 **선택 상태**로 만들고 영역 B 라벨(`선택된 서식: {전자결재서식아이디}`)과 영역 B-2 그리드를 갱신한다. +- 페이지네이션: TBD (스크린샷에는 미표시). + +### 4-3. 버튼 + +| 버튼명 | 위치 | 동작 | +| --------- | -------------- | ----------------------------------------------------------------- | +| 서식 등록 | 섹션 우측 상단 | 결재서식 등록 다이얼로그 오픈 (입력 항목·필수값 TBD) | +| 수정 | 행별 | 결재서식 수정 다이얼로그 오픈 (해당 행 값으로 초기화) | +| 삭제 | 행별 | 확인 다이얼로그 후 삭제. 선택 중인 서식이면 영역 B 상태도 초기화. | + +### 4-4. 다이얼로그(개요) + +| 다이얼로그명 | 트리거 | 입력 항목(예상) | 비고 | +| ------------------ | ---------------- | ------------------------------ | ------------------------ | +| 결재서식 등록 | [서식 등록] 클릭 | 결재서식명, 문서구분(드롭다운) | 등록 성공 시 영역 A 갱신 | +| 결재서식 수정 | 행별 [수정] | 결재서식명, 문서구분 | 기존 값으로 초기화 | +| 결재서식 삭제 확인 | 행별 [삭제] | (확인/취소) | | + +--- + +## 5. 영역 B: 결재선 관리 + +영역 A에서 선택된 결재서식을 기준으로 결재 단계(접수·검토·최종승인)와 결재자, 순서를 관리한다. 영역 B는 상단 **결재자 입력 폼(B-1)** 과 하단 **결재선 그리드(B-2)** 로 구성된다. + +### 5-1. 헤더 / 상태 표시 + +| 항목 | 내용 | +| ----------- | ------------------------------------------------------------------------------------------------------------ | +| 섹션 타이틀 | 결재선 관리 | +| 우측 라벨 | `선택된 서식: {전자결재서식아이디}` — 영역 A에서 선택한 서식 ID를 강조 색으로 표시. 미선택 시 영역 B 비활성. | + +### 5-2. 영역 B-1: 결재자 입력 폼 + +#### 필드 정의 + +| 표시명 | 입력 유형 | 필수 | 기본값 | 데이터 소스 | API 파라미터 매핑 | 비고 | +| ---------------------- | ----------------- | ---- | ----------- | -------------------- | ----------------- | --------------------------------------------------------------------- | +| 결재선아이디(자동발급) | 텍스트(읽기 전용) | — | `APLN-XXXX` | 서버 자동 발급 (TBD) | TBD | 입력 불가. 저장 시 서버 발급. 표시값은 미발급 자리표시(또는 임시 ID). | +| 결재구분 | 드롭다운 | 예 | `접수` | 코드 (TBD) | TBD | 옵션: **접수 / 검토 / 최종승인** (그리드 행 기준) | +| 결재자명 | 텍스트(읽기 전용) | 예 | `""` | [검토인력 선택] 결과 | TBD | 자유 입력 불가. 다이얼로그 선택 결과로 채움. | +| 결재순서번호 | 숫자 입력 | 예 | `1` | — | TBD | 1 이상의 정수. 동일 서식 내 중복 검증 필요. | + +플레이스홀더 / 표시 텍스트: + +- 결재자명 입력란 플레이스홀더: `검토인력을 선택하세요` +- 결재선아이디 표시 예시: `APLN-0007` (다음 발급 예정 번호 노출 또는 빈 칸 — 표시 정책 TBD) + +#### 버튼 + +| 버튼명 | 동작 | +| ------------- | ------------------------------------------------------------------------------------------------------------- | +| 검토인력 선택 | **검토인력 선택 다이얼로그** 오픈. 선택 시 `결재자명`과 내부 `결재자 ID`를 폼 상태에 반영. | +| 결재자 추가 | 입력 폼 값을 영역 B-2 그리드에 **임시 행**으로 추가하고 폼을 초기화한다. (서버 호출 없음, 저장 시 일괄 반영.) | +| 결재선 저장 | 영역 B-2의 결재선 목록을 일괄 저장 API로 전송한다. 성공 시 그리드 재조회 + 토스트 표시. | +| 초기화 | 입력 폼을 기본값으로 리셋(결재구분=접수, 결재자명=빈값, 결재순서번호=1). | + +> 정책 메모: **[결재자 추가]** 와 **[결재선 저장]** 의 분리에 대해서는 두 가지 운영이 가능하다. 본 SPEC 초안은 그리드에 임시 행을 누적해 두고 [결재선 저장] 시점에 서버에 일괄 반영하는 모델을 가정한다. 실제 구현 방식(추가 즉시 저장 vs 일괄 저장)은 백엔드 API 확정 후 재정의(TBD). + +#### 다이얼로그 + +| 다이얼로그명 | 트리거 | 표시 내용 / 입력 항목 | 결과 | +| ---------------- | --------------- | ---------------------------------------------------------- | ----------------------------------- | +| 검토인력 선택 | [검토인력 선택] | 사용자 검색 필터(이름/소속 등 TBD), 사용자 목록(단일 선택) | 선택된 사용자 정보를 입력 폼에 반영 | +| 결재자 수정 | B-2 행별 [수정] | 결재구분, 결재자명(선택), 결재순서번호 | 확인 시 해당 행 갱신 | +| 결재자 삭제 확인 | B-2 행별 [삭제] | (확인/취소) | 확인 시 해당 행 삭제 | + +### 5-3. 영역 B-2: 결재선 그리드 + +#### 그리드 컬럼 + +| 컬럼명 | Field | 타입 | 비고 | +| ------------ | ------------ | ------ | ------------------------------------- | +| 결재선아이디 | `atrzLineId` | string | 예) `APLN-0001` (서버 자동 발급, TBD) | +| 결재순서번호 | `atrzSeqNo` | number | 1부터 시작. 단계 순서. | +| 결재구분 | `atrzSeNm` | string | `접수` / `검토` / `최종승인` | +| 결재자명 | `atrzUserNm` | string | 검토인력 선택 시 채워짐 | +| 등록일시 | `regDt` | string | `YYYY-MM-DD HH:mm:ss` | +| 관리 | — | — | 행별 **[수정] · [삭제]** 버튼 | + +- 표시 정렬: 기본 `결재순서번호` 오름차순. +- 임시 행(서버 미저장)과 저장된 행의 시각적 구분 필요(예: ID 컬럼 표기 또는 행 스타일). 세부 UI는 TBD. +- 페이지네이션: TBD. + +--- + +## 6. API/DTO 명세 + +> 본 화면은 현재 UI 위주 초안으로, API/DTO는 백엔드 명세 확정 후 채운다. + +### 6-1. 공통 + +| 항목 | 내용 | +| ----------- | ------------------------------------------- | +| Base prefix | TBD (예: `/server` 또는 결재 서비스 prefix) | +| 인증 | JWT 토큰 기반 (Authorization 헤더) | +| 클라이언트 | TBD (예: `@/entities/approval-line`) | + +### 6-2. 전자결재서식 (영역 A) + +| 기능 | Method | URL | Request DTO | Response DTO | API(클라이언트 함수명) | +| ------------------ | ------ | --- | ----------- | ------------ | ---------------------- | +| 결재서식 목록 조회 | TBD | TBD | TBD | TBD | TBD | +| 결재서식 등록 | TBD | TBD | TBD | TBD | TBD | +| 결재서식 수정 | TBD | TBD | TBD | TBD | TBD | +| 결재서식 삭제 | TBD | TBD | TBD | TBD | TBD | + +### 6-3. 결재선 (영역 B) + +| 기능 | Method | URL | Request DTO | Response DTO | API(클라이언트 함수명) | +| ------------------------ | ------ | --- | ------------------------------------ | ------------ | ---------------------- | +| 결재선 목록 조회(서식별) | TBD | TBD | `{ elctAtrzTmpltId }` (TBD) | TBD | TBD | +| 결재선 추가/일괄 저장 | TBD | TBD | `{ elctAtrzTmpltId, lines[] }` (TBD) | TBD | TBD | +| 결재선 수정 | TBD | TBD | TBD | TBD | TBD | +| 결재선 삭제 | TBD | TBD | TBD | TBD | TBD | + +### 6-4. 검토인력 (다이얼로그) + +| 기능 | Method | URL | Request DTO | Response DTO | API(클라이언트 함수명) | +| ------------------ | ------ | --- | --------------- | ------------ | ---------------------- | +| 검토인력 목록 조회 | TBD | TBD | TBD (검색 필터) | TBD | TBD | + +--- + +## 7. UI 구현 가이드 + +### 7-1. 기술 스택·원칙 + +- **공통 컴포넌트**: `@common/components` (버튼, 셀렉트, 인풋, 카드 등 기존 패턴 재사용). +- **테이블**: `@tanstack/react-table`, 프로젝트 표준 `useDataTable` 패턴이 있다면 동일하게 적용. +- **데이터**: React Query 기반 조회/뮤테이션. API 확정 후 `queryKey` 와 무효화 정책 정리. +- **상태**: 영역 A의 **선택된 서식 ID**, 영역 B-1의 **결재자 입력 폼 값**, 영역 B-2의 **임시 결재선 배열**은 페이지 로컬 상태로 관리. 다이얼로그 상태(검토인력 선택 등)는 컴포넌트 로컬 상태 또는 가벼운 상태 관리 훅으로 분리. +- **모의 데이터**: 백엔드 명세 확정 전까지 `@faker-js/faker` 로 그리드 목데이터 구성(스크린샷 컬럼/값과 동일 형태). + +### 7-2. 모듈·파일 배치(제안) + +| 구분 | 경로 | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| 라우트(App Router) | `apps/as/app/(with-base-layout)/approval-line-management/page.tsx` | +| 페이지 컴포넌트 | `apps/as/src/pages/approval-line-management/index.ts`
`.../ui/approval-line-management-page.tsx` | +| 위젯(영역 단위) | `apps/as/src/widgets/approval-template-list/`
`apps/as/src/widgets/approval-line-editor/` | +| 피처(상호작용) | `apps/as/src/features/approver-picker/` (검토인력 선택 다이얼로그) | +| 엔티티(도메인) | `apps/as/src/entities/approval-template/`
`apps/as/src/entities/approval-line/` | +| 그리드 컬럼 정의 | `entities/approval-template/model/approval-template-table-columns.tsx`
`entities/approval-line/model/approval-line-table-columns.tsx` | +| API 클라이언트 | `entities/approval-template/api/*`, `entities/approval-line/api/*` (TBD) | + +### 7-3. 영역별 UI 매핑 + +| 영역 | 주요 UI | 비고 | +| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | +| A | 섹션 헤더 + Button(`서식 등록`) + DataTable (행 선택, 행별 Action 버튼) | 선택 행 강조, 행 클릭으로 영역 B 갱신 | +| B 헤더 | 상태 라벨 `선택된 서식: {ID}` | 미선택 시 영역 B-1·B-2 비활성 | +| B-1 | 읽기 전용 Input(아이디), Select(결재구분), Input+Button(결재자/검토인력 선택), NumberInput(순서번호), Action 버튼 3종(`결재자 추가`/`결재선 저장`/`초기화`) | 폼 유효성 검증 후 그리드에 임시 행 추가 | +| B-2 | DataTable (행별 [수정][삭제]), 임시 행 시각적 구분 | 기본 정렬: 결재순서번호 ASC | +| 공통 | Dialog(검토인력 선택, 서식 등록/수정, 결재자 수정, 삭제 확인) | 디자인 시스템의 `Dialog` 컴포넌트 사용 | + +### 7-4. 데이터 흐름(요약) + +- **진입 시**: 결재서식 목록 조회(TBD) → 영역 A 그리드 렌더. 영역 B는 안내/비활성 상태. +- **서식 선택 시**: 선택된 `elctAtrzTmpltId` 를 페이지 상태로 보관 → 결재선 목록 조회(TBD) → 영역 B-2 그리드 갱신. 영역 B-1 폼 초기화. +- **결재자 추가**: 입력 폼 값을 영역 B-2의 **클라이언트 임시 배열**에 push. 결재선아이디는 미발급 상태로 표시(또는 임시 키). 폼 리셋. +- **결재선 저장**: 임시 배열 + 기존 그리드 행을 합쳐 일괄 저장 API(TBD) 호출 → 성공 시 그리드 재조회 + 토스트. +- **수정/삭제**: 각각의 단건 API(TBD) 호출 후 그리드 재조회. +- **검토인력 선택 다이얼로그**: 사용자 목록 조회(TBD) → 단일 선택 → 폼의 결재자명/결재자 ID 갱신. +- **서식 등록/수정/삭제**: 다이얼로그 또는 확인 후 단건 API(TBD) 호출 → 영역 A 그리드 재조회. 삭제 대상이 현재 선택된 서식이면 영역 B 초기화. + +### 7-5. 미확정 / 검증 필요 항목 (TBD) + +- 모든 백엔드 API 경로·DTO·필드명. +- `결재선아이디(자동발급)` 입력 박스의 표시 정책: (a) 다음 발급 예정 번호 미리 보여주기, (b) 저장 전에는 빈 칸 또는 자리표시자만, (c) 임시 ID. +- `[결재자 추가]` 의 동작: 그리드에 임시 누적(현재 가정) vs 즉시 단건 저장. +- 결재구분 코드 값(`접수`/`검토`/`최종승인`)의 코드 매핑. +- 문서구분 코드 값(`설치인가`/`기본계획` 등)의 코드 매핑. +- 영역 A·B 그리드의 페이지네이션·정렬·검색 필터 유무. +- 검토인력 선택 다이얼로그의 검색 필드(부서/직급/이름 등) 및 다중 선택 허용 여부. +- 결재선 저장 시 순서번호 중복·누락(예: 1, 2, 4) 허용 정책. +- 권한(역할별 등록/수정/삭제 가능 여부). diff --git a/apps/as/eslint.config.mjs b/apps/as/eslint.config.mjs new file mode 100644 index 000000000..19ccdfae3 --- /dev/null +++ b/apps/as/eslint.config.mjs @@ -0,0 +1,33 @@ +import { FlatCompat } from "@eslint/eslintrc"; +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import baseEsLintConfig from "../../eslint.config.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), + ...baseEsLintConfig, + { + rules: { + "react-hooks/exhaustive-deps": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-wrapper-object-types": "warn", + }, + }, +]; + +export default eslintConfig; diff --git a/apps/as/middleware.ts b/apps/as/middleware.ts new file mode 100644 index 000000000..d9239dc40 --- /dev/null +++ b/apps/as/middleware.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { PORT, PROXY_HOST, SERVER_API_URL, SITE_ID } from "@/shared/config"; + +const serverApiUrl = process.env.SERVER_API_URL ?? "http://localhost:3000"; + +// CSP 헤더 설정 함수 (img-src만) +function addSecurityHeaders(response: NextResponse): NextResponse { + response.headers.set("Content-Security-Policy", "img-src 'self' data: https: blob:;"); + return response; +} + +export function middleware(req: NextRequest) { + const pathname = req.nextUrl.pathname; + const searchParams = req.nextUrl.searchParams; + + const logMessages: string[] = [ + `[Middleware Request] ${new Date().toISOString()}`, + `[Environment]\n Mode: ${process.env.NODE_ENV}\n Port: ${PORT}\n Proxy Host: ${PROXY_HOST}\n Server API URL: ${SERVER_API_URL}\n Site ID: ${SITE_ID}`, + `[Request]\n Original Pathname: ${pathname}`, + ]; + + if (pathname.startsWith("/server")) { + const queryParams = searchParams.toString(); + const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${ + queryParams ? `?${queryParams}` : "" + }`; + + logMessages.push(` Rewritten URL: ${newUrl}`); + logMessages.push("\n--------------------------------\n"); + console.log(logMessages.join("\n")); + + const response = NextResponse.rewrite(new URL(newUrl)); + return addSecurityHeaders(response); + } + + // if (pathname === "/") { + // const response = NextResponse.redirect(new URL("/dashboard", req.url)); + // return addSecurityHeaders(response); + // } + + logMessages.push("\n--------------------------------\n"); + console.log(logMessages.join("\n")); + + const response = NextResponse.next(); + return addSecurityHeaders(response); +} + +export const config = { + matcher: ["/server/:path*", "/"], +}; diff --git a/apps/as/next-env.d.ts b/apps/as/next-env.d.ts index 1b3be0840..3cd7048ed 100644 --- a/apps/as/next-env.d.ts +++ b/apps/as/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/as/next.config.ts b/apps/as/next.config.ts new file mode 100644 index 000000000..8864de0e7 --- /dev/null +++ b/apps/as/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/as/package.json b/apps/as/package.json new file mode 100644 index 000000000..efafdcc50 --- /dev/null +++ b/apps/as/package.json @@ -0,0 +1,40 @@ +{ + "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", + "js-cookie": "^3.0.5", + "next": "15.3.8", + "react-hook-form": "^7.54.2", + "zod": "^3.24.2" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@types/js-cookie": "^3.0.6", + "eslint": "^9.0.0", + "eslint-config-next": "15.3.8", + "eslint-plugin-react-hooks": "^5.0.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" + } +} diff --git a/apps/as/pages/README.md b/apps/as/pages/README.md new file mode 100644 index 000000000..e011944c3 --- /dev/null +++ b/apps/as/pages/README.md @@ -0,0 +1,9 @@ +# 이 폴더는 비워 둡니다 (FSD + Next.js App Router) + +Next.js가 `src/pages`를 **Pages Router**용 `pages` 디렉터리로 인식하지 않도록, +프로젝트 루트에 **빈 `pages` 폴더**를 두는 것이 FSD 공식 가이드 권장사항입니다. + +- 실제 페이지(FSD Pages 레이어)는 **`src/pages`**에 있습니다. +- Next.js 라우트는 **`app/`** 에서 `src/pages`를 re-export 하는 방식으로 연결됩니다. + +[Feature-Sliced Design - Usage with Next.js (App Router)](https://feature-sliced.design/docs/guides/tech/with-nextjs) diff --git a/apps/as/postcss.config.mjs b/apps/as/postcss.config.mjs new file mode 100644 index 000000000..1a69fd2a4 --- /dev/null +++ b/apps/as/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config; diff --git a/apps/as/public/images/bg_login.jpg b/apps/as/public/images/bg_login.jpg new file mode 100755 index 000000000..b7793d16a Binary files /dev/null and b/apps/as/public/images/bg_login.jpg differ diff --git a/apps/as/src/app/layouts/base-layout.tsx b/apps/as/src/app/layouts/base-layout.tsx new file mode 100644 index 000000000..54fb55c52 --- /dev/null +++ b/apps/as/src/app/layouts/base-layout.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useCallback } from "react"; +import { usePathname, useRouter } from "next/navigation"; + +import { useQueryClient } from "@tanstack/react-query"; + +import { PageLayout } from "@/shared/ui/page-layout"; +import type { MenuItem } from "@/entities/menu"; +import { BaseLayout } from "@common/components"; + +interface BaseLayoutWrapperProps { + children: React.ReactNode; +} + +export function BaseLayoutWrapper({ children }: BaseLayoutWrapperProps) { + 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/as/src/app/providers/index.tsx b/apps/as/src/app/providers/index.tsx new file mode 100644 index 000000000..476609d59 --- /dev/null +++ b/apps/as/src/app/providers/index.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; + +import { queryClient } from "@/shared/lib"; +import { StoreProvider } from "@/shared/providers"; +import { AuthWrapper } from "@/entities/auth"; + +interface ProvidersProps { + children: React.ReactNode; +} + +export function Providers({ children }: ProvidersProps) { + return ( + + + {children} + + + + ); +} diff --git a/apps/as/src/app/styles/globals.css b/apps/as/src/app/styles/globals.css new file mode 100644 index 000000000..e557f947b --- /dev/null +++ b/apps/as/src/app/styles/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: "Noto Sans KR", sans-serif; + font-size: 10px; +} diff --git a/apps/as/src/entities/README.md b/apps/as/src/entities/README.md new file mode 100644 index 000000000..b28c10790 --- /dev/null +++ b/apps/as/src/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/as/src/entities/auth/api/login-service.ts b/apps/as/src/entities/auth/api/login-service.ts new file mode 100644 index 000000000..2dc784e97 --- /dev/null +++ b/apps/as/src/entities/auth/api/login-service.ts @@ -0,0 +1,92 @@ +import { AxiosError } from "axios"; + +import { + ACCESS_TOKEN, + AUTH_USER_ID, + CLAIM_NAME, + ENDPOINT, + JWT_EXPIRATION_TIME, +} from "@/shared/config"; + +import { tokenManager } from "../lib/token-manager"; +import type { ILogin, LoginService } from "../model/login-interface"; + +export class LoginApiService implements LoginService { + private static instance: LoginApiService; + + private constructor() {} + + public static getInstance(): LoginApiService { + if (!LoginApiService.instance) { + LoginApiService.instance = new LoginApiService(); + } + return LoginApiService.instance; + } + + public async login(payload: ILogin): Promise { + try { + const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { + method: "POST", + body: JSON.stringify({ + userId: payload.userId, + password: payload.password, + }), + }); + + const data = await response.json(); + console.log("data ", data); + const accessToken = data[ACCESS_TOKEN]; + const tokenId = data[AUTH_USER_ID]; + + if (!accessToken || !tokenId) { + if (data.message == "Invalid Credentials") { + throw new Error("아이디와 비밀번호가 일치하지 않습니다."); + } else if (data.message == "User not found") { + throw new Error("존재하지 않는 아이디입니다."); + } else if (data.message == "User is not active") { + throw new Error("활성화되지 않은 계정입니다."); + } else if (data.message == "User is not verified") { + throw new Error("이메일 인증이 필요합니다."); + } else { + throw new Error(data.message); + } + } + + tokenManager.setToken(CLAIM_NAME, accessToken); + tokenManager.setToken(AUTH_USER_ID, tokenId); + tokenManager.setupRefreshTimer(() => this.silentRefresh(), JWT_EXPIRATION_TIME); + } catch (error) { + if (error instanceof AxiosError) { + throw new Error(`Login 실패 ${error.response?.data?.message}`); + } + throw new Error(`Login 실패 ${error}`); + } + } + + public async silentRefresh(): Promise { + try { + const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, { + method: "PUT", + }); + + const data = await response.json(); + const accessToken = data[ACCESS_TOKEN]; + const tokenId = data[AUTH_USER_ID]; + + if (!accessToken || !tokenId) { + throw new Error("Missing required authentication tokens"); + } + + tokenManager.setToken(CLAIM_NAME, accessToken); + tokenManager.setToken(AUTH_USER_ID, tokenId); + } catch (error) { + tokenManager.clearAuthToken(); + if (error instanceof AxiosError) { + throw new Error(`Silent refresh failed: ${error.response?.data?.message}`); + } + throw new Error(`Silent refresh failed: ${error}`); + } + } +} + +export const LoginApi = LoginApiService.getInstance(); diff --git a/apps/as/src/entities/auth/index.ts b/apps/as/src/entities/auth/index.ts new file mode 100644 index 000000000..97e142225 --- /dev/null +++ b/apps/as/src/entities/auth/index.ts @@ -0,0 +1,2 @@ +export { LoginApi } from "./api/login-service"; +export { AuthWrapper } from "./lib/auth-wrapper"; diff --git a/apps/as/src/entities/auth/lib/auth-wrapper.tsx b/apps/as/src/entities/auth/lib/auth-wrapper.tsx new file mode 100644 index 000000000..e92957163 --- /dev/null +++ b/apps/as/src/entities/auth/lib/auth-wrapper.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect } from "react"; + +import { client } from "@/shared/api"; +import { AUTH_LOGIN_REDIRECT_URL, AUTH_USER_ID, ENDPOINT } from "@/shared/config"; +import { getCookie, useUserStore } from "@/shared/lib"; +import type { IUserInfo } from "@/shared/store"; +import { isAtLoginRedirectUrl } from "@common/network"; + +const AuthWrapper = ({ children }: { children: React.ReactNode }) => { + const { userInfo, setUserInfo } = useUserStore((state) => state); + + async function authCheck() { + if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { + return; + } + + const userId = getCookie(AUTH_USER_ID); + if (!userId) { + throw new Error("Missing required authentication tokens"); + } + + const response = await client.get(`${ENDPOINT.USER_SERVICE.USERS}/info/me`); + if (!userInfo) setUserInfo(response.data); + } + + useEffect(() => { + (async () => { + try { + await authCheck(); + } catch (error) { + console.error(error); + setUserInfo(null); + if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { + window.location.assign(AUTH_LOGIN_REDIRECT_URL); + } + } + })(); + }, []); + + return <>{children}; +}; + +export { AuthWrapper }; diff --git a/apps/as/src/entities/auth/lib/token-manager.ts b/apps/as/src/entities/auth/lib/token-manager.ts new file mode 100644 index 000000000..c5eeffc3a --- /dev/null +++ b/apps/as/src/entities/auth/lib/token-manager.ts @@ -0,0 +1,82 @@ +import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config"; +import { getCookie, removeCookie,setCookie } from "@/shared/lib"; + +export class TokenManager { + private static instance: TokenManager; + private refreshTimer: NodeJS.Timeout | null = null; + + private constructor() {} + + public static getInstance(): TokenManager { + if (!TokenManager.instance) { + TokenManager.instance = new TokenManager(); + } + return TokenManager.instance; + } + + public getToken(key: string): string | undefined { + return getCookie(key); + } + + public setToken(key: string, value: string): void { + const domain = this.getCookieDomain(); + + setCookie(key, value, { + secure: window.location.protocol === "https:", + sameSite: "lax", + domain: domain, + path: "/", // 모든 경로에서 접근 가능 + }); + } + + + private getCookieDomain(): string | undefined { + const hostname = window.location.hostname; + + // 개발 환경에서는 localhost 도메인 사용 + if (hostname === "localhost" || hostname === "127.0.0.1") { + return undefined; // localhost에서는 도메인 설정하지 않음 + } + + // 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용 + if (hostname.includes("hasuon.or.kr")) { + return ".hasuon.or.kr"; + } + + // 기타 환경에서는 현재 호스트명 사용 + return hostname; + } + + public removeToken(key: string): void { + const domain = this.getCookieDomain(); + removeCookie(key, { domain, path: "/" }); + } + + public setupRefreshTimer( + callback: () => Promise, + expirationTime: number, + ): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + this.refreshTimer = setTimeout(() => { + callback().catch((error) => { + console.error("Silent refresh failed:", error); + this.clearAuthToken(); + }); + }, expirationTime - 60000); + } + + public clearAuthToken(): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + + this.removeToken(CLAIM_NAME); + this.removeToken(AUTH_USER_ID); + } +} + +export const tokenManager = TokenManager.getInstance(); diff --git a/apps/as/src/entities/auth/model/login-interface.ts b/apps/as/src/entities/auth/model/login-interface.ts new file mode 100644 index 000000000..5aaf53044 --- /dev/null +++ b/apps/as/src/entities/auth/model/login-interface.ts @@ -0,0 +1,9 @@ +export interface ILogin { + userId: string; + password: string; +} + +export interface LoginService { + login(payload: ILogin): Promise; + silentRefresh(): Promise; +} diff --git a/apps/as/src/entities/menu/index.ts b/apps/as/src/entities/menu/index.ts new file mode 100644 index 000000000..cb55d9a1c --- /dev/null +++ b/apps/as/src/entities/menu/index.ts @@ -0,0 +1 @@ +export { type MenuItem } from "./model/types"; diff --git a/apps/as/src/entities/menu/model/types.ts b/apps/as/src/entities/menu/model/types.ts new file mode 100644 index 000000000..9692821e3 --- /dev/null +++ b/apps/as/src/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/as/src/features/README.md b/apps/as/src/features/README.md new file mode 100644 index 000000000..2be9a3e17 --- /dev/null +++ b/apps/as/src/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/as/src/pages/auth/login/index.ts b/apps/as/src/pages/auth/login/index.ts new file mode 100644 index 000000000..2093c3d8d --- /dev/null +++ b/apps/as/src/pages/auth/login/index.ts @@ -0,0 +1 @@ +export { LoginPage } from "./ui/login-page"; diff --git a/apps/as/src/pages/auth/login/ui/login-page.tsx b/apps/as/src/pages/auth/login/ui/login-page.tsx new file mode 100644 index 000000000..2926a2ebe --- /dev/null +++ b/apps/as/src/pages/auth/login/ui/login-page.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; + +import { LoginApi } from "@/entities/auth"; +import { images } from "@common/assets"; + +import "./login.css"; + +function LoginPage() { + const [errorMessage, setErrorMessage] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setErrorMessage(""); // 에러 메시지 초기화 + + const formData = new FormData(e.target as HTMLFormElement); + const userId = formData.get("id") as string; + const password = formData.get("password") as string; + + try { + await LoginApi.login({ userId, password }); + window.location.href = "/"; + } catch (error) { + const message = error instanceof Error ? error.message : "로그인에 실패했습니다."; + setErrorMessage(message); + } + }; + + return ( +
+
+
+

+ 하수도정보시스템 +
+ CMS 통합관리시스템 +

+

하수도정보시스템 CMS 통합관리시스템에 오신 것을 환영합니다.

+
+
+ logo +
+
+
+
+
    +
  • +

    ID

    +
    + + +
    +
  • +
  • +

    PASSWORD

    +
    + + +
    +
  • +
+
+
+ + +
+ {/* 비밀번호 또는 아이디를 잃어버리셨나요? */} +
+ {errorMessage && ( +
+ {errorMessage} +
+ )} + +
+
+

copyright © 2023 KECO. All rights reserved.

+
+ +

+ 주소 : (22689) 인천광역시 서구 환경로 42(경서동 종합환경연구단지) + 전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723, 3722 + 팩스 :032-590-3729 +

+
+
+ +
+ ); +} + +export { LoginPage }; diff --git a/apps/as/src/pages/auth/login/ui/login.css b/apps/as/src/pages/auth/login/ui/login.css new file mode 100755 index 000000000..e8029fdb1 --- /dev/null +++ b/apps/as/src/pages/auth/login/ui/login.css @@ -0,0 +1,325 @@ +/** +* OLYM Cascading Style Sheets +* File Name : login.css +* Description : 로그인 스타일시트 +* Author : Web business Team / Choi Hyun Mi +* Email : desingtj@olym.co.kr +* Date : 2023.07.20 +* Update : 2023.07.26 +* Copyright(c) 2023 OLYM Communications. All Rights Reserved. +*/ +html, +body { + background: #fff; +} +.fx { + display: flex; + -ms-display: flex; + -webkit-display: flex; + box-sizing: border-box; +} +.login { + width: 100%; + height: 100vh; +} +.login .left_box { + width: 100%; + background: url(../../../../../public/images/bg_login.jpg) no-repeat + center/cover; + flex-direction: column; + justify-content: space-between; + padding: 7% 5% 5% 5%; +} +.login .left_box .text h1 { + font-size: 53px; + color: #fff; +} +.login .left_box .text h1 span { + font-weight: 800; +} +.login .left_box .text p { + margin-top: 40px; + font-size: 18px; + color: #fff; +} +.login .login_box { + width: 100%; + flex-direction: column; + justify-content: center; + padding: 4% 12% 4% 6%; +} +.login .login_box > ul { + flex-direction: column; + gap: 40px; +} +.login .login_box > ul > li h2 { + font-size: 22px; + font-weight: 600; + color: #2640a5; + margin-bottom: 14px; +} +.login .login_box > ul > li .input_st { + position: relative; +} +.login .login_box > ul > li .input_st .diveicon { + position: absolute; + top: 50%; + left: 28px; + transform: translateY(-50%); + font-family: "Xeicon"; + font-size: 25px; + color: #003288; +} +.login .login_box > ul > li input { + width: 100%; + padding: 20px 20px 20px 70px; + background: #f7f7f7; + box-sizing: border-box; + border: none; + border-radius: 5px; + font-size: 18px; + font-weight: 700; + color: #000; + height: auto; +} +.login .login_box > ul > li input:focus { + outline: 1px solid #3c5fe5; +} +.login .login_box > ul > li input::placeholder { + color: #afabab; + font-weight: 500; +} +.login .login_box .bottom_box { + justify-content: space-between; + gap: 20px; + padding: 50px 0 45px 0; +} +.login .login_box .bottom_box .checks { + display: flex; + align-items: center; + gap: 8px; +} +.login .login_box .bottom_box .checks input[type="checkbox"] { + width: 18px; + height: 18px; +} +.login .login_box .bottom_box .checks label { + font-size: 14px; +} +.login .login_box .bottom_box .pw_message { + align-items: center; + gap: 5px; + color: #636363; + font-weight: 500; + border-bottom: 1px solid #ccc; + padding-bottom: 5px; +} +.login .login_box .bottom_box .pw_message i { + color: #327af5; + font-size: 20px; +} +.login .login_box .error_message { + margin: 20px 0; + padding: 12px 20px; + background: #fee; + border: 1px solid #fcc; + border-radius: 5px; + color: #c33; + font-size: 14px; + text-align: center; +} +.login .login_box .error_message span { + display: block; + font-weight: 500; +} +.login .login_box .login_btn { + position: relative; + border-radius: 5px; + padding: 24px 15px; + background: linear-gradient( + to left, + #1d3969 0%, + #264078 10%, + #172558 40%, + #162254 50%, + #111c43 60%, + #111c42 100% + ); + overflow: hidden; + justify-content: center; + align-items: center; + gap: 10px; + color: #fff; + cursor: pointer; +} +.login .login_box .login_btn span { + font-family: "Montserrat"; + font-size: 19px; + font-weight: 700; + position: relative; +} +.login .login_box .login_btn i { + font-size: 22px; + transform: translateY(-2px); +} +.login .login_box .login_btn:before { + display: block; + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + background: linear-gradient( + to right, + #1d3969 0%, + #264078 10%, + #172558 40%, + #162254 50%, + #111c43 60%, + #111c42 100% + ); + transition: opacity 0.5s; +} +.login .login_box .login_btn:hover::before { + opacity: 1; +} +.login .footer .copy { + margin-top: 36px; + justify-content: space-between; + align-items: center; + gap: 3%; +} +.login .footer .copy p { + font-size: 13px; + color: #d4d4d4; + width: 44%; + text-align: center; +} +.login .footer .copy::before, +.login .login_box .copy::after { + content: ""; + width: 20%; + height: 1px; + background: #d4d4d4; +} +.login .footer .fot_btn { + gap: 20px; + margin: 30px 0 10px 0; +} +.login .footer .fot_btn a { + color: #333; + font-weight: 700; + font-size: 13px; +} +.login .footer .fot_btn a:hover { + color: #003288; +} +.login .footer .addr { + color: #666; + font-size: 13px; + gap: 3px 20px; + flex-wrap: wrap; +} +/* 비밀번호 변경 팝업창 */ +.login .modal .modal_container { + width: 750px; +} +.login .pw_desc { + padding: 10px 10px 30px 10px; + color: #666; +} +.login .pw_desc p { + font-size: 1.25rem; + color: #000; + font-weight: 700; + margin-bottom: 10px; +} +.login .pw_desc span { + color: #f60; + font-weight: 700; +} +.login .modal .form_write { + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + padding: 20px 50px; +} +@media (max-width: 1024px) { + .login { + flex-direction: column; + height: auto; + } + .login .left_box { + padding: 30px; + flex-direction: column-reverse; + gap: 20px; + } + .login .left_box .text h1 { + font-size: 30px; + line-height: 1.2em; + } + .login .left_box .text p { + margin-top: 20px; + font-size: 14px; + } + .login .left_box .img img { + height: 40px; + } + .login .login_box { + padding: 30px; + } + .login .login_box > ul { + gap: 20px; + } + .login .login_box > ul > li h2 { + margin-bottom: 7px; + } + .login .login_box > ul > li .input_st::after { + left: 15px; + font-size: 20px; + } + .login .login_box > ul > li input { + padding: 15px 10px 15px 50px; + font-size: 16px; + } + .login .login_box .bottom_box { + flex-direction: column; + align-items: flex-start; + padding: 30px 0; + } + .login .login_box .bottom_box .id_save label { + font-size: 14px; + } + .login .login_box .login_btn { + padding: 15px; + } + .login .login_box .login_btn span { + font-size: 16px; + } + .login .login_box .login_btn i { + font-size: 18px; + transform: translateY(-1px); + } + .login .login_box .copy p { + width: 100%; + } + .login .login_box .copy::before, + .login .login_box .copy::after { + display: none; + } + .login .footer .addr { + font-size: 11px; + gap: 3px 10px; + } + .login .modal .modal_container { + width: 90%; + } + .login .pw_desc { + padding: 0 0 20px 0; + } + .login .modal .form_write { + padding: 10px; + } +} +/* // login */ diff --git a/apps/as/src/pages/home/index.ts b/apps/as/src/pages/home/index.ts new file mode 100644 index 000000000..97a463408 --- /dev/null +++ b/apps/as/src/pages/home/index.ts @@ -0,0 +1 @@ +export { MainPage } from "./ui/main-page"; diff --git a/apps/as/src/pages/home/ui/main-page.tsx b/apps/as/src/pages/home/ui/main-page.tsx new file mode 100644 index 000000000..f757224dc --- /dev/null +++ b/apps/as/src/pages/home/ui/main-page.tsx @@ -0,0 +1,9 @@ +"use client"; + +export function MainPage() { + return ( +
+

Home

+
+ ); +} diff --git a/apps/as/src/shared/README.md b/apps/as/src/shared/README.md new file mode 100644 index 000000000..2b247021d --- /dev/null +++ b/apps/as/src/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/as/src/shared/api/client.ts b/apps/as/src/shared/api/client.ts new file mode 100644 index 000000000..22008492b --- /dev/null +++ b/apps/as/src/shared/api/client.ts @@ -0,0 +1,13 @@ +import { PROXY_HOST, SITE_ID } from "@/shared/config"; +import { Client } from "@common/network"; + +import { setCookie } from "../lib"; + +setCookie("site-id-key", SITE_ID); + +const BASE_URL = `${PROXY_HOST}/server`; + +export const client = Client.getInstance({ + baseURL: BASE_URL, + siteId: SITE_ID, +}); diff --git a/apps/as/src/shared/api/index.ts b/apps/as/src/shared/api/index.ts new file mode 100644 index 000000000..0b96ffaa1 --- /dev/null +++ b/apps/as/src/shared/api/index.ts @@ -0,0 +1 @@ +export { client } from "./client"; diff --git a/apps/as/src/shared/config/api.ts b/apps/as/src/shared/config/api.ts new file mode 100644 index 000000000..154aa1052 --- /dev/null +++ b/apps/as/src/shared/config/api.ts @@ -0,0 +1,9 @@ +export const ENDPOINT = { + USER_SERVICE: { + LOGIN: "/user-service/login", + SILENT_REFRESH: "/user-service/api/v1/users/token/refresh", + USERS: "/user-service/api/v1/users", + }, +}; + +export const JWT_EXPIRATION_TIME = 1800000; diff --git a/apps/as/src/shared/config/env.ts b/apps/as/src/shared/config/env.ts new file mode 100644 index 000000000..4625722bd --- /dev/null +++ b/apps/as/src/shared/config/env.ts @@ -0,0 +1,10 @@ +export const PORT = process.env.NEXT_PUBLIC_PORT || "3000"; +export const PROXY_HOST = process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`; +export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "AS"; +export const SERVER_API_URL = process.env.SERVER_API_URL; +export const AUTH_LOGIN_REDIRECT_URL = + process.env.NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL || "/auth/login"; +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/as/src/shared/config/index.ts b/apps/as/src/shared/config/index.ts new file mode 100644 index 000000000..856d35f87 --- /dev/null +++ b/apps/as/src/shared/config/index.ts @@ -0,0 +1,2 @@ +export * from "./api"; +export * from "./env"; diff --git a/apps/as/src/shared/lib/cookie.ts b/apps/as/src/shared/lib/cookie.ts new file mode 100644 index 000000000..8f5d0011b --- /dev/null +++ b/apps/as/src/shared/lib/cookie.ts @@ -0,0 +1,29 @@ +import Cookies from "js-cookie"; + +export function getCookie(name: string): string | undefined { + if (typeof window === "undefined") { + return undefined; + } + return Cookies.get(name); +} + +export function setCookie( + name: string, + value: string, + options?: Cookies.CookieAttributes, +): void { + if (typeof window === "undefined") { + return; + } + Cookies.set(name, value, options); +} + +export function removeCookie( + name: string, + options?: Cookies.CookieAttributes, +): void { + if (typeof window === "undefined") { + return; + } + Cookies.remove(name, options); +} diff --git a/apps/as/src/shared/lib/dayjs.ts b/apps/as/src/shared/lib/dayjs.ts new file mode 100644 index 000000000..62c6cef6e --- /dev/null +++ b/apps/as/src/shared/lib/dayjs.ts @@ -0,0 +1,13 @@ +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import timezone from "dayjs/plugin/timezone"; +import utc from "dayjs/plugin/utc"; + +import "dayjs/locale/ko"; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(relativeTime); +dayjs.locale("ko"); + +export { dayjs }; diff --git a/apps/as/src/shared/lib/index.ts b/apps/as/src/shared/lib/index.ts new file mode 100644 index 000000000..013c429d1 --- /dev/null +++ b/apps/as/src/shared/lib/index.ts @@ -0,0 +1,4 @@ +export { getCookie, removeCookie, setCookie } from "./cookie"; +export { dayjs } from "./dayjs"; +export { queryClient } from "./query-client"; +export { useUserStore } from "./use-user-store"; diff --git a/apps/as/src/shared/lib/query-client.ts b/apps/as/src/shared/lib/query-client.ts new file mode 100644 index 000000000..0da2fb899 --- /dev/null +++ b/apps/as/src/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/as/src/shared/lib/use-user-store.ts b/apps/as/src/shared/lib/use-user-store.ts new file mode 100644 index 000000000..8aa5085a9 --- /dev/null +++ b/apps/as/src/shared/lib/use-user-store.ts @@ -0,0 +1,16 @@ +import { useContext } from "react"; + +import { useStore } from "zustand"; + +import { StoreContext } from "../providers"; +import type { UserStore } from "../store"; + +export const useUserStore = (selector: (store: UserStore) => T): T => { + const storeContext = useContext(StoreContext); + + if (!storeContext) { + throw new Error("useUserStore must be used within StoreProvider"); + } + + return useStore(storeContext.userStore, selector); +}; diff --git a/apps/as/src/shared/model/index.ts b/apps/as/src/shared/model/index.ts new file mode 100644 index 000000000..15ddf4254 --- /dev/null +++ b/apps/as/src/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/as/src/shared/providers/index.ts b/apps/as/src/shared/providers/index.ts new file mode 100644 index 000000000..e19453b80 --- /dev/null +++ b/apps/as/src/shared/providers/index.ts @@ -0,0 +1 @@ +export { StoreContext, StoreProvider } from "./store-provider"; diff --git a/apps/as/src/shared/providers/store-provider.tsx b/apps/as/src/shared/providers/store-provider.tsx new file mode 100644 index 000000000..4ebb2151e --- /dev/null +++ b/apps/as/src/shared/providers/store-provider.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { createContext, useRef } from "react"; + +import { createUserStore } from "../store"; + +type StoreContextType = { + userStore: ReturnType; +}; + +export const StoreContext = createContext(undefined); + +export const StoreProvider = ({ children }: { children: React.ReactNode }) => { + const storeRef = useRef(null); + if (storeRef.current === null) { + storeRef.current = { + userStore: createUserStore(), + }; + } + + return {children}; +}; diff --git a/apps/as/src/shared/store/index.ts b/apps/as/src/shared/store/index.ts new file mode 100644 index 000000000..8a0f34588 --- /dev/null +++ b/apps/as/src/shared/store/index.ts @@ -0,0 +1 @@ +export { createUserStore, type IUserInfo, type UserStore } from "./user-store"; diff --git a/apps/as/src/shared/store/user-store.ts b/apps/as/src/shared/store/user-store.ts new file mode 100644 index 000000000..65d897cf1 --- /dev/null +++ b/apps/as/src/shared/store/user-store.ts @@ -0,0 +1,57 @@ +import { createStore } from "zustand/vanilla"; + +export interface IUserInfo { + regDt: string; + mdfcnDt: string; + rgtr: string; + mdfr: string; + adminRoleYn: string; + userUnqId: string; + userId: string; + userName: string; + email: string; + encryptedPassword: string; + refreshToken: string; + userStateCode: string; + lastLoginDate: string; + loginFailCount: number; + pswdHnt: string; + pswdAns: string; + mphn: string; + telno: string; + zip: string; + addr: string; + daddr: string; + laeSeCd: string; + ogdpSeCd: string; + aprvYn: string; + autzrId: string; + expryYmd: string; + lastPswdChgDt: string; + googleId: string; + kakaoId: string; + naverId: string; + isSocialUser: boolean; + hasPassword: boolean; +} + +export type UserState = { + userInfo: IUserInfo | null; +}; + +export type UserActions = { + setUserInfo: (userInfo: IUserInfo | null) => void; +}; + +export type UserStore = UserState & UserActions; + +export const defaultInitState: UserState = { + userInfo: null, +}; + +export const createUserStore = (initState: UserState = defaultInitState) => { + return createStore()((set) => ({ + ...initState, + setUserInfo: (userInfo: IUserInfo | null) => set(() => ({ userInfo })), + })); +}; diff --git a/apps/as/src/shared/ui/index.ts b/apps/as/src/shared/ui/index.ts new file mode 100644 index 000000000..460542995 --- /dev/null +++ b/apps/as/src/shared/ui/index.ts @@ -0,0 +1 @@ +export { PageLayout } from "./page-layout"; diff --git a/apps/as/src/shared/ui/page-layout.tsx b/apps/as/src/shared/ui/page-layout.tsx new file mode 100644 index 000000000..ecefc36bd --- /dev/null +++ b/apps/as/src/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/as/src/widgets/README.md b/apps/as/src/widgets/README.md new file mode 100644 index 000000000..cd160abd9 --- /dev/null +++ b/apps/as/src/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/tailwind.config.ts b/apps/as/tailwind.config.ts new file mode 100644 index 000000000..d19dec7ed --- /dev/null +++ b/apps/as/tailwind.config.ts @@ -0,0 +1,26 @@ +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)", + }, + }, + }, + plugins: [], +}; + +export default config; diff --git a/apps/as/tsconfig.json b/apps/as/tsconfig.json new file mode 100644 index 000000000..dab9b0b8e --- /dev/null +++ b/apps/as/tsconfig.json @@ -0,0 +1,29 @@ +{ + "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": { + "@/*": ["./src/*"], + "@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/cm/src/pages/users/ui/users-form.tsx b/apps/cm/src/pages/users/ui/users-form.tsx index 7920ce758..a8a5a86f3 100644 --- a/apps/cm/src/pages/users/ui/users-form.tsx +++ b/apps/cm/src/pages/users/ui/users-form.tsx @@ -102,35 +102,35 @@ function UsersForm({ return ( authSeCd?.filter( (code) => - code.lwpoCd === "ROLE_USER1" || - code.lwpoCd === "ROLE_USER2" || - code.lwpoCd === "ROLE_USER3", + code.lwpoCd === "ROLE_USER01" || + code.lwpoCd === "ROLE_USER02" || + code.lwpoCd === "ROLE_USER03", ) ?? [] ); if (watchOgdpSeCd === "3" || watchOgdpSeCd === "4") return ( authSeCd?.filter( (code) => - code.lwpoCd === "ROLE_USER4" || code.lwpoCd === "ROLE_USER5", + code.lwpoCd === "ROLE_USER04" || code.lwpoCd === "ROLE_USER05", ) ?? [] ); return []; }, [authSeCd, watchOgdpSeCd]); const laeSeCdSelectData = useMemo(() => { - if (watchDtlInstCd === "ROLE_USER1") + if (watchDtlInstCd === "ROLE_USER01") return laeSeCd?.filter((code) => code.lwpoCd === "G102") ?? []; - if (watchDtlInstCd === "ROLE_USER2") + if (watchDtlInstCd === "ROLE_USER02") return laeSeCd?.filter((code) => code.lwpoCd === "G101") ?? []; - if (watchDtlInstCd === "ROLE_USER3") + if (watchDtlInstCd === "ROLE_USER03") return laeSeCd?.filter((code) => code.lwpoCd.startsWith("G2")) ?? []; - if (watchDtlInstCd === "ROLE_USER4") + if (watchDtlInstCd === "ROLE_USER04") return ( laeSeCd?.filter( (code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 4, ) ?? [] ); - if (watchDtlInstCd === "ROLE_USER5") + if (watchDtlInstCd === "ROLE_USER05") return ( laeSeCd?.filter( (code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 7, @@ -622,8 +622,9 @@ function UsersForm({ onValueChange={field.onChange} disabled={ !( - watchDtlInstCd === "ROLE_USER4" || - watchDtlInstCd === "ROLE_USER5" + watchDtlInstCd === "ROLE_USER03" || + watchDtlInstCd === "ROLE_USER04" || + watchDtlInstCd === "ROLE_USER05" ) } > diff --git a/apps/cs/docs/spec/ntftalk/temp/SPEC.md b/apps/cs/docs/spec/ntftalk/temp/SPEC.md index 0b84ace4a..81f361925 100644 --- a/apps/cs/docs/spec/ntftalk/temp/SPEC.md +++ b/apps/cs/docs/spec/ntftalk/temp/SPEC.md @@ -260,3 +260,79 @@ BEGIN END ``` + +--- + +## 9. 템플릿 등록 다이얼로그 + +대상 UI: `NtftalkTempRegistDialog` (`apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-regist-dialog.tsx`) + +### 9-1. 목적/오픈 조건 + +- 목적: 신규 알림톡 템플릿 1건을 등록한다. +- 오픈 조건: 영역 B 툴바의 `템플릿등록` 클릭 시 모달 오픈. +- 저장 성공 시: 목록 캐시 무효화 후 영역 B 재조회 상태로 복귀(모달 닫힘). + +### 9-2. 다이얼로그 레이아웃 + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ 알림 템플릿 등록 │ +├──────────────────────────────────────────────────────────────────────┤ +│ 메시지 유형 [기본형(고정)] │ +│ 채널 [Select(senderKey)] │ +│ 템플릿코드 [Input] │ +│ 템플릿명 [Input] │ +│ 보안템플릿여부 ( )설정 ( )미설정 │ +│ 템플릿내용 [+ 변수추가] │ +│ [Textarea] │ +│ 버튼 Table(버튼타입|버튼명|버튼링크) │ +│ [CodeSelect(upCd: NTFBUTTONTYPE) | Input | Input] │ +│ 바로연결 Table(바로연결타입|바로연결명|바로연결링크) │ +│ [CodeSelect(upCd: NTFQRTYPE) | Input | Input] │ +├──────────────────────────────────────────────────────────────────────┤ +│ [저장] [취소] │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 9-3. 필드 상세 + +| 표시명 | Form Key | 입력 유형 | 필수 | 기본값 | 검증/제약 | 저장 Payload 매핑 | +| -------------- | ----------------------- | ----------- | ---- | ------------------------------------- | -------------------- | --------------------- | +| 메시지 유형 | `templateMessageType` | 고정 텍스트 | Y | `BA` | UI에서 `기본형` 고정 | `templateMessageType` | +| 채널 | `senderKey` | Select | Y | `senderQuery.data.senderKey` 자동세팅 | 미선택 불가 | `senderKey` | +| 발신키 유형 | `senderKeyType` | hidden 상수 | Y | `S` | submit 시 강제 `S` | `senderKeyType` | +| 템플릿코드 | `templateCode` | Input | Y | `""` | 1자 이상 | `templateCode` | +| 템플릿명 | `templateName` | Input | Y | `""` | 1자 이상 | `templateName` | +| 보안템플릿여부 | `templateEmphasizeType` | Radio | N | `false` | `true` / `false` | `pcflag` | +| 템플릿내용 | `templateContent` | Textarea | Y | `""` | 1자 이상 | `templateContent` | +| 버튼 | `buttons` | Table | N | `[]` | 빈 배열 | `buttons` | +| 바로연결 | `quickReplies` | Table | N | `[]` | 빈 배열 | `quickReplies` | + +### 9-4. 버튼/이벤트 상세 + +| 트리거 | 처리 로직 | 비고 | +| ---------------- | ----------------------------------------------- | ------------------------------------ | +| 템플릿등록(외부) | 다이얼로그 open = true, form reset | 진입 시 초기화 | +| 저장 | zod 검증 통과 후 `saveNtftalkTemp` 호출 | 성공 시 `등록되었습니다.` 알림 | +| 저장 성공 | `invalidateQueries([NTFTALK_TEMP])` → 모달 닫기 | 목록 최신화 | +| 저장 실패 | 공통 mutation error 처리 | 에러 메시지 표시 정책 공통 모듈 따름 | +| + 변수추가 | 변수 선택 팝업 open | 9-5 참조 | + +### 9-5. 변수추가 팝업 상세 + +| 항목 | 내용 | +| ----------- | -------------------------------------------------------------------- | +| 팝업명 | 변수 목록 | +| 데이터 소스 | `NtftalkVarApi.getNtftalkVarPage` | +| 조회 조건 | `sort=regDt,desc`, 페이지네이션(`pageIndex/pageSize`) | +| 선택 방식 | 다중 선택 (`RowSelectionState`) | +| [추가] 동작 | 선택된 `varCd`를 줄바꿈(`\n`)으로 `templateContent` 하단에 append | +| 유효성 체크 | 선택 0건: "추가할 변수를 선택해 주세요." / `varCd` 공백 포함 시 에러 | +| [취소] 동작 | 팝업 닫기(선택값 유지 안 함) | + +### 9-6. API 명세 + +| 기능 | Method | URL | Request DTO | Response DTO | 클라이언트 API | +| ----------- | ------ | --------------- | ------------------------ | ------------- | -------------------------------- | +| 템플릿 등록 | POST | `/ntftalk-temp` | `NtftalkTempSavePayload` | `NtftalkTemp` | `NtftalkTempApi.saveNtftalkTemp` | diff --git a/apps/cs/docs/표준단어정의서.md b/apps/cs/docs/표준단어정의서.md deleted file mode 100644 index 6950eb84b..000000000 --- a/apps/cs/docs/표준단어정의서.md +++ /dev/null @@ -1,831 +0,0 @@ -# 표준단어정의서 - -AI 이해용으로 정리된 표준단어명, 영문약어명, 영문명 목록입니다. - -| 표준단어명 | 표준단어 영문약어명 | 표준단어 영문명 | -|-----------|-------------------|----------------| -| BOD | BOD | Biochemical Oxygen Demand | -| C/N비 | CNR | Carbon-Nitrogen Ratio | -| COD | COD | Chemical Oxygen Demand | -| F/M비 | FMR | Food-Microorganism Ratio | -| GISD | GISDATA | Geographic Information System Data | -| HTML | HTML | Hypertext Markup Language | -| ID | ID | Id | -| IP | IP | Internet Protocol | -| KEY | KEY | Key | -| ND | ND | Not Detected | -| NON | NON | Non | -| NTU | NTU | Nephelometry Turbidity Unit | -| OPT | OPT | Opt | -| PAC | PAC | Polyaluminium Chloride | -| PH | PH | Hydrogen Exponent | -| PKI | PKI | Public Key Infrastructure | -| POOL | POOL | Pool | -| SS | SS | Suspended Solids (Ss) | -| TCE | TCE | Trichloroethane | -| TM | TM | Transverse Mercator | -| T-N | TN | Total Nitrogen | -| TOC | TOC | Total Organic Carbon | -| T-P | TP | Total Phosphorus | -| URL | URL | Uniform Resource Locator | -| 가구 | HSHD | Household | -| 가능 | PSBL | Possible | -| 가동 | OPR | Operation | -| 가산 | ADTN | Addition | -| 가성소다 | NAOH | Caustic Soda(Nach) | -| 가축분뇨 | ANMA | Animal Manure | -| 값 | VAL | Value | -| 강도 | IN | Intensity | -| 강우 | RAIN | Rain | -| 개방 | OPN | Opening | -| 개별 | INDIV | Individual | -| 개보수비 | RPAFEE | Repair Fee | -| 개설 | ESTBL | Establishment | -| 개시 | STRT | Start | -| 개정 | RVSN | Revision | -| 개조 | REMDEL | Remodeling | -| 개체 | INDVD | Individual | -| 개체군 | POPU | Population | -| 개폐 | RORGN | Reorganization | -| 갱신 | UPDT | Update | -| 건물 | BLDG | Building | -| 건설 | CONC | Construction | -| 건수 | NOCS | The Number Of Cases | -| 건조 | DRY | Dry | -| 검사 | INSP | Inspection | -| 검사서 | EXRPT | Examination Report | -| 검정 | RCGN | Recognition | -| 검증 | VRFC | Verification | -| 검출 | DETECT | Detection | -| 검토 | RVW | Review | -| 격자 | LATTC | Lattice | -| 결과 | RSLT | Result | -| 결의 | DCIS | Decision | -| 결정 | DCSN | Decision | -| 경계 | BNDRY | Boundary | -| 경과 | PROC | Process | -| 경도 | LOT | Longitude | -| 경보 | ALARM | Alarm | -| 경사판 | ICNTPT | Inclination plate | -| 계 | SUM | Summation | -| 계량기 | MRNR | Measuring Instrument | -| 계량기분리 | METRSPRT | Meter Separation | -| 계면 | SGLV | Sludge Level | -| 계산식 | FMUL | Formula | -| 계약 | CTRT | Contract | -| 계약기간 | CNTRPD | Contract Period | -| 계절 | SEASON | Season | -| 계획 | PLAN | Plan | -| 고도처리 | AWTR | Advanced Water Treatment | -| 고시 | ANCMNT | Announcement | -| 고액분리 | SLSPRT | Solid-Liquid Separation | -| 고형물체류시간 | SRT | Solid Retention Time | -| 공간데이터 | SPCEDATA | Space Data | -| 공공 | PUBLIC | Public | -| 공급 | SPLY | Supply | -| 공기 | AIR | Air | -| 공기여과 | ARFT | Air Filter | -| 공문 | ODOC | Official Documnet | -| 공법 | MSRC | Method Of Construction | -| 공사 | CSTRN | Construction | -| 공사비 | CONSTEXPN | Construction Expenses | -| 공업지역 | MNFTR_ARA | Manufacturing Area | -| 공용면적 | CMAR | Common Area | -| 공통 | CMMN | Commonness | -| 관 | PP | Pipe | -| 관계 | REL | Relation | -| 관련 | RLTN | Relation | -| 관로 | PPLINE | Pipe Line | -| 관리 | MNG | Management | -| 관리기관 | MGC | Management Agency | -| 관리자 | MNGR | Manager | -| 관찰 | WTCH | Watch | -| 관측 | OBSR | Observation | -| 관측소 | OBSRVT | Observatory | -| 교반기 | AGITOR | Agirator | -| 교정 | CRRC | Correction | -| 구간 | SCTN | Section | -| 구동 | DRIVE | Drive | -| 구분 | SE | Section | -| 구성 | CMPSG | Composing | -| 구역 | ZONE | Zone | -| 구조 | STRCT | Structure | -| 권역 | DSTR | District | -| 권한 | AUTH | Authority | -| 규격 | STNDRD | Standard | -| 규모 | SCAL | Scale | -| 규제 | REGL | Regulation | -| 균등 | EQLTY | Equality | -| 금액 | AMT | Amount | -| 급수 | WSP | Water Supply | -| 기간 | WHL | While | -| 기관 | INST | Institution | -| 기록 | RECD | Recording | -| 기록부 | REGI | Register | -| 기밀 | SCRET | Secret | -| 기법 | TECQU | Techniques | -| 기본 | BSIC | Basics | -| 기본정보 | BASINF | Bassinfo | -| 기본키 | PK | Primary Key | -| 기상 | WETHER | Weather | -| 기안 | DRFTG | Drafting | -| 기재 | WRIT | Write | -| 기점 | CDPNT | Cardinal Point | -| 기존 | LGCY | Legacy | -| 기준 | CRTR | Criteria | -| 기타 | ETC | Etc | -| 기한 | TERM | Term | -| 기후 | CLMT | Climate | -| 긴급 | EMRG | Emergency | -| 날개벽 | WW | Wing Wall | -| 날씨 | WETHR | Weather | -| 날짜 | DATE | DATE | -| 남조류 | BLAL | Blue-Green Algae | -| 납 | PB | Plumbum | -| 내려받기 | DWLD | Download | -| 내부반송 | ITNRCC | Internal Recycl | -| 내용 | CN | Content | -| 내용연수 | SVLF | Service Life | -| 냄새 | SMELL | Smell | -| 년 | YY | Year | -| 년간 | FAYR | For A Year | -| 노즐 | NZL | Nozzle | -| 노출 | EXPSR | Exposure | -| 농도 | DNSTY | Density | -| 농축 | TKN | Thickened | -| 누적 | ACML | Accumulate | -| 누출 | LKG | Leakage | -| 눈 | SNW | Snow | -| 단가 | UNPRC | Unit Price | -| 단기 | SRTPD | Short Period | -| 단순 | SIMPL | Simplicity | -| 단위 | UNIT | Unit | -| 대구역 | LZONE | Large Zone | -| 대분류 | LCLAS | Large Classification | -| 대장 | REGSTR | Register | -| 대장균 | COLI | Coliform, Fecal Coliform | -| 대장균군 | COLIB | Coliform Bacteria | -| 대지면적 | SIAR | Site Area | -| 대체 | RPLC | Replace | -| 덮개 | COVR | Cover | -| 데이타 | DAT | Data | -| 도구 | UNT | Unit | -| 도래 | ADVNT | Advent | -| 도로 | ROAD | Road | -| 도로명 | RN | Road Name | -| 도시 | CTY | City | -| 도엽 | MAPDMC | Map Demarcation | -| 도표 | CHART | Chart | -| 독성 | TOXCTY | Toxicity | -| 동의 | AGRE | Agreement | -| 동절기 | WSESN | The Winter Season | -| 등급 | GRD | Grade | -| 등기 | RG | Registered | -| 등록 | REG | Registration | -| 디바이스 | DVICE | Device | -| 디코딩 | DCD | Decoding | -| 레이어 | LYR | Layer | -| 로그인 | LGN | Login | -| 로우 | ROW | Row | -| 마감 | DDLN | Deadline | -| 마이크로필터 | MCRFT | Micro Filter | -| 막 | MBRN | Membrane | -| 만족도 | STFDG | Satisfaction Degree | -| 맑음 | CLAR | Clear | -| 매각 | SAL | Sale | -| 맨홀 | MH | Manhole | -| 맵 | MAP | Map | -| 메인 | MAIN | Main | -| 메일 | EMAIL | Email | -| 메탄올 | MTN | Methanol(CH3OH) | -| 명 | NM | Name | -| 모니터링 | MTRG | Monitoring | -| 모델 | MODL | Model | -| 모델링 | MODLNG | Modeling | -| 모래 | SAND | Sand | -| 목 | MOK | Mok | -| 무산소 | ANX | Anoxic | -| 무산소조 | ANX_TANK | Anoxic Tank | -| 무인센서 | MNLSENSR | Manless Sensor | -| 문서 | DCMT | Document | -| 물 | WTR | Water | -| 미급수 | NONWTRSPL | Non Water Supply | -| 미생물 | BLGC | Biological | -| 미세먼지 | PM | Particulate Matter | -| 미세목 | MCRB | Micro Bar | -| 민간위탁 | ETSVN | Entrustment To Civilian | -| 민감 | SST | Sensitive | -| 민원 | CVLCPT | Civil Complaint | -| 바이러스 | VIRS | Virus | -| 반기 | HT | Half Term | -| 반류수 | SIDSWT | Side Stream Water | -| 반송 | SNDBK | Send Back | -| 반송수 | RCCWT | Recycled Water | -| 반송슬러지 | RTNSLG | Return Sludge | -| 반영 | RFLT | Reflection | -| 반응조 | RETNK | Reactant Tank | -| 반출 | EXPRT | Export | -| 반토 | ALUM | Alumina | -| 반환 | GVBK | Giving Back | -| 받이 | INL | Inlet | -| 발생 | OCRN | Occurrence | -| 발전 | PWGNRT | Power Generation | -| 발주기관 | ORNT | Order Institution | -| 방류 | DCWTR | Discharge Water Stock Etc | -| 방류구 | DSCHG | Discharge | -| 방류수 | EFLEC | Effluence | -| 방문 | VST | Visit | -| 방문자 | VSTR | Visitor | -| 방법 | MTHD | Method | -| 방사 | RLSE | Release | -| 배관 | PLINE | Pipeline | -| 배분 | DSTRB | Distirbution | -| 배설물 | EXCRTN | Excretion | -| 배수 | DRNG | Drainage | -| 배수구역 | DRANGZN | Drainage Zone | -| 배출 | EXHST | Exhaust | -| 배출가스 | EMGA | Emission Gases | -| 배출구 | VENT | Vent | -| 배출시설 | DHGP | Discharge Equipment | -| 배출시험 | EXHTST | Exhaust Test | -| 배출자 | DCHGR | Discharge Person | -| 밸브 | VALVE | Valve | -| 번호 | NO | Number | -| 법 | LAW | Law | -| 법적 | LGL | Legal | -| 법정 | STTY | Statutory | -| 법정동 | STDG | Statutory Dong(Administrative District) | -| 법정리 | STLI | Statutory Li(Administrative District) | -| 변경 | CHG | Change | -| 변동 | FLCTN | Fluctuation | -| 변속기 | TRMIS | Transmission | -| 보고 | RPT | Report | -| 보고서 | RPOT | Report | -| 보관 | STORG | Storage | -| 보수 | MEND | Mend | -| 보유 | HOLD | Hold | -| 보정 | RVSION | Revision | -| 보조 | ASSISC | Assistance | -| 보조금 | SBSIDY | Subsidy | -| 복개 | COVE | Cover | -| 복토 | CWTH | Covering With Soil | -| 본류 | MI_RV | Main River | -| 본번 | MNO | Main Number | -| 부검 | ATPSY | Autopsy | -| 부과 | LEVY | Levy | -| 부과금 | LVYAMT | Levy Amount | -| 부기 | BUKIP | Bookkeeping | -| 부담금 | ALOTM | Allotment | -| 부번 | SNO | Sub Number | -| 부산물 | BYPRD | By-Product | -| 부서 | DEPT | Department | -| 부숙화 | DECPZ | Decomposed ~Ize | -| 부적합 | IMPPRTY | Impropriety | -| 부착 | ASN | Adhesion | -| 부품 | CMPNT | Components | -| 부하량 | LOADQTY | Load Quantity | -| 분과 | DIVSN | Division | -| 분뇨 | ENSO | Excreta, Night Soil | -| 분류 | CL | Classification | -| 분류군 | TAXON | Taxon | -| 분리막 | SPRTMBR | Separation Membrane | -| 분배 | DTRBT | Distribution | -| 분배조 | DTRBT_TANK | Distribution Tank | -| 분야 | RLM | Realm | -| 분원성 | FECL | Fecal | -| 분원성대장균군 | FCOLI | Fecal Coliform | -| 분원성연쇄상구균 | FCSTRCCI | Fecal Streptococci | -| 분할 | PRTIN | Partition | -| 비밀번호 | PSWD | Password | -| 비상 | EMGNY | Emergency | -| 비정상 | ABNRM | Abnormality | -| 사양 | SPECS | Specification | -| 사업비 | WCT | Working Cost | -| 사업장 | BPLC | Business Place | -| 사여과지 | SAND_ FILTR | Sand Filter | -| 사용 | USE | Use | -| 사용가능 | USABLE | Usable | -| 사용권 | RIOUS | Right Of Using | -| 사용량 | USG | USAGE | -| 사용자 | USER | User | -| 사위 | FDG | Four Directions Girth | -| 사유 | RESN | Reason | -| 사이클 | CYCLE | Cycle | -| 사이트 | SITE | Site | -| 사진 | PHOT | Photograph | -| 사항 | MATTR | Matter | -| 사후 | AFTFAT | After Fact | -| 삭제 | DEL | Deletion | -| 산기관 | DFS | Diffuser | -| 산업 | INDUST | Industrial | -| 산화환원전위 | ORP | Oxidation Reduction Potential | -| 살모넬라 | SLMN | Salmonella | -| 삼원촉매장치 | TWCTLC | Three Way Catalytic Converter | -| 상세 | DTL | Details | -| 상세주소 | DADDR | Detailed Address | -| 상수 | WT | Water | -| 상업지역 | CMCL_ARA | Commercial Area | -| 상태 | STUS | Status | -| 상품 | GDS | Goods | -| 상향류 | UFW | Upflow | -| 상호 | CONM | Company Name | -| 색도 | CRMTY | Chromaticity | -| 생략 | OMS | Omission | -| 생물 | LVB | Living Being | -| 생물반응조 | BIRAT | Bioreactor | -| 생물서식처 | ORGNHBT | Organism Habitat | -| 생물종 | BISP | Biological Species | -| 생성 | CRT | Creation | -| 생슬러지 | RSLG | Raw Sludge | -| 생태 | ECLGY | Ecology | -| 생태독성 | ECTOX | ecotoxicity | -| 서류 | PAPE | Papers | -| 서명 | SIGN | Signing | -| 서버 | SRVR | Server | -| 서비스 | SRVC | Service | -| 설계 | DESGN | Design | -| 설계용량 | DESGN_CPCT | Design Capacity | -| 설립 | FNDN | Foundation | -| 설정 | STNG | Setting | -| 설치 | INSTL | Installation | -| 성격 | CHTR | Character | -| 성공 | SCS | Success | -| 성능 | PRFMNC | Performance | -| 성능검사 | EFTST | Efficiency Test | -| 성적서 | SCRRP | Score Report | -| 세균 | BCLS | Bacillus | -| 세목 | FB | Fine bar | -| 세부 | DETAIL | Detail | -| 세정 | WS | Washing | -| 세정제 | CNML | Cleaning Material | -| 소 | SML | Small | -| 소독 | DSNF | Disinfection | -| 소독제 | DSINF | Disinfectant | -| 소분류 | SCLAS | Small Classification | -| 소수점 | DCPT | Decimal Point | -| 소요 | REQ | Required | -| 소유 | OWN | Own | -| 소유역 | SBASIN | Sub Basin | -| 소재지 | PLAC | Place Of Location | -| 소포 | ATFM | Antifoaming | -| 소포수 | FBW | Foam Breaking Water | -| 소포제 | ANAG | Antifoaming Agent | -| 송부 | SENDNG | Sending | -| 송신 | SNDM | Send Message | -| 송풍기 | BLW | Blower | -| 수 | CNT | Count | -| 수거 | TKAWY | Take Away | -| 수계 | WWBH | Watershed, Water Basin, Hydrosphere | -| 수동 | HOPR | Hand Operation | -| 수리학적 체류시간 | HRT | Hydraulic Retention Time | -| 수문 | Gate | Gate | -| 수산화나트륨 | SOHY | Sodium Hydroxide | -| 수소이온 | HION | Hydrogen Ion | -| 수신 | RCPTN | Reception | -| 수용가 | RCPTR | Receptor | -| 수위 | WAL | Water Level | -| 수정 | MDFCN | Modification | -| 수정자 | MDFR | Modifier | -| 수제 | SPRDIKE | Superdike | -| 수조 | Tank | Tank | -| 수질 | QTWT | Quality Of Water | -| 수질개선 | WTRQLIMP | Water Quality Improvement | -| 수질기준 | WQST | Water Quality Standards | -| 수질배출정보 | WTDI | Water Discharge Information | -| 수집 | CLCT | Collection | -| 순환 | CCL | Cycling | -| 쉬겔라 | SHIGLA | Shigella | -| 스컴 | SCM | Scum | -| 스크린 | SCR | Screen | -| 슬러지 | SLG | Sludge | -| 슬러지 밀도지수 | SDI | sludge density index | -| 슬러지 용량 지표 | SVI | Sludge Volume Index | -| 습지 | WTLN | Wetland | -| 승인 | APRV | Approval | -| 승인권 | APPRVRGT | Right Of Approval | -| 시각 | TIME | Time | -| 시간 | HR | Hour | -| 시군구 | SGG | Si/Gun/Gu | -| 시나리오 | SCEN | Scenario | -| 시도 | CTPV | Cities And Provinces | -| 시료 | SMPL | Sample | -| 시설 | FCLT | Facility | -| 시설면적 | FCAR | Facility Area | -| 시설물 | FCLTS | Facilities | -| 시스템 | SYS | System | -| 시안 | CYN | Cyaan | -| 시운전 | TSTRN | Test Run | -| 시작 | BGNG | Beginning | -| 시점 | PTM | Point Time | -| 시행 | ENFC | Enforcement | -| 시험 | TEST | Test | -| 신고 | DCLR | Declaration | -| 신고자 | DCL | Declarer | -| 신규 | NEW | New | -| 신기술 | NTCH | New Technology | -| 신뢰 | CNFDNC | Confidence | -| 신뢰성 | CRDIBLTY | Credibility | -| 실시 | OPRT | Operation | -| 실적 | ACRS | Actual Result | -| 실행 | EXCN | Execution | -| 심사 | SRNG | Screening | -| 심의 | DLBR | Deliberation | -| 아황산환원혐기성포자형성균 | SFSRA | Spore Forming Sulfite Reducing Anaerobes | -| 안정화 | STBLZT | Stabilization | -| 암롤박스(컨테이너) | ARTCT | Arm roll type container | -| 암모니아성질소 | NHN | Ammonia Nitrogen | -| 압력 | PRESSR | Pressure | -| 압송 | PUMPNG | Pumping | -| 압축 | CMPRS | Compress | -| 액비 | LQFERTLZ | Liquid Fertilizer | -| 액상 | LIQD | Liquid | -| 약 | GENTLE | Gentle | -| 약품 | CMC | Chemical | -| 약품비 | MEDFEE | Medicine Fee | -| 약품사용 | USMD | Use Medicines | -| 업로드 | UPLD | Upload | -| 업무 | DTY | Duty | -| 없음 | NOT | Nothing | -| 에너지 | ENR | Energy | -| 에틸벤젠 | ETBNZN | Ethylbenzene | -| 여과 | FILTR | Filteration | -| 여과수 | FTWT | Filtered Water | -| 여과재 | FTMTRL | Filter Material | -| 여과형 | FILTRFOM | Filter Form | -| 여부 | YN | Yes or No | -| 여시니아균 | YERSNA | Yersinia | -| 역률 | PWRER_FCT | Power Factor | -| 역세척 | BWS | Back Wash | -| 연결 | CONN | Connection | -| 연계 | LINK | Link | -| 연도 | YR | Year | -| 연락 | CNTC | Contact | -| 연령 | AGE | Age | -| 연료화 | FUELZ | Fuelize | -| 연월 | YM | Year and Month | -| 염소 | CLRN | Chlorine | -| 염소소독 | CHDI | Chlorine Disinfection | -| 염소이온 | CLION | Chlorine Ion | -| 염화제이철 | IRCLD | Iron(III) Chloride | -| 예보 | FRCST | Forecast | -| 예비 | PRPT | Preparation | -| 예산 | BGT | Budget | -| 예상 | EXPCT | Expectation | -| 예약 | RSVT | Reservation | -| 예외 | EXCPT | Exception | -| 예정 | PRNG | Prearrangement | -| 예찰 | PRDICT | Prediction | -| 오류 | ERR | Error | -| 오수받이 | HUS_INL | House Inlet | -| 오염물질 | CNTMN | Contaminant | -| 오염원 | POLUSRC | Pollution Source | -| 오존 | OZN | Ozone | -| 오프로드 | OFFRD | Off Road | -| 옥살산 | OXACID | Oxalic Acid | -| 와류 | VORTEX | Vortex | -| 와류형 | VORTXFOM | Vortex From | -| 완결 | COMPLT | Completion | -| 완료 | CMPTN | Completion | -| 완전 | PRFECT | Perfection | -| 요건 | REQSIT | Requisite | -| 요막액 | ALLNTFLD | Allantoic Fluid | -| 요청 | DMND | Demand | -| 용량 | CPCT | Capacity | -| 용역 | SERVC | Service | -| 용존산소 | DO | Dissolved Oxygen | -| 용해 | DSLV | Dissolving | -| 우량 | EXLT | Excellent | -| 우수 | ST | Storm | -| 우수받이 | STRT_INL | Street Inlet | -| 우수토구 | STM_OVFL_MUT | Storm Overflow Mouth | -| 우회 | BP | BY PASS | -| 운반 | CNVY | Conveyance | -| 운영 | OPER | Operation | -| 운영비 | OPEXP | Operation Expense | -| 운행 | OPRAT | Operation | -| 원격 | RMRT | Rmrt | -| 원격감시체계 | TMS | Tele Monitoring System | -| 원료 | MTRAL | Material | -| 원수 | ORGWT | Origin'S Water | -| 원인자 | ORGPR | The Origin Person | -| 월 | MM | Month | -| 위도 | LAT | Latitude | -| 위반 | CNTR | Contravene | -| 위촉 | ENTRST | Entrusting | -| 위치 | LOC | Location | -| 위탁 | CNSG | Consign | -| 위탁자 | CONSGNR | Consignor | -| 유기인 | ORGP | Organic Phosphorus | -| 유량 | FLUX | Flux | -| 유량조정조 | EQLZT__TANK | Equalization Tank | -| 유입 | INFlOW | Inflow | -| 유입수 | INFWTR | Influent Water | -| 유주 | SWARMG | Swarming | -| 유지 | MNTNC | Maintenance | -| 유지용수공급 | MWRS | Maintenance Water Requirement Supply | -| 유체 | FLUID | Fluid | -| 유출 | EFFLUX | Efflux | -| 유충 | LAVL | Larval | -| 유형 | TY | Type | -| 유효 | VLD | Validity | -| 음용 | DKPS | For Drinking Purpose | -| 읍면동 | EMD | Eup/Myun/Dong | -| 응답 | RSPNS | Response | -| 응집제 | COAG | Cohesive Agents | -| 이관 | TRNSF | Transfer | -| 이동 | MVMN | Movement | -| 이력 | HST | History | -| 이메일 | EML | Email | -| 이상 | ABOVE | Above | -| 이송 | TRSF | Transfer | -| 이의 | OBJC | Objection | -| 이차 | SCDR | Secondary | -| 이차침전지 | SCDR_SDMT_TANK | Secondary Sedimaentation | -| 이행 | MIG | Migratation | -| 인건비 | PEXP | Personnel Expenses{Expenditure] | -| 인계 | HNDVR | Handover | -| 인계서 | HDOVDC | Handover Document | -| 인구 | POPL | Population | -| 인산 | PHACID | Phosphoric Acid | -| 인산염인 | POP | phosphate | -| 인양기 | LFT | Lifter | -| 인원수 | NOPE | The Number of Persons | -| 인증 | CERT | Certification | -| 인처리 | PPRCS | Phosphorus Process | -| 인허가 | APVL | Approval | -| 일 | DY | Day | -| 일련번호 | SN | Serial Number | -| 일반 | GNRL | General | -| 일별 | DALY | Daily | -| 일시 | DT | Date and Time | -| 일자 | YMD | YEAR Month Day | -| 일지 | DIARY | Diary | -| 일차 | PRM | Primary | -| 일차침전지 | PST_SDMT_TANK | Primary Sedimentation Tank | -| 일평균 | DYRG | Day Average | -| 임시 | TMPR | Temporary | -| 임의 | RNDM | Random | -| 입고 | WRHS | Warehousing | -| 입력 | INPT | Input | -| 잉여 | SPS | Surplus | -| 잉여슬러지 | EC_SLG | Excess Sludge | -| 자 | MAN | Man | -| 자갈 | GRAVEL | Gravel | -| 자료 | DATA | Data | -| 자리 | DIGIT | Digit | -| 자립화 | SLFRLI | Self Reliance | -| 자연형 | NATRLFOM | Natural Form | -| 자외선 | URLI | Ultraviolet Rays, Light | -| 자재 | MTRIL | Material | -| 자체 | ITSL | Itself | -| 작성 | WRT | Writing | -| 작업 | JOB | Job | -| 잠금 | LOCK | Lock | -| 장기 | LNGTR | Long Term | -| 장소 | PLCE | Place | -| 장착 | MNTNG | Mounting | -| 재 | RE | Re | -| 재이용 | REUSE | Reuse | -| 재이용수 | | Reuse Water | -| 재활용 | RUSE | Reuse | -| 저감 | REDUC | Reduction | -| 저류조 | DTT_TANK | Detention Tank | -| 저온 | LOWTMP | Low Temperature | -| 저장 | STRG | Storage | -| 저장소 | STORGE | Storage | -| 적발 | DSCL | Disclose | -| 적산 | ADD | Addition | -| 적용 | APLCN | Application | -| 적합 | STBLT | Suitability | -| 전기 | ELCTY | Electricity | -| 전기료 | ELFEE | Electricity Fee | -| 전력 | PWRER | Power | -| 전문 | SPCY | Specially | -| 전산 | CMPUTT | Computation | -| 전송 | TRSM | Transmission | -| 전압 | VLTGE | Voltage | -| 전용 | EXUSE | Exclusive Use | -| 전원 | SELCT | Source Of Electricity | -| 전처리 | PRETR | Pretreatment | -| 점검 | CHCK | Check | -| 접속 | CNTN | Connection | -| 정밀 | PRCIS | Precision | -| 정보 | INFO | Information | -| 정산 | CLCLN | Calculation | -| 정상 | NRMLT | Normality | -| 정수장 | WPPW | Water Purification Plant, Waterworks | -| 정제 | REFINE | Refining | -| 정지 | STP | Stop | -| 제거 | RMV | Removal | -| 제수변 | SL_VALVE | Sluice Valve | -| 제작 | MNFCT | Manufacture | -| 제조사 | MKR | Maker | -| 제출 | SBMSN | Submission | -| 제한 | LIMT | Limitation | -| 조류 | ALG | Algae | -| 조목 | CSB | Coarse Bar | -| 조사 | EXMN | Examination | -| 조성 | MAKE | Make | -| 조정 | EQLT | Equalization | -| 조치 | ACTN | Action | -| 조회 | INQ | Inquiry | -| 존재 | EXST | Existence | -| 종결 | TRMN | Termination | -| 종균제 | MICBA | Microbial Agent | -| 종료 | END | End | -| 종류 | KND | Kind | -| 종목 | IEM | Item | -| 종별 | ASORT | Assortment | -| 종사자 | ENFSN | Enfilade Person | -| 종점 | TMNL | Terminal | -| 좌표 | COOR | Coordinate | -| 주간 | WIK | Week | -| 주변 | CFR | Circumference | -| 주소 | ADDR | Address | -| 주입 | IJTN | Injection | -| 주파수 | FQNC | Frequency | -| 준공 | CMCN | Completion Of Construction | -| 중권역 | MDSTRC | Medium District | -| 중복 | DPCN | Duplication | -| 중분류 | MLSFC | Middle Classification | -| 중온 | MESPH | Mesophilic | -| 중첩 | RETRTN | Reiteration | -| 중화설비 | NUTEQP | Neutralization Equipment | -| 중화제 | CNTAG | Counteragent | -| 증가 | INCRS | Increase | -| 지도점검표사본 | CITBMR | Copy of Instruction Checklist | -| 지방자치단체 | LGOV | Local Governments | -| 지번 | LOTNO | Lot Number | -| 지상 | GRND | Ground | -| 지역 | ARA | Area | -| 지질 | NTRFS | Nature Of Soil | -| 지질도 | GEOMAP | Geological Map | -| 지하 | UDGD | Underground | -| 지하수 | GROU | Groundwater | -| 지형 | TPGRPH | Topography | -| 진공 | VCM | Vacuum | -| 진단 | DGNSS | Diagnosis | -| 진동 | VIBRAT | Vibration | -| 진행 | PRGRE | Progress | -| 질산 | NIACID | Nitric Acid. | -| 질산성질소 | NO3N | Nitrate Nitrogen | -| 집계 | TOT | Total | -| 집계구 | SUMDST | Summation District | -| 집행 | IMPL | Implementation | -| 찌꺼기 | LEES | Lees | -| 차아염소산 | NAOCL | Hypochlorous Acid | -| 차집 | ITCT | Intercepting | -| 착공 | BGNCST | Begin Construction | -| 채수 | SAMWT | Sampling Of Water | -| 채수기 | WTRSMPLR | Water Sampler | -| 채수병 | WTRBOTL | Water Bottle | -| 처리 | PRCS | Process | -| 처리비 | PSSFEE | Process Fee | -| 처리수 | TRT_WT | Treated Water | -| 처리수조 | TRT_WT_TANK | Treated Water Tank | -| 처리시설 | TRTMTFCLT | Treatment Facility | -| 처리자 | PRCR | Processor | -| 처분 | DSPS | Disposition | -| 철염 | FESLT | Ferric Salt | -| 첨부 | ATCH | Attachment | -| 첨부파일 | ATFI | Attachment File | -| 초과 | EXCS | Excess | -| 초기화 | INITL | Initialization | -| 촉매 | CTLY | Catalyst | -| 총계 | TSM | Summation | -| 총대장균군 | TCOLFGRP | Total Coliform Group | -| 총량 | TQT | Total Quantity | -| 총면적 | GFA | Gross Floor Area | -| 총사업비 | TPCO | Total Project Cost | -| 총인구 | TPOPLTN | Total Population | -| 총인처리시설 | TPFCT | Total Phosphorus facility | -| 최근 | RCNT | Recent | -| 최대값 | MAXV | Maximum Value | -| 최소값 | MINV | Minimum Value | -| 최적 | BMTB | Being The Most Suitable | -| 최종 | LAST | Last | -| 최초 | FRST | First | -| 추출 | EXTR | Extraction | -| 추출정보 | EXTRINFO | Extraction Information | -| 축 | AXS | Axis | -| 출력 | OTPT | Output | -| 출발 | LVE | Leave | -| 충전기 | CHRGR | Charger | -| 취득 | ACQS | Acquisition | -| 취소 | RTRCN | Retraction | -| 측정 | MSUR | Measurement | -| 측정기 | MSRR | Measurement Instrument | -| 측정소 | MSRSTN | Measurement Station | -| 침사지 | GTCB | Grit Chamber | -| 침전 | SDMT | Sediment | -| 침전지 | SDMT_TANK | sedimentation tank | -| 컨베이어 | CVY | Conveyor | -| 컬럼 | COL | Column | -| 코드 | CD | Code | -| 크기 | SZ | Size | -| 크실렌 | XYLEN | Xylene | -| 탄화 | CARB | Carbonization | -| 탈거 | CRYOFF | Carry Off | -| 탈리액 | SPNT | Supernatant | -| 탈수 | DWTR | Dewatering | -| 탈수기 | DEHYM | Dehydration Machine | -| 탈질 | DNTFCT | Denitrificatio | -| 탈취 | DODRZ | Deodorization | -| 탈취기 | DEODRZ | Deodorizer | -| 탈취제 | DEODR | Deodorant | -| 탈황제 | DSLFA | Desulfurizing Agent | -| 터널형 | YUNLTP | Tunnel Type | -| 테이블 | TBL | Table | -| 토구 | OTFL | Outfall | -| 토지 | LAD | Land | -| 토지면적 | LDAR | Land Area | -| 통계 | STATS | Statistics | -| 통보 | NTFCTN | Notification | -| 통신 | COMM | Communication | -| 통지 | AVTSMT | Avertissement | -| 통합 | UNTY | Unity | -| 특성 | CHRT | Character | -| 특수 | SPECL | Special | -| 특정유해물질 | SHCH | Specified Hazardous Chemicals | -| 파일 | FILE | File | -| 판정 | JGMT | Judgement | -| 펌프 | PUMP | Pump | -| 펌프장 | PUMP_STT | Pump Station | -| 페놀 | PHNL | Phenol | -| 평가 | EVL | Evaluation | -| 폐기 | DSCD | Discard | -| 폐기물 | WSTE | Waste | -| 폐쇄 | CLSG | Closing | -| 폐수 | WAWA | Waste Water | -| 폐수처리 | WSWT | Wastewater Treatment | -| 폐열 | WASTHET | Waste Heat | -| 포기 | ARTN | Aeration | -| 포트 | PORT | Port | -| 폭기조 | ARTN_TANK | Aeration Tank | -| 폴리머 | PLYMR | Polymer | -| 폴리철 | FRRCSU | Ferric Sulfate | -| 표고 | AL | Altitude | -| 표기 | MARK | Mark | -| 표시 | INDCT | Indication | -| 표준 | STD | Standard | -| 표출 | EPRSS | Expression | -| 플래그 | FLAG | Flag | -| 필드 | FILD | Field | -| 필수 | ESNTL | Essential | -| 필증 | CERFIN | Certificate Of Finishing | -| 필터 | FLTER | Filter | -| 하수 | SWGE | Sewage | -| 하수도 | DRNGSYS | Drainage System | -| 하수미처리 | SEWGUNTTM | Un Sewage Treatment | -| 하수처리 | SEWGTRTM | Sewage Treatment | -| 하수처리수 | STWTR | Sewage Treatment Water | -| 하위 | LOWR | Lower | -| 하향류 | DWFW | Downflow | -| 할인 | DSCNT | Discount | -| 함수율 | MISTR_CTNT | Moisture content | -| 합계 | SMM | Summation | -| 해당 | RLVT | Relevant | -| 해석 | INTRPRTT | Interpretation | -| 해양투기 | OCDU | Ocean Dumping | -| 행망 | ADMNW | Administration Network | -| 행정 | ADMN | Administration | -| 행정구역 | ADMI | Administrative District | -| 행정동 | DONG | Dong (Administrative District) | -| 허가 | PRMSN | Permission | -| 허용 | PERM | Permission | -| 현상 | PHNMN | Phenomenon | -| 혐기 | AARB | Anaerobic | -| 혐기조 | AARB_TANK | Anaerobic tank | -| 협잡물 | CCMT | Concomitant | -| 형태 | SHAP | Shape | -| 호기 | ARB | Aerobic | -| 호기조 | ARB_TANK | Aerobic Tank | -| 호소수 | LMSHWT | Lake And Marsh Water | -| 호수 | LKE | Lake | -| 호안 | RVTMNT | Revetment | -| 호이스트 | HIST | Hoist | -| 혼합 | CMPN | Compound | -| 혼합액현탁고형물 | MLSS | Mixed liquor suspended solids | -| 혼화지 | MXG_TNAK | Mixing tank | -| 홈페이지 | HMPG | Homepage | -| 홍수 | FLOOD | Flood | -| 확인 | IDNTY | Identify | -| 확인서 | CNFRMN | Confirmation | -| 확인자 | IDFR | Identifier | -| 확장자 | EXTN | Extension | -| 확정 | CFMTN | Confirmation | -| 환경 | ENVR | Environment | -| 환경기초시설 | ENINF | Environmental Infrastructure | -| 환원 | RSTORE | Restoration | -| 활성탄 | AC | Activated Carbon | -| 활성화 | PRMT | Promote/Facilitate | -| 활용 | PRUS | Practical Use | -| 황산 | ECCBT | Encyclopaedia Britannica (H2So4) | -| 회사 | CO | Company | -| 회수 | RTRVL | Retrieval | -| 회신 | RPLY | Reply | -| 효율 | EFC | Efficiency | -| 효율성 | EFFCTV | Effectiveness | -| 휴대 | MBL | Mobile | -| 흡수정 | EXTTW | Extraction Well | -| 흡착 | ADST | Adsorption | diff --git a/apps/cs/src/entities/code/model/lwpocode-interface.ts b/apps/cs/src/entities/code/model/lwpocode-interface.ts index d44a89ef2..51137294a 100644 --- a/apps/cs/src/entities/code/model/lwpocode-interface.ts +++ b/apps/cs/src/entities/code/model/lwpocode-interface.ts @@ -1,5 +1,6 @@ import { z } from "zod"; -import type { PageableParams, PageableList } from "@/shared/model"; + +import type { PageableList, PageableParams } from "@/shared/model"; export interface LwpoCodeParams extends PageableParams { upCd?: string; @@ -36,11 +37,11 @@ export interface LwpoCodeDetail extends LwpoCodeFormData { } export const LwpoCodeFormSchema = z.object({ - lwpoCd: z.string().max(10).nonempty(), - upCd: z.string().max(10).nonempty(), + lwpoCd: z.string().max(30).nonempty(), + upCd: z.string().max(30).nonempty(), lwpoCdNm: z.string().max(100).nonempty(), lwpoCdExpln: z.string().max(1000).nullish(), - unit: z.string().max(20).nullish(), + unit: z.string().max(50).nullish(), prntLwpoCd: z.string().max(10).nullish(), useYn: z.string(), sortSeq: z.number(), diff --git a/apps/cs/src/entities/code/model/upcode-interface.ts b/apps/cs/src/entities/code/model/upcode-interface.ts index c63eccc0a..3738a5d0e 100644 --- a/apps/cs/src/entities/code/model/upcode-interface.ts +++ b/apps/cs/src/entities/code/model/upcode-interface.ts @@ -38,7 +38,7 @@ export const upCodeFormSchema = z.object({ .nonempty("최소 1자 이상 입력해주세요."), upCdNm: z.string().max(100).nonempty("최소 1자 이상 입력해주세요."), upCdExpln: z.string().max(1000).nullish(), - cdSe: z.string().max(10).nonempty(), + cdSe: z.string().max(30).nonempty(), siteId: z.string().max(2).nullish(), useYn: z.string(), delYn: z.string(), diff --git a/apps/cs/src/entities/ntftalk/model/ntftalk-temp-interface.ts b/apps/cs/src/entities/ntftalk/model/ntftalk-temp-interface.ts index 34ed20803..5149ad230 100644 --- a/apps/cs/src/entities/ntftalk/model/ntftalk-temp-interface.ts +++ b/apps/cs/src/entities/ntftalk/model/ntftalk-temp-interface.ts @@ -46,6 +46,7 @@ export interface NtftalkTemp { templateImageUrl: string; inspectionStatus: string; inspectionStatusNm: string; + pcflag: string; status: string; statusNm: string; createdAt: string; @@ -85,6 +86,7 @@ export interface NtftalkTempSavePayload { templateEmphasizeType: string; templateImageUrl?: string; inspectionStatus?: string; + pcflag?: string; buttons?: NtftalkBtnTempSavePayload[]; quickReplies?: NtftalkQuickTempSavePayload[]; } @@ -94,6 +96,7 @@ export interface NtftalkTempUpdatePayload { templateContent: string; inspectionStatus?: string; status?: string; + pcflag?: string; buttons?: NtftalkBtnTempSavePayload[]; quickReplies?: NtftalkQuickTempSavePayload[]; } diff --git a/apps/cs/src/pages/users/ui/users-form.tsx b/apps/cs/src/pages/users/ui/users-form.tsx index 7920ce758..963e0f87e 100644 --- a/apps/cs/src/pages/users/ui/users-form.tsx +++ b/apps/cs/src/pages/users/ui/users-form.tsx @@ -102,29 +102,29 @@ function UsersForm({ return ( authSeCd?.filter( (code) => - code.lwpoCd === "ROLE_USER1" || - code.lwpoCd === "ROLE_USER2" || - code.lwpoCd === "ROLE_USER3", + code.lwpoCd === "ROLE_USER01" || + code.lwpoCd === "ROLE_USER02" || + code.lwpoCd === "ROLE_USER03", ) ?? [] ); if (watchOgdpSeCd === "3" || watchOgdpSeCd === "4") return ( authSeCd?.filter( (code) => - code.lwpoCd === "ROLE_USER4" || code.lwpoCd === "ROLE_USER5", + code.lwpoCd === "ROLE_USER04" || code.lwpoCd === "ROLE_USER05", ) ?? [] ); return []; }, [authSeCd, watchOgdpSeCd]); const laeSeCdSelectData = useMemo(() => { - if (watchDtlInstCd === "ROLE_USER1") + if (watchDtlInstCd === "ROLE_USER01") return laeSeCd?.filter((code) => code.lwpoCd === "G102") ?? []; - if (watchDtlInstCd === "ROLE_USER2") + if (watchDtlInstCd === "ROLE_USER02") return laeSeCd?.filter((code) => code.lwpoCd === "G101") ?? []; - if (watchDtlInstCd === "ROLE_USER3") + if (watchDtlInstCd === "ROLE_USER03") return laeSeCd?.filter((code) => code.lwpoCd.startsWith("G2")) ?? []; - if (watchDtlInstCd === "ROLE_USER4") + if (watchDtlInstCd === "ROLE_USER04") return ( laeSeCd?.filter( (code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 4, diff --git a/apps/dm/.env.local b/apps/dm/.env.local index 4dce58c9d..df18c9228 100644 --- a/apps/dm/.env.local +++ b/apps/dm/.env.local @@ -18,8 +18,7 @@ NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y} NEXT_PUBLIC_GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API -#NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.128:8883/sewerage-service/api # 최용성 -#NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883/sewerage-service/api # 박이정 +# NEXT_PUBLIC_GATEWAY_URL=http://localhost:8883 # 개발 서버 API NEXT_PUBLIC_FINDADRESS_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do diff --git a/apps/dm/app/(landing)/reuse/_components/BusinessSubNav.tsx b/apps/dm/app/(landing)/reuse/_components/BusinessSubNav.tsx new file mode 100644 index 000000000..6b4246412 --- /dev/null +++ b/apps/dm/app/(landing)/reuse/_components/BusinessSubNav.tsx @@ -0,0 +1,242 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; +import { ChevronDown, ChevronUp } from "lucide-react"; + +export type AnchorSection = { + id: string; + label: string; +}; + +export type BusinessSubNavMenu = { + key: string; + label: string; + href?: string; + current?: boolean; + defaultOpen?: boolean; + sections?: AnchorSection[]; +}; + +type BusinessSubNavProps = { + menus?: BusinessSubNavMenu[]; + offsetTop?: number; + className?: string; +}; + +export default function BusinessSubNav({ + menus = [], + offsetTop = 120, + className = "", +}: BusinessSubNavProps) { + const [activeId, setActiveId] = useState(""); + const [openMenus, setOpenMenus] = useState>({}); + + const allSections = useMemo( + () => menus.flatMap((menu) => menu.sections ?? []), + [menus] + ); + + useEffect(() => { + const initialOpenState: Record = {}; + + menus.forEach((menu) => { + initialOpenState[menu.key] = menu.defaultOpen ?? menu.current ?? false; + }); + + setOpenMenus(initialOpenState); + }, [menus]); + + useEffect(() => { + if (!allSections.length) return; + + const observer = new IntersectionObserver( + (entries) => { + const visible = entries + .filter((entry) => entry.isIntersecting) + .sort((a, b) => b.intersectionRatio - a.intersectionRatio); + + if (visible[0]) { + setActiveId(visible[0].target.id); + } + }, + { + rootMargin: `-${offsetTop}px 0px -55% 0px`, + threshold: [0.2, 0.35, 0.5, 0.7], + } + ); + + const targets = allSections + .map((section) => document.getElementById(section.id)) + .filter(Boolean) as HTMLElement[]; + + targets.forEach((el) => observer.observe(el)); + + const hash = window.location.hash.replace("#", ""); + const initialActive = + allSections.find((section) => section.id === hash)?.id ?? + allSections[0]?.id ?? + ""; + + setActiveId(initialActive); + + return () => observer.disconnect(); + }, [allSections, offsetTop]); + + useEffect(() => { + if (!activeId) return; + + const parentMenu = menus.find((menu) => + (menu.sections ?? []).some((section) => section.id === activeId) + ); + + if (!parentMenu) return; + + setOpenMenus(() => { + const nextOpenState: Record = {}; + + menus.forEach((menu) => { + nextOpenState[menu.key] = menu.key === parentMenu.key; + }); + + return nextOpenState; + }); + }, [activeId, menus]); + + const handleMove = (id: string) => { + setActiveId(id); + + const target = document.getElementById(id); + if (!target) return; + + window.history.replaceState(null, "", `#${id}`); + + const top = target.getBoundingClientRect().top + window.scrollY - offsetTop; + + window.scrollTo({ + top, + behavior: "smooth", + }); + }; + + const toggleMenu = (key: string) => { + setOpenMenus((prev) => ({ + ...prev, + [key]: !prev[key], + })); + }; + + if (!menus.length) return null; + + return ( + + ); +} \ No newline at end of file diff --git a/apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx b/apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx index ece8f0848..e70bc3e02 100644 --- a/apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx +++ b/apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx @@ -33,8 +33,8 @@ type CaseItem = { }; const TABS: { key: TabKey; label: string }[] = [ - { key: "rain", label: "빗물 재이용 사례" }, - { key: "reuse", label: "중수 재이용 사례" }, + { key: "rain", label: "빗물이용시설 사례" }, + { key: "reuse", label: "중수도 사례" }, { key: "sewage", label: "하수 재이용 사례" }, { key: "global", label: "해외 재이용 사례" }, ]; @@ -59,7 +59,7 @@ const MOCK: Record = { ], reuse: Array.from({ length: 6 }).map((_, i) => ({ id: `reuse-${i}`, - title: `중수 재이용 사례 ${i + 1}`, + title: `중수도 사례 ${i + 1}`, desc: "설명 문구가 들어갑니다. (최대 2~3줄) …", imageSrc: "/images/landing/sample_case.png", href: "#", diff --git a/apps/dm/app/(landing)/reuse/_components/TopNav.tsx b/apps/dm/app/(landing)/reuse/_components/TopNav.tsx index 95598aaa7..65b914c91 100644 --- a/apps/dm/app/(landing)/reuse/_components/TopNav.tsx +++ b/apps/dm/app/(landing)/reuse/_components/TopNav.tsx @@ -9,7 +9,7 @@ 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/business/rain", label: "물 재이용 사례" }, { href: "/reuse/apply/place", label: "수요신청" }, ]; diff --git a/apps/dm/app/(landing)/reuse/_constants/lnb.ts b/apps/dm/app/(landing)/reuse/_constants/lnb.ts index 671dabd65..f88de62bc 100644 --- a/apps/dm/app/(landing)/reuse/_constants/lnb.ts +++ b/apps/dm/app/(landing)/reuse/_constants/lnb.ts @@ -23,7 +23,7 @@ export const LNB_CASES = [ // 물 재이용 사례처럼 2depth가 있는 메뉴 export const LNB_BUSINESS: LnbItem[] = [ { - label: "빗물 재이용 사례", + label: "빗물이용시설 사례", children: [ { label: "경기도 수원시", href: "/reuse/business/rain/suwon" }, // { label: "충청남도 보령시", href: "/reuse/business/rain/bolyeong" }, @@ -31,7 +31,7 @@ export const LNB_BUSINESS: LnbItem[] = [ ], }, { - label: "중수 재이용 사례", + label: "중수도 사례", children: [{ label: "사례 목록", href: "/reuse/business/graywater" }], }, { diff --git a/apps/dm/app/(landing)/reuse/apply/modal/SimilarCaseModal.tsx b/apps/dm/app/(landing)/reuse/apply/modal/SimilarCaseModal.tsx index 774ab81b8..6f68ab515 100644 --- a/apps/dm/app/(landing)/reuse/apply/modal/SimilarCaseModal.tsx +++ b/apps/dm/app/(landing)/reuse/apply/modal/SimilarCaseModal.tsx @@ -1,10 +1,12 @@ -// apps/dm/app/(landing)/reuse/apply/modal/SimilarCaseModal.tsx (유사사례 모달) +/** + * 유사사례 Modal + */ "use client"; import { useEffect, useMemo, useState, useId } from "react"; import { createPortal } from "react-dom"; -type Criterion = "" | "supplyAmt" | "treatment" | "usePurpose"; +type Criterion = "" | "supplyAmt" | "treatment" | "usePurpose"; // 유사기준 export type SimilarCaseRow = { caseId: string; @@ -90,7 +92,7 @@ export default function SimilarCaseModal({ // 모달 열릴 때 초기화 useEffect(() => { if (!open) return; - setCriterion("supplyAmt"); + setCriterion("supplyAmt"); // 요구수량(㎥/d) onSearch("supplyAmt"); setSelectedCaseId(""); }, [open]); diff --git a/apps/dm/app/(landing)/reuse/business/_components/GlobalCaseSection.tsx b/apps/dm/app/(landing)/reuse/business/_components/GlobalCaseSection.tsx new file mode 100644 index 000000000..5d981d7de --- /dev/null +++ b/apps/dm/app/(landing)/reuse/business/_components/GlobalCaseSection.tsx @@ -0,0 +1,116 @@ +"use client"; + +import CaseImageSlider from "@/components/CaseImageSlider"; + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ +

{children}

+
+ ); +} + +// 구분선 +function Hr({ className = "" }: { className?: string }) { + return
; +} + +export default function GlobalCaseSection() { + const title = "해외 재이용 사례"; + return ( +
+ {/* 상단 타이틀 */} +
+

+ {title} + +

+
+
+
+ 사례 개요 + + +
+ +
+ +
+ 대만 + + +
+ +
+ +
+ 호주 + + +
+ +
+ +
+ 싱가포르 + + +
+ +
+ +
+ EU + + +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/dm/app/(landing)/reuse/business/_components/GreywaterExampleSection.tsx b/apps/dm/app/(landing)/reuse/business/_components/GreywaterExampleSection.tsx new file mode 100644 index 000000000..35dfc1247 --- /dev/null +++ b/apps/dm/app/(landing)/reuse/business/_components/GreywaterExampleSection.tsx @@ -0,0 +1,191 @@ +"use client"; + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ +

{children}

+
+ ); +} + +function BlockTitle({ children }: { children: React.ReactNode }) { + return ( +
+ +

{children}

+
+ ); +} + +// 구분선 +function Hr({ className = "" }: { className?: string }) { + return
; +} + +type SupplyRow = { + facility: string; + usage: string; + amount: string; +}; + +function SupplyTable({ rows }: { rows: SupplyRow[] }) { + return ( +
+ + + + + + + + + + {rows.map((row, index) => ( + + + + + + ))} + +
+ 시설명 + + 이용용도 + + 공급량(㎥) +
+ {row.facility} + + {row.usage} + + {row.amount} +
+
+ ); +} + +function InfoList({ + projectName, + purpose, + totalCost, + treatment, +}: { + projectName: string; + purpose: string; + totalCost: string; + treatment: string; +}) { + return ( +
+
+ 사업명 +
    +
  • {projectName}
  • +
+
+ +
+ 사업목적 +
    +
  • {purpose}
  • +
+
+ +
+ 사업 주요 내용 +
    +
  • 총사업비 : {totalCost}
  • +
  • 처리공법 : {treatment}
  • +
+
+ +
+ 사업량 +
+
+ ); +} + +export default function GreywaterExampleSection() { + const title = "중수도 사례"; + return ( +
+ {/* 상단 타이틀 */} +
+

+ {title} + +

+
+ +
+
+ 경기도 안산시 + + + + +
+ +
+ +
+ 경기도 용인시 + + + + +
+ +
+ +
+ 경기도 평택시 + + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/dm/app/(landing)/reuse/business/_components/RainExampleSection.tsx b/apps/dm/app/(landing)/reuse/business/_components/RainExampleSection.tsx new file mode 100644 index 000000000..0381b3ebc --- /dev/null +++ b/apps/dm/app/(landing)/reuse/business/_components/RainExampleSection.tsx @@ -0,0 +1,72 @@ +"use client"; + +import CaseImageSlider from "@/components/CaseImageSlider"; + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ +

{children}

+
+ ); +} + +// 구분선 +function Hr({ className = "" }: { className?: string }) { + return
; +} + +export default function RainExampleSection() { + const title = "빗물이용시설 사례"; + return ( +
+ {/* 상단 타이틀 */} +
+

+ {title} + +

+
+ +
+ 경기도 수원시 + + +
+ +
+ +
+ 제주특별자치도 + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/dm/app/(landing)/reuse/business/_components/SewageExampleSection.tsx b/apps/dm/app/(landing)/reuse/business/_components/SewageExampleSection.tsx new file mode 100644 index 000000000..72ac9741a --- /dev/null +++ b/apps/dm/app/(landing)/reuse/business/_components/SewageExampleSection.tsx @@ -0,0 +1,256 @@ +"use client"; + +import CaseImageSlider from "@/components/CaseImageSlider"; + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ +

{children}

+
+ ); +} + +// 구분선 +function Hr({ className = "" }: { className?: string }) { + return
; +} + +export default function SewageExampleSection() { + const title = "하수처리수 재이용 사례"; + return ( +
+ {/* 상단 타이틀 */} +
+

+ {title} + +

+
+ +
+
+ 충청남도 보령시 + + + +
+ 사업명 +

+ 충남 보령 하수재이용 +
: 보령·신보령화력발전소에 하루 1만톤의 탈황용수 공급을 위해 + 보령하수처리시설의 하수처리수 재이용 사업 추진 +

+
+ +
+ +
+ 사업 방식 +

+ 2017년 한국환경공단과 사업대행 위 · 수탁 협약 체결 +

+
+ +
+ 사업 주요 내용 +

+ 처리공정 : 전처리 UF + 후처리 RO +
+ 시설용량 : 1만m³/일 +
+ 관로 : 8.7km +
+ 사업비 : 315억원 +
+ 위치: 충청남도 보령시 대청방조제로 322 일원 +

+
+ +
+ +
+ 특이사항 +

+ 국내 유일 하수재이용 공업용수 공급 재정사업으로 2016년 충남 서부권 + 가뭄 시 지역사회 기여 차원에서 기관간 MOU를 바탕으로 추진 +

+
+
+ +
+ +
+ 청주시 + + + +
+ 사업명 +

청주시 하수 재이용 시설

+
+ +
+ +
+ 사업 주요 내용 +

+ 청주시 하수처리수 재이용 민간투자사업(BTO) +
+ 공급량 : 35,000㎥/일 +
+ 공급관로 : 8.3㎞ +
+ 사업비 : 622억원 +
+ 수요처 : SK하이닉스 등 일반산업단지 +
+ 지역 : 충청북도 청주시 흥덕구 가락리 135-9번지 일원 +
+ 적용기술 : 분리막 UF, RO +

+
+
+ +
+ +
+ 구리시 도로자동청소시스템 + + + +
+ 사업명 +

+ 구리시 도로자동청소시스템 설치사업 +

+
+ +
+ +
+ 사업목적 +

+ 공공하수처리시설 발생 재이용수 일부를 도로자동청소시스템에 활용하여 + 미세먼지 저감 및 폭염 취약성 완화 +

+
+
+ +
+ +
+ 수원시 황구지천 + + + +
+ 사업명 +

+ 수원시 황구지천 하수처리수 재이용사업 +

+
+
+ +
+ +
+ 파주시 + + + +
+ 사업명 +

+ 경기도 파주시 하수재이용 +
+ 운정·금촌하수처리시설의 하수처리수를 막공법으로 재처리하여 파주 LCD + 산업단지에 공업용수 공급 +

+
+
+ +
+ +
+ 여수시 + + + +
+ 사업명 +

+ 여수시 하수처리수 재이용 민간 투자사업(BTO) +

+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/dm/app/(landing)/reuse/business/layout.tsx b/apps/dm/app/(landing)/reuse/business/layout.tsx index dc62f77cc..9d0d95ab9 100644 --- a/apps/dm/app/(landing)/reuse/business/layout.tsx +++ b/apps/dm/app/(landing)/reuse/business/layout.tsx @@ -1,16 +1,71 @@ -// apps/dm/app/(landing)/reuse/business/layout.tsx +"use client"; import type { ReactNode } from "react"; -import Lnb from "../_components/Lnb"; -import { LNB_BUSINESS } from "../_constants/lnb"; +import { useEffect } from "react"; +// import { usePathname } from "next/navigation"; +// import Lnb from "../_components/Lnb"; +// import BusinessSubNav from "../_components/BusinessSubNav"; +import { + // fetchAnonymousMenu, + // findActiveTopMenu, + // type MenuItem, +} from "@/app/store/common"; export default function BusinessLayout({ children }: { children: ReactNode }) { + // const pathname = usePathname(); + // const [menus, setMenus] = useState([]); + + useEffect(() => { + let isMounted = true; + + (async () => { + try { + // const loaded = await fetchAnonymousMenu(); + if (!isMounted) return; + // setMenus(loaded); + } catch { + if (!isMounted) return; + // setMenus([]); + } + })(); + + return () => { + isMounted = false; + }; + }, []); + + // const currentTopMenu = useMemo( + // () => findActiveTopMenu(menus, pathname), + // [menus, pathname], + // ); + + // const businessMenus = useMemo( + // () => currentTopMenu?.children ?? [], + // [currentTopMenu], + // ); + return ( -
- -
- {children} -
+
+ + {/* 좌측 LNB */} + {/* */} + + {/* 컨텐츠 영역 */} +
+
+ + {/* 본문 */} +
+ {children} +
+ + {/* 우측 서브 메뉴 */} +
+ {/* */} +
+ +
+
); -} \ No newline at end of file +} diff --git a/apps/dm/app/(landing)/reuse/business/page.tsx b/apps/dm/app/(landing)/reuse/business/page.tsx new file mode 100644 index 000000000..660680c0c --- /dev/null +++ b/apps/dm/app/(landing)/reuse/business/page.tsx @@ -0,0 +1,85 @@ +import SubPageHeader from "@/components/SubPageHeader"; +import BusinessSubNav, { type BusinessSubNavMenu } from "@/components/BusinessSubNav"; + +import RainExampleSection from "./_components/RainExampleSection"; +import GreywaterExampleSection from "./_components/GreywaterExampleSection"; +import SewageExampleSection from "./_components/SewageExampleSection"; +import GlobalCaseSection from "./_components/GlobalCaseSection"; + +const menus: BusinessSubNavMenu[] = [ + { + key: "example-rain", + label: "빗물이용시설 사례", + sections: [ + { id: "example-rain-suwon", label: "경기도 수원시" }, + { id: "example-rain-jeju", label: "제주특별자치도" }, + ], + }, + { + key: "example-greywater", + label: "중수도 사례", + defaultOpen: false, + sections: [ + { id: "example-greywater-ansan", label: "경기도 안산시" }, + { id: "example-greywater-yongin", label: "경기도 용인시" }, + { id: "example-greywater-pyeongtaek", label: "경기도 평택시" }, + ], + }, + { + key: "example-sewage", + label: "하수처리수 재이용 사례", + defaultOpen: false, + sections: [ + { id: "example-sewage-bolyeong", label: "충청남도 보령시" }, + { id: "example-sewage-cheongju", label: "청주시" }, + { id: "example-sewage-guri", label: "구리시 도로자동청소시스템" }, + { id: "example-sewage-hwanggu", label: "수원시 황구지천" }, + { id: "example-sewage-paju", label: "파주시" }, + { id: "example-sewage-yeosu", label: "여수시" }, + ], + }, + { + key: "example-global", + label: "해외 재이용 사례", + defaultOpen: false, + sections: [ + { id: "example-global-overview", label: "사례 개요" }, + { id: "example-global-taiwan", label: "대만" }, + { id: "example-global-australia", label: "호주" }, + { id: "example-global-singapore", label: "싱가포르" }, + { id: "example-global-eu", label: "EU" }, + ], + }, +]; + +function Hr({ className = "" }: { className?: string }) { + return
; +} + +export default function ExamplePage() { + return ( +
+ {/* 상단 헤더 */} + + + {/* 우측 앵커 네비 */} + + +
+ +
+ +
+ +
+ +
+
+ ); +} \ No newline at end of file diff --git a/apps/dm/app/(landing)/reuse/business/rain/suwon/page.tsx b/apps/dm/app/(landing)/reuse/business/rain/suwon/page.tsx index 3acba860b..d96772126 100644 --- a/apps/dm/app/(landing)/reuse/business/rain/suwon/page.tsx +++ b/apps/dm/app/(landing)/reuse/business/rain/suwon/page.tsx @@ -46,7 +46,7 @@ export default function GreywaterPage() { crumbs={[ { label: "Home", href: "/reuse" }, { label: "물 재이용 사례" }, - { label: "빗물 재이용 사례" }, + { label: "빗물이용시설 사례" }, { label: "수원시", href: "/reuse/business/rain/suwon" }, ]} hideFooterLine diff --git a/apps/dm/app/(landing)/reuse/cases/_components/GreywaterStatusSection.tsx b/apps/dm/app/(landing)/reuse/cases/_components/GreywaterStatusSection.tsx new file mode 100644 index 000000000..1f5f93b44 --- /dev/null +++ b/apps/dm/app/(landing)/reuse/cases/_components/GreywaterStatusSection.tsx @@ -0,0 +1,461 @@ +"use client"; + +import React from "react"; + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ +

{children}

+
+ ); +} + +function Hr({ className = "" }: { className?: string }) { + return
; +} + +function SourceText() { + return ( +

+ 자료: 환경부(2024), 「2023 하수도 통계」 +

+ ); +} + +const tableStyles = { + container: "w-[1000px] mx-auto overflow-hidden", + table: "w-full table-fixed border-collapse text-center text-[16px]", + th: "border border-[#858F98] py-3 font-bold text-neutral-800", + td: "border border-[#858F98] py-3 text-neutral-700 font-medium break-keep", + headerBg: "bg-[#E3E7EA]", +}; + +export default function GreywaterStatusSection() { + const title = "중수도(현황)"; + + return ( +
+ {/* 상단 타이틀 */} +
+

+ {title} + +

+
+ +
+ {/* 1) 전체 중수도 설치 및 이용 현황 */} +
+ 전체 중수도 설치 및 이용 현황 + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + {[ + { + group: "전체", + rows: [ + ["시설수 (개소)", "576", "633", "682", "740", "819", "842"], + ["건축연면적 (만m²)", "1,494.2", "1,514.4", "1,524.9", "989.1", "1,104.3", "1,015.4"], + ["처리용량 (만m³/일)", "39.6", "40.0", "51.1", "47.1", "48.9", "50.7"], + ["사용량 (만m³/년)", "2,223.4", "1,165.2", "6,713.0", "4,681.7", "3,918.2", "898.4"], + ], + }, + { + group: "법적 대상", + rows: [ + ["시설수 (개소)", "146", "154", "156", "170", "166", "169"], + ["건축연면적 (만m²)", "674.9", "682.4", "736.1", "421.1", "434.9", "465.5"], + ["처리용량 (만m³/일)", "20.0", "20.1", "31.2", "22.8", "21.2", "24.7"], + ["사용량 (만m³/년)", "1,819.4", "712.8", "6,479.9", "4,416.8", "3,663.1", "616.1"], + ], + }, + { + group: "법적 비대상", + rows: [ + ["시설수 (개소)", "430", "479", "526", "570", "653", "673"], + ["건축연면적 (만m²)", "819.3", "832.0", "788.8", "568.0", "669.4", "549.9"], + ["처리용량 (만m³/일)", "19.6", "19.5", "19.9", "24.3", "27.7", "26.0"], + ["사용량 (만m³/년)", "404.0", "452.4", "233.1", "264.9", "255.1", "282.3"], + ], + }, + ].map((section, sIdx) => ( + + {section.rows.map((row, rIdx) => ( + + {rIdx === 0 && ( + + )} + {row.map((val, i) => { + const isFirstCol = i === 0; + const isHighlightedCol = [1, 3, 5].includes(i); + + return ( + + ))} + + ))} + +
+ 구분 + + 2018 + + 2019 + + 2020 + + 2021 + + 2022 + + 2023 +
+ {section.group} + + ); + })} +
+
+ + +
+
+ +
+ + {/* 2) 공장 및 발전시설 내 중수도 설치 현황 */} +
+ 공장 및 발전시설 내 중수도 설치 현황 + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + {[ + { + group: "전체", + rows: [ + ["시설수 (개소)", "125", "127", "139", "146", "151", "176"], + ["처리용량 (만m³/일)", "141.3", "141.0", "146.7", "189.0", "190.3", "166.5"], + ["사용량 (만m³/년)", "33,712", "29,300", "33,110", "22,284", "21,593", "21,349"], + ], + }, + { + group: "법적 대상", + rows: [ + ["시설수 (개소)", "59", "63", "78", "78", "82", "86"], + ["처리용량 (만m³/일)", "108.6", "109.7", "118.1", "159.5", "142.6", "119.4"], + ["사용량 (만m³/년)", "24,847", "24,021", "28,055", "17,959", "12,241", "12,296"], + ], + }, + { + group: "법적 비대상", + rows: [ + ["시설수 (개소)", "66", "64", "61", "68", "69", "90"], + ["처리용량 (만m³/일)", "32.7", "31.4", "28.7", "29.5", "47.7", "47.1"], + ["사용량 (만m³/년)", "8,864.9", "5,278.8", "5,054.7", "4,324.6", "9,353.3", "9,052.4"], + ], + }, + ].map((section, sIdx) => ( + + {section.rows.map((row, rIdx) => ( + + {rIdx === 0 && ( + + )} + {row.map((val, i) => { + const isFirstCol = i === 0; + const isHighlightedCol = [1, 3, 5].includes(i); + + return ( + + ))} + + ))} + +
+ 구분 + + 2018 + + 2019 + + 2020 + + 2021 + + 2022 + + 2023 +
+ {section.group} + + ); + })} +
+
+ + +
+
+ +
+ + {/* 3) 중수도 건축물별 이용량 현황 */} +
+ 중수도 건축물별 이용량 현황 + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + {[ + ["공동주택", "804.0", "245.8", "255.1", "282.3", "255.1", "282.3"], + ["판매시설", "1,447.5", "477.1", "802.1", "713.0", "972.3", "742.4"], + ["숙박시설", "458.5", "0.4", "177.0", "102.0", "123.3", "140.9"], + ["업무시설", "20.7", "0.3", "14.3", "44.9", "31.5", "37.4"], + ["의료시설", "97.7", "7.2", "63.7", "55.7", "85.1", "70.4"], + ["교육연구시설", "536.6", "1.5", "1,558.4", "772.2", "315.1", "66.7"], + ["운수·물류시설", "759.2", "34.2", "459.8", "259.8", "252.8", "410.4"], + ["기타", "453.1", "443.3", "3,126.8", "2,451.5", "1,880.8", "199.7"], + ["합계", "35,936.3", "30,466.6", "37,487.2", "25,480.6", "24,345.8", "22,247.6"], + ].map((row, idx) => ( + + {row.map((val, i) => ( + + ))} + + ))} + +
+ 구분 + + 이용량 (천m³/년) +
+ 2018 + + 2019 + + 2020 + + 2021 + + 2022 + + 2023 +
+ {val} +
+
+ + +
+
+ +
+ + {/* 4) 중수도 용도별 이용량 현황 */} +
+ 중수도 용도별 이용량 현황 + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + {[ + ["공업용수", "33,082", "28,719", "32,312", "21,707", "20,991", "20,470"], + ["세척·살수용수", "860.2", "146.5", "133.8", "100.7", "95.1", "149.9"], + ["조경용수", "403.7", "99.3", "3,297.0", "2,611.1", "1,827.0", "1,116.7"], + ["청소용수", "623.9", "764.2", "1,430.6", "879.7", "1,146.2", "286.0"], + ["수세식화장실용수", "530.0", "646.6", "183.5", "161.2", "216.6", "147.6"], + ["냉·난방용수", "150.1", "56.2", "57.5", "6.4", "0.9", "0.5"], + ["친수용수", "120.8", "18.5", "29.3", "8.2", "4.1", "2.8"], + ["기타", "165.6", "16.1", "43.2", "5.7", "64.0", "73.9"], + ].map((row, idx) => ( + + {row.map((val, i) => ( + + ))} + + ))} + +
+ 구분 + + 이용량 (천m³/년) +
+ 2018 + + 2019 + + 2020 + + 2021 + + 2022 + + 2023 +
+ {val} +
+
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/dm/app/(landing)/reuse/cases/_components/RainStatusSection.tsx b/apps/dm/app/(landing)/reuse/cases/_components/RainStatusSection.tsx new file mode 100644 index 000000000..ec150d55b --- /dev/null +++ b/apps/dm/app/(landing)/reuse/cases/_components/RainStatusSection.tsx @@ -0,0 +1,381 @@ +"use client"; + +import React from "react"; + +// 공통 섹션 타이틀 컴포넌트 +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ +

{children}

+
+ ); +} + +// 구분선 +function Hr({ className = "" }: { className?: string }) { + return
; +} + +// 출처 텍스트 +function SourceText() { + return ( +

+ 자료: 환경부(2024), 「2023 하수도 통계」 +

+ ); +} + +const tableStyles = { + container: "w-[1000px] mx-auto overflow-hidden", + table: "w-full table-fixed border-collapse text-center text-[16px]", + th: "border border-[#858F98] py-3 font-bold text-neutral-800", + td: "border border-[#858F98] py-3 text-neutral-700 font-medium break-keep", + headerBg: "bg-[#E3E7EA]", +}; + +export default function RainStatusSection() { + const title = "빗물이용시설(현황)"; + + return ( +
+ {/* 상단 타이틀 */} +
+

+ {title} + +

+
+ +
+ {/* 1) 빗물이용시설의 건축물별 빗물이용량 현황 */} +
+ 빗물이용시설의 건축물별 빗물이용량 현황 + +
+
+ + + + + + + + + + + + + + + + + {[ + ["골프장", "6,771.8", "6,417.4", "8,371.6", "10,215.4", "11,360", "10,191.6"], + ["공공시설", "364", "325.2", "201.9", "244.6", "209.9", "299.6"], + ["공동주택", "291", "840.5", "804.2", "2,393.0", "2,386", "2,428.4"], + ["대규모점포", "169.9", "214.4", "58.4", "427.2", "371", "293.3"], + ["체육시설", "52.6", "44.7", "48.6", "75.3", "71.8", "77.6"], + ["학교", "33", "25.5", "22", "155.8", "165.4", "32.6"], + ["기타", "311.6", "901.2", "344.5", "537.8", "564", "621.2"], + ].map((row, idx) => ( + + {row.map((val, i) => ( + + ))} + + ))} + +
+ 구분 + + 빗물이용량 (천m³/년) +
+ 2018 + + 2019 + + 2020 + + 2021 + + 2022 + + 2023 +
+ {val} +
+
+ + +
+
+ +
+ + {/* 2) 빗물이용시설의 용도별 분포 (2023년 기준) */} +
+ 빗물이용시설의 용도별 분포 (2023년 기준) + +
+
+ + + + + + + + + + + + + + + + + + + + + {[ + ["청소·환경정화용수", "195", "32.7", "108.8", "24.9", "5.8", "2.6", "4.4", "2.9", "-", "68.1"], + ["세척·살수용수", "12", "1.4", "37", "35", "-", "-", "1.9", "-", "-", "0.2"], + ["조경용수", "3,165", "7,440.8", "13,554.1", "168.1", "2,421.9", "290.7", "66.2", "29", "10,044", "534.3"], + ["냉·난방수", "8", "1.6", "0.1", "-", "-", "-", "-", "0.1", "-", "-"], + ["공업용수", "6", "14.9", "53.2", "52.8", "-", "-", "-", "-", "-", "0.4"], + ["농업용수", "6.0", "0.5", "0.6", "-", "-", "-", "-", "-", "-", "0.6"], + ["기타", "48", "306.1", "155.6", "2.8", "-", "-", "0.6", "0.5", "147.5", "4.2"], + ["미기재", "103", "22.9", "34.8", "16.0", "0.6", "-", "4.6", "0.2", "-", "13.5"], + ["합계", "3,543", "7,820.8", "13,944.7", "299.6", "2,428.4", "293.3", "77.6", "32.6", "10,191.6", "621.2"], + ].map((row, idx) => ( + + {row.map((val, i) => { + const isHighlightedCol = [1, 2].includes(i); + + return ( + + ); + })} + + ))} + +
+ 용도 + + 개소수 +
+ (개소) +
+ 저류용량 +
+ (천m³) +
+ 빗물이용량 (천m³/년) +
+ 총사용량 + + 공공시설 + + 공동주택 + + 대규모점포 + + 체육시설 + + 학교 + + 골프장 + + 기타 +
+ {val} +
+
+ + +
+
+ +
+ + {/* 3) 빗물이용시설의 용도별 빗물이용량 현황 */} +
+ 빗물이용시설의 용도별 빗물이용량 현황 + +
+
+ + + + + + + + + + + + + + + + + {[ + ["청소·화장실용수", "126.5", "134.1", "96.5", "153.9", "140.5", "108.8"], + ["세척·살수용수", "14.8", "14.6", "9.5", "49.8", "49.7", "37.0"], + ["조경용수", "7,629.8", "8,496.2", "9,415.4", "13,360.4", "14,466.3", "13,554.1"], + ["친수용수", "1.0", "0.1", "0.1", "0.1", "0.1", "0.1"], + ["공업용수", "152.0", "77.0", "77.0", "75.4", "0.4", "53.2"], + ["농업용수", "4.4", "4.3", "4.3", "4.3", "0.6", "0.6"], + ["기타", "37.7", "42.6", "230.7", "394.3", "463.2", "155.6"], + ].map((row, idx) => ( + + {row.map((val, i) => ( + + ))} + + ))} + +
+ 용도 + + 빗물이용량 (천m³/년) +
+ 2018 + + 2019 + + 2020 + + 2021 + + 2022 + + 2023 +
+ {val} +
+
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/dm/app/(landing)/reuse/cases/_components/SewageStatusSection.tsx b/apps/dm/app/(landing)/reuse/cases/_components/SewageStatusSection.tsx new file mode 100644 index 000000000..0a2b34d5d --- /dev/null +++ b/apps/dm/app/(landing)/reuse/cases/_components/SewageStatusSection.tsx @@ -0,0 +1,705 @@ +"use client"; + +import React from "react"; + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ +

{children}

+
+ ); +} + +function Hr({ className = "" }: { className?: string }) { + return
; +} + +function SourceText() { + return ( +

+ 자료: 환경부(2024), 「2023 하수도 통계」 +

+ ); +} + +const tableStyles = { + container: "w-[1000px] mx-auto overflow-hidden", + table: "w-full table-fixed border-collapse text-center text-[16px]", + th: "border border-[#858F98] py-3 font-bold text-neutral-800", + td: "border border-[#858F98] py-3 text-neutral-700 font-medium break-keep", + headerBg: "bg-[#E3E7EA]", +}; + +export default function SewageStatusSection() { + const title = "하수처리수 재이용시설(현황)"; + + return ( +
+ {/* 상단 타이틀 */} +
+

+ {title} + +

+
+ +
+ {/* 1) 연도별 하수처리수 재이용 현황 */} +
+ 연도별 하수처리수 재이용 현황 + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + {[ + { + group: "전체", + rows: [ + [ + "개소수 (개소)", + "671", + "681", + "698", + "704", + "714", + "717", + ], + [ + "하수처리량 (백만m³/년)", + "7,163.7", + "7,140.4", + "7,375.6", + "7,335.8", + "7,378.9", + "7,537.2", + ], + [ + "재이용량 (백만m³/년)", + "1,112.7", + "1,148.7", + "1,148.7", + "1,127.0", + "1,136.8", + "1,137.5", + ], + [ + "재이용률 (%)", + "17.4", + "16.5", + "15.5", + "14.1", + "14.3", + "14.4", + ], + ], + }, + { + group: "법적 대상", + rows: [ + [ + "개소수 (개소)", + "68", + "82", + "94", + "97", + "101", + "118", + ], + [ + "하수처리량 (백만m³/년)", + "761.2", + "839.0", + "935.3", + "1,158.7", + "1,220.6", + "1,473.6", + ], + [ + "재이용량 (백만m³/년)", + "162.2", + "190.6", + "218.5", + "244.6", + "247.2", + "324.9", + ], + [ + "재이용률 (%)", + "24.7", + "25.4", + "27.8", + "25.7", + "24.2", + "24.1", + ], + ], + }, + { + group: "법적 미대상", + rows: [ + [ + "개소수 (개소)", + "603", + "599", + "604", + "607", + "613", + "599", + ], + [ + "하수처리량 (백만m³/년)", + "6,402.5", + "6,301.4", + "6,440.3", + "6,177.1", + "6,158.3", + "6,063.5", + ], + [ + "재이용량 (백만m³/년)", + "950.6", + "958.1", + "926.2", + "882.4", + "889.5", + "812.7", + ], + [ + "재이용률 (%)", + "16.5", + "15.3", + "13.6", + "12.2", + "12.6", + "12.5", + ], + ], + }, + ].map((section, sIdx) => ( + + {section.rows.map((row, rIdx) => ( + + {rIdx === 0 && ( + + )} + {row.map((val, i) => { + const isFirstCol = i === 0; + const isHighlightedCol = [1, 3, 5].includes(i); + + return ( + + ))} + + ))} + +
+ 구분 + + 2018 + + 2019 + + 2020 + + 2021 + + 2022 + + 2023 +
+ {section.group} + + ); + })} +
+
+ + +
+
+ +
+ + {/* 2) 용도별 하수처리수 재이용 현황 */} +
+ 용도별 하수처리수 재이용 현황 + +
+
+ + + + + + + + + + + + + + + + + + + + + + + {[ + { label: "총 재이용량", type: "total", values: ["1,112.7", "1,148.7", "1,144.7", "1,127.0", "1,136.8", "1,137.5"] }, + { label: "장내 재이용", type: "category", values: ["520.6", "519.8", "513.9", "520.3", "502.2", "490.0"] }, + { label: "세척수", type: "red-item", values: ["177.9", "174.4", "169.4", "180.9", "196.8", "191.2"] }, + { label: "냉각수", type: "red-item", values: ["45.5", "46.8", "50.4", "40.6", "38.3", "41.7"] }, + { label: "청소수", type: "red-item", values: ["17.6", "21.2", "21.2", "21.7", "20.8", "20.5"] }, + { label: "조경용수", type: "red-item", values: ["0.1", "0.1", "-", "-", "-", "-"] }, + { label: "회생용수", type: "red-item", values: ["15.8", "16.7", "17.8", "15.7", "14.4", "14.0"] }, + { label: "장내급수도", type: "red-item", values: ["2.4", "-", "-", "-", "-", "-"] }, + { label: "장내기타용수", type: "red-item", values: ["261.3", "260.6", "255.0", "261.1", "231.8", "222.6"] }, + { label: "장외 재이용", type: "category", values: ["592.1", "628.9", "630.8", "606.7", "634.6", "647.5"] }, + { label: "청소·화장실용수", type: "blue-item", values: ["0.6", "0.4", "0.4", "0.4", "0.4", "0.4"] }, + { label: "세척·살수용수", type: "blue-item", values: ["5.6", "9.9", "2.9", "2.1", "3.0", "3.6"] }, + { label: "조경용수", type: "blue-item", values: ["10.3", "16.4", "5.9", "4.5", "8.9", "9.5"] }, + { label: "친수용수", type: "blue-item", values: ["7.8", "3.1", "3.1", "3.0", "10.9", "10.5"] }, + { label: "하천유지용수", type: "blue-item", values: ["480.5", "482.5", "496.2", "442.7", "471.7", "484.0"] }, + { label: "농업용수", type: "blue-item", values: ["12.2", "11.9", "13.4", "10.7", "10.6", "11.4"] }, + { label: "지하수 충전", type: "blue-item", values: ["0.0", "0.0", "0.0", "13.4", "0.0", "0.0"] }, + { label: "공업용수", type: "blue-item", values: ["75.2", "104.6", "108.9", "129.9", "129.0", "128.1"] }, + ].map((row, idx) => { + const isSubItem = row.type === "red-item" || row.type === "blue-item"; + const isLastInGroup = idx === 8 || idx === 17; + + return ( + + + + {row.values.map((val, i) => ( + + ))} + + ); + })} + +
+ 구분 + + 2018 + + 2019 + + 2020 + + 2021 + + 2022 + + 2023 +
+ {isSubItem ? ( + <> +
+
+ + {row.label} + + + ) : ( + + {row.label} + + )} +
+ {val} +
+
+ + +
+
+ +
+ + {/* 3) 하수처리시설 설치 및 이용 현황 */} +
+ 하수처리시설 설치 및 이용 현황 + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + {[ + { + group: "전체", + rows: [ + [ + "시설수 (개소)", + "671", + "681", + "698", + "704", + "714", + "717", + ], + [ + "시설용량 (만m³/일)", + "25,846", + "26,067", + "26,244", + "26,492", + "26,586", + "26,766", + ], + [ + "처리용량 (만m³/일)", + "1,112.7", + "1,148.7", + "1,144.7", + "1,127.0", + "1,136.8", + "1,137.5", + ], + ], + }, + { + group: "법적 대상", + rows: [ + [ + "시설수 (개소)", + "68", + "82", + "94", + "97", + "101", + "118", + ], + [ + "시설용량 (만m³/일)", + "3,088.9", + "3,359.2", + "3,612.6", + "4,412.2", + "4,692.3", + "5,503.8", + ], + [ + "처리용량 (만m³/일)", + "162.2", + "190.6", + "218.5", + "244.6", + "247.2", + "324.9", + ], + ], + }, + { + group: "법적 미대상", + rows: [ + [ + "시설수 (개소)", + "603", + "599", + "604", + "607", + "613", + "599", + ], + [ + "시설용량 (만m³/일)", + "22,757", + "22,708", + "22,631", + "22,080", + "21,894", + "21,262", + ], + [ + "처리용량 (만m³/일)", + "950.6", + "958.1", + "926.2", + "882.4", + "889.5", + "812.7", + ], + ], + }, + ].map((section, sIdx) => ( + + {section.rows.map((row, rIdx) => ( + + {rIdx === 0 && ( + + )} + {row.map((val, i) => { + const isFirstCol = i === 0; + const isHighlightedCol = [1, 3, 5].includes(i); + + return ( + + ))} + + ))} + +
+ 구분 + + 2018 + + 2019 + + 2020 + + 2021 + + 2022 + + 2023 +
+ {section.group} + + ); + })} +
+
+ + +
+
+ +
+ + {/* 4) 시도별 하수처리수 재이용 현황 */} +
+ 시도별 하수처리수 재이용 현황 + +
+
+
+ (단위: 백만m³/일) +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {[ + ["합계", "7,537.2", "1,137.5", "490.0", "647.5", "484.0", "128.1", "11.4", "24.0", "15.1%", ""], + ["서울특별시", "1,567.0", "114.9", "59.3", "55.6", "55.1", "-", "-", "0.5", "7.3%", ""], + ["부산광역시", "491.5", "146.2", "120.7", "25.5", "25.3", "-", "-", "0.2", "29.7%", ""], + ["대구광역시", "432.7", "102.4", "38.2", "64.2", "64.1", "-", "-", "0.1", "23.7%", ""], + ["인천광역시", "302.3", "57.7", "11.6", "46.1", "29.2", "12.1", "0.5", "4.3", "19.1%", ""], + ["광주광역시", "211.1", "28.7", "6.5", "22.3", "22.2", "-", "-", "0.1", "13.6%", ""], + ["대전광역시", "244.6", "11.9", "11.9", "0.68", "0.0", "-", "-", "0.1", "4.9%", ""], + ["울산광역시", "235.1", "36.1", "6.1", "29.9", "29.7", "0.2", "-", "-", "15.3%", ""], + ["세종특별자치시", "38.5", "2.5", "2.0", "0.5", "0.4", "-", "0.2", "-", "6.6%", ""], + ["경기도", "1,926.8", "313.3", "122.2", "191.0", "153.0", "33.3", "0.4", "4.3", "16.3%", ""], + ["강원도", "230.4", "19.6", "15.4", "4.2", "2.1", "-", "-", "2.1", "8.5%", ""], + ["충청북도", "190.9", "15.6", "6.3", "9.2", "7.4", "0.6", "-", "1.2", "8.2%", ""], + ["충청남도", "241.5", "41.4", "9.8", "31.5", "17.7", "8.6", "4.5", "0.8", "17.1%", ""], + ["전라북도", "292.4", "42.7", "7.5", "35.1", "33.5", "0.2", "1.0", "0.5", "14.6%", ""], + ["전라남도", "254.0", "23.6", "12.3", "11.3", "8.7", "0.0", "1.0", "1.6", "9.3%", ""], + ["경상북도", "378.4", "129.1", "33.7", "95.3", "20.8", "72.5", "0.8", "1.3", "34.1%", ""], + ["경상남도", "411.3", "50.1", "25.0", "25.0", "14.7", "0.5", "3.0", "6.9", "12.2%", ""], + ["제주특별자치도", "88.6", "1.7", "1.5", "0.12", "-", "-", "0.1", "-", "1.9%", ""], + ].map((row, idx) => ( + + {row.map((val, i) => ( + + ))} + + ))} + +
+ 시도 + + 연간 +
+ 하수 +
+ 처리량 +
+ 재이용현황 + + 재이용률 +
+ (%) +
+ 비고 +
+ 계 + + 장내 + + 장외 +
+ 소계 + + 하천유지용수 + + 공업용수 + + 농업용수 + + 기타용수 +
+ {val} +
+
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/dm/app/(landing)/reuse/cases/page.tsx b/apps/dm/app/(landing)/reuse/cases/page.tsx new file mode 100644 index 000000000..1043827dd --- /dev/null +++ b/apps/dm/app/(landing)/reuse/cases/page.tsx @@ -0,0 +1,96 @@ +// DM 물 재이용 현황 페이지 +import BusinessSubNav, { type BusinessSubNavMenu } from "@/components/BusinessSubNav"; +import SubPageHeader from "@/components/SubPageHeader"; +import RainStatusSection from "./_components/RainStatusSection"; +import GreywaterStatusSection from "./_components/GreywaterStatusSection"; +import SewageStatusSection from "./_components/SewageStatusSection"; + +const menus: BusinessSubNavMenu[] = [ + { + key: "status-rain", + label: "빗물이용시설(현황)", + sections: [ + { + id: "status-rain-building", + label: "빗물이용시설의 건축물별 빗물이용량 현황", + }, + { + id: "status-rain-purpose-distribution", + label: "빗물이용시설의 용도별 분포 (2023년 기준)", + }, + { + id: "status-rain-purpose-usage", + label: "빗물이용시설의 용도별 빗물이용량 현황", + }, + ], + }, + { + key: "status-greywater", + label: "중수도(현황)", + defaultOpen: false, + sections: [ + { + id: "status-greywater-overall", + label: "전체 중수도 설치 및 이용 현황", + }, + { + id: "status-greywater-factory", + label: "공장 및 발전시설 내 중수도 설치 현황", + }, + { + id: "status-greywater-building", + label: "중수도 건축물별 이용량 현황", + }, + { + id: "status-greywater-usage", + label: "중수도 용도별 이용량 현황", + }, + ], + }, + { + key: "status-sewage", + label: "하수처리수 재이용시설(현황)", + defaultOpen: false, + sections: [ + { + id: "status-treated-year", + label: "연도별 하수처리수 재이용 현황", + }, + { + id: "status-treated-usage", + label: "용도별 하수처리수 재이용 현황", + }, + { + id: "status-treated-facility", + label: "하수처리시설 설치 및 이용 현황", + }, + { + id: "status-treated-region", + label: "시도별 하수처리수 재이용 현황", + }, + ], + }, +]; + +export default function StatusPage() { + return ( +
+ {/* 상단 헤더 */} + + {/* 우측 앵커 네비 */} + + +
+ + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/dm/app/(landing)/reuse/intro/_components/LawIntroSection.tsx b/apps/dm/app/(landing)/reuse/intro/_components/LawIntroSection.tsx new file mode 100644 index 000000000..de39aa798 --- /dev/null +++ b/apps/dm/app/(landing)/reuse/intro/_components/LawIntroSection.tsx @@ -0,0 +1,123 @@ +"use client"; + +import React from "react"; +import Image, { type StaticImageData } from "next/image"; + +import lawPlan from "@/public/images/landing/intro/law_timeline.png"; +import planPerformance from "@/public/images/landing/intro/performance_left.png"; +import domesticChange from "@/public/images/landing/intro/domestic_textbox.png"; +import policyHistory from "@/public/images/landing/intro/policy_timeline.png"; +import lawRelation from "@/public/images/landing/intro/rel_note.png"; +import domesticLinkage from "@/public/images/landing/intro/linkage.png"; + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ +

{children}

+
+ ); +} + +function Hr({ className = "" }: { className?: string }) { + return
; +} + +function ImgCard({ src, alt }: { src: StaticImageData; alt: string }) { + return ( +
+ {alt} +
+ ); +} + +function SectionShell({ + id, + title, + children, + className = "", +}: { + id: string; + title: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {title} +
{children}
+
+ ); +} + +export default function LawIntroSection() { + const title = "물 재이용 법"; + + return ( +
+
+

+ {title} + +

+
+ +
+ + + + +
+ + + + + +
+ + + + + +
+ + + + + +
+ + + + + +
+ + + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/dm/app/(landing)/reuse/intro/_components/RainIntroSection.tsx b/apps/dm/app/(landing)/reuse/intro/_components/RainIntroSection.tsx new file mode 100644 index 000000000..4de564a54 --- /dev/null +++ b/apps/dm/app/(landing)/reuse/intro/_components/RainIntroSection.tsx @@ -0,0 +1,99 @@ +"use client"; + +import React from "react"; +import Image, { type StaticImageData } from "next/image"; + +import rainSummary from "@/public/images/landing/intro/rainreuse_intro.png"; +import rainDuty from "@/public/images/landing/intro/rainreuse_mandatory.png"; +import rainDesign from "@/public/images/landing/intro/rainwater_design.png"; +import rainStandard from "@/public/images/landing/intro/rainwater_standards.png"; + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ +

{children}

+
+ ); +} + +function Hr({ className = "" }: { className?: string }) { + return
; +} + +function ImgCard({ src, alt }: { src: StaticImageData; alt: string }) { + return ( +
+ {alt} +
+ ); +} + +function SectionShell({ + id, + title, + children, + className = "", +}: { + id: string; + title: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {title} +
{children}
+
+ ); +} + +export default function RainIntroSection() { + const title = "빗물 이용시설"; + + return ( +
+
+

+ {title} + +

+
+ +
+ + + + +
+ + + + + +
+ + + + + +
+ + + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/dm/app/(landing)/reuse/intro/_components/ReuseWaterIntroSection.tsx b/apps/dm/app/(landing)/reuse/intro/_components/ReuseWaterIntroSection.tsx new file mode 100644 index 000000000..03b01e398 --- /dev/null +++ b/apps/dm/app/(landing)/reuse/intro/_components/ReuseWaterIntroSection.tsx @@ -0,0 +1,96 @@ +"use client"; + +import React from "react"; +import Image, { type StaticImageData } from "next/image"; + +import greywaterIntro from "@/public/images/landing/intro/greywater_intro.png"; +import greywaterMandatory from "@/public/images/landing/intro/greywater_mandatory.png"; +import greywaterStandard from "@/public/images/landing/intro/greywater_standard.png"; + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ +

{children}

+
+ ); +} + +function Hr({ className = "" }: { className?: string }) { + return
; +} + +function ImgCard({ src, alt }: { src: StaticImageData; alt: string }) { + return ( +
+ {alt} +
+ ); +} + +function SectionShell({ + id, + title, + children, + className = "", +}: { + id: string; + title: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {title} +
{children}
+
+ ); +} + +export default function ReuseWaterIntroSection() { + const title = "중수도"; + + return ( +
+
+

+ {title} + +

+
+ +
+ + + + +
+ + + + + +
+ + + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/dm/app/(landing)/reuse/intro/_components/TreatedIntroSection.tsx b/apps/dm/app/(landing)/reuse/intro/_components/TreatedIntroSection.tsx new file mode 100644 index 000000000..ff3ece0fc --- /dev/null +++ b/apps/dm/app/(landing)/reuse/intro/_components/TreatedIntroSection.tsx @@ -0,0 +1,89 @@ +"use client"; + +import React from "react"; +import Image, { type StaticImageData } from "next/image"; + +import treatedwaterIntro from "@/public/images/landing/intro/treatedwater_intro.png"; +import treatedwaterMandatory from "@/public/images/landing/intro/treatedwater_mandatory.png"; + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ +

{children}

+
+ ); +} + +function Hr({ className = "" }: { className?: string }) { + return
; +} + +function ImgCard({ src, alt }: { src: StaticImageData; alt: string }) { + return ( +
+ {alt} +
+ ); +} + +function SectionShell({ + id, + title, + children, + className = "", +}: { + id: string; + title: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {title} +
{children}
+
+ ); +} + +export default function TreatedIntroSection() { + const title = "하수처리수"; + + return ( +
+
+

+ {title} + +

+
+ +
+ + + + +
+ + + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/dm/app/(landing)/reuse/intro/page.tsx b/apps/dm/app/(landing)/reuse/intro/page.tsx new file mode 100644 index 000000000..5ee955604 --- /dev/null +++ b/apps/dm/app/(landing)/reuse/intro/page.tsx @@ -0,0 +1,86 @@ +// DM 물 재이용 소개 페이지 +import BusinessSubNav, { type BusinessSubNavMenu } from "@/components/BusinessSubNav"; +import SubPageHeader from "@/components/SubPageHeader"; +import LawIntroSection from "./_components/LawIntroSection"; +import RainIntroSection from "./_components/RainIntroSection"; +import ReuseWaterIntroSection from "./_components/ReuseWaterIntroSection"; +import TreatedIntroSection from "./_components/TreatedIntroSection"; + +const menus: BusinessSubNavMenu[] = [ + { + key: "intro-law", + label: "물 재이용 법", + sections: [ + { id: "law-plan", label: "법률 제·개정 및 기본계획 수립" }, + { id: "plan-performance", label: "제2차 계획 대비 실적 현황" }, + { id: "domestic-change", label: "우리나라의 수자원 현황 및 물이용 여건" }, + { id: "policy-history", label: "물 재이용 정책 변화 추이" }, + { id: "law-relation", label: "타법령과의 관계 검토" }, + { id: "domestic-linkage", label: "주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요" }, + ], + }, + { + key: "intro-rain", + label: "빗물 이용시설", + defaultOpen: false, + sections: [ + { id: "rain-intro", label: "빗물 재이용 소개" }, + { + id: "rain-mandatory", + label: "빗물이용시설 설치 관리 법적 의무 대상", + }, + { id: "rain-design", label: "빗물이용시설 설계 방안" }, + { + id: "rain-standards", + label: "빗물이용시설 시설기준 및 관리기준", + }, + ], + }, + { + key: "intro-greywater", + label: "중수도", + defaultOpen: false, + sections: [ + { id: "greywater-intro", label: "중수 재이용 소개" }, + { id: "greywater-mandatory", label: "중수도 설치 의무 대상" }, + { id: "greywater-standard", label: "중수도 시설기준 및 관리기준" }, + ], + }, + { + key: "intro-treated", + label: "하수처리수", + defaultOpen: false, + sections: [ + { id: "treatedwater-intro", label: "하수처리수 소개" }, + { + id: "treatedwater-mandatory", + label: "하수처리수 재이용시설 의무 대상", + }, + ], + }, +]; + +export default function IntroPage() { + return ( +
+ {/* 상단 헤더 */} + + {/* 우측 앵커 네비 */} + + + {/* 본문 */} +
+ + + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/dm/app/(popup-layout)/map/popup/page.tsx b/apps/dm/app/(popup-layout)/map/popup/page.tsx index 1c625bcac..bdce68a87 100644 --- a/apps/dm/app/(popup-layout)/map/popup/page.tsx +++ b/apps/dm/app/(popup-layout)/map/popup/page.tsx @@ -1,6 +1,7 @@ "use client"; import Gis from "@common/business_components/ui/map/Gis"; +import "@/app/commonOrigin.css"; import { useEffect, useRef, useState } from "react"; import { mapService } from "../service"; import GisApp from "@common/business_components/ui/map/lib/gis.core"; diff --git a/apps/dm/app/(popup-layout)/map/popup/selectedFcltList.tsx b/apps/dm/app/(popup-layout)/map/popup/selectedFcltList.tsx index 5c742684f..121de004e 100644 --- a/apps/dm/app/(popup-layout)/map/popup/selectedFcltList.tsx +++ b/apps/dm/app/(popup-layout)/map/popup/selectedFcltList.tsx @@ -22,32 +22,36 @@ export default function SelectedFcltList({ const TableContent = ( <> - + - 시설명 - - 처리량(㎥/일) + 시설명 + + 하수처리량 +
+ (㎥/일) - - 재이용량(㎥/일) + + 재이용량 +
+ (㎥/일) - 삭제 + 삭제 {data.map((item) => ( - - {item.ruseUtztnFcltNm} - + + {item.ruseUtztnFcltNm} + {Number(item.splyExtraCpct || 0).toLocaleString()} - + {Number(item.ruseCpct || 0).toLocaleString()} - + +
+ + + +
+ + + + + +
+ setTel(formatKoreanPhoneNumber(v))} + placeholder="전화번호를 입력하세요." + /> +
+
+ + + + {editMode ? ( + <> + +
+
+
+ { + setCurrentPw(value); + setIsCurrentPwVerified(false); + }} + placeholder="기존 비밀번호를 입력하세요." + type="password" + /> +
+ +
+

+ {isCurrentPwVerified ? "기존 비밀번호 확인이 완료되었습니다." : "수정 전 기존 비밀번호 확인이 필요합니다."} +

+
+
+ + + + ) : null} + + +
+ +
+
+ + + + +
+ +
+
+
+ +
+ +
+ + setIsPostcodeModalOpen(false)} + onSelect={handleSelectAddress} + />
- - -
- - - - - -
-
-
- -
- -
- - - -
-
- - - - -
- setTel(formatKoreanPhoneNumber(v))} - placeholder="전화번호를 입력하세요." - /> -
-
- - - - -
- -
-
- - - - -
- -
-
-
- -
- -
- - setIsPostcodeModalOpen(false)} - onSelect={handleSelectAddress} - /> +
); } diff --git a/apps/dm/app/(with-layout)/apply/register/page.tsx b/apps/dm/app/(with-layout)/apply/dmdinfo/page.tsx similarity index 57% rename from apps/dm/app/(with-layout)/apply/register/page.tsx rename to apps/dm/app/(with-layout)/apply/dmdinfo/page.tsx index db1c47cb5..cef0461d2 100644 --- a/apps/dm/app/(with-layout)/apply/register/page.tsx +++ b/apps/dm/app/(with-layout)/apply/dmdinfo/page.tsx @@ -1,4 +1,3 @@ -// app/(with-layout)/apply/register/page.tsx /** * 수요정보등록(수정) 화면 * @@ -37,9 +36,9 @@ export default function DemandRegisterPage() { // 기본정보 const [reqDate, setReqDate] = useState(today); - const [reqQty, setReqQty] = useState(""); - const [usePurpose, setUsePurpose] = useState(""); - const [hopePrice, setHopePrice] = useState(""); + const [reqQty, setReqQty] = useState(""); // 요구수량(㎥/d) + const [usePurpose, setUsePurpose] = useState(""); // 사용용도 + const [hopePrice, setHopePrice] = useState(""); // 희망단가 // 요구수질 const [coliform, setColiform] = useState(""); // 총대장균군수 @@ -63,9 +62,9 @@ export default function DemandRegisterPage() { const [cand3, setCand3] = useState(""); //const [candidateList, setCandidateList] = useState([]); - const useTypesData = useDmStore((state) => state.Sdm.useTypesData); - const getUseTypesData = useDmStore((state) => state.getUseTypesData); - const [openSimilar, setOpenSimilar] = useState(false); + const useTypesData = useDmStore((state) => state.Sdm.useTypesData); // 사용용도 + const getUseTypesData = useDmStore((state) => state.getUseTypesData); // 사용용도 함수 + const [openSimilar, setOpenSimilar] = useState(false); // 유사사례 modal open // 후보지 팝업 메시지 핸들러 const candsRef = useRef<{ name: string; code: string }[]>([]); @@ -115,15 +114,16 @@ export default function DemandRegisterPage() { const getSimilarCaseList = useDmStore((state) => state.getSimilarCaseList); const [farmText, setFarmText] = useState("이하"); - const insertSdmInfoData = useDmStore( - (state) => state.insertSdmInfoData - ); + const insertSdmInfoData = useDmStore( (state) => state.insertSdmInfoData ); // 수요정보등록 useEffect(() => { - getUseTypesData(); + getUseTypesData(); // 사용용도 조회 }, [getUseTypesData]); + // 후보지 선택 팝업이 window.postMessage로 넘긴 선택 결과를 받아 + // 현재 화면의 후보지 입력칸(cand1~3)에 반영한다. useEffect(() => { + // message 이벤트의 payload를 읽어 후보지 목록 상태를 갱신하는 핸들러 const handler = (event: MessageEvent) => { if (!event.data) return; @@ -200,25 +200,26 @@ export default function DemandRegisterPage() { return true; } + // 유사사례 const rows: SimilarCaseRow[] = similarCaseList.map((el, idx) => ({ caseId: String(idx), - projectName: el.ruseBizNm, - period: `${el.bizBgngDate} ~ ${el.bizEndDate}`, + projectName: el.ruseBizNm, // 사업명 + period: `${el.bizBgngDate} ~ ${el.bizEndDate}`, // 사업기간 - supplyFacilityType: el.splyFcltKnd, - supplyName: el.splyFcltNm, - supplyCapacity: Number(el.splyFcltCpct), + supplyFacilityType: el.splyFcltKnd, // 시설종류 + supplyName: el.splyFcltNm, // 공급처명 + supplyCapacity: Number(el.splyFcltCpct), // 공급처 시설용량 - demandName: el.dmdFcltNm, - demandAddress: el.dmdFcltAddr, + demandName: el.dmdFcltNm, // 수요처명 + demandAddress: el.dmdFcltAddr, // 수요처 주소 - facilityCapacity: Number(el.ruseFcltCpct), - pipeKm: Number(el.ruseFcltPplLen), - purpose: el.ruseFcltUsg, + facilityCapacity: Number(el.ruseFcltCpct), // 재이용시설 시설용량 + pipeKm: Number(el.ruseFcltPplLen), // 재이용시설 관로길이 + purpose: el.ruseFcltUsg, // 재이용시설 용도 - supplyAmt: Number(el.ruseFcltSplyCpct), - treatment: el.ruseFcltPrcsMsrc, - usePurpose: el.ruseFcltUsg, + supplyAmt: Number(el.ruseFcltSplyCpct), // 재이용시설 공급(용)량 + treatment: el.ruseFcltPrcsMsrc, // 재이용시설 처리공법 + usePurpose: el.ruseFcltUsg, // 재이용시설 용도 })); const detailsByCaseId: Record = {}; @@ -312,6 +313,7 @@ export default function DemandRegisterPage() { } } + // 후보지 조회 팝업 Open const searchCandidate = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -328,28 +330,27 @@ export default function DemandRegisterPage() { window.open(url, "candidatePopup", popupOptions); }; + // 유사기준 const getCriterionCode = (criterion: string) => { switch (criterion) { - case "supplyAmt": - return "S"; - case "money": - return "M"; - case "usePurpose": - return "U"; - default: - return ""; + case "supplyAmt": return "S"; // 공급량 + case "money": return "M"; // 단가? + case "usePurpose": return "U"; // 이용용도 + default: return ""; } }; + // 유사사례 Modal Open const searchSimilarCase = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setOpenSimilar(true); }; + // 수요정보 입력란 초기화 const initQualityForm = () => { setColiform(""); - setNoDetect(false); + setNoDetect(false); // 총대장균군수 불검출 setTn(""); setResidualCl(""); setTp(""); @@ -364,6 +365,7 @@ export default function DemandRegisterPage() { setFarmText("이하"); } + // 사용용도별 초기화 const fncSetOptionValue = (e: React.ChangeEvent) => { const value = e.target.value; initQualityForm(); @@ -422,261 +424,278 @@ export default function DemandRegisterPage() { setUsePurpose(value); } + const pageTitle = "수요정보등록"; + return ( <> -
+
+
+ {/* 서브 타이틀 + 브레드크럼 */} + - {/* 서브 타이틀 + 브레드크럼 */} - - - {/* 컨텐츠 영역 */} -
- - {/* 기본정보 테이블 */} -
- - - - - - - - - - - - - - - - - - - - - -
- - {/* 요구수질 섹션 타이틀 바 */} -
-
- 요구수질 - - {/* 수질기준참고 모달 */} - setOpenRef(false)} - imageSrc="/images/landing/water_quality_ref_1.png" - /> -
-
- - {/* 요구수질 테이블 */} -
- {/* 1행 */} - - - + {/* 컨텐츠 영역 */} +
+ {/* 타이틀 */} +
+

+ {pageTitle} + +

- } - 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={ -