[as] 프로젝트 생성

This commit is contained in:
2026-05-12 14:31:45 +09:00
parent 61f433e899
commit d07c5fab1a
64 changed files with 1214 additions and 1887 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
# apps/as/shared/config/env.ts 참고
# src/shared/config/env.ts 참고
NEXT_PUBLIC_PORT=
NEXT_PUBLIC_PROXY_HOST=
NEXT_PUBLIC_SITE_ID=
NEXT_PUBLIC_SERVER_API_URL=
SERVER_API_URL=
# 401 시 브라우저 리다이렉트 대상
NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL=/auth/login
+1 -1
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { PORT, PROXY_HOST, SERVER_API_URL, SITE_ID } from "@/shared/config";
const serverApiUrl = process.env.NEXT_PUBLIC_SERVER_API_URL ?? "http://localhost:3000";
const serverApiUrl = process.env.SERVER_API_URL ?? "http://localhost:3000";
// CSP 헤더 설정 함수 (img-src만)
function addSecurityHeaders(response: NextResponse): NextResponse {
+4 -7
View File
@@ -1,11 +1,8 @@
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
export const PROXY_HOST =
process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
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 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";
-5
View File
@@ -1,5 +0,0 @@
# apps/as/shared/config/env.ts 참고
NEXT_PUBLIC_PORT=
NEXT_PUBLIC_PROXY_HOST=
NEXT_PUBLIC_SITE_ID=
NEXT_PUBLIC_SERVER_API_URL=
+20 -75
View File
@@ -1,4 +1,4 @@
# AS Application
# Template Application
Next.js 애플리케이션 with FSD(Feature Sliced Design) 아키텍처
@@ -18,12 +18,12 @@ Next.js 애플리케이션 with FSD(Feature Sliced Design) 아키텍처
이 프로젝트는 **Feature-Sliced Design v2.1**과 **FSD 공식 Next.js 가이드**에 맞춰 구성되어 있습니다. Next.js `app/`은 라우트만 담당하고, 실제 레이어는 `src/` 아래에 있습니다.
```
as/
_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'
│ └── page.tsx # re-export: export { MainPage as default } from '@/pages/home'
│
├── pages/ # 빈 폴더 + README (Next.js가 src/pages를 Pages Router로 쓰지 않도록)
│ └── README.md
@@ -31,7 +31,7 @@ as/
└── src/ # FSD 레이어 (실제 코드)
├── app/ # App 레이어 (슬라이스 없음, 세그먼트만)
│ ├── providers/ # 전역 Providers (QueryClient 등)
│ ├── styles/ # 글로벌 스타일 (globals.css)
│ ├── styles/ # 글로벌 스타일 (globals.css)
│ └── layouts/ # 공통 레이아웃 컴포넌트 (BaseLayoutWrapper)
│
├── pages/ # Pages 레이어 (페이지 슬라이스)
@@ -112,43 +112,20 @@ shared/ ↓ 가장 낮은 책임, 의존성 없음
- **특징**: 비즈니스 로직 없음, 슬라이스가 없고 세그먼트로만 구성
- **의존성**: 외부 라이브러리만 사용 가능 (다른 레이어 불가)
## 레이어 간 Import 규칙
> **핵심 규칙**: 슬라이스 내의 모듈(파일)은 **오직 하위 레이어**에 있는 다른 슬라이스만 import할 수 있습니다.
예시:
- `features/auth-login/ui/form.tsx`는 `entities/user`와 `shared/ui`를 import 가능
- 하지만 다른 `features/post-create`나 상위 레이어인 `widgets`는 import 불가
- 같은 슬라이스 내부(`features/auth-login/`)의 다른 파일들은 자유롭게 import 가능
## Public API 규칙
> **슬라이스 외부의 모듈은 슬라이스의 Public API만 참조할 수 있으며, 내부 파일 구조에 직접 접근할 수 없습니다.**
모든 슬라이스(및 슬라이스가 없는 레이어의 세그먼트)는 반드시 `index.ts`를 통해 Public API를 정의해야 합니다.
```typescript
// ✅ 올바른 import
import { LoginForm } from 'features/auth-login';
// ❌ 잘못된 import (내부 구조 직접 접근)
import { LoginForm } from 'features/auth-login/ui/login-form';
```
## 시작하기
```bash
# 의존성 설치
npm install
pnpm install
# 개발 서버 실행
npm run dev
pnpm run dev
# 프로덕션 빌드
npm run build
pnpm run build
# 프로덕션 서버 실행
npm start
pnpm start
```
## 환경 변수
@@ -168,16 +145,16 @@ NEXT_PUBLIC_API_URL=http://localhost:3000/api
```typescript
// ✅ 올바른 import (하위 레이어)
// features/auth-login/ui/form.tsx
import { User } from 'entities/user';
import { Button } from 'shared/ui/button';
import { User } from "entities/user";
import { Button } from "shared/ui/button";
// ❌ 잘못된 import (같은 레이어)
// features/auth-login/ui/form.tsx
import { PostForm } from 'features/post-create'; // 금지!
import { PostForm } from "features/post-create"; // 금지!
// ❌ 잘못된 import (상위 레이어)
// features/auth-login/ui/form.tsx
import { Header } from 'widgets/header'; // 금지!
import { Header } from "widgets/header"; // 금지!
```
### 2. Public API 규칙
@@ -186,10 +163,10 @@ import { Header } from 'widgets/header'; // 금지!
```typescript
// ✅ 올바른 import (Public API 사용)
import { LoginForm } from 'features/auth-login';
import { LoginForm } from "features/auth-login";
// ❌ 잘못된 import (내부 구조 직접 접근)
import { LoginForm } from 'features/auth-login/ui/login-form';
import { LoginForm } from "features/auth-login/ui/login-form";
```
**Public API 작성 시 주의사항**:
@@ -199,44 +176,12 @@ import { LoginForm } from 'features/auth-login/ui/login-form';
```typescript
// ❌ 나쁜 예시
export * from './ui/Comment';
export * from './model/comments';
export * from "./ui/Comment";
export * from "./model/comments";
// ✅ 좋은 예시
export { Comment } from './ui/comment';
export { CommentForm } from './ui/comment-form';
export { useComments } from './model/use-comments';
export type { Comment, CommentFormData } from './model/types';
```
### 3. 슬라이스 독립성
- **Zero Coupling**: 같은 레이어의 슬라이스들은 서로 독립적이어야 합니다
- **High Cohesion**: 슬라이스는 주요 목표와 관련된 대부분의 코드를 포함해야 합니다
- 예외: Entities 레이어에서 필요한 경우 `@x` 표기법으로 cross-import 가능
### 4. 세그먼트 명명 규칙
세그먼트 이름은 **목적**을 설명해야 하며, **본질**을 설명하면 안 됩니다:
- ❌ **나쁜 이름**: `components`, `hooks`, `types` (무엇인지만 설명)
- ✅ **좋은 이름**: `ui`, `api`, `model`, `lib` (왜 있는지 설명)
## 코드 규칙
- TypeScript 사용 (strict mode)
- 함수형 컴포넌트 사용
- Named exports 선호
- Tailwind CSS 사용
- ESLint + Prettier 적용
- Public API는 명시적으로 정의 (와일드카드 export 지양)
## 아키텍처 린팅
코드베이스의 FSD 규칙 준수를 자동으로 검증하려면 [Steiger](https://github.com/feature-sliced/steiger)를 사용하세요.
```bash
# Steiger 설치 및 실행
npm install -D @feature-sliced/steiger
npx steiger src
export { Comment } from "./ui/comment";
export { CommentForm } from "./ui/comment-form";
export { useComments } from "./model/use-comments";
export type { Comment, CommentFormData } from "./model/types";
```
@@ -1 +0,0 @@
export { InstlSncnListPage as default } from "@/pages/instl-sncn";
+11 -9
View File
@@ -1,9 +1,11 @@
import { BaseLayoutWrapper } from "@/app/layouts/base-layout";
interface WithBaseLayoutProps {
children: React.ReactNode;
}
export default function WithBaseLayout({ children }: WithBaseLayoutProps) {
return <BaseLayoutWrapper>{children}</BaseLayoutWrapper>;
}
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
View File
@@ -0,0 +1 @@
export { MainPage as default } from "@/pages/home";
+94
View File
@@ -0,0 +1,94 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
REFRESH_TOKEN,
SERVER_API_URL,
SITE_ID,
} from "@/shared/config";
async function handler(
request: Request,
params: { url: string[] },
method: "GET" | "POST" | "PUT" | "DELETE",
) {
const cookieStore = await cookies();
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
};
let body = {};
const isRefreshHandler = params.url.includes("refresh");
// silent refresh
if (isRefreshHandler) {
const refreshToken = cookieStore.get(REFRESH_TOKEN);
headers[CLAIM_NAME] = refreshToken?.value ?? "";
if (!headers[CLAIM_NAME]) {
return NextResponse.json({ message: "Can't refresh" }, { status: 401 });
}
}
try {
if (!isRefreshHandler) {
body = await request.json();
}
const result = await fetch(`${SERVER_API_URL}/${params.url.join("/")}`, {
method,
headers,
body: JSON.stringify(body),
});
if (!result.ok) {
return NextResponse.json({ message: "Invalid Credentials" }, { status: 401 });
}
const accessToken = result.headers.get(ACCESS_TOKEN);
const refreshToken = result.headers.get(REFRESH_TOKEN);
const userId = result.headers.get(AUTH_USER_ID);
if (accessToken && userId) {
const response = NextResponse.json({
[ACCESS_TOKEN]: accessToken,
[AUTH_USER_ID]: userId,
});
const forwardedHost = request.headers.get("x-forwarded-host");
const host = request.headers.get("host");
const actualHost = forwardedHost || host || new URL(request.url).hostname.toString();
// 운영 환경에서 domain 설정
const domain = actualHost.includes("hasuon.or.kr") ? ".hasuon.or.kr" : actualHost;
if (refreshToken) {
cookieStore.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "strict",
domain: domain,
path: "/",
});
}
return response;
}
return NextResponse.json({ message: "Invalid Credentials" }, { status: 401 });
} catch (error) {
return NextResponse.json({ message: "Server Error", error }, { status: 500 });
}
}
export async function POST(request: Request, props: { params: Promise<{ url: string[] }> }) {
const params = await props.params;
return handler(request, params, "POST");
}
export async function PUT(request: Request, props: { params: Promise<{ url: string[] }> }) {
const params = await props.params;
return handler(request, params, "PUT");
}
+2
View File
@@ -0,0 +1,2 @@
import { LoginPage } from "@/pages/auth/login";
export default LoginPage;
+2 -5
View File
@@ -1,10 +1,9 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { Providers } from "@/app/providers";
import { setCookie } from "@/shared/lib";
import { SITE_ID } from "@/shared/config";
import "@/app/styles/globals.css";
import "@/app/styles/components.css";
export const metadata: Metadata = {
title: "행정지원시스템",
@@ -15,8 +14,6 @@ interface RootLayoutProps {
children: React.ReactNode;
}
setCookie("site-id-key", SITE_ID);
export default async function RootLayout({ children }: RootLayoutProps) {
const theme = (await cookies()).get("site-theme-key");
+97 -97
View File
@@ -2,19 +2,19 @@
## 1. 기본 정보
| 항목 | 내용 |
| ---------- | -------------------------------------------------------- |
| 버전 | 0.1 (초안) |
| 프로세스명 | UI설계 |
| 화면명 | 결재선 관리 |
| 화면 유형 | [x] 조회 [x] 입력 [x] 수정 [x] 삭제 [ ] 출력 |
| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 결재선 관리 |
| 항목 | 내용 |
| ---------- | -------------------------------------------- |
| 버전 | 0.1 (초안) |
| 프로세스명 | UI설계 |
| 화면명 | 결재선 관리 |
| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 |
| 메뉴 경로 | 디지털행정지원 > 시스템관리 > 결재선 관리 |
---
## 2. 화면 목적
전자결재 처리에 사용할 **결재서식**(템플릿)을 등록·관리하고, 각 서식별로 **결재선**(접수 → 검토 → 최종승인 단계)을 구성한다. 좌측 상단의 결재서식 목록에서 하나를 선택하면 하단 영역에 해당 서식의 결재 단계(결재자/순서/구분)가 표시되며, 사용자는 결재자 추가·수정·삭제와 결재선 저장을 수행한다.
전자결재 처리에 사용할 **결재서식**(템플릿)을 등록·관리하고, 각 서식별로 **결재선**(접수 -> 검토 -> 승인 -> 최종승인 단계)을 구성한다. 상단의 결재서식 목록에서 하나를 선택하면 하단 영역에 해당 서식의 결재 단계(결재자/순서/구분)가 표시되며, 사용자는 결재자 추가·수정·삭제와 결재선 저장을 수행한다.
---
@@ -28,10 +28,10 @@
├──────────────────────────────────────────────────────────────────────────────┤
│ [영역 A] 전자결재선 관리 목록 [서식 등록] │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ 전자결재서식아이디 | 결재서식명 | 문서구분 | 등록일시 | 관리 │ │
│ │ 전자결재서식아이디 | 결재서식명 | 문서구분 | 등록일시 | 관리 │ │
│ ├──────────────────────────────────────────────────────────────────────┤ │
│ │ TMPL-0001 (선택 행 강조) … [수정][삭제] │ │
│ │ TMPL-0002 … [수정][삭제] │ │
│ │ TMPL-0001 (선택 행 강조) … [수정][삭제] │ │
│ │ TMPL-0002 … [수정][삭제] │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────────────────────────┤
│ [영역 B] 결재선 관리 선택된 서식: TMPL-0001 │
@@ -164,39 +164,39 @@ END
### 4-1. 헤더 / 툴바
| 항목 | 내용 |
| ---------- | ------------------------------------------------------------- |
| 섹션 타이틀 | 전자결재선 관리 목록 |
| 우측 버튼 | **[서식 등록]** — 결재서식 등록 다이얼로그 오픈 |
| 항목 | 내용 |
| ----------- | ----------------------------------------------- |
| 섹션 타이틀 | 전자결재선 관리 목록 |
| 우측 버튼 | **[서식 등록]** — 결재서식 등록 다이얼로그 오픈 |
### 4-2. 그리드 컬럼
| 컬럼명 | Field | 타입 | 비고 |
| ------------------ | -------------------- | ------ | ------------------------------------------------- |
| 전자결재서식아이디 | `elctAtrzTmpltId` | string | 예) `TMPL-0001` (서버 자동 발급, TBD) |
| 결재서식명 | `atrzTmpltNm` | string | 예) `설치인가 검토 결재` |
| 문서구분 | `docSeNm` | string | 예) `설치인가`, `기본계획` (코드/명 매핑 TBD) |
| 등록일시 | `regDt` | string | `YYYY-MM-DD HH:mm:ss` |
| 관리 | — | — | 행별 **[수정] · [삭제]** 버튼 |
| 컬럼명 | Field | 타입 | 비고 |
| ------------------ | ----------------- | ------ | --------------------------------------------- |
| 전자결재서식아이디 | `elctAtrzTmpltId` | string | 예) `TMPL-0001` (서버 자동 발급, TBD) |
| 결재서식명 | `atrzTmpltNm` | string | 예) `설치인가 검토 결재` |
| 문서구분 | `docSeNm` | string | 예) `설치인가`, `기본계획` (코드/명 매핑 TBD) |
| 등록일시 | `regDt` | string | `YYYY-MM-DD HH:mm:ss` |
| 관리 | — | — | 행별 **[수정] · [삭제]** 버튼 |
- 행 클릭 시: 해당 서식을 **선택 상태**로 만들고 영역 B 라벨(`선택된 서식: {전자결재서식아이디}`)과 영역 B-2 그리드를 갱신한다.
- 페이지네이션: TBD (스크린샷에는 미표시).
### 4-3. 버튼
| 버튼명 | 위치 | 동작 |
| ---------- | ------------------ | -------------------------------------------------------------------- |
| 서식 등록 | 섹션 우측 상단 | 결재서식 등록 다이얼로그 오픈 (입력 항목·필수값 TBD) |
| 수정 | 행별 | 결재서식 수정 다이얼로그 오픈 (해당 행 값으로 초기화) |
| 삭제 | 행별 | 확인 다이얼로그 후 삭제. 선택 중인 서식이면 영역 B 상태도 초기화. |
| 버튼명 | 위치 | 동작 |
| --------- | -------------- | ----------------------------------------------------------------- |
| 서식 등록 | 섹션 우측 상단 | 결재서식 등록 다이얼로그 오픈 (입력 항목·필수값 TBD) |
| 수정 | 행별 | 결재서식 수정 다이얼로그 오픈 (해당 행 값으로 초기화) |
| 삭제 | 행별 | 확인 다이얼로그 후 삭제. 선택 중인 서식이면 영역 B 상태도 초기화. |
### 4-4. 다이얼로그(개요)
| 다이얼로그명 | 트리거 | 입력 항목(예상) | 비고 |
| ---------------------- | ---------------- | ------------------------------------------------ | -------------------------- |
| 결재서식 등록 | [서식 등록] 클릭 | 결재서식명, 문서구분(드롭다운) | 등록 성공 시 영역 A 갱신 |
| 결재서식 수정 | 행별 [수정] | 결재서식명, 문서구분 | 기존 값으로 초기화 |
| 결재서식 삭제 확인 | 행별 [삭제] | (확인/취소) | |
| 다이얼로그명 | 트리거 | 입력 항목(예상) | 비고 |
| ------------------ | ---------------- | ------------------------------ | ------------------------ |
| 결재서식 등록 | [서식 등록] 클릭 | 결재서식명, 문서구분(드롭다운) | 등록 성공 시 영역 A 갱신 |
| 결재서식 수정 | 행별 [수정] | 결재서식명, 문서구분 | 기존 값으로 초기화 |
| 결재서식 삭제 확인 | 행별 [삭제] | (확인/취소) | |
---
@@ -206,21 +206,21 @@ END
### 5-1. 헤더 / 상태 표시
| 항목 | 내용 |
| ------------ | ------------------------------------------------------------------------------------- |
| 섹션 타이틀 | 결재선 관리 |
| 우측 라벨 | `선택된 서식: {전자결재서식아이디}` — 영역 A에서 선택한 서식 ID를 강조 색으로 표시. 미선택 시 영역 B 비활성. |
| 항목 | 내용 |
| ----------- | ------------------------------------------------------------------------------------------------------------ |
| 섹션 타이틀 | 결재선 관리 |
| 우측 라벨 | `선택된 서식: {전자결재서식아이디}` — 영역 A에서 선택한 서식 ID를 강조 색으로 표시. 미선택 시 영역 B 비활성. |
### 5-2. 영역 B-1: 결재자 입력 폼
#### 필드 정의
| 표시명 | 입력 유형 | 필수 | 기본값 | 데이터 소스 | API 파라미터 매핑 | 비고 |
| -------------------- | -------------------- | ---- | ------------ | ---------------------- | ----------------- | --------------------------------------------------------------- |
| 결재선아이디(자동발급) | 텍스트(읽기 전용) | — | `APLN-XXXX` | 서버 자동 발급 (TBD) | TBD | 입력 불가. 저장 시 서버 발급. 표시값은 미발급 자리표시(또는 임시 ID). |
| 결재구분 | 드롭다운 | 예 | `접수` | 코드 (TBD) | TBD | 옵션: **접수 / 검토 / 최종승인** (그리드 행 기준) |
| 결재자명 | 텍스트(읽기 전용) | 예 | `""` | [검토인력 선택] 결과 | TBD | 자유 입력 불가. 다이얼로그 선택 결과로 채움. |
| 결재순서번호 | 숫자 입력 | 예 | `1` | — | TBD | 1 이상의 정수. 동일 서식 내 중복 검증 필요. |
| 표시명 | 입력 유형 | 필수 | 기본값 | 데이터 소스 | API 파라미터 매핑 | 비고 |
| ---------------------- | ----------------- | ---- | ----------- | -------------------- | ----------------- | --------------------------------------------------------------------- |
| 결재선아이디(자동발급) | 텍스트(읽기 전용) | — | `APLN-XXXX` | 서버 자동 발급 (TBD) | TBD | 입력 불가. 저장 시 서버 발급. 표시값은 미발급 자리표시(또는 임시 ID). |
| 결재구분 | 드롭다운 | 예 | `접수` | 코드 (TBD) | TBD | 옵션: **접수 / 검토 / 최종승인** (그리드 행 기준) |
| 결재자명 | 텍스트(읽기 전용) | 예 | `""` | [검토인력 선택] 결과 | TBD | 자유 입력 불가. 다이얼로그 선택 결과로 채움. |
| 결재순서번호 | 숫자 입력 | 예 | `1` | — | TBD | 1 이상의 정수. 동일 서식 내 중복 검증 필요. |
플레이스홀더 / 표시 텍스트:
@@ -229,35 +229,35 @@ END
#### 버튼
| 버튼명 | 동작 |
| --------------- | ------------------------------------------------------------------------------------------------------------- |
| 검토인력 선택 | **검토인력 선택 다이얼로그** 오픈. 선택 시 `결재자명`과 내부 `결재자 ID`를 폼 상태에 반영. |
| 결재자 추가 | 입력 폼 값을 영역 B-2 그리드에 **임시 행**으로 추가하고 폼을 초기화한다. (서버 호출 없음, 저장 시 일괄 반영.) |
| 결재선 저장 | 영역 B-2의 결재선 목록을 일괄 저장 API로 전송한다. 성공 시 그리드 재조회 + 토스트 표시. |
| 초기화 | 입력 폼을 기본값으로 리셋(결재구분=접수, 결재자명=빈값, 결재순서번호=1). |
| 버튼명 | 동작 |
| ------------- | ------------------------------------------------------------------------------------------------------------- |
| 검토인력 선택 | **검토인력 선택 다이얼로그** 오픈. 선택 시 `결재자명`과 내부 `결재자 ID`를 폼 상태에 반영. |
| 결재자 추가 | 입력 폼 값을 영역 B-2 그리드에 **임시 행**으로 추가하고 폼을 초기화한다. (서버 호출 없음, 저장 시 일괄 반영.) |
| 결재선 저장 | 영역 B-2의 결재선 목록을 일괄 저장 API로 전송한다. 성공 시 그리드 재조회 + 토스트 표시. |
| 초기화 | 입력 폼을 기본값으로 리셋(결재구분=접수, 결재자명=빈값, 결재순서번호=1). |
> 정책 메모: **[결재자 추가]** 와 **[결재선 저장]** 의 분리에 대해서는 두 가지 운영이 가능하다. 본 SPEC 초안은 그리드에 임시 행을 누적해 두고 [결재선 저장] 시점에 서버에 일괄 반영하는 모델을 가정한다. 실제 구현 방식(추가 즉시 저장 vs 일괄 저장)은 백엔드 API 확정 후 재정의(TBD).
#### 다이얼로그
| 다이얼로그명 | 트리거 | 표시 내용 / 입력 항목 | 결과 |
| -------------------- | -------------------- | -------------------------------------------------------- | --------------------------------------------------- |
| 검토인력 선택 | [검토인력 선택] | 사용자 검색 필터(이름/소속 등 TBD), 사용자 목록(단일 선택) | 선택된 사용자 정보를 입력 폼에 반영 |
| 결재자 수정 | B-2 행별 [수정] | 결재구분, 결재자명(선택), 결재순서번호 | 확인 시 해당 행 갱신 |
| 결재자 삭제 확인 | B-2 행별 [삭제] | (확인/취소) | 확인 시 해당 행 삭제 |
| 다이얼로그명 | 트리거 | 표시 내용 / 입력 항목 | 결과 |
| ---------------- | --------------- | ---------------------------------------------------------- | ----------------------------------- |
| 검토인력 선택 | [검토인력 선택] | 사용자 검색 필터(이름/소속 등 TBD), 사용자 목록(단일 선택) | 선택된 사용자 정보를 입력 폼에 반영 |
| 결재자 수정 | B-2 행별 [수정] | 결재구분, 결재자명(선택), 결재순서번호 | 확인 시 해당 행 갱신 |
| 결재자 삭제 확인 | B-2 행별 [삭제] | (확인/취소) | 확인 시 해당 행 삭제 |
### 5-3. 영역 B-2: 결재선 그리드
#### 그리드 컬럼
| 컬럼명 | Field | 타입 | 비고 |
| -------------- | ---------------- | ------ | ---------------------------------------------------------- |
| 결재선아이디 | `atrzLineId` | string | 예) `APLN-0001` (서버 자동 발급, TBD) |
| 결재순서번호 | `atrzSeqNo` | number | 1부터 시작. 단계 순서. |
| 결재구분 | `atrzSeNm` | string | `접수` / `검토` / `최종승인` |
| 결재자명 | `atrzUserNm` | string | 검토인력 선택 시 채워짐 |
| 등록일시 | `regDt` | string | `YYYY-MM-DD HH:mm:ss` |
| 관리 | — | — | 행별 **[수정] · [삭제]** 버튼 |
| 컬럼명 | Field | 타입 | 비고 |
| ------------ | ------------ | ------ | ------------------------------------- |
| 결재선아이디 | `atrzLineId` | string | 예) `APLN-0001` (서버 자동 발급, TBD) |
| 결재순서번호 | `atrzSeqNo` | number | 1부터 시작. 단계 순서. |
| 결재구분 | `atrzSeNm` | string | `접수` / `검토` / `최종승인` |
| 결재자명 | `atrzUserNm` | string | 검토인력 선택 시 채워짐 |
| 등록일시 | `regDt` | string | `YYYY-MM-DD HH:mm:ss` |
| 관리 | — | — | 행별 **[수정] · [삭제]** 버튼 |
- 표시 정렬: 기본 `결재순서번호` 오름차순.
- 임시 행(서버 미저장)과 저장된 행의 시각적 구분 필요(예: ID 컬럼 표기 또는 행 스타일). 세부 UI는 TBD.
@@ -271,35 +271,35 @@ END
### 6-1. 공통
| 항목 | 내용 |
| ----------- | ------------------------------------------------------------------- |
| Base prefix | TBD (예: `/server` 또는 결재 서비스 prefix) |
| 인증 | JWT 토큰 기반 (Authorization 헤더) |
| 클라이언트 | TBD (예: `@/entities/approval-line`) |
| 항목 | 내용 |
| ----------- | ------------------------------------------- |
| Base prefix | TBD (예: `/server` 또는 결재 서비스 prefix) |
| 인증 | JWT 토큰 기반 (Authorization 헤더) |
| 클라이언트 | TBD (예: `@/entities/approval-line`) |
### 6-2. 전자결재서식 (영역 A)
| 기능 | Method | URL | Request DTO | Response DTO | API(클라이언트 함수명) |
| --------------------- | ------ | ---- | ----------- | ------------ | ---------------------- |
| 결재서식 목록 조회 | TBD | TBD | TBD | TBD | TBD |
| 결재서식 등록 | TBD | TBD | TBD | TBD | TBD |
| 결재서식 수정 | TBD | TBD | TBD | TBD | TBD |
| 결재서식 삭제 | TBD | TBD | TBD | TBD | TBD |
| 기능 | Method | URL | Request DTO | Response DTO | API(클라이언트 함수명) |
| ------------------ | ------ | --- | ----------- | ------------ | ---------------------- |
| 결재서식 목록 조회 | TBD | TBD | TBD | TBD | TBD |
| 결재서식 등록 | TBD | TBD | TBD | TBD | TBD |
| 결재서식 수정 | TBD | TBD | TBD | TBD | TBD |
| 결재서식 삭제 | TBD | TBD | TBD | TBD | TBD |
### 6-3. 결재선 (영역 B)
| 기능 | Method | URL | Request DTO | Response DTO | API(클라이언트 함수명) |
| ------------------------------- | ------ | ---- | ----------- | ------------ | ---------------------- |
| 결재선 목록 조회(서식별) | TBD | TBD | `{ elctAtrzTmpltId }` (TBD) | TBD | TBD |
| 결재선 추가/일괄 저장 | TBD | TBD | `{ elctAtrzTmpltId, lines[] }` (TBD) | TBD | TBD |
| 결재선 수정 | TBD | TBD | TBD | TBD | TBD |
| 결재선 삭제 | TBD | TBD | TBD | TBD | TBD |
| 기능 | Method | URL | Request DTO | Response DTO | API(클라이언트 함수명) |
| ------------------------ | ------ | --- | ------------------------------------ | ------------ | ---------------------- |
| 결재선 목록 조회(서식별) | TBD | TBD | `{ elctAtrzTmpltId }` (TBD) | TBD | TBD |
| 결재선 추가/일괄 저장 | TBD | TBD | `{ elctAtrzTmpltId, lines[] }` (TBD) | TBD | TBD |
| 결재선 수정 | TBD | TBD | TBD | TBD | TBD |
| 결재선 삭제 | TBD | TBD | TBD | TBD | TBD |
### 6-4. 검토인력 (다이얼로그)
| 기능 | Method | URL | Request DTO | Response DTO | API(클라이언트 함수명) |
| ------------------- | ------ | ---- | ----------- | ------------ | ---------------------- |
| 검토인력 목록 조회 | TBD | TBD | TBD (검색 필터) | TBD | TBD |
| 기능 | Method | URL | Request DTO | Response DTO | API(클라이언트 함수명) |
| ------------------ | ------ | --- | --------------- | ------------ | ---------------------- |
| 검토인력 목록 조회 | TBD | TBD | TBD (검색 필터) | TBD | TBD |
---
@@ -315,25 +315,25 @@ END
### 7-2. 모듈·파일 배치(제안)
| 구분 | 경로 |
| ---------------- | --------------------------------------------------------------------------------------------- |
| 라우트(App Router) | `apps/as/app/(with-base-layout)/approval-line-management/page.tsx` |
| 페이지 컴포넌트 | `apps/as/src/pages/approval-line-management/index.ts`<br/>`.../ui/approval-line-management-page.tsx` |
| 위젯(영역 단위) | `apps/as/src/widgets/approval-template-list/`<br/>`apps/as/src/widgets/approval-line-editor/` |
| 피처(상호작용) | `apps/as/src/features/approver-picker/` (검토인력 선택 다이얼로그) |
| 엔티티(도메인) | `apps/as/src/entities/approval-template/`<br/>`apps/as/src/entities/approval-line/` |
| 그리드 컬럼 정의 | `entities/approval-template/model/approval-template-table-columns.tsx`<br/>`entities/approval-line/model/approval-line-table-columns.tsx` |
| API 클라이언트 | `entities/approval-template/api/*`, `entities/approval-line/api/*` (TBD) |
| 구분 | 경로 |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
| 라우트(App Router) | `apps/as/app/(with-base-layout)/approval-line-management/page.tsx` |
| 페이지 컴포넌트 | `apps/as/src/pages/approval-line-management/index.ts`<br/>`.../ui/approval-line-management-page.tsx` |
| 위젯(영역 단위) | `apps/as/src/widgets/approval-template-list/`<br/>`apps/as/src/widgets/approval-line-editor/` |
| 피처(상호작용) | `apps/as/src/features/approver-picker/` (검토인력 선택 다이얼로그) |
| 엔티티(도메인) | `apps/as/src/entities/approval-template/`<br/>`apps/as/src/entities/approval-line/` |
| 그리드 컬럼 정의 | `entities/approval-template/model/approval-template-table-columns.tsx`<br/>`entities/approval-line/model/approval-line-table-columns.tsx` |
| API 클라이언트 | `entities/approval-template/api/*`, `entities/approval-line/api/*` (TBD) |
### 7-3. 영역별 UI 매핑
| 영역 | 주요 UI | 비고 |
| ----- | ----------------------------------------------------------------------------- | --------------------------------------------------- |
| A | 섹션 헤더 + Button(`서식 등록`) + DataTable (행 선택, 행별 Action 버튼) | 선택 행 강조, 행 클릭으로 영역 B 갱신 |
| B 헤더| 상태 라벨 `선택된 서식: {ID}` | 미선택 시 영역 B-1·B-2 비활성 |
| B-1 | 읽기 전용 Input(아이디), Select(결재구분), Input+Button(결재자/검토인력 선택), NumberInput(순서번호), Action 버튼 3종(`결재자 추가`/`결재선 저장`/`초기화`) | 폼 유효성 검증 후 그리드에 임시 행 추가 |
| B-2 | DataTable (행별 [수정][삭제]), 임시 행 시각적 구분 | 기본 정렬: 결재순서번호 ASC |
| 공통 | Dialog(검토인력 선택, 서식 등록/수정, 결재자 수정, 삭제 확인) | 디자인 시스템의 `Dialog` 컴포넌트 사용 |
| 영역 | 주요 UI | 비고 |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
| A | 섹션 헤더 + Button(`서식 등록`) + DataTable (행 선택, 행별 Action 버튼) | 선택 행 강조, 행 클릭으로 영역 B 갱신 |
| B 헤더 | 상태 라벨 `선택된 서식: {ID}` | 미선택 시 영역 B-1·B-2 비활성 |
| B-1 | 읽기 전용 Input(아이디), Select(결재구분), Input+Button(결재자/검토인력 선택), NumberInput(순서번호), Action 버튼 3종(`결재자 추가`/`결재선 저장`/`초기화`) | 폼 유효성 검증 후 그리드에 임시 행 추가 |
| B-2 | DataTable (행별 [수정][삭제]), 임시 행 시각적 구분 | 기본 정렬: 결재순서번호 ASC |
| 공통 | Dialog(검토인력 선택, 서식 등록/수정, 결재자 수정, 삭제 확인) | 디자인 시스템의 `Dialog` 컴포넌트 사용 |
### 7-4. 데이터 흐름(요약)
+30 -15
View File
@@ -1,18 +1,33 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import { FlatCompat } from "@eslint/eslintrc";
import { dirname } from "path";
import { fileURLToPath } from "url";
import baseEsLintConfig from "../../eslint.config.mjs";
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",
]),
]);
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;
+2 -1
View File
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { PORT, PROXY_HOST, SERVER_API_URL, SITE_ID } from "@/shared/config";
const serverApiUrl = process.env.NEXT_PUBLIC_SERVER_API_URL ?? "http://localhost:3000";
const serverApiUrl = process.env.SERVER_API_URL ?? "http://localhost:3000";
// CSP 헤더 설정 함수 (img-src만)
function addSecurityHeaders(response: NextResponse): NextResponse {
+4 -3
View File
@@ -13,7 +13,6 @@
"@common/assets": "workspace:*",
"@common/components": "workspace:*",
"@common/network": "workspace:*",
"@faker-js/faker": "^10.3.0",
"@hookform/resolvers": "^4.1.3",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5",
@@ -26,9 +25,11 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/js-cookie": "^3.0.6",
"eslint": "^9.0.0",
"eslint-config-next": "15.4.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",
@@ -36,4 +37,4 @@
"tailwindcss": "^3.4.14",
"typescript": "^5.7.2"
}
}
}
View File
+9
View File
@@ -0,0 +1,9 @@
# 이 폴더는 비워 둡니다 (FSD + Next.js App Router)
Next.js가 `src/pages`를 **Pages Router**용 `pages` 디렉터리로 인식하지 않도록,
프로젝트 루트에 **빈 `pages` 폴더**를 두는 것이 FSD 공식 가이드 권장사항입니다.
- 실제 페이지(FSD Pages 레이어)는 **`src/pages`**에 있습니다.
- Next.js 라우트는 **`app/`** 에서 `src/pages`를 re-export 하는 방식으로 연결됩니다.
[Feature-Sliced Design - Usage with Next.js (App Router)](https://feature-sliced.design/docs/guides/tech/with-nextjs)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

+37 -35
View File
@@ -1,35 +1,37 @@
"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>
);
}
"use client";
import { useCallback } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { PageLayout } from "@/shared/ui/page-layout";
import type { MenuItem } from "@/entities/menu";
import { BaseLayout } from "@common/components";
interface BaseLayoutWrapperProps {
children: React.ReactNode;
}
export function BaseLayoutWrapper({ children }: BaseLayoutWrapperProps) {
const router = useRouter();
const pathname = usePathname();
const queryClient = useQueryClient();
const handleMenuClick = useCallback(
(item: MenuItem) => {
router.push(item.urlPath);
},
[router],
);
return (
<BaseLayout
queryClient={queryClient}
currentPath={pathname ?? ""}
handleMenuClick={handleMenuClick}
>
<PageLayout>{children}</PageLayout>
</BaseLayout>
);
}
+23 -20
View File
@@ -1,20 +1,23 @@
"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}
<div className="text-2xl">
<ReactQueryDevtools initialIsOpen={false} />
</div>
</QueryClientProvider>
);
}
"use client";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { queryClient } from "@/shared/lib";
import { StoreProvider } from "@/shared/providers";
import { AuthWrapper } from "@/entities/auth";
interface ProvidersProps {
children: React.ReactNode;
}
export function Providers({ children }: ProvidersProps) {
return (
<QueryClientProvider client={queryClient}>
<StoreProvider>
<AuthWrapper>{children}</AuthWrapper>
</StoreProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
-129
View File
@@ -1,129 +0,0 @@
.card {
position: relative;
margin: 0.6rem;
box-sizing: border-box;
min-height: 2rem;
border-radius: 0.5rem;
padding: 0.6rem;
font-size: 1.3rem;
color: #666;
}
.card_width10 {
width: calc(10% - 12px);
}
.card_width20 {
width: calc(20% - 12px);
}
.card_width25 {
width: calc(25% - 12px);
}
.card_width30 {
width: calc(30% - 12px);
}
.card_width33 {
width: calc(33.333% - 12px);
}
.card_width40 {
width: calc(40% - 12px);
}
.card_width45 {
width: calc(45% - 12px);
}
.card_width50 {
width: calc(50% - 12px);
}
.card_width60 {
width: calc(60% - 12px);
}
.card_width66 {
width: calc(66.666% - 12px);
}
.card_width70 {
width: calc(70% - 12px);
}
.card_width75 {
width: calc(75% - 12px);
}
.card_width80 {
width: calc(80% - 12px);
}
.card_width90 {
width: calc(90% - 12px);
}
.card_width100 {
width: calc(100% - 12px);
}
.card_nopadding {
padding: 0 !important;
}
.card_nomargin {
width: 100%;
margin: 0 !important;
padding: 0 !important;
}
.topline {
border-top: 1px solid #ccc;
}
.bottomline {
border-bottom: 1px solid #e6eef3;
background: #fff;
}
.card-border {
border: 1px solid #dbe2e6;
background-color: rgba(255, 255, 255, 0.6);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.card-header {
margin-top: -0.6rem;
margin-left: -0.6rem;
margin-right: -0.6rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e1e7eb;
padding: 1rem;
}
.card-footer {
margin-left: -0.6rem;
margin-right: -0.6rem;
margin-top: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
border-top: 1px solid #e1e7eb;
padding-top: 1rem;
padding-bottom: 1rem;
}
/* 블랙 테마 */
[data-theme="black"] {
.card-border {
background-color: rgba(255, 255, 255, 0.065);
border-color: #38383a;
}
.card-header {
border-bottom-color: #3f4042;
}
}
+15 -15
View File
@@ -1,15 +1,15 @@
@import "@common/assets/styles/base.css";
@import "@common/assets/styles/diveicon.css";
@import "@common/components/index.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
color: var(--foreground);
background: var(--background);
font-family: "Noto Sans KR", sans-serif;
font-size: 10px;
}
@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;
}
@@ -0,0 +1,92 @@
import { AxiosError } from "axios";
import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
ENDPOINT,
JWT_EXPIRATION_TIME,
} from "@/shared/config";
import { tokenManager } from "../lib/token-manager";
import type { ILogin, LoginService } from "../model/login-interface";
export class LoginApiService implements LoginService {
private static instance: LoginApiService;
private constructor() {}
public static getInstance(): LoginApiService {
if (!LoginApiService.instance) {
LoginApiService.instance = new LoginApiService();
}
return LoginApiService.instance;
}
public async login(payload: ILogin): Promise<void> {
try {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
body: JSON.stringify({
userId: payload.userId,
password: payload.password,
}),
});
const data = await response.json();
console.log("data ", data);
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
if (data.message == "Invalid Credentials") {
throw new Error("아이디와 비밀번호가 일치하지 않습니다.");
} else if (data.message == "User not found") {
throw new Error("존재하지 않는 아이디입니다.");
} else if (data.message == "User is not active") {
throw new Error("활성화되지 않은 계정입니다.");
} else if (data.message == "User is not verified") {
throw new Error("이메일 인증이 필요합니다.");
} else {
throw new Error(data.message);
}
}
tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId);
tokenManager.setupRefreshTimer(() => this.silentRefresh(), JWT_EXPIRATION_TIME);
} catch (error) {
if (error instanceof AxiosError) {
throw new Error(`Login 실패 ${error.response?.data?.message}`);
}
throw new Error(`Login 실패 ${error}`);
}
}
public async silentRefresh(): Promise<void> {
try {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, {
method: "PUT",
});
const data = await response.json();
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("Missing required authentication tokens");
}
tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId);
} catch (error) {
tokenManager.clearAuthToken();
if (error instanceof AxiosError) {
throw new Error(`Silent refresh failed: ${error.response?.data?.message}`);
}
throw new Error(`Silent refresh failed: ${error}`);
}
}
}
export const LoginApi = LoginApiService.getInstance();
+2
View File
@@ -0,0 +1,2 @@
export { LoginApi } from "./api/login-service";
export { AuthWrapper } from "./lib/auth-wrapper";
@@ -0,0 +1,45 @@
"use client";
import { useEffect } from "react";
import { client } from "@/shared/api";
import { AUTH_LOGIN_REDIRECT_URL, AUTH_USER_ID, ENDPOINT } from "@/shared/config";
import { getCookie, useUserStore } from "@/shared/lib";
import type { IUserInfo } from "@/shared/store";
import { isAtLoginRedirectUrl } from "@common/network";
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
const { userInfo, setUserInfo } = useUserStore((state) => state);
async function authCheck() {
if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
return;
}
const userId = getCookie(AUTH_USER_ID);
if (!userId) {
throw new Error("Missing required authentication tokens");
}
const response = await client.get<IUserInfo>(`${ENDPOINT.USER_SERVICE.USERS}/info/me`);
if (!userInfo) setUserInfo(response.data);
}
useEffect(() => {
(async () => {
try {
await authCheck();
} catch (error) {
console.error(error);
setUserInfo(null);
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
window.location.assign(AUTH_LOGIN_REDIRECT_URL);
}
}
})();
}, []);
return <>{children}</>;
};
export { AuthWrapper };
@@ -0,0 +1,82 @@
import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config";
import { getCookie, removeCookie,setCookie } from "@/shared/lib";
export class TokenManager {
private static instance: TokenManager;
private refreshTimer: NodeJS.Timeout | null = null;
private constructor() {}
public static getInstance(): TokenManager {
if (!TokenManager.instance) {
TokenManager.instance = new TokenManager();
}
return TokenManager.instance;
}
public getToken(key: string): string | undefined {
return getCookie(key);
}
public setToken(key: string, value: string): void {
const domain = this.getCookieDomain();
setCookie(key, value, {
secure: window.location.protocol === "https:",
sameSite: "lax",
domain: domain,
path: "/", // 모든 경로에서 접근 가능
});
}
private getCookieDomain(): string | undefined {
const hostname = window.location.hostname;
// 개발 환경에서는 localhost 도메인 사용
if (hostname === "localhost" || hostname === "127.0.0.1") {
return undefined; // localhost에서는 도메인 설정하지 않음
}
// 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용
if (hostname.includes("hasuon.or.kr")) {
return ".hasuon.or.kr";
}
// 기타 환경에서는 현재 호스트명 사용
return hostname;
}
public removeToken(key: string): void {
const domain = this.getCookieDomain();
removeCookie(key, { domain, path: "/" });
}
public setupRefreshTimer(
callback: () => Promise<void>,
expirationTime: number,
): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
this.refreshTimer = setTimeout(() => {
callback().catch((error) => {
console.error("Silent refresh failed:", error);
this.clearAuthToken();
});
}, expirationTime - 60000);
}
public clearAuthToken(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
this.removeToken(CLAIM_NAME);
this.removeToken(AUTH_USER_ID);
}
}
export const tokenManager = TokenManager.getInstance();
@@ -0,0 +1,9 @@
export interface ILogin {
userId: string;
password: string;
}
export interface LoginService {
login(payload: ILogin): Promise<void>;
silentRefresh(): Promise<void>;
}
@@ -1,23 +0,0 @@
export { instlSncnService } from "./instl-sncn-service-impl";
export {
instlSncnKeys,
useListPageLoadCnfm,
useRegistPageLoadCnfm,
useViewPageLoadCnfm,
useSelectInstlCnfmListCnt,
useSelectInstlCnfmList,
useSelectInstlCnfmDtl,
useInsertInstlCnfm,
useMergeInstlCnfm,
useDeleteInstlCnfm,
useUpdatePrgrsSttusCnfm,
useSaveInstlCnfmPrgrsStts,
useSaveInstlCnfmSanctnPrgrsStts,
useInsertInstlCnfmSpl,
useSelectEssntlInstlCnfmAtchFileList,
useSelectInstlCnfmExmntFile,
useSelectInstlCnfmEtcFile,
useSelectInstlCnfmSpl,
useSelectInstlCnfmExmntResult,
useSelectInstlCnfmOpinionList,
} from "./queries";
@@ -1,222 +0,0 @@
import { apiClient } from "@/shared/api";
import type {
InstlSncnService,
FnrscVO,
ListPageResult,
RegistPageData,
ViewPageData,
SplmntRqst,
} from "../model";
class InstlSncnServiceImpl implements InstlSncnService {
async selectInstlCnfmListCount(vo: FnrscVO): Promise<number> {
const response = await apiClient.get<{ count: number }>(
"/fnrsc/cnfm/list/count",
{ params: vo }
);
return response.data.count;
}
async selectInstlCnfmList(vo: FnrscVO): Promise<FnrscVO[]> {
const response = await apiClient.get<FnrscVO[]>("/fnrsc/cnfm/list", {
params: vo,
});
return response.data;
}
async listPageLoadCnfm(vo: FnrscVO): Promise<ListPageResult> {
const response = await apiClient.get<ListPageResult>(
"/fnrsc/cnfm/list/pageLoad.do",
{
params: {
...vo,
pageIndex: vo.pageIndex || 1,
recordCountPerPage: vo.recordCountPerPage || 10,
pageSize: vo.pageSize || 10,
},
}
);
return response.data;
}
async registPageLoadCnfm(vo: FnrscVO): Promise<RegistPageData> {
const response = await apiClient.get<RegistPageData>(
"/fnrsc/cnfm/regist/pageLoad.do",
{
params: vo,
}
);
return response.data;
}
async insertInstlCnfm(vo: FnrscVO, files?: File[]): Promise<void> {
const formData = new FormData();
Object.entries(vo).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (typeof value === "object") {
formData.append(key, JSON.stringify(value));
} else {
formData.append(key, String(value));
}
}
});
if (files && files.length > 0) {
files.forEach((file) => {
formData.append("uploadFile", file);
});
}
await apiClient.post("/fnrsc/cnfm/regist/insertFnrscCnfm.do", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}
async mergeInstlCnfm(vo: FnrscVO, files?: File[]): Promise<void> {
const formData = new FormData();
Object.entries(vo).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (typeof value === "object") {
formData.append(key, JSON.stringify(value));
} else {
formData.append(key, String(value));
}
}
});
if (files && files.length > 0) {
files.forEach((file) => {
formData.append("uploadFile", file);
});
}
await apiClient.post("/fnrsc/cnfm/regist/mergeFnrscCnfm.do", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}
async selectInstlCnfmDetail(vo: FnrscVO): Promise<FnrscVO> {
const response = await apiClient.get<FnrscVO>("/fnrsc/cnfm/detail", {
params: vo,
});
return response.data;
}
async viewPageLoadCnfm(vo: FnrscVO): Promise<ViewPageData> {
const response = await apiClient.get<ViewPageData>(
"/fnrsc/cnfm/view/pageLoad.do",
{
params: vo,
}
);
return response.data;
}
async updateProgrsSttusCnfm(vo: FnrscVO): Promise<void> {
await apiClient.post("/fnrsc/cnfm/update/updateProgrsSttus.do", vo);
}
async saveInstlCnfmProgrsSttus(vo: FnrscVO, file?: File): Promise<void> {
const formData = new FormData();
Object.entries(vo).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
formData.append(key, String(value));
}
});
if (file) {
formData.append("uploadFile", file);
}
await apiClient.post(
"/fnrsc/cnfm/update/saveProgrsSttus.do",
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
}
);
}
async saveInstlCnfmSanctnProgrsSttus(vo: FnrscVO): Promise<void> {
await apiClient.post(
"/fnrsc/cnfm/update/saveInstlCnfmSanctnProgrsSttus.do",
vo
);
}
async deleteInstlCnfm(vo: FnrscVO): Promise<void> {
await apiClient.post("/fnrsc/cnfm/regist/deleteInstlCnfm.do", vo);
}
async selectEssntlInstlCnfmAtchFileList(vo: FnrscVO): Promise<FnrscVO[]> {
const response = await apiClient.get<FnrscVO[]>(
"/fnrsc/cnfm/files/essential",
{ params: vo }
);
return response.data;
}
async selectInstlCnfmExmntFile(vo: FnrscVO): Promise<FnrscVO[]> {
const response = await apiClient.get<FnrscVO[]>(
"/fnrsc/cnfm/files/examination",
{ params: vo }
);
return response.data;
}
async selectInstlCnfmEtcFile(vo: FnrscVO): Promise<FnrscVO[]> {
const response = await apiClient.get<FnrscVO[]>("/fnrsc/cnfm/files/etc", {
params: vo,
});
return response.data;
}
async selectInstlCnfmSanctnManage(
vo: FnrscVO
): Promise<Record<string, unknown>> {
const response = await apiClient.get<Record<string, unknown>>(
"/fnrsc/cnfm/sanction/manage",
{ params: vo }
);
return response.data;
}
async insertInstlCnfmSpl(vo: FnrscVO): Promise<void> {
await apiClient.post("/fnrsc/cnfm/update/insertInstlCnfmSpl.do", vo);
}
async selectInstlCnfmSpl(vo: FnrscVO): Promise<SplmntRqst | null> {
const response = await apiClient.get<SplmntRqst | null>(
"/fnrsc/cnfm/supplement",
{ params: vo }
);
return response.data;
}
async selectInstlCnfmExmntResult(vo: FnrscVO): Promise<FnrscVO[]> {
const response = await apiClient.get<FnrscVO[]>(
"/fnrsc/cnfm/examination/result",
{ params: vo }
);
return response.data;
}
async selectInstlCnfmOpinionList(vo: FnrscVO): Promise<FnrscVO[]> {
const response = await apiClient.get<FnrscVO[]>(
"/fnrsc/cnfm/opinion/list",
{ params: vo }
);
return response.data;
}
}
export const instlSncnService = new InstlSncnServiceImpl();
@@ -1,312 +0,0 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { instlSncnService } from "./instl-sncn-service-impl";
import type { FnrscVO } from "../model";
export const instlSncnKeys = {
all: ["instl-sncn"] as const,
lists: () => [...instlSncnKeys.all, "list"] as const,
list: (vo: FnrscVO) => [...instlSncnKeys.lists(), vo] as const,
dtls: () => [...instlSncnKeys.all, "dtl"] as const,
dtl: (instlCnfmDocNo: string) =>
[...instlSncnKeys.dtls(), instlCnfmDocNo] as const,
registPage: (jobSeSp: string) =>
[...instlSncnKeys.all, "regist-page", jobSeSp] as const,
viewPage: (instlCnfmDocNo: string) =>
[...instlSncnKeys.all, "view-page", instlCnfmDocNo] as const,
};
/**
* 설치인가 목록 페이지 로드 (카운트 + 목록 + 페이지네이션)
*/
export function useListPageLoadCnfm(vo: FnrscVO) {
return useQuery({
queryKey: instlSncnKeys.list(vo),
queryFn: () => instlSncnService.listPageLoadCnfm(vo),
});
}
/**
* 설치인가 등록 페이지 로드 (체크리스트 + 필수첨부파일)
*/
export function useRegistPageLoadCnfm(vo: FnrscVO) {
return useQuery({
queryKey: instlSncnKeys.registPage(vo.jobSeSp || ""),
queryFn: () => instlSncnService.registPageLoadCnfm(vo),
enabled: !!vo.jobSeSp,
});
}
/**
* 설치인가 상세/수정 페이지 로드
*/
export function useViewPageLoadCnfm(vo: FnrscVO) {
return useQuery({
queryKey: instlSncnKeys.viewPage(vo.instlCnfmDocNo || ""),
queryFn: () => instlSncnService.viewPageLoadCnfm(vo),
enabled: !!vo.instlCnfmDocNo,
});
}
/**
* 설치인가 목록 카운트 조회
*/
export function useSelectInstlCnfmListCnt(vo: FnrscVO) {
return useQuery({
queryKey: [...instlSncnKeys.lists(), "count", vo],
queryFn: () => instlSncnService.selectInstlCnfmListCount(vo),
});
}
/**
* 설치인가 목록 조회
*/
export function useSelectInstlCnfmList(vo: FnrscVO) {
return useQuery({
queryKey: instlSncnKeys.list(vo),
queryFn: () => instlSncnService.selectInstlCnfmList(vo),
});
}
/**
* 설치인가 상세 조회
*/
export function useSelectInstlCnfmDtl(vo: FnrscVO) {
return useQuery({
queryKey: instlSncnKeys.dtl(vo.instlCnfmDocNo || ""),
queryFn: () => instlSncnService.selectInstlCnfmDetail(vo),
enabled: !!vo.instlCnfmDocNo,
});
}
/**
* 설치인가 신규 등록
*/
export function useInsertInstlCnfm() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ vo, files }: { vo: FnrscVO; files?: File[] }) =>
instlSncnService.insertInstlCnfm(vo, files),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: instlSncnKeys.lists(),
});
},
});
}
/**
* 설치인가 수정 (병합)
*/
export function useMergeInstlCnfm() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ vo, files }: { vo: FnrscVO; files?: File[] }) =>
instlSncnService.mergeInstlCnfm(vo, files),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: instlSncnKeys.lists(),
});
if (variables.vo.instlCnfmDocNo) {
queryClient.invalidateQueries({
queryKey: instlSncnKeys.dtl(variables.vo.instlCnfmDocNo),
});
queryClient.invalidateQueries({
queryKey: instlSncnKeys.viewPage(variables.vo.instlCnfmDocNo),
});
}
},
});
}
/**
* 설치인가 삭제
*/
export function useDeleteInstlCnfm() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (vo: FnrscVO) => instlSncnService.deleteInstlCnfm(vo),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: instlSncnKeys.lists(),
});
},
});
}
/**
* 설치인가 진행상태 업데이트
*/
export function useUpdatePrgrsSttusCnfm() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (vo: FnrscVO) =>
instlSncnService.updateProgrsSttusCnfm(vo),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: instlSncnKeys.lists(),
});
if (variables.instlCnfmDocNo) {
queryClient.invalidateQueries({
queryKey: instlSncnKeys.dtl(variables.instlCnfmDocNo),
});
queryClient.invalidateQueries({
queryKey: instlSncnKeys.viewPage(variables.instlCnfmDocNo),
});
}
},
});
}
/**
* 설치인가 진행상태 저장 (검토의견 + 파일)
*/
export function useSaveInstlCnfmPrgrsStts() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ vo, file }: { vo: FnrscVO; file?: File }) =>
instlSncnService.saveInstlCnfmProgrsSttus(vo, file),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: instlSncnKeys.lists(),
});
if (variables.vo.instlCnfmDocNo) {
queryClient.invalidateQueries({
queryKey: instlSncnKeys.dtl(variables.vo.instlCnfmDocNo),
});
queryClient.invalidateQueries({
queryKey: instlSncnKeys.viewPage(variables.vo.instlCnfmDocNo),
});
}
},
});
}
/**
* 설치인가 기술검토 최종결재
*/
export function useSaveInstlCnfmSanctnPrgrsStts() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (vo: FnrscVO) =>
instlSncnService.saveInstlCnfmSanctnProgrsSttus(vo),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: instlSncnKeys.lists(),
});
if (variables.instlCnfmDocNo) {
queryClient.invalidateQueries({
queryKey: instlSncnKeys.dtl(variables.instlCnfmDocNo),
});
queryClient.invalidateQueries({
queryKey: instlSncnKeys.viewPage(variables.instlCnfmDocNo),
});
}
},
});
}
/**
* 설치인가 보완요청 등록
*/
export function useInsertInstlCnfmSpl() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (vo: FnrscVO) => instlSncnService.insertInstlCnfmSpl(vo),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: instlSncnKeys.lists(),
});
if (variables.instlCnfmDocNo) {
queryClient.invalidateQueries({
queryKey: instlSncnKeys.dtl(variables.instlCnfmDocNo),
});
queryClient.invalidateQueries({
queryKey: instlSncnKeys.viewPage(variables.instlCnfmDocNo),
});
}
},
});
}
/**
* 설치인가 필수 첨부파일 목록 조회
*/
export function useSelectEssntlInstlCnfmAtchFileList(vo: FnrscVO) {
return useQuery({
queryKey: [...instlSncnKeys.all, "essential-files", vo.instlCnfmDocNo],
queryFn: () => instlSncnService.selectEssntlInstlCnfmAtchFileList(vo),
enabled: !!vo.instlCnfmDocNo,
});
}
/**
* 설치인가 검토의견 첨부파일 조회
*/
export function useSelectInstlCnfmExmntFile(vo: FnrscVO) {
return useQuery({
queryKey: [
...instlSncnKeys.all,
"exmnt-files",
vo.instlCnfmDocNo,
vo.exmntOpinionSeqNo,
],
queryFn: () => instlSncnService.selectInstlCnfmExmntFile(vo),
enabled: !!vo.instlCnfmDocNo && !!vo.exmntOpinionSeqNo,
});
}
/**
* 설치인가 기타 첨부파일 조회
*/
export function useSelectInstlCnfmEtcFile(vo: FnrscVO) {
return useQuery({
queryKey: [...instlSncnKeys.all, "etc-files", vo.instlCnfmDocNo],
queryFn: () => instlSncnService.selectInstlCnfmEtcFile(vo),
enabled: !!vo.instlCnfmDocNo,
});
}
/**
* 설치인가 보완요청 조회
*/
export function useSelectInstlCnfmSpl(vo: FnrscVO) {
return useQuery({
queryKey: [...instlSncnKeys.all, "supplement", vo.instlCnfmDocNo],
queryFn: () => instlSncnService.selectInstlCnfmSpl(vo),
enabled: !!vo.instlCnfmDocNo,
});
}
/**
* 설치인가 검토결과 조회
*/
export function useSelectInstlCnfmExmntResult(vo: FnrscVO) {
return useQuery({
queryKey: [
...instlSncnKeys.all,
"exmnt-result",
vo.instlCnfmDocNo,
vo.jobSe,
],
queryFn: () => instlSncnService.selectInstlCnfmExmntResult(vo),
enabled: !!vo.instlCnfmDocNo && !!vo.jobSe,
});
}
/**
* 설치인가 담당자 목록 조회
*/
export function useSelectInstlCnfmOpinionList(vo: FnrscVO) {
return useQuery({
queryKey: [...instlSncnKeys.all, "opinion-list", vo.instlCnfmDocNo],
queryFn: () => instlSncnService.selectInstlCnfmOpinionList(vo),
enabled: !!vo.instlCnfmDocNo,
});
}
-61
View File
@@ -1,61 +0,0 @@
export type {
PrgrsStts,
FnrscVO,
JobSeItem,
CeckVO,
AtchFile,
ExmntOpinion,
SplmntRqst,
ListPageResult,
RegistPageData,
ViewPageData,
InstlSncnService,
} from "./model";
export {
PRGRS_STTS_LABELS,
PRGRS_STTS_OPTIONS,
JOB_TYPE_CODE,
DEFAULT_PAGE_SIZE,
DEFAULT_RECORD_COUNT_PER_PAGE,
} from "./model";
export {
instlSncnService,
instlSncnKeys,
useListPageLoadCnfm,
useRegistPageLoadCnfm,
useViewPageLoadCnfm,
useSelectInstlCnfmListCnt,
useSelectInstlCnfmList,
useSelectInstlCnfmDtl,
useInsertInstlCnfm,
useMergeInstlCnfm,
useDeleteInstlCnfm,
useUpdatePrgrsSttusCnfm,
useSaveInstlCnfmPrgrsStts,
useSaveInstlCnfmSanctnPrgrsStts,
useInsertInstlCnfmSpl,
useSelectEssntlInstlCnfmAtchFileList,
useSelectInstlCnfmExmntFile,
useSelectInstlCnfmEtcFile,
useSelectInstlCnfmSpl,
useSelectInstlCnfmExmntResult,
useSelectInstlCnfmOpinionList,
} from "./api";
export {
fmtPrgrsStts,
fmtJobSeComma,
jobSeSpSummary,
parseJobSeSpArr,
fmtFileSize,
fmtDt,
vldtInstlSncnForm,
canAply,
canDel,
canMdfy,
canRqstSplmnt,
canAprv,
type VldtnErr,
} from "./lib";
@@ -1,47 +0,0 @@
import { PRGRS_STTS_LABELS, type PrgrsStts } from "../model";
export function fmtPrgrsStts(stts: PrgrsStts): string {
return PRGRS_STTS_LABELS[stts] || stts;
}
export function fmtJobSeComma(jobSe: string): string {
if (!jobSe) return "";
return jobSe.split("").join(",");
}
export function jobSeSpSummary(jobSeSp: string): string {
if (!jobSeSp) return "";
return jobSeSp.replace(/,/g, "");
}
export function parseJobSeSpArr(jobSeSp: string): string[] {
if (!jobSeSp) return [];
return jobSeSp.split(",").filter(Boolean);
}
export function fmtFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
}
export function fmtDt(dtString: string | undefined): string {
if (!dtString) return "-";
try {
const dt = new Date(dtString);
return new Intl.DateTimeFormat("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(dt);
} catch {
return dtString;
}
}
@@ -1,18 +0,0 @@
export {
fmtPrgrsStts,
fmtJobSeComma,
jobSeSpSummary,
parseJobSeSpArr,
fmtFileSize,
fmtDt,
} from "./format";
export {
vldtInstlSncnForm,
canAply,
canDel,
canMdfy,
canRqstSplmnt,
canAprv,
type VldtnErr,
} from "./validation";
@@ -1,73 +0,0 @@
import type { FnrscVO } from "../model";
export interface VldtnErr {
field: string;
message: string;
}
export function vldtInstlSncnForm(data: FnrscVO): VldtnErr[] {
const errors: VldtnErr[] = [];
if (!data.jobSe) {
errors.push({
field: "jobSe",
message: "업무구분을 선택해주세요.",
});
}
if (!data.bplcNm || data.bplcNm.trim() === "") {
errors.push({
field: "bplcNm",
message: "사업장명을 입력해주세요.",
});
}
if (!data.fcltNm || data.fcltNm.trim() === "") {
errors.push({
field: "fcltNm",
message: "시설명을 입력해주세요.",
});
}
return errors;
}
export function canAply(data: FnrscVO): boolean {
const errors = vldtInstlSncnForm(data);
return errors.length === 0;
}
export function canDel(
prgrsStts: string | undefined,
regId: string | undefined,
currentUsrId: string
): boolean {
return prgrsStts === "SV" && regId === currentUsrId;
}
export function canMdfy(
prgrsStts: string | undefined,
regId: string | undefined,
currentUsrId: string
): boolean {
return (
(prgrsStts === "SV" || prgrsStts === "SL") && regId === currentUsrId
);
}
export function canRqstSplmnt(
prgrsStts: string | undefined,
usrRole: string
): boolean {
return (
(prgrsStts === "ER" || prgrsStts === "PR") &&
(usrRole === "REVIEWER" || usrRole === "APPROVER")
);
}
export function canAprv(
prgrsStts: string | undefined,
usrRole: string
): boolean {
return prgrsStts === "PE" && usrRole === "APPROVER";
}
@@ -1,30 +0,0 @@
import type { PrgrsStts } from "./types";
export const PRGRS_STTS_LABELS: Record<PrgrsStts, string> = {
SV: "저장",
ST: "신청",
SL: "보완요청",
ER: "기술검토중",
EE: "기술검토완료",
PR: "환경청검토중",
PE: "환경청검토완료",
AP: "승인",
RJ: "반려",
} as const;
export const PRGRS_STTS_OPTIONS = [
{ value: "SV", label: "저장" },
{ value: "ST", label: "신청" },
{ value: "SL", label: "보완요청" },
{ value: "ER", label: "기술검토중" },
{ value: "EE", label: "기술검토완료" },
{ value: "PR", label: "환경청검토중" },
{ value: "PE", label: "환경청검토완료" },
{ value: "AP", label: "승인" },
{ value: "RJ", label: "반려" },
] as const;
export const JOB_TYPE_CODE = "FNR01" as const;
export const DEFAULT_PAGE_SIZE = 10 as const;
export const DEFAULT_RECORD_COUNT_PER_PAGE = 10 as const;
@@ -1,22 +0,0 @@
export type {
PrgrsStts,
FnrscVO,
JobSeItem,
CeckVO,
AtchFile,
ExmntOpinion,
SplmntRqst,
ListPageResult,
RegistPageData,
ViewPageData,
} from "./types";
export {
PRGRS_STTS_LABELS,
PRGRS_STTS_OPTIONS,
JOB_TYPE_CODE,
DEFAULT_PAGE_SIZE,
DEFAULT_RECORD_COUNT_PER_PAGE,
} from "./constants";
export type { InstlSncnService } from "./instl-sncn-service";
@@ -1,151 +0,0 @@
import type {
FnrscVO,
CeckVO,
AtchFile,
SplmntRqst,
ListPageResult,
RegistPageData,
ViewPageData,
} from "./types";
/**
* 설치인가 서비스 인터페이스
* Java CnfmService.java의 TypeScript 버전
*/
export interface InstlSncnService {
/**
* 설치인가 목록 카운트 조회
* @param vo 검색 조건
* @returns 목록 개수
*/
selectInstlCnfmListCount(vo: FnrscVO): Promise<number>;
/**
* 설치인가 목록 조회
* @param vo 검색 조건
* @returns 설치인가 목록
*/
selectInstlCnfmList(vo: FnrscVO): Promise<FnrscVO[]>;
/**
* 설치인가 목록 페이지 로드 (카운트 + 목록 + 페이지네이션)
* @param vo 검색 조건
* @returns 목록 페이지 데이터
*/
listPageLoadCnfm(vo: FnrscVO): Promise<ListPageResult>;
/**
* 설치인가 등록 페이지 로드 (체크리스트 + 필수첨부파일)
* @param vo jobSeSp 포함
* @returns 등록 페이지 데이터
*/
registPageLoadCnfm(vo: FnrscVO): Promise<RegistPageData>;
/**
* 설치인가 데이터 저장 (신규)
* @param vo 설치인가 VO
* @param files 첨부파일 목록
*/
insertInstlCnfm(vo: FnrscVO, files?: File[]): Promise<void>;
/**
* 설치인가 데이터 병합 (수정)
* @param vo 설치인가 VO
* @param files 첨부파일 목록
*/
mergeInstlCnfm(vo: FnrscVO, files?: File[]): Promise<void>;
/**
* 설치인가 상세 데이터 조회
* @param vo instlCnfmDocNo 포함
* @returns 설치인가 상세 정보
*/
selectInstlCnfmDetail(vo: FnrscVO): Promise<FnrscVO>;
/**
* 설치인가 상세/수정 페이지 로드
* @param vo instlCnfmDocNo 포함
* @returns 상세 페이지 데이터
*/
viewPageLoadCnfm(vo: FnrscVO): Promise<ViewPageData>;
/**
* 설치인가 진행상태 업데이트
* @param vo instlCnfmDocNo, progrsSttus 포함
*/
updateProgrsSttusCnfm(vo: FnrscVO): Promise<void>;
/**
* 설치인가 진행상태 저장 (검토의견 + 파일)
* @param vo instlCnfmDocNo, progrsSttus, exmntOpinionCn 포함
* @param file 첨부파일
*/
saveInstlCnfmProgrsSttus(vo: FnrscVO, file?: File): Promise<void>;
/**
* 설치인가 기술검토 최종결재
* @param vo instlCnfmDocNo, progrsSttus 포함
*/
saveInstlCnfmSanctnProgrsSttus(vo: FnrscVO): Promise<void>;
/**
* 설치인가 삭제
* @param vo instlCnfmDocNo 포함
*/
deleteInstlCnfm(vo: FnrscVO): Promise<void>;
/**
* 설치인가 신청 첨부파일 목록
* @param vo instlCnfmDocNo 포함
* @returns 필수 첨부파일 목록
*/
selectEssntlInstlCnfmAtchFileList(vo: FnrscVO): Promise<FnrscVO[]>;
/**
* 설치인가 의견 첨부파일
* @param vo instlCnfmDocNo, exmntOpinionSeqNo 포함
* @returns 검토의견 첨부파일 목록
*/
selectInstlCnfmExmntFile(vo: FnrscVO): Promise<FnrscVO[]>;
/**
* 설치인가 기타 첨부파일
* @param vo instlCnfmDocNo 포함
* @returns 기타 첨부파일 목록
*/
selectInstlCnfmEtcFile(vo: FnrscVO): Promise<FnrscVO[]>;
/**
* 설치인가 결재권자 조회
* @param vo instlCnfmDocNo, regId 포함
* @returns 결재권자 정보
*/
selectInstlCnfmSanctnManage(vo: FnrscVO): Promise<Record<string, unknown>>;
/**
* 설치인가 보완요청 등록
* @param vo instlCnfmDocNo, splCn 포함
*/
insertInstlCnfmSpl(vo: FnrscVO): Promise<void>;
/**
* 설치인가 보완요청 조회
* @param vo instlCnfmDocNo 포함
* @returns 보완요청 정보
*/
selectInstlCnfmSpl(vo: FnrscVO): Promise<SplmntRqst | null>;
/**
* 설치인가 검토결과 조회
* @param vo instlCnfmDocNo, jobSe 포함
* @returns 검토결과 목록
*/
selectInstlCnfmExmntResult(vo: FnrscVO): Promise<FnrscVO[]>;
/**
* 설치인가 담당자 정보 조회
* @param vo instlCnfmDocNo 포함
* @returns 담당자 목록
*/
selectInstlCnfmOpinionList(vo: FnrscVO): Promise<FnrscVO[]>;
}
@@ -1,158 +0,0 @@
export type PrgrsStts =
| "SV" // 저장
| "ST" // 신청
| "SL" // 보완요청
| "ER" // 기술검토
| "EE" // 기술검토완료
| "PR" // 환경청검토
| "PE" // 환경청검토완료
| "AP" // 승인
| "RJ"; // 반려
export interface FnrscVO {
fnrscDscssDocNo?: string;
instlCnfmDocNo?: string;
seqNo?: string;
yr?: string;
bzCd?: string;
bzNm?: string;
strtYr?: string;
edYr?: string;
dtlBzCd?: string;
untBzCd?: string;
rgnCd?: string;
rgnNm?: string;
rvbsnCd?: string;
adstDstrCd?: string;
adstDstrNm?: string;
bzSpec?: string;
aidRt?: string;
confBzCd?: string;
bzPd?: string;
bzCn?: string;
jobSe?: string;
jobSeSp?: string;
jobSeList?: Array<JobSeItem>;
jobSeNm?: string;
fcltyKnd?: string;
reusePrpos?: string;
progrsSttus?: PrgrsStts;
fileSeq?: number;
regId?: string;
regDt?: string;
udtId?: string;
udtDt?: string;
registMan?: string;
updtMan?: string;
registDe?: string;
updtDe?: string;
atchFileId?: string;
grpCd?: string;
bplcNm?: string;
fcltNm?: string;
lclgvrnmntNm?: string;
localCd?: string;
exmntOpinionSeqNo?: string;
exmntOpinionCn?: string;
scdExmntOpinionSeqNo?: string;
prmyExmntOpinionSeqNo?: string;
splCn?: string;
pageIndex?: number;
recordCountPerPage?: number;
pageSize?: number;
searchKeyword?: string;
searchCondition?: string;
}
export interface JobSeItem {
jobSeCd: string;
jobSeNm: string;
}
export interface CeckVO {
ceckSn: string;
ceckCn: string;
ceckResultAt?: "Y" | "N";
ceckResultCn?: string;
jobSe: string;
}
export interface AtchFile {
atchFileId: string;
fileSn: string;
fileStreCours: string;
streFileNm: string;
orignlFileNm: string;
fileExtsn: string;
fileMg: number;
fileType?: string;
essntlAt?: "Y" | "N";
}
export interface ExmntOpinion {
exmntOpinionSeqNo: string;
instlCnfmDocNo: string;
exmntOpinionCn: string;
exmntResultAt?: "Y" | "N";
atchFileList?: AtchFile[];
regId?: string;
regDt?: string;
}
export interface SplmntRqst {
splSeqNo: string;
instlCnfmDocNo: string;
splCn: string;
splDt?: string;
regId?: string;
regDt?: string;
}
export interface ListPageResult {
cnt: number;
list: FnrscVO[];
pageIndex: number;
lastPage: number;
paginationInfo: {
currentPageNo: number;
recordCountPerPage: number;
pageSize: number;
totalRecordCount: number;
lastPageNo: number;
};
searchVO: FnrscVO;
}
export interface RegistPageData {
ceckEssntlArr: Array<{
jobSeCd: string;
jobSeNm: string;
ceckForm1: CeckVO[];
essntlList: FnrscVO[];
}>;
jobSe: string;
jobSeSp: string;
grpCd: string;
adstDstrCd: string;
lclgvrnmntNm: string;
localCd: string;
}
export interface ViewPageData {
view: FnrscVO;
jobSeSp: string;
ceckEssntlArr: Array<{
jobSeCd: string;
jobSeNm: string;
ceckForm1: FnrscVO[];
essntlList: FnrscVO[];
}>;
prmyFileList: FnrscVO[];
scdFileList: FnrscVO[];
etcFileList: FnrscVO[];
grpCd: string;
usrId: string;
sanctnChk: Record<string, unknown>;
spl: SplmntRqst | null;
opinionList: FnrscVO[];
}
+1 -1
View File
@@ -1 +1 @@
export { type MenuItem } from "./model";
export { type MenuItem } from "./model/types";
-1
View File
@@ -1 +0,0 @@
export { type MenuItem } from "./types";
+1
View File
@@ -0,0 +1 @@
export { LoginPage } from "./ui/login-page";
@@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { LoginApi } from "@/entities/auth";
import { images } from "@common/assets";
import "./login.css";
function LoginPage() {
const [errorMessage, setErrorMessage] = useState<string>("");
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMessage(""); // 에러 메시지 초기화
const formData = new FormData(e.target as HTMLFormElement);
const userId = formData.get("id") as string;
const password = formData.get("password") as string;
try {
await LoginApi.login({ userId, password });
window.location.href = "/";
} catch (error) {
const message = error instanceof Error ? error.message : "로그인에 실패했습니다.";
setErrorMessage(message);
}
};
return (
<div className="login fx">
<div className="left_box fx">
<div className="text">
<h1>
하수도정보시스템
<br />
<span>CMS 통합관리시스템</span>
</h1>
<p>하수도정보시스템 CMS 통합관리시스템에 오신 것을 환영합니다.</p>
</div>
<div className="img">
<Image src={images.logo} width={229} height={46} alt="logo" />
</div>
</div>
<form id="loginForm" name="loginForm" method="post" onSubmit={handleSubmit}>
<div className="login_box fx">
<ul className="fx">
<li>
<h2>ID</h2>
<div className="input_st st1">
<i className="diveicon di-account-box" />
<input
type="text"
className="id"
title="아이디를 입력하세요."
id="id"
name="id"
maxLength={12}
/>
</div>
</li>
<li>
<h2>PASSWORD</h2>
<div className="input_st st2">
<i className="diveicon di-key" />
<input
type="password"
className="input_style"
maxLength={25}
title="비밀번호를 입력하세요."
id="password"
name="password"
/>
</div>
</li>
</ul>
<div className="bottom_box fx">
<div className="checks fx">
<input type="checkbox" name="checkId" title="로그인ID 저장여부" id="checkId" />
<label htmlFor="checkId">아이디저장</label>
</div>
{/* <a href="javascript:fnUserSearch();" className=" pw_message fx"><i className="xi-external-link"></i>비밀번호 또는 아이디를 잃어버리셨나요?</a> */}
</div>
{errorMessage && (
<div className="error_message">
<span>{errorMessage}</span>
</div>
)}
<button type="submit" className="login_btn fx">
<span>LOGIN</span>
</button>
<div className="footer">
<div className="copy fx">
<p>copyright © 2023 KECO. All rights reserved.</p>
</div>
<div className="fot_btn fx">
<a href="https://keco.or.kr/web/lay1/S1T6C1348/contents.do" target="_blank">
개인정보처리방침
</a>
<a href="https://keco.or.kr/web/lay1/S1T6C1349/contents.do" target="_blank">
저작권정책안내
</a>
<a href="https://keco.or.kr/web/lay1/S1T6C1350/contents.do" target="_blank">
이메일무단수집거부
</a>
</div>
<p className="addr fx">
<span>주소 : (22689) 인천광역시 서구 환경로 42(경서동 종합환경연구단지)</span>
<span>전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723, 3722</span>
<span>팩스 :032-590-3729</span>
</p>
</div>
</div>
</form>
</div>
);
}
export { LoginPage };
+325
View File
@@ -0,0 +1,325 @@
/**
* OLYM Cascading Style Sheets
* File Name : login.css
* Description : 로그인 스타일시트
* Author : Web business Team / Choi Hyun Mi
* Email : desingtj@olym.co.kr
* Date : 2023.07.20
* Update : 2023.07.26
* Copyright(c) 2023 OLYM Communications. All Rights Reserved.
*/
html,
body {
background: #fff;
}
.fx {
display: flex;
-ms-display: flex;
-webkit-display: flex;
box-sizing: border-box;
}
.login {
width: 100%;
height: 100vh;
}
.login .left_box {
width: 100%;
background: url(../../../../../public/images/bg_login.jpg) no-repeat
center/cover;
flex-direction: column;
justify-content: space-between;
padding: 7% 5% 5% 5%;
}
.login .left_box .text h1 {
font-size: 53px;
color: #fff;
}
.login .left_box .text h1 span {
font-weight: 800;
}
.login .left_box .text p {
margin-top: 40px;
font-size: 18px;
color: #fff;
}
.login .login_box {
width: 100%;
flex-direction: column;
justify-content: center;
padding: 4% 12% 4% 6%;
}
.login .login_box > ul {
flex-direction: column;
gap: 40px;
}
.login .login_box > ul > li h2 {
font-size: 22px;
font-weight: 600;
color: #2640a5;
margin-bottom: 14px;
}
.login .login_box > ul > li .input_st {
position: relative;
}
.login .login_box > ul > li .input_st .diveicon {
position: absolute;
top: 50%;
left: 28px;
transform: translateY(-50%);
font-family: "Xeicon";
font-size: 25px;
color: #003288;
}
.login .login_box > ul > li input {
width: 100%;
padding: 20px 20px 20px 70px;
background: #f7f7f7;
box-sizing: border-box;
border: none;
border-radius: 5px;
font-size: 18px;
font-weight: 700;
color: #000;
height: auto;
}
.login .login_box > ul > li input:focus {
outline: 1px solid #3c5fe5;
}
.login .login_box > ul > li input::placeholder {
color: #afabab;
font-weight: 500;
}
.login .login_box .bottom_box {
justify-content: space-between;
gap: 20px;
padding: 50px 0 45px 0;
}
.login .login_box .bottom_box .checks {
display: flex;
align-items: center;
gap: 8px;
}
.login .login_box .bottom_box .checks input[type="checkbox"] {
width: 18px;
height: 18px;
}
.login .login_box .bottom_box .checks label {
font-size: 14px;
}
.login .login_box .bottom_box .pw_message {
align-items: center;
gap: 5px;
color: #636363;
font-weight: 500;
border-bottom: 1px solid #ccc;
padding-bottom: 5px;
}
.login .login_box .bottom_box .pw_message i {
color: #327af5;
font-size: 20px;
}
.login .login_box .error_message {
margin: 20px 0;
padding: 12px 20px;
background: #fee;
border: 1px solid #fcc;
border-radius: 5px;
color: #c33;
font-size: 14px;
text-align: center;
}
.login .login_box .error_message span {
display: block;
font-weight: 500;
}
.login .login_box .login_btn {
position: relative;
border-radius: 5px;
padding: 24px 15px;
background: linear-gradient(
to left,
#1d3969 0%,
#264078 10%,
#172558 40%,
#162254 50%,
#111c43 60%,
#111c42 100%
);
overflow: hidden;
justify-content: center;
align-items: center;
gap: 10px;
color: #fff;
cursor: pointer;
}
.login .login_box .login_btn span {
font-family: "Montserrat";
font-size: 19px;
font-weight: 700;
position: relative;
}
.login .login_box .login_btn i {
font-size: 22px;
transform: translateY(-2px);
}
.login .login_box .login_btn:before {
display: block;
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
background: linear-gradient(
to right,
#1d3969 0%,
#264078 10%,
#172558 40%,
#162254 50%,
#111c43 60%,
#111c42 100%
);
transition: opacity 0.5s;
}
.login .login_box .login_btn:hover::before {
opacity: 1;
}
.login .footer .copy {
margin-top: 36px;
justify-content: space-between;
align-items: center;
gap: 3%;
}
.login .footer .copy p {
font-size: 13px;
color: #d4d4d4;
width: 44%;
text-align: center;
}
.login .footer .copy::before,
.login .login_box .copy::after {
content: "";
width: 20%;
height: 1px;
background: #d4d4d4;
}
.login .footer .fot_btn {
gap: 20px;
margin: 30px 0 10px 0;
}
.login .footer .fot_btn a {
color: #333;
font-weight: 700;
font-size: 13px;
}
.login .footer .fot_btn a:hover {
color: #003288;
}
.login .footer .addr {
color: #666;
font-size: 13px;
gap: 3px 20px;
flex-wrap: wrap;
}
/* 비밀번호 변경 팝업창 */
.login .modal .modal_container {
width: 750px;
}
.login .pw_desc {
padding: 10px 10px 30px 10px;
color: #666;
}
.login .pw_desc p {
font-size: 1.25rem;
color: #000;
font-weight: 700;
margin-bottom: 10px;
}
.login .pw_desc span {
color: #f60;
font-weight: 700;
}
.login .modal .form_write {
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
padding: 20px 50px;
}
@media (max-width: 1024px) {
.login {
flex-direction: column;
height: auto;
}
.login .left_box {
padding: 30px;
flex-direction: column-reverse;
gap: 20px;
}
.login .left_box .text h1 {
font-size: 30px;
line-height: 1.2em;
}
.login .left_box .text p {
margin-top: 20px;
font-size: 14px;
}
.login .left_box .img img {
height: 40px;
}
.login .login_box {
padding: 30px;
}
.login .login_box > ul {
gap: 20px;
}
.login .login_box > ul > li h2 {
margin-bottom: 7px;
}
.login .login_box > ul > li .input_st::after {
left: 15px;
font-size: 20px;
}
.login .login_box > ul > li input {
padding: 15px 10px 15px 50px;
font-size: 16px;
}
.login .login_box .bottom_box {
flex-direction: column;
align-items: flex-start;
padding: 30px 0;
}
.login .login_box .bottom_box .id_save label {
font-size: 14px;
}
.login .login_box .login_btn {
padding: 15px;
}
.login .login_box .login_btn span {
font-size: 16px;
}
.login .login_box .login_btn i {
font-size: 18px;
transform: translateY(-1px);
}
.login .login_box .copy p {
width: 100%;
}
.login .login_box .copy::before,
.login .login_box .copy::after {
display: none;
}
.login .footer .addr {
font-size: 11px;
gap: 3px 10px;
}
.login .modal .modal_container {
width: 90%;
}
.login .pw_desc {
padding: 0 0 20px 0;
}
.login .modal .form_write {
padding: 10px;
}
}
/* // login */
+1
View File
@@ -0,0 +1 @@
export { MainPage } from "./ui/main-page";
+9
View File
@@ -0,0 +1,9 @@
"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>
);
}
-1
View File
@@ -1 +0,0 @@
export { InstlSncnListPage } from "./ui/list-page";
@@ -1,80 +0,0 @@
import { createColumnHelper } from "@tanstack/react-table";
import type { FnrscVO } from "@/entities/instl-sncn";
import { Badge } from "@common/components";
const columnHelper = createColumnHelper<FnrscVO>();
const LIST_STEPS = ["저장", "신청완료", "기술검토", "검토결과 회신"] as const;
function stepIndex(status: string | undefined): number {
if (!status) return 0;
const map: Record<string, number> = {
SV: 0,
ST: 1,
LE: 2,
SL: 2,
ER: 2,
TE: 2,
TR: 2,
TP: 2,
EE: 2,
TA: 3,
};
return map[status] ?? 0;
}
function ProgrsSttusCell({ value }: { value?: string }) {
const current = stepIndex(value);
return (
<div className="flex flex-wrap items-center gap-1">
{LIST_STEPS.map((label, i) => (
<>
<Badge key={label} size="lg" color={i === current ? "blue" : "lightGray"}>
{label}
</Badge>
{i < LIST_STEPS.length - 1 && (
<i className="diveicon di-arrow-forward text-sm text-gray-400" />
)}
</>
))}
</div>
);
}
export const instlSncnListColumns = [
columnHelper.accessor("adstDstrNm", {
id: "adstDstrNm",
header: "지자체",
cell: (info) => info.getValue() ?? "-",
}),
columnHelper.accessor("bzNm", {
id: "bzNm",
header: "사업명",
cell: (info) => info.getValue() ?? "-",
}),
columnHelper.accessor("jobSeNm", {
id: "jobSeNm",
header: "업무구분",
cell: (info) => info.getValue() ?? "-",
}),
columnHelper.accessor("regId", {
id: "regId",
header: "담당자",
cell: (info) => info.getValue() ?? "-",
}),
columnHelper.accessor("rgnNm", {
id: "rgnNm",
header: "해당기관",
cell: (info) => info.getValue() ?? "-",
}),
columnHelper.display({
id: "progrsSttus",
header: "진행상황",
cell: (info) => <ProgrsSttusCell value={info.row.original.progrsSttus} />,
}),
columnHelper.accessor("regDt", {
id: "regDt",
header: "시행일",
cell: (info) => info.getValue() ?? "-",
}),
];
@@ -1,178 +0,0 @@
"use client";
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { fakerKO as faker } from "@faker-js/faker";
import {
Button,
DataTable,
Input,
Label,
LoadingWithAnimation,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useListPageLoadCnfm } from "@/entities/instl-sncn";
import type { FnrscVO } from "@/entities/instl-sncn";
import { instlSncnListColumns } from "../model/table-columns";
const PAGE_SIZE = 10;
const JOB_SE_OPTIONS = [
{ value: "ALL", label: "전체" },
{ value: "SF", label: "하수처리시설" },
{ value: "SP", label: "하수관로" },
{ value: "RE", label: "재이용수" },
] as const;
function buildMockFnrscVO(): FnrscVO {
return {
instlCnfmDocNo: faker.string.alphanumeric(10),
adstDstrNm: faker.location.city(),
bzNm: faker.company.name(),
jobSeNm: faker.helpers.arrayElement(["하수처리시설", "하수관로", "재이용수"]),
regId: faker.person.fullName(),
rgnNm: faker.company.name(),
progrsSttus: faker.helpers.arrayElement(["SV", "ST", "SL", "ER", "EE", "PR", "PE", "AP", "RJ"]),
regDt: faker.date.past().toISOString().slice(0, 10),
};
}
const MOCK_LIST: FnrscVO[] = Array.from({ length: PAGE_SIZE }, () => buildMockFnrscVO());
export function InstlSncnListPage() {
const router = useRouter();
const [bzNm, setBzNm] = useState("");
const [jobSeNm, setJobSeNm] = useState<string>("ALL");
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: PAGE_SIZE });
const searchVO: FnrscVO = {
bzNm: bzNm.trim() || undefined,
jobSeNm: jobSeNm === "ALL" ? undefined : jobSeNm,
pageIndex: pagination.pageIndex + 1,
recordCountPerPage: PAGE_SIZE,
pageSize: PAGE_SIZE,
};
const { data, isPending, isError } = useListPageLoadCnfm(searchVO);
const list = data?.list && data.list.length > 0 ? data.list : MOCK_LIST;
const totalCount = data?.cnt ?? list.length;
const pageCount = data?.paginationInfo?.lastPageNo ?? 1;
const handleSearch = useCallback((e: React.FormEvent) => {
e.preventDefault();
setPagination((p) => ({ ...p, pageIndex: 0 }));
}, []);
const handleRowClick = useCallback(
(row: FnrscVO) => {
if (row.instlCnfmDocNo) {
router.push(`/instl-sncn/${row.instlCnfmDocNo}`);
}
},
[router],
);
const handleRegist = useCallback(() => {
router.push("/instl-sncn/regist");
}, [router]);
const table = useReactTable({
data: list,
columns: instlSncnListColumns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
pageCount,
state: {
pagination,
},
onPaginationChange: setPagination,
});
const isEmpty = list.length === 0;
return (
<div className="contents px-[1.8rem] pb-8">
<form
onSubmit={handleSearch}
className="card card-border flex flex-wrap items-center space-x-6 !p-4"
>
<div className="flex items-center gap-2">
<Label htmlFor="bzNm" className="shrink-0 text-label">
사업명
</Label>
<Input
id="bzNm"
placeholder="사업명"
value={bzNm}
onChange={(e) => setBzNm(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch(e as unknown as React.FormEvent)}
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="jobSeNm" className="shrink-0 text-label">
구분
</Label>
<Select
value={jobSeNm}
onValueChange={(v) => {
setJobSeNm(v);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
<SelectTrigger id="jobSeNm" className="w-[12rem]">
<SelectValue placeholder="구분" />
</SelectTrigger>
<SelectContent>
{JOB_SE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit">조회</Button>
</form>
<div className="p-4">
<div className="mb-3 flex items-end justify-between">
<p className="text-xl text-label">
검색된 리스트 (<span className="font-semibold text-primary">{totalCount}</span>)
</p>
<Button type="button" color="green" onClick={handleRegist}>
신청
</Button>
</div>
<div className="rounded border border-border bg-card">
{isPending && (
<div className="flex w-full items-center justify-center py-10">
<div className="relative flex size-[4rem] items-center justify-center">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
{/* {isError && (
<div className="flex items-center justify-center py-12 text-sm text-destructive">
목록을 불러오는 중 오류가 발생했습니다.
</div>
)} */}
{!isPending && !isError && isEmpty && (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
목록이 존재하지 않습니다.
</div>
)}
{!isPending && !isEmpty && (
<div className="pb-10">
<DataTable table={table} viewPagination onRowClick={handleRowClick} />
</div>
)}
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -1,9 +1,9 @@
import { PROXY_HOST, SITE_ID } from "@/shared/config";
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({
export const client = Client.getInstance({
baseURL: BASE_URL,
siteId: SITE_ID,
});
+1 -1
View File
@@ -1 +1 @@
export { apiClient } from "./client";
export { client } from "./client";
+9
View File
@@ -0,0 +1,9 @@
export const ENDPOINT = {
USER_SERVICE: {
LOGIN: "/user-service/login",
SILENT_REFRESH: "/user-service/api/v1/users/token/refresh",
USERS: "/user-service/api/v1/users",
},
};
export const JWT_EXPIRATION_TIME = 1800000;
+6 -7
View File
@@ -1,11 +1,10 @@
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
export const PROXY_HOST =
process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
export const PROXY_HOST = process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "AS";
export const SERVER_API_URL = process.env.SERVER_API_URL;
export const AUTH_LOGIN_REDIRECT_URL =
process.env.NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL || "/auth/login";
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
export const ACCESS_TOKEN =
process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
export const REFRESH_TOKEN =
process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
export const 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
View File
@@ -1 +1,2 @@
export * from "./api";
export * from "./env";
+29 -22
View File
@@ -1,22 +1,29 @@
import Cookies from "js-cookie";
export function getCookie(name: string): string | undefined {
if (typeof window === "undefined") {
return undefined;
}
return Cookies.get(name);
}
export function setCookie(name: string, value: string, options?: Cookies.CookieAttributes): void {
if (typeof window === "undefined") {
return;
}
Cookies.set(name, value, options);
}
export function removeCookie(name: string, options?: Cookies.CookieAttributes): void {
if (typeof window === "undefined") {
return;
}
Cookies.remove(name, options);
}
import Cookies from "js-cookie";
export function getCookie(name: string): string | undefined {
if (typeof window === "undefined") {
return undefined;
}
return Cookies.get(name);
}
export function setCookie(
name: string,
value: string,
options?: Cookies.CookieAttributes,
): void {
if (typeof window === "undefined") {
return;
}
Cookies.set(name, value, options);
}
export function removeCookie(
name: string,
options?: Cookies.CookieAttributes,
): void {
if (typeof window === "undefined") {
return;
}
Cookies.remove(name, options);
}
+4 -3
View File
@@ -1,8 +1,9 @@
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";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import "dayjs/locale/ko";
dayjs.extend(utc);
dayjs.extend(timezone);
+3 -2
View File
@@ -1,3 +1,4 @@
export { queryClient } from "./query-client";
export { getCookie, removeCookie, setCookie } from "./cookie";
export { dayjs } from "./dayjs";
export { getCookie, setCookie, removeCookie } from "./cookie";
export { queryClient } from "./query-client";
export { useUserStore } from "./use-user-store";
+16
View File
@@ -0,0 +1,16 @@
import { useContext } from "react";
import { useStore } from "zustand";
import { StoreContext } from "../providers";
import type { UserStore } from "../store";
export const useUserStore = <T>(selector: (store: UserStore) => T): T => {
const storeContext = useContext(StoreContext);
if (!storeContext) {
throw new Error("useUserStore must be used within StoreProvider");
}
return useStore(storeContext.userStore, selector);
};
+1
View File
@@ -0,0 +1 @@
export { StoreContext, StoreProvider } from "./store-provider";
@@ -0,0 +1,22 @@
"use client";
import { createContext, useRef } from "react";
import { createUserStore } from "../store";
type StoreContextType = {
userStore: ReturnType<typeof createUserStore>;
};
export const StoreContext = createContext<StoreContextType | undefined>(undefined);
export const StoreProvider = ({ children }: { children: React.ReactNode }) => {
const storeRef = useRef<StoreContextType | null>(null);
if (storeRef.current === null) {
storeRef.current = {
userStore: createUserStore(),
};
}
return <StoreContext.Provider value={storeRef.current}>{children}</StoreContext.Provider>;
};
+1
View File
@@ -0,0 +1 @@
export { createUserStore, type IUserInfo, type UserStore } from "./user-store";
+57
View File
@@ -0,0 +1,57 @@
import { createStore } from "zustand/vanilla";
export interface IUserInfo {
regDt: string;
mdfcnDt: string;
rgtr: string;
mdfr: string;
adminRoleYn: string;
userUnqId: string;
userId: string;
userName: string;
email: string;
encryptedPassword: string;
refreshToken: string;
userStateCode: string;
lastLoginDate: string;
loginFailCount: number;
pswdHnt: string;
pswdAns: string;
mphn: string;
telno: string;
zip: string;
addr: string;
daddr: string;
laeSeCd: string;
ogdpSeCd: string;
aprvYn: string;
autzrId: string;
expryYmd: string;
lastPswdChgDt: string;
googleId: string;
kakaoId: string;
naverId: string;
isSocialUser: boolean;
hasPassword: boolean;
}
export type UserState = {
userInfo: IUserInfo | null;
};
export type UserActions = {
setUserInfo: (userInfo: IUserInfo | null) => void;
};
export type UserStore = UserState & UserActions;
export const defaultInitState: UserState = {
userInfo: null,
};
export const createUserStore = (initState: UserState = defaultInitState) => {
return createStore<UserStore>()((set) => ({
...initState,
setUserInfo: (userInfo: IUserInfo | null) => set(() => ({ userInfo })),
}));
};
+5 -1
View File
@@ -4,8 +4,12 @@ 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}",
"./src/**/*.{js,ts,jsx,tsx,mdx}",
"../../packages/common/components/dist/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
+5 -1
View File
@@ -13,7 +13,11 @@
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"],
"@common/components": ["../../packages/common/components/dist/"],
+11 -50
View File
@@ -138,9 +138,6 @@ importers:
'@common/network':
specifier: workspace:*
version: link:../../packages/common/network
'@faker-js/faker':
specifier: ^10.3.0
version: 10.3.0
'@hookform/resolvers':
specifier: ^4.1.3
version: 4.1.3(react-hook-form@7.72.0(react@19.2.4))
@@ -172,6 +169,9 @@ importers:
specifier: ^3.24.2
version: 3.25.76
devDependencies:
'@eslint/eslintrc':
specifier: ^3
version: 3.3.5
'@types/js-cookie':
specifier: ^3.0.6
version: 3.0.6
@@ -179,8 +179,11 @@ importers:
specifier: ^9.0.0
version: 9.39.4(jiti@2.6.1)
eslint-config-next:
specifier: 15.4.0
version: 15.4.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
specifier: 15.3.8
version: 15.3.8(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
eslint-plugin-react-hooks:
specifier: ^5.0.0
version: 5.2.0(eslint@9.39.4(jiti@2.6.1))
postcss:
specifier: ^8.4.49
version: 8.5.8
@@ -2497,10 +2500,6 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@faker-js/faker@10.3.0':
resolution: {integrity: sha512-It0Sne6P3szg7JIi6CgKbvTZoMjxBZhcv91ZrqrNuaZQfB5WoqYYbzCUOq89YR+VY8juY9M1vDWmDDa2TzfXCw==}
engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'}
'@faker-js/faker@9.9.0':
resolution: {integrity: sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==}
engines: {node: '>=18.0.0', npm: '>=9.0.0'}
@@ -2796,9 +2795,6 @@ packages:
'@next/eslint-plugin-next@15.3.8':
resolution: {integrity: sha512-CKpvDZhNXPUjUHmjHddcsqjwj46oQblFms2hr2uA79C0kVTiSXQEQxkawUsDNSofHhTI31IbSeBdVyuk/U1nKw==}
'@next/eslint-plugin-next@15.4.0':
resolution: {integrity: sha512-dlGuj2B76ltswtpdDFQJfTlu6ux0v+EUYzsAYR1IqxqHi5iMPWhPRztgu8eqQZo4uNYkABKzLCmVeGDwbTYTiw==}
'@next/swc-darwin-arm64@15.3.5':
resolution: {integrity: sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w==}
engines: {node: '>= 10'}
@@ -5032,15 +5028,6 @@ packages:
typescript:
optional: true
eslint-config-next@15.4.0:
resolution: {integrity: sha512-OFkQ8br0wuZNRlXDyLpfaCwzW+qayzNaiHNcLHDMW874/1Nktb2+cCUMlItXdzDvNS/QY5jggKlYXEH2+vz1BA==}
peerDependencies:
eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
typescript: '>=3.3.1'
peerDependenciesMeta:
typescript:
optional: true
eslint-import-resolver-node@0.3.9:
resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==}
@@ -7999,8 +7986,6 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
'@faker-js/faker@10.3.0': {}
'@faker-js/faker@9.9.0': {}
'@feature-sliced/filesystem@3.0.1':
@@ -8293,10 +8278,6 @@ snapshots:
dependencies:
fast-glob: 3.3.1
'@next/eslint-plugin-next@15.4.0':
dependencies:
fast-glob: 3.3.1
'@next/swc-darwin-arm64@15.3.5':
optional: true
@@ -10812,7 +10793,7 @@ snapshots:
eslint: 9.39.4(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4(jiti@1.21.7))
@@ -10843,26 +10824,6 @@ snapshots:
- eslint-plugin-import-x
- supports-color
eslint-config-next@15.4.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
dependencies:
'@next/eslint-plugin-next': 15.4.0
'@rushstack/eslint-patch': 1.16.1
'@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4(jiti@2.6.1))
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- eslint-import-resolver-webpack
- eslint-plugin-import-x
- supports-color
eslint-import-resolver-node@0.3.9:
dependencies:
debug: 3.2.7
@@ -10882,7 +10843,7 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))
transitivePeerDependencies:
- supports-color
@@ -10923,7 +10884,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7)):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9