Compare commits

..
Author SHA1 Message Date
rsone001 ba2ccf6f97 feat: 입력 > 02. 하수관로 시설별 설치 현황관리 버튼 추가
- tab 6. 시설별 현황 관리 탭에서만 필요한 버튼
2026-02-19 10:21:26 +09:00
2510 changed files with 37392 additions and 277237 deletions
-33
View File
@@ -1,33 +0,0 @@
name: Deploy Frontend
on:
push:
branches:
- deploy
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p 28 -H 192.168.0.4 >> ~/.ssh/known_hosts
- name: Deploy
run: |
ssh -i ~/.ssh/deploy_key -p 28 onasys@192.168.0.4 << 'EOF'
cd ~/frontend
git restore .
git clean -fd
git pull origin deploy
pnpm install
pnpm turbo build
pm2 reload all
EOF
+5 -10
View File
@@ -4,7 +4,10 @@
# dependencies
node_modules/
.idea/
# build
.next/
dist/
# build
.next
@@ -19,15 +22,7 @@ apps/fo/.next/**/*
#.env*
.qodo
# next-agents-md
.next-docs/
pnpm-lock.yaml
settings.json
.npmrc
.cursor
.agent
apps/**/tsconfig.tsbuildinfo
packages/**/tsconfig.tsbuildinfo
logs/
.cursor
-10
View File
@@ -1,10 +0,0 @@
#!/bin/sh
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$BRANCH" != "main" ]; then
exit 0
fi
echo "Branch: $BRANCH"
echo "Changed packages (dry-run):"
pnpm turbo run build --filter="...[origin/$BRANCH]" --dry-run
echo "Starting build..."
pnpm turbo run build --filter="...[origin/$BRANCH]"
-88
View File
@@ -1,88 +0,0 @@
{
// IntelliSense를 사용하여 가능한 특성에 대해 알아보세요.
// 기존 특성에 대한 설명을 보려면 가리킵니다.
// 자세한 내용을 보려면 https://go.microsoft.com/fwlink/?linkid=830387을(를) 방문하세요.
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "dev:as 디버그",
"runtimeExecutable": "pnpm",
"runtimeArgs": [
"run",
"dev:as"
],
"skipFiles": [
"<node_internals>/**"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"sourceMaps": true,
"env": {
"NODE_OPTIONS": "--inspect"
}
},
{
"type": "node",
"request": "launch",
"name": "dev:cs 디버그",
"runtimeExecutable": "pnpm",
"runtimeArgs": [
"run",
"dev:cs"
],
"skipFiles": [
"<node_internals>/**"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"sourceMaps": true,
"env": {
"NODE_OPTIONS": "--inspect"
}
},
{
"type": "node",
"request": "attach",
"name": "dev:cs 서버 연결 (attach)",
"port": 9229,
"restart": true,
"skipFiles": [
"<node_internals>/**"
],
"sourceMaps": true,
"resolveSourceMapLocations": [
"${workspaceFolder}/apps/cs/**",
"!**/node_modules/**"
]
},
{
"type": "chrome",
"request": "launch",
"name": "dev:cs 클라이언트 디버그 (Chrome)",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/apps/cs",
"sourceMaps": true
},
{
"type": "node",
"request": "launch",
"name": "dev:st 디버그",
"runtimeExecutable": "pnpm",
"runtimeArgs": [
"run",
"dev:st"
],
"skipFiles": [
"<node_internals>/**"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"sourceMaps": true,
"env": {
"NODE_OPTIONS": "--inspect"
}
}
]
}
-5
View File
@@ -1,5 +0,0 @@
{
"chat.tools.terminal.autoApprove": {
"pnpm": true
}
}
-8
View File
@@ -1,8 +0,0 @@
# src/shared/config/env.ts 참고
NEXT_PUBLIC_PORT=
NEXT_PUBLIC_PROXY_HOST=
NEXT_PUBLIC_SITE_ID=
SERVER_API_URL=
# 401 시 브라우저 리다이렉트 대상
NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL=/auth/login
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}
-8
View File
@@ -1,8 +0,0 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": false,
"printWidth": 100,
"tabWidth": 2,
"plugins": ["prettier-plugin-tailwindcss"]
}
-187
View File
@@ -1,187 +0,0 @@
# 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";
```
@@ -1,10 +0,0 @@
import type { ReactNode } from "react";
import { BaseLayoutWrapper } from "@/app/layouts/base-layout";
interface WithBaseLayoutProps {
children: ReactNode;
}
export default function WithBaseLayout({ children }: WithBaseLayoutProps) {
return <BaseLayoutWrapper>{children}</BaseLayoutWrapper>;
}
@@ -1 +0,0 @@
export { MainPage as default } from "@/pages/home";
-169
View File
@@ -1,169 +0,0 @@
# App Layer
**App**은 애플리케이션 전체에 영향을 미치는 기술적, 비즈니스적 사항을 다루는 최상위 레이어입니다.
## 목적
- 애플리케이션의 진입점(entrypoint) 제공
- 전역 Context Providers 설정
- 라우터 구성
- 글로벌 스타일 정의
- 전역 상태 저장소 설정
- 애널리틱스 및 모니터링 설정
## 특징
- **슬라이스 없음**: App 레이어는 Shared처럼 슬라이스가 없고 세그먼트로만 구성됩니다
- **전체 앱 관심사**: 특정 비즈니스 도메인이 아닌 앱 전체와 관련된 코드만 포함
- **최상위 레이어**: 모든 하위 레이어의 모듈을 자유롭게 import 가능
## 구조 (Next.js App Router)
Next.js를 사용하는 경우, App 레이어는 주로 `app/` 디렉토리로 표현됩니다:
```
app/
├── (routes)/ # 라우트 그룹 및 페이지
│ ├── (auth)/
│ │ ├── login/
│ │ │ └── page.tsx
│ │ └── register/
│ │ └── page.tsx
│ └── (main)/
│ ├── dashboard/
│ │ └── page.tsx
│ └── settings/
│ └── page.tsx
├── layout.tsx # 루트 레이아웃
├── page.tsx # 홈 페이지
├── globals.css # 글로벌 스타일
├── providers.tsx # 전역 Providers 설정
└── error.tsx # 글로벌 에러 핸들러
```
### 일반적인 FSD 세그먼트 구조
FSD 표준 세그먼트를 사용하는 경우:
```
app/
├── entrypoint/ # 애플리케이션 진입점 (프레임워크별)
│ └── index.tsx
├── providers/ # Context Providers
│ ├── query-provider.tsx
│ ├── theme-provider.tsx
│ └── index.tsx
├── styles/ # 글로벌 스타일
│ ├── globals.css
│ └── theme.css
├── store/ # 글로벌 상태 저장소 설정
│ └── index.ts
└── routes/ # 라우터 설정
└── index.tsx
```
## 예시
### Next.js Providers 설정
```typescript
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from 'next-themes';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
}));
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</QueryClientProvider>
);
}
// app/layout.tsx
import { Providers } from './providers';
import './globals.css';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<Providers>
{children}
</Providers>
</body>
</html>
);
}
```
### 글로벌 설정
```typescript
// app/config/analytics.ts
export function initAnalytics() {
if (typeof window !== 'undefined') {
// 애널리틱스 초기화
}
}
// app/config/monitoring.ts
export function initMonitoring() {
// 에러 모니터링 설정
}
```
## Next.js App Router와 FSD
Next.js의 `app/` 디렉토리는 FSD의 **App 레이어**와 **Pages 레이어**를 동시에 담당합니다:
- **루트 레벨 파일들** (`layout.tsx`, `providers.tsx`, `globals.css`): App 레이어
- **라우트 폴더들** (`dashboard/`, `login/` 등): Pages 레이어
각 페이지별 비즈니스 로직은:
- 재사용되지 않는 UI는 페이지 슬라이스(`app/dashboard/` 등) 안에 유지
- 재사용되는 기능은 `features/` 레이어로 분리
- 여러 페이지에서 재사용되는 큰 UI 블록은 `widgets/` 레이어로 분리
## 레이어 의존성 규칙
✅ **허용되는 의존성**:
- 모든 하위 레이어 (`pages`, `widgets`, `features`, `entities`, `shared`)
- 같은 `app` 레이어 내의 다른 세그먼트
- 외부 라이브러리
❌ **금지된 의존성**:
- 없음 (App은 최상위 레이어)
## 주의사항
1. **비대화 방지**: App 레이어가 너무 커지면 관리가 어려워집니다. 대부분의 로직은 하위 레이어에 배치하세요
2. **전역 관심사만**: 특정 페이지나 기능에만 관련된 코드는 해당 레이어로 이동
3. **라우터 레벨 로직**: 라우터 및 서버 레벨 로직은 이 레이어에 유지
4. **Processes 레이어 사용 지양**: Processes 레이어는 deprecated되었으므로 해당 로직은 `app`이나 `features`에 배치
## FSD v2.1 권장사항
최신 FSD 버전(v2.1)은 **"pages-first"** 접근 방식을 권장합니다:
- 더 많은 코드를 페이지 슬라이스에 유지
- 큰 UI 블록과 재사용되지 않는 비즈니스 로직은 즉시 추출하지 말고 페이지에 보관
- 실제로 재사용이 필요할 때만 `features`나 `widgets`로 분리
이 접근 방식은 과도한 추상화를 방지하고 코드를 더 쉽게 탐색할 수 있게 합니다.
-25
View File
@@ -1,25 +0,0 @@
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 (
<html lang="ko" data-theme={theme?.value}>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
-18
View File
@@ -1,18 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
-50
View File
@@ -1,50 +0,0 @@
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*", "/"],
};
-8
View File
@@ -1,8 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
transpilePackages: ["@common/assets"],
};
export default nextConfig;
-36
View File
@@ -1,36 +0,0 @@
{
"name": "template",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint:fsd": "steiger ."
},
"dependencies": {
"@common/assets": "workspace:*",
"@common/components": "workspace:*",
"@common/network": "workspace:*",
"@hookform/resolvers": "^4.1.3",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5",
"@tanstack/react-table": "^8.21.2",
"axios": "^1.8.1",
"dayjs": "^1.11.13",
"next": "15.5.18",
"react-hook-form": "^7.54.2",
"zod": "^3.24.2"
},
"devDependencies": {
"eslint": "^9.0.0",
"eslint-config-next": "15.3.8",
"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"
}
}
-9
View File
@@ -1,9 +0,0 @@
# 이 폴더는 비워 둡니다 (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)
-8
View File
@@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
@@ -1,35 +0,0 @@
"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 (
<BaseLayout
queryClient={queryClient}
currentPath={pathname ?? ""}
handleMenuClick={handleMenuClick}
>
<PageLayout>{children}</PageLayout>
</BaseLayout>
);
}
@@ -1,18 +0,0 @@
"use client";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { queryClient } from "@/shared/lib";
interface ProvidersProps {
children: React.ReactNode;
}
export function Providers({ children }: ProvidersProps) {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
-15
View File
@@ -1,15 +0,0 @@
@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;
}
-114
View File
@@ -1,114 +0,0 @@
# Entities Layer
**Entities**는 프로젝트가 다루는 실제 비즈니스 개념을 나타내는 레이어입니다. 비즈니스에서 제품을 설명할 때 사용하는 용어들(예: User, Product, Order)이 주로 엔티티가 됩니다.
## 목적
- 비즈니스 엔티티의 데이터 모델 및 타입 정의
- 엔티티 관련 API 요청 함수 관리
- 엔티티의 시각적 표현 제공 (재사용 가능한 UI)
- 여러 페이지에서 동일한 외관을 재사용할 수 있도록 지원
## 구조
각 엔티티 슬라이스는 다음 세그먼트로 구성됩니다:
```
entities/
└── [entity-name]/
├── model/ # 데이터 저장소, 검증 스키마, 상태 관리
├── api/ # 엔티티 관련 API 요청 함수
├── ui/ # 엔티티 시각적 표현 (선택적)
├── lib/ # 엔티티 관련 유틸리티 (선택적)
├── @x/ # 다른 엔티티로부터의 cross-import용 Public API (선택적)
└── index.ts # Public API
```
### 세그먼트 설명
- **model**: 데이터 모델, 타입 정의, 상태 저장소 (Zustand 등)
- **api**: 백엔드 API 호출 함수
- **ui**: 엔티티를 표시하는 UI 컴포넌트 (완전한 UI 블록이 아니라 재사용 가능한 표현)
- **lib**: 엔티티 관련 유틸리티 함수
- **@x**: 다른 엔티티가 이 엔티티를 import할 때 사용하는 명시적 cross-import API
- **index.ts**: 슬라이스의 Public API (외부에서 사용할 수 있는 것만 export)
## 예시
```typescript
// entities/user/model/user.ts
export interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
// entities/user/api/get-user.ts
import type { User } from '../model/user';
export async function getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// entities/user/ui/user-card.tsx
import type { User } from '../model/user';
interface UserCardProps {
user: User;
onClick?: () => void;
}
export function UserCard({ user, onClick }: UserCardProps) {
return (
<div onClick={onClick}>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
}
// entities/user/index.ts
export { type User } from './model/user';
export { getUser } from './api/get-user';
export { UserCard } from './ui/user-card';
```
## 엔티티 간 관계
엔티티가 서로 연결되어 있을 때는 `@x` 표기법을 사용하여 명시적으로 cross-import를 관리하는 것이 권장됩니다:
```typescript
// entities/artist/model/artist.ts
import type { Song } from 'entities/song/@x/artist';
export interface Artist {
name: string;
songs: Array<Song>;
}
// entities/song/@x/artist.ts
export type { Song } from '../model/song';
```
> `@x` 폴더를 사용하면 엔티티 간 연결을 명확하게 만들어 함께 리팩터링이 필요한 코드를 쉽게 찾을 수 있습니다.
## 레이어 의존성 규칙
✅ **허용되는 의존성**:
- `shared` 레이어의 모듈
- 같은 엔티티 슬라이스 내의 다른 세그먼트
- 다른 엔티티 슬라이스 (필요한 경우, `@x` 표기법 권장)
❌ **금지된 의존성**:
- `features` 레이어
- `widgets` 레이어
- `pages` 레이어
- `app` 레이어
## 주의사항
1. **UI 컴포넌트는 완전한 비즈니스 로직을 포함하지 않음**: UI는 주로 props나 slots를 통해 다양한 비즈니스 로직을 연결할 수 있도록 설계
2. **엔티티 간 비즈니스 로직은 상위 레이어에**: 엔티티 간 상호작용의 비즈니스 로직은 Features나 Pages 레이어에서 처리
3. **데이터 관리에 집중**: 복잡한 비즈니스 로직보다는 데이터 모델, 검증, 저장에 집중
@@ -1 +0,0 @@
export { type MenuItem } from "./model/types";
@@ -1,19 +0,0 @@
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;
}
-133
View File
@@ -1,133 +0,0 @@
# Features Layer
**Features**는 앱의 주요 사용자 상호작용과 기능을 구현하는 레이어입니다. 사용자가 실제로 수행하는 작업들로, 주로 비즈니스 엔티티와 관련이 있습니다.
## 목적
- 사용자가 중요하게 생각하는 주요 상호작용 구현
- 재사용 가능한 비즈니스 기능 제공
- 여러 페이지에서 사용되는 기능의 중복 제거
## Feature로 만들어야 하는 경우
> ⚠️ **중요**: 모든 것을 feature로 만들 필요는 없습니다.
Feature로 만드는 것이 좋은 지표:
- **여러 페이지에서 재사용됨**: 예를 들어, 여러 에디터가 있고 모두 댓글 기능이 있다면, 댓글은 재사용 가능한 feature입니다
- **중요한 비즈니스 기능**: 신규 프로젝트 참여자가 Features 폴더를 보고 앱의 주요 기능을 빠르게 파악할 수 있어야 합니다
Feature가 너무 많으면 중요한 기능이 묻히므로, 신규 참여자의 경험을 최적화하여 결정하세요.
## 구조
각 feature 슬라이스는 다음 세그먼트로 구성됩니다:
```
features/
└── [feature-name]/
├── ui/ # 상호작용을 수행하는 UI (폼, 버튼 등)
├── api/ # 액션 수행에 필요한 API 호출 (선택적)
├── model/ # 검증 및 내부 상태 관리 (선택적)
├── lib/ # 유틸리티 함수 (선택적)
├── config/ # feature flags (선택적)
└── index.ts # Public API
```
### 세그먼트 설명
- **ui**: 사용자 상호작용을 위한 UI 컴포넌트 (폼, 모달, 버튼 등)
- **api**: feature 실행에 필요한 API 요청 함수
- **model**: 폼 검증, 내부 상태, 비즈니스 로직
- **lib**: feature 내부에서만 사용되는 유틸리티
- **config**: feature flags, feature별 설정값
- **index.ts**: 슬라이스의 Public API (외부에서 사용할 수 있는 것만 export)
## 예시
```typescript
// features/auth-login/ui/login-form.tsx
import { useState } from 'react';
import { loginUser } from '../api/login-user';
import type { LoginCredentials } from '../model/types';
export function LoginForm() {
const [credentials, setCredentials] = useState<LoginCredentials>({
email: '',
password: ''
});
const handleSubmit = async (e: React.SubmitEvent) => {
e.preventDefault();
await loginUser(credentials);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={credentials.email}
onChange={(e) => setCredentials({ ...credentials, email: e.target.value })}
/>
<input
type="password"
value={credentials.password}
onChange={(e) => setCredentials({ ...credentials, password: e.target.value })}
/>
<button type="submit">로그인</button>
</form>
);
}
// features/auth-login/api/login-user.ts
import type { LoginCredentials } from '../model/types';
export async function loginUser(credentials: LoginCredentials) {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
});
return response.json();
}
// features/auth-login/model/types.ts
export interface LoginCredentials {
email: string;
password: string;
}
// features/auth-login/index.ts
export { LoginForm } from './ui/login-form';
export { loginUser } from './api/login-user';
export type { LoginCredentials } from './model/types';
```
## 레이어 의존성 규칙
✅ **허용되는 의존성**:
- `shared` 레이어의 모듈
- `entities` 레이어의 모듈
- 같은 feature 슬라이스 내의 다른 세그먼트
❌ **금지된 의존성**:
- 다른 `features` 슬라이스 (feature 간 직접 의존 금지)
- `widgets` 레이어
- `pages` 레이어
- `app` 레이어
> 💡 **팁**: 여러 features가 공통 기능을 필요로 한다면, 그 기능을 `shared` 레이어로 이동시키거나 `entities` 레이어에서 관리하세요.
## 명명 규칙
- 소문자와 대시 사용: `auth-login`, `post-create`, `comment-edit`
- 동사 형태 권장: 사용자 액션을 나타내는 이름 사용
- 명확하고 구체적으로: 기능의 목적이 명확하게 드러나도록
## 주의사항
1. **하나의 기능에만 집중**: 각 feature는 단일 사용자 시나리오를 구현해야 합니다
2. **재사용성 고려**: 여러 페이지에서 사용될 수 있도록 설계
3. **독립성 유지**: 다른 features에 의존하지 않고 독립적으로 작동
4. **적절한 추상화**: 너무 많은 feature를 만들지 말고, 중요한 기능 위주로 구성
-1
View File
@@ -1 +0,0 @@
export { MainPage } from "./ui/main-page";
@@ -1,9 +0,0 @@
"use client";
export function MainPage() {
return (
<div className="flex h-full flex-col items-center justify-center py-10">
<h1 className="mb-4 text-3xl font-bold">Home</h1>
</div>
);
}
-149
View File
@@ -1,149 +0,0 @@
# Shared Layer
**Shared**는 애플리케이션의 기반을 형성하는 레이어입니다. 외부 세계(백엔드, 서드파티 라이브러리, 환경)와의 연결을 생성하고, 높은 응집력을 가진 자체 라이브러리를 정의하는 곳입니다.
## 목적
- 외부 시스템 및 라이브러리와의 통합
- 프로젝트 전체에서 사용되는 공통 코드 제공
- 비즈니스 로직이 없는 재사용 가능한 유틸리티 및 UI 제공
- 글로벌 설정 및 환경 변수 관리
## 특징
- **슬라이스 없음**: Shared 레이어는 비즈니스 도메인이 없으므로 슬라이스로 나뉘지 않습니다
- **세그먼트 구성**: 기술적 목적에 따라 세그먼트로 직접 구성됩니다
- **자유로운 import**: Shared 내의 모든 파일은 서로를 참조하고 import할 수 있습니다
## 구조
```
shared/
├── ui/ # 애플리케이션 UI Kit
│ ├── button/
│ ├── input/
│ ├── modal/
│ └── index.ts
├── api/ # API 클라이언트 및 백엔드 엔드포인트 함수
│ ├── client.ts
│ └── index.ts
├── lib/ # 내부 라이브러리 모음
│ ├── dates/ # 날짜 관련 유틸리티
│ ├── colors/ # 색상 관련 유틸리티
│ ├── text/ # 텍스트 처리 유틸리티
│ └── index.ts
├── config/ # 환경 변수, 글로벌 feature flags
│ ├── env.ts
│ └── index.ts
├── types/ # 공통 타입 정의
│ └── index.ts
├── i18n/ # 번역 설정, 글로벌 번역 문자열
│ └── index.ts
└── routes/ # 라우트 상수 또는 패턴
└── index.ts
```
## 세그먼트 설명
### 📁 ui
애플리케이션의 UI Kit로, 비즈니스 로직을 포함하지 않는 재사용 가능한 UI 컴포넌트들입니다.
- Button, Input, Modal 같은 기본 컴포넌트
- 비즈니스 로직은 없지만 UI 로직은 포함 가능 (예: Autocomplete, SearchBar)
- 비즈니스 테마 컴포넌트도 허용 (예: 회사 로고, 페이지 레이아웃)
### 📁 api
API 클라이언트 및 특정 백엔드 엔드포인트 요청 함수입니다.
- HTTP 클라이언트 설정 (Axios, Fetch 등)
- 인증 인터셉터
- 공통 에러 핸들링
### 📁 lib
내부 라이브러리 모음으로, 각 라이브러리는 **하나의 관심사**에 집중해야 합니다.
> ⚠️ **중요**: `helpers`나 `utils` 폴더처럼 덤프 폴더로 만들지 마세요. 각 라이브러리는 명확한 초점 영역(예: dates, colors, text)을 가져야 하며, README로 문서화해야 합니다.
### 📁 config
환경 변수, 글로벌 feature flags 및 앱 전체 설정입니다.
### 📁 types
프로젝트 전체에서 사용되는 공통 타입 정의입니다.
### 📁 i18n
번역 설정 코드 및 글로벌 번역 문자열입니다.
### 📁 routes
라우트 상수 또는 라우트 매칭 패턴입니다.
## 예시
```typescript
// shared/ui/button/button.tsx
interface ButtonProps {
children: React.ReactNode;
variant?: 'primary' | 'secondary';
onClick?: () => void;
}
export function Button({ children, variant = 'primary', onClick }: ButtonProps) {
return (
<button
className={variant === 'primary' ? 'bg-blue-500' : 'bg-gray-500'}
onClick={onClick}
>
{children}
</button>
);
}
// shared/api/client.ts
import axios from 'axios';
export const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
timeout: 10000,
});
// shared/lib/dates/format-date.ts
import dayjs from 'dayjs';
export function formatDate(date: Date, format: string = 'YYYY-MM-DD'): string {
return dayjs(date).format(format);
}
// shared/config/env.ts
export const ENV = {
apiUrl: process.env.NEXT_PUBLIC_API_URL!,
isDev: process.env.NODE_ENV === 'development',
} as const;
```
## 레이어 의존성 규칙
✅ **허용되는 의존성**:
- 같은 `shared` 레이어 내의 다른 세그먼트 (자유로운 cross-import)
- 외부 라이브러리 및 패키지
❌ **금지된 의존성**:
- `entities` 레이어
- `features` 레이어
- `widgets` 레이어
- `pages` 레이어
- `app` 레이어
> 💡 **원칙**: Shared는 가장 낮은 레이어이므로 상위 레이어에 의존하지 않습니다.
## 세그먼트 명명 규칙
세그먼트 이름은 **내용의 목적**을 설명해야 하며, **본질**을 설명하면 안 됩니다.
❌ **나쁜 예시**: `components`, `hooks`, `types` (본질을 설명)
✅ **좋은 예시**: `ui`, `api`, `lib/dates`, `config` (목적을 설명)
## 주의사항
1. **비즈니스 로직 없음**: Shared는 순수한 기술적 코드만 포함
2. **높은 재사용성**: 앱 전체에서 사용될 수 있어야 함
3. **명확한 라이브러리 구조**: `lib` 내부는 관심사별로 명확하게 분리
4. **문서화**: 각 라이브러리는 README로 사용법과 목적을 문서화
-9
View File
@@ -1,9 +0,0 @@
import { Client } from "@common/network";
import { PROXY_HOST, SITE_ID } from "@/shared/config/env";
const BASE_URL = `${PROXY_HOST}/server`;
export const apiClient = Client.getInstance({
baseURL: BASE_URL,
siteId: SITE_ID,
});
-1
View File
@@ -1 +0,0 @@
export { apiClient } from "./client";
-8
View File
@@ -1,8 +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 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";
@@ -1 +0,0 @@
export * from "./env";
-12
View File
@@ -1,12 +0,0 @@
import dayjs from "dayjs";
import "dayjs/locale/ko";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(relativeTime);
dayjs.locale("ko");
export { dayjs };
-2
View File
@@ -1,2 +0,0 @@
export { queryClient } from "./query-client";
export { dayjs } from "./dayjs";
@@ -1,11 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 60 * 1000,
},
},
});
-29
View File
@@ -1,29 +0,0 @@
export interface BaseEntity {
id: string;
createdAt: string;
updatedAt: string;
}
export interface PaginationParams {
page: number;
limit: number;
}
export interface PaginationResponse<T> {
data: T[];
total: number;
page: number;
limit: number;
}
export interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
export interface ApiError {
message: string;
code?: string;
status?: number;
}
-1
View File
@@ -1 +0,0 @@
export { PageLayout } from "./page-layout";
@@ -1,35 +0,0 @@
"use client";
import { PageTitle } from "@common/components";
const mockCurrentMenuInfo = {
menuCd: 0,
menuKornNm: "메인",
menuEngNm: "Main",
parentId: null,
sortSeq: 0,
children: null,
levelNo: 0,
urlPath: "/",
menuLnkgTypeCd: "",
menuLnkgSn: 0,
menuPath: "메인",
qrcd: "",
useYn: "Y",
otptYn: "Y",
newWndwYn: "N",
requiredUrlPath: false,
};
function PageLayout({ children }: { children: React.ReactNode }) {
const menuPaths = mockCurrentMenuInfo.menuPath.split(">").map((path) => path.trim());
return (
<div className="bg-container h-auto min-h-full pb-2">
<PageTitle title={mockCurrentMenuInfo.menuKornNm} menuPaths={menuPaths} />
{children}
</div>
);
}
export { PageLayout };
-135
View File
@@ -1,135 +0,0 @@
# Widgets Layer
**Widgets**는 큰 규모의 독립적인 UI 블록을 위한 레이어입니다. 여러 features와 entities를 조합하여 완전한 기능을 제공하는 복합 컴포넌트를 담습니다.
## 목적
- 큰 규모의 독립적인 UI 블록 구성
- 여러 features와 entities를 하나의 응집력 있는 UI로 결합
- 여러 페이지에서 재사용되는 복잡한 UI 제공
- 페이지 레벨 레이아웃 및 섹션 관리
## Widget으로 만들어야 하는 경우
Widget은 다음 조건에 해당할 때 사용하세요:
✅ **Widget으로 만들기 좋은 경우**:
- 여러 페이지에서 재사용되는 큰 UI 블록
- 페이지에 여러 개의 큰 독립적 블록이 있고, 이것이 그 중 하나인 경우
- 페이지 레벨 레이아웃 컴포넌트 (nested routing 시스템 사용 시)
❌ **Widget으로 만들지 말아야 하는 경우**:
- UI 블록이 페이지의 대부분을 차지하고 재사용되지 않는 경우 → 페이지 슬라이스 내부에 직접 배치
- 작은 재사용 컴포넌트 → `shared/ui`에 배치
## 구조
각 widget 슬라이스는 다음 세그먼트로 구성됩니다:
```
widgets/
└── [widget-name]/
├── ui/ # 위젯의 메인 UI 컴포넌트
├── model/ # 위젯 내부 상태 관리 (선택적)
├── api/ # 위젯 전용 API 호출 (선택적)
├── lib/ # 위젯 내부 유틸리티 (선택적)
└── index.ts # Public API
```
### 세그먼트 설명
- **ui**: 여러 features와 entities를 조합한 위젯 UI
- **model**: 위젯 내부 상태 및 로직 (선택적)
- **api**: 위젯이 필요로 하는 데이터 fetching (선택적)
- **lib**: 위젯 내부에서만 사용되는 헬퍼 함수 (선택적)
- **index.ts**: 슬라이스의 Public API (외부에서 사용할 수 있는 것만 export)
## 예시
```typescript
// widgets/header/ui/header.tsx
import { Logo } from 'shared/ui/logo';
import { Navigation } from 'shared/ui/navigation';
import { UserMenu } from 'features/user-menu';
import { NotificationBell } from 'features/notifications';
export function Header() {
return (
<header>
<Logo />
<Navigation />
<NotificationBell />
<UserMenu />
</header>
);
}
// widgets/product-list/ui/product-list.tsx
import { useProducts } from '../api/use-products';
import { ProductCard } from 'entities/product';
import { AddToCartButton } from 'features/cart-add-item';
export function ProductList() {
const { data: products, isLoading } = useProducts();
if (isLoading) return <div>로딩 중...</div>;
return (
<div>
{products.map((product) => (
<div key={product.id}>
<ProductCard product={product} />
<AddToCartButton productId={product.id} />
</div>
))}
</div>
);
}
// widgets/product-list/api/use-products.ts
import { useQuery } from '@tanstack/react-query';
import { getProducts } from 'entities/product';
export function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: getProducts,
});
}
// widgets/product-list/index.ts
export { ProductList } from './ui/product-list';
```
## 레이어 의존성 규칙
✅ **허용되는 의존성**:
- `shared` 레이어의 모듈
- `entities` 레이어의 모듈
- `features` 레이어의 모듈
- 같은 widget 슬라이스 내의 다른 세그먼트
❌ **금지된 의존성**:
- 다른 `widgets` 슬라이스 (widget 간 직접 의존 금지)
- `pages` 레이어
- `app` 레이어
## 명명 규칙
- 소문자와 대시 사용: `header`, `sidebar`, `product-list`
- 명사 형태 권장: UI 블록의 성격을 나타내는 이름 사용
- 구체적으로: 위젯의 역할이 명확하게 드러나도록
## Nested Routing 사용 시
Remix 같은 nested routing 시스템을 사용한다면:
- Widgets 레이어를 flat routing의 Pages 레이어처럼 사용 가능
- 완전한 라우터 블록 생성 (데이터 fetching, loading states, error boundaries 포함)
- 페이지 레이아웃을 이 레이어에 저장 가능
## 주의사항
1. **독립성 유지**: 각 위젯은 독립적으로 작동 가능해야 합니다
2. **적절한 크기**: 너무 작은 컴포넌트는 `shared/ui`에, 재사용되지 않으면 `pages`에 배치
3. **응집력**: 관련된 features와 entities를 의미 있게 조합
4. **재사용 최적화**: 여러 페이지에서 동일한 방식으로 사용될 수 있도록 설계
-26
View File
@@ -1,26 +0,0 @@
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;
-33
View File
@@ -1,33 +0,0 @@
{
"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/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"../../packages/common/types/**/*.d.ts"
],
"exclude": ["node_modules", ".next", "dist", "coverage", "storybook-static", "public"]
}
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}
-36
View File
@@ -1,36 +0,0 @@
# 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
View File
-8
View File
@@ -1,8 +0,0 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": false,
"printWidth": 100,
"tabWidth": 2,
"plugins": ["prettier-plugin-tailwindcss"]
}
-187
View File
@@ -1,187 +0,0 @@
# 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";
```
-13
View File
@@ -1,13 +0,0 @@
import "@/app/styles/globals.css";
export const metadata = {
title: "디지털 행정지원 시스템",
};
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>{children}</body>
</html>
);
}
@@ -1 +0,0 @@
export { ApprovalLineManagementPage as default } from "@/pages/approval-line-management";
@@ -1 +0,0 @@
export { ChecklistManagementPage as default } from "@/pages/checklist-management";
@@ -1 +0,0 @@
export { CodeManagementPage as default } from "@/pages/code-management";
@@ -1,3 +0,0 @@
import { DashboardPage } from "@/pages/dashboard/ui/dashboard-page";
export default DashboardPage;
@@ -1,10 +0,0 @@
import { DesignEditPage } from "@/pages/design-cnstn";
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function Page({ params }: PageProps) {
const { id } = await params;
return <DesignEditPage id={id} />;
}
@@ -1,10 +0,0 @@
import { DesignDetailGate } from "@/pages/design-cnstn";
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function Page({ params }: PageProps) {
const { id } = await params;
return <DesignDetailGate id={id} />;
}
@@ -1,3 +0,0 @@
import { DesignListPage } from "@/pages/design-cnstn";
export default DesignListPage;
@@ -1,27 +0,0 @@
import { DesignRegistPage } from "@/pages/design-cnstn";
import { parseTaskSeComCdFromApi } from "@/pages/design-cnstn/lib/design-cnstn-regist-schema";
interface PageProps {
searchParams: Promise<{
taskSeComCd?: string;
admdstCd?: string;
sido?: string;
gugun?: string;
}>;
}
export default async function Page({ searchParams }: PageProps) {
const params = await searchParams;
const taskSeComCd = parseTaskSeComCdFromApi(params.taskSeComCd);
return (
<DesignRegistPage
defaults={{
taskSeComCd: taskSeComCd.length ? taskSeComCd : undefined,
admdstCd: params.admdstCd,
sido: params.sido,
gugun: params.gugun,
}}
/>
);
}
@@ -1 +0,0 @@
export { EsntlAtchFileMngPage as default } from "@/pages/esntl-atch-file-mng";
@@ -1,10 +0,0 @@
import { FnrsEditPage } from "@/pages/fnrs-cnsltn";
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function Page({ params }: PageProps) {
const { id } = await params;
return <FnrsEditPage id={id} />;
}
@@ -1,10 +0,0 @@
import { FnrsDetailGate } from "@/pages/fnrs-cnsltn";
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function Page({ params }: PageProps) {
const { id } = await params;
return <FnrsDetailGate id={id} />;
}
@@ -1,3 +0,0 @@
import { FnrsListPage } from "@/pages/fnrs-cnsltn";
export default FnrsListPage;
@@ -1,27 +0,0 @@
import { FnrsRegistPage } from "@/pages/fnrs-cnsltn";
import { parseTaskSeComCdFromApi } from "@/pages/fnrs-cnsltn/lib/fnrs-cnsltn-regist-schema";
interface PageProps {
searchParams: Promise<{
taskSeComCd?: string;
admdstCd?: string;
sido?: string;
gugun?: string;
}>;
}
export default async function Page({ searchParams }: PageProps) {
const params = await searchParams;
const taskSeComCd = parseTaskSeComCdFromApi(params.taskSeComCd);
return (
<FnrsRegistPage
defaults={{
taskSeComCd: taskSeComCd.length ? taskSeComCd : undefined,
admdstCd: params.admdstCd,
sido: params.sido,
gugun: params.gugun,
}}
/>
);
}
@@ -1,10 +0,0 @@
import { InstlEditPage } from "@/pages/instl-sncn";
interface InstlSncnEditRouteProps {
params: Promise<{ id: string }>;
}
export default async function Page({ params }: InstlSncnEditRouteProps) {
const { id } = await params;
return <InstlEditPage id={id} />;
}
@@ -1,6 +0,0 @@
import { InstlSncnDetailGate } from "@/pages/instl-sncn";
export default async function InstlSncnDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <InstlSncnDetailGate id={id} />;
}
@@ -1,3 +0,0 @@
import { InstlListPage } from "@/pages/instl-sncn";
export default InstlListPage;
@@ -1,28 +0,0 @@
import { InstlRegistPage } from "@/pages/instl-sncn";
interface InstlSncnRegistRouteProps {
searchParams: Promise<{
taskSeComCd?: string;
admdstCd?: string;
sido?: string;
gugun?: string;
}>;
}
export default async function Page({ searchParams }: InstlSncnRegistRouteProps) {
const params = await searchParams;
const taskSeComCd = params.taskSeComCd
? params.taskSeComCd.split(",").filter(Boolean)
: undefined;
return (
<InstlRegistPage
defaults={{
taskSeComCd,
admdstCd: params.admdstCd,
sido: params.sido,
gugun: params.gugun,
}}
/>
);
}
@@ -1,11 +0,0 @@
import type { ReactNode } from "react";
import { BaseLayoutWrapper } from "@/app/layouts/base-layout";
interface WithBaseLayoutProps {
children: ReactNode;
}
export default function WithBaseLayout({ children }: WithBaseLayoutProps) {
return <BaseLayoutWrapper>{children}</BaseLayoutWrapper>;
}
@@ -1,3 +0,0 @@
import { MyPbadmsSprtListPage } from "@/pages/my-pbadms-sprt";
export default MyPbadmsSprtListPage;
@@ -1,3 +0,0 @@
import { NtiHstryListPage } from "@/pages/nti-hstry";
export default NtiHstryListPage;
@@ -1 +0,0 @@
export { RvwMnpwMngPage as default } from "@/pages/rvw-mnpw-mng";
@@ -1,13 +0,0 @@
import { SewerBscPlanEditPage } from "@/pages/sewer-bsc-plan";
interface PageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ chgCycl?: string }>;
}
export default async function Page({ params, searchParams }: PageProps) {
const { id } = await params;
const { chgCycl } = await searchParams;
const propChgCycl = chgCycl ? Number(chgCycl) : undefined;
return <SewerBscPlanEditPage id={id} propChgCycl={propChgCycl} />;
}
@@ -1,13 +0,0 @@
import { SewerBscPlanDetailGate } from "@/pages/sewer-bsc-plan";
interface PageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ chgCycl?: string }>;
}
export default async function Page({ params, searchParams }: PageProps) {
const { id } = await params;
const { chgCycl } = await searchParams;
const propChgCycl = chgCycl ? Number(chgCycl) : undefined;
return <SewerBscPlanDetailGate id={id} propChgCycl={propChgCycl} />;
}
@@ -1,3 +0,0 @@
import { SewerBscPlanListPage } from "@/pages/sewer-bsc-plan";
export default SewerBscPlanListPage;
@@ -1,19 +0,0 @@
import { SewerBscPlanRegistPage } from "@/pages/sewer-bsc-plan";
interface PageProps {
searchParams: Promise<{
taskSeComCd?: string;
taskSeCdNm?: string;
sewerBscPlanSeComCd?: string;
sewerBscPlanSeCdNm?: string;
admdstCd?: string;
sido?: string;
gugun?: string;
}>;
}
export default async function Page({ searchParams }: PageProps) {
const params = await searchParams;
return <SewerBscPlanRegistPage defaults={params} />;
}
-27
View File
@@ -1,27 +0,0 @@
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 (
<html lang="ko" data-theme={theme?.value}>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
-169
View File
@@ -1,169 +0,0 @@
# App Layer
**App**은 애플리케이션 전체에 영향을 미치는 기술적, 비즈니스적 사항을 다루는 최상위 레이어입니다.
## 목적
- 애플리케이션의 진입점(entrypoint) 제공
- 전역 Context Providers 설정
- 라우터 구성
- 글로벌 스타일 정의
- 전역 상태 저장소 설정
- 애널리틱스 및 모니터링 설정
## 특징
- **슬라이스 없음**: App 레이어는 Shared처럼 슬라이스가 없고 세그먼트로만 구성됩니다
- **전체 앱 관심사**: 특정 비즈니스 도메인이 아닌 앱 전체와 관련된 코드만 포함
- **최상위 레이어**: 모든 하위 레이어의 모듈을 자유롭게 import 가능
## 구조 (Next.js App Router)
Next.js를 사용하는 경우, App 레이어는 주로 `app/` 디렉토리로 표현됩니다:
```
app/
├── (routes)/ # 라우트 그룹 및 페이지
│ ├── (auth)/
│ │ ├── login/
│ │ │ └── page.tsx
│ │ └── register/
│ │ └── page.tsx
│ └── (main)/
│ ├── dashboard/
│ │ └── page.tsx
│ └── settings/
│ └── page.tsx
├── layout.tsx # 루트 레이아웃
├── page.tsx # 홈 페이지
├── globals.css # 글로벌 스타일
├── providers.tsx # 전역 Providers 설정
└── error.tsx # 글로벌 에러 핸들러
```
### 일반적인 FSD 세그먼트 구조
FSD 표준 세그먼트를 사용하는 경우:
```
app/
├── entrypoint/ # 애플리케이션 진입점 (프레임워크별)
│ └── index.tsx
├── providers/ # Context Providers
│ ├── query-provider.tsx
│ ├── theme-provider.tsx
│ └── index.tsx
├── styles/ # 글로벌 스타일
│ ├── globals.css
│ └── theme.css
├── store/ # 글로벌 상태 저장소 설정
│ └── index.ts
└── routes/ # 라우터 설정
└── index.tsx
```
## 예시
### Next.js Providers 설정
```typescript
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from 'next-themes';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
}));
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</QueryClientProvider>
);
}
// app/layout.tsx
import { Providers } from './providers';
import './globals.css';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<Providers>
{children}
</Providers>
</body>
</html>
);
}
```
### 글로벌 설정
```typescript
// app/config/analytics.ts
export function initAnalytics() {
if (typeof window !== 'undefined') {
// 애널리틱스 초기화
}
}
// app/config/monitoring.ts
export function initMonitoring() {
// 에러 모니터링 설정
}
```
## Next.js App Router와 FSD
Next.js의 `app/` 디렉토리는 FSD의 **App 레이어**와 **Pages 레이어**를 동시에 담당합니다:
- **루트 레벨 파일들** (`layout.tsx`, `providers.tsx`, `globals.css`): App 레이어
- **라우트 폴더들** (`dashboard/`, `login/` 등): Pages 레이어
각 페이지별 비즈니스 로직은:
- 재사용되지 않는 UI는 페이지 슬라이스(`app/dashboard/` 등) 안에 유지
- 재사용되는 기능은 `features/` 레이어로 분리
- 여러 페이지에서 재사용되는 큰 UI 블록은 `widgets/` 레이어로 분리
## 레이어 의존성 규칙
✅ **허용되는 의존성**:
- 모든 하위 레이어 (`pages`, `widgets`, `features`, `entities`, `shared`)
- 같은 `app` 레이어 내의 다른 세그먼트
- 외부 라이브러리
❌ **금지된 의존성**:
- 없음 (App은 최상위 레이어)
## 주의사항
1. **비대화 방지**: App 레이어가 너무 커지면 관리가 어려워집니다. 대부분의 로직은 하위 레이어에 배치하세요
2. **전역 관심사만**: 특정 페이지나 기능에만 관련된 코드는 해당 레이어로 이동
3. **라우터 레벨 로직**: 라우터 및 서버 레벨 로직은 이 레이어에 유지
4. **Processes 레이어 사용 지양**: Processes 레이어는 deprecated되었으므로 해당 로직은 `app`이나 `features`에 배치
## FSD v2.1 권장사항
최신 FSD 버전(v2.1)은 **"pages-first"** 접근 방식을 권장합니다:
- 더 많은 코드를 페이지 슬라이스에 유지
- 큰 UI 블록과 재사용되지 않는 비즈니스 로직은 즉시 추출하지 말고 페이지에 보관
- 실제로 재사용이 필요할 때만 `features`나 `widgets`로 분리
이 접근 방식은 과도한 추상화를 방지하고 코드를 더 쉽게 탐색할 수 있게 합니다.
-10
View File
@@ -1,10 +0,0 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { COOKIE_DOMAIN, REFRESH_TOKEN } from "@/shared/config";
export async function POST() {
const cookieStore = await cookies();
cookieStore.delete({ name: REFRESH_TOKEN, domain: COOKIE_DOMAIN });
return NextResponse.json({ message: "Logged out" }, { status: 200 });
}
-94
View File
@@ -1,94 +0,0 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
COOKIE_DOMAIN,
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<string, string> = {
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
};
const deviceId = request.headers.get("X-Device-Id");
if (deviceId) {
headers["X-Device-Id"] = deviceId;
}
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 targetUrl = `${SERVER_API_URL}/${params.url.join("/")}`;
const result = await fetch(targetUrl, {
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,
});
if (refreshToken) {
cookieStore.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "strict",
domain: COOKIE_DOMAIN,
path: "/",
});
}
return response;
}
return NextResponse.json({ message: "Invalid Credentials" }, { status: 401 });
} catch (error) {
console.error(`[Proxy API] Error caught in handler:`, 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");
}
-38
View File
@@ -1,38 +0,0 @@
import type { Metadata } from "next";
import Image from "next/image";
import "@/app/styles/globals.css";
export const metadata: Metadata = {
title: "디지털 행정지원 시스템",
description: "페이지가 존재하지 않습니다. 관리자에게 문의하여 주십시오.",
};
export default function GlobalNotFound() {
return (
<html lang="ko">
<body>
<main className="flex min-h-screen flex-col items-center justify-center bg-[#f0f4f8] px-4 py-16">
<div className="mb-16 text-center">
<h1 className="text-[3.2rem] font-bold leading-snug tracking-tight">
<span className="text-primary">디지털 행정지원 시스템</span>에 오신 것을 환영합니다.
</h1>
<p className="mt-6 text-[2rem] text-[#666]">오류가 발생하였습니다.</p>
</div>
<section className="card card-border flex w-full max-w-[72rem] flex-col items-center !rounded-2xl !py-16">
<Image
src="/images/magnifying-glass-with-warning.png"
alt=""
width={280}
height={280}
className="size-[28rem] object-contain"
/>
<p className="mt-4 text-[2.4rem] font-bold text-[#444]">페이지가 존재하지 않습니다.</p>
<p className="mt-2 text-[1.6rem] text-[#666]">관리자에게 문의하여 주십시오</p>
</section>
</main>
</body>
</html>
);
}
-800
View File
@@ -1,800 +0,0 @@
# 실무협의문서 개발 가이드
> **대상 앱**: `frontend/apps/as`
> **작성일**: 2026-08-27
> **버전**: 1.0
> **SoT (백엔드)**:
>
> - `PwrkCnsltnDocCrtRsltController` — `/api/v1/pwrk-cnsltn-doc-crt-rslt`
> - `PwrkCnsltnMngController` — `/api/v1/pwrk-cnsltn-mng`
> - `DcRagApiToController` — `/api/v1/dcrag-to`
> - 초안생성 데이터 설명 (`02.03.초안생성_데이터_설명.md`)
>
> **현재 프론트 상태**: `pwrk-cnsltn-`\* 엔티티·화면 **미구현**. 재원협의(`fnrs-cnsltn`)만 존재한다.
---
## 1. 문서 목적
재원협의 접수자가 **자료 검증 → 실무협의서 초안 생성 → 한글 웹기안기 열람/다운로드 → 편집본 버전 저장**까지 구현할 때 사용하는 프론트엔드 개발 가이드이다.
백엔드 컨트롤러·DTO를 **API source of truth**로 두고, 초안생성 데이터 설명의 업무 시퀀스를 화면 동작에 매핑한다.
---
## 2. 도메인 개요
실무협의서(PWRK)는 재원협의서(FNRS)의 **하위 문서**이다. 문서번호 규칙:
| 문서 | 번호 예시 | 필드 |
| -------- | ----------------- | ----------------------------- |
| 재원협의 | `F-20260824-0001` | `fnrsCnsltnDocNo` / `upDocNo` |
| 실무협의 | `P-20260827-0001` | `pwrkCnsltnDocNo` |
재원협의 1건당 실무협의서는 **1건만** 생성된다. `POST /pwrk-cnsltn-mng`는 `upDocNo`로 기존 문서를 재사용하고, 없으면 문서번호를 발급한다.
### 2-1. 핵심 식별자
| 필드 | 의미 | 비고 |
| ----------------- | ------------------------------ | --------------------------------------------------------- |
| `pwrkCnsltnDocNo` | 실무협의문서번호 | 문서 PK. `P-YYYYMMDD-NNNN` |
| `upDocNo` | 상위문서번호(재원협의) | `upDocSeComCd = "FNRS"` |
| `crtDmndUuid` | 생성요청 UUID | 초안 생성 작업·결과·다운로드의 키 |
| `verSn` | 버전일련번호 | 복합 PK `(crtDmndUuid, verSn)`. 저장 시 서버가 `+1` |
| `orgnlOtptDocCn` | 원본출력문서내용(HWP 바이너리) | **목록/상세 응답에서는 항상** `null`. 다운로드 API만 제공 |
### 2-2. IIPBS 약어
| 한글 | 약어 | 필드 예 |
| -------- | ------ | ----------------------------- |
| 실무 | PWRK | `pwrkCnsltnDocNo` |
| 협의 | CNSLTN | `pwrk-cnsltn-mng` |
| 관리 | MNG | `PwrkCnsltnMng` |
| 문서 | DOC | `pwrkCnsltnDocNo` |
| 생성 | CRT | `crtDmndUuid`, `crtSttsComCd` |
| 요청 | DMND | `crtDmndUuid` |
| 결과 | RSLT | `crtRslt` |
| 버전 | VER | `verSn` |
| 일련번호 | SN | `verSn` |
| 원본 | ORGNL | `orgnlOtptDocCn` |
| 출력 | OTPT | `orgnlOtptDocCn` |
| 내용 | CN | `orgnlOtptDocCn` |
| 완료 | CMPTN | `crtCmptnDt` |
| 일시 | DT | `crtCmptnDt` |
| 검증 | VRFC | `vrfcSttsComCd` |
| 전송 | TRSM | `trsmFileList` |
| 보완 | SPLMNT | `splmntDmndList` |
| 요청자 | RQSTR | `rqstrUuid`, `rqstrNm` |
---
## 3. 전체 업무 흐름
재원협의 **접수자**가 실무협의서 초안을 만드는 경로이다. 프론트는 DC RAG를 직접 호출하지 않고 **as-service**만 호출한다.
```mermaid
sequenceDiagram
autonumber
participant User as 재원협의 접수자
participant FE as AS Frontend
participant AS as as-service
participant RAG as DC RAG
User->>FE: [자료 검증 요청]
FE->>AS: POST /dcrag-to/documents/transfer<br/>{ fnrsCnsltnDocNo }
AS->>RAG: 자료전송 (IF-01)
AS-->>FE: { resultCode: SUCCESS, requestId }
Note over FE: 재원협의 상세 재조회
AS-->>FE: esntlFileList / etcFileList 에<br/>vrfcDt, vrfcRsltCn, vrfcSttsComCd
User->>FE: [초안 생성]
FE->>AS: POST /pwrk-cnsltn-mng<br/>{ upDocNo, upDocSeComCd: FNRS, prgrsSttsComCd: DV }
alt 실무협의서 번호 없음
AS->>AS: P-번호 발급 + 문서 등록
else 이미 존재
AS->>AS: 기존 pwrkCnsltnDocNo 재사용
end
AS->>RAG: 초안생성 비동기 요청 (IF-03-1)
AS-->>FE: 201 { pwrkCnsltnDocNo, crtDmndUuid, crtSttsComCd: R, estimatedMinutes: 10 }
loop 약 10분, status !== COMPLETED/FAILED
FE->>AS: GET /dcrag-to/documents/draft/status/{crtDmndUuid}
AS-->>FE: { status, events[], progressPercent }
end
Note over FE: events 에 COMPLETED 가 있으면<br/>웹기안기 열기 / 다운로드 가능
User->>FE: [문서 열기] 또는 [다운로드]
FE->>AS: GET /pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/webhwp/open<br/>또는 /download
User->>FE: 웹기안기 편집 후 [저장]
FE->>AS: POST /pwrk-cnsltn-doc-crt-rslt (multipart)<br/>crtDmndUuid + file
AS-->>FE: 201 { verSn: N+1, ... }
```
### 3-1. 프론트가 호출하면 안 되는 API
| API | 이유 |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `POST /dcrag-to/documents/draft/generate` | 초안 생성은 `POST /pwrk-cnsltn-mng`가 문서번호 발급 + DC RAG 호출을 **한 트랜잭션**으로 처리한다. 프론트는 관리 API만 호출한다. |
---
## 4. 화면 데이터 흐름
초안 생성은 **재원협의 상세**에서 시작하고, 실무협의 목록/상세는 별도 화면이다.
```
BEGIN
// ===== 재원협의 상세 (기존 fnrs-cnsltn view) =====
CALL findById(fnrsCnsltnDocNo)
DISPLAY 재원협의 상세 + 필수/기타 첨부
ON [자료 검증 요청] 버튼 CLICK DO
CALL requestDocumentTransfer({ fnrsCnsltnDocNo })
CALL findById(fnrsCnsltnDocNo) // 검증결과 필드 반영
DISPLAY 첨부 리스트 (vrfcSttsComCd: PASS | FAIL)
END ON
ON [초안 생성] 버튼 CLICK DO
CALL pwrkCnsltnMngService.save({
upDocNo: fnrsCnsltnDocNo,
upDocSeComCd: "FNRS",
prgrsSttsComCd: "DV"
})
// → pwrkCnsltnDocNo, crtDmndUuid, estimatedMinutes
START POLL getDraftGenerationStatus(crtDmndUuid)
DISPLAY 초안 생성 진행률 (약 10분)
END ON
ON POLL status = COMPLETED 또는 events.eventType 에 COMPLETED DO
ENABLE [웹기안기 열기] [다운로드]
END ON
ON [웹기안기 열기] 버튼 CLICK DO
CALL HwpCtrl.Open( webhwpOpenUrl(crtDmndUuid) ) // TBD: 컨트롤러 미구현
END ON
ON [다운로드] 버튼 CLICK DO
CALL downloadLatest(crtDmndUuid) // blob + Content-Disposition
SAVE 로컬 파일
END ON
// ===== 실무협의 목록 =====
CALL pwrkCnsltnMngService.findPage(searchDto + pageable)
DISPLAY 실무협의 문서 그리드
ON 행 CLICK DO
NAVIGATE 실무협의 상세 (pwrkCnsltnDocNo)
END ON
// ===== 실무협의 상세 / 재원협의에서 연계 조회 =====
CALL findById(pwrkCnsltnDocNo)
// 또는 재원협의 번호로: findByUpDocNo(fnrsCnsltnDocNo)
DISPLAY 마스터 + cnsltnList + pjtcoAjmtList + crtDmndList.crtRslt
// crtRslt.orgnlOtptDocCn 은 항상 null — 바이너리는 download API
ON 버전 이력 조회 DO
CALL pwrkCnsltnDocCrtRsltService.findPage({ crtDmndUuid, sort: "verSn,desc" })
DISPLAY 버전 목록
END ON
ON 웹기안기 편집 완료 [저장] DO
CALL saveMultipart({ crtDmndUuid, file }) // 서버가 verSn + 1
INVALIDATE 상세·버전 목록
END ON
END
```
---
## 5. API 명세
### 5-1. 공통
| 항목 | 값 |
| ----------- | ----------------------------------------------------------------- |
| Gateway | `/as-service` |
| Base prefix | `ENDPOINT.AS_SERVICE.*` (`apps/as/src/shared/config/api.ts`) |
| Auth 헤더 | `Authorization`, `X-Site-Id`, `token-id` (기존 `client` 인터셉터) |
| 날짜 포맷 | `yyyy-MM-dd HH:mm:ss` |
추가할 ENDPOINT:
```ts
AS_SERVICE: {
// 기존 …
PWRK_CNSLTN_MNG: "/as-service/api/v1/pwrk-cnsltn-mng",
PWRK_CNSLTN_DOC_CRT_RSLT: "/as-service/api/v1/pwrk-cnsltn-doc-crt-rslt",
DCRAG_TO: "/as-service/api/v1/dcrag-to",
}
```
---
### 5-2. DC RAG 연계 (`DcRagApiToController`)
프론트 엔티티 슬라이스: `entities/dcrag-to/`
| 기능 | Controller | Method | URL | Request | Response |
| ----------------- | -------------------------- | ------ | ------------------------------------------------ | --------------------------------- | ------------------------------------- |
| 자료전송요청 | `requestDocumentTransfer` | POST | `/dcrag-to/documents/transfer` | `DcRagDocumentTransferRequestDto` | `DcRagCommonResponseDto` |
| 초안생성 진행상태 | `getDraftGenerationStatus` | GET | `/dcrag-to/documents/draft/status/{crtDmndUuid}` | path | `DcRagDraftStatusResponseDto` |
| 금액검증요청 | `requestCostVerification` | POST | `/dcrag-to/documents/cost/verify` | `DcRagCostVerifyRequestDto` | `DcRagCommonResponseDto` (202) |
| 초안생성요청 | `requestDraftGeneration` | POST | `/dcrag-to/documents/draft/generate` | `DcRagDraftGenerateRequestDto` | `DcRagDraftGenerateResponseDto` (202) |
**화면에서 쓰는 것**: 자료전송 + 상태 폴링 (+ 추후 금액검증).
초안생성요청은 5-3의 `pwrkCnsltnMngService.save`로 대체한다.
#### 자료전송 Request / Response
```json
POST /as-service/api/v1/dcrag-to/documents/transfer
{ "fnrsCnsltnDocNo": "F-20260824-0001" }
```
```json
{
"resultCode": "SUCCESS",
"message": "자료전송요청 접수 완료",
"requestId": "F-20260824-0001"
}
```
자료 검증이 **완료된 뒤** 재원협의 상세를 다시 조회하면, 필수첨부(`esntlFileList`)·기타첨부(`etcFileList`) 각 파일에 검증 결과가 붙는다.
| 필드 | 예 | 의미 |
| --------------- | ----------------------------- | ------------ |
| `vrfcDt` | `2026-08-25 18:13:49` | 검증일시 |
| `vrfcRsltCn` | `정상 확인 (재원협의 신청서)` | 검증결과내용 |
| `vrfcSttsComCd` | `PASS` / `FAIL` | 검증상태 |
프론트 `FnrsCnsltnMngDetResponseDto` / `AtflMngClientResponseDto` / `EsntlAtchFileMngTreeResponseDto`에 위 3필드가 **아직 없다**. 재원협의 상세 첨부 영역에 배지를 그리려면 타입을 확장해야 한다.
#### 초안생성 상태 폴링
```http
GET /as-service/api/v1/dcrag-to/documents/draft/status/{crtDmndUuid}
```
| 필드 | 설명 |
| ----------------------- | --------------------------------------------------------------------------------- |
| `status` | `REQUESTED` / `DOWNLOADING` / `ANALYZING` / `GENERATING` / `COMPLETED` / `FAILED` |
| `statusName` | 표시명 (예: `생성 완료`) |
| `progressPercent` | 0~100 |
| `events[]` | `{ eventType, eventMessage, occurredDt }` |
| `startedDt` | 시작일시 |
| `estimatedCompletionDt` | 예상 완료일시 |
| `errorMessage` | 실패 시 메시지 |
폴링 규칙:
- 주기: **5~10초** (TBD, UX 확정).
- 예상 소요: **약 10분** (`estimatedMinutes`).
- 종료 조건: `status === "COMPLETED"` **또는** `events`에 `eventType === "COMPLETED"`.
- 실패: `status === "FAILED"` → 폴링 중단, `errorMessage` 표시.
- 완료 전에는 웹기안기 열기·다운로드 버튼을 **비활성**.
이벤트 순서 예시:
| eventType | eventMessage |
| ------------- | --------------------------- |
| `REQUESTED` | 요청을 접수했습니다. |
| `DOWNLOADING` | 신청서를 수신하는 중입니다. |
| `ANALYZING` | 자료 준비가 완료되었습니다. |
| `GENERATING` | 문서 저장이 완료되었습니다. |
| `COMPLETED` | 문서 생성이 완료되었습니다. |
---
### 5-3. 실무협의관리 (`PwrkCnsltnMngController`)
프론트 엔티티 슬라이스: `entities/pwrk-cnsltn-mng/`
| 기능 | Controller | Method | URL | Request | Response |
| ------------------- | ---------------------- | ------ | ----------------------------------------------------- | ----------------------------------- | ------------------------------------ |
| 페이징 조회 | `findPage` | GET | `/pwrk-cnsltn-mng` | `PwrkCnsltnMngSearchDto` + Pageable | `Page<PwrkCnsltnMngResponseDto>` |
| 상세 (문서번호) | `findById` | GET | `/pwrk-cnsltn-mng/{pwrkCnsltnDocNo}` | path | `PwrkCnsltnMngDetResponseDto` |
| 상세 (재원협의번호) | `findByUpDocNo` | GET | `/pwrk-cnsltn-mng/updocno/{upDocNo}` | path | `PwrkCnsltnMngDetResponseDto` |
| 등록 + 초안생성 | `save` | POST | `/pwrk-cnsltn-mng` | `PwrkCnsltnMngSaveRequestDto` | `PwrkCnsltnMngSaveResponseDto` (201) |
| 신청 통합 저장 | `saveApply` | POST | `/pwrk-cnsltn-mng/save-apply` | `PwrkCnsltnApplySaveRequestDto` | `string` (문서번호, 201) |
| 수정 | `update` | PUT | `/pwrk-cnsltn-mng/{pwrkCnsltnDocNo}` | `PwrkCnsltnMngUpdateRequestDto` | `string` |
| 삭제 | `delete` → `remove` | DELETE | `/pwrk-cnsltn-mng/{pwrkCnsltnDocNo}` | path | 204 |
| 보완완료 제출 | `submitSupplement` | POST | `/pwrk-cnsltn-mng/{pwrkCnsltnDocNo}/submitsupplement` | `PwrkCnsltnApplySaveRequestDto` | `string` |
| 보완요청 등록 | `submitReqSupplement` | POST | `/pwrk-cnsltn-mng/{pwrkCnsltnDocNo}/reqsupplement` | `PwrkCnsltnApplySaveRequestDto` | `string` |
| 진행상태 수정 | `changePrgrsSttsComCd` | POST | `/pwrk-cnsltn-mng/doc/updatePrgrsStts` | `DocPrgrsUpdateRequestDto` | 200 |
목록 기본 호출:
```http
GET /as-service/api/v1/pwrk-cnsltn-mng?page=0&size=10&sort=pwrkCnsltnDocNo,desc
```
#### 초안 생성 Request / Response (`save`)
```json
POST /as-service/api/v1/pwrk-cnsltn-mng
{
"upDocNo": "F-20260824-0001",
"upDocSeComCd": "FNRS",
"prgrsSttsComCd": "DV"
}
```
```json
{
"pwrkCnsltnDocNo": "P-20260826-0001",
"upDocNo": "F-20260824-0001",
"upDocSeComCd": "FNRS",
"prgrsSttsComCd": "DV",
"resultCode": "ACCEPTED",
"message": "초안 생성 작업이 접수되었습니다.",
"crtDmndUuid": "382f2821-9231-42e6-8309-4a1718a2ead8",
"crtSttsComCd": "R",
"requestId": "382f2821-9231-42e6-8309-4a1718a2ead8",
"estimatedMinutes": 10
}
```
백엔드 동작 (`PwrkCnsltnMngService.save`):
1. `upDocNo`로 기존 실무협의서가 있으면 **그 번호를 재사용** (재원협의 1 : 실무협의 1).
2. 없으면 `docNoMngService.issueDocNo(PWRK)`로 `P-` 번호 발급 후 insert.
3. DC RAG 초안생성을 비동기로 호출하고 `crtDmndUuid`를 응답에 실어 반환.
4. DC RAG 실패 시에도 **문서는 롤백하지 않고** 로그만 남긴다. 프론트는 `resultCode`가 `ACCEPTED`/`SUCCESS`가 아니면 토스트로 실패를 알린다.
#### 상세 응답 구조 (`PwrkCnsltnMngDetResponseDto`)
재원협의 상세에서 `GET .../updocno/{fnrsCnsltnDocNo}`로 연계 데이터를 한 번에 가져온다.
```json
{
"pwrkCnsltnDocNo": "P-20260827-0001",
"upDocNo": "F-20260824-0001",
"upDocSeComCd": "FNRS",
"prgrsSttsComCd": "DV",
"cnsltnList": [],
"pjtcoAjmtList": [],
"crtDmndList": [
{
"crtDmndUuid": "d0545bef-735f-4d7b-a781-88e96e03577a",
"pwrkCnsltnDocNo": "P-20260827-0001",
"rqstrUuid": "65a00f65-8460-49af-98ec-042977e56f4b",
"rqstrNm": "관리자",
"crtSttsComCd": "C",
"crtRslt": {
"crtDmndUuid": "d0545bef-735f-4d7b-a781-88e96e03577a",
"verSn": 1,
"rgtrUuid": "...",
"rgtrNm": "관리자",
"orgnlOtptDocCn": null,
"crtCmptnDt": "2026-08-27 10:47:01"
}
}
],
"splmntDmndList": [],
"trsmFileList": []
}
```
`crtRslt`는 최신 버전 메타만 포함한다. `orgnlOtptDocCn`은 절대 쓰지 말고 download API를 호출한다.
마스터에 재원협의 사업정보가 조인된다: `bizNm`, `admdstCd`, `envoSeComCd`, `taskSeComCd`, `aplcntNm` 등.
---
### 5-4. 문서생성결과 (`PwrkCnsltnDocCrtRsltController`)
프론트 엔티티 슬라이스: `entities/pwrk-cnsltn-doc-crt-rslt/`
복합 PK: `crtDmndUuid` + `verSn`
| 기능 | Controller | Method | URL | Request | Response |
| ------------------ | ------------------------- | ------ | ---------------------------------- | ----------------------------------------------- | --------------------------------------- |
| 버전 이력 페이징 | `findPage` | GET | `/pwrk-cnsltn-doc-crt-rslt` | `PwrkCnsltnDocCrtRsltSearchDto` + Pageable | `Page<PwrkCnsltnDocCrtRsltResponseDto>` |
| 특정 버전 상세 | `findById` | GET | `/{crtDmndUuid}/{verSn}` | path | `PwrkCnsltnDocCrtRsltResponseDto` |
| 최신 버전 상세 | `findLatestByCrtDmndUuid` | GET | `/{crtDmndUuid}/latest` | path | `PwrkCnsltnDocCrtRsltResponseDto` |
| 특정 버전 다운로드 | `download` | GET | `/{crtDmndUuid}/{verSn}/download` | path | `byte[]` (attachment) |
| 최신 버전 다운로드 | `downloadLatest` | GET | `/{crtDmndUuid}/download` | path | `byte[]` (attachment) |
| 등록 (multipart) | `saveMultipart` | POST | `/` `multipart/form-data` | `PwrkCnsltnDocCrtRsltSaveRequestDto` + `file` | `PwrkCnsltnDocCrtRsltResponseDto` (201) |
| 등록 (JSON) | `saveJson` | POST | `/` `application/json` | `PwrkCnsltnDocCrtRsltSaveRequestDto` | 201 |
| 수정 (multipart) | `updateMultipart` | PUT | `/{crtDmndUuid}/{verSn}` multipart | `PwrkCnsltnDocCrtRsltUpdateRequestDto` + `file` | ResponseDto |
| 수정 (JSON) | `updateJson` | PUT | `/{crtDmndUuid}/{verSn}` json | UpdateRequestDto | ResponseDto |
| 삭제 | `delete` → `remove` | DELETE | `/{crtDmndUuid}/{verSn}` | path | 204 |
Spring 매핑 주의: `/{crtDmndUuid}/latest` 와 `/{crtDmndUuid}/download` 는 `/{crtDmndUuid}/{verSn}` 보다 **구체 경로가 우선**한다. 프론트 path를 섞지 말 것.
버전 이력 조회 예:
```http
GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt?page=0&size=10&crtDmndUuid={uuid}&sort=verSn,desc
```
#### 다운로드 (attachment)
화면 [다운로드] 버튼 → PC 저장.
```http
GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/download
GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/{verSn}/download
```
- axios: `responseType: "blob"`.
- 파일명: `Content-Disposition`의 `filename*=UTF-8''...` (`parseFilenameFromResponse` 재사용).
- 서버 파일명 규칙: `재원협의 실무협의서({bizNm})_v{verSn}.hwp`.
- **현재 컨트롤러 Content-Type**: `application/x-hwp`.
데이터 설명 예시는 `application/haansofthwpx`. 프론트는 Content-Type에 의존하지 말고 blob으로 저장한다.
기존 `attachService` 다운로드와 동일 패턴:
```ts
const response = await client.get<Blob>(url, { responseType: "blob" });
const filename = parseFilenameFromResponse(response);
const objectUrl = window.URL.createObjectURL(response.data);
```
#### 웹기안기 열기 (inline) — TBD
데이터 설명 기준 엔드포인트. **현재** `PwrkCnsltnDocCrtRsltController`**에는 없음.** 백엔드 추가 후 연결한다.
```http
GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/webhwp/open
GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/{verSn}/webhwp/open
```
의도:
- `Content-Disposition: inline`
- 한컴 웹기안기 `HwpCtrl.Open(url)` 이 이 URL을 직접 호출
- 브라우저 다운로드 창이 뜨지 않고 캔버스에 로드
프론트는 **blob fetch 후 Object URL을 넘기지 않는다.** 인증 헤더가 필요한 절대 URL을 기안기에 전달하는 방식이므로, 쿠키/게이트웨이 인증 가능 여부를 백엔드와 확인한다 (TBD).
#### 신규 버전 등록 (웹기안기 저장)
서버가 최신 `verSn + 1`을 부여한다. 클라이언트가 `verSn`을 보내지 않는다.
```http
POST /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt
Content-Type: multipart/form-data
crtDmndUuid: {uuid}
file: 재원협의 실무협의서_v2.hwpx
```
화면에서는 **multipart만** 사용한다. JSON `saveJson`은 바이너리를 body에 넣는 경로라 UI에서 쓰지 않는다.
`file` part 이름: `"file"`. DTO 필드는 `@ModelAttribute` 로 평문 필드(`crtDmndUuid`) 또는 `saveDto` part.
---
## 6. DTO 매핑 (프론트 타입명 = 백엔드 클래스명)
### 6-1. `PwrkCnsltnDocCrtRslt*`
| 계층 | 파일 | 정의 |
| ------------------------------------ | -------------------------------------------- | ----------------------------------------------- |
| Save Request | `pwrk-cnsltn-doc-crt-rslt-payload-schema.ts` | zod SSOT → `PwrkCnsltnDocCrtRsltSaveRequestDto` |
| Response / Search / Update / Service | `pwrk-cnsltn-doc-crt-rslt-interface.ts` | `interface` |
**Response**
| 필드 | 타입 | 비고 |
| ---------------- | -------- | --------------------- |
| `crtDmndUuid` | `string` | UUID |
| `verSn` | `number` | BigDecimal |
| `rgtrUuid` | `string` | |
| `rgtrNm` | `string` | 서버 enrich |
| `orgnlOtptDocCn` | `null` | 목록/상세 미포함 |
| `crtCmptnDt` | `string` | `yyyy-MM-dd HH:mm:ss` |
**Search** (`PageableParams` 확장)
| 필드 | 비고 |
| ----------------- | ---------------------- |
| `crtDmndUuid` | 버전 이력 조회 시 필수 |
| `pwrkCnsltnDocNo` | |
| `verSn` | |
| `rgtrUuid` | |
**Save Request (zod)**
| 필드 | 필수 | 비고 |
| ------------- | ---- | ------------------------------------ |
| `crtDmndUuid` | Y | |
| `rgtrUuid` | N | 미전송 시 서버가 로그인 사용자 사용 |
| `file` | UI | multipart `file` part. DTO 필드 아님 |
**Update Request**
| 필드 | 비고 |
| ------------ | --------- |
| `rgtrUuid` | |
| `crtCmptnDt` | |
| `file` | multipart |
### 6-2. `PwrkCnsltnMng*`
**Search**
`pwrkCnsltnDocNo`, `upDocNo`, `upDocSeComCd`, `prgrsSttsComCd`, `admdstCd`, `envoSeComCd`, `taskSeComCd`, `bizNm`
**Save Request (초안 생성)** — 필드가 얇으므로 interface만으로도 가능. 권장은 zod.
| 필드 | 필수 | 값 |
| ----------------- | ---- | ----------------- |
| `upDocNo` | Y | 재원협의 문서번호 |
| `upDocSeComCd` | | `"FNRS"` |
| `prgrsSttsComCd` | | `"DV"` (초안생성) |
| `pwrkCnsltnDocNo` | N | 서버 발급 |
**Save Response** — `save`만 이 DTO. `fnrsCnsltnMngService.save`가 `string`을 반환하는 것과 **다름**.
| 필드 | 의미 |
| ------------------ | --------------------------------- |
| `pwrkCnsltnDocNo` | 발급/재사용 번호 |
| `resultCode` | `ACCEPTED` / `SUCCESS` / 실패코드 |
| `crtDmndUuid` | 폴링 키 |
| `crtSttsComCd` | `R` 생성요청 |
| `estimatedMinutes` | 기본 10 |
**DetResponse 중첩**
- `cnsltnList`: 협의내역 (`rvwOpnnSn`, `rvwOpnnClsfComCd`, `cnsltnCn`, `rfltYn`, …)
- `pjtcoAjmtList`: 사업비조정
- `crtDmndList[].crtRslt`: 생성요청 + 최신 결과
- `splmntDmndList`: 보완요청
- `sttsChgHstryList`: 상태변경이력
- `trsmFileList`: 전송파일 (데이터 설명 기준. Det DTO Java 필드 확인 후 타입 반영)
### 6-3. DC RAG
```ts
interface DcRagDocumentTransferRequestDto {
fnrsCnsltnDocNo: string;
taskSeComCd?: string;
taskSeCdNm?: string;
}
interface DcRagCommonResponseDto {
resultCode: string;
message?: string;
requestId?: string;
}
interface DcRagDraftStatusResponseDto {
crtDmndUuid: string;
status: DraftStatus;
statusName?: string;
progressPercent?: number;
events?: EventInfoDto[];
startedDt?: string;
estimatedCompletionDt?: string;
errorMessage?: string | null;
}
type DraftStatus =
| "REQUESTED"
| "DOWNLOADING"
| "ANALYZING"
| "GENERATING"
| "COMPLETED"
| "FAILED";
```
---
## 7. 공통코드
| 상위코드 | 필드 | 값 | 명칭 |
| ----------------------- | ---------------- | --------------- | ------------- |
| `ASDOCSE` | `upDocSeComCd` | `FNRS` | 재원협의 |
| `ASDOCSE` | 문서구분 | `PWRK` | 실무협의 |
| `PWRKSTTS` | `prgrsSttsComCd` | `DV` | 초안생성 |
| `PWRKSTTS` | | `AR` | 검토자지정 |
| `PWRKSTTS` | | `SV` | 작성중 |
| `PWRKSTTS` | | `RB` | 검토회신 |
| `PWRKSTTS` | | `RC` | 협의회신 |
| `PWRKSTTS` | | `TR` | 검토결과 회신 |
| `PWRKSTTS` | | `SL` | 보완요청 |
| `PWRKSTTS` | | `AA` | 금액조정중 |
| `AICRTSTTS` / `CRTSTTS` | `crtSttsComCd` | `R` | 생성요청 |
| | | `I` | 생성중 |
| | | `C` | 생성완료 |
| | | `F` | 생성실패 |
| (검증) | `vrfcSttsComCd` | `PASS` / `FAIL` | |
`CrtDmndResponseDto`는 `@CodeName(upCd = "CRTSTTS")`, 생성요청 단건 DTO는 `AICRTSTTS`를 쓴다. 코드값이 `R/I/C/F`로 같으므로 UI는 코드값으로 분기하고, 표시명은 응답의 `*Nm` 필드를 쓴다.
초안 생성 직후 `crtSttsComCd = R`, 폴링 `COMPLETED` 이후 상세의 `crtSttsComCd = C`.
---
## 8. 프론트엔드 구현 가이드
### 8-1. 기술 스택·원칙
- App Router + FSD (`app/` → `pages/` → `widgets/` → `entities/` → `shared/`)
- React Query. ad-hoc `useQuery` + service 직접 호출 금지
- `@common/components`, `useDataTable`, `@tanstack/react-table`
- `'use client'`는 Web API(다운로드, 폴링, 웹기안기)가 있는 페이지만
- 함수명 = Controller public 메서드명 (`delete` → `remove`)
- 타입명 = 백엔드 DTO명. 프론트 전용 축약 금지
- Save/Apply Request = zod SSOT. Response는 `interface`
참조 슬라이스: `entities/fnrs-cnsltn/`, `entities/sewer-bsc-plan/`
### 8-2. 모듈·파일 배치
| 구분 | 경로 |
| -------------------- | ------------------------------------------------------------------- |
| ENDPOINT | `apps/as/src/shared/config/api.ts` |
| 실무협의 관리 엔티티 | `entities/pwrk-cnsltn-mng/` |
| 생성결과 엔티티 | `entities/pwrk-cnsltn-doc-crt-rslt/` |
| DC RAG 엔티티 | `entities/dcrag-to/` |
| 목록 라우트 | `app/(main)/(with-base-layout)/pwrk-cnsltn/page.tsx` |
| 상세 라우트 | `app/(main)/(with-base-layout)/pwrk-cnsltn/[id]/page.tsx` |
| 페이지 UI | `pages/pwrk-cnsltn/` |
| 재원협의 상세 연계 | `pages/fnrs-cnsltn/ui/fnrs-view-page.tsx` (버튼·폴링·첨부 검증배지) |
엔티티 내부:
```
entities/pwrk-cnsltn-mng/
├── model/
│ ├── pwrk-cnsltn-mng-interface.ts
│ └── pwrk-cnsltn-mng-payload-schema.ts
├── api/
│ ├── pwrk-cnsltn-mng-service.ts
│ └── queries.ts
└── index.ts
entities/pwrk-cnsltn-doc-crt-rslt/
├── model/
│ ├── pwrk-cnsltn-doc-crt-rslt-interface.ts
│ └── pwrk-cnsltn-doc-crt-rslt-payload-schema.ts
├── api/
│ ├── pwrk-cnsltn-doc-crt-rslt-service.ts
│ └── queries.ts
└── index.ts
```
### 8-3. Service 시그니처 요지
`pwrkCnsltnMngService.save` 반환 타입이 `string`이 아니라 `PwrkCnsltnMngSaveResponseDto` 인 점을 `fnrs-cnsltn`과 혼동하지 말 것.
```ts
export interface PwrkCnsltnMngService {
findPage(searchDto: PwrkCnsltnMngSearchDto): Promise<PageResponse<PwrkCnsltnMngResponseDto>>;
findById(pwrkCnsltnDocNo: string): Promise<PwrkCnsltnMngDetResponseDto>;
findByUpDocNo(upDocNo: string): Promise<PwrkCnsltnMngDetResponseDto>;
save(body: PwrkCnsltnMngSaveRequestDto): Promise<PwrkCnsltnMngSaveResponseDto>;
saveApply(body: PwrkCnsltnApplySaveRequestDto): Promise<string>;
update(pwrkCnsltnDocNo: string, body: PwrkCnsltnMngUpdateRequestDto): Promise<string>;
submitSupplement(pwrkCnsltnDocNo: string, body: PwrkCnsltnApplySaveRequestDto): Promise<string>;
submitReqSupplement(
pwrkCnsltnDocNo: string,
body: PwrkCnsltnApplySaveRequestDto,
): Promise<string>;
remove(pwrkCnsltnDocNo: string): Promise<void>;
changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise<void>;
}
export interface PwrkCnsltnDocCrtRsltService {
findPage(
searchDto: PwrkCnsltnDocCrtRsltSearchDto,
): Promise<PageResponse<PwrkCnsltnDocCrtRsltResponseDto>>;
findById(crtDmndUuid: string, verSn: number): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
findLatestByCrtDmndUuid(crtDmndUuid: string): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
download(crtDmndUuid: string, verSn: number): Promise<AxiosResponse<Blob>>;
downloadLatest(crtDmndUuid: string): Promise<AxiosResponse<Blob>>;
saveMultipart(formData: FormData): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
updateMultipart(
crtDmndUuid: string,
verSn: number,
formData: FormData,
): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
remove(crtDmndUuid: string, verSn: number): Promise<void>;
}
```
multipart `Content-Type`은 axios가 boundary를 붙이도록 **헤더를 수동 지정하지 않는다.**
### 8-4. 폴링 훅
`useQuery` + `refetchInterval`:
```ts
useQuery({
queryKey: [...dcragToKeys.draftStatus(crtDmndUuid)],
queryFn: () => dcragToService.getDraftGenerationStatus(crtDmndUuid),
enabled: Boolean(crtDmndUuid) && isPolling,
refetchInterval: (query) => {
const status = query.state.data?.status;
if (status === "COMPLETED" || status === "FAILED") return false;
return 5_000;
},
});
```
`COMPLETED` 시 `pwrkCnsltnMng` 상세 쿼리를 invalidate 하여 `crtDmndList[].crtRslt`를 갱신한다.
### 8-5. 재원협의 상세에 붙일 UI
`pages/fnrs-cnsltn` 조회 모드:
1. **자료 검증 요청** — `requestDocumentTransfer` → 상세 refetch → 첨부 행에 PASS/FAIL 배지 (`vrfcSttsComCd`, `vrfcRsltCn`, `vrfcDt`).
2. **초안 생성** — 검증이 끝난 뒤 활성. `pwrkCnsltnMngService.save`.
3. **진행 표시** — `progressPercent` + 이벤트 타임라인. `estimatedMinutes`(10)를 안내.
4. **웹기안기 열기 / 다운로드** — `COMPLETED` 이후에만.
`findByUpDocNo(fnrsCnsltnDocNo)`로 이미 초안이 있으면 `crtDmndUuid`를 복원해 폴링·열기를 이어간다.
### 8-6. 실무협의 목록 컬럼 (권장)
마스터 응답 + 재원협의 조인 필드 기준. 화면설계 확정 전 TBD.
| 표시명 | Field |
| ------------ | ------------------ |
| 실무협의번호 | `pwrkCnsltnDocNo` |
| 재원협의번호 | `upDocNo` |
| 진행상태 | `prgrsSttsComCdNm` |
| 사업명 | `bizNm` |
| 시도/구군 | `sido` / `gugun` |
| 신청자 | `aplcntNm` |
| 등록일 | `regDt` |
행 클릭 → `/pwrk-cnsltn/{pwrkCnsltnDocNo}`.
---
## 9. 구현 순서
```
Task Progress:
- [ ] 1. ENDPOINT 3개 추가 (PWRK_CNSLTN_MNG, PWRK_CNSLTN_DOC_CRT_RSLT, DCRAG_TO)
- [ ] 2. entities/dcrag-to (transfer + draft status)
- [ ] 3. entities/pwrk-cnsltn-mng (findPage, findById, findByUpDocNo, save)
- [ ] 4. entities/pwrk-cnsltn-doc-crt-rslt (findPage, downloadLatest, saveMultipart)
- [ ] 5. 재원협의 상세: 검증결과 3필드 타입 확장 + 배지
- [ ] 6. 재원협의 상세: 자료검증 / 초안생성 / 폴링 UI
- [ ] 7. 실무협의 목록·상세 라우트
- [ ] 8. 다운로드 (blob)
- [ ] 9. 웹기안기 Open — 백엔드 webhwp/open 확인 후
- [ ] 10. 웹기안기 저장 → saveMultipart (verSn + 1)
```
1~4는 엔티티 레이어만으로 먼저 닫는다. 화면은 5부터.
---
## 10. TBD · 백엔드 확인
| 항목 | 내용 |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `webhwp/open` | 데이터 설명에는 있으나 **현재 Controller에 없음**. 추가 일정·인증 방식 확인 |
| Content-Type | Controller `application/x-hwp` vs 설명 `application/haansofthwpx` |
| 파일 확장자 | 다운로드 파일명이 `.hwp`로 고정. 실제 바이너리가 hwpx일 수 있음 |
| 폴링 주기 | 5초 vs 10초, 탭 비활성 시 중단 여부 |
| 검증 필드 타입 | `FnrsCnsltnMngDetResponseDto.esntlFileList` / `etcFileList` Java 타입이 아직 `FnrsCnsltnAtflResponseDto`가 아님. 런타임 JSON에 `vrfc*`가 오는지 확인 |
| `trsmFileList` | 데이터 설명 상세 예시에는 있으나 Det DTO Java 선언과 불일치 가능 |
| `crtSttsComCd` 상위코드 | `CRTSTTS` vs `AICRTSTTS` |
| 웹기안기 라이선스 URL | `HwpCtrl.Open`에 넘길 게이트웨이 절대 경로 (쿠키 인증 가능 여부) |
| 초안 생성 1회 제한 | `requestDraftGeneration` 주석에 “1회 제한”. 재클릭 UX (비활성 / 재요청 확인) |
| 자료전송 허용 확장자 | `.doc .docx .hwp .hwpx .md .pdf .txt .xls .xlsm .xlsx`. 이미지 첨부 시 DC RAG 실패 |
---
## 11. 참고
| 자료 | 위치 |
| ---------------------- | ------------------------------------------------------------------------------ |
| 생성결과 컨트롤러 | `backend/.../pwrkcnsltndoccrtrslt/PwrkCnsltnDocCrtRsltController.java` |
| 실무협의관리 컨트롤러 | `backend/.../pwrkcnsltnmng/PwrkCnsltnMngController.java` |
| DC RAG TO 컨트롤러 | `backend/.../dcrag/DcRagApiToController.java` |
| 초안생성 데이터 설명 | `02.03.초안생성_데이터_설명.md` |
| 엔티티 구현 스킬 | `frontend/.cursor/skills/backend-controller-to-entity/` |
| 재원협의 엔티티 (패턴) | `apps/as/src/entities/fnrs-cnsltn/` |
| IIPBS 단어목록 | `frontend/docs/IIPBS_단어목록.md` |
| 진행상태 코드 | `backend/IIPBS_CS/docs/2026/05.행정지원/01.문서상태별_사용자_상태변경_이력.md` |
@@ -1,674 +0,0 @@
# dmbpRegist.jsp → React 마이그레이션 분석·구현 가이드
| 항목 | 내용 |
| ----------- | ------------------------------------------------------ |
| 원본 JSP | `adssNewOld/.../fnrsc/dmbp/dmbpRegist.jsp` |
| 대상 앱 | `frontend/apps/as` |
| 도메인 | 하수도기본계획 (구: 기본계획검토 / dmbp) |
| API 엔티티 | `@/entities/sewer-bsc-plan` (`SewerBscPlanController`) |
| 참조 패턴 | `@/pages/fnrs-cnsltn` + `@/widgets/pbadms-sprt` |
| 문서 목적 | contents 영역 **섹션별** UI·상태·API 매핑 및 구현 방향 |
| 작성 기준일 | 2026-07-27 |
---
## 1. 마이그레이션 범위
### 1-1. 포함 (contents만)
JSP `568~1274`행: `<div class="contents">` ~ `<!-- //contents -->`
| 영역 | JSP 식별 | 개요 |
| ---- | ---------------------------------- | --------------------------------------------------------------- |
| A | `gTitle1` | 페이지 타이틀·브레드크럼 |
| B | `slide_10` | 기본계획변경 신청(마스터·연도·개요) |
| C | `slide_15` | 신청자·설계자 정보 |
| D | `slide_19` | 개인정보 동의 |
| E | `slide_11` | 사업개요(단계별 인구·보급률) |
| F | `slide_12` | 총사업비 — `BizCstMatrixFormSection` 전용 |
| G | `slide_13` | 재원분담 |
| H | `slide_14` | 처리시설별 하수도정비기본계획 (Checkbox + Tabs) |
| I | `slide_16` | 체크리스트 + 필수첨부 + 기타첨부 |
| J | JSP 상단 `saveBtn` / `presentnBtn` | React: 폼 하단 액션바 (`fnrs-form-page` 279~323과 동일 구성, API만 sewer) |
### 1-2. 제외
- `<%@ include %>`: top / sideMenu / footer / `bscPlnPopup.jsp` / `sndrdBzAmnt.jsp`
- 레이아웃·메뉴·권한 셸 → 기존 `BaseLayout` / `PageLayout` / AUTH 체계
- `mStep1` 진행 단계 표시 (미구현 · 마이그레이션 범위 밖)
팝업(`이전기본계획조회`)은 **별도 Dialog 컴포넌트**로 재구현하며, include JSP 자체는 포팅하지 않는다.
---
## 2. 목표 아키텍처
```
app/(with-base-layout)/sewer-bsc-plan/regist/page.tsx
└─ pages/sewer-bsc-plan/ui/sewer-bsc-plan-regist-page.tsx
└─ sewer-bsc-plan-form-page.tsx # FormProvider + 섹션 조립
├─ sections/*-form-section.tsx # 도메인 전용
└─ widgets/pbadms-sprt/* # 공통 재사용
entities/sewer-bsc-plan/ # 이미 구현됨
saveApply / findDetails / submitSupplement …
```
**폼 스택 (fnrs-cnsltn과 동일)**
- `react-hook-form` + `zod` (`zodResolver`, `mode: "onChange"`)
- 섹션 래퍼: `SectionCollapsible` (Accordion 아님)
- UI: `@common/components` (Input, Select, Textarea, Button, Radio, Tabs, Table 등)
- 그리드가 필요한 곳만 `@tanstack/react-table` + `DataTable`
- 데이터: React Query (`useSaveApplySewerBscPlan` 등) — 목데이터는 API 미연동 구간만 `@faker-js/faker`
**Vercel React Best Practices (적용 요약)**
- `client-swr-dedup`: React Query로 체크리스트/필수첨부/코드 조회 중복 제거
- `bundle-dynamic-imports`: 처리시설 Tabs 패널·이전계획 Dialog는 `next/dynamic` 후보
- `rerender-derived-state`: 단계 연도·합계·진행률은 render 중 파생, 불필요 effect 금지
- `async-parallel`: 등록 진입 시 코드/체크리스트트리/필수첨부트리를 병렬 조회
---
## 3. 전체 레이아웃
`fnrs-form-page.tsx`와 동일한 **FormProvider + 세로 섹션 스택** 구조를 따르고,
하단 액션바는 **[`fnrs-form-page.tsx` 279~323](../../src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx)** 과 동일 구성(목록·저장·신청완료·삭제·보완완료)이며 **API/라우트만 sewer** 로 바꾼다.
타이틀·브레드크럼은 폼 밖(`PageLayout` / regist·edit·view 셸)에서 처리한다.
(상세 View의 sticky FAB·검토/결재 버튼은 본 등록 문서 범위 밖 — `fnrs-view-page` 대응 후속.)
### 3-1. 컴포넌트 트리 (fnrs 대응)
```
PageLayout (title / breadcrumb)
└─ SewerBscPlanRegistPage | SewerBscPlanEditPage | SewerBscPlanViewPage
└─ SewerBscPlanFormPage | SewerBscPlanViewPage 본문
FormProvider (등록/수정) / 읽기 섹션 (상세)
└─ <form|div className="mt-6 space-y-4">
├─ BasicInfoFormSection // JSP slide_10
├─ ContactInfoFormSection // JSP slide_15 (pbadms-sprt)
├─ PrivacyConsentSection // JSP slide_19 (pbadms-sprt, 등록·수정)
├─ OutlineFormSection // JSP slide_11 (사업개요, 신규)
├─ BizCstMatrixFormSection // JSP slide_12 (총사업비 전용, 신규)
├─ FnrsCstFormSection // JSP slide_13 (재원분담, 신규 ※ FnrsShar 아님)
├─ TmplntFormSection // JSP slide_14 (처리시설, Tabs로 시설별 패널)
├─ ChecklistFormSection // JSP slide_16 (pbadms-sprt)
├─ AttachmentsFormSection // JSP 필수·기타첨부 (pbadms-sprt)
├─ SplmntDmndFormSection // edit + SL 상태만 (pbadms-sprt)
└─ form footer action bar // fnrs-form-page 279~323 동일
// [목록] / [저장] / edit시 [신청완료][삭제] / SL시 [보완완료]
```
**폼 하단 액션바 버튼 구성**
기준: [`fnrs-form-page.tsx` 279~323](../../src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx). UI·분기만 동일하고 훅/라우트는 sewer.
| 순서 | 버튼 | 조건 | sewer 비고 |
| ---- | -------- | ----------------------------------------- | ---------------------------------------------------- |
| 1 | 목록 | 항상 | `router.push("/sewer-bsc-plan")` |
| 2 | 저장 | `!isSplmntPrgrsStts` | `type="submit"` → `saveApply` / 임시저장(SV) |
| 3 | 신청완료 | `!isSplmntPrgrsStts && isEditMode` | 체크리스트+필수첨부 검증 → `saveApply` / LE |
| 4 | 삭제 | `!isSplmntPrgrsStts && isEditMode` | confirm → `delete` → 목록 |
| 5 | 보완완료 | `isSplmntPrgrsStts` (`prgrsSttsComCd=SL`) | `submitSupplement` (fnrs `onSplmntCmptn` 대응) |
**fnrs와의 차이 (섹션 치환만, 골격 동일)**
| fnrs-form-page | sewer-bsc-plan |
| ---------------------------------------------- | ------------------------------------------------------ |
| `BasicInfoFormSection` | 동일 역할 (마스터·연도·개요 필드는 sewer DTO) |
| `ContactInfoFormSection` | 동일 (`fieldPrefix="sewerBscPlanDtl"`) |
| `PrivacyConsentSection` | 동일 |
| `BusinessScaleFormSection` | → `OutlineFormSection` (단계별 인구·보급률) |
| `BusinessCostFormSection` | → `BizCstMatrixFormSection` (단계×원가 계층 전용) |
| `FnrsSharFormSection` | → `FnrsCstFormSection` (국고·지방비·원인자·기타) |
| _(없음)_ | `TmplntFormSection` (체크박스 + Tabs 시설별 매트릭스) |
| `ChecklistFormSection` `docSeComCd="FNRS"` | `docSeComCd="SEWER"` (TBD) |
| `AttachmentsFormSection` | 동일 패턴 |
| `SplmntDmndFormSection` | 동일 (`readOnly={!isSplmntPrgrsStts}`) |
| 폼 하단 액션바 (목록·저장·신청완료·삭제·보완완료) | **동일 구성, API·라우트만 sewer** |
### 3-2. 본문 ASCII (폼 페이지)
```
┌─────────────────────────────────────────────────────────────────┐
│ PageLayout: 하수도기본계획 신청/수정 breadcrumb │
├─────────────────────────────────────────────────────────────────┤
│ FormProvider │
│ <form className="mt-6 space-y-4"> │
│ │
│ ┌─ SectionCollapsible: 기본계획 신청 ─────────────────────┐ │
│ │ BasicInfoFormSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 신청자 정보 ────────────────────────────────────────────┐ │
│ │ ContactInfoFormSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 개인정보 동의 ──────────────────────────────────────────┐ │
│ │ PrivacyConsentSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 사업개요 ────────────── [이전기본계획조회] ─────────────┐ │
│ │ OutlineFormSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 총사업비 (백만원) ──────────────────────────────────────┐ │
│ │ BizCstMatrixFormSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 재원분담 (백만원) ──────────────────────────────────────┐ │
│ │ FnrsCstFormSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 처리시설별 하수도정비기본계획 ──────────────────────────┐ │
│ │ TmplntFormSection │ │
│ │ ├─ BscFctList Checkbox │ │
│ │ └─ Tabs (선택 시설별 TabsTrigger / TabsContent) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 체크리스트 ─────────────────────────────────────────────┐ │
│ │ ChecklistFormSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 첨부파일 ───────────────────────────────────────────────┐ │
│ │ AttachmentsFormSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌─ 보완요구 (edit + SL) ───────────────────────────────────┐ │
│ │ SplmntDmndFormSection │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ form footer (fnrs-form-page 279~323 동일) ──────────────┐ │
│ │ [목록] [저장] / edit: [신청완료][삭제] / SL: [보완완료] │ │
│ │ ※ API·라우트만 sewer — UI 분기는 fnrs와 동일 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ </form> │
└─────────────────────────────────────────────────────────────────┘
```
### 3-3. 액션바 동작 (`fnrs-form-page` 279~323 동일, API만 sewer)
구현 위치: `sewer-bsc-plan-form-page.tsx` 하단
(`fnrs-form-page.tsx`의 `mx-4 flex flex-wrap justify-center gap-3` 블록을 그대로 옮기고 훅·path만 교체)
| 버튼 | 조건 | 대표 동작 |
| -------- | ---------------------------- | -------------------------------------------------------------------------- |
| 목록 | 항상 | `router.push("/sewer-bsc-plan")` |
| 저장 | `!isSplmntPrgrsStts` | `type="submit"` → `saveApply` + 임시저장(`SV`) |
| 신청완료 | edit && `!isSplmntPrgrsStts` | 체크리스트 완료 + **필수 첨부 완료** 검증 → `saveApply` + `LE` |
| 삭제 | edit && `!isSplmntPrgrsStts` | confirm → `delete` → 목록 |
| 보완완료 | `isSplmntPrgrsStts` (SL) | `form.handleSubmit` → `useSubmitSupplementSewerBscPlan` |
**신청완료 사전 검증** (fnrs `handleApplyClick`과 동일 정책)
1. **체크리스트**: `isFormChecklistComplete(cklstResultList)` — 미완료 시 `"모든 체크리스트 항목을 체크해야 신청이 가능합니다."`
2. **필수 첨부**: `esntlAtchYn === "Y"`인 필수첨부 슬롯마다 `esntlAtchFileList`에 `atchFileGroupId`가 있어야 함
- Form: `AttachmentsFormSection`의 트리 + 폼 `esntlAtchFileList`
- 미충족 시 예: `"필수 첨부파일을 모두 등록해야 신청이 가능합니다."`
- 헬퍼 후보: `isFormEsntlAtchComplete(requiredTree, esntlAtchFileList)` (`pbadms-sprt`에 추가)
저장에는 위 검증을 적용하지 않는다 (임시저장 허용).
저장/제출 버튼을 섹션 헤더(`gTitle2 gRt`)에 두지 않는다 — **폼 하단 액션바 단일 진입점** (§4-J).
### 3-4. 라우트
| 화면 | path | 셸 → FormPage |
| ---- | --------------------------- | ------------------------------------------ |
| 등록 | `/sewer-bsc-plan/regist` | `mode="create"` |
| 수정 | `/sewer-bsc-plan/[id]/edit` | `mode="edit"` + `useSewerBscPlanDetails` |
| 상세 | `/sewer-bsc-plan/[id]` | ViewPage (본 문서 범위 밖, fnrs-view 대응) |
`docSeComCd` 후보: 백엔드 체크리스트·필수첨부 등록값 확인 후 **`SEWER`** (또는 레거시 `DMBP`) — **TBD, CMS 코드와 맞출 것**.
---
## 4. 섹션별 상세 구현
### 4-0. Hidden / 세션 컨텍스트 (폼 루트)
JSP hidden → React 폼 기본값·세션 스토어로 흡수.
| JSP | 의미 | React |
| ------------------------------- | --------------- | ------------------------------------------------------- |
| `chgSe` / `chgSeSp` / `chgSeCd` | 변경구분 | `sewerBscPlan.sewerBscPlanSeComCd` + 표시용 코드명 |
| `progrsSttus` | SAVE / PRESENTN | 저장·제출 시 `prgrsSttsComCd`로 매핑 (임시저장 vs 제출) |
| `planSeCd` | 고정 `A` | 필요 시 상수 또는 API 필드 확인 |
| `adstDstrCd` / `rgnCd` | 행정구역 | `sewerBscPlan.admdstCd` |
| `sidoNm` / `gugunNm` | 표시용 | 읽기 전용 표시 (`detDto.sido`/`gugun`) |
구현: `getCreateDefaultValues(userInfo)`에서 로그인 사용자·소속 지자체 주입.
---
### 4-A. 타이틀·브레드크럼
| JSP | React |
| ------------------------ | ------------------------------------------------------ |
| `h3` 기본계획검토 | PageLayout title: **하수도기본계획 신청** (IIPBS 명명) |
| 설치인가 및 제원협의 > … | 메뉴 메타 기반 breadcrumb |
별도 섹션 컴포넌트 불필요. 라우트 `page.tsx` / layout metadata로 처리.
---
### 4-B. 기본계획변경 신청 (`slide_10`)
**파일**: `ui/sections/basic-info-form-section.tsx`
**래퍼**: `SectionCollapsible` title=`기본계획 신청`
**필드 접두사**: `sewerBscPlan.*` + `sewerBscPlanDtl.*`
| 표시명 | JSP | API 매핑 | UI | 필수 | 비고 |
| ---------------- | ----------------- | ------------------------------- | ------------------ | ---- | ---------------------------------- |
| 기본계획명 | `drngMtncPlanNm` | `sewerBscPlan.sewerBscPlanNm` | Input | Y | |
| 계획년도 | `planYr` | `sewerBscPlanDtl.planYr` | Select (현재~2005) | Y | |
| 기준년도 | `crtrYr` | `sewerBscPlanDtl.crtrYr` | Select | Y | |
| 1~4단계 목표연도 | `goalYrStep1~4` | `frlvlGoalYr`~`folvlGoalYr` | Select (현재~+30) | Y | 1단계 변경 시 +5/+10/+15 자동 연동 |
| 계획개요 | `bzCn` | `sewerBscPlanDtl.bizOtlnCn` | Textarea | Y | |
| 구분 | `#chgSeNm` | `sewerBscPlanSeComCd` (+코드명) | 읽기 또는 Select | | 신규/변경 등 |
| 신청 지자체 | `${lclgvrnmntNm}` | `admdstCd` | 읽기 전용 | | |
**동작**
- `goalYrStep1` onChange → 2·3·4단계 기본값 세팅 + E/F/G 헤더 연도 span 갱신 (`watch` 파생)
- JSP 상단 `gRt` 저장/제출은 사용하지 않음 → FormPage 하단 액션바 (§3-3, §4-J)
---
### 4-C. 신청자 정보 (`slide_15`)
**재사용**: `@/widgets/pbadms-sprt`의 `ContactInfoFormSection`
**props**: `fieldPrefix="sewerBscPlanDtl"` (또는 dtl 필드 구조에 맞게)
| 표시 | JSP | API |
| ---------------------------- | --------------------------- | --------------------------------------------------------------------------------------- |
| 신청자 이름/전화/부서/이메일 | `${UsrNm}` 등 서버렌더 | 로그인 사용자 읽기 전용 → `aplcntNm`, `aplcntMblTelno`, `aplcntOgdpNm`, `aplcntEmlAddr` |
| 설계자 | `dsgnrNm/Telno/Psitn/Email` | `dsgnrNm`, `dsgnrTelno`, `dsgnrOgdpNm`, `dsgnrEmlAddr` |
신청자 4필드는 세션에서 defaultValues에 채우고 Input `disabled`.
---
### 4-D. 개인정보 동의 (`slide_19`)
**재사용**: `PrivacyConsentSection`
| JSP | React |
| ----------------------------- | ------------------------------------------------------------------ |
| 고지 textarea + 보유기간 문구 | 동일 문구 상수/마크다운 |
| `privacyAgree` Y/N | 폼 필드 `privacyAgree: "Y" \| "N"` (API 미전송, 클라이언트 검증만) |
**검증**: 제출 시 `Y` 필수 (JSP `presentnBtn`과 동일). 저장(임시)은 동의 없이 허용할지 정책 TBD — 기본은 JSP와 같이 **제출만 강제**.
---
### 4-E. 사업개요 (`slide_11`)
**파일**: `ui/sections/outline-form-section.tsx` (신규, 도메인 전용)
**API**: `sewerBscPlanOtlnList: SewerBscPlanOtlnItem[]` (planStpNo 1~4)
| 행(구분) | JSP name | Item 필드 |
| ------------ | --------------- | ------------------- |
| 계획인구 | `arrPlanPpltn` | `sewerPlanPpltnCnt` |
| 처리인구 | `arrPrcsPpltn` | `sewerPrcsPpltnCnt` |
| 하수도보급율 | `arrDrngSplyRt` | `sewerPsplyRt` |
열: 1~4단계 (헤더에 `frlvlGoalYr` 등 표시).
**버튼**: `이전기본계획조회` → Dialog (`PreviousPlanLookupDialog`)
- 레거시: `fnbscPlnPopupToggle` + `bscPlnPopup.jsp`
- React: Modal + 조회 API (**TBD**: 이전 계획 조회 엔드포인트; 없으면 1차는 Dialog UI만 + mock)
숫자 입력: 콤마 제거 후 number 전송 (제출 시 JSP `replaceComma` 대체).
---
### 4-F. 총사업비 (`slide_12`)
**파일**: `ui/sections/biz-cst-matrix-form-section.tsx` (**전용 컴포넌트** — fnrs `BusinessCostFormSection` 재사용 금지)
**래퍼**: `SectionCollapsible` title=`총사업비` (단위 `백만원`은 헤더 우측)
**JSP 현실**: 단계(1~4) × 원가 계층(총사업비, 공사비, 처리시설, 하수관로 신설/보수, 펌프장, 배수설비, 기타, 부대경비, 설계/감리/부대비). 일부 readonly·자동합산 JS.
**신규 API**: `bizCstList: SewerBscPlanBizCstItem[]` = `{ planStpNo, acntgSeCd, cstrnAmt }`
**구현 방향 (`BizCstMatrixFormSection` 전용)**
- 행 = `useAcntgSeMngTree` → `buildAcntgCostItems` (fnrs `BusinessCostFormSection`과 동일 출처)
- `costItems` 로드 시 `useEffect`로 `bizCstList`를 `mergeBizCstList(existing, costItems)`로 동기화
- 열 = `planStpNo` 1~4 (헤더에 목표연도 표시)
- readonly 행(`CostItemDef.readonly=true`)은 children 합산으로 자동계산; Input에 `readOnly` + `bg-muted`
- 변경 시 `setAmt` → `recalculateBizCstMatrix(list, costItems)` → `setValue("bizCstList")`
- 저장 시 섹션 onChange 합산을 신뢰하여 `bizCstList` 그대로 전송 (`toBizCstSaveList` identity)
**표준사업비 조회** 버튼은 JSP에서 주석 처리됨 → **미구현**.
---
### 4-G. 재원분담 (`slide_13`)
**주의**: fnrs의 `FnrsSharFormSection`(재원협의 공유구분 트리)과 **다름**.
| 행 | JSP | `SewerBscPlanFnrsCstItem` |
| ------------ | -------------- | ------------------------- |
| 총사업비 | `arrTotAmnt` | `sumAmt` (또는 파생) |
| 국고 | `arrNlepAmt` | `gvexpAmt` |
| 지방비 | `arrLcepAmt` | `lcexpAmt` |
| 원인자부담금 | `arrCusrAlotm` | `orgprBdchgAmt` |
| 기타 | `arrEtcAmt` | `etcAmt` |
**파일**: `ui/sections/fnrs-cst-form-section.tsx` (신규)
데이터: `fnrsCstList` 길이 4 (`planStpNo` 1~4).
검증(선택): 단계별 `gvexp+lcexp+orgpr+etc === sumAmt` (JSP `calculateTotalFunding` 참고).
---
### 4-H. 처리시설별 하수도정비기본계획 (`slide_14`) — 핵심 복잡도
**JSP 동작**
1. `BscFctList` 체크박스로 처리시설 다중 선택 (`arrBscFctCd`)
2. 선택 시 `createFormSection(code, name)`로 **당초 4단계 | 현재 | 변경 4단계** 매트릭스 동적 삽입
→ **React**: 세로 삽입 대신 `@common/components` `Tabs`로 시설별 패널 관리 (§아래 Tabs 규칙)
3. 활성 `formFields`(축소판): 처리구역면적, 하수처리인구, 계획하수량, 시설용량, 분류식 오수/우수, 합류식, 차집, 배수설비, 오수중계/일반/우수 펌프
4. 시설별 필수첨부 `createFileAttachmentSection`
5. `fetchBasicPlanData`로 당초(이전) 데이터 바인딩
**API 매핑**
| UI | DTO |
| -------------------- | ------------------------------------------------------------- |
| 선택 시설 목록 | `sewerBscPlanTmplntList[].swtrmtFcltCd` (+ `atchFileGroupId`) |
| 단계별 수치 (변경측) | `sewerBscPlanTmplntDtlList[]` → `SewerBscPlanTmplntDtlItem` |
| 임시저장 | `tmplntTmprStrgList` (편집 중 임시, 필요 시) |
필드 대응 (변경 단계 기준):
| UI 라벨 | `SewerBscPlanTmplntDtlItem` |
| -------------- | --------------------------- |
| 처리구역면적 | `prcsZoneArea` |
| 하수처리인구 | `swtrmtPpltnCnt` |
| 계획하수량 | `planSwqty` |
| 시설용량 | `fcltCpct` |
| 분류식 오수 | `sprsysDrtwPplLen` |
| 분류식 우수 | `sprsysExlnPplLen` |
| 합류식 | `cbsysPplLen` |
| 차집관로 | `icptgPplLen` |
| 배수설비 | `drgCnt` |
| 오수 중계 펌프 | `drtwRlyPumpCnt` |
| 오수 일반 펌프 | `drtwPumpCnt` |
| 우수 펌프 | `exlnPumpCnt` |
**구현 구성**
```
ui/sections/tmplnt-form-section.tsx
├─ TmplntFacilityPicker # BscFctList Checkbox 목록 (시설 마스터 API TBD)
└─ Tabs (@common/components Tabs) # 선택 시설별 동적 패널
├─ TabsList / TabsTrigger # value=swtrmtFcltCd, 라벨=시설명
└─ TabsContent (시설 1개당)
├─ TmplntMatrixTable # 당초|현재|변경 매트릭스 (useFieldArray)
└─ TmplntFacilityAttachments # 시설별 필수첨부 (선택)
```
**Tabs 관리 규칙** (`packages/common/components/src/ui/tabs`)
- `BscFctList` 체크 **선택** → `useFieldArray` append + 해당 시설 `TabsTrigger`/`TabsContent` 추가 → `Tabs` `value`를 신규 시설로 전환
- 체크 **해제** → confirm 후 remove + 해당 탭 제거 → 남은 탭 중 하나로 `value` 이동 (없으면 Tabs 영역 비표시)
- 선택 시설이 0개면 Tabs UI 숨김; 1개 이상이면 Tabs로 시설별 패널 전환
- 매트릭스·첨부는 각 `TabsContent` 안에만 렌더 (JSP의 세로 동적 삽입을 탭 패널로 대체)
**매트릭스 열**
- **당초 열**: 이전계획 API 조회 결과 읽기 전용 (없으면 빈 칸)
- **현재 열**: 기준년도 시점 값 (API 제공 시)
- **변경 열**: 편집 가능 → 저장 payload의 dtl만 전송
시설 마스터 목록 API: 레거시 `BscFctList` 대체 — **TBD** (`swtrmtFclt` 계열).
`bundle-dynamic-imports`: `TmplntFormSection`(또는 TabsContent 내부 매트릭스)을 `next/dynamic` 후보.
---
### 4-I. 체크리스트 + 첨부 (`slide_16`)
#### I-1. 체크리스트
**재사용**: `ChecklistFormSection`
`docSeComCd="<SEWER|DMBP TBD>"`
`fieldPrefix`: 마스터에서 `taskSeComCd`를 읽는 경로 (예: `sewerBscPlan`)
| JSP | React |
| ----------------------- | ------------------------------------------- |
| 업무탭 `ceckEssntlArr` | 코드 `taskSeComCd` 다중 → Tabs |
| 진행률 / 전체 예·아니오 | 위젯 내장 로직 |
| `exmntResult` Y/N | `cklstResultList[].selected` (+ `picRvwCn`) |
저장: `cklstResultList: CklstResultItem[]`
#### I-2. 필수 첨부 (탭별)
**재사용**: `AttachmentsFormSection`
`docSeComCd` 동일, `esntlAtchFileList`
레거시: 체크리스트 탭마다 `essntlList` + `uploadFile` multipart.
신규: TUS/`attach-service` + `esntlAtchFileId` / `atchFileGroupId` (fnrs와 동일).
#### I-3. 기타 첨부
`AttachmentsFormSection`의 etc 영역 또는 `atchFileGroupId` on `sewerBscPlanDtl`.
JSP `uploadFileETC` multiple → Zip 안내 문구 유지.
---
### 4-J. 저장 / 제출
**UI 구성**: [`fnrs-form-page.tsx` 279~323](../../src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx) 과 **동일**.
마크업·분기(`isSplmntPrgrsStts` / `isEditMode`)·버튼 색상·`disabled={isSubmitting}` 패턴을 그대로 두고, **호출 API·목록 path만 sewer** 로 교체한다.
```tsx
<div className="mx-4 flex flex-wrap justify-center gap-3 sm:gap-4">
<Button color="white" type="button" onClick={() => router.push("/sewer-bsc-plan")} disabled={isSubmitting}>
목록
</Button>
{!isSplmntPrgrsStts ? (
<>
<Button type="submit" disabled={isSubmitting}>저장</Button>
{isEditMode && (
<>
<Button color="green" type="button" onClick={handleApplyClick} disabled={isSubmitting}>
신청완료
</Button>
<Button color="red" type="button" onClick={handleDelClick} disabled={isSubmitting}>
삭제
</Button>
</>
)}
</>
) : (
<Button type="button" onClick={form.handleSubmit(onSplmntCmptn)} disabled={isSubmitting}>
보완완료
</Button>
)}
</div>
```
| 버튼 | fnrs 대응 | sewer API / 동작 |
| -------- | ----------------------------- | ------------------------------------------------------------ |
| 목록 | `router.push("/fnrs-cnsltn")` | `router.push("/sewer-bsc-plan")` |
| 저장 | `saveApply` + 임시저장 | `useSaveApplySewerBscPlan` + `prgrsSttsComCd`=임시저장(`SV`) |
| 신청완료 | `handleApplyClick` → 제출 | 체크리스트+필수첨부 검증 → `saveApply` + `LE` (상태코드 TBD) |
| 삭제 | `handleDelClick` | `useDeleteSewerBscPlan`(또는 동등) → 목록 |
| 보완완료 | `onSplmntCmptn` | `useSubmitSupplementSewerBscPlan` |
| JSP | 진행값 | URL | React |
| ---- | ---------- | ------------------------- | ---------------------------------------------------------- |
| 저장 | `SAVE` | `insertFnrscDmbp.do` | `saveApply` + 임시저장 코드 |
| 제출 | `PRESENTN` | `insertFnrscDscss.do` (!) | `saveApply` + 제출 상태코드 — **백엔드 상태코드 확인 TBD** |
페이로드 조립 (`buildApplyPayload`):
```ts
SewerBscPlanApplySaveRequestDto {
sewerBscPlan, sewerBscPlanDtl,
sewerBscPlanOtlnList, bizCstList, fnrsCstList,
sewerBscPlanTmplntList, tmplntTmprStrgList?,
cklstResultList, esntlAtchFileList,
splmntDmndList?, rvwOpnnList? // 등록 화면에서는 보통 빈 배열
}
```
성공 후: `invalidateQueries(sewerBscPlanBaseKey)` → `router.push('/sewer-bsc-plan')` (신청완료는 상세로 보낼 수 있음 — fnrs와 동일 정책).
권한: JSP `AUTH3` → 기존 AS 권한 훅/역할로 버튼 `disabled`/`hidden`.
---
## 5. API / DTO 연결
Base: `/as-service/api/v1/sewer-bsc-plans`
엔티티: `@/entities/sewer-bsc-plan`
| 화면 동작 | Hook / Service | 비고 |
| -------------- | ---------------------------------------- | ---------------- |
| 등록 저장·제출 | `useSaveApplySewerBscPlan` → `saveApply` | 본 문서 핵심 |
| 수정 로드 | `useSewerBscPlanDetails(docNo, chgCycl)` | 편집 시 |
| 마스터만 | `useSewerBscPlan` | 목록 행 클릭 등 |
| 보완 제출 | `useSubmitSupplementSewerBscPlan` | edit + `prgrsSttsComCd=SL` 시 폼 하단 [보완완료] |
| 기술검토 | `useTechReviewSewerBscPlan` | 등록 JSP 범위 밖 |
등록 JSP에 없는 섹션(보완요구·검토의견)은 **상세/검토 화면**에서 `SplmntDmndFormSection` / 검토의견 섹션으로 후속 구현.
---
## 6. 권장 파일 배치
```
apps/as/
├── app/(with-base-layout)/sewer-bsc-plan/
│ └── regist/page.tsx
├── src/pages/sewer-bsc-plan/
│ ├── lib/
│ │ ├── sewer-bsc-plan-regist-schema.ts # zod
│ │ └── form-data-mapper.ts # Det ↔ Form, Apply payload
│ ├── model/
│ │ └── (필요 시 table columns)
│ └── ui/
│ ├── sewer-bsc-plan-regist-page.tsx
│ ├── sewer-bsc-plan-form-page.tsx
│ ├── components/
│ │ └── previous-plan-lookup-dialog.tsx
│ └── sections/
│ ├── basic-info-form-section.tsx
│ ├── outline-form-section.tsx # 사업개요
│ ├── biz-cst-matrix-form-section.tsx # 총사업비 (전용)
│ ├── fnrs-cst-form-section.tsx # 재원분담
│ └── tmplnt-form-section.tsx # 처리시설 (Checkbox + Tabs)
└── docs/sewer-bsc-plan/
└── dmbp-regist-migration.md # 본 문서
```
공통 재사용 (신규 작성 최소화):
- `ContactInfoFormSection`, `PrivacyConsentSection`
- `ChecklistFormSection`, `AttachmentsFormSection`
- `SectionCollapsible`
- `Tabs` / `TabsList` / `TabsTrigger` / `TabsContent` (`@common/components` — 처리시설 패널)
도메인 전용 (재사용 금지):
- `BizCstMatrixFormSection` — 총사업비 (fnrs `BusinessCostFormSection` 미사용)
- 폼 하단 액션바 — `fnrs-form-page` 279~323 복제, API만 sewer
---
## 7. 화면 데이터 흐름
```
BEGIN
// 최초 진입 (등록)
CALL 로그인사용자조회
CALL 코드조회(SEWERBSCPLANSE, SEWER, …) // 병렬
CALL 체크리스트트리(docSeComCd) // 병렬
CALL 필수첨부트리(docSeComCd) // 병렬
CALL 처리시설마스터목록 // TBD (BscFctList)
DISPLAY 폼 defaultValues
ON 1단계목표연도 CHANGE DO
SET 2·3·4단계 연도 (+5/+10/+15)
DISPLAY E/F/G/H 헤더 연도
END ON
ON 처리시설 체크 DO
ADD tmplntList 항목
ADD TabsTrigger / TabsContent (시설 패널)
SET Tabs value = 신규 시설
CALL 이전기본계획수치(시설코드) // 당초열, TBD
END ON
ON 처리시설 체크해제 DO
CONFIRM 제거
REMOVE tmplntList + 해당 Tabs 패널
SET Tabs value = 남은 시설 (없으면 Tabs 숨김)
END ON
ON [이전기본계획조회] CLICK DO
OPEN PreviousPlanLookupDialog
END ON
ON [저장] CLICK DO
VALIDATE zod (동의 제외 가능)
CALL saveApply(payload, 임시저장 상태)
NAVIGATE 목록
END ON
ON [신청완료] CLICK DO
VALIDATE zod + privacyAgree=Y + 체크리스트 완료 + 필수첨부(esntlAtchYn=Y) 완료
CONFIRM 신청 확인
CALL saveApply(payload, 제출 상태 LE)
NAVIGATE 상세
END ON
END
```
---
## 8. JSP ↔ 신규 갭·리스크
| # | 이슈 | 대응 |
| --- | ---------------------------------------------------- | ------------------------------------------------- |
| 1 | JSP 제출 URL이 `dscss`로 분기 (오타/구프로세스 가능) | 신규는 `saveApply` 단일 진입으로 통일 |
| 2 | 총사업비 UI 계층 ≠ `BizCstItem` | `BizCstMatrixFormSection` 전용 + `acntgSeCd` 매핑 테이블 |
| 3 | 재원분담 ≠ fnrs shar | 전용 `fnrs-cst-form-section` |
| 4 | 처리시설 매트릭스(당초/현재/변경) | Checkbox 선택 → Tabs 패널; 저장은 변경 dtl 중심; 당초는 조회 전용 |
| 5 | 시설·이전계획 API | TBD — 미정 시 체크박스 목록 mock(`faker`)만 |
| 6 | `docSeComCd` 값 | CMS/체크리스트 마스터와 합의 |
| 7 | 레거시 multipart vs TUS | fnrs 첨부 패턴 준수 |
| 8 | 주석 처리된 상세 `formFields` (자연발생인구 등) | **축소된 formFields만** 이관; 주석 블록 복원 금지 |
---
## 9. 구현 순서 (권장)
1. 라우트 + `FormPage` 골격 + zod 스키마(마스터·dtl·동의) + 폼 하단 액션바(§4-J)
2. B·C·D 섹션 (공통 위젯 최대 활용)
3. E 사업개요 + G 재원분담 (4열 매트릭스 패턴 공유)
4. F 총사업비 (`BizCstMatrixFormSection` 전용)
5. I 체크리스트·첨부
6. H 처리시설 (`BscFctList` Checkbox + `@common/components` Tabs — 가장 복잡)
7. 저장/신청완료/삭제/보완완료 API 연동 (§4-J)
8. 이전계획 Dialog (API 확정 후)
---
## 10. 체크리스트 (jsp-migration-as)
- [ ] contents만 이관 (include 제외)
- [ ] vercel-react-best-practices 적용 (Query 병렬·파생상태·dynamic)
- [ ] `@common/components` + 필요 시 DataTable
- [ ] API·모델은 `@/entities/sewer-bsc-plan` 우선
- [ ] 목데이터는 `@faker-js/faker`만 (시설 마스터 등 TBD 구간)
- [ ] 로딩·빈·에러 상태
- [ ] `router.push`로 목록 복귀
---
## 11. 후속 문서
- 목록(`dmbpList.jsp`) / 상세(`dmbpView.jsp`) / 수정(`dmbpModify.jsp`)은 본 문서와 동일 섹션 체계로 별도 확장
- 화면설계서 SPEC 형식이 필요하면 `docs/sewer-bsc-plan/SPEC.md`로 분리 (영역 A~J 표 + API 표)
@@ -1,407 +0,0 @@
# 화면설계서: 결재선 관리
## 1. 기본 정보
| 항목 | 내용 |
| ---------- | -------------------------------------------- |
| 버전 | 0.3 |
| 프로세스명 | UI설계 |
| 화면명 | 결재선 관리 |
| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 |
| 메뉴 경로 | 디지털행정지원 > 시스템관리 > 결재선 관리 |
---
## 2. 화면 목적
전자결재 처리에 사용할 **결재서식**(템플릿)을 등록·관리하고, 각 서식별로 **결재선**(결재구분·결재자·순서)을 구성한다. 상단의 결재서식 목록에서 하나를 선택하면 하단 영역에 해당 서식의 결재선이 표시되며, 사용자는 **그리드 인라인 폼**으로 결재자를 등록·수정한다. **행 추가 또는 한 행 수정 중에는 다른 행을 편집할 수 없다**(단일 행 편집 잠금). 드래그로 순서를 변경한 뒤 **[결재선 저장]** 으로 서버에 반영한다.
---
## 3. 화면 구성
### 3-1. 전체 레이아웃
```
┌───────────────────────────────────────────────────────────────────────────────────────────────┐
│ 페이지 타이틀: 결재선 관리 브레드크럼: 디지털행정지원 > … > │
├───────────────────────────────────────────────────────────────────────────────────────────────┤
│ [영역 A] 전자결재선 관리 목록 [서식 등록] │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ 전자결재서식아이디 | 결재서식명 | 문서구분 | 등록일시 | 관리 │ │
│ ├──────────────────────────────────────────────────────────────────────┤ │
│ │ TMPL-0001 (선택 행 강조) … [수정][삭제] │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
├───────────────────────────────────────────────────────────────────────────────────────────────┤
│ [영역 B] 결재선 관리 선택된 서식: TMPL-0001 [결재선 저장] │
│ ┌─────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ [영역 B-2] 결재선 그리드 (인라인 편집 + 행 드래그 — @dnd-kit) │ │
│ │ ⋮⋮ | 결재선아이디 | 결재순서 | 결재구분 ▼ | 결재자 [선택] | 등록일시 | 관리 │ │
│ │ ⋮⋮ | APLN-0001 | [ 1 ] | [검토자 ▼] | [김민수 ][선택] | … | [저장][삭제] │ │
│ │ ⋮⋮ | (신규) | [ 2 ] | [승인자 ▼] | [ ][선택] | — | [저장][취소] │ │
│ └─────────────────────────────────────────────────────────────────────────────────────────┘ │
│ [행 추가] │
│ ※ 결재자 등록·수정은 그리드 셀에서 직접 입력. 검토인력만 [선택] 팝업/다이얼로그 │
│ ※ 행 추가·수정 중: 해당 행만 편집 가능, 나머지 행·[행 추가]·드래그 비활성 │
└───────────────────────────────────────────────────────────────────────────────────────────────┘
```
### 3-2. 화면 데이터 흐름
```
BEGIN
// 최초 진입
CALL GET /as-elapr-service/api/v1/elapr-tmplt
DISPLAY 결재서식 그리드 (영역 A)
DISPLAY 빈 상태 안내 (영역 B) // 서식 미선택 시 그리드·툴바 비활성
// 영역 A: 서식 행 선택
ON 영역 A 행 CLICK DO
SET 선택된 서식 = 행.elaprTmpltId
CALL GET /as-elapr-service/api/v1/elapr-apln?elaprTmpltId={id}
DISPLAY 결재선 그리드 (영역 B-2) — 조회 모드
END ON
// 영역 A: 신규 서식 등록
ON [서식 등록] 버튼 CLICK DO
OPEN 결재서식 등록 다이얼로그
ON 확인 DO
CALL POST /as-elapr-service/api/v1/elapr-tmplt
CALL GET /as-elapr-service/api/v1/elapr-tmplt
END ON
END ON
// 영역 A: 서식 수정·삭제 (다이얼로그 유지)
ON 영역 A [수정] / [삭제] DO
(기존과 동일 — PUT / DELETE elapr-tmplt)
END ON
// 영역 B: 행 추가 (인라인 신규 행)
ON [행 추가] 버튼 CLICK DO
VALIDATE 서식 선택됨
VALIDATE 편집 중인 행 없음 (editingRowKey == null)
ADD 영역 B-2 그리드 맨 아래 편집 가능 행
SET editingRowKey = rowKey (draft-{timestamp})
SET 편집상태 = 신규
SET aplnId = 미설정 (POST 저장 시 서버 fn_gen_apln_id 발급)
SET atrzSeqNo = 현재 행 수 + 1
SET atrzSeComCd, aprvrUuid = 빈 값
DISABLE 나머지 행 편집 · [행 추가] · 드래그 · [결재선 저장](정책에 따라)
END ON
// 영역 B-2: 기존 행 수정 진입
ON 행별 [수정] CLICK DO
VALIDATE 편집 중인 행 없음
SET editingRowId = 행.aplnId
SET 해당 행만 편집 모드
DISABLE 나머지 행 편집 · [행 추가] · 드래그
END ON
// 영역 B-2: 인라인 편집
ON 그리드 셀 변경 DO
VALIDATE 변경 대상 행 == editingRowId
SET 해당 행 dirty = true
END ON
ON 셀 [선택] (검토인력) CLICK DO
OPEN 검토인력 선택 다이얼로그 (단일 선택)
ON 선택 DO
SET 행.aprvrUuid = 선택인력.uuid
SET 행.aprvrNm = 선택인력.성명 // UI 표시용 (응답 DTO에 없음 — 로컬 보조 필드)
END ON
END ON
// 영역 B-2: 행별 저장
ON 행별 [저장] CLICK DO
VALIDATE atrzSeComCd, aprvrUuid, atrzSeqNo
IF 편집상태 = 신규 THEN
CALL POST /as-elapr-service/api/v1/elapr-apln
ELSE
CALL PUT /as-elapr-service/api/v1/elapr-apln/{aplnId}
END IF
CALL GET /as-elapr-service/api/v1/elapr-apln?elaprTmpltId={id}
CLEAR editingRowId
ENABLE 전체 행 조회 모드 · [행 추가] · 드래그
END ON
// 영역 B: 결재선 저장 (드래그 순서 일괄 반영)
ON [결재선 저장] 버튼 CLICK DO
VALIDATE 서식 선택됨
VALIDATE 편집 중인 행 없음
REORDER atrzSeqNo = 그리드 행 순서 (1..n)
FOR EACH dirty 또는 순서 변경된 행 DO
CALL PUT /as-elapr-service/api/v1/elapr-apln/{aplnId} // atrzSeqNo 등 반영
END FOR
FOR EACH 신규 미저장 행 DO
CALL POST /as-elapr-service/api/v1/elapr-apln
END FOR
CALL GET /as-elapr-service/api/v1/elapr-apln?elaprTmpltId={id}
TOAST "결재선이 저장되었습니다."
END ON
// 영역 B-2: 행 드래그
ON 영역 B-2 행 DRAG END DO
VALIDATE 편집 중인 행 없음
REORDER 그리드 행
SET 각 행 atrzSeqNo = 행 인덱스 + 1 (UI 즉시 반영, 서버는 [결재선 저장] 또는 행별 [저장])
END ON
// 영역 B-2: 행 삭제
ON 행별 [삭제] CLICK DO
CONFIRM "선택한 결재선을 삭제하시겠습니까?"
IF 서버 저장된 행 THEN
CALL DELETE /as-elapr-service/api/v1/elapr-apln/{aplnId}
END IF
REMOVE 그리드 행 (신규 미저장 행은 API 호출 없이 제거)
CALL GET /as-elapr-service/api/v1/elapr-apln?elaprTmpltId={id}
END ON
ON 행별 [취소] CLICK DO // 신규·수정 공통
IF 편집상태 = 신규 THEN REMOVE 임시 행
ELSE RESTORE 행 스냅샷 (수정 취소)
CLEAR editingRowId
ENABLE 전체 행 조회 모드 · [행 추가] · 드래그
END ON
END
```
---
## 4. 영역 A: 전자결재선 관리 목록
선택된 결재서식이 영역 B의 작업 대상이 된다. 행 클릭으로 선택 상태를 표시(좌측 컬러 바·배경 강조)한다.
### 4-1. 헤더 / 툴바
| 항목 | 내용 |
| ----------- | ----------------------------------------------- |
| 섹션 타이틀 | 전자결재선 관리 목록 |
| 우측 버튼 | **[서식 등록]** — 결재서식 등록 다이얼로그 오픈 |
### 4-2. 그리드 컬럼
| 컬럼명 | Field | 타입 | 비고 |
| ------------------ | -------------- | ------ | --------------------------------------- |
| 전자결재서식아이디 | `elaprTmpltId` | string | 등록 시 서버(DB 함수) 자동 발급 |
| 결재서식명 | `elaprTmpltNm` | string | |
| 문서구분 | `docSeComNm` | string | 코드 `docSeComCd`, 상위코드 `ASDOCSECD` |
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
| 관리 | — | — | 행별 **[수정] · [삭제]** 버튼 |
- 행 클릭 시: `elaprTmpltId` 선택 → 영역 B 라벨·B-2 그리드 갱신.
- 페이지네이션: Spring `Pageable` (`page`, `size`, `sort`).
### 4-3. 버튼
| 버튼명 | 위치 | 동작 |
| --------- | -------------- | ----------------------------------------------- |
| 서식 등록 | 섹션 우측 상단 | 결재서식 등록 다이얼로그 오픈 |
| 수정 | 행별 | 결재서식 수정 다이얼로그 (기존 값 초기화) |
| 삭제 | 행별 | 확인 후 삭제. 선택 중인 서식이면 영역 B 초기화. |
### 4-4. 다이얼로그(개요)
| 다이얼로그명 | 트리거 | 입력 항목 | 비고 |
| ------------------ | ----------- | ----------------------------------------------- | ----------------------------------------- |
| 결재서식 등록 | [서식 등록] | `elaprTmpltNm`, `docSeComCd` | `elaprTmpltExpln` 선택, ID는 저장 후 응답 |
| 결재서식 수정 | 행별 [수정] | `elaprTmpltNm`, `docSeComCd`, `elaprTmpltExpln` | ID는 변경 불가 |
| 결재서식 삭제 확인 | 행별 [삭제] | (확인/취소) | |
---
## 5. 영역 B: 결재선 관리
영역 A에서 선택된 `elaprTmpltId` 기준으로 결재선을 관리한다. **등록·수정은 B-2 그리드 인라인 폼**으로 수행하며, 검토인력 지정만 **[선택]** 보조 다이얼로그를 사용한다.
### 5-1. 헤더 / 툴바
| 항목 | 내용 |
| ----------- | --------------------------------------------------------------------------------------------------------------------------- |
| 섹션 타이틀 | 결재선 관리 |
| 우측 라벨 | `선택된 서식: {elaprTmpltId}` — 미선택 시 영역 B 비활성 |
| 우측 버튼 | **[행 추가]** — 편집 중인 행이 없을 때만 활성 · **[결재선 저장]** — 편집 중인 행이 없을 때만 활성(순서·미저장 행 일괄 반영) |
### 5-2. 영역 B-2: 결재선 그리드 (인라인 편집)
#### 그리드 컬럼
| 컬럼명 | Field | 입력 UI (편집 모드) | 비고 |
| ------------- | ------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------- |
| (드래그 핸들) | — | — | `useSortable` 연동 |
| 결재선아이디 | `aplnId` | 읽기 전용 | 신규 행: `—`, 저장 후 서버 `aplnId` 표시 |
| 결재순서번호 | `atrzSeqNo` | 읽기 전용 (그리드 행 순서 1부터 표시) | 저장 시 행 순서 기준 자동 채번 |
| 결재구분 | `atrzSeComCd` | Select (`ATRZSECD` 공통코드) | 표시명 `atrzSeComNm` (조회 행) |
| 결재자 | `aprvrUuid` | 읽기 전용 이름 + **[선택]** 버튼 | 응답에 성명 없음 — 선택 시 로컬 `aprvrNm` 유지 |
| 등록일시 | `regDt` | 읽기 전용 | 신규 행은 `—` |
| 관리 | — | 편집 중인 행: **[저장] [취소]** · 조회 행: **[수정] [삭제]** (다른 행 편집 중이면 비활성) |
#### 인라인 편집 정책
| 항목 | 내용 |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| **단일 행 편집** | **행 추가 또는 한 행 수정 중에는 다른 행을 편집할 수 없다.** `editingRowId` 로 현재 편집 행 1건만 허용. |
| 편집 잠금 시 비활성 | 다른 행: 셀·[수정]·[삭제] 비활성 · `[행 추가]` 비활성 · 행 드래그 비활성 · (선택) `[결재선 저장]` 비활성 |
| 편집 해제 | 행별 **[저장]** 성공 또는 **[취소]** 시 `editingRowId` 초기화 후 전체 조회 모드 복귀 |
| 조회 행 | 편집 중이 아닐 때 읽기 전용. **[수정]** 클릭 시 해당 행만 편집 모드 진입 |
| 신규 행 | `[행 추가]` 시 맨 아래 편집 행 1건 생성 (`editingRowId` = 임시 키) |
| 필수값 | `atrzSeComCd`, `aprvrUuid` |
| 순서 중복 | 저장 시 행 순서로 자동 채번하므로 중복 없음 |
| 결재구분 코드 | `ATRZSECD` — 예: `ATRZ01` 검토자, `ATRZ02` 승인자, `ATRZ03` 최종승인자 |
| **순서 제약** | `ATRZ01`은 최상단 블록, `ATRZ03`은 최하단 블록. 드래그 종료·저장 시 `validateAtrzOrder`로 검증, 위반 시 적용/저장 불가 (결재구분 변경 시에는 미검증) |
| 저장 단위 | 행별 **[저장]** (단건 POST/PUT) 또는 툴바 **[결재선 저장]** (일괄) |
#### 행 드래그 정렬 (`@dnd-kit` + `useSortable`)
| 항목 | 내용 |
| ---------- | ------------------------------------------------------------------------------------------------------- |
| 목적 | 결재 단계 순서를 그리드에서 직접 변경 |
| 라이브러리 | `@dnd-kit/core`, `@dnd-kit/sortable` — 테이블 행 POC 후 확정 |
| 저장 시점 | 드래그는 클라이언트 상태만 변경 → **[결재선 저장]** 또는 행별 **[저장]** 시 `atrzSeqNo` PUT |
| 편집 중 | `editingRowKey != null` 이면 드래그 **불가** |
| 순서 제약 | 드래그 종료 시 `validateAtrzOrder` — 실패하면 순서 변경 미적용 + 안내. 결재구분 미선택 행은 드래그 불가 |
### 5-3. 보조 다이얼로그 (검토인력만)
| 다이얼로그명 | 트리거 | 결과 |
| ------------- | ----------------------- | ---------------------------------- |
| 검토인력 선택 | 그리드 결재자 셀 [선택] | `aprvrUuid` 및 UI용 `aprvrNm` 반영 |
> 결재자 **등록·수정 다이얼로그는 사용하지 않음** (v0.2 변경).
---
## 6. API/DTO 명세
### 6-1. 공통
| 항목 | 내용 |
| ----------- | ------------------------------------------------------------- |
| 서비스 | `as-elapr-service` |
| Base prefix | `/as-elapr-service/api/v1` (프록시 경유) |
| 인증 | JWT (`Authorization` 헤더) |
| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse<T>` |
| 엔티티 | `@/entities/elapr` (`elaprTmpltService`, `elaprAplnService`) |
### 6-2. 전자결재서식 (영역 A) — `elapr-tmplt`
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
| --------- | ------ | ----------------------------- | ------------------------------------------ | ------------------ | ---------------------------- |
| 목록 조회 | GET | `/elapr-tmplt` | Query: `ElaprTmpltSearchParams` + Pageable | `Page<ElaprTmplt>` | `elaprTmpltService.findPage` |
| 단건 조회 | GET | `/elapr-tmplt/{elaprTmpltId}` | — | `ElaprTmplt` | `elaprTmpltService.findById` |
| 등록 | POST | `/elapr-tmplt` | `ElaprTmpltSaveRequest` | `ElaprTmplt` (201) | `elaprTmpltService.create` |
| 수정 | PUT | `/elapr-tmplt/{elaprTmpltId}` | `ElaprTmpltUpdateRequest` | `ElaprTmplt` | `elaprTmpltService.update` |
| 삭제 | DELETE | `/elapr-tmplt/{elaprTmpltId}` | — | 204 | `elaprTmpltService.remove` |
**ElaprTmplt (응답)**
| Field | 타입 | 설명 |
| ----------------- | ------- | ------------------ |
| `elaprTmpltId` | string | 전자결재서식아이디 |
| `elaprTmpltNm` | string | 결재서식명 |
| `elaprTmpltExpln` | string? | 서식 설명 |
| `docSeComCd` | string? | 문서구분 코드 |
| `docSeComNm` | string? | 문서구분명 |
| `regDt` | string? | 등록일시 |
**ElaprTmpltSaveRequest**: `elaprTmpltNm`\_, `elaprTmpltExpln?`, `docSeComCd?`
**ElaprTmpltUpdateRequest**: `elaprTmpltNm`\*, `elaprTmpltExpln?`, `docSeComCd?`
**ElaprTmpltSearchParams**: `elaprTmpltNm?`, `docSeComCd?`, `regDtStart?`, `regDtEnd?` + `page`, `size`, `sort`
### 6-3. 결재선 (영역 B) — `elapr-apln`
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
| --------- | ------ | ---------------------- | ------------------------------- | ----------------- | --------------------------- |
| 목록 조회 | GET | `/elapr-apln` | Query: `elaprTmpltId`, Pageable | `Page<ElaprApln>` | `elaprAplnService.findPage` |
| 단건 조회 | GET | `/elapr-apln/{aplnId}` | — | `ElaprApln` | `elaprAplnService.findById` |
| 등록 | POST | `/elapr-apln` | `ElaprAplnSaveRequest` | `ElaprApln` (201) | `elaprAplnService.create` |
| 수정 | PUT | `/elapr-apln/{aplnId}` | `ElaprAplnUpdateRequest` | `ElaprApln` | `elaprAplnService.update` |
| 삭제 | DELETE | `/elapr-apln/{aplnId}` | — | 204 | `elaprAplnService.remove` |
> **일괄 저장 API 없음** — UI의 [결재선 저장]은 변경된 행에 대해 POST/PUT을 순차(또는 병렬) 호출하는 클라이언트 오케스트레이션으로 구현.
**ElaprApln (응답)**
| Field | 타입 | 설명 |
| -------------- | ------- | ------------------ |
| `aplnId` | string | 결재선아이디 |
| `elaprTmpltId` | string | 전자결재서식아이디 |
| `aprvrUuid` | string? | 결재자 UUID |
| `atrzSeComCd` | string? | 결재구분 코드 |
| `atrzSeComNm` | string? | 결재구분명 |
| `atrzSeqNo` | number? | 결재순서번호 |
| `regDt` | string? | 등록일시 |
**ElaprAplnSaveRequest**: `elaprTmpltId`\_, `aprvrUuid?`, `atrzSeComCd?`, `atrzSeqNo?`
**ElaprAplnUpdateRequest**: `elaprTmpltId`\*, `aprvrUuid?`, `atrzSeComCd?`, `atrzSeqNo?`
### 6-4. 검토인력 (보조)
| 기능 | Method | URL | 비고 |
| ------------------ | ------ | --- | --------------------------- |
| 검토인력 목록 조회 | TBD | TBD | 사용자 서비스 또는 별도 API |
### 6-5. Backend ↔ 화면 필드 매핑 (구 SPEC 대비)
| 구 SPEC 필드 | 백엔드 필드 |
| ----------------- | ---------------------------------------------------- |
| `elctAtrzTmpltId` | `elaprTmpltId` |
| `atrzTmpltNm` | `elaprTmpltNm` |
| `docSeNm` | `docSeComNm` |
| `atrzLineId` | `aplnId` |
| `atrzSeNm` | `atrzSeComNm` |
| `atrzUserNm` | (없음) `aprvrUuid` + 사용자 조회/선택 시 로컬 표시명 |
---
## 7. UI 구현 가이드
### 7-1. 기술 스택·원칙
- **공통 컴포넌트**: `@common/components`
- **테이블**: `@tanstack/react-table` + 인라인 편집 셀 (`Select`, `Input`, `Button`)
- **드래그**: `@dnd-kit` — B-2 행 정렬 (POC 필수)
- **데이터**: React Query — `@/entities/elapr` hooks
- **상태**: 선택된 `elaprTmpltId`, `editingRowId` (단일 행 편집 잠금), B-2 행 배열(dirty·신규·`aprvrNm`·수정 전 스냅샷)
- **모의 데이터**: API 연동 전 `@faker-js/faker`
### 7-2. 모듈·파일 배치(제안)
| 구분 | 경로 |
| ------ | -------------------------------------------------------------------------------------- |
| 라우트 | `apps/as/app/(with-base-layout)/approval-line-management/page.tsx` |
| 페이지 | `apps/as/src/pages/approval-line-management/` (Public API: page만 export) |
| UI | `.../ui/elapr-tmplt-list.tsx`, `elapr-tmplt-form-dialog.tsx`, `elapr-apln-grid.tsx` 등 |
| model | `.../model/columns.tsx`, `apln-row-types.ts`, `mock-reviewers.ts` |
| lib | `.../lib/use-apln-rows.ts`, `validators.ts`, `use-dnd-sensors.ts` |
| 엔티티 | `apps/as/src/entities/elapr/` |
### 7-3. 영역별 UI 매핑
| 영역 | 주요 UI |
| ------ | -------------------------------------------------------------------------------------------- |
| A | DataTable + 행 선택 + 서식 등록/수정 다이얼로그 |
| B 헤더 | 상태 라벨 + `행 추가`, `결재선 저장` |
| B-2 | 인라인 편집 DataTable + `editingRowId` 단일 행 잠금 + 드래그 핸들 + 행별 수정/저장/삭제/취소 |
| 공통 | 서식 Dialog, 검토인력 선택 Dialog만 |
### 7-4. 데이터 흐름(요약)
- **진입**: `useElaprTmpltPage` → 영역 A. B 비활성.
- **서식 선택**: `useElaprAplnPage({ elaprTmpltId })` → B-2.
- **행 추가**: `editingRowId` 없을 때만 가능 → 신규 행 1건 편집 잠금 → 저장/취소 후 해제.
- **인라인 수정**: [수정]으로 `editingRowId` 설정 → 해당 행만 편집 → `useUpdateElaprApln` 후 해제.
- **드래그**: `editingRowId == null` 일 때만 가능 → 로컬 `atrzSeqNo` 재부여 → 일괄 PUT.
- **삭제**: `useDeleteElaprApln` 또는 신규 행 로컬 제거.
### 7-5. 미확정 / 검증 필요
- 검토인력 API 경로·DTO.
- `@dnd-kit` + react-table 행 인라인 Input 동시 적용 POC.
- 결재자 성명 표시: `aprvrUuid` → 사용자명 조회 API.
- 영역 A·B 페이지네이션·검색 UI 노출 여부.
- 권한(역할별 CRUD).
@@ -1,487 +0,0 @@
# 화면설계서: 체크리스트관리
## 1. 기본 정보
| 항목 | 내용 |
| ---------- | -------------------------------------------- |
| 버전 | 0.1 |
| 프로세스명 | UI설계 |
| 화면명 | 체크리스트관리 |
| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 |
| 메뉴 경로 | 디지털행정지원 > 시스템관리 > 체크리스트관리 |
---
## 2. 화면 목적
업무별·문서별 **점검표**(체크리스트)를 등록·관리하고, **점검항목** 마스터를 유지한다. 상단 좌측에서 점검표를 선택하면 하단 **체크리스트 항목** 영역에 해당 점검표의 검토 구성(계층형 검토내용)을 편집한다. 상단 우측 **점검항목 관리**는 점검표에 연결할 항목 풀을 등록·수정·삭제한다.
---
## 3. 화면 구성
### 3-1. 전체 레이아웃
```
┌───────────────────────────────────────────────────────────────────────────────────────────────┐
│ 페이지 타이틀: 체크리스트관리 브레드크럼: 디지털행정지원 > … > │
├──────────────────────────────────────────────┬────────────────────────────────────────────────┤
│ [영역 A] 점검표 관리 │ [영역 B] 점검항목 관리 │
│ [점검표 추가] │ [점검항목 추가] │
│ ┌────────────────────────────────────────┐ │ ┌──────────────────────────────────────────┐ │
│ │ 점검표일련번호 | 점검표명 | 문서구분 │ │ │ 점검항목코드 | 점검항목명 | 사용여부 │ │
│ │ 업무구분 | 사용여부 | 등록일시 | 관리 │ │ │ 등록일시 | 관리 │ │
│ ├────────────────────────────────────────┤ │ ├──────────────────────────────────────────┤ │
│ │ 1 (선택 행 강조) [관리] [삭제] │ │ │ ITEM_001 요건검토 … [관리][삭제] │ │
│ │ │ │ │ ITEM_002 법령검토 … [관리][삭제] │ │
│ └────────────────────────────────────────┘ │ └──────────────────────────────────────────┘ │
├──────────────────────────────────────────────┴────────────────────────────────────────────────┤
│ [영역 C] 체크리스트 항목 [미리보기] [추가] [저장] │
│ ┌─────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 점검항목 ▼ | 주요 검토내용 | 사용 여부 | 구분 | 관리 │ │
│ │ L1 [요건검토 ▼] | (입력) | ○예 ○아니오 | 1단계 | [하위추가]│ │
│ │ L2 [요건검토 ▼] | (입력) | ○예 ○아니오 | 2단계 | [삭제] │ │
│ └─────────────────────────────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────────────────────────────┘
```
### 3-2. 화면 데이터 흐름
```
BEGIN
// 최초 진입
CALL GET /as-checklist-service/api/v1/cklst
DISPLAY 점검표 그리드 (영역 A)
CALL GET /as-checklist-service/api/v1/cklst-artcl
DISPLAY 점검항목 그리드 (영역 B)
DISPLAY 빈 상태 안내 (영역 C) // 점검표 미선택 시 C 비활성
// 영역 A: 점검표 행 선택
ON 영역 A 행 CLICK DO
SET 선택된 점검표 = 행.cklstSn
CALL GET /as-checklist-service/api/v1/cklst-rvw?cklstSn={cklstSn}
// TBD: 점검표 단위 전체 검토 트리 조회 API — 현재는 항목별 by-artcl 조합 또는 클라이언트 병합
DISPLAY 체크리스트 항목 그리드 (영역 C)
END ON
// 영역 A: 점검표 추가
ON [점검표 추가] 버튼 CLICK DO
OPEN 점검표 추가 다이얼로그
ON [저장] DO
VALIDATE cklstNm, taskSeComCd
CALL POST /as-checklist-service/api/v1/cklst
CALL GET /as-checklist-service/api/v1/cklst
CLOSE 다이얼로그
END ON
ON [취소] / [닫기] DO
CLOSE 다이얼로그
END ON
END ON
// 영역 A: 점검표 수정·삭제
ON 영역 A 행 [관리] CLICK DO
OPEN 점검표 수정 다이얼로그 (기존 값 초기화)
ON [저장] DO
CALL PUT /as-checklist-service/api/v1/cklst/{cklstSn}
CALL GET /as-checklist-service/api/v1/cklst
END ON
END ON
ON 영역 A 행 [삭제] CLICK DO
CONFIRM "선택한 점검표를 삭제하시겠습니까?"
CALL DELETE /as-checklist-service/api/v1/cklst/{cklstSn}
IF 삭제 대상 == 선택된 점검표 THEN RESET 영역 C
CALL GET /as-checklist-service/api/v1/cklst
END ON
// 영역 B: 점검항목 추가
ON [점검항목 추가] 버튼 CLICK DO
OPEN 점검항목 추가 다이얼로그
ON [저장] DO
VALIDATE cklstArtclNm
// TBD: cklstSn 필수 — UI에 소속 점검표 선택 없음. 백엔드 정책 확인
CALL POST /as-checklist-service/api/v1/cklst-artcl
CALL GET /as-checklist-service/api/v1/cklst-artcl
CLOSE 다이얼로그
END ON
END ON
// 영역 B: 점검항목 수정·삭제
ON 영역 B 행 [관리] CLICK DO
OPEN 점검항목 수정 다이얼로그
ON [저장] DO
CALL PUT /as-checklist-service/api/v1/cklst-artcl/{cklstArtclSn}/{cklstSn}
END ON
END ON
ON 영역 B 행 [삭제] CLICK DO
CONFIRM "선택한 점검항목을 삭제하시겠습니까?"
CALL DELETE /as-checklist-service/api/v1/cklst-artcl/{cklstArtclSn}/{cklstSn}
CALL GET /as-checklist-service/api/v1/cklst-artcl
END ON
// 영역 C: 검토 행 추가
ON [추가] 버튼 CLICK DO
VALIDATE 선택된 점검표 존재
ADD 영역 C 그리드 맨 아래 편집 가능 행 (1단계, rvwCnLvNo=1)
END ON
// 영역 C: 하위 검토 추가
ON 행 [하위추가] CLICK DO
ADD 부모행 하위 편집 행 (2단계, upCklstRvwSn=부모.cklstRvwSn, rvwCnLvNo=2)
END ON
// 영역 C: 일괄 저장
ON [저장] 버튼 CLICK DO
VALIDATE 선택된 점검표 존재
FOR EACH 신규·변경된 검토 행 DO
IF 신규 THEN
CALL POST /as-checklist-service/api/v1/cklst-rvw
ELSE
CALL PUT /as-checklist-service/api/v1/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}
END IF
END FOR
CALL GET /as-checklist-service/api/v1/cklst-rvw?cklstSn={cklstSn}
TOAST "체크리스트 항목이 저장되었습니다."
END ON
// 영역 C: 행 삭제
ON 행 [삭제] CLICK DO
CONFIRM "선택한 항목을 삭제하시겠습니까?"
IF 서버 저장된 행 THEN
CALL DELETE /as-checklist-service/api/v1/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}
END IF
REMOVE 그리드 행 (미저장 행은 API 호출 없이 제거)
END ON
// 영역 C: 미리보기
ON [미리보기] 버튼 CLICK DO
OPEN 체크리스트 미리보기 다이얼로그 (읽기 전용 트리)
// TBD: 미리보기 UI·API
END ON
END
```
---
## 4. 영역 A: 점검표 관리
선택된 점검표가 영역 C의 편집 대상이 된다. 행 클릭으로 선택 상태를 표시한다.
### 4-1. 헤더 / 툴바
| 항목 | 내용 |
| ----------- | ------------------------------------------ |
| 섹션 타이틀 | 점검표 관리 |
| 우측 버튼 | **[점검표 추가]** — 점검표 등록 다이얼로그 |
### 4-2. 그리드 컬럼
| 컬럼명 | Field | 타입 | 비고 |
| ---------------- | ------------- | ------ | ---------------------------------------------------- |
| 점검표일련번호 | `cklstSn` | number | PK |
| 점검표명 | `cklstNm` | string | |
| 문서구분공통코드 | `docSeComCd` | string | 코드명 표시 — `ASDOCSECD` 등 공통코드 (TBD 상위코드) |
| 업무구분공통코드 | `taskSeComCd` | string | RE/SF/SP 등 — 공통코드 (TBD 상위코드) |
| 사용여부 | `useYn` | string | `Y`→예, `N`→아니오 |
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
| 관리 | — | — | 행별 **[관리] · [삭제]** |
- 행 클릭 시: `cklstSn` 선택 → 영역 C 헤더·그리드 갱신.
- 페이지네이션: Spring `Pageable` (`page`, `size`, `sort`).
### 4-3. 버튼
| 버튼명 | 위치 | 동작 |
| ----------- | -------------- | ------------------------------------------------- |
| 점검표 추가 | 섹션 우측 상단 | 점검표 추가 다이얼로그 오픈 |
| 관리 | 행별 | 점검표 수정 다이얼로그 (기존 값 초기화) |
| 삭제 | 행별 | 확인 후 삭제. 선택 중인 점검표이면 영역 C 초기화. |
### 4-4. 다이얼로그: 점검표 추가 / 수정
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| -------- | ---------------- | ---- | ------ | ----------------- | ------------------------------------------------ |
| 점검표명 | Text | Y | — | `cklstNm` | placeholder: "예: 설치인가 체크리스트" |
| 문서구분 | Select(공통코드) | N | — | `docSeComCd` | 예: FNRS, INSTL, PWRK, DESIGN |
| 업무구분 | Select(공통코드) | Y | — | `taskSeComCd` | 예: RE(재이용수), SF(하수처리시설), SP(하수관로) |
| 사용여부 | Select | N | Y | `useYn` | Y/N, UI 표시: 예/아니오 |
| 버튼명 | 동작 |
| ------ | -------------------------------------- |
| 저장 | POST(등록) 또는 PUT(수정) 후 목록 갱신 |
| 취소 | 다이얼로그 닫기, 입력값 폐기 |
| 닫기 | 다이얼로그 닫기 |
---
## 5. 영역 B: 점검항목 관리
점검표 하단(영역 C)에서 선택할 **점검항목** 마스터를 관리한다.
### 5-1. 헤더 / 툴바
| 항목 | 내용 |
| ----------- | ---------------------------------------------- |
| 섹션 타이틀 | 점검항목 관리 |
| 우측 버튼 | **[점검항목 추가]** — 점검항목 등록 다이얼로그 |
### 5-2. 그리드 컬럼
| 컬럼명 | Field | 타입 | 비고 |
| ------------ | -------------- | ------ | ------------------------------------- |
| 점검항목코드 | `cklstArtclSn` | number | UI mock `ITEM_001` 형식 표시 여부 TBD |
| 점검항목명 | `cklstArtclNm` | string | |
| 사용여부 | `useYn` | string | `Y`→예, `N`→아니오 |
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
| 관리 | — | — | 행별 **[관리] · [삭제]** |
> **TBD**: 백엔드 `CklstArtcl`은 복합 PK(`cklstArtclSn`, `cklstSn`)이며 등록 시 `cklstSn` 필수. UI 다이얼로그에는 `cklstSn` 입력이 없음 — 소속 점검표 지정 방식·전역 마스터 여부 백엔드 정책 확인.
### 5-3. 다이얼로그: 점검항목 추가 / 수정
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| ---------- | --------- | ---- | ------ | ----------------- | --------------------------- |
| 점검항목명 | Text | Y | — | `cklstArtclNm` | placeholder: "예: 법령검토" |
| 사용여부 | Select | N | Y | `useYn` | Y/N |
| 버튼명 | 동작 |
| ------ | -------------------------------------- |
| 저장 | POST(등록) 또는 PUT(수정) 후 목록 갱신 |
| 취소 | 다이얼로그 닫기 |
| 닫기 | 다이얼로그 닫기 |
---
## 6. 영역 C: 체크리스트 항목 (선택된 점검표)
영역 A에서 선택된 `cklstSn` 기준으로 **점검표검토**(계층형 검토내용)를 구성한다.
### 6-1. 헤더 / 툴바
| 항목 | 내용 |
| ----------- | -------------------------------------------------------------------- |
| 섹션 타이틀 | 체크리스트 항목 (선택된 점검표: {cklstNm}) |
| 우측 버튼 | **[미리보기]** · **[추가]** · **[저장]** — 점검표 미선택 시 C 비활성 |
### 6-2. 그리드 컬럼
| 컬럼명 | Field | 입력 UI | 비고 |
| ------------- | -------------- | ------------------------------------- | ------------------------------------ |
| 점검항목 | `cklstArtclSn` | Select (영역 B 항목 목록) | L1/L2 접두 표시, `cklstArtclNm` 라벨 |
| 주요 검토내용 | `rvwCn` | Text / Textarea | 최대 4000자 |
| 사용 여부 | `useYn` | Radio (예/아니오) | `Y` / `N` |
| 구분 | `rvwCnLvNo` | 읽기 전용 | 1→1단계, 2→2단계, 3→3단계 |
| 관리 | — | **[하위추가]** (1·2단계, 저장 후) · **[삭제]** | 3단계 행은 [하위추가] 미표시 |
#### 계층 정책
| 항목 | 내용 |
| -------------- | -------------------------------------------------------------------- |
| 최대 깊이 | 3단계 (`rvwCnLvNo`: 1, 2, 3) |
| 상위-하위 연결 | `upCklstRvwSn` — 2단계 행은 부모 1단계 `cklstRvwSn` 참조 |
| 정렬 | `sortSeq` — 동일 레벨 내 순서 (TBD: UI 드래그 정렬 여부) |
| 체크 사용 | `bchckUseYn` — 미리보기·실사용 화면 체크박스 노출 여부 (TBD UI 반영) |
### 6-3. 버튼
| 버튼명 | 동작 |
| -------- | ---------------------------------------------------- |
| 추가 | 1단계 검토 행 1건 추가 (편집 가능) |
| 저장 | 변경·신규 검토 행 일괄 POST/PUT (일괄 저장 API 없음) |
| 미리보기 | 선택 점검표 체크리스트 읽기 전용 미리보기 (TBD) |
| 하위추가 | 선택 1·2단계 행 아래 하위 단계(2·3단계) 자식 행 추가 |
| 삭제 | 확인 후 DELETE 또는 미저장 행 로컬 제거 |
---
## 7. API/DTO 명세
### 7-1. 공통
| 항목 | 내용 |
| ------------- | ------------------------------------------------------------- |
| 서비스 | `as-checklist-service` |
| Base prefix | `/as-checklist-service/api/v1` (게이트웨이 프록시 경유) |
| 인증 | JWT (`Authorization` 헤더), `X-Site-Id`, `token-id` |
| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse<T>` |
| 엔티티 | `@/entities/cklst` (`cklstMngService`, `cklstArtclService`, `cklstRvwService`) |
### 7-2. 점검표 (영역 A) — `cklst`
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
| --------- | ------ | ------------------ | --------------------------------- | ------------------ | -------------------------- |
| 목록 조회 | GET | `/cklst` | `CklstMngSearchParams` + Pageable | `Page<CklstMng>` | `cklstMngService.findPage` |
| 단건 조회 | GET | `/cklst/{cklstSn}` | — | `CklstMng` | `cklstMngService.findById` |
| 등록 | POST | `/cklst` | `CklstMngSaveRequest` | `number` (cklstSn) | `cklstMngService.create` |
| 수정 | PUT | `/cklst/{cklstSn}` | `CklstMngSaveRequest` | `number` (cklstSn) | `cklstMngService.update` |
| 삭제 | DELETE | `/cklst/{cklstSn}` | — | `number` (cklstSn) | `cklstMngService.remove` |
**CklstMng (응답)**
| Field | 타입 | 설명 |
| ------------- | ------- | ---------------- |
| `cklstSn` | number | 점검표일련번호 |
| `cklstNm` | string | 점검표명 |
| `taskSeComCd` | string | 업무구분공통코드 |
| `useYn` | string? | 사용여부 |
| `docSeComCd` | string? | 문서구분공통코드 |
| `rgtr` | string? | 등록자 UUID |
| `rgtrNm` | string? | 등록자명 |
| `mdfr` | string? | 수정자 UUID |
| `regDt` | string? | 등록일시 |
| `mdfcnDt` | string? | 수정일시 |
**CklstMngSaveRequest**: `cklstNm`\*, `taskSeComCd`\*, `useYn?`, `docSeComCd?`
**CklstMngSearchParams**: `cklstNm?`, `taskSeComCd?`, `useYn?`, `docSeComCd?` + `page`, `size`, `sort`
**React Query hooks**: `useCklstMngPage`, `useCklstMng`, `useCreateCklstMng`, `useUpdateCklstMng`, `useDeleteCklstMng`
### 7-3. 점검표항목 (영역 B) — `cklst-artcl`
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
| ------------------ | ------ | --------------------------------------- | ----------------------------------- | ------------------ | --------------------------------- |
| 목록 조회 (페이징) | GET | `/cklst-artcl` | `CklstArtclSearchParams` + Pageable | `Page<CklstArtcl>` | `cklstArtclService.findPage` |
| 점검표별 전체 목록 | GET | `/cklst-artcl/by-cklst/{cklstSn}` | — | `CklstArtcl[]` | `cklstArtclService.findByCklstSn` |
| 단건 조회 | GET | `/cklst-artcl/{cklstArtclSn}/{cklstSn}` | — | `CklstArtcl` | `cklstArtclService.findById` |
| 등록 | POST | `/cklst-artcl` | `CklstArtclSaveRequest` | `CklstArtclId` | `cklstArtclService.create` |
| 수정 | PUT | `/cklst-artcl/{cklstArtclSn}/{cklstSn}` | `CklstArtclUpdateRequest` | `CklstArtclId` | `cklstArtclService.update` |
| 삭제 | DELETE | `/cklst-artcl/{cklstArtclSn}/{cklstSn}` | — | `CklstArtclId` | `cklstArtclService.remove` |
**CklstArtcl (응답)**
| Field | 타입 | 설명 |
| ----------------- | ------- | ------------------ |
| `cklstArtclSn` | number | 점검표항목일련번호 |
| `cklstSn` | number | 점검표일련번호 |
| `cklstArtclNm` | string? | 점검표항목명 |
| `useYn` | string? | 사용여부 |
| `rgtr` / `rgtrNm` | string? | 등록자 |
| `regDt` | string? | 등록일시 |
**CklstArtclSaveRequest**: `cklstSn`\*, `cklstArtclNm?`, `useYn?`
**CklstArtclUpdateRequest**: `cklstArtclNm?`, `useYn?`
**CklstArtclId**: `{ cklstArtclSn: number; cklstSn: number }`
**React Query hooks**: `useCklstArtclPage`, `useCklstArtclList`, `useCklstArtcl`, `useCreateCklstArtcl`, `useUpdateCklstArtcl`, `useDeleteCklstArtcl`
### 7-4. 점검표검토 (영역 C) — `cklst-rvw`
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
| --------------------- | ------ | -------------------------------------------------- | ------------------------------ | ---------------- | ------------------------------- |
| 목록 조회 (페이징) | GET | `/cklst-rvw` | `CklstRvwSearchParams` + Pageable | `Page<CklstRvw>` | `cklstRvwService.findPage` |
| 항목별 검토 전체 목록 | GET | `/cklst-rvw/by-artcl/{cklstSn}/{cklstArtclSn}` | — | `CklstRvw[]` | `cklstRvwService.findByArtcl` |
| 단건 조회 | GET | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | — | `CklstRvw` | `cklstRvwService.findById` |
| 등록 | POST | `/cklst-rvw` | `CklstRvwSaveRequest` | `CklstRvwId` | `cklstRvwService.create` |
| 수정 | PUT | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | `CklstRvwUpdateRequest` | `CklstRvwId` | `cklstRvwService.update` |
| 삭제 | DELETE | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | — | `CklstRvwId` | `cklstRvwService.remove` |
**CklstRvw (응답)**
| Field | 타입 | 설명 |
| -------------- | ------- | ---------------------- |
| `cklstRvwSn` | number | 점검표검토일련번호 |
| `cklstArtclSn` | number | 점검표항목일련번호 |
| `cklstSn` | number | 점검표일련번호 |
| `upCklstRvwSn` | number? | 상위점검표검토일련번호 |
| `rvwCnLvNo` | number? | 검토내용레벨번호 |
| `rvwCn` | string? | 검토내용 |
| `bchckUseYn` | string? | 체크사용여부 |
| `sortSeq` | number? | 정렬순서 |
| `useYn` | string? | 사용여부 |
| `regDt` | string? | 등록일시 |
**CklstRvwSaveRequest**: `cklstArtclSn`\*, `cklstSn`\*, `upCklstRvwSn?`, `rvwCnLvNo?`, `rvwCn?`, `bchckUseYn?`, `sortSeq?`, `useYn?`
**CklstRvwUpdateRequest**: `upCklstRvwSn?`, `rvwCnLvNo?`, `rvwCn?`, `bchckUseYn?`, `sortSeq?`, `useYn?`
**CklstRvwSearchParams**: `cklstSn?`, `cklstArtclSn?`, `useYn?` + `page`, `size`, `sort`
**CklstRvwId**: `{ cklstRvwSn: number; cklstArtclSn: number; cklstSn: number }`
**React Query hooks**: `useCklstRvwPage`, `useCklstRvwByArtcl`, `useCklstRvw`, `useCreateCklstRvw`, `useUpdateCklstRvw`, `useDeleteCklstRvw`
### 7-5. 공통코드 (보조)
| UI 필드 | 코드 그룹 (TBD) | 예시 값 |
| -------- | --------------- | -------------------------------------------- |
| 문서구분 | `ASDOCSECD` 등 | FNRS, INSTL, PWRK, DESIGN |
| 업무구분 | TBD | RE(재이용수), SF(하수처리시설), SP(하수관로) |
| 사용여부 | 고정 | Y, N |
공통코드 조회: `@/entities/code` — `codeService`, `CodeSelect`
---
## 8. UI 구현 가이드
### 8-1. 기술 스택·원칙
- **공통 컴포넌트**: `@common/components` (`Dialog`, `Button`, `Input`, `Select`, `RadioGroup` 등)
- **테이블**: `@tanstack/react-table` + `useDataTable`
- **데이터**: React Query — `@/entities/cklst` hooks (`useCklstRvwPage` 등 영역 C 포함)
- **공통코드**: `@/entities/code` — `CodeSelect`, `findCodeLabel`
- **상태**: 선택된 `cklstSn`, 영역 C 검토 행 배열(신규·dirty·계층 트리)
- **모의 데이터**: API 연동 전 `@faker-js/faker`
### 8-2. 모듈·파일 배치(제안)
| 구분 | 경로 |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 라우트 | `apps/as/app/(with-base-layout)/checklist-management/page.tsx` |
| 페이지 | `apps/as/src/pages/checklist-management/` (Public API: page만 export) |
| UI | `.../ui/cklst-mng-list.tsx`, `cklst-mng-form-dialog.tsx`, `cklst-artcl-list.tsx`, `cklst-artcl-form-dialog.tsx`, `cklst-rvw-grid.tsx`, `cklst-preview-dialog.tsx` |
| model | `.../model/cklst-mng-columns.tsx`, `cklst-artcl-columns.tsx`, `cklst-rvw-row-types.ts` |
| lib | `.../lib/use-cklst-rvw-rows.ts`, `validators.ts`, `build-rvw-tree.ts` |
| 엔티티 | `apps/as/src/entities/cklst/` (`cklstMng`, `cklstArtcl`, `cklstRvw` 서비스·hooks) |
| 페이지 | `apps/as/src/pages/checklist-management/` — `/checklist-management` |
| 문서 | `apps/as/docs/checklist-management/SPEC.md` |
### 8-3. 영역별 UI 매핑
| 영역 | 주요 UI |
| ---- | ---------------------------------------------------------------------------- |
| A | DataTable + 행 선택 + 점검표 추가/수정 Dialog |
| B | DataTable + 점검항목 추가/수정 Dialog |
| C | 계층형 편집 그리드(1·2단계) + [추가][저장][미리보기] + 행별 [하위추가][삭제] |
| 공통 | `CodeSelect` (문서구분·업무구분), 사용여부 Y/N Select/Radio |
### 8-4. 다이얼로그·라우팅
| 다이얼로그명 | 트리거 | API 연동 |
| ------------------- | --------------- | ------------------------- |
| 점검표 추가 | [점검표 추가] | `useCreateCklstMng` |
| 점검표 수정 | 행 [관리] | `useUpdateCklstMng` |
| 점검표 삭제 확인 | 행 [삭제] | `useDeleteCklstMng` |
| 점검항목 추가 | [점검항목 추가] | `useCreateCklstArtcl` |
| 점검항목 수정 | 행 [관리] | `useUpdateCklstArtcl` |
| 체크리스트 미리보기 | [미리보기] | TBD — 읽기 전용 트리 렌더 |
라우트: `/checklist-management` (메뉴 URL은 CMS 등록값과 일치시킴)
### 8-5. 데이터 흐름(요약)
- **진입**: `useCklstMngPage` → 영역 A, `useCklstArtclPage` → 영역 B. C 비활성.
- **점검표 선택**: `cklstSn` 설정 → 영역 C 활성 → `useCklstRvwPage({ cklstSn })` → 트리 그리드 표시.
- **점검표 CRUD**: Dialog → `useCreateCklstMng` / `useUpdateCklstMng` / `useDeleteCklstMng`.
- **점검항목 CRUD**: Dialog → `useCreateCklstArtcl` / `useUpdateCklstArtcl` / `useDeleteCklstArtcl`.
- **검토 저장**: [저장] → 신규 POST / 기존 PUT 순차 호출 (일괄 API 없음).
- **영역 C 점검항목 Select**: `useCklstArtclList(cklstSn)` 또는 영역 B 전체 목록과 교차 — **TBD**.
### 8-6. 미확정 / 검증 필요
- [ ] 영역 B 점검항목 등록 시 `cklstSn` 필수 vs UI 무소속 마스터 — 백엔드·기획 확인
- [ ] `점검항목코드`(ITEM_001) 표시 형식 vs `cklstArtclSn` 숫자 PK
- [ ] 영역 C 전체 검토 트리 조회: `cklstSn` 단위 일괄 API 부재 — `by-artcl` 다건 호출 또는 백엔드 확장
- [x] `@/entities/cklst` — `cklstRvwService`·hooks 구현
- [x] `pages/checklist-management` — 영역 A·B·C UI 구현 (`/checklist-management`)
- [ ] 문서구분·업무구분 공통코드 상위코드 ID (`ASDOCSECD` 등)
- [ ] [미리보기] 다이얼로그 UI·데이터 소스
- [ ] `bchckUseYn` UI 반영 여부
- [ ] 영역 A·B 페이지네이션·검색 UI 노출 여부
- [ ] 권한(역할별 CRUD)
-385
View File
@@ -1,385 +0,0 @@
# 화면설계서: 코드관리
## 1. 기본 정보
| 항목 | 내용 |
| ---------- | -------------------------------------------- |
| 버전 | 0.1 |
| 프로세스명 | UI설계 |
| 화면명 | 코드관리 |
| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 |
| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 코드관리 |
---
## 2. 화면 목적
설치인가 등 AS 업무에서 참조하는 **공종구분코드**(공종코드)와 **회계구분코드**(회계코드)를 한 화면에서 조회·등록·수정·삭제한다. 좌측 **공종코드 관리**와 우측 **회계코드 관리**는 독립된 목록·CRUD 영역이며, 각 섹션은 자체 API(`cnstp-se`, `acntg-se`)와 연동한다.
---
## 3. 화면 구성
### 3-1. 전체 레이아웃
```
┌───────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 페이지 타이틀: 코드관리 브레드크럼: 디지털행정지원 > 사이트관리 > 코드관리 │
├──────────────────────────────────────────────────┬────────────────────────────────────────────────┤
│ [영역 A] 공종코드 관리 │ [영역 B] 회계코드 관리 │
│ [등록] │ [등록] │
│ ┌────────────────────────────────────────────┐ │ ┌──────────────────────────────────────────┐ │
│ │ 코드 | 코드명 | 설명 | 사용여부 | 관리 │ │ │ 코드 | 코드명 | 설명 | 사용여부 | 관리 │ │
│ ├────────────────────────────────────────────┤ │ ├──────────────────────────────────────────┤ │
│ │ WC001 공공하수처리시설 … 사용 [수정][삭제]│ │ │ AC101 시설비 … 사용 [수정][삭제] │ │
│ │ WC002 하수관로정비 … 사용 [수정][삭제]│ │ │ AC102 용역비 … 사용 [수정][삭제] │ │
│ │ WC003 재이용시설 … 사용 [수정][삭제]│ │ │ AC103 운영비 … 미사용 [수정][삭제] │ │
│ └────────────────────────────────────────────┘ │ └──────────────────────────────────────────┘ │
│ « 1 2 3 … » │ « 1 2 3 … » │
└──────────────────────────────────────────────────┴────────────────────────────────────────────────┘
```
### 3-2. 화면 데이터 흐름
```
BEGIN
// 최초 진입 — 좌·우 독립 조회
CALL cnstpSeMngService.findAll({ page, size })
DISPLAY 공종코드 그리드 (영역 A)
CALL acntgSeMngService.findAll({ page, size })
DISPLAY 회계코드 그리드 (영역 B)
// 영역 A: 공종코드 등록
ON 영역 A [등록] 버튼 CLICK DO
OPEN 공종코드 등록 다이얼로그
ON [저장] DO
VALIDATE cnstpSeCd
CALL cnstpSeMngService.save(CnstpSeMngSaveRequestDto)
CALL cnstpSeMngService.findAll
CLOSE 다이얼로그
END ON
ON [취소] / [닫기] DO
CLOSE 다이얼로그
END ON
END ON
// 영역 A: 공종코드 수정
ON 영역 A 행 [수정] CLICK DO
CALL cnstpSeMngService.findById(cnstpSeCd)
OPEN 공종코드 수정 다이얼로그 (기존 값 초기화)
ON [저장] DO
CALL cnstpSeMngService.update(cnstpSeCd, CnstpSeMngSaveRequestDto)
CALL cnstpSeMngService.findAll
CLOSE 다이얼로그
END ON
END ON
// 영역 A: 공종코드 삭제
ON 영역 A 행 [삭제] CLICK DO
CONFIRM "선택한 공종코드를 삭제하시겠습니까?"
CALL cnstpSeMngService.delete(cnstpSeCd)
CALL cnstpSeMngService.findAll
END ON
// 영역 B: 회계코드 등록
ON 영역 B [등록] 버튼 CLICK DO
OPEN 회계코드 등록 다이얼로그
ON [저장] DO
VALIDATE acntgSeCd, acntgSeCdNm
CALL acntgSeMngService.save(AcntgSeMngSaveRequestDto)
CALL acntgSeMngService.findAll
CLOSE 다이얼로그
END ON
ON [취소] / [닫기] DO
CLOSE 다이얼로그
END ON
END ON
// 영역 B: 회계코드 수정
ON 영역 B 행 [수정] CLICK DO
CALL acntgSeMngService.findById(acntgSeCd)
OPEN 회계코드 수정 다이얼로그 (기존 값 초기화)
ON [저장] DO
CALL acntgSeMngService.update(acntgSeCd, AcntgSeMngSaveRequestDto)
CALL acntgSeMngService.findAll
CLOSE 다이얼로그
END ON
END ON
// 영역 B: 회계코드 삭제
ON 영역 B 행 [삭제] CLICK DO
CONFIRM "선택한 회계코드를 삭제하시겠습니까?"
CALL acntgSeMngService.delete(acntgSeCd)
CALL acntgSeMngService.findAll
END ON
END
```
---
## 4. 영역 A: 공종코드 관리
백엔드 **공종구분코드**(`CnstpSeMng`) CRUD. 계층 구조(`upCnstpCd`, `cdLvNo`)를 지원하나, 본 화면은 **평면 목록**으로 표시.
### 4-1. 헤더 / 툴바
| 항목 | 내용 |
| ----------- | ------------------------------------- |
| 섹션 타이틀 | 공종코드 관리 |
| 우측 버튼 | **[등록]** — 공종코드 등록 다이얼로그 |
### 4-2. 그리드 컬럼
| 컬럼명 | Field | 타입 | 비고 |
| -------- | ------------- | ------ | ------------------------ |
| 코드 | `cnstpSeCd` | string | PK, 최대 10자 |
| 코드명 | `cnstpSeCdNm` | string | 최대 100자 |
| 설명 | `expln` | string | 최대 1000자, 선택 |
| 사용여부 | `useYn` | string | `Y`→사용, `N`→미사용 |
| 관리 | — | — | 행별 **[수정] · [삭제]** |
- 행 클릭 시 별도 상세 이동 없음. 수정은 **[수정]** 버튼으로 다이얼로그 진입.
- 페이지네이션: Spring `Pageable` (`page`, `size`, `sort`) — **UI 노출**. 기본 `size: 10`, `@common/components` 공통 페이지네이션 컴포넌트 사용.
### 4-3. 버튼
| 버튼명 | 위치 | 동작 |
| ------ | -------------- | ---------------------------------------- |
| 등록 | 섹션 우측 상단 | 공종코드 등록 다이얼로그 오픈 |
| 수정 | 행별 | 상세 조회 후 수정 다이얼로그 (값 초기화) |
| 삭제 | 행별 | 확인 후 삭제, 목록 갱신 |
### 4-4. 다이얼로그: 공종코드 등록 / 수정
다이얼로그 입력 필드는 **코드 · 코드명 · 설명 · 사용여부** 4개만 노출한다.
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| -------- | --------- | ---- | ------ | ----------------- | ------------------------------- |
| 코드 | Input | Y | — | `cnstpSeCd` | 등록 시 입력, 수정 시 읽기 전용 |
| 코드명 | Input | N | — | `cnstpSeCdNm` | 최대 100자 |
| 설명 | Input | N | — | `expln` | 최대 1000자 |
| 사용여부 | Radio | N | Y | `useYn` | `Y`→사용, `N`→미사용 |
> UI에 노출하지 않는 API 필드(`upCnstpCd`, `cdLvNo`): 등록 시 `cdLvNo: 1` 등 기본값 전송, 수정 시 `findById` 응답값 유지.
| 버튼명 | 동작 |
| ------ | ------------------------------------------------ |
| 저장 | POST(등록) 또는 PUT(수정) 후 `findAll` 목록 갱신 |
| 취소 | 다이얼로그 닫기, 입력값 폐기 |
| 닫기 | 다이얼로그 닫기 |
---
## 5. 영역 B: 회계코드 관리
백엔드 **회계구분관리**(`AcntgSeMng`) CRUD. 영역 A와 동일한 **평면 목록** UI 패턴을 따른다.
### 5-1. 헤더 / 툴바
| 항목 | 내용 |
| ----------- | ------------------------------------- |
| 섹션 타이틀 | 회계코드 관리 |
| 우측 버튼 | **[등록]** — 회계코드 등록 다이얼로그 |
### 5-2. 그리드 컬럼
| 컬럼명 | Field | 타입 | 비고 |
| -------- | ------------- | ------ | ------------------------ |
| 코드 | `acntgSeCd` | string | PK, 최대 10자 |
| 코드명 | `acntgSeCdNm` | string | 최대 100자 |
| 설명 | `expln` | string | 최대 1000자, 선택 |
| 사용여부 | `useYn` | string | `Y`→사용, `N`→미사용 |
| 관리 | — | — | 행별 **[수정] · [삭제]** |
- 페이지네이션: 영역 A와 동일 — **UI 노출**, 기본 `size: 10`.
### 5-3. 버튼
| 버튼명 | 위치 | 동작 |
| ------ | -------------- | ---------------------------------------- |
| 등록 | 섹션 우측 상단 | 회계코드 등록 다이얼로그 오픈 |
| 수정 | 행별 | 상세 조회 후 수정 다이얼로그 (값 초기화) |
| 삭제 | 행별 | 확인 후 삭제, 목록 갱신 |
### 5-4. 다이얼로그: 회계코드 등록 / 수정
다이얼로그 입력 필드는 **코드 · 코드명 · 설명 · 사용여부** 4개만 노출한다. (영역 A와 동일 패턴)
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| -------- | --------- | ---- | ------ | ----------------- | ------------------------------- |
| 코드 | Input | Y | — | `acntgSeCd` | 등록 시 입력, 수정 시 읽기 전용 |
| 코드명 | Input | Y | — | `acntgSeCdNm` | 최대 100자 |
| 설명 | Input | N | — | `expln` | 최대 1000자 |
| 사용여부 | Radio | N | Y | `useYn` | `Y`→사용, `N`→미사용 |
> UI에 노출하지 않는 API 필드(`upAcntgSeCd`, `cdLvNo`): 등록 시 `cdLvNo: 1` 등 기본값 전송, 수정 시 `findById` 응답값 유지.
| 버튼명 | 동작 |
| ------ | ------------------------------------------------ |
| 저장 | POST(등록) 또는 PUT(수정) 후 `findAll` 목록 갱신 |
| 취소 | 다이얼로그 닫기, 입력값 폐기 |
| 닫기 | 다이얼로그 닫기 |
---
## 6. API/DTO 명세
### 6-1. 공통
| 항목 | 내용 |
| ----------- | ---------------------------------------------------------------------------------- |
| 서비스 | `as-service` |
| Base prefix | `/as-service/api/v1` (`ENDPOINT.AS_COMMON_SERVICE`) |
| 인증 | JWT (`Authorization` 헤더), `X-Site-Id`, `token-id` |
| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse<T>` |
| 엔티티 | `@/entities/cnstp` (`cnstpSeMngService`), `@/entities/acntg` (`acntgSeMngService`) |
**부가 API (본 화면 목록 외 참조용)**
| 리소스 | Method | URL | Response | 용도 |
| ------------ | ------ | --------------------- | --------------------- | --------------------------------- |
| 공종구분코드 | GET | `/cnstp-se/list/tree` | `CnstpSeMngTreeDto[]` | 계층 트리 (타 화면·상위코드 선택) |
| 회계구분관리 | GET | `/acntg-se/list/tree` | `AcntgSeMngTreeDto[]` | 계층 트리 (타 화면·상위코드 선택) |
### 6-2. 공종구분코드 (영역 A) — `cnstp-se`
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
| --------- | ------ | ----------------------- | -------------------------------- | ----------------------------- | ---------------------------- |
| 목록 조회 | GET | `/cnstp-se` | `CnstpSeMngSearchDto` + Pageable | `Page<CnstpSeMngResponseDto>` | `cnstpSeMngService.findAll` |
| 트리 조회 | GET | `/cnstp-se/list/tree` | — | `CnstpSeMngTreeDto[]` | `cnstpSeMngService.findTree` |
| 상세 조회 | GET | `/cnstp-se/{cnstpSeCd}` | — | `CnstpSeMngResponseDto` | `cnstpSeMngService.findById` |
| 등록 | POST | `/cnstp-se` | `CnstpSeMngSaveRequestDto` | `string` (cnstpSeCd) | `cnstpSeMngService.save` |
| 수정 | PUT | `/cnstp-se/{cnstpSeCd}` | `CnstpSeMngSaveRequestDto` | `string` (cnstpSeCd) | `cnstpSeMngService.update` |
| 삭제 | DELETE | `/cnstp-se/{cnstpSeCd}` | — | `string` (cnstpSeCd) | `cnstpSeMngService.delete` |
**CnstpSeMngResponseDto**
| Field | 타입 | 설명 |
| ------------- | ------- | ------------------ |
| `cnstpSeCd` | string | 공종구분코드 |
| `cnstpSeCdNm` | string | 공종구분코드명 |
| `upCnstpCd` | string? | 상위공종코드 |
| `cdLvNo` | number | 코드레벨번호 |
| `useYn` | string | 사용여부 |
| `expln` | string? | 설명 (최대 1000자) |
| `rgtr` | string? | 등록자 UUID |
| `rgtrNm` | string? | 등록자명 |
| `mdfr` | string? | 수정자 UUID |
| `regDt` | string? | 등록일시 |
| `mdfcnDt` | string? | 수정일시 |
**CnstpSeMngSaveRequestDto**: `cnstpSeCd`, `cnstpSeCdNm?`, `upCnstpCd?`, `cdLvNo?`, `useYn?`, `expln?`
**CnstpSeMngSearchDto**: `cnstpSeCd?`, `cnstpSeCdNm?`, `useYn?` + `page`, `size`, `sort`
**React Query hooks**: `useCnstpSeMngPage`, `useCnstpSeMngTree`, `useCnstpSeMng`, `useSaveCnstpSeMng`, `useUpdateCnstpSeMng`, `useDeleteCnstpSeMng`
### 6-3. 회계구분관리 (영역 B) — `acntg-se`
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
| --------- | ------ | ----------------------- | -------------------------------- | ----------------------------- | ---------------------------- |
| 목록 조회 | GET | `/acntg-se` | `AcntgSeMngSearchDto` + Pageable | `Page<AcntgSeMngResponseDto>` | `acntgSeMngService.findAll` |
| 트리 조회 | GET | `/acntg-se/list/tree` | — | `AcntgSeMngTreeDto[]` | `acntgSeMngService.findTree` |
| 상세 조회 | GET | `/acntg-se/{acntgSeCd}` | — | `AcntgSeMngResponseDto` | `acntgSeMngService.findById` |
| 등록 | POST | `/acntg-se` | `AcntgSeMngSaveRequestDto` | `string` (acntgSeCd) | `acntgSeMngService.save` |
| 수정 | PUT | `/acntg-se/{acntgSeCd}` | `AcntgSeMngSaveRequestDto` | `string` (acntgSeCd) | `acntgSeMngService.update` |
| 삭제 | DELETE | `/acntg-se/{acntgSeCd}` | — | `string` (acntgSeCd) | `acntgSeMngService.delete` |
**AcntgSeMngResponseDto**
| Field | 타입 | 설명 |
| ------------- | ------- | ------------------ |
| `acntgSeCd` | string | 회계구분코드 |
| `acntgSeCdNm` | string | 회계구분코드명 |
| `upAcntgSeCd` | string? | 상위회계구분코드 |
| `cdLvNo` | number | 코드레벨번호 |
| `useYn` | string | 사용여부 |
| `expln` | string? | 설명 (최대 1000자) |
| `rgtr` | string? | 등록자 UUID |
| `rgtrNm` | string? | 등록자명 |
| `mdfr` | string? | 수정자 UUID |
| `regDt` | string? | 등록일시 |
| `mdfcnDt` | string? | 수정일시 |
**AcntgSeMngSaveRequestDto**: `acntgSeCd`, `acntgSeCdNm`, `upAcntgSeCd?`, `cdLvNo`, `useYn?`, `expln?`
**AcntgSeMngSearchDto**: `acntgSeCd?`, `acntgSeCdNm?`, `useYn?` + `page`, `size`, `sort`
**React Query hooks**: `useAcntgSeMngPage`, `useAcntgSeMngTree`, `useAcntgSeMng`, `useSaveAcntgSeMng`, `useUpdateAcntgSeMng`, `useDeleteAcntgSeMng`
---
## 7. UI 구현 가이드
### 7-1. 기술 스택·원칙
- Next.js App Router, React Query (`@tanstack/react-query`)
- `@common/components` — `Card`, `Button`, `Dialog`, `DataTable` / `useDataTable`
- `@tanstack/react-table` — 컬럼 정의는 `model/*-table-columns.tsx` 분리
- 기존 entity 레이어 재사용: `@/entities/cnstp`, `@/entities/acntg` (service·queries·interface 구현 완료)
- 좌·우 2열 레이아웃: `grid grid-cols-1 lg:grid-cols-2 gap-4` (모바일 1열 스택)
- 삭제 전 `confirm` / Toast 피드백 — 프로젝트 공통 패턴 준수
- 목록 하단 **페이지네이션 UI 노출** — 영역 A·B 각각 독립 page state
- **검색 필터 UI 미제공** — `findAll` 호출 시 `page`, `size`만 전달
- **등록/수정 다이얼로그** — 코드·코드명·설명은 `Input`, 사용여부는 `Radio` (4필드만 노출)
- 상위 코드 삭제 시 **하위 코드 연쇄 삭제 UI·로직 미구현** — 단건 삭제만 수행
### 7-2. 모듈·파일 배치
| 구분 | 경로 (제안) |
| ----------------- | -------------------------------------------------------------------- |
| 라우트 | `apps/as/src/app/(authenticated)/site/code-management/page.tsx` |
| 페이지 컴포넌트 | `apps/as/src/pages/code-management/ui/code-management-page.tsx` |
| 공종 그리드 컬럼 | `apps/as/src/pages/code-management/model/cnstp-se-table-columns.tsx` |
| 회계 그리드 컬럼 | `apps/as/src/pages/code-management/model/acntg-se-table-columns.tsx` |
| 공종 등록/수정 폼 | `apps/as/src/pages/code-management/ui/cnstp-se-form-dialog.tsx` |
| 회계 등록/수정 폼 | `apps/as/src/pages/code-management/ui/acntg-se-form-dialog.tsx` |
| API·타입 (기존) | `@/entities/cnstp`, `@/entities/acntg` |
### 7-3. 영역별 UI 매핑
| 영역 | UI 컴포넌트 | 데이터 소스 | 비고 |
| ------------ | ------------------------------------- | ------------------------------------------- | ------------------------------ |
| A | `CnstpSeSection` + Table + Pagination | `useCnstpSeMngPage({ page, size })` | 독립 page state·mutation |
| B | `AcntgSeSection` + Table + Pagination | `useAcntgSeMngPage({ page, size })` | A와 상호 의존 없음 |
| A 다이얼로그 | `CnstpSeFormDialog` | `useCnstpSeMng`, `useSave/UpdateCnstpSeMng` | Input 4필드 + Radio 사용여부 |
| B 다이얼로그 | `AcntgSeFormDialog` | `useAcntgSeMng`, `useSave/UpdateAcntgSeMng` | Input 4필드 + Radio 사용여부 |
### 7-4. 다이얼로그·라우팅
| 다이얼로그 | 트리거 | 라우팅 |
| ------------- | ---------------- | -------------------- |
| 공종코드 등록 | 영역 A [등록] | URL 변경 없음 (모달) |
| 공종코드 수정 | 영역 A 행 [수정] | URL 변경 없음 (모달) |
| 회계코드 등록 | 영역 B [등록] | URL 변경 없음 (모달) |
| 회계코드 수정 | 영역 B 행 [수정] | URL 변경 없음 (모달) |
### 7-5. 데이터 흐름 (구현 관점)
```
BEGIN
// 페이지 마운트 — 검색 조건 없이 page·size만 전달
const cnstpQuery = useCnstpSeMngPage({ page: 0, size: 20 })
const acntgQuery = useAcntgSeMngPage({ page: 0, size: 20 })
DISPLAY CodeManagementPage
├─ CnstpSeSection(data=cnstpQuery.data, pagination)
└─ AcntgSeSection(data=acntgQuery.data, pagination)
// 페이지 변경
ON pagination page/size CHANGE DO
refetch cnstpQuery | acntgQuery with new page, size
END ON
// mutation 성공 시
ON save/update/delete SUCCESS DO
invalidateQueries cnstp-se | acntg-se baseKey
TOAST "저장되었습니다." | "삭제되었습니다."
END ON
END
```
-498
View File
@@ -1,498 +0,0 @@
# 화면설계서: 전국 행정지원 지도 (대시보드 메인)
## 1. 기본 정보
| 항목 | 내용 |
| ---------- | -------------------------------------------- |
| 버전 | 1.2 |
| 프로세스명 | UI설계 |
| 화면명 | 전국 행정지원 지도 (대시보드 메인) |
| 화면 ID | TBD |
| 화면 유형 | [v] 조회 [ ] 입력 [ ] 수정 [ ] 삭제 [ ] 출력 |
| 메뉴 경로 | 메인 |
---
## 2. 화면 목적
로그인 사용자의 **기관 권한·당해 연도** 기준으로 문서별 진행상태 통계와 행정지원 알림을 한 화면에서 조회한다. 좌측 지도에서 전국 행정지원 현황을 보고, 우측에서 진행상황 카드·알림이력·공지사항을 확인한 뒤 알림 [처리하기]로 연계 업무 화면으로 이동한다.
---
## 3. 화면 구성
### 3-1. 전체 레이아웃
브레드크럼·페이지 타이틀은 스크린샷에 없음. 홈(`/`) 메인 콘텐츠로 구성한다.
```
┌────────────────────────────────┬─────────────────────────────────────────────────┐
│ [영역 A] 전국 행정지원 지도 │ [영역 B] 문서별 진행상황 카드 (2×2) │
│ 타이틀: 전국 행정지원 지도 │ ┌────────────────┐ ┌────────────────┐ │
│ │ │ 재원협의 │ │ 설치인가 │ │
│ (대한민국 시도·시군구 경계) │ │ 신청/진행/완료 │ │ 신청/진행/완료 │ │
│ 마커: 서울 강남구, │ ├────────────────┤ ├────────────────┤ │
│ 대전 유성구, │ │ 설계자문 │ │ 기본계획 │ │
│ 부산 해운대구 │ │ 신청/진행/완료 │ │ 신청/진행/완료 │ │
│ │ └────────────────┘ └────────────────┘ │
│ ├─────────────────────────────────────────────────┤
│ │ [영역 C] 알림이력 │
│ │ 알림이력 업데이트 HH:mm [전체보기] │
│ │ [오늘 N건] [지연 N건] [완료대기 N건] │
│ │ [뱃지] [일자] 제목 [처리하기] │
│ │ … (최대 5건) │
│ ├─────────────────────────────────────────────────┤
│ │ [영역 D] 공지사항 [더보기] │
│ │ ┌────┬────────┬────────┬────────┬──────┐ │
│ │ │번호│ 제목 │ 작성자 │ 작성일 │조회수│ │
│ │ └────┴────────┴────────┴────────┴──────┘ │
└────────────────────────────────┴─────────────────────────────────────────────────┘
```
### 3-2. 화면 데이터 흐름
```
BEGIN
// 최초 진입
// searchYear 미전달 → 서버가 당해 연도로 고정. 알림 목록은 최신 5건
CALL getMainDashboard(DashboardMainSearchDto) // Authorization은 client 인터셉터
DISPLAY 진행상황 카드 (영역 B) // docPrgrsSttsList, TOTAL 행 제외
DISPLAY 알림 통계 칩 · 알림 목록 (영역 C) // notiStats, notiList
CALL getUserPostList(UserPostSearchParams) // board-service, AS_DASHBOARD_BBS_ID
DISPLAY 공지사항 그리드 (영역 D)
CALL 지도 마커/음영 조회(…) // TBD — 대시보드 API에 없음
DISPLAY 전국 지도 (영역 A)
ON 영역 C [전체보기] 버튼 CLICK DO
NAVIGATE /nti-hstry
END ON
ON 영역 C [처리하기] 버튼 CLICK DO
CALL readNoti(notiId) // entities/noti
NAVIGATE notiUrl // 비어 있으면 이동하지 않음
CALL getMainDashboard(…) // → 영역 C 갱신 (invalidate)
END ON
ON 영역 D [더보기] 버튼 CLICK DO
NAVIGATE /cms/bbsnotice/{AS_DASHBOARD_BBS_ID} // as 앱 공지 목록 라우트 미구현. st/cm 관례
END ON
ON 영역 D 행 CLICK DO
NAVIGATE /cms/bbsnotice/{AS_DASHBOARD_BBS_ID}/{pstNo}
END ON
ON 영역 B 카드 CLICK DO
NAVIGATE 해당 문서 목록 // §5-3
END ON
ON 영역 A 마커 CLICK DO
// TBD — 팝업/필터/이동 여부 미확정
END ON
END
```
---
## 4. 영역 A — 전국 행정지원 지도
스크린샷 타이틀: **전국 행정지원 지도**.
`getMainDashboard` 응답에 지도·좌표·행정구역 음영 데이터는 **없다**. 마커·음영 소스 API는 **TBD**.
### 4-1. 표시 요소
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| ----------- | --------- | ---- | ------ | ----------------- | ----------------------------------------------------------------- |
| 섹션 타이틀 | Text | — | — | — | 고정 문구 `전국 행정지원 지도` |
| 전국 지도 | Map | — | — | TBD | 시도·시군구 경계. 북·동부 음영(청) / 남서부 비음영(백) — 의미 TBD |
| 마커 라벨 | Marker | — | — | TBD | 스크린샷 샘플: `서울 강남구`, `대전 유성구`, `부산 해운대구` |
### 4-2. 상호작용
| 동작 | 설명 |
| ---------- | ----------------------------------------- |
| 마커 클릭 | TBD (상세 팝업 / 해당 지자체 필터 / 이동) |
| 지도 줌·팬 | TBD (스크린샷에 컨트롤 없음) |
`entities/adstdstr`는 시도·구군 **콤보**만 제공하며 좌표를 주지 않는다. 지도 라이브러리도 as 앱에 없음 — 선정 TBD.
---
## 5. 영역 B — 문서별 진행상황 카드
`DashboardMainResponseDto.docPrgrsSttsList`를 **`docSeComCd`로 매핑**해 4장 카드를 그린다. 배열 순서에 의존하지 않는다.
스크린샷 배치(좌→우, 상→하):
| 화면 카드 타이틀 | `docSeComCd` | API `docSeNm` |
| ----------------- | ------------ | -------------- |
| 재원협의 진행상황 | `FNRS` | 재원협의 |
| 설치인가 진행상황 | `INSTL` | 설치인가 |
| 설계자문 진행상황 | `CNFM` | 설계자문 |
| 기본계획 진행상황 | `SEWER` | 하수도기본계획 |
서버 SQL `ORDER BY`는 `INSTL → FNRS → SEWER → CNFM` 이며, `GROUP BY ROLLUP`으로 **`docSeComCd = TOTAL`** 합계 행이 추가된다. 카드에는 TOTAL 행을 **표시하지 않는다**.
카드 타이틀은 스크린샷 문구(`… 진행상황`)를 쓴다. `SEWER`의 API명 `하수도기본계획`은 화면에서 `기본계획`으로 축약한다.
### 5-1. 카드 내부 필드
각 카드 공통 구조. `totCnt`는 화면에 없음.
| 표시명 | Field | 비고 |
| ------ | ---------- | ------------------ |
| 신청 | `reqCnt` | 파란 아이콘 + 건수 |
| 진행 | `prgrsCnt` | 초록 아이콘 + 건수 |
| 완료 | `cmptnCnt` | 주황 아이콘 + 건수 |
상태 그룹(서버): 임시저장(`SV`/`CV`) 제외. 문서별 `prgrs_stts_com_cd` → `REQ`/`PRG`/`CMP`.
### 5-2. 검색 파라미터 (화면 미표시)
영역 B에 연도·필터 UI는 없다. 서버 기본값을 따른다.
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| ------ | --------- | ---- | --------- | ----------------- | --------------------------------------------------------------------- |
| (없음) | — | — | 당해 연도 | `searchYear` | 미전달 시 서버 `LocalDate.now().getYear()`. 연도 셀렉터 추가 여부 TBD |
`docSeComCd` 쿼리는 **알림 목록 필터**에만 쓰이며, 진행상황 SQL에는 전달되지 않는다. 카드 4종은 항상 전원 집계한다.
### 5-3. 상호작용
카드 클릭 시 **해당 문서 목록 페이지**로 이동한다. `docSeComCd` → 라우트 고정 매핑.
| 동작 | `docSeComCd` | 이동 경로 |
| --------- | ------------ | ----------------- |
| 카드 클릭 | `FNRS` | `/fnrs-cnsltn` |
| 카드 클릭 | `INSTL` | `/instl-sncn` |
| 카드 클릭 | `CNFM` | `/design-cnstn` |
| 카드 클릭 | `SEWER` | `/sewer-bsc-plan` |
---
## 6. 영역 C — 알림이력
서버가 cms-service 알림을 대시보드 응답에 합친다. 목록은 **page=0, size=5** (최신 5건). 페이지네이션 없음.
### 6-1. 헤더
| 표시명 | Field / 소스 | 비고 |
| ------------- | ------------ | --------------------------------------------------------------------------------------- |
| 섹션 타이틀 | — | 고정 문구 `알림이력` |
| 업데이트 시각 | (client) | 스크린샷 `업데이트 10:32`. API 필드 없음. `getMainDashboard` 마지막 fetch 시각(`HH:mm`) |
| 전체보기 | Button | `/nti-hstry`로 이동 |
### 6-2. 통계 칩
`NotiAsStatResponseDto`.
| 표시명 | Field | 비고 |
| ------------ | -------------- | -------------------------------------- |
| 오늘 N건 | `todayCount` | 당일 00:00 이후 수신 |
| 지연 N건 | `delayCount` | `notReadYn=N` 이고 수신 후 3일 이상 |
| 완료대기 N건 | `pendingCount` | `notReadYn=Y` 이고 `notiPrcsCmptnYn=N` |
### 6-3. 목록 행
| 표시명 | Field | 비고 |
| ------ | --------- | --------------------------------------------- |
| 일자 | `regDt` | 표시 `YYYY-MM-DD`. 응답 `yyyy-MM-dd HH:mm:ss` |
| 제목 | `notiTtl` | 알림 제목 |
| 처리 | (action) | 스크린샷 라벨 `처리하기`. 클릭 §6-4 |
응답에 있으나 이 목록에 **표시하지 않음**:
| Field | 비고 |
| ----------------- | ----------------------------------------- |
| `notiId` | 행 키 · `readNoti` 파라미터 |
| `recvUnqId` | 수신 회원 UUID (서버 로그인 사용자 고정) |
| `senderUnqId` | 발신 회원 UUID |
| `notiType` | `ADSS` |
| `notiCn` | 본문 |
| `notiUrl` | 처리 후 이동 URL. 컬럼 미표시 |
| `notiLnkgUnqNo` | 알림연결고유번호 |
| `docSeComCd` | 문서구분 코드 |
| `docSeComNm` | 문서구분명 (알림이력 화면에서는 연계업무) |
| `notReadYn` | 뱃지 판정 TBD에 사용 가능 |
| `notiIdntyYn` | 확인 여부 |
| `notiIdntyDt` | 확인 일시 |
| `notiPrcsCmptnYn` | 처리 완료 여부 |
| `notiPrcsCmptnDt` | 처리 완료 일시 |
### 6-4. 행 상호작용
| 동작 | 설명 |
| --------------- | ----------------------------------------------------------------------------------------------------------------- |
| [전체보기] 클릭 | `/nti-hstry` (알림이력 목록)로 이동 |
| [처리하기] 클릭 | `readNoti(notiId)` 후 `notiUrl`로 이동. URL이 비면 이동하지 않음(읽음 처리는 수행). `dashboardBaseKey` invalidate |
`DashboardController`에 읽음 API는 없다. **`entities/noti`의 `readNoti`** 를 쓴다. 알림이력 화면과 동일 (`PUT /cms-service/api/v1/noti/{notiId}/read`).
### 6-5. 검색 파라미터 (화면 미표시)
서버 `toNotiSearchDto()` 기본: `notiType = ADSS`. 화면 필터 없음.
| DashboardMainSearchDto | 화면 | 비고 |
| ---------------------- | ---- | --------------------------------- |
| `notiType` | 없음 | 서버 기본 `ADSS` |
| `notReadYn` | 없음 | 통계 칩 클릭 필터 시 후보 (TBD) |
| `startDt` / `endDt` | 없음 | 대시보드 기본 미전달 |
| `docSeComCd` | 없음 | 알림만 필터. 진행상황 카드와 무관 |
---
## 7. 영역 D — 공지사항
`getMainDashboard`에 공지 필드는 없다. **board-service** `getUserPostList`로 별도 조회한다.
- Base: `GET /board-service/api/v1/posts/user/{AS_DASHBOARD_BBS_ID}`
- `AS_DASHBOARD_BBS_ID` = `NEXT_PUBLIC_AS_DASHBOARD_BBS_ID` (로컬 예: `7`)
- 클라이언트: `@/entities/board` — `postApi.getUserPostList` / `useUserPostList`
페이지네이션 컨트롤은 없다. 요약 목록만 표시한다 (`page=0`, `size=3`).
### 7-1. 헤더·버튼
| 버튼명 | 동작 |
| ------ | ------------------------------------------------------------------------------------------- |
| 더보기 | `/cms/bbsnotice/{AS_DASHBOARD_BBS_ID}` 로 이동. as 앱에 공지 목록 라우트는 없음(st/cm 관례) |
### 7-2. 그리드 컬럼
스크린샷 헤더 순서. `UserPost`.
| 표시명 | Field | 비고 |
| ------ | --------- | ---------------------------------------------------- |
| 번호 | `rowNo` | `upndFixYn` = `Y` 이면 `[공지]` Badge, 그 외 `rowNo` |
| 제목 | `pstTtl` | truncate |
| 작성자 | `wrtrNm` | |
| 작성일 | `regDt` | 표시 `YYYY-MM-DD` (응답 datetime) |
| 조회수 | `inqNocs` | 숫자 |
조회 파라미터 (`UserPostSearchParams`):
| 필드 | 값 | 비고 |
| ------------- | ----------- | ------------------------------------------ |
| `page` | `0` | Spring 0-based |
| `size` | `3` | 대시보드 요약. 스크린샷 3행 |
| `keywordType` | `bbsTypeCd` | st 대시보드 공지 패널과 동일 |
| `keyword` | `BBSNOTICE` | 게시판 유형. path의 bbsId와 함께 공지 한정 |
응답에 있으나 그리드에 **표시하지 않음**: `bbsId`, `pstNo`(행 키·상세 이동), `pstCn`, `upndFixYn`(번호 열 판정만), `rgtrNm`, `atflCd` 등.
### 7-3. 행 상호작용
| 동작 | 설명 |
| ------- | ------------------------------------------------------------------------------ |
| 행 클릭 | `/cms/bbsnotice/{AS_DASHBOARD_BBS_ID}/{pstNo}` (as 앱 공지 상세 라우트 미구현) |
---
## 8. API/DTO 명세
### 8-1. 공통
| 항목 | 값 |
| --------------- | ---------------------------------------------------------------------------------------------- |
| Base prefix | `/as-service/api/v1/dashboard` (`ENDPOINT.AS_SERVICE.DASHBOARD`) |
| 인증 | `Authorization` 헤더. 프론트는 `client` 인터셉터. 로그인 기관 권한은 서버가 사용자 캐시로 적용 |
| 클라이언트 모듈 | `@/entities/dashboard` — `dashboardService` / `useGetMainDashboard` |
| 알림 읽음 | `@/entities/noti` — `notiService.readNoti` / `useReadNoti` |
| 공지사항 | `@/entities/board` — `postApi.getUserPostList` / `useUserPostList` |
영역 A(지도)는 본 컨트롤러·board-service 범위 밖 → **TBD**.
### 8-2. 대시보드 메인 조회 — `getMainDashboard`
| 기능 | Method | URL | Request DTO | Response DTO | API |
| -------------------------------------------- | ------ | ------------- | ------------------------------- | -------------------------- | ----------------------------------------------------------- |
| 대시보드 메인 통합진행상태 및 알림 정보 조회 | GET | `{BASE}/main` | Query: `DashboardMainSearchDto` | `DashboardMainResponseDto` | `dashboardService.getMainDashboard` / `useGetMainDashboard` |
**Request (`DashboardMainSearchDto`)**
| 필드 | 필수 | 설명 |
| ------------ | ---- | ---------------------------------------------------------------------- |
| `searchYear` | N | 검색 연도. 미전달 시 서버 당해 연도. 영역 B 집계 기간 |
| `notiType` | N | 알림 종류. 미전달 시 서버 `ADSS` |
| `notReadYn` | N | 알림 확인 여부 `Y`/`N`. 본 화면 미사용 |
| `startDt` | N | 알림 시작일 `YYYY-MM-DD`. 본 화면 미사용 |
| `endDt` | N | 알림 종료일 `YYYY-MM-DD`. 본 화면 미사용 |
| `docSeComCd` | N | 알림 문서구분 (`INSTL`/`FNRS`/`SEWER`/`CNFM` 등). 진행상황 집계와 무관 |
`Authorization`은 service 시그니처에 넣지 않는다.
**Response (`DashboardMainResponseDto`)**
| 필드 | 타입 | 설명 |
| ------------------ | --------------------------- | --------------------------- |
| `docPrgrsSttsList` | `DocPrgrsSttsResponseDto[]` | 문서별 진행상태 + TOTAL 행 |
| `notiList` | `NotiAsUserResponseDto[]` | 최신 최대 5건 |
| `notiStats` | `NotiAsStatResponseDto` | 오늘 / 지연 / 완료대기 건수 |
**`DocPrgrsSttsResponseDto`**
| 필드 | 타입 | 설명 |
| ------------ | ------ | --------------------------------------------- |
| `docSeNm` | string | 문서 구분 명칭 |
| `docSeComCd` | string | `INSTL` / `FNRS` / `SEWER` / `CNFM` / `TOTAL` |
| `reqCnt` | number | 신청 건수 |
| `prgrsCnt` | number | 진행 건수 |
| `cmptnCnt` | number | 완료 건수 |
| `totCnt` | number | 총 건수 (화면 미표시) |
**`NotiAsStatResponseDto`**
| 필드 | 타입 | 설명 |
| -------------- | ------ | --------- |
| `todayCount` | number | 오늘 알림 |
| `delayCount` | number | 지연 알림 |
| `pendingCount` | number | 완료대기 |
**`NotiAsUserResponseDto`** — 필드 상세는 `docs/spec/nti-hstry` §6-2와 동일 (`notiId`, `notiTtl`, `regDt`, `notiUrl`, `notReadYn`, `notiPrcsCmptnYn` 등).
### 8-3. 알림 읽음 처리 — `readNoti`
대시보드 컨트롤러가 아니라 cms-service. 영역 C [처리하기]에서 호출.
| 기능 | Method | URL | Request DTO | Response DTO | API |
| -------------- | ------ | ---------------------------------------- | ----------------------- | ---------------- | -------------------------------------- |
| 알림 읽음 처리 | PUT | `/cms-service/api/v1/noti/{notiId}/read` | Path: `notiId` (`Long`) | `Long` (알림 ID) | `notiService.readNoti` / `useReadNoti` |
Body 없음. 성공 시 `dashboardBaseKey`(및 필요 시 `notiBaseKey`) invalidate.
### 8-4. 전국 행정지원 지도 — TBD
| 기능 | Method | URL | Request DTO | Response DTO | API |
| ------------------- | ------ | --- | ----------- | ------------ | --- |
| 지도 마커·음영 조회 | TBD | TBD | TBD | TBD | TBD |
### 8-5. 공지사항 — `getUserPostList`
board-service. bbsId는 환경변수 `AS_DASHBOARD_BBS_ID`로 path에 고정한다.
| 기능 | Method | URL | Request DTO | Response DTO | API |
| ----------------------- | ------ | ------------------------------------------ | ----------------------------- | ------------------------ | --------------------------------------------- |
| 대시보드 공지 요약 목록 | GET | `/board-service/api/v1/posts/user/{bbsId}` | Query: `UserPostSearchParams` | `PageResponse<UserPost>` | `postApi.getUserPostList` / `useUserPostList` |
**Request (`UserPostSearchParams`)** — 본 화면 고정값
| 필드 | 필수 | 설명 |
| ------------- | ---- | -------------- |
| `page` | N | `0` |
| `size` | N | `3` |
| `keywordType` | N | `bbsTypeCd` |
| `keyword` | N | `BBSNOTICE` |
| `popupYn` 등 | N | 본 화면 미사용 |
**Response (`UserPost`) — 화면 매핑 필드**
| 필드 | 타입 | 설명 |
| ----------- | ------ | --------------------------- |
| `rowNo` | number | 번호. 고정글이 아니면 표시 |
| `pstNo` | number | 게시 번호. 상세 이동 키 |
| `bbsId` | number | 게시판 ID |
| `pstTtl` | string | 제목 |
| `wrtrNm` | string | 작성자 |
| `regDt` | string | 작성일 |
| `inqNocs` | number | 조회수 |
| `upndFixYn` | string | 상단고정 `Y`/`N` → `[공지]` |
---
## 9. UI 구현 가이드
### 9-1. 기술 스택·원칙
- 라우트: 기존 홈 `app/(main)/(with-base-layout)/page.tsx` → `pages/home` (현재 `MainPage` 플레이스홀더)
- UI: `@common/components` — Card, Badge, Button, Table(공지). 지도 라이브러리 TBD
- 데이터: React Query `useGetMainDashboard()`. 처리하기는 `useReadNoti()`. 공지는 `useUserPostList()`
- 진행상황 카드: `docSeComCd`로 4슬롯 매핑. `TOTAL` 제외
- 알림 목록: 테이블이 아닌 행 리스트(뱃지 + 일자 + 제목 + 버튼)
- 공지: `@tanstack/react-table` + `useDataTable`, 페이지네이션 없음
- 검색 조건 URL 동기화: 본 화면 필터 없음. `searchYear` UI가 생기면 그때 검토
### 9-2. 모듈·파일 배치
| 구분 | 경로 |
| ------------- | ------------------------------------------------------------ |
| 라우트 | `app/(main)/(with-base-layout)/page.tsx` |
| 페이지 | `pages/dashboard/ui/dashboard-page.tsx` |
| 지도 | `pages/dashboard/ui/national-as-map.tsx` (API TBD, Skeleton) |
| 진행상황 카드 | `pages/dashboard/ui/doc-prgrs-stts-cards.tsx` |
| 알림이력 | `pages/dashboard/ui/dashboard-noti-list.tsx` |
| 공지사항 | `pages/dashboard/ui/dashboard-notice-table.tsx` |
| 공지 컬럼 | `pages/dashboard/model/dashboard-notice-table-columns.tsx` |
| Entity API | `entities/dashboard/api/dashboard-service.ts` |
| Entity Hook | `entities/dashboard/api/queries.ts` → `useGetMainDashboard` |
| Entity Model | `entities/dashboard/model/dashboard-interface.ts` |
| 알림 읽음 | `entities/noti` → `useReadNoti` |
| 공지 Entity | `entities/board` → `useUserPostList` |
Entity(`getMainDashboard`, `getUserPostList`) 구현됨. 영역 B·C·D 화면 구현. 영역 A는 Skeleton.
### 9-3. 영역별 UI 매핑
| 영역 | 컴포넌트 | 데이터 |
| ---- | ------------------------ | ---------------------------------------------------------------------------- |
| A | `national-as-map` | Skeleton (지도 API TBD) |
| B | `doc-prgrs-stts-cards` | `useGetMainDashboard` → `docPrgrsSttsList` (`TOTAL` 제외, 코드 매핑) |
| C | `dashboard-noti-list` | 동 훅 → `notiStats`, `notiList`. 처리 → `useReadNoti`. 전체보기 `/nti-hstry` |
| D | `dashboard-notice-table` | `useUserPostList` → `content[]`. [더보기]·행 클릭 → `/cms/bbsnotice/...` |
### 9-4. 다이얼로그·라우팅
| 항목 | 내용 |
| -------------- | -------------------------------------------------------------------------------------- |
| 다이얼로그 | 없음 |
| 본 화면 라우트 | `/` (홈) |
| 알림 처리 이동 | [처리하기] → `readNoti` 후 `notiUrl` (알림이력과 동일, SEWER URL은 상세 세그먼트 제거) |
| 알림 전체보기 | `/nti-hstry` |
| 공지 더보기 | `/cms/bbsnotice/{AS_DASHBOARD_BBS_ID}` (as 앱 공지 라우트 미구현, st/cm 관례) |
| 공지 행 클릭 | `/cms/bbsnotice/{AS_DASHBOARD_BBS_ID}/{pstNo}` |
| 진행상황 카드 | §5-3 목록 라우트 (`/fnrs-cnsltn`, `/instl-sncn`, `/design-cnstn`, `/sewer-bsc-plan`) |
### 9-5. 데이터 흐름
```
BEGIN
// 최초 진입 (홈)
CALL getMainDashboard({})
DISPLAY 영역 B // docPrgrsSttsList — FNRS/INSTL/CNFM/SEWER 슬롯
DISPLAY 영역 C // notiStats + notiList(≤5)
CALL getUserPostList({ page: 0, size: 3, keywordType: "bbsTypeCd", keyword: "BBSNOTICE" })
DISPLAY 영역 D
CALL 지도 데이터 // TBD → 영역 A
ON 영역 C [전체보기] CLICK DO
NAVIGATE /nti-hstry
END ON
ON 영역 C [처리하기] CLICK DO
CALL readNoti(notiId)
IF notiUrl THEN
NAVIGATE notiUrl
END IF
CALL getMainDashboard // invalidate dashboardBaseKey
END ON
ON 영역 B 카드 CLICK DO
NAVIGATE 문서 목록 // §5-3
END ON
ON 영역 D [더보기] CLICK DO
NAVIGATE /cms/bbsnotice/{AS_DASHBOARD_BBS_ID}
END ON
ON 영역 D 행 CLICK DO
NAVIGATE /cms/bbsnotice/{AS_DASHBOARD_BBS_ID}/{pstNo}
END ON
END
```
@@ -1,284 +0,0 @@
# 화면설계서: 필수첨부파일관리
## 1. 기본 정보
| 항목 | 내용 |
| ---------- | ---------------------------------------------- |
| 버전 | 0.1 |
| 프로세스명 | UI설계 |
| 화면명 | 필수첨부파일관리 |
| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 |
| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 필수첨부파일관리 |
---
## 2. 화면 목적
문서(업무) 구분별로 **필수 첨부파일** 항목을 등록·조회·수정·삭제한다. 상단 검색 영역에서 **문서구분**으로 목록을 필터링하고, **등록** 또는 행별 **수정**·**삭제**로 마스터 데이터를 유지한다. 등록·수정 시 **필수첨부파일아이디**는 클라이언트에서 입력(또는 생성)하여 요청 본문에 포함한다.
---
## 3. 화면 구성
### 3-1. 전체 레이아웃
```
┌──────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 페이지 타이틀: 필수첨부파일관리 브레드크럼: 디지털행정지원 > … > │
├──────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ [영역 A] 검색 / 액션 │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 문서구분 [전체 ▼] [조회] [등록] │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ [영역 B] 필수첨부파일 목록 │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 필수첨부파일아이디 | 문서구분 | 필수첨부파일명 | 필수첨부여부 | 비고 | 등록일시 | 수정일시 | 관리 │
│ ├────────────────────────────────────────────────────────────────────────────────────────────────┤ │
│ │ EAF-0001 | 설치인가 | 사업계획서 | 예 | 서명본 첨부 | 2026-04-22 10:10:00 | — | [수정][삭제] │ │
│ │ EAF-0002 | 기본계획 | 변경사유서 | 예 | — | 2026-04-22 11:00:00 | — | [수정][삭제] │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────┘
(모달) [다이얼로그 C] 필수첨부파일 등록 / 수정
┌──────────────────────────────────────────────────────┐
│ 필수첨부파일 등록 [닫기] │
├──────────────────────────────────────────────────────┤
│ 필수첨부파일아이디(자동발급) [EAF-0004] │
│ 문서구분 [설치인가 ▼] │
│ 필수첨부파일명 [예: 사업계획서] │
│ 필수첨부여부 [ ] 예 [ ] 아니오 │
│ 비고 [비고를 입력하세요] │
├──────────────────────────────────────────────────────┤
│ [취소] [저장] │
└──────────────────────────────────────────────────────┘
```
### 3-2. 화면 데이터 흐름
```
BEGIN
// 최초 진입
CALL 필수첨부파일 목록 조회 (findPage)
DISPLAY 필수첨부파일 그리드 (영역 B)
// 영역 A: 검색
ON [조회] 버튼 CLICK DO
CALL 필수첨부파일 목록 조회 (문서구분 = 선택값, findPage)
DISPLAY 필수첨부파일 그리드 (영역 B)
END ON
// 영역 A: 등록
ON [등록] 버튼 CLICK DO
OPEN 필수첨부파일 등록 다이얼로그 (다이얼로그 C)
ON [저장] DO
CONFIRM "등록하시겠습니까?"
VALIDATE 문서구분, 필수첨부파일명, 필수첨부여부
CALL 필수첨부파일 등록 (create)
CALL 필수첨부파일 목록 조회
CLOSE 다이얼로그
TOAST "저장되었습니다."
END ON
ON [취소] / [닫기] DO
CLOSE 다이얼로그
END ON
END ON
// 영역 B: 수정
ON 행 [수정] CLICK DO
OPEN 필수첨부파일 수정 다이얼로그 (기존 값 초기화)
ON [저장] DO
VALIDATE 문서구분, 필수첨부파일명, 필수첨부여부
CALL 필수첨부파일 수정 (update)
CALL 필수첨부파일 목록 조회
CLOSE 다이얼로그
END ON
END ON
// 영역 B: 삭제
ON 행 [삭제] CLICK DO
CONFIRM "삭제하시겠습니까?"
CALL 필수첨부파일 삭제 (remove)
CALL 필수첨부파일 목록 조회
END ON
END
```
---
## 4. 영역 A: 검색 / 액션
문서구분 조건으로 목록을 조회하고, 신규 등록 다이얼로그를 연다.
### 4-1. 검색 필드
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| -------- | ---------- | ---- | ------ | ----------------- | ------------------------------ |
| 문서구분 | CodeSelect | N | 전체 | `docSeComCd?` | "전체" 선택 시 파라미터 미전송 |
### 4-2. 버튼
| 버튼명 | 위치 | 동작 |
| ------ | -------------- | ----------------------------------------------- |
| 조회 | 검색 필드 우측 | 현재 검색 조건으로 영역 B 목록 갱신 |
| 등록 | 영역 우측 끝 | 필수첨부파일 등록 다이얼로그(다이얼로그 C) 오픈 |
---
## 5. 영역 B: 필수첨부파일 목록
필수 첨부파일 마스터를 테이블로 표시한다. 페이지네이션 UI 적용.
### 5-1. 헤더
| 항목 | 내용 |
| ----------- | ----------------- |
| 섹션 타이틀 | 필수첨부파일 목록 |
### 5-2. 그리드 컬럼
| 컬럼명 | Field | 타입 | 비고 |
| ------------------ | ------------------- | ------ | ------------------------------------- |
| 필수첨부파일아이디 | `esntlAtchFileId` | string | 예: `EAF-0001`, 클라이언트 생성/입력 |
| 문서구분 | `docSeComCd` | string | 코드명 표시 — 공통코드 (TBD 상위코드) |
| 필수첨부파일명 | `esntlAtchFileNm` | string | |
| 필수첨부여부 | `esntlAtchYn` | string | `Y`→예, `N`→아니오 |
| 비고 | `esntlAtchFileRmrk` | string | 없으면 `—` 표시 |
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
| 수정일시 | `mdfcnDt` | string | 미수정 시 빈 값 또는 `—` |
| 관리 | — | — | 행별 **[수정] · [삭제]** |
- 행 클릭 시 별도 상세 화면 없음. 편집은 **[수정]** 버튼으로 다이얼로그 C를 연다.
### 5-3. 버튼
| 버튼명 | 위치 | 동작 |
| ------ | ---- | -------------------------------------- |
| 수정 | 행별 | 필수첨부파일 수정 다이얼로그 (기존 값) |
| 삭제 | 행별 | 확인 후 삭제 API 호출 → 목록 갱신 |
---
## 6. 다이얼로그 C: 필수첨부파일 등록 / 수정
등록·수정 공용 폼 다이얼로그. 등록 시 제목 **「필수첨부파일 등록」**, 수정 시 **「필수첨부파일 수정」**.
### 6-1. 폼 필드
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| ------------------ | ---------- | ---- | ------ | ------------------- | -------------------------------- |
| 필수첨부파일아이디 | (Readonly) | Y | — | `esntlAtchFileId` | 등록 시 미노출 |
| 문서구분 | CodeSelect | Y | — | `docSeComCd` | 예: 설치인가, 기본계획 |
| 필수첨부파일명 | Text | Y | — | `esntlAtchFileNm` | placeholder: "예: 사업계획서" |
| 필수첨부여부 | RadioGroup | Y | Y | `esntlAtchYn` | Y/N, UI 표시: 예/아니오 |
| 비고 | Textarea | N | — | `esntlAtchFileRmrk` | placeholder: "비고를 입력하세요" |
### 6-2. 버튼
| 버튼명 | 동작 |
| ------ | -------------------------------------- |
| 저장 | POST(등록) 또는 PUT(수정) 후 목록 갱신 |
| 취소 | 다이얼로그 닫기, 입력값 폐기 |
| 닫기 | 다이얼로그 닫기 (헤더 우측) |
---
## 7. API/DTO 명세
### 7-1. 공통
| 항목 | 내용 |
| ----------- | ----------------------------------------------------- |
| 서비스 | AS Common Service |
| Base prefix | `/as-service/api/v1/esntl-atch-file` |
| 인증 | JWT (`Authorization`), `X-Site-Id` 등 |
| 페이징 | Spring `Pageable` 사용 (`page`, `size`, `sort`) |
| 엔티티 | `@/entities/esntl` (`esntl-service.ts`, `queries.ts`) |
### 7-2. 필수첨부파일 API
| 기능 | Method | URL | Request DTO | Response DTO | API (클라이언트 함수명) |
| --------- | ------ | -------------------- | ----------------------------- | ----------------------------------- | ----------------------- |
| 트리 조회 | GET | `/list/tree` | Query: `docSeComCd`(필수) | `EsntlAtchFileMngTreeResponseDto[]` | `findTree` |
| 목록 조회 | GET | `/` | Query: Search + Pageable | `PageResponse<EsntlAtchFileMng>` | `findPage` |
| 단건 조회 | GET | `/{esntlAtchFileId}` | Path: `esntlAtchFileId` | `EsntlAtchFileMng` | `findById` |
| 등록 | POST | `/` | `EsntlAtchFileMngSaveRequest` | `string` (생성된 ID) | `create` |
| 수정 | PUT | `/{esntlAtchFileId}` | `EsntlAtchFileMngSaveRequest` | `string` (수정된 ID) | `update` |
| 삭제 | DELETE | `/{esntlAtchFileId}` | Path: `esntlAtchFileId` | `string` (삭제된 ID) | `remove` |
**응답 DTO**
| Field | 타입 | 설명 |
| ------------------- | ------- | -------------------------------- |
| `esntlAtchFileId` | string | 필수첨부파일아이디 |
| `docSeComCd` | string | 문서구분공통코드 |
| `esntlAtchFileNm` | string | 필수첨부파일명 |
| `esntlAtchYn` | string | 필수첨부여부 (Y/N) |
| `esntlAtchFileRmrk` | string? | 필수첨부파일비고 |
| `rgtr` | string? | 등록자 UUID |
| `rgtrNm` | string? | 등록자명 |
| `mdfr` | string? | 수정자 UUID |
| `regDt` | string? | 등록일시 (`yyyy-MM-dd HH:mm:ss`) |
| `mdfcnDt` | string? | 수정일시 |
**저장 Request**
| Field | 필수 | 설명 |
| ------------------- | ---- | ------------------ |
| `esntlAtchFileId` | Y | 필수첨부파일아이디 |
| `docSeComCd` | N | 문서구분 |
| `esntlAtchFileNm` | N | 필수첨부파일명 |
| `esntlAtchYn` | N | 필수첨부여부 |
| `esntlAtchFileRmrk` | N | 비고 |
**검색 Query**
| Field | 필수 | 설명 |
| ----------------- | ---- | ----------------------------- |
| `docSeComCd` | N | 문서구분 (전체 시 미전송) |
| `esntlAtchFileNm` | N | 필수첨부파일명 부분/일치 검색 |
| `esntlAtchYn` | N | 필수첨부여부 (`Y`/`N`) |
| `page` | N | 페이지 번호 (0-base) |
| `size` | N | 페이지 크기 (기본값: 100) |
| `sort` | N | 정렬 조건 (예: `regDt,desc`) |
### 7-3. 공통코드
| UI 필드 | 코드 그룹 | 예시 값 |
| ------------ | --------- | ------------------ |
| 문서구분 | ASDOCSECD | 설치인가, 기본계획 |
| 필수첨부여부 | 고정 | Y(예), N(아니오) |
---
## 8. UI 구현 가이드
### 8-1. 기술 스택·원칙
- **공통 컴포넌트**: `@common/components` (`Dialog`, `Button`, `Input`, `Select`, `Textarea`, `Label` 등)
- **테이블**: `@tanstack/react-table` + `useDataTable`
- **폼**: `react-hook-form`
- **데이터**: React Query — `@/entities/esntl/api/queries.ts` 훅 사용
- **공통코드**: `@/entities/code` — `CodeSelect`, `findCodeLabel`
- **확인**: `@frontend-opensource/use-react-hooks` — `useConfirm` (등록·삭제 확인)
### 8-2. 모듈·파일 배치
| 구분 | 경로 |
| ------ | ---------------------------------------------------------------------------------------------------- |
| 라우트 | `apps/as/app/(with-base-layout)/esntl-atch-file-mng/page.tsx` |
| 페이지 | `apps/as/src/pages/esntl-atch-file-mng/` (Public API: page만 export) |
| UI | `.../ui/esntl-atch-file-mng-page.tsx`, `esntl-atch-file-list.tsx`, `esntl-atch-file-form-dialog.tsx` |
| model | `.../model/esntl-atch-file-columns.tsx`, `esntl-atch-file-search-params.ts` |
| 엔티티 | `apps/as/src/entities/esntl/` (`esntl-service.ts`, `queries.ts`, `esntl-interface.ts`) |
| 문서 | `apps/as/docs/essential-attachment-file-management/SPEC.md` |
### 8-3. 데이터 흐름(요약)
- **진입**: 검색 params 기본값(`docSeComCd` = 전체) → 목록 query → 영역 B DataTable.
- **조회**: [조회] 클릭 시 search state 반영 → 목록 query refetch.
- **등록**: [등록] → Dialog `mode=create` → [저장] → POST → 목록 invalidate.
- **수정**: [수정] → Dialog `mode=edit` + row 초기값 → [저장] → PUT → 목록 invalidate.
- **삭제**: confirm → DELETE → 목록 invalidate.
-285
View File
@@ -1,285 +0,0 @@
# 화면설계서: 내 행정지원
## 1. 기본 정보
| 항목 | 내용 |
| ---------- | -------------------------------------------- |
| 버전 | 1.0 |
| 프로세스명 | UI설계 |
| 화면명 | 내 행정지원 |
| 화면 ID | AS_SC0204011 |
| 화면 유형 | [v] 조회 [ ] 입력 [ ] 수정 [ ] 삭제 [ ] 출력 |
| 메뉴 경로 | 메인 > 행정지원서비스 > 내 행정지원 |
---
## 2. 화면 목적
로그인한 사용자가 **본인이 신청한 행정지원 문서**를 기간·구분·진행상태로 조회한다. 목록에서 행을 선택하면 해당 문서 상세 화면으로 이동한다.
---
## 3. 화면 구성
### 3-1. 전체 레이아웃
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 브레드크럼: 메인 > 행정지원서비스 > 내 행정지원 │
│ 페이지 타이틀: 내 행정지원 │
├─────────────────────────────────────────────────────────────────────────────────┤
│ [영역 A] 검색 필터 │
│ 기간 [시작일] ~ [종료일] 구분 ▼ 진행상태 ▼ [검색] │
├─────────────────────────────────────────────────────────────────────────────────┤
│ [영역 B] 내 신청 목록 │
│ 타이틀: 내 신청 목록 N개 │
│ ┌────┬──────┬────────┬──────────────┬────────────┬────────┬──────────────┐ │
│ │ No │ 구분 │ 신청일 │ 진행상태 │ 완료(예정)일│ 담당자 │ 담당자연락처 │ │
│ ├────┼──────┼────────┼──────────────┼────────────┼────────┼──────────────┤ │
│ │ … │ … │ … │ prgrsSttsComNm│ … │ … │ … │ │
│ └────┴──────┴────────┴──────────────┴────────────┴────────┴──────────────┘ │
│ [페이지네이션] │
└─────────────────────────────────────────────────────────────────────────────────┘
```
### 3-2. 화면 데이터 흐름
```
BEGIN
// 최초 진입
// 기간 기본값: 오늘 기준 최근 1개월 (startDate = today-1M, endDate = today)
CALL findMyElaperDocList(ElaprMyDocSearchDto + pageable)
DISPLAY 내 신청 목록 그리드 (영역 B)
ON [검색] 버튼 CLICK DO
CALL findMyElaperDocList(영역 A 조건 + page=0)
DISPLAY 내 신청 목록 그리드 (영역 B)
END ON
ON 페이지네이션 CHANGE DO
CALL findMyElaperDocList(영역 A 조건 + page/size)
DISPLAY 내 신청 목록 그리드 (영역 B)
END ON
ON 영역 B 행 CLICK DO
// docSeComCd·prgrsSttsComCd·rgtr → view/edit
NAVIGATE 문서 상세 또는 수정 화면
END ON
END
```
---
## 4. 영역 A — 검색 필터
구성: **기간 · 구분 · 진행상태** (+ 검색).
> UI설계 상세설명의 `docSecs` / `docStatusCd`는 레거시·설계 문서 명칭이다.
> **API SoT**는 `ElaprMyDocSearchDto`의 `docSeComCd` / `prgrsSttsComCd`를 사용한다.
### 4-1. 필드 정의
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| ---------- | ---------------- | ---- | --------------------------- | ----------------- | --------------------------------------------------------------------- |
| 기간(시작) | DatePicker | N | 오늘 − 1개월 (`YYYY-MM-DD`) | `startDate` | 신청일(`reqDt`) 기준 검색으로 가정 |
| 기간(종료) | DatePicker | N | 오늘 (`YYYY-MM-DD`) | `endDate` | |
| 구분 | Select(공통코드) | N | 전체(미선택) | `docSeComCd` | 상위코드 `ASDOCSE`. UI설계 `docSecs` → API `docSeComCd` |
| 진행상태 | Select(공통코드) | N | 전체(미선택) | `prgrsSttsComCd` | 상위코드 = `{docSeComCd}STTS` (예: `INSTL` → `INSTLSTTS`). UI설계 `docStatusCd` → API 필드명 |
### 4-2. 버튼
| 버튼명 | 동작 |
| ------ | ----------------------------------------------------------------- |
| 검색 | 영역 A 조건으로 `findMyElaperDocList` 호출 → 영역 B 갱신 (page=0) |
### 4-3. 콤보 의존
| 트리거 | 동작 |
| ----------- | ------------------------------------------------------------------- |
| 구분 변경 | 진행상태 옵션을 `upCd = docSeComCd + "STTS"`로 재조회. 진행상태 선택값 RESET |
| 구분 미선택 | 진행상태 비활성(옵션 비움). `upCd` 조합 불가이므로 조회하지 않음 |
---
## 5. 영역 B — 내 신청 목록
### 5-1. 그리드 컬럼
| 표시명 | Field | 비고 |
| ------------ | ---------------- | ------------------------------------------------- |
| No | (row index) | 페이지 오프셋 반영 |
| 구분 | `docSeComNm` | 코드 `docSeComCd` (`ASDOCSE`) |
| 신청일 | `reqDt` | 표시 `YYYY-MM-DD` (응답은 `yyyy-MM-dd HH:mm:ss`) |
| 진행상태 | `prgrsSttsComNm` | 텍스트 렌더링 (단계형 프로그레스 미사용) |
| 완료(예정)일 | `cmptnExpctDt` | 표시 `YYYY-MM-DD` |
| 담당자 | `picNm` | |
| 담당자연락처 | `picTelno` | |
응답에 있으나 그리드에 **표시하지 않음**:
| Field | 비고 |
| ---------------- | ----------------------------------------------------------------- |
| `docNo` | 문서번호 — 행 클릭 라우팅 키 |
| `docTtl` | 문서명/사업명 — 미표시 |
| `chgCycl` | 변경차수 — 그리드 미표시. SEWER 이동 시 query 파라미터로 사용 |
| `prgrsSttsComCd` | 진행상태 코드 — 컬럼 표시는 `prgrsSttsComNm` |
| `rgtr` | 등록자 UUID — edit 판정용 (`rgtr === userUnqId`) |
### 5-2. 목록 메타·페이지네이션
| 항목 | 매핑 |
| ------------- | ------------------------------------------- |
| 건수 `N개` | `PageResponse.totalElements` |
| page / size | `ElaprMyDocSearchDto`의 `PageableParams` |
| 페이지 컨트롤 | Spring `Page` 표준 (`number`, `totalPages`) |
### 5-3. 행 상호작용
| 동작 | 설명 |
| ------- | -------------------------------------------------------------------------------------------- |
| 행 클릭 | `docSeComCd`·`prgrsSttsComCd`·`rgtr`로 view/edit 분기 후 이동 (`resolveMyPbadmsSprtDetailPath`) |
**edit 조건** (기존 도메인 목록과 동일):
| `docSeComCd` | edit 조건 |
| ------------------------ | ---------------------------------------------------------------------- |
| `INSTL` / `FNRS` / `SEWER` | `(prgrsSttsComCd === "SV" \|\| "SL") && rgtr === loggedInUserUnqId` |
| `CNFM` | `prgrsSttsComCd === "CV" && rgtr === loggedInUserUnqId` |
| `docSeComCd` | edit 경로 | view 경로 |
| ------------ | -------------------------------------------- | --------------------------------------- |
| `FNRS` | `/fnrs-cnsltn/{docNo}/edit` | `/fnrs-cnsltn/{docNo}` |
| `INSTL` | `/instl-sncn/{docNo}/edit` | `/instl-sncn/{docNo}` |
| `CNFM` | `/design-cnstn/{docNo}/edit` | `/design-cnstn/{docNo}` |
| `SEWER` | `/sewer-bsc-plan/{docNo}/edit?chgCycl={n}` | `/sewer-bsc-plan/{docNo}?chgCycl={n}` |
| 그 외 | 이동하지 않음 | |
- SEWER `chgCycl`: `row.chgCycl ?? 1`
- 조건 불충족 시 항상 view
---
## 6. API/DTO 명세
### 6-1. 공통
| 항목 | 값 |
| --------------- | ------------------------------------------------------------------------------------------ |
| Base prefix | `/as-elapr-service/api/v1/elapr-prgrs-stts` (`ENDPOINT.AS_ELAPR_SERVICE.ELAPR_PRGRS_STTS`) |
| 인증 | 로그인 사용자 UUID — 서버 `SecurityUtils.getLoggedInUserUnqIdUUID()` (클라이언트 미전달) |
| 클라이언트 모듈 | `@/entities/elapr` — `elaprPrgrsSttsService` / `useElaprPrgrsSttsMyDocPage` |
### 6-2. 내 행정지원 목록 — `findMyElaperDocList`
| 기능 | Method | URL | Request DTO | Response DTO | API |
| ----------------- | ------ | --------------- | --------------------------------------- | ------------------------------------ | -------------------------------------------------------------------------- |
| 내 신청 목록 조회 | GET | `{BASE}/my/doc` | Query: `ElaprMyDocSearchDto` + Pageable | `PageResponse<MyAdssDocResponseDto>` | `elaprPrgrsSttsService.findMyElaperDocList` / `useElaprPrgrsSttsMyDocPage` |
**Request (`ElaprMyDocSearchDto` + Pageable)**
| 필드 | 필수 | 설명 |
| ------------------------ | ---- | ------------------------------------------------------------ |
| `startDate` | N | 검색 시작일 `YYYY-MM-DD` |
| `endDate` | N | 검색 종료일 `YYYY-MM-DD` |
| `docSeComCd` | N | 문서구분 (`INSTL` / `FNRS` / `DESIGN` / `SEWER` / `PWRK` 등) |
| `prgrsSttsComCd` | N | 문서진행상태공통코드 |
| `page` / `size` / `sort` | N | Spring Pageable |
**Response (`MyAdssDocResponseDto`)**
| 필드 | 타입 | 설명 |
| ---------------- | ------- | ------------------------------------------- |
| `docNo` | string | 문서번호 |
| `docTtl` | string? | 문서명/사업명 |
| `docSeComCd` | string? | 문서구분 코드 |
| `docSeComNm` | string? | 문서구분 코드명 (`ASDOCSE`) |
| `reqDt` | string? | 신청일시 `yyyy-MM-dd HH:mm:ss` |
| `prgrsSttsComCd` | string? | 문서진행상태 코드 |
| `prgrsSttsComNm` | string? | 문서진행상태 코드명 |
| `cmptnExpctDt` | string? | 완료(예정)일 `yyyy-MM-dd` |
| `picNm` | string? | 담당자 성명 |
| `picTelno` | string? | 담당자 연락처 |
| `chgCycl` | number? | 변경차수 |
| `rgtr` | string? | 등록자 UUID |
### 6-3. 공통코드 (콤보)
| 용도 | 상위코드 | API | 비고 |
| -------- | ------------ | --------------------------- | ----------------------------- |
| 구분 | `ASDOCSE` | `useCodeList("ASDOCSE")` 등 | 기존 관리 화면과 동일 |
| 진행상태 | `{docSeComCd}STTS` | `useCodeList(docSeComCd + "STTS")` | 예: `INSTLSTTS`, `FNRSSTTS`, `SEWERSTTS`. 구분 미선택 시 미호출 |
---
## 7. UI 구현 가이드
### 7-1. 기술 스택·원칙
- UI: `@common/components` — DatePicker, Select/`CodeSelect`, Button, DataTable, Pagination
- 목록: `@tanstack/react-table` + `useDataTable` (기존 `instl-list` / `fnrs-list` 패턴)
- 데이터: React Query `useElaprPrgrsSttsMyDocPage(params)`
- 검색 조건·페이지: URL `searchParams` 동기화 권장 (`useSearchParamsState` 스킬)
- 진행상태 컬럼: `prgrsSttsComNm` 텍스트 렌더링 (단계형 프로그레스 미사용)
- 행 클릭: `docSeComCd` + `prgrsSttsComCd` + `rgtr`로 view/edit 분기 (`SEWER`는 `chgCycl` query 포함)
### 7-2. 앱·파일 배치
| 구분 | 경로 |
| ------------ | -------------------------------------------------------------- |
| 라우트 | `app/(main)/(with-base-layout)/my-pbadms-sprt/page.tsx` |
| 페이지 | `pages/my-pbadms-sprt/ui/my-pbadms-sprt-list-page.tsx` |
| 목록 그리드 | `pages/my-pbadms-sprt/ui/my-pbadms-sprt-list.tsx` |
| 경로 해석 | `pages/my-pbadms-sprt/lib/resolve-detail-path.ts` |
| 테이블 컬럼 | `pages/my-pbadms-sprt/model/my-pbadms-sprt-table-columns.tsx` |
| Entity API | `entities/elapr/api/elapr-prgrs-stts-service.ts` |
| Entity Hook | `entities/elapr/api/queries.ts` → `useElaprPrgrsSttsMyDocPage` |
| Entity Model | `entities/elapr/model/elapr-prgrs-stts-interface.ts` |
### 7-3. 영역별 UI 매핑
| 영역 | 컴포넌트 | 데이터 |
| ---- | -------------------------- | ------------------------------------------ |
| A | `my-pbadms-sprt-list-page` | 로컬 state → `ElaprMyDocSearchDto` |
| B | DataTable + columns | `useElaprPrgrsSttsMyDocPage` → `content[]` |
### 7-4. 다이얼로그·라우팅
| 항목 | 내용 |
| ----------- | -------------------------------------------------------------------- |
| 다이얼로그 | 없음 (목록 전용) |
| 목록 라우트 | `/my-pbadms-sprt` |
| 상세 이동 | §5-3 표와 동일 (view/edit + `rgtr` 소유자 검사, SEWER `chgCycl`) |
### 7-5. 데이터 흐름
```
BEGIN
// 최초 진입
SET 기간 = 최근 1개월
CALL findMyElaperDocList
DISPLAY 영역 B
ON [검색] CLICK DO
CALL findMyElaperDocList // → 영역 B 갱신
END ON
ON 페이지 CHANGE DO
CALL findMyElaperDocList
END ON
ON 영역 B 행 CLICK DO
IF NOT docNo OR unsupported docSeComCd THEN
// no-op
ELSE IF canEdit(prgrsSttsComCd, rgtr === userUnqId) THEN
NAVIGATE edit 경로 // SEWER면 ?chgCycl=
ELSE
NAVIGATE view 경로
END IF
END ON
END
```
-352
View File
@@ -1,352 +0,0 @@
# 화면설계서: 알림이력
## 1. 기본 정보
| 항목 | 내용 |
| ----------- | -------------------------------------------- |
| 버전 | 1.4 |
| 프로세스명 | UI설계 |
| 화면명 | 알림이력 |
| 화면 ID | AS_SC0205011 |
| 요구사항 ID | NSUR-001-001 |
| 화면 유형 | [v] 조회 [ ] 입력 [ ] 수정 [ ] 삭제 [ ] 출력 |
| 메뉴 경로 | 메인 > 행정지원서비스 > 알림이력 |
---
## 2. 화면 목적
로그인한 사용자가 **본인에게 발송된 행정지원(ADSS) 알림**을 기간·구분으로 조회한다. 목록의 처리 버튼으로 해당 알림을 읽음 처리한 뒤, 연계된 업무 화면으로 이동한다.
---
## 3. 화면 구성
### 3-1. 전체 레이아웃
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 브레드크럼: 메인 > 행정지원서비스 > 알림이력 │
│ 페이지 타이틀: 알림이력 │
├─────────────────────────────────────────────────────────────────────────────────┤
│ [영역 A] 검색 필터 │
│ 기간 [시작일] ~ [종료일] 구분 ▼ [검색] │
├─────────────────────────────────────────────────────────────────────────────────┤
│ [영역 B] 알림 이력 목록 │
│ 타이틀: 알림 이력 목록 N개 ▼ │
│ ┌────┬──────────────┬────────────────┬──────────┬────────┬────────┐ │
│ │ No │ 알림일시 │ 알림내용 │ 연계업무 │ 상태 │ 처리 │ │
│ ├────┼──────────────┼────────────────┼──────────┼────────┼────────┤ │
│ │ … │ regDt │ notiTtl │ docSeComNm│ Badge │ 버튼 │ │
│ └────┴──────────────┴────────────────┴──────────┴────────┴────────┘ │
│ [페이지네이션] │
└─────────────────────────────────────────────────────────────────────────────────┘
```
### 3-2. 화면 데이터 흐름
```
BEGIN
// 최초 진입
// 기간 기본값: 오늘 기준 최근 1개월 (시작일 = today-1M, 종료일 = today)
CALL findMyAsNotiList(NotiSearchDto + pageable)
DISPLAY 알림 이력 목록 그리드 (영역 B)
ON [검색] 버튼 CLICK DO
CALL findMyAsNotiList(영역 A 조건 + page=0)
DISPLAY 알림 이력 목록 그리드 (영역 B)
END ON
ON 페이지네이션 CHANGE DO
CALL findMyAsNotiList(영역 A 조건 + page/size)
DISPLAY 알림 이력 목록 그리드 (영역 B)
END ON
ON 영역 B [처리] 버튼 CLICK DO
CALL readNoti(notiId)
// UI설계: 해당 알림에 연계된 URL로 이동
NAVIGATE notiUrl
END ON
END
```
---
## 4. 영역 A — 검색 필터
구성: **기간 · 구분** (+ 검색).
서버 `findMyAsNotiList` 고정 정책 (클라이언트 미전달):
- `recvUnqId` = 로그인 사용자 UUID
- `delYn` = `N`
- `notiType` = `ADSS` (행정지원 전용 API)
### 4-1. 필드 정의
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| ---------- | ---------- | ---- | --------------------------- | ----------------- | ------------------------------------------------------------------------------------------------ |
| 기간(시작) | DatePicker | N | 오늘 − 1개월 (`YYYY-MM-DD`) | `startDt` | `NotiSearchDto.startDt` |
| 기간(종료) | DatePicker | N | 오늘 (`YYYY-MM-DD`) | `endDt` | `NotiSearchDto.endDt` |
| 구분 | Select | N | 전체(미선택) | `docSeComCd` | `INSTL` / `FNRS` / `SEWER` / `CNFM` / `PWRK` (또는 단축 `I`/`F`/`S`/`C`/`P`). 공통코드 `ASDOCSE` |
### 4-2. 버튼
| 버튼명 | 동작 |
| ------ | -------------------------------------------------------------- |
| 검색 | 영역 A 조건으로 `findMyAsNotiList` 호출 → 영역 B 갱신 (page=0) |
### 4-3. 콤보·기본값 주의
| 항목 | 내용 |
| -------------- | ---------------------------------------------------------- |
| 구분 = 전체 | `docSeComCd` 미전달. `notiType`은 서버가 `ADSS`로 고정 |
| 구분 옵션 소스 | 공통코드 `ASDOCSE` (`docSeComCd` ↔ `docSeComNm`) |
| 기간 | UI설계 상세설명: 기본 최근 1개월. `startDt`/`endDt`로 전달 |
---
## 5. 영역 B — 알림 이력 목록
### 5-1. 그리드 컬럼
| 표시명 | Field | 비고 |
| -------- | ------------ | --------------------------------------------------------------- |
| No | (row index) | 페이지 오프셋 반영 |
| 알림일시 | `regDt` | 표시 `YYYY-MM-DD HH:mm` (응답 `yyyy-MM-dd HH:mm:ss`) |
| 알림내용 | `notiTtl` | 제목 문자열. UI 샘플은 `[YYYY-MM-DD] …` 접두 |
| 연계업무 | `docSeComNm` | 문서구분 공통코드명 (`docSeComCd` + `@CodeName` `ASDOCSE`) |
| 상태 | (derived) | `Badge` 렌더링. 판정 규칙은 §5-1-1 |
| 처리 | (action) | 상태별 버튼 라벨. 클릭 시 `readNoti` 후 `notiUrl`로 이동 (§5-3) |
#### 5-1-1. 상태 UI 케이스 (`@common/components` `Badge`)
판정에 사용: `notiPrcsCmptnYn`, `notReadYn`, `regDt` (오늘 기준 경과일).
| 조건 | 라벨 | Badge |
| ------------------------------------------------------------------------------ | -------- | ----------------------------- |
| `notiPrcsCmptnYn` = `Y` | 처리완료 | `variant="default"` (primary) |
| `notiPrcsCmptnYn` = `N` 이고 `notReadYn` = `Y` 이고 `regDt`로부터 **7일 초과** | 처리지연 | `color="red"` |
| `notiPrcsCmptnYn` = `N` 이고 `notReadYn` = `Y` 이고 `regDt`로부터 **7일 이내** | 처리중 | `color="green"` |
| `notiPrcsCmptnYn` = `N` 이고 `notReadYn` = `N` 이고 `regDt`로부터 **3일 초과** | 지연 | `color="orange"` |
| `notiPrcsCmptnYn` = `N` 이고 `notReadYn` = `N` 이고 `regDt`로부터 **3일 이내** | 미조회 | `color="yellow"` |
```
IF notiPrcsCmptnYn = Y THEN
DISPLAY Badge "처리완료" (primary / variant=default)
ELSE IF notReadYn = Y THEN
IF daysSince(regDt) > 7 THEN
DISPLAY Badge "처리지연" (red)
ELSE
DISPLAY Badge "처리중" (green)
END IF
ELSE // notReadYn = N
IF daysSince(regDt) > 3 THEN
DISPLAY Badge "지연" (orange)
ELSE
DISPLAY Badge "미조회" (yellow)
END IF
END IF
```
- `daysSince(regDt)`: 오늘 날짜 − `regDt` 일자 (일 단위)
- `regDt`가 없으면 상태 Badge 표시 전략 TBD (미표시 또는 기본값)
응답에 있으나 그리드에 **표시하지 않음**:
| Field | 비고 |
| ----------------- | ---------------------------------------------------------------------- |
| `notiId` | 알림 ID — 행 키 |
| `recvUnqId` | 수신 회원 UUID (서버가 로그인 사용자로 고정) |
| `senderUnqId` | 발신 회원 UUID |
| `notiType` | 알림 종류 (`ADSS`) |
| `notiCn` | 알림 내용 (목록에서는 서버가 제외할 수 있음) |
| `notReadYn` | 확인 여부 `Y`/`N`. 상태 Badge 판정에 사용 (§5-1-1) |
| `notiUrl` | 처리 버튼 이동 URL. 컬럼 미표시 |
| `notiLnkgUnqNo` | 알림연결고유번호 |
| `docSeComCd` | 문서구분 공통코드. 연계업무 표시는 `docSeComNm` 사용 |
| `notiIdntyDt` | 알림 확인 일시 |
| `notiIdntyYn` | 알림 확인 여부 (`notReadYn`과 동일 의미). 상태 판정은 `notReadYn` 사용 |
| `notiPrcsCmptnYn` | 알림 처리 완료 여부. 상태 Badge 판정에 사용 (§5-1-1) |
| `notiPrcsCmptnDt` | 알림 처리 완료 일시 |
### 5-2. 목록 메타·페이지네이션
| 항목 | 매핑 |
| ------------- | ------------------------------------------- |
| 건수 / size ▼ | 상단 우측 `N개` 셀렉트 → `Pageable.size` |
| page / size | `NotiSearchDto`의 `PageableParams` |
| 페이지 컨트롤 | Spring `Page` 표준 (`number`, `totalPages`) |
### 5-3. 행 상호작용
| 동작 | 설명 |
| -------------- | --------------------------------------------------------------------------- |
| 처리 버튼 클릭 | `readNoti(notiId)`로 읽음 처리 후, 해당 알림에 연계된 URL(`notiUrl`)로 이동 |
| 행 클릭 | UI설계에 없음. 처리 버튼만 명시 |
**처리 버튼 라벨** (상태 Badge §5-1-1 기준):
| 상태 | 버튼명 |
| -------- | -------- |
| 미조회 | 처리하기 |
| 지연 | 처리하기 |
| 처리중 | 처리하기 |
| 처리지연 | 처리하기 |
| 처리완료 | 상세보기 |
- `notiUrl`이 비어 있으면 이동하지 않음 (no-op). 읽음 처리는 `notiUrl` 유무와 관계없이 호출
- 읽음 처리 성공 후 목록 쿼리(`notiBaseKey`)를 invalidate 하여 영역 B 상태를 갱신
---
## 6. API/DTO 명세
### 6-1. 공통
| 항목 | 값 |
| --------------- | ---------------------------------------------------------------------------------------- |
| Base prefix | `/cms-service/api/v1/noti` (`ENDPOINT.CMS_SERVICE.NOTI`) |
| 인증 | 로그인 사용자 UUID — 서버 `SecurityUtils.getLoggedInUserUnqIdUUID()` (클라이언트 미전달) |
| 클라이언트 모듈 | `@/entities/noti` — `notiService` / `useFindMyAsNotiList` / `useReadNoti` |
### 6-2. 로그인한 사용자 행정지원 알림 목록 — `findMyAsNotiList`
| 기능 | Method | URL | Request DTO | Response DTO | API |
| ---------------------------------- | ------ | ------------------- | --------------------------------- | ------------------------------------- | ------------------------------------------------------ |
| 로그인한 사용자 행정지원 알림 목록 | GET | `{BASE}/as/my/list` | Query: `NotiSearchDto` + Pageable | `PageResponse<NotiAsUserResponseDto>` | `notiService.findMyAsNotiList` / `useFindMyAsNotiList` |
**Request (`NotiSearchDto` + Pageable)**
| 필드 | 필수 | 설명 |
| ------------------------ | ---- | --------------------------------------------------------------------------- |
| `recvUnqId` | N | 수신 회원 UUID. 서버가 로그인 사용자로 덮어씀 |
| `senderUnqId` | N | 발신 회원 UUID. 본 화면 필터에 없음 |
| `notiType` | N | 알림 종류. 본 API는 서버가 `ADSS`로 고정 |
| `notiTtlKeyword` | N | 알림 제목 키워드. 본 화면 필터에 없음 |
| `notiCnKeyword` | N | 알림 내용 키워드. 본 화면 필터에 없음 |
| `notReadYn` | N | 알림 확인 여부 (`Y`/`N`). 본 화면 검색 필터에 없음 |
| `delYn` | N | 삭제 여부. 서버가 `N`으로 고정 |
| `startDt` | N | 시작일자 `YYYY-MM-DD` — 영역 A 기간(시작) |
| `endDt` | N | 종료일자 `YYYY-MM-DD` — 영역 A 기간(종료) |
| `docSeComCd` | N | 문서 구분 공통 코드 (`INSTL`/`FNRS`/`SEWER`/`CNFM`/`PWRK` 등) — 영역 A 구분 |
| `page` / `size` / `sort` | N | Spring Pageable |
**Response (`NotiAsUserResponseDto`)**
| 필드 | 타입 | 설명 |
| ----------------- | ------- | ------------------------------------------------------- |
| `notiId` | number | 알림 ID |
| `recvUnqId` | string | 수신 회원 UUID |
| `senderUnqId` | string | 발신 회원 UUID |
| `notiType` | string | 알림 종류 (`ADSS`) |
| `notiTtl` | string | 알림 제목 |
| `notiCn` | string? | 알림 내용 (목록에서 제외될 수 있음) |
| `notReadYn` | string? | 알림 확인 여부 (`Y`/`N`). 상태 Badge 판정 |
| `regDt` | string? | 등록 일시 `yyyy-MM-dd HH:mm:ss`. 상태 Badge 경과일 기준 |
| `notiUrl` | string? | 알림 URL 주소 |
| `notiLnkgUnqNo` | string? | 알림연결고유번호 |
| `docSeComCd` | string? | 문서구분 공통코드 |
| `docSeComNm` | string? | 문서구분 공통코드명 (`ASDOCSE`) |
| `notiIdntyDt` | string? | 알림 확인 일시 `yyyy-MM-dd HH:mm:ss` |
| `notiIdntyYn` | string? | 알림 확인 여부 (`Y`/`N`) |
| `notiPrcsCmptnYn` | string? | 알림 처리 완료 여부 (`Y`/`N`). 상태 Badge 판정 |
| `notiPrcsCmptnDt` | string? | 알림 처리 완료 일시 `yyyy-MM-dd HH:mm:ss` |
### 6-3. 알림 읽음 처리 — `readNoti`
| 기능 | Method | URL | Request DTO | Response DTO | API |
| -------------- | ------ | ---------------------- | ----------------------- | ---------------- | -------------------------------------- |
| 알림 읽음 처리 | PUT | `{BASE}/{notiId}/read` | Path: `notiId` (`Long`) | `Long` (알림 ID) | `notiService.readNoti` / `useReadNoti` |
**Request**
| 필드 | 위치 | 필수 | 설명 |
| -------- | ---- | ---- | ------- |
| `notiId` | Path | Y | 알림 ID |
Body 없음.
**Response**
| 타입 | 설명 |
| ------ | ------------------------------ |
| number | 읽음 처리된 알림 ID (`notiId`) |
화면 매핑: 영역 B [처리] 버튼 클릭 시 호출. 성공 시 `notiBaseKey` invalidate → `findMyAsNotiList` 재조회.
---
## 7. UI 구현 가이드
### 7-1. 기술 스택·원칙
- UI: `@common/components` — DatePicker, Select, Button, DataTable, Pagination, **Badge**
- 목록: `@tanstack/react-table` + `useDataTable` (기존 `my-pbadms-sprt-list` 패턴)
- 데이터: React Query `useFindMyAsNotiList(params)`, `useReadNoti()`
- 검색 조건·페이지: URL `searchParams` 동기화 권장 (`useSearchParamsState` 스킬)
- 처리 컬럼: 행 클릭이 아니라 **버튼 클릭**으로 `readNoti` 후 `notiUrl` 이동
- 상태 컬럼: §5-1-1 규칙으로 `Badge` 렌더링 (`notiPrcsCmptnYn` / `notReadYn` / `regDt`)
- 연계업무: `docSeComNm` 표시
### 7-2. 앱·파일 배치
| 구분 | 경로 |
| ------------ | --------------------------------------------------------------------- |
| 라우트 | `app/(main)/(with-base-layout)/noti/page.tsx` |
| 페이지 | `pages/noti/ui/noti-list-page.tsx` |
| 목록 그리드 | `pages/noti/ui/noti-list.tsx` |
| 테이블 컬럼 | `pages/noti/model/noti-table-columns.tsx` |
| 상태 판정 | `pages/noti/lib/resolve-noti-status.ts` |
| Entity API | `entities/noti/api/noti-service.ts` |
| Entity Hook | `entities/noti/api/queries.ts` → `useFindMyAsNotiList`, `useReadNoti` |
| Entity Model | `entities/noti/model/noti-interface.ts` |
화면 레이어(`pages/noti`)·Entity(`findMyAsNotiList`, `readNoti`) 구현 완료.
### 7-3. 영역별 UI 매핑
| 영역 | 컴포넌트 | 데이터 |
| ---- | ------------------- | -------------------------------------------------------------- |
| A | `noti-list-page` | 로컬 state → `NotiSearchDto` |
| B | DataTable + columns | `useFindMyAsNotiList` → `content[]`. 처리 버튼 → `useReadNoti` |
### 7-4. 다이얼로그·라우팅
| 항목 | 내용 |
| ----------- | --------------------------------------------------------------------- |
| 다이얼로그 | 없음 (목록 전용) |
| 목록 라우트 | `/noti` |
| 상세 이동 | 처리 버튼 → `readNoti(notiId)` 후 `notiUrl` (절대/상대 경로 형식 TBD) |
### 7-5. 데이터 흐름
```
BEGIN
// 최초 진입
SET 기간 = 최근 1개월 // startDt / endDt 전달
CALL findMyAsNotiList
DISPLAY 영역 B
ON [검색] CLICK DO
CALL findMyAsNotiList // → 영역 B 갱신
END ON
ON 페이지 CHANGE DO
CALL findMyAsNotiList
END ON
ON 영역 B [처리] 버튼 CLICK DO
CALL readNoti(notiId) // → 영역 B 상태 갱신 (invalidate)
IF NOT notiUrl THEN
// no-op (읽음 처리는 수행됨)
ELSE
NAVIGATE notiUrl
END IF
END ON
END
```
@@ -1,441 +0,0 @@
# 화면설계서: 이전기본계획조회 (기본 계획 찾기)
## 1. 기본 정보
| 항목 | 내용 |
| ---------- | --------------------------------------------------------------- |
| 버전 | 0.2 |
| 프로세스명 | UI설계 |
| 화면명 | 이전기본계획조회 (기본 계획 찾기) |
| 화면 유형 | [v] 조회 [ ] 입력 [ ] 수정 [ ] 삭제 [ ] 출력 |
| 메뉴 경로 | 하수도기본계획 등록·수정 > 사업개요 > [이전기본계획조회] Dialog |
---
## 2. 화면 목적
하수도기본계획 등록·수정 화면에서 **이전(기수립) 기본계획** 수치와 관련 보고서를 **조회·다운로드**한다. 시도·구군·연도·처리시설을 선택한 뒤 총설·처리구역별 현황을 표시하고, 분류 트리에서 기본계획 파일을 내려받는다. **부모 사업개요 폼에는 수치를 반영하지 않는다**(레거시 `bscPlnPopup.jsp`와 동일).
---
## 3. 화면 구성
### 3-1. 전체 레이아웃
부모: 하수도기본계획 등록/수정 폼 → 사업개요 섹션의 `[이전기본계획조회]` 버튼으로 Dialog 오픈.
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ Dialog 타이틀: 기본 계획 찾기 [닫기] │
├─────────────────────────────────────────────────────────────────────────────────┤
│ [영역 A] 검색 필터 │
│ 시도 ▼ 구군 ▼ 연도 ▼ 처리시설 ▼ [조회] │
├─────────────────────────────────────────────────────────────────────────────────┤
│ [영역 B] 기본계획파일 │
│ ┌──────────────────────┬────────────────────────────────────────────────────┐ │
│ │ [영역 B-1] 분류 트리 │ [영역 B-2] 파일 목록 │ │
│ │ (약 40%) │ (약 60%) │ │
│ │ └ 노드… │ 파일명 / 다운로드 │ │
│ └──────────────────────┴────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────────────────────┤
│ [영역 C] 총설 │
│ ┌────────┬──────────────당초──────────────┬──기준──┬──────────────변경──────────────┐ │
│ │ 구분 │ 1단계 │ 2단계 │ 3단계 │ 4단계 │ 기준 │ 1단계 │ 2단계 │ 3단계 │ 4단계 │ │
│ ├────────┼───────┼───────┼───────┼───────┼────────┼───────┼───────┼───────┼───────┤ │
│ │계획목표│ 년 │ 년 │ 년 │ 년 │ 년 │ 년 │ 년 │ 년 │ 년 │ │
│ │년도 │ │ │ │ │ │ │ │ │ │ │
│ └────────┴───────┴───────┴───────┴───────┴────────┴───────┴───────┴───────┴───────┘ │
├─────────────────────────────────────────────────────────────────────────────────┤
│ [영역 D] 처리구역별 현황 │
│ ┌────────┬──────────────당초──────────────┬──기준──┬──────────────변경──────────────┐ │
│ │ 구분 │ 1~4단계(+연도헤더) │ 기준 │ 1~4단계(+연도헤더) │ │
│ ├────────┼────────────────────────────────┼────────┼────────────────────────────────┤ │
│ │ 하수처리인구 … 배제방식 (읽기 전용 행) │ │
│ └────────┴────────────────────────────────┴────────┴────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘
```
### 3-2. 화면 데이터 흐름
```
BEGIN
// 부모 폼 — 사업개요
ON [이전기본계획조회] 버튼 CLICK DO
OPEN PreviousPlanLookupDialog
// 프리필: 부모 폼 sewerBscPlan.sido / gugun / admdstCd
CALL findSidoCombo()
DISPLAY 시도 옵션 (영역 A)
IF 시도 프리필 THEN
CALL findGugunCombo(sidoCd)
DISPLAY 구군 옵션 (영역 A)
CALL getLvNowDmgMtncGnsvy({ level: "1", ctpvNm })
// TBD: useLvNowDmgMtncGnsvy enabled — level별 필수 파라미터만 검사하도록 조정
IF 구군 프리필 THEN
CALL getLvNowDmgMtncGnsvy({ level: "2", ctpvNm, sggNm })
DISPLAY 연도 옵션 (영역 A) // children 중 level=3 노드의 gnsvyYr
CALL getClsfSystm({ clsfSystmNm: gugunNm })
DISPLAY 분류 트리 (영역 B-1)
END IF
END IF
END ON
// 영역 A: 시도 변경
ON 영역 A 시도 CHANGE DO
RESET 구군 / 연도 / 처리시설
CALL findGugunCombo(sidoCd)
CALL getLvNowDmgMtncGnsvy({ level: "1", ctpvNm })
// 구군 미선택 → 연도·처리시설 옵션 비움, 트리 빈 안내
END ON
// 영역 A: 구군 변경
ON 영역 A 구군 CHANGE DO
RESET 연도 / 처리시설
CALL getLvNowDmgMtncGnsvy({ level: "2", ctpvNm, sggNm })
DISPLAY 연도 옵션 (영역 A) // level=3 노드 → gnsvyYr
CALL getClsfSystm({ clsfSystmNm: sggNm })
DISPLAY 분류 트리 (영역 B-1)
END ON
// 영역 A: 연도 변경
ON 영역 A 연도 CHANGE DO
RESET 처리시설
CALL getLvNowDmgMtncGnsvy({ level: "3", ctpvNm, sggNm, gnsvyYr })
DISPLAY 처리시설 옵션 (영역 A) // level=3 노드 children → swtrmtFcltCd / swtrmtFcltNm
END ON
// 영역 A: 조회
ON [조회] 버튼 CLICK DO
VALIDATE 시도, 구군, 연도(targetYear), 처리시설(swtrmtFcltCd)
CALL getNowDmgMtncGnsvy({ swtrmtFcltCd, ctpvNm, sggNm, targetYear })
DISPLAY 총설 (영역 C) // 응답[0]의 planYr / planYrDt* / planYrDc*
DISPLAY 처리구역별 현황 (영역 D) // 응답[0]의 지표 Dt1~4 / base / Dc1~4
END ON
// 영역 B: 트리 노드 선택
ON 영역 B-1 노드 CLICK DO
CALL getFile({ clsfSystmSn, sort, vetPrcsYn }) // Response DTO TBD
DISPLAY 파일 목록 (영역 B-2)
END ON
// 영역 B: 파일 다운로드
ON 영역 B-2 파일 CLICK DO
CALL getFilePrcs(fileSn) // 파일 메타(경로·제목) — Response TBD
CALL fileDownload({ filePath, fileName })
// 브라우저 다운로드 트리거
END ON
ON [닫기] DO
CLOSE PreviousPlanLookupDialog
// 부모 폼 값 변경 없음
END ON
END
```
---
## 4. 영역 A — 검색 필터
구성: **시도 · 구군 · 연도 · 처리시설** (+ 조회).
- 시도·구군: `adstdstr` 콤보 (기존 스펙 유지)
- 연도·처리시설: 각각 `useLvNowDmgMtncGnsvy`를 **별도 호출**. `level`에 따라 반환 계층이 달라진다.
### 4-1. 필드 정의
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| -------- | --------- | ---- | ------------------------- | ------------------------------------------------ | -------------------------------------------------------------- |
| 시도 | Select | Y | 부모 `sewerBscPlan.sido` | `ctpvNm`(표시명), 코드는 `adstDstrCd` | `useFindSidoCombo` |
| 구군 | Select | Y | 부모 `sewerBscPlan.gugun` | `sggNm`(표시명), 코드는 `adstDstrCd` | `useFindGugunCombo(sidoCd)` |
| 연도 | Select | Y | 없음 | `gnsvyYr` / 조회 시 `targetYear` | `useLvNowDmgMtncGnsvy({ level: "2", ctpvNm, sggNm })` |
| 처리시설 | Select | Y | 없음 | `swtrmtFcltCd` ← 옵션 value, 라벨 `swtrmtFcltNm` | `useLvNowDmgMtncGnsvy({ level: "3", ctpvNm, sggNm, gnsvyYr })` |
> 처리시설은 `bns-if` `useFacilityList`를 **사용하지 않는다**.
### 4-2. 버튼
| 버튼명 | 동작 |
| ------ | -------------------------------------------------------- |
| 조회 | 필수값 검증 후 `getNowDmgMtncGnsvy` 호출 → 영역 C·D 갱신 |
| 닫기 | Dialog 종료 (부모 미반영) |
### 4-3. `lvNowDmgMtncGnsvy` level 캐스케이드
| 트리거 | Request `level` | Request 파라미터 | UI에 쓰는 응답 | 옵션 추출 |
| --------- | --------------- | ---------------------------- | --------------- | ------------------------------------------------------------------------------ |
| 시도 선택 | `"1"` | `ctpvNm` | (하위 초기화용) | 연도·처리시설 Select 리셋. 구군은 `findGugunCombo` |
| 구군 선택 | `"2"` | `ctpvNm`, `sggNm` | 연도 Select | 트리에서 `level === 3` 노드의 `gnsvyYr` |
| 연도 선택 | `"3"` | `ctpvNm`, `sggNm`, `gnsvyYr` | 처리시설 Select | 선택 연도(`level === 3`) 노드의 `children[]` → `swtrmtFcltCd` / `swtrmtFcltNm` |
상위 변경 시 하위 선택값·옵션을 RESET한다 (시도→구군·연도·시설 / 구군→연도·시설 / 연도→시설).
---
## 5. 영역 B — 기본계획파일
### 5-1. 트리 (B-1)
| 표시명 | Field | 비고 |
| ------ | --------------- | ------------------------ |
| 분류명 | `clsfSystmNm` | `ClsfSystmResponseDto` |
| 노드키 | `clsfSystmSn` | 선택 시 파일 목록 조회 |
| 상위키 | `upClsfSystmSn` | 클라이언트에서 트리 조립 |
| 정렬 | `sortSeq` | |
| 경로 | `path` | |
- 조회 파라미터: `clsfSystmNm` = 선택 구군명(레거시와 동일). `upClsfSystmSn`은 하위 전개 시 TBD.
- 시도·구군 미선택 시: 「시도와 구군을 선택하세요.」 안내.
### 5-2. 파일 목록 (B-2)
| 표시명 | Field | 비고 |
| ------ | ----- | ------------------------------------------------------------------- |
| 파일명 | TBD | `getFile` Response DTO가 `unknown` — 백엔드 스키마 확정 후 매핑 |
| (기타) | TBD | 레거시: 트리 선택 후 목록 표시, 클릭 시 `fileprcs` → `fileDownload` |
### 5-3. 버튼·상호작용
| 동작 | 설명 |
| -------------- | -------------------------------------------------------------- |
| 트리 노드 클릭 | `getFile({ clsfSystmSn, … })` → B-2 목록 |
| 파일 클릭 | `getFilePrcs(fileSn)` → `fileDownload({ filePath, fileName })` |
| 빈 목록 | 「트리에서 항목을 선택하세요.」 / 「파일이 없습니다.」 |
---
## 6. 영역 C — 총설
### 6-1. 테이블 구조
| 행(구분) | 열 매핑 (당초1~~4 / 기준 / 변경1~~4) |
| ------------ | ------------------------------------------------------------------------------ |
| 계획목표년도 | `planYrDt1planYrDt4` ~~/~~ `planYr` ~~/~~ `planYrDc1planYrDc4` (+ `년` 접미사) |
- 헤더: 당초(1~~4단계) · 기준 · 변경(1~~4단계)
- 데이터 소스: `getNowDmgMtncGnsvy` 응답 배열의 **첫 번째** 요소
- 읽기 전용
---
## 7. 영역 D — 처리구역별 현황
### 7-1. 행 정의 (표시명 → Field 접두사)
각 행은 `Dt1Dt4` ~~/ (접미사 없음=기준) /~~ `Dc1Dc4` 셀로 표시한다. 숫자는 천단위 콤마, 없으면 `-`.
| 표시명 | Field 접두사 | 비고 |
| ------------------------ | -------------------- | ---------------------------------------------- |
| 하수처리인구(인) | `swtrmtPpltnCnt` | |
| 자연적하수처리인구수(인) | `natrSwtrmtPpltnCnt` | |
| 사회적하수처리인구수(인) | `scilSwtrmtPpltnCnt` | |
| 하수처리구역면적 | `swtrmtZoneArea` | |
| 물사용량원단위 | `wtrUsqtyOrgnUnit` | |
| 오수전환율(%) | `drtwSwtcRt` | |
| 변동부하율(%) | `flctnLodRt` | |
| 오수원단위 | `drtwOrgnUnit` | |
| 계획하수량합계(㎥/일) | `planSwageQntSum` | |
| 생활오수(㎥/일) | `lvngDrtwSum` | |
| 지하수(㎥/일) | `ugwtSum` | |
| 공장폐수(㎥/일) | `fctryWwtSum` | |
| 관광오수(㎥/일) | `toursmDrtwSum` | |
| 군부대(㎥/일) | `mtbsSum` | |
| 연계처리수(㎥/일) | `linkPrcsCntSum` | |
| 기타오수(㎥/일) | `etcDrtwSum` | |
| 지하수사용량(㎥/일) | `ugwtUseSum` | |
| 시설용량(㎥/일) | `fcltCpctSum` | |
| 배제방식 | `exclsMethExpln` | 문자열; 단계별 `*Dt*`/`*Dc*` 존재 시 동일 규칙 |
헤더 3행째 연도: 영역 C와 동일하게 `planYrDt*` / `planYr` / `planYrDc*` 표시.
---
## 8. API/DTO 명세
### 8-1. 공통
| 항목 | 값 |
| --------------- | ---------------------------------------------------- |
| DC Base | `/dc-service/api/v1` (`ENDPOINT.DC_SERVICE`) |
| AS adstdstr | `/as-service/api/v1/adstdstr` |
| 클라이언트 모듈 | `@/entities/dc` (`dcService`), `@/entities/adstdstr` |
> 영역 A 처리시설은 `bns-if`를 사용하지 않음.
### 8-2. 시도·구군 콤보 (non-DC)
| 기능 | Method | URL | Request DTO | Response DTO | API |
| -------- | ------ | ----------------------------------- | ------------------------ | -------------------------------- | ---------------------------------------------------------- |
| 시도목록 | GET | `/as-service/api/v1/adstdstr/sido` | — | `CmCdAdstDstrComboResponseDto[]` | `cmCdAdstDstrService.findSidoCombo` / `useFindSidoCombo` |
| 구군목록 | GET | `/as-service/api/v1/adstdstr/gugun` | Query: `sido` (시도코드) | `CmCdAdstDstrComboResponseDto[]` | `cmCdAdstDstrService.findGugunCombo` / `useFindGugunCombo` |
### 8-3. 연도·처리시설 계층 조회 — DC
| 기능 | Method | URL | Request DTO | Response DTO | API |
| --------------- | ------ | -------------------------------------- | ---------------------------- | -------------------------------- | --------------------------------------------------------- |
| 계층형 계획목록 | GET | `/dc-service/api/v1/lvNowDmgMtncGnsvy` | `LvNowDmgMtncGnsvySearchDto` | `LvNowDmgMtncGnsvyResponseDto[]` | `dcService.getLvNowDmgMtncGnsvy` / `useLvNowDmgMtncGnsvy` |
연도 Select·처리시설 Select는 **각각** `useLvNowDmgMtncGnsvy`를 호출한다 (`level`만 다르게).
**Request (**`LvNowDmgMtncGnsvySearchDto`**)**
| 필드 | 시도(`level=1`) | 구군→연도(`level=2`) | 연도→시설(`level=3`) |
| --------- | --------------- | -------------------- | -------------------- |
| `level` | `"1"` | `"2"` | `"3"` |
| `ctpvNm` | 필수 | 필수 | 필수 |
| `sggNm` | — | 필수 | 필수 |
| `gnsvyYr` | — | — | 필수 |
**Response 노드 필드 (**`LvNowDmgMtncGnsvyResponseDto`**, 부분 optional)**
| 필드 | 등장 level | 설명 |
| -------------- | ---------- | ------------------------ |
| `level` | 1~3 | 계층 깊이 |
| `ctpvNm` | 1 | 시도명 |
| `sggNm` | 2 | 구군명 |
| `gnsvyYr` | 3 | 연도 → 연도 Select value |
| `swtrmtFcltCd` | 시설(리프) | 처리시설 코드 |
| `swtrmtFcltNm` | 시설(리프) | 처리시설명 |
| `children` | 1~3 | 하위 노드 배열 |
> **TBD**: 현재 `useLvNowDmgMtncGnsvy` `enabled`가 `level && ctpvNm && sggNm && gnsvyYr`로 고정되어 `level=1`/`2` 호출이 막힘. level별 필수 파라미터만 검사하도록 수정 필요.
>
> 예: `level=1` → `ctpvNm` / `level=2` → `ctpvNm && sggNm` / `level=3` → `ctpvNm && sggNm && gnsvyYr`
### 8-4. 기본계획 수치 조회 — DC
| 기능 | Method | URL | Request DTO | Response DTO | API |
| ---------------- | ------ | ------------------------------------ | -------------------------- | ------------------------------ | ----------------------------------------------------- |
| 현재·단계별 수치 | GET | `/dc-service/api/v1/nowDmgMtncGnsvy` | `NowDmgMtncGnsvySearchDto` | `NowDmgMtncGnsvyResponseDto[]` | `dcService.getNowDmgMtncGnsvy` / `useNowDmgMtncGnsvy` |
**Request (**`NowDmgMtncGnsvySearchDto`**)**
| 필드 | 매핑 |
| -------------- | ----------------------- |
| `swtrmtFcltCd` | 영역 A 처리시설 |
| `ctpvNm` | 영역 A 시도 표시명 |
| `sggNm` | 영역 A 구군 표시명 |
| `targetYear` | 영역 A 연도 (`gnsvyYr`) |
**Response**: 지표 필드 + `Dt1Dt4` ~~/~~ `Dc1Dc4` 접미사 스테이지 필드 (`NowDmgMtncGnsvyResponseDto`). UI는 `data[0]` 사용.
### 8-5. 분류체계 — DC
| 기능 | Method | URL | Request DTO | Response DTO | API |
| ------------ | ------ | ------------------------------ | -------------------- | ------------------------ | ----------------------------------------- |
| 분류트리조회 | GET | `/dc-service/api/v1/clsfSystm` | `ClsfSystmParamsDto` | `ClsfSystmResponseDto[]` | `dcService.getClsfSystm` / `useClsfSystm` |
**Request**: `clsfSystmNm?`, `upClsfSystmSn?`
**Response**: `clsfSystmSn`, `upClsfSystmSn`, `clsfSystmNm`, `sortSeq`, `droppAble`, `path`
> 레거시 경로 `/clsf` → 현재 클라이언트는 `/clsfSystm`.
### 8-6. 파일 목록·처리·다운로드 — DC
| 기능 | Method | URL | Request DTO / Params | Response DTO | API |
| ------------ | ------ | --------------------------------- | ------------------------------------------------------- | ----------------- | -------------------------------------------- |
| 파일목록 | GET | `/dc-service/api/v1/file` | `FileParamsDto` (`sort?`, `clsfSystmSn?`, `vetPrcsYn?`) | `unknown` **TBD** | `dcService.getFile` / `useFile` |
| 파일처리정보 | GET | `/dc-service/api/v1/fileprcs` | Query: `fileSn` | `unknown` **TBD** | `dcService.getFilePrcs` / `useFilePrcs` |
| 파일다운로드 | POST | `/dc-service/api/v1/fileDownload` | `FileDownloadParamsDto` (`filePath?`, `fileName?`) | `unknown` **TBD** | `dcService.fileDownload` / `useFileDownload` |
> **TBD**: 파일 관련 Response 스키마·다운로드 blob/헤더 처리·`fileDownload` POST body vs query 전달 방식은 백엔드/게이트웨이 확인 후 확정.
---
## 9. UI 구현 가이드
### 9-1. 기술 스택·원칙
- UI: `@common/components` — `Dialog`, `Select`, `Button`, `Table`, `Skeleton`
- 상태: Dialog 로컬 state + React Query (`entities/dc`, `adstdstr`)
- 영역 A 연도·처리시설: `useLvNowDmgMtncGnsvy`를 level별 **두 번**(또는 시도 포함 시 세 번) 호출
- 테이블: 읽기 전용 고정 행 → `@tanstack/react-table` / `DataTable` **불필요**
- 트리: jsTree 미사용 — 재귀 들여쓰기 트리
- 번들: `PreviousPlanLookupDialog`는 outline에서 `next/dynamic` 로드 권장
- 부모 폼: **수치 미반영** (조회·다운로드 only)
- 1차 구현 시 DC 미연동이면 `@faker-js/faker` mock + TODO → 이후 `dcService` 훅으로 교체
### 9-2. 앱·파일 배치
| 구분 | 경로 |
| ------------ | ------------------------------------------------------------------------ |
| 진입 버튼 | `pages/sewer-bsc-plan/ui/sections/outline-form-section.tsx` |
| Dialog | `pages/sewer-bsc-plan/ui/previous-plan-lookup-dialog.tsx` |
| 필터 | `pages/sewer-bsc-plan/ui/previous-plan/previous-plan-search-filters.tsx` |
| 파일 | `pages/sewer-bsc-plan/ui/previous-plan/previous-plan-file-section.tsx` |
| 총설 | `pages/sewer-bsc-plan/ui/previous-plan/previous-plan-summary-table.tsx` |
| 처리구역 | `pages/sewer-bsc-plan/ui/previous-plan/previous-plan-area-table.tsx` |
| 셀 매핑 유틸 | `pages/sewer-bsc-plan/lib/previous-plan-table-utils.ts` |
| mock(선택) | `pages/sewer-bsc-plan/lib/previous-plan-mock.ts` |
| DC entity | `entities/dc/api/dc-service.ts`, `queries.ts`, `model/dc-interface.ts` |
| 레거시 참조 | `adssNewOld/.../cmmn/bscPlnPopup.jsp` |
### 9-3. 영역별 UI 매핑
| 영역 | 컴포넌트 | 데이터 |
| ---- | ------------------------------ | -------------------------------------------------------------- |
| A | `previous-plan-search-filters` | adstdstr + `useLvNowDmgMtncGnsvy`(level 1·2·3) |
| B | `previous-plan-file-section` | `useClsfSystm` + `useFile` + `useFilePrcs` + `useFileDownload` |
| C | `previous-plan-summary-table` | `useNowDmgMtncGnsvy` / 조회 결과 state |
| D | `previous-plan-area-table` | 동일 |
### 9-4. 다이얼로그·라우팅
| 항목 | 내용 |
| ------------ | ------------------------------------------------------------------------ |
| 다이얼로그명 | `PreviousPlanLookupDialog` / 타이틀 `기본 계획 찾기` |
| 오픈 | 사업개요 `[이전기본계획조회]` |
| 라우트 | 없음 (모달). 부모: `/sewer-bsc-plan/regist`, `/sewer-bsc-plan/[id]/edit` |
| 선택 반영 | 없음 |
### 9-5. 데이터 흐름 (구현 요약)
```
BEGIN
ON [이전기본계획조회] CLICK DO
OPEN PreviousPlanLookupDialog (sido/gugun 프리필)
END ON
ON 시도 CHANGE DO
CALL findGugunCombo
CALL getLvNowDmgMtncGnsvy(level=1)
RESET 연도·처리시설
END ON
ON 구군 CHANGE DO
CALL getLvNowDmgMtncGnsvy(level=2) // → 연도 옵션
CALL getClsfSystm
RESET 처리시설
END ON
ON 연도 CHANGE DO
CALL getLvNowDmgMtncGnsvy(level=3) // → 처리시설 옵션
END ON
ON [조회] CLICK DO
VALIDATE
CALL getNowDmgMtncGnsvy
DISPLAY 영역 C · 영역 D
END ON
ON 트리 노드 / 파일 CLICK DO
CALL getFile → getFilePrcs → fileDownload // Response TBD
END ON
END
```
---
## 10. 미확정 (TBD) 추적
| ID | 항목 | 비고 |
| --- | -------------------------------------------------------- | -------------------------------------------------------------------- |
| T1 | `useLvNowDmgMtncGnsvy` enabled을 level별 필수값으로 분기 | `1`: ctpvNm / `2`: +sggNm / `3`: +gnsvyYr |
| T2 | `getFile` / `getFilePrcs` / `fileDownload` Response DTO | 현재 `unknown` |
| T3 | 파일 다운로드 UX (blob, Content-Disposition, 게이트웨이) | 레거시 apigateway 직접 호출과 차이 |
| T4 | `clsfSystm`의 `upClsfSystmSn` 점진 로드 여부 | flat 목록 vs 계층 API |
| T5 | 1차 mock vs 즉시 `dcService` 실연동 | 마이그레이션 플랜은 mock 우선, entity는 준비됨 |
| T6 | 부모 폼 수치 반영 | 요구 없음(레거시 동일). 요청 시 별도 스펙 |
| T7 | `level=1` 응답 UI 활용 여부 | 시도 선택 시 호출하나 구군은 adstdstr — 응답은 캐시·검증용일 수 있음 |
@@ -1,370 +0,0 @@
# 화면설계서: 검토인력관리
## 1. 기본 정보
| 항목 | 내용 |
| ---------- | -------------------------------------------- |
| 버전 | 0.2 |
| 프로세스명 | UI설계 |
| 화면명 | 검토인력관리 |
| 화면 유형 | [v] 조회 [v] 입력 [ ] 수정 [v] 삭제 [ ] 출력 |
| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 검토인력관리 |
---
## 2. 화면 목적
조직도에서 **한 명의 사용자**를 선택한 뒤, 결재 단계(접수·검토·승인·최종승인)별 **인원 배치** 영역에 즉시 등록한다. 등록·목록 갱신은 별도 저장 버튼 없이 **[선택 인원 배치]** 클릭 시 `rvw-mnpw` API로 바로 반영한다. 좌측 조직도는 CMS 조직·사용자 트리 API를, 우측 배치 목록은 검토인력관리 API를 사용한다.
---
## 3. 화면 구성
### 3-1. 전체 레이아웃
```
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 페이지 타이틀: 검토인력관리 브레드크럼: 디지털행정지원 > 사이트관리 > 검토인력관리 │
├─────────────────────────────────┬──────────────────────────────────────────────────────────────────┤
│ [영역 A] 조직도 │ [영역 B] 인원 배치 (결재구분별 4블록, 세로 스택) │
│ 제목: 조직도 │ │
│ [A-1] 검색: │ ┌─ [B-1] 접수 ───────────── count ─ [선택 인원 배치] ─┐ │
│ [성명·부서 검색] [조회][초기화]│ │ (배치 목록 또는 빈 상태 문구) │ │
│ 선택 안내: │ └──────────────────────────────────────────────────────┘ │
│ "선택된 인원: {이름} │ ┌─ [B-2] 검토 ───────────── count ─ [선택 인원 배치] ──┐ │
│ ({부서} / {직급})" │ └──────────────────────────────────────────────────────┘ │
│ [A-2] 조직 트리 + 사용자 행 │ ┌─ [B-3] 승인 ───────────── count ─ [선택 인원 배치] ──┐ │
│ ┌────────────────────┐ │ └──────────────────────────────────────────────────────┘ │
│ │ 조직 트리 + 사용자 │ │ ┌─ [B-4] 최종승인 ───────── count ─ [선택 인원 배치] ──┐ │
│ │ (단일 선택 강조) │ │ └──────────────────────────────────────────────────────┘ │
│ └────────────────────┘ │ │
└─────────────────────────────────┴──────────────────────────────────────────────────────────────────┘
```
### 3-2. 화면 데이터 흐름
```
BEGIN
// 최초 진입
CALL userOgnzService.findOgnzUserTree()
DISPLAY 조직도 트리 (영역 A)
CALL rvwMnpwMngService.findPage({ size: 충분히 큰 값 }) // 또는 atrzSeComCd별 분할 조회
GROUP BY atrzSeComCd
DISPLAY 인원 배치 4블록 (영역 B-1 ~ B-4) + 건수 뱃지
// 영역 A-1: 조직도 검색 (클라이언트 필터 — 서버 API 없음)
ON [조회] 버튼 CLICK / 검색 폼 SUBMIT DO
SET displayTree = filterOgnzUserTree(원본트리, keyword)
SET expandedOgnzSnIds = collectExpandedOgnzSnIds(displayTree) // 매칭 노드 자동 펼침
DISPLAY 필터된 트리 (영역 A-2)
END ON
ON [초기화] 버튼 CLICK DO
RESET keyword = ""
RESET displayTree = 원본트리
RESET expandedOgnzSnIds = 빈 Set (또는 기본 접힘)
DISPLAY 전체 트리 (영역 A-2)
END ON
// 영역 A-2: 사용자 단일 선택
ON 조직도 사용자 행 CLICK DO
VALIDATE 동일 행 재클릭이 아닌 경우만 변경
SET selectedPerson = { userUuid, userNm, ognzSn, ognzNm, jbgdSeCd, ... }
DISPLAY "선택된 인원: {userNm} ({ognzNm} / {직급명})"
END ON
// 영역 B-k: 선택 인원 즉시 배치
ON [B-k] [선택 인원 배치] 버튼 CLICK DO
VALIDATE selectedPerson 존재
SET targetAtrzSeComCd = 블록 k 에 매핑된 결재구분 코드 (§4-2 표)
CALL rvwMnpwMngService.save({
atrzSeComCd: targetAtrzSeComCd,
ognzSn: selectedPerson.ognzSn,
userUuid: selectedPerson.userUuid
})
ON 성공 DO
CALL rvwMnpwMngService.findPage (또는 해당 블록 목록만 갱신)
DISPLAY [B-k] 배치 목록·건수 뱃지 갱신
TOAST "배치되었습니다."
END ON
ON 실패(이미 존재) DO
TOAST (백엔드 메시지 연동)
END ON
END ON
// 영역 B-k: 배치 인원 제거 (UI 상 삭제 UX — 스크린샷 미표기, API는 제공됨)
ON 배치 인원 [삭제] / [제거] CLICK DO
CONFIRM "해당 인원을 배치에서 제거하시겠습니까?"
CALL rvwMnpwMngService.delete(atrzSeComCd, ognzSn, userUuid)
CALL rvwMnpwMngService.findPage
DISPLAY [B-k] 목록·뱃지 갱신
END ON
END
```
---
## 4. 영역 A — 조직도
### 4-1. 표시·상호작용
| 요소 | 내용 |
| ---------------- | --------------------------------------------------------------------- |
| 섹션 제목 | 조직도 |
| 검색 폼 (A-1) | 성명·부서 키워드 입력 + **[조회]** + **[초기화]** — §4-3 |
| 선택 안내 문구 | `선택된 인원: {userNm} ({ognzNm} / {직급표시})` — 미선택 시 안내 문구 |
| 트리 (A-2) | 본부 → 팀 계층 + 팀 소속 사용자 목록 (`displayTree` 기준 표시) |
| 선택 규칙 | **사용자 1명만** 선택 가능 (라디오형 단일 선택) |
| 선택 시각 피드백 | 선택된 사용자 행 강조(배경·테두리) |
| 로딩/오류/빈값 | Skeleton / 오류 문구 / "등록된 조직/사용자가 없습니다." |
| 검색 결과 없음 | 필터 후 매칭 노드·사용자 0건. "조회된 인원이 없습니다." 문구 노출 |
### 4-2. 조직도 데이터 매핑
| UI 표시 | 트리 DTO 필드 (`OgnzUserTreeResponseDto`) | 비고 |
| --------------- | ----------------------------------------- | ----------------------------------------- |
| 조직명 | `ognzNm` | 접기/펼치기 노드 |
| 사용자명 | `users[].userNm` | 클릭 시 선택 |
| 부서(안내 문구) | `ognzNm` (소속 팀 노드) | 선택 시 `selectedPerson.ognzNm` |
| 직급(안내 문구) | `users[].jbgdSeCd` 또는 `jbpsSeCd` | 공통코드 라벨 변환 |
| 저장용 조직 SN | `ognzSn` | 사용자가 속한 **리프/팀 노드**의 `ognzSn` |
| 저장용 사용자 | `users[].userUnqId` | API body `userUuid`로 전달 (§6-3) |
### 4-3. 조직도 검색 (영역 A-1)
결재선 관리 `AprvrPickerDialog`와 동일한 **클라이언트 사이드 필터** 패턴을 사용한다. 별도 검색 API는 없으며, `useOgnzUserTree`로 받은 **원본 트리**를 `filterOgnzUserTree`로 가공해 `displayTree`에 반영한다.
#### 필드 정의
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| -------------- | --------- | ---- | ------ | ----------------- | ----------------------------------------- |
| 성명·부서 검색 | Text | N | (빈값) | — (클라이언트만) | `placeholder="성명·부서 검색"` |
| 조회 | Button | — | — | — | `type="submit"` — 폼 제출 시 필터 적용 |
| 초기화 | Button | — | — | — | `type="button"` — 키워드·트리·펼침 초기화 |
#### 필터 규칙 (`filterOgnzUserTree`)
| 대상 | 매칭 조건 |
| ---------------- | ----------------------------------------------------- |
| 조직명 | `ognzNm`에 키워드 포함 (대소문자 무시) |
| 사용자명 | `userNm`에 키워드 포함 (대소문자 무시) |
| 조직 매칭 시 | 해당 노드 **소속 사용자 전원** 유지 |
| 사용자만 매칭 시 | 매칭 사용자만 남기고 상위 조직 노드는 유지(경로 보존) |
| 하위 조직 | 재귀 필터 — 자식·손자 노드 매칭 시 상위도 표시 |
| 키워드 빈값 | 원본 트리 전체 반환 |
#### 조회 후 트리 펼침 (`collectExpandedOgnzSnIds`)
- **[조회]** 성공 후 필터 결과 트리의 **모든 노드** `ognzSn`을 `expandedOgnzSnIds`에 넣어 자동 펼침
- 검색 결과에서 사용자·하위 조직을 바로 확인할 수 있도록 함
#### 초기화 동작
| 상태 | 초기화 후 값 |
| ------------------- | --------------------------------------- |
| `keyword` | `""` |
| `displayTree` | `useOgnzUserTree` 원본 `data` |
| `expandedOgnzSnIds` | `new Set()` (전체 접힘) |
| `selectedPerson` | **유지** — 검색 초기화와 선택 해제 분리 |
#### 버튼
| 버튼명 | 동작 |
| ------ | --------------------------------------------------------- |
| 조회 | `filterOgnzUserTree(data, keyword)` → `displayTree` 갱신 |
| 초기화 | 키워드·표시 트리·펼침 상태 리셋 (§3-2 `ON [초기화]` 블록) |
---
## 5. 영역 B — 인원 배치
### 5-1. 블록 공통
| 요소 | 내용 |
| ------------------ | ------------------------------------------------- |
| 블록 제목 | 접수 / 검토 / 승인 / 최종승인 |
| 건수 뱃지 | 해당 `atrzSeComCd`에 배치된 인원 수 |
| 액션 버튼 | **[선택 인원 배치]** — 클릭 시 즉시 `save` (§3-2) |
| 빈 상태 | "배치된 인원이 없습니다." |
| 배치 목록(있을 때) | 인원명·부서·직급 등 |
### 5-2. 결재구분 ↔ 블록 매핑
| 블록 | UI 제목 | `atrzSeComCd` | 비고 |
| ---- | -------- | ------------- | -------------------------------------------------------------- |
| B-1 | 접수 | `ATRZ00` | 결재선 관리 `ATRZ_RECEIVER_CD`와 동일 |
| B-2 | 검토 | `ATRZ01` | 결재선 `ATRZ_REVIEWER_1_CD`. `ATRZ02` 별도 블록 여부 — **TBD** |
| B-3 | 승인 | `ATRZ03` | 결재선 `ATRZ_APPROVER_CD` |
| B-4 | 최종승인 | `ATRZ99` | 결재선 `ATRZ_FINAL_CD` |
공통코드 마스터 `ATRZSE` / 화면 셀렉트용 `ATRZSECD`와 정합성은 배포 환경 코드표로 최종 확인.
### 5-3. 배치 목록 항목 (표시)
| 표시명 | Field (응답 DTO) | 비고 |
| -------- | ---------------- | ---------------------------- |
| 사용자명 | `userUuidNm` | `@CodeName` 미적용 시 TBD |
| 결재구분 | `atrzSeComCdNm` | 블록 제목과 중복 — 생략 가능 |
| 조직 SN | `ognzSn` | 삭제 API path에 사용 |
| 사용자 | `userUuid` | 삭제 API path에 사용 |
### 5-4. 버튼
| 버튼명 | 위치 | 동작 |
| -------------- | ----------- | --------------------------------- |
| 선택 인원 배치 | 각 B-k 헤더 | `save` 후 목록·뱃지 갱신 (§3-2) |
| 제거 | 목록 항목 | `delete` 후 목록·뱃지 갱신 (§3-2) |
### 5-5. 검증·제약
| 규칙 | 내용 |
| ------------- | ------------------------------------------------------------------- |
| 선택 필수 | `[선택 인원 배치]` 시 `selectedPerson` 없으면 버튼 비활성 또는 안내 |
| 단일 선택 | 조직도에서 사용자 1명만 `selectedPerson` 유지 |
| 중복 등록 | 동일 PK `(atrzSeComCd, ognzSn, userUuid)` 재등록 시 서버 오류 |
| 다건 배치 | 동일 블록에 서로 다른 사용자·조직은 **복수 행** 가능 (PK가 다름) |
| 결재구분 변경 | 본 화면 주 흐름은 `save`만 |
---
## 6. API/DTO 명세
### 6-1. 공통
| 항목 | 내용 |
| ------------- | ------------------------------------------------------------- |
| 인증 | JWT (`Authorization` 헤더) |
| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse<T>` |
| 프론트 엔티티 | `@/entities/user-organization`, `@/entities/rvw-mnpw` |
### 6-2. 조직·사용자 트리 (영역 A) — `user-ognz`
| 항목 | 내용 |
| ----------- | ------------------------------- |
| 서비스 | `cms-service` |
| Base prefix | `/cms-service/api/v1/user-ognz` |
| 기능 | Method | URL | Request DTO | Response DTO | API |
| ---------------- | ------ | ----------- | ----------- | --------------------------- | ---------------------------------- |
| 조직·사용자 트리 | GET | `/treeuser` | — | `OgnzUserTreeResponseDto[]` | `userOgnzService.findOgnzUserTree` |
**`OgnzUserTreeResponseDto`**: `ognzSn`, `upOgnzSn`, `ognzNm`, `users[]`, `children[]`
**`OgnzUserTreeUserDto`**: `userUnqId`, `userNm`, `jbpsSeCd`, `jbgdSeCd`
**React Query**: `useOgnzUserTree`
### 6-3. 검토인력관리 (영역 B) — `rvw-mnpw`
| 항목 | 내용 |
| ----------- | ------------------------------------- |
| 서비스 | `as-service` |
| Base prefix | `/as-service/api/v1/rvw-mnpw` |
| ENDPOINT | `ENDPOINT.AS_COMMON_SERVICE.RVW_MNPW` |
| 기능 | Method | URL | Request DTO / Query | Response DTO | API |
| --------------- | ------ | ------------------------------------ | -------------------------------- | ----------------------------- | ---------------------------- |
| 목록 조회 | GET | `/` | `RvwMnpwMngSearchDto` + Pageable | `Page<RvwMnpwMngResponseDto>` | `rvwMnpwMngService.findPage` |
| 상세 조회 | GET | `/{atrzSeComCd}/{ognzSn}/{userUuid}` | Path | `RvwMnpwMngResponseDto` | `rvwMnpwMngService.findById` |
| 등록(즉시 배치) | POST | `/` | `RvwMnpwMngSaveRequestDto` | `RvwMnpwMngResponseDto` (201) | `rvwMnpwMngService.save` |
| 결재구분 변경 | PUT | `/{atrzSeComCd}/{ognzSn}/{userUuid}` | `RvwMnpwMngUpdateRequestDto` | `RvwMnpwMngResponseDto` | `rvwMnpwMngService.update` |
| 삭제 | DELETE | `/{atrzSeComCd}/{ognzSn}/{userUuid}` | Path | `string` | `rvwMnpwMngService.delete` |
**`RvwMnpwMngSearchDto`**: `atrzSeComCd?`, `ognzSn?`, `userUuid?` + `page`, `size`, `sort`
**`RvwMnpwMngSaveRequestDto`**: `atrzSeComCd*`, `ognzSn*`, `userUuid*`
**`RvwMnpwMngUpdateRequestDto`**: `newAtrzSeComCd*` — 본 화면 기본 흐름에서는 미사용
**`RvwMnpwMngResponseDto`**: `atrzSeComCd`, `atrzSeComCdNm?`, `ognzSn`, `userUuid`, `userUuidNm?`, `rgtr?`, `regDt?`
### 6-4. 조직도 ↔ 검토인력 필드 매핑 (저장 시)
| 조직도 (`OgnzUserTreeUserDto` + 노드) | `RvwMnpwMngSaveRequestDto` | 비고 |
| ------------------------------------- | -------------------------- | --------------------- |
| (블록 k의 `atrzSeComCd`) | `atrzSeComCd` | §5-2 표 |
| 소속 팀 `ognzSn` | `ognzSn` | |
| `userUnqId` | `userUuid` | 문자열(UUID)로 직렬화 |
---
## 7. UI 구현 가이드
### 7-1. 기술 스택·원칙
- **공통 컴포넌트**: `@common/components` (`Button`, `Badge`, `Card` 또는 섹션 레이아웃)
- **데이터**: React Query — `useOgnzUserTree`, `useRvwMnpwMngPage`, `useSaveRvwMnpwMng`, `useDeleteRvwMnpwMng`
- **상태**: `selectedPerson`(단일), `keyword`, `displayTree`, `expandedOgnzSnIds`, 블록별 배치 목록(서버 데이터 파생)
- **조직도 검색**: `filterOgnzUserTree`, `collectExpandedOgnzSnIds` (`widgets/ognz-user-tree/lib` 또는 페이지 `lib`)
- **저장 UX**: 별도 화면 저장 없음 — **[선택 인원 배치]** = `save` mutation 즉시 호출
- **조직도 UI**: 결재선 관리 `ognz-user-tree.tsx`를 **widgets**로 승격해 공통화
### 7-2. 모듈·파일 배치(제안)
| 구분 | 경로 |
| -------- | ---------------------------------------------------------------------------------- |
| 라우트 | `apps/as/app/(with-base-layout)/rvw-mnpw-mng/page.tsx` |
| 페이지 | `apps/as/src/pages/rvw-mnpw-mng/` |
| UI | `.../ui/rvw-mnpw-mng-page.tsx`, `rvw-mnpw-assignment-panel.tsx` 등 |
| model | `.../model/selected-ognz-user.ts`, `rvw-mnpw-block-config.ts` (`atrzSeComCd` 상수) |
| lib | `.../lib/group-rvw-mnpw-by-atrz-se.ts` (목록 → B-1~B-4 분배) |
| **위젯** | `apps/as/src/widgets/ognz-user-tree/` — 아래 이전 대상 |
| 엔티티 | `entities/user-organization/`, `entities/rvw-mnpw/` (구현 완료) |
#### widgets/ognz-user-tree 이전 (결재선 → 공통)
| 현재 (결재선 관리) | 이전 후 (widgets) |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------ |
| `pages/approval-line-management/ui/ognz-user-tree.tsx` | `widgets/ognz-user-tree/ui/ognz-user-tree.tsx` |
| `pages/.../ui/ognz-user-tree-node.tsx` | `widgets/ognz-user-tree/ui/ognz-user-tree-node.tsx` |
| `pages/.../model/reviewer-option.ts` | `widgets/ognz-user-tree/model/selected-ognz-user.ts` (이름·필드 확장: `ognzSn` 필수) |
| `pages/.../lib/filter-ognz-user-tree.ts` | `widgets/ognz-user-tree/lib/filter-ognz-user-tree.ts` (**필수** — 검색 공통화) |
| `widgets/ognz-user-tree/index.ts` | `OgnzUserTree`, `OgnzUserTreeNode`, 타입 export |
결재선 `AprvrPickerDialog`는 `@/widgets/ognz-user-tree` import로 교체. 검토인력관리 페이지는 **인라인 조직도**(다이얼로그 없음)로 동일 위젯 사용.
**`SelectedOgnzUser`** — `ReviewerOption` 확장:
```typescript
export interface SelectedOgnzUser {
userUuid: string; // OgnzUserTreeUserDto.userUnqId
userNm: string;
ognzSn: number;
ognzNm: string;
jbgdSeCd?: string;
jbpsSeCd?: string;
}
```
### 7-3. 영역별 UI 매핑
| 영역 | 주요 UI |
| ---- | -------------------------------------------------------------------- |
| A-1 | 검색 폼 (`Input` + 조회/초기화) — `AprvrPickerDialog` 90~103행 패턴 |
| A-2 | `OgnzUserTree(nodes={displayTree})` + 선택 안내 + `useOgnzUserTree` |
| B-k | 제목·Badge(count)·`선택 인원 배치`·배치 목록(또는 empty) |
| 공통 | 페이지 헤더·브레드크럼·2열 레이아웃 (`grid` / `flex`, 모바일은 스택) |
### 7-4. 다이얼로그·라우팅
| 항목 | 내용 |
| ---------- | ----------------------------------- |
| 다이얼로그 | **없음** (조직도 인라인, 즉시 저장) |
| 라우팅 | 메뉴 `검토인력관리` 단일 페이지 |
### 7-5. 데이터 흐름(구현 요약)
- **진입**: `useOgnzUserTree` → `displayTree = data` + `useRvwMnpwMngPage({ page: 0, size: N })` → 4패널.
- **검색**: `keyword` 로컬 상태 → **[조회]** 시 `filterOgnzUserTree` + `collectExpandedOgnzSnIds` → `OgnzUserTree`에 `displayTree` 전달.
- **초기화**: `keyword`·`displayTree`·`expandedOgnzSnIds` 리셋 (`selectedPerson` 유지).
- **선택**: 트리 `onSelect` → `selectedPerson` (반드시 `ognzSn` 포함).
- **배치**: `useSaveRvwMnpwMng().mutate({ atrzSeComCd, ognzSn, userUuid })` → `onSuccess`에서 `rvw-mnpw` query invalidate.
- **삭제**: 목록 항목에서 `useDeleteRvwMnpwMng` — UI 확정 후 연결.
### 7-6. 미확정 / 검증 필요
- [ ] `ATRZ02`(검토2) 별도 블록 필요 여부
- [ ] 권한(역할별 CRUD)
-33
View File
@@ -1,33 +0,0 @@
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;
-88
View File
@@ -1,88 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import {
AUTH_USER_ID,
CLAIM_NAME,
PORT,
PROXY_HOST,
SERVER_API_URL,
SITE_ID,
} from "@/shared/config";
const serverApiUrl = process.env.SERVER_API_URL;
const dcServiceApiUrl = process.env.DC_SERVICE_API_URL;
// 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();
let serverUrl: string | undefined;
if (pathname.includes("/dc-service")) {
serverUrl = dcServiceApiUrl;
} else {
serverUrl = serverApiUrl;
}
const newUrl = `${serverUrl}${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);
}
// tus-js-client PATCH 요청: Location 헤더로 /server 없이 반환된 경로 직접 처리
// (예: /attach-service/api/v1/tus/upload/{id})
if (pathname.startsWith("/attach-service")) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
const userId = req.cookies.get(AUTH_USER_ID)?.value;
const newUrl = `${serverApiUrl}${pathname}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
logMessages.push(` [TUS Proxy] Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
const response = NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
return addSecurityHeaders(response);
}
if (pathname === "/") {
const response = NextResponse.redirect(new URL("/dashboard", req.url));
return response;
}
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
const response = NextResponse.next();
return addSecurityHeaders(response);
}
export const config = {
matcher: ["/server/:path*", "/attach-service/:path*", "/"],
};
-39
View File
@@ -1,39 +0,0 @@
import type { NextConfig } from "next";
const subDomain = process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
const nextConfig: NextConfig = {
output: "standalone",
reactStrictMode: true,
transpilePackages: ["@common/assets"],
experimental: {
globalNotFound: true,
},
async rewrites() {
return {
beforeFiles: [
{
source: "/_next/static/:path*",
destination: `${subDomain}/_next/static/:path*`,
has: [
{
type: "header",
key: "referer",
value: "(.*)/cms(.*)",
},
],
},
],
afterFiles: [
{
source: "/cms/:path*",
destination: `${subDomain}/cms/:path*`,
},
],
fallback: [],
};
},
};
export default nextConfig;
-50
View File
@@ -1,50 +0,0 @@
{
"name": "as",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3003",
"build": "next build",
"start": "next start -p 3003",
"lint": "next lint",
"lint:fsd": "steiger ."
},
"dependencies": {
"@common/assets": "workspace:*",
"@common/auth": "workspace:*",
"@common/components": "workspace:*",
"@common/network": "workspace:*",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@faker-js/faker": "^9.9.0",
"@frontend-opensource/use-react-hooks": "^1.1.4",
"@hookform/resolvers": "^4.1.3",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5",
"@tanstack/react-table": "^8.21.2",
"axios": "^1.8.1",
"class-variance-authority": "^0.7.1",
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"lucide-react": "^0.474.0",
"next": "15.5.18",
"react-hook-form": "^7.54.2",
"tus-js-client": "^4.3.1",
"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"
}
}
-9
View File
@@ -1,9 +0,0 @@
# 이 폴더는 비워 둡니다 (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)
-8
View File
@@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 483 KiB

-63
View File
@@ -1,63 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 538 755">
<defs>
<style>
.cls-1 {
fill: #fff;
}
</style>
</defs>
<!-- Generator: Adobe Illustrator 28.7.9, SVG Export Plug-In . SVG Version: 1.2.0 Build 218) -->
<g>
<g id="Layer_1">
<g id="han" data-label-shift="dx,dy">
<path class="cls-1" d="M159.2,197l-5,5.8,5.1,3.7,13.2,9.7-7.3,3.5-8.2-1.2-9.4,1.4,1.4,4.9-3.5,7.3,3.8,2.6-.9,4.7,8.9-2.3,4.2-3.7,5.2,4.2-6.1,2.3.9,12,3,7-.9,3.3-.5,1.6,5.4,6.8,5.2,13.4h-3.6c0-.1,6.4,3.8,6.4,3.8l4.8,6h1.2l3.6-5.4,3.6-.6,13.8,5.9,19.7-1.1,4.6-6.5,3-5.2,8.2-1.7,11.7-17.8,7.1,1.9,4.9,10,13.2,9.6,3.8,13-1.4,12.2-.5,9.5,1.6,1.1-.6,1.3v1.1c.1,0-2.2,2.4-2.2,2.4l-17.5,7.4,7.6,19.2,16.3,1.1,8.4,10.8,12.6.6,4.2-5.2-4.6-7.9-9.2-6.2,12.1-7.1,6.4,5.2,1.3-5.6-6.6-6.5,17.5-5.8,4.8,4.4,4.8-2.1-4.7-6.5,4.5-8.8,6.3-.4,4.7-3,8,3,24.1,4.5,11.5-7.8-2.8-6.4,6.5-11.7,10.5-5.5,7.6-7.9,10.2-2.3,13.4,4.6,4.4-8,12.4,8.2,3.4-3.1.2-15.2.4-11.3,13.4-3.2,4.6-.4-1.9,9.9,9.8,4.6,1.6,12.8,12.2,9.2,9.8-8.4,15-8.9-3.7-19-21.8-40.6-5.7-10.6,1.2-6.8-4.9-6.1-4-4.9,1.2-5.2-26.3-33.1-5.8-12.1-7.1-14.9-11.9-20.9-2.9-9.4-2.1-6.7-1.8-5.2-8.7-25.5-8.6-14.4-.9-11-5.2-11-3-6.3-6,6.5-5.2,22.8-9.8,11-5.5,2.8-7.4,3.7-17.1.5-12.7-1.6-8.2,2.3-5.2-3.4-3.4-2.2-18.8-2.3-21.8,4.5-19-3.7-3.5,4.7-11.2-7.3-5.2,4.2-6.1-.7-11.5,3.5-14.1,10.8-3.7,10.1-12.9,9.8-4,7.3-8.5,6.8-2.3,1.9-9.1,4.5-4,18.3-9.2,11.5-14.3,1.2-2.1,30,5.6,1.4,3.3,9.2-1.4,2.7.9,2.6,2.9-.7,1.8,3.2-4.5,2.2,1.7,8.6,3.8,6.9,8.9,3.4Z"/>
</g>
<g id="yeongsan" data-label-shift="dx,dy">
<path class="cls-1" d="M57.4,578.4s.2,1.4.2,2.3.7,1.6.7,1.9.5,1.4.7,2.3c.2.9,1.2,1.2,1.2,1.2h0s.5,1.4.5,1.4c0,0-.5.7-.9,1.4-.5.7,1.6.9,1.6.9l2.8-.7s1.4-.7,1.4-1.2,0,0-.2-.7c-.2-.7-.5-.7-.9-1.2-.5-.5.5-.2.5-.2l.5.2,2.6-.5s.9-.5,1.4-.7c.5-.2-.2-.5-.2-.9s-.2-1.2-.2-1.2l.2-.9v-1.2c0-.5.7-.7.9-1.2.2-.5.5-.7.9-1.2.5-.5,0-.7,0-1.6s-.5-.5-.5-.9-.5,0-.5,0l-.9.5-.9.7s-.2.9-.5,1.2c-.2.2-1.4.2-1.4.2,0,0-.2-.5-1.2-.9-.9-.5,0,0-.5,0s-1.2-.2-1.2-.2c0,0-1.2-.7-1.6-.9-.5-.2-.5-.9-.5-.9h-1.2s-1.9,1.4-2.8,1.9c-.9.5,0,1.2,0,1.2Z"/>
<path class="cls-1" d="M45.2,585.7c.2.2,0,.2.7.9.7.7.5,1.6.9,1.6s.2-.5,1.2,0c.9.5,1.4.5,1.9.7.5.2,1.6.9,1.9.9s1.4.7,1.4.9-.2.2,0,.5c.2.2,0,.5.5,1.4.5.9,1.9,2.6,1.9,2.6,0,0-.7.7.5.5,1.2-.2,1.9-.2,1.9-.9s.2-1.2.2-1.4-.2-.2-.5-1.2c-.2-.9-.7-1.6-.7-1.6,0,0,1.2-.2,0-1.2-1.2-.9-.2-1.2-1.2-.9-.9.2-1.9.2-1.9.2-.2-1.2-.2-1.9-.2-1.9l-.5-.5.7-1.4h0c.5,0,.9-.2,1.2-.2s.9-.7.9-.7c0,0,.2-.2,0-.7-.2-.5-.2-.2-.9-.7s-1.6-.7-1.6-.7l-2.1-1.2-1.4-.9c-.7-.2-2.6.9-2.6.9,0,0,.9-.5,0,.7-.9,1.2-2.3,1.9-2.3,1.9,0,0-.2.5,0,.9.2.5,0,1.2.2,1.4Z"/>
<path class="cls-1" d="M28.6,585.5s0,.7,1.2.7h2.1c.5,0,.9.7.9.7v1.9l1.2.7s1.4,0,1.9-.5c.5-.5.5-.7.5-.7l-.5-1.4,2.3-.7s.5-.5.7-1.2c.2-.7-.2-1.2,0-2.3.2-1.2,0-2.8,0-2.8,0,0,.9-1.4.9-2.1s1.9-2.6,1.9-2.8h0v-.7h-1.4c-.2,0-1.7.5-2.1.7-.5.2,0,0-.9,1.2s-.2,1.6-.2,1.6l-1.9.5-1.4.7-.9.2s-1.2-.2-2.1-.2-.5.9-.5.9c0,0-1.2,1.4-1.9,1.6-.7.2-.7.7-.7.7,0,0,.9,2.3.9,2.8v.5Z"/>
<path class="cls-1" d="M41.7,591.4c-.5.2-.7-1.2-.5,0,.2,1.2,1.2,2.1,1.2,2.1l1.4.9s.9-.2,1.4,0c.5.2.9,0,.9,0l1.9.7v.2s.7-.2.5-1.6c-.2-1.4,0-1.4,0-1.4l-.7-.9-2.6-1.4h0s-2.8-.7-3-.5c-.2.2-.5-.2-.5.2s.5,1.4,0,1.6Z"/>
<path class="cls-1" d="M34.2,597.7s0,0,0,0c0,0,0,0,0,0Z"/>
<path class="cls-1" d="M45,598.9l-.7-.7-.5-.9s.2-1.4-.2-1.4-1.9-.5-1.9-.5l-1.4-.7h0s-1.4,0-1.4,0c0,0-.9,0-1.2.7-.2.7-.9.5-.9.9s-2.1,1.2-2.5,1.4c0,0,.2.2.4.5.5.7.5,1.4,1.2,1.4s2.1.2,2.3,0c.2-.2.9-.7,1.2-.5.2.2.5.5.5.9s-.5,1.2-.5,1.6.5,0,0,.5c-.5.5.2,1.4.5,1.6.2.2-.7,2.1-.7,2.1,0,0-.5.5-.5.7s1.4.7,1.6,0c.2-.7.7-1.2.9-1.4.2-.2.9-.5.9-.5,0,0-.5.2.7.2s2.1.2,2.1-.5v-1.9s-.2-1.2,0-1.4c.2-.2,0-2.3,0-2.3Z"/>
<path class="cls-1" d="M18.5,619.7c.7,0,1.9-.9,2.3,0,.5.9,1.9,2.6,1.9,2.6,0,0,.2-.2,1.4,0,1.2.2,1.2.5,2.1,0,.9-.5,1.4-1.4,1.4-1.4l1.2-1.2,3.3-1.4s-.2-.7-.7-2.1c-.5-1.4-.7-2.1-.7-2.1,0,0,1.2.2.5-.9-.7-1.2-.9-1.9-.9-1.9l-.5-1.6h0s-.7-.9-.7-.9l-2.1.2s-.2,1.2,0,1.9c.2.7.2.9,0,1.4-.2.5-.7-.5-1.2,0-.5.5-2.3.2-2.3.2h-1.2l-.9,1.4-1.2.9-2.3.9-2.1,1.4s-.5,0,0,.9c.5.9,2.1,1.6,2.8,1.6Z"/>
<path class="cls-1" d="M33.1,624h-.9l-1.4-.7-2.1.7s-.9,1.2-.9,1.6.7,1.9,1.4,2.3c.7.5,1.6.7,1.9,1.6.2.9.2,1.9.2,1.9,0,0,1.9.7,2.3-.7.5-1.4.2-2.3.7-2.6.5-.2,1.9-1.6,1.9-1.6,0,0-.5-.5.7-.5s1.6-.5,1.6-.7v-1.9l-.9-.9h.9l.7-.9s.2-.7,0-1.2c-.2-.5-.5-1.9-.5-1.9l-2.6.5h0l-1.4,1.4-.5.9s.2.9,0,1.2c-.2.2-1.2,1.4-1.2,1.4Z"/>
<path class="cls-1" d="M65.6,620.2l1.9-.7-2.1-2.6-3.5-1.9h0s-3.3.7-3.3.7l1.6,2.1.9,1.4s-.5.5-.9.9c-.5.5-1.6.7-1.6.7,0,0-.5.5-.5.9s-.9,0-2.1.7c-1.2.7-4.5,1.9-4.5,1.9h-.9c.9,0,1.6,2.6,2.3,2.6h3.1s-.2-.5.9-.7c1.2-.2,2.6-1.2,2.6-1.2,1.4.2,2.3-.7,3,.5.7,1.2,1.4.9,1.6,1.4.2.5,1.2,2.1,1.2,2.1l-.7,2.6s1.2.9,1.6.7c.5-.2,1.9-1.4,1.9-1.4l.9-1.9s-.5-1.4,0-1.9c.5-.5-1.6-1.4-1.6-1.4l-2.8-.7-1.9-2.6v-1.2l2.8-1.2Z"/>
<path class="cls-1" d="M64.2,635.5v-1.6s-3.3,1.4-3.3,1.6.9,1.6.9,1.6h1.6l.7-1.6Z"/>
<path class="cls-1" d="M59.8,639.2c0,1.2.5,2.8,1.2,3,.7.2,1.6,0,1.6,0v-3l-1.6-.9s-1.2-.2-1.2.9Z"/>
<path class="cls-1" d="M38.4,638.3c1.2,0,2.8-4.5,2.8-4.5v-1.4h0s-3.5,0-3.5,0l-2.1,1.4-3.5.7s-.2,1.9,1.4,1.9.7-.1.6-.2c0,0,.3,0,.6.2,1.6.5,3.3.7,3.3,1.2s-.7.7.5.7Z"/>
<path class="cls-1" d="M15.4,630.1h0s0,0,0,0h0Z"/>
<path class="cls-1" d="M7,642.7c.2-.5,1.2-.7,1.2-1.4s-1.6-2.6-.5-3.1c1.2-.5,2.6-.9,2.8-1.2.2-.2,1.2-.7,1.6-.5s.9.9.9.9l1.4-.5.5-.9s.2-.9.5-1.4c.2-.5.7-1.9.7-1.9v-1.2l-.7-1.6c-.3.2-2.6,2.1-2.8,2.3-.2.2-3,1.2-3.3,1.2s-3.1.2-3.1.2l-3.8.5-2.6,1.9,1.9,2.1s-.2,1.9,0,2.1c.2.2-.5,1.2.5,1.6.9.5,1.6.9,1.6.9,0,0,2.8.2,3-.2Z"/>
<path class="cls-1" d="M16.6,649.1c0-.7-1.2-1.6-1.2-1.6l-.7-.7-.9-.9s-.2-1.2-.2-1.4-.7-.9-.7-.9c0,0-2.3.2-2.3.5s-.7.7-.7.7c0,0-.2.7-1.6.7s-2.8-.5-2.6,0c.2.5-.2.9-.2,1.6s-.5,1.9-.5,1.9v1.2s.2,1.2.7,1.4c.5.2,1.2.5,1.4.7.2.2,1.4.9,1.2,1.4-.2.5,2.3.2,2.3.2l.5-.5c.2-.2,1.9-1.2,1.9-1.2,0,0,0-.9.5-.9s3.3-.5,3.3-.5v-1.6Z"/>
<polygon class="cls-1" points="12.9 643.4 12.9 643.4 12.9 643.4 12.9 643.4"/>
<path class="cls-1" d="M27.7,639.9c.5.5,1.4,1.6,2.1,1.9.7.2,1.2.5,1.4,1.2.2.7.2,1.4.5,2.1.2.7,1.9.7,1.9,1.2s.5-.2,1.4,0c.9.2.2-.2,1.6,1.2,1.4,1.4,2.3,2.1,2.6,2.6.2.5.9,0,.9,0l1.2-.9s-.2-1.6,0-1.6.2-2.1.2-2.3-.5-2.3-.5-2.3c0,0-1.2-1.4-1.4-1.4s-2.1-.7-2.1-.7l-1.2-1.2-.9-1.2-1.4-.5h0l-3.7-.7h-2.1l-1.2,1.6s.2.7.7,1.2Z"/>
<path class="cls-1" d="M32.6,663.4c.5.2,2.3.5,2.8.5s1.6-.2,1.9-.5c.2-.2.9-.2,1.6-.2s3.5.2,3.5.2c0,0,.5-.7.2-1.4-.2-.7-.9-1.4-.9-1.4l-.2-.7-.7-.5.7-1.2v-.9s-.7-.5-1.4-.5-1.4.2-1.4-.2-.9-1.9-.9-1.9l-1.4.7h0c-.9.5-1.9.7-2.1.9-.2.2-3.3.2-3.1.7.2.5.7,1.2.9,1.4.2.2.9,1.6.9,1.6,0,0-.5,1.6-.5,1.9s-.5,1.2,0,1.4Z"/>
<path class="cls-1" d="M21.3,670.4c0-.2,0-1.4.5-1.6.5-.2.7-.5.7-.7s-.7-1.2-.7-1.4-1.2-1.2-1.2-1.2l-.5-1.2-.2-1.2-.2-1.9h0s-.9-1.9-1.2-1.9-1.4-.2-1.4,0-.5.9-.5.9v.7c0,.2-.2.7,0,1.2.2.5-.2,1.4,0,1.6.2.2.2.7.5,1.4.2.7.7,1.2.7,1.4s-.2,1.2-.2,1.2c0,0-.5-.7-.5.5s-1.9,1.9-.5,2.1c1.4.2,2.8.5,3,.5s1.6-.2,1.6-.5Z"/>
<path class="cls-1" d="M27.4,673s-.5-.9,0-1.6c.3-.5.5-.9.7-1.2.2-.5.9-.5.9-.9s.5-2.3,0-2.8c-.5-.5-.9-1.6-1.4-2.3-.5-.7-.2-.9-1.2-1.6-.9-.7-1.9-1.2-1.9-1.2l-1.2.2h0s-.7,2.1-.7,2.3.2,2.3.7,2.6c.5.2,1.6,1.4,1.9,1.9.2.5.2,1.2.2,1.6s-.7,2.1-.9,2.1,0,0-1.2.5c-1.2.5-2.6.2-3,.9-.5.7-1.2,1.4-1.2,1.4v1.4c0,.2-.2.7.5.7s2.3-.2,2.8-.5c.5-.2,1.4-.7,1.4-.7l2.3-2.1,1.2-.7Z"/>
<path class="cls-1" d="M63.7,683.1c-.2-.5-1.4-2.1-1.4-2.1l-.5-.9s0-1.6-.9-1.9c-.9-.2-3-.2-3-.2l-1.6-2.3s-1.4-2.6-1.6-2.6-1.4,0-2.1-.2c-.7-.2-2.6-1.2-2.6-1.2l-.9.7c.2.7,1.6,2.8,1.6,2.8l1.2,2.3-.5,1.9s-1.9.5-2.3.5h-3s-.5-.7-.7.7c-.2,1.4-2.6,2.6-2.6,2.6h-3s-.9,1.6-.9,2.1-6.1,3.1-6.1,3.1l-1.9,2.6-1.9.5s-.5.7-.5.9-1.2,1.6-1.2,1.6h-.7s-2.3.5-2.3.9.5,1.6.5,2.1v1.6s0,1.6.2,1.6,1.4.7,1.4.7l.7,1.2s.5.2.5.5.7,1.2.7,1.2l1.2,1.2s-.5,1.6,0,2.1c.5.5.9.5,1.4.7.5.2,4.2.7,4.2.7,0,0,1.9.2,2.1.5.2.2,1.4.5,2.1,0,.7-.5,7-1.6,7-1.6,0,0-.7-.9,0-1.2.7-.2,1.9-.5,3-1.4,1.2-.9,3.7-1.2,3.7-1.2,0,0-.5-1.2.2-1.6.7-.5,2.6-2.1,2.8-2.1s2.1,1.4,2.1,1.4c0,0-.7.7.5.7s1.4-.5,1.9-2.1c.5-1.6,1.9-2.3,1.9-2.3l.5-.7s-.5-.5,0-1.6c.5-1.2.9-2.6.9-2.6v-3.5s-.5,0,.5-.5c.9-.5,2.1-2.3,1.4-3.1-.7-.7-1.6-1.9-1.9-2.3Z"/>
<polygon class="cls-1" points="49 672.3 49 672.3 49 672.3 49 672.3"/>
<path class="cls-1" d="M93.5,737.5c-2.3-1.2-4.2-2.3-4.2-2.3h-1.9l-1.2,3.5s1.4,0,1.2.9c-.2.9-1.6.5-1.2,1.6.5,1.2-.5.7,1.6,1.6,2.1.9,2.8,1.9,3.7.9.9-.9,1.9-2.6,1.9-2.6h0l.7-1.2s1.6-1.4-.7-2.6Z"/>
<path class="cls-1" d="M86.2,745l-2.8-1.9s-2.8,2.6-2.8,3.1-2.1,3.3-1.6,4,1.2,1.6,2.8,1.6,3.3-1.6,5.4-2.6c2.1-.9,2.8-2.3,3.7-1.9.9.5,2.1-.5,2.1-.5h0l-1.9-1.2-4.9-.7Z"/>
<path class="cls-1" d="M101.7,742h0l-3.5,1.2s-.9,1.2-.9,1.6.5,2.6.5,2.6c0,0-.5.5-.5,1.6v2.3s1.2,3.1,2.1,3.5c.9.5,2.6-.5,3.1-1.9.5-1.4-.9-5.6-.9-5.6,0,0-2.8-1.2-2.1-1.4.7-.2,2.8-.5,3.1-.5s.9-.7.9-.7l-.7-2.8h-.9Z"/>
<path class="cls-1" d="M115.1,720.4l-1.2-1.6-.5-1.6h0v-2.6l-2.1-3.3s-1.6-1.2-1.9-1.6c-.2-.5-3-2.3-3-2.1s-2.3,2.3-2.3,2.6-3.7,2.8-3.7,2.8c0,0,1.2,4,1.2,4.2s-1.9.2-.5,1.9c1.4,1.6,5.9,2.1,5.9,2.1,0,0,.2-1.4.5.5.2,1.9-.5,2.8-.5,3.1s1.2,1.2,2.6,1.2,3.3-.2,3.5,0c.2.2,3.5.5,3.5.5,0,0,.2,0,0-2.1-.2-2.1-1.4-3.8-1.4-3.8Z"/>
<path class="cls-1" d="M125.6,711.9c.5,0,1.4-1.9,1.4-1.9,0,0-.5-.7.9-.5,1.4.2.9,0,1.6.5.7.5,1.2.5,1.4.5s1.2-.7.5-1.4c-.7-.7-1.4-.9-1.9-1.6-.5-.7-.5-2.6-.5-2.6,0,0-.5-1.4-.7-1.4h-2.8l-.9.9h0c-.7,0-3.5-.7-3.5-.7l-.5,2.8-2.1,1.4s-.9.5-.5,1.2c.5.7.9,1.2.7,1.6-.2.5-1.6,0-1.4.7.2.7,1.6,1.9,1.6,1.9,0,0-.7,1.2-.7,1.6s.9,1.4,1.4,1.4,1.9-2.1,1.9-2.1c0,0,.5-.9.9-1.2.5-.2,2.1-1.2,2.1-1.2h.9Z"/>
<path class="cls-1" d="M141.5,712.4c0-.2-.9-.5-1.2-.7-.2-.2-1.6-.7-1.6-.7l-3-.9h-1.7s-.7,2.1-.7,2.3-.5,1.2,0,1.4c.5.2,2.1.7,2.1.7v1.2c0,.5-.7.7.5,1.2,1.2.5,4.2-.5,4.2-.5h2.6s.9-1.4.5-1.9c-.5-.5-1.6-1.9-1.6-2.1Z"/>
<path class="cls-1" d="M128.6,725.1s.2-.2.5,0c.2.2,1.4-.5,1.4-.5,0,0-.2-.5.5-.5s1.9.5,1.9.5c0,0,.7-.2.9-.7.2-.5-.2-.9,0-1.4.2-.5,0-1.6,0-1.6l-.7-1.6s-.9-.2-1.4-.2-1.2-.2-1.2-.2l-.5-.2-.9-.7h0s-2.3-.2-2.6.5c-.2.7-.7,1.6-.9,1.9-.2.2-1.9.9-1.9.9l1.4,1.4s.2.7,1.2,1.2c.9.5,2.3,1.4,2.3,1.4Z"/>
<path class="cls-1" d="M121.1,719.9c-.3-.3-.7-.5-1.2-.5h0c-.6,0-1.1.6-1.2,1.2,0,.4.2.8.5,1.2.1.2.3.5.7.7.5.3,1.4.6,1.6.2.2-.2,0-.5-.2-1.6,0-.7,0-.9-.2-1.2Z"/>
<path class="cls-1" d="M137.3,747.6c-.9-.5-1.9-.9-1.9-.9l-.5-1.2.9-1.2.2-2.3-4.2-.5-2.1.5h0s-1.2,2.1-1.4,2.6c-.2.5-2.1,1.9-2.1,2.1s0,.7-.2,1.4c-.2.7-.9,1.9,0,2.3.9.5,2.1.7,2.1.7,0,0,2.6-1.4,2.8-.7.2.7.2,1.2,1.2,1.9.9.7,2.1.9,2.3.9s.2-1.2.9-1.4c.7-.2,2.6-1.9,2.6-1.9,0,0,.2-1.9-.7-2.3Z"/>
<path class="cls-1" d="M267.1,679.6s.5-.5,0-1.6c-.5-1.2-.7-2.1-.7-2.1l-.7-1.2-.5-1.4s-.5-.5-.2-1.2c.2-.7,0-2.6,0-2.6l-.2-.7.7-.9h0c.2-.2.5-2.1.5-2.1l-3.3-2.3s-1.4.9-1.2,1.9c.2.9.9,2.1.9,2.6s.2,1.6,0,2.1c-.2.5-.9,1.2-.9,1.4s-.7,1.4-.9,1.9c-.2.5-2.8,1.9-2.8,1.9,0,0-1.9,1.9-1.9,2.1s-1.2.2-.7,1.6c.5,1.4.5,3.3.5,3.3,0,0-1.2.9-.5,1.6.7.7,1.4,1.6,2.8,2.1,1.4.5,4.2,1.6,4.2,1.6l3.1.5s2.3-.9,1.9-1.4c-.5-.5-1.6-1.4-1.6-1.4l-.5-1.2s0-1.2.5-1.9,1.6-2.6,1.6-2.6Z"/>
<polygon class="cls-1" points="279.4 600.4 279.4 586.1 272 579.9 262.7 571.1 261.3 561.5 254.5 559.9 251.7 557.3 244.7 562.9 243.5 566.9 235.1 563.3 234.4 553 235.1 535.7 240.9 533.6 245.9 532.1 247.1 523.8 256.9 510.3 248.3 507 239.9 515.4 232.7 509.4 238.7 493.8 231.6 486.6 229.2 475.8 217.2 472.2 206.4 484.2 205.2 493.8 195.7 486.6 186.1 495 186.1 502.2 176.5 510.6 159.8 515.4 157.4 523.8 140.6 521.4 127.4 517.8 124.5 512.7 119.4 508.3 115.3 512.5 110.2 512.4 96.4 517.8 89.3 530 84.4 535 85.6 538.3 88.3 540.1 87.2 541.1 82.3 539.9 76.8 551.3 72.3 553.6 67.3 564.8 76.6 566.5 73.6 571.4 75.3 574.4 80.6 583.9 85.4 585.9 85.4 589.7 80 596 78.4 589.4 69.9 588.5 68.8 590 71.3 593.4 75.4 595.9 74.3 598.4 69.7 600 68.7 603.3 63 605.4 61.9 611.5 67.8 615.5 70.4 613.1 73.2 605.9 74.3 613.6 75.3 626.5 69.7 634.5 69.7 639.2 68.1 643.4 67.8 644.3 80.4 650.4 92.7 661.4 89.3 661.9 86.5 659.8 85.6 664 79 654.2 69.4 650 67.8 656.3 70.6 661.9 74.3 663.3 77.1 670.4 72.7 669.9 66.1 661.5 62.9 648.1 58.5 642.1 51.9 654.2 55.1 668.3 59.6 674.6 61.9 673.9 65.8 676.6 67.1 680.5 69.9 680.2 72.7 677.9 79 686.6 77.7 698.2 82.8 699.7 79.7 706.7 76.6 709.9 77.4 712.1 82.5 712.8 82.7 721.2 93.1 719.9 97.3 713.3 97.1 709.8 112.1 701.6 110.7 695.9 117.2 694.3 120.8 688.2 120.2 673.4 122.8 671.1 124.7 675.1 124.5 684.5 122.4 694.3 125.7 699.9 130.4 702.1 134.8 704.2 136.9 700.4 143.5 702.3 145.8 697.6 149.1 698.3 148.3 688.6 152.1 687 152.9 684.2 151.7 682 155 673 162 671.1 167.4 666.2 174.9 662.4 179.6 664 183.8 662.6 187.1 655.8 192.4 654.7 194.6 657.2 202.1 657.2 202.7 663.8 200.6 669.2 196 669.8 194.8 663.8 189.6 664 187.5 671.6 189.1 675.6 185.9 681.9 181.9 681.2 183.1 676 181.4 674.3 176 692.2 189.2 694.5 191.7 701.4 195.5 706.3 203.9 704.4 207.4 697.8 213.3 694.1 214.2 689.9 209.3 687.1 210 684 218 684.7 221 686.6 224.2 683.9 222.7 674.6 209.2 661.4 209.5 657 212.8 653.7 210.5 650.9 215.4 646.2 211.9 643.7 213.5 642.5 216.5 642.5 222 642.5 227.6 640.4 228.1 635.9 230.9 638 231.2 641.7 231.8 647.4 236.2 650.7 238.4 657 234.6 663.8 232.6 672.3 234.9 676.9 238.7 676.9 243.5 679.8 244.5 675.1 243.3 671.1 245.9 662.9 249.9 659.4 255 664.1 261.6 661 262.3 655.8 265.1 651.9 267 645.5 264.6 642 261.1 642.5 257.9 642 248 646 246.3 642 239.9 634.6 244.2 626.8 246.3 631 251.6 632.8 262.5 623.5 278.8 628.6 285.4 617.2 285.4 607.6 279.4 600.4"/>
</g>
<g id="nakdong" data-label-shift="dx,dy">
<path class="cls-1" d="M405.4,649.3l-2.1-.2-1.6,1.2-.9,1.6-1.2.2-.9-.5v-1.2l.2-.9,1.4-.9.9-.7v-.9l.9-.9.7-1.2-.2-1.6-.2-1.4.5-3v-1.9l-1.6-1.6-.9-.9.2-1.9.7-2.8h0c0,0,1.6-.5,1.6-.5,0,0,1.2-.9,1.2-1.2s.2-.7.2-.7l-.2-.9-1.4-.9-1.9-.7-1.6.2-1.2.5-.9,1.2-.7,1.2-.2,1.2.5,1.9v.9l-.5.7-.7.2-1.2.2-.9.5-.5.5-.2,1.2-.2.5h-1.4l-1.4.5s-.9.2-1.2.2-.7.7-.7.7l-1.2.5h-1.4c-.2,0-.7.2-.7.2l-.5.5v.5l.2.7s.7.7.9.7,0,.7,0,.7l.2.9v.7l.2.9.5.5.7.5s.5.5.7.5.2.7.2.7c0,0,.5.7.5.9v2.1h-.2s-.7-.2-.9-.2-.5-.5-.5-.5l-.9-.7h-.9l-.9-.5-.5-.7-.7-1.2-.7-.5-.5-.2-.5.2-.7.7-.7.2h-.9l-1.4-.9-1.4-.9-1.4-.7h-1.2l-.5.2-.7.9-.5.7-1.2.7s-.2,0-.5.2c-.2.2-.7.5-.7.5l-.5.7-.7,1.2-.5,1.2-.9.9v3.3l1.2,1.4s.7,1.4.7,1.6.2,1.4.2,1.4l.7,1.2.7,1.6,1.6.2,1.4-.9.7-1.2.7-.7h2.6l1.4-1.2.9-1.6.7-.9.7-.5.5.5.5.7v2.6s.5.5.7.5,0,.7,0,.7l-.7,1.2-1.2,1.9-.5.7-.2.5-.2.5-.9.5-1.2.2-1.4.2-.7.5-.5.5-.2.7,1.4.5.5.5v.7l.5.5.7.2h1.2l2.1.2.7.5.2.7-.2.5-.7.5s-.9.7-1.2.7-.5.9-.5.9l-.2.7.2.9v.7l.9.2.9.7,1.2.2v.9l-.2.2-.9.5-1.4.5-.7.2-.5.7v.9l.5.7s.5.5.7.5,1.4.2,1.4.2c0,0,1.4.2,1.6.2s.9-.5.9-.5l1.2-.5,1.2-.7.5-.7v-.7l.2-1.2s.5-.9.5-1.2.9-.5.9-.5l1.4-.2.2-.7-.9-.9-.7-1.4.5-1.6v-1.2l.7-.7,1.6-.2,1.6-.2.7-.9.2-1.2v-2.1l.7-.7,1.9.2,1.6-.2.5,2.1,1.2,1.4,2.1.9,1.6-.7.2-1.2-.2-1.2-1.6-1.2-.7-1.4.2-1.2-1.2-.7-.9-.9.5-1.4h1.9c.2,0,.7-.5.7-.5l.7-.9v-1.6h.9c.2,0,.7-.5.7-.5l.2-1.4s.2-.5.7-.7c.5-.2,0-1.4,0-1.4v-1.4Z"/>
<path class="cls-1" d="M330.3,650.8v-1.2l-.9-.5h0s-4.2-1.2-4.2-.9-2.8,0-2.8,0l-1.4.9s-.2.7-.2.9.2,1.2.2,1.2l.5.2.9.7,2.8.5h1.9l.9-.7.9-.7.9-.2.5-.2Z"/>
<polygon class="cls-1" points="333.8 654.8 332.6 654.1 332.2 652.9 331.7 652.7 331 652.9 329.8 653.9 329.6 654.8 329.1 656 329.1 657.6 329.4 659 329.8 659.7 330.8 659.7 331.9 659.5 332.4 659 333.8 659 335 658.8 335.2 657.8 335.2 656.9 334.7 655.7 333.8 654.8"/>
<polygon class="cls-1" points="308 649.2 307.6 648.4 307.6 647.5 308 646.3 308.7 645.2 308.7 644.2 308.3 644 307.3 643.5 306.4 642.8 305.5 641.9 304.7 641.2 303.1 641.4 302.2 642.8 302.2 644.7 301.2 645.2 300.8 644.2 300.5 642.6 301 641.4 301.7 639.8 302.4 638.4 303.6 637.2 303.6 636.5 303.3 635.8 302.6 635.8 301 636 299.8 637 298.4 638.1 298.2 639.5 297.3 640 295.6 640.9 294.7 642.4 293.5 643.5 293.3 644.9 293.3 645.9 293.5 647 294.2 648.2 295.4 648.4 297 649.2 299.4 649.2 301.2 648.7 303.1 649.2 304.1 649.9 306.2 651 307.8 652 308.3 651.7 309.4 651.7 309.7 651 309.2 650.3 308 649.2"/>
<path class="cls-1" d="M305.2,653.4l-1.2-.5-.9-.7-.7-.2h-2.1l-1.6-.2-.9-.7h-1.6l-1.2,1.2-1.6,1.2-.9.7v.7l-1.4-.2-.5-1.2-.9-1.4-.2-.7-.5-.9-1.2-.5-1.2-.5-.5-.5s-.2-.5-.2-.7-.2-1.2-.2-1.2v-.5c0-.2-.7-.7-.7-.7l-.2-.7-.5-1.2-.2-1.4.7-.9.2-.9,1.6-.2.7-.5.5-.2.2-.7.2-1.2.5-.5.7-.9v-.7l-.2-1.2-.5-1.4.2-1.4-2.1-1.4h-3.1l-1.9.2-1.4,1.2-.9,1.4-.5,1.2-.5,1.4v.7l-1.6.5-.9.5-.7.9s.2.5,0,.7c-.2.2-1.2.9-1.2.9v1.4l-.5,1.2-.7.5-.9.5-.2.9v1.4l.2,1.2-.2,1.4.5,1.6.5,1.9,1.2,1.9h0v1.6l.2,1.4.7.9.9.7.2.7-.2.7-.9.9-.9.7-.5.7-.2.7,1.2.7.7.5,1.4.7v.7l.2,1.2.5.9.9.5.9-.2h1.9l1.2-.2.9-.5.9-.9v-1.4l-.5-.9-.2-1.4.2-.7.7-.5,1.9-.2,1.2-.9h2.3l.5.9v.7s-.5.7-.5.9-.2,1.2-.2,1.2l-.5,1.4v.5l-.2.9.2.5.2.5.2,1.2v1.2l.5.9,2.1.2,2.3.7,1.6-.2,1.2.2.9-.7s.5.5.7.5.5.9.5.9l1.6.7,1.6.2h.7l.2-1.2-.2-1.4-.7-.7-.2-.9v-1.2l.5-.5.9-.9.7-.2.5-.5v-2.3s-.5-2.1-.5-2.3v-.9l.2-1.2.5-1.4s-.2-.9,0-.9.7-.7.7-.7l.7-.5.2-.9-.2-.9-1.2-.5Z"/>
<polygon class="cls-1" points="533.7 451.9 529.9 457.3 516.8 465.7 509.4 457.5 516.6 447.7 513.8 417.3 513.1 408.8 521.3 392.4 522.9 355.1 523.6 353.3 531.1 333.9 526.2 315.4 526.2 282.1 519.8 263.8 504.8 272.7 495 281.2 482.8 272 481.1 259.2 471.4 254.5 473.3 244.6 468.7 245 455.3 248.2 454.9 259.5 454.7 274.8 451.4 277.9 438.9 269.7 434.5 277.6 421.1 273 410.9 275.3 403.3 283.3 392.8 288.8 386.2 300.5 389 306.9 377.6 314.7 353.5 310.3 345.4 307.2 340.7 310.3 334.5 310.7 330 319.5 334.6 326 329.8 328.1 325 323.7 307.5 329.5 314.2 336 312.9 341.5 306.5 336.4 294.4 343.5 303.6 349.6 308.1 357.5 304 362.7 314.1 371.6 316.5 383.6 314.1 388.4 326.1 392 323.3 402.6 325.2 414.4 315.5 413.8 315 423.6 307.5 439.1 298 443 298.5 447.6 301.2 454.4 296.8 462.5 286.2 472.3 276.7 473.4 269.4 480.7 267.1 483.1 264.5 485.8 256.9 505.2 256.9 510.3 247.1 523.8 245.9 532.1 240.9 533.6 235.1 535.7 234.4 553 235.1 563.3 243.5 566.9 244.7 562.9 251.7 557.3 254.5 559.9 261.3 561.5 262.7 571.1 272 579.9 279.4 586.1 279.4 600.4 285.4 607.6 285.4 617.2 278.8 628.6 282.6 628.4 288.8 622.6 289.3 620.3 291.9 621.5 292.3 624.3 296.3 623 298.7 624.3 301.6 622.6 298.9 615.9 301.2 614.9 303.4 615.5 304.7 611.2 309.9 609.7 307.1 614.2 306.9 625.2 305.5 632.3 314.8 637.2 317 641.5 318.3 642.1 322.4 640.3 325.1 641.7 326.8 641.4 329.4 634.9 335.5 635.5 338.5 640.5 342.8 638.4 346.2 633 347.4 636.3 348.6 639.8 343.1 642.3 344.4 645.9 349.4 647.4 351.4 645.9 353.5 647.7 353.3 655.5 347.7 654.1 346.2 655.7 351 659.7 353.7 666.6 356.3 667.8 358.7 665.9 359.9 659.7 358.5 655.6 361.6 654.2 365.6 646.5 363.1 643.5 360.7 647.8 358.5 646.8 361.3 632.3 361.1 630.7 361 629.5 368.3 628 371.4 624.3 365.9 619.1 357.2 620.8 355.4 623.6 351.9 624.1 354.8 618.5 361.5 616.3 367.3 614.4 366.1 610.6 379 609.8 381.7 611.6 383.5 619.4 389.9 616.8 383.5 596.4 387.2 591.5 388.9 592.4 387.7 599.2 390.1 604.4 394.7 605 398 608.8 399.5 604.8 401.5 606.5 400.5 612.2 406.2 614.7 414 613.3 414.9 615.6 420.1 616.2 424.6 617.3 428.8 614.1 432.5 616.6 438.5 610.1 436.6 622.3 438 626.7 441.3 625.8 442.5 620.6 445.7 622.8 453.5 612.9 457 616.4 461.6 616.6 461.7 613.8 459.8 610.5 463.1 606.6 469.9 608.2 480.9 594.4 480.7 591.3 485.5 581.8 491 579.8 494.6 576.1 498.5 573.7 497.1 571.8 499.6 567.6 499.9 561.3 498 558.2 504.1 554 504 546.1 505.5 548.8 506.9 554.5 511.6 551.2 514.6 544.4 514.4 537.4 515.8 532.7 513.9 528 514 525.4 524.7 493.4 527.1 486.2 527.8 477.8 535.1 466.5 538 455.6 533.7 451.9"/>
</g>
<g id="geum" data-label-shift="dx,dy">
<path class="cls-1" d="M298,443l9.5-3.9,7.5-15.6.5-9.8,9.7.6-1.9-11.8,2.8-10.6-12-3.6,2.4-4.8-2.4-12-10.1-8.9-12.6-.6-8.4-10.8-16.3-1.1-7.6-19.2,17.5-7.4,2.3-2.4v-1.1c-.1,0,.4-1.3.4-1.3l-1.6-1.1.5-9.5,1.4-12.2-3.8-13-13.2-9.6-4.9-10-7.1-1.9-11.7,17.8-8.2,1.7-3,5.2-4.6,6.5-19.7,1.1-13.8-5.9-3.6.6-3.6,5.4h-1.2l-4.8-6-6.4-3.9-5-.2-9.1-18.5-5.4,3-2.4-5.7-11.4-2.8-11.7-9.2h-3.9c0,.1-3.4,4.8-3.4,4.8l1,6.2,2.1,5.4-1.2.8-4.4-7.5-6.7-3.2-6.3-.3-5.2,3.3,5.9,3.4-3.6,2.2,5.3,4.4,2.5.8.4,3-6.5,6.1-8.7,11.2-4-1.9,6.4-13.5-1.6-1.9,1.1-7.9-2.1-3-4.7,14.5-3,1.8-3.8-5.7-4.4,2.7.5,8.4-1.5,1.7-3.3-.8-4.2,3.2-5.1,13.8.5,1.5,3.9-.2.2-6.5,5.6,2.5,2.8,5.3-7.4,2.4v3c0,0,5.9,1.2,5.9,1.2l4.3-1.5,2.1-3.3,4-.3-1.4,6,4.1,3.7-2.4,9.4,4.3,6.1,1.1,27,11.6,2.1-6.6-26.7,2.3-4.1-6.3-4.9,1.7-1.9,1-6.9,2-1.5,2.2,9,4.4,3.2,3.1-1.9,3.1-6.1,1.8,1.5-1.9,8.6,2,14.4,1.9,13.7-2.8,6.5,8.2,7.8v2.1l-6.9,2.9,2.9,21.8-1.6,1.9-5.9,6.7,1.3,2.7,3.3-2.7,8.2,2.8,6.2,7.7-1.2,2.8,5.1,13.1,5.6,3.1-19.1.5-1.8,3.3,6.4,3.1-.6,10.7,7.3,2.8,3-3.3,8.9,1.9,9.4-3.4.3,1.4.2.9-3.1,5.4-5.1-.9-9.4,3.5,6.8,6.4,4.3,1.4-.2,5.6-20.6-5.8-6.4,13.7-18.1,10.4-1.9,6.6,5.9,4.7h19.9s3.8,4.2,3.8,4.2l-.5,3.4,3,5.1,13.2,3.6,16.8,2.4,2.4-8.4,16.8-4.8,9.6-8.4v-7.2l9.6-8.4,9.6,7.2,1.2-9.6,10.8-12,12,3.6,2.4,10.8,7.2,7.2-6,15.6,7.2,6,8.4-8.4,8.6,3.3v-5.1s7.6-19.4,7.6-19.4l12.2-12.4,9.6-1.1,10.5-9.8,4.5-8.1-2.7-6.8-.6-4.6Z"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

Some files were not shown because too many files have changed in this diff Show More