diff --git a/apps/_template/.env.example b/apps/_template/.env.example index f9794a6f8..318a30511 100644 --- a/apps/_template/.env.example +++ b/apps/_template/.env.example @@ -1,8 +1,8 @@ -# apps/as/shared/config/env.ts 참고 +# src/shared/config/env.ts 참고 NEXT_PUBLIC_PORT= NEXT_PUBLIC_PROXY_HOST= NEXT_PUBLIC_SITE_ID= -NEXT_PUBLIC_SERVER_API_URL= +SERVER_API_URL= # 401 시 브라우저 리다이렉트 대상 NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL=/auth/login diff --git a/apps/_template/middleware.ts b/apps/_template/middleware.ts index da09c5840..e6bbba40a 100644 --- a/apps/_template/middleware.ts +++ b/apps/_template/middleware.ts @@ -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 { diff --git a/apps/_template/src/shared/config/env.ts b/apps/_template/src/shared/config/env.ts index 4f08c722a..a1825f415 100644 --- a/apps/_template/src/shared/config/env.ts +++ b/apps/_template/src/shared/config/env.ts @@ -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"; diff --git a/apps/as/.env.example b/apps/as/.env.example deleted file mode 100644 index b2d706024..000000000 --- a/apps/as/.env.example +++ /dev/null @@ -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= diff --git a/apps/as/README.md b/apps/as/README.md index 79d0b2e2a..c48c1534a 100644 --- a/apps/as/README.md +++ b/apps/as/README.md @@ -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"; ``` diff --git a/apps/as/app/(with-base-layout)/instl-sncn/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/page.tsx deleted file mode 100644 index 324982f41..000000000 --- a/apps/as/app/(with-base-layout)/instl-sncn/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { InstlSncnListPage as default } from "@/pages/instl-sncn"; diff --git a/apps/as/app/(with-base-layout)/layout.tsx b/apps/as/app/(with-base-layout)/layout.tsx index 24e6e7276..71dd294a8 100644 --- a/apps/as/app/(with-base-layout)/layout.tsx +++ b/apps/as/app/(with-base-layout)/layout.tsx @@ -1,9 +1,11 @@ -import { BaseLayoutWrapper } from "@/app/layouts/base-layout"; - -interface WithBaseLayoutProps { - children: React.ReactNode; -} - -export default function WithBaseLayout({ children }: WithBaseLayoutProps) { - return {children}; -} +import type { ReactNode } from "react"; + +import { BaseLayoutWrapper } from "@/app/layouts/base-layout"; + +interface WithBaseLayoutProps { + children: ReactNode; +} + +export default function WithBaseLayout({ children }: WithBaseLayoutProps) { + return {children}; +} diff --git a/apps/as/app/(with-base-layout)/page.tsx b/apps/as/app/(with-base-layout)/page.tsx new file mode 100644 index 000000000..82a918b59 --- /dev/null +++ b/apps/as/app/(with-base-layout)/page.tsx @@ -0,0 +1 @@ +export { MainPage as default } from "@/pages/home"; diff --git a/apps/as/app/api/proxy/[...url]/route.ts b/apps/as/app/api/proxy/[...url]/route.ts new file mode 100644 index 000000000..36ec17557 --- /dev/null +++ b/apps/as/app/api/proxy/[...url]/route.ts @@ -0,0 +1,94 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +import { + ACCESS_TOKEN, + AUTH_USER_ID, + CLAIM_NAME, + REFRESH_TOKEN, + SERVER_API_URL, + SITE_ID, +} from "@/shared/config"; + +async function handler( + request: Request, + params: { url: string[] }, + method: "GET" | "POST" | "PUT" | "DELETE", +) { + const cookieStore = await cookies(); + const headers: Record = { + "Content-Type": "application/json", + "X-Site-Id": SITE_ID, + }; + let body = {}; + const isRefreshHandler = params.url.includes("refresh"); + + // silent refresh + if (isRefreshHandler) { + const refreshToken = cookieStore.get(REFRESH_TOKEN); + headers[CLAIM_NAME] = refreshToken?.value ?? ""; + + if (!headers[CLAIM_NAME]) { + return NextResponse.json({ message: "Can't refresh" }, { status: 401 }); + } + } + + try { + if (!isRefreshHandler) { + body = await request.json(); + } + + const result = await fetch(`${SERVER_API_URL}/${params.url.join("/")}`, { + method, + headers, + body: JSON.stringify(body), + }); + + if (!result.ok) { + return NextResponse.json({ message: "Invalid Credentials" }, { status: 401 }); + } + + const accessToken = result.headers.get(ACCESS_TOKEN); + const refreshToken = result.headers.get(REFRESH_TOKEN); + const userId = result.headers.get(AUTH_USER_ID); + + if (accessToken && userId) { + const response = NextResponse.json({ + [ACCESS_TOKEN]: accessToken, + [AUTH_USER_ID]: userId, + }); + + const forwardedHost = request.headers.get("x-forwarded-host"); + const host = request.headers.get("host"); + const actualHost = forwardedHost || host || new URL(request.url).hostname.toString(); + + // 운영 환경에서 domain 설정 + const domain = actualHost.includes("hasuon.or.kr") ? ".hasuon.or.kr" : actualHost; + + if (refreshToken) { + cookieStore.set(REFRESH_TOKEN, refreshToken, { + httpOnly: true, + sameSite: "strict", + domain: domain, + path: "/", + }); + } + + return response; + } + + return NextResponse.json({ message: "Invalid Credentials" }, { status: 401 }); + } catch (error) { + return NextResponse.json({ message: "Server Error", error }, { status: 500 }); + } +} + +export async function POST(request: Request, props: { params: Promise<{ url: string[] }> }) { + const params = await props.params; + return handler(request, params, "POST"); +} + +export async function PUT(request: Request, props: { params: Promise<{ url: string[] }> }) { + const params = await props.params; + return handler(request, params, "PUT"); +} diff --git a/apps/as/app/auth/login/page.tsx b/apps/as/app/auth/login/page.tsx new file mode 100644 index 000000000..8f9e6adc6 --- /dev/null +++ b/apps/as/app/auth/login/page.tsx @@ -0,0 +1,2 @@ +import { LoginPage } from "@/pages/auth/login"; +export default LoginPage; diff --git a/apps/as/app/layout.tsx b/apps/as/app/layout.tsx index 5293046d6..3b64ab52f 100644 --- a/apps/as/app/layout.tsx +++ b/apps/as/app/layout.tsx @@ -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"); diff --git a/apps/as/docs/approval-line-management/SPEC.md b/apps/as/docs/approval-line-management/SPEC.md index 42e9999c1..8528f8241 100644 --- a/apps/as/docs/approval-line-management/SPEC.md +++ b/apps/as/docs/approval-line-management/SPEC.md @@ -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``.../ui/approval-line-management-page.tsx` | -| 위젯(영역 단위) | `apps/as/src/widgets/approval-template-list/``apps/as/src/widgets/approval-line-editor/` | -| 피처(상호작용) | `apps/as/src/features/approver-picker/` (검토인력 선택 다이얼로그) | -| 엔티티(도메인) | `apps/as/src/entities/approval-template/``apps/as/src/entities/approval-line/` | -| 그리드 컬럼 정의 | `entities/approval-template/model/approval-template-table-columns.tsx``entities/approval-line/model/approval-line-table-columns.tsx` | -| API 클라이언트 | `entities/approval-template/api/*`, `entities/approval-line/api/*` (TBD) | +| 구분 | 경로 | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| 라우트(App Router) | `apps/as/app/(with-base-layout)/approval-line-management/page.tsx` | +| 페이지 컴포넌트 | `apps/as/src/pages/approval-line-management/index.ts``.../ui/approval-line-management-page.tsx` | +| 위젯(영역 단위) | `apps/as/src/widgets/approval-template-list/``apps/as/src/widgets/approval-line-editor/` | +| 피처(상호작용) | `apps/as/src/features/approver-picker/` (검토인력 선택 다이얼로그) | +| 엔티티(도메인) | `apps/as/src/entities/approval-template/``apps/as/src/entities/approval-line/` | +| 그리드 컬럼 정의 | `entities/approval-template/model/approval-template-table-columns.tsx``entities/approval-line/model/approval-line-table-columns.tsx` | +| API 클라이언트 | `entities/approval-template/api/*`, `entities/approval-line/api/*` (TBD) | ### 7-3. 영역별 UI 매핑 -| 영역 | 주요 UI | 비고 | -| ----- | ----------------------------------------------------------------------------- | --------------------------------------------------- | -| A | 섹션 헤더 + Button(`서식 등록`) + DataTable (행 선택, 행별 Action 버튼) | 선택 행 강조, 행 클릭으로 영역 B 갱신 | -| B 헤더| 상태 라벨 `선택된 서식: {ID}` | 미선택 시 영역 B-1·B-2 비활성 | -| B-1 | 읽기 전용 Input(아이디), Select(결재구분), Input+Button(결재자/검토인력 선택), NumberInput(순서번호), Action 버튼 3종(`결재자 추가`/`결재선 저장`/`초기화`) | 폼 유효성 검증 후 그리드에 임시 행 추가 | -| B-2 | DataTable (행별 [수정][삭제]), 임시 행 시각적 구분 | 기본 정렬: 결재순서번호 ASC | -| 공통 | Dialog(검토인력 선택, 서식 등록/수정, 결재자 수정, 삭제 확인) | 디자인 시스템의 `Dialog` 컴포넌트 사용 | +| 영역 | 주요 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. 데이터 흐름(요약) diff --git a/apps/as/eslint.config.mjs b/apps/as/eslint.config.mjs index 05e726d1b..19ccdfae3 100644 --- a/apps/as/eslint.config.mjs +++ b/apps/as/eslint.config.mjs @@ -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; diff --git a/apps/as/middleware.ts b/apps/as/middleware.ts index da09c5840..d9239dc40 100644 --- a/apps/as/middleware.ts +++ b/apps/as/middleware.ts @@ -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 { diff --git a/apps/as/package.json b/apps/as/package.json index 92b2e6482..efafdcc50 100644 --- a/apps/as/package.json +++ b/apps/as/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/apps/as/pages/.gitkeep b/apps/as/pages/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/as/pages/README.md b/apps/as/pages/README.md new file mode 100644 index 000000000..e011944c3 --- /dev/null +++ b/apps/as/pages/README.md @@ -0,0 +1,9 @@ +# 이 폴더는 비워 둡니다 (FSD + Next.js App Router) + +Next.js가 `src/pages`를 **Pages Router**용 `pages` 디렉터리로 인식하지 않도록, +프로젝트 루트에 **빈 `pages` 폴더**를 두는 것이 FSD 공식 가이드 권장사항입니다. + +- 실제 페이지(FSD Pages 레이어)는 **`src/pages`**에 있습니다. +- Next.js 라우트는 **`app/`** 에서 `src/pages`를 re-export 하는 방식으로 연결됩니다. + +[Feature-Sliced Design - Usage with Next.js (App Router)](https://feature-sliced.design/docs/guides/tech/with-nextjs) diff --git a/apps/as/public/images/bg_login.jpg b/apps/as/public/images/bg_login.jpg new file mode 100755 index 000000000..b7793d16a Binary files /dev/null and b/apps/as/public/images/bg_login.jpg differ diff --git a/apps/as/src/app/layouts/base-layout.tsx b/apps/as/src/app/layouts/base-layout.tsx index 72b95709f..54fb55c52 100644 --- a/apps/as/src/app/layouts/base-layout.tsx +++ b/apps/as/src/app/layouts/base-layout.tsx @@ -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 ( - - {children} - - ); -} +"use client"; + +import { useCallback } from "react"; +import { usePathname, useRouter } from "next/navigation"; + +import { useQueryClient } from "@tanstack/react-query"; + +import { PageLayout } from "@/shared/ui/page-layout"; +import type { MenuItem } from "@/entities/menu"; +import { BaseLayout } from "@common/components"; + +interface BaseLayoutWrapperProps { + children: React.ReactNode; +} + +export function BaseLayoutWrapper({ children }: BaseLayoutWrapperProps) { + const router = useRouter(); + const pathname = usePathname(); + const queryClient = useQueryClient(); + + const handleMenuClick = useCallback( + (item: MenuItem) => { + router.push(item.urlPath); + }, + [router], + ); + + return ( + + {children} + + ); +} diff --git a/apps/as/src/app/providers/index.tsx b/apps/as/src/app/providers/index.tsx index ff37c57b7..476609d59 100644 --- a/apps/as/src/app/providers/index.tsx +++ b/apps/as/src/app/providers/index.tsx @@ -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 ( - - {children} - - - - - ); -} +"use client"; + +import { QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; + +import { queryClient } from "@/shared/lib"; +import { StoreProvider } from "@/shared/providers"; +import { AuthWrapper } from "@/entities/auth"; + +interface ProvidersProps { + children: React.ReactNode; +} + +export function Providers({ children }: ProvidersProps) { + return ( + + + {children} + + + + ); +} diff --git a/apps/as/src/app/styles/components.css b/apps/as/src/app/styles/components.css deleted file mode 100644 index 5b9742c0f..000000000 --- a/apps/as/src/app/styles/components.css +++ /dev/null @@ -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; - } -} diff --git a/apps/as/src/app/styles/globals.css b/apps/as/src/app/styles/globals.css index 91c6845e5..e557f947b 100644 --- a/apps/as/src/app/styles/globals.css +++ b/apps/as/src/app/styles/globals.css @@ -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; +} diff --git a/apps/as/src/entities/auth/api/login-service.ts b/apps/as/src/entities/auth/api/login-service.ts new file mode 100644 index 000000000..2dc784e97 --- /dev/null +++ b/apps/as/src/entities/auth/api/login-service.ts @@ -0,0 +1,92 @@ +import { AxiosError } from "axios"; + +import { + ACCESS_TOKEN, + AUTH_USER_ID, + CLAIM_NAME, + ENDPOINT, + JWT_EXPIRATION_TIME, +} from "@/shared/config"; + +import { tokenManager } from "../lib/token-manager"; +import type { ILogin, LoginService } from "../model/login-interface"; + +export class LoginApiService implements LoginService { + private static instance: LoginApiService; + + private constructor() {} + + public static getInstance(): LoginApiService { + if (!LoginApiService.instance) { + LoginApiService.instance = new LoginApiService(); + } + return LoginApiService.instance; + } + + public async login(payload: ILogin): Promise { + try { + const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { + method: "POST", + body: JSON.stringify({ + userId: payload.userId, + password: payload.password, + }), + }); + + const data = await response.json(); + console.log("data ", data); + const accessToken = data[ACCESS_TOKEN]; + const tokenId = data[AUTH_USER_ID]; + + if (!accessToken || !tokenId) { + if (data.message == "Invalid Credentials") { + throw new Error("아이디와 비밀번호가 일치하지 않습니다."); + } else if (data.message == "User not found") { + throw new Error("존재하지 않는 아이디입니다."); + } else if (data.message == "User is not active") { + throw new Error("활성화되지 않은 계정입니다."); + } else if (data.message == "User is not verified") { + throw new Error("이메일 인증이 필요합니다."); + } else { + throw new Error(data.message); + } + } + + tokenManager.setToken(CLAIM_NAME, accessToken); + tokenManager.setToken(AUTH_USER_ID, tokenId); + tokenManager.setupRefreshTimer(() => this.silentRefresh(), JWT_EXPIRATION_TIME); + } catch (error) { + if (error instanceof AxiosError) { + throw new Error(`Login 실패 ${error.response?.data?.message}`); + } + throw new Error(`Login 실패 ${error}`); + } + } + + public async silentRefresh(): Promise { + try { + const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, { + method: "PUT", + }); + + const data = await response.json(); + const accessToken = data[ACCESS_TOKEN]; + const tokenId = data[AUTH_USER_ID]; + + if (!accessToken || !tokenId) { + throw new Error("Missing required authentication tokens"); + } + + tokenManager.setToken(CLAIM_NAME, accessToken); + tokenManager.setToken(AUTH_USER_ID, tokenId); + } catch (error) { + tokenManager.clearAuthToken(); + if (error instanceof AxiosError) { + throw new Error(`Silent refresh failed: ${error.response?.data?.message}`); + } + throw new Error(`Silent refresh failed: ${error}`); + } + } +} + +export const LoginApi = LoginApiService.getInstance(); diff --git a/apps/as/src/entities/auth/index.ts b/apps/as/src/entities/auth/index.ts new file mode 100644 index 000000000..97e142225 --- /dev/null +++ b/apps/as/src/entities/auth/index.ts @@ -0,0 +1,2 @@ +export { LoginApi } from "./api/login-service"; +export { AuthWrapper } from "./lib/auth-wrapper"; diff --git a/apps/as/src/entities/auth/lib/auth-wrapper.tsx b/apps/as/src/entities/auth/lib/auth-wrapper.tsx new file mode 100644 index 000000000..e92957163 --- /dev/null +++ b/apps/as/src/entities/auth/lib/auth-wrapper.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect } from "react"; + +import { client } from "@/shared/api"; +import { AUTH_LOGIN_REDIRECT_URL, AUTH_USER_ID, ENDPOINT } from "@/shared/config"; +import { getCookie, useUserStore } from "@/shared/lib"; +import type { IUserInfo } from "@/shared/store"; +import { isAtLoginRedirectUrl } from "@common/network"; + +const AuthWrapper = ({ children }: { children: React.ReactNode }) => { + const { userInfo, setUserInfo } = useUserStore((state) => state); + + async function authCheck() { + if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { + return; + } + + const userId = getCookie(AUTH_USER_ID); + if (!userId) { + throw new Error("Missing required authentication tokens"); + } + + const response = await client.get(`${ENDPOINT.USER_SERVICE.USERS}/info/me`); + if (!userInfo) setUserInfo(response.data); + } + + useEffect(() => { + (async () => { + try { + await authCheck(); + } catch (error) { + console.error(error); + setUserInfo(null); + if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { + window.location.assign(AUTH_LOGIN_REDIRECT_URL); + } + } + })(); + }, []); + + return <>{children}>; +}; + +export { AuthWrapper }; diff --git a/apps/as/src/entities/auth/lib/token-manager.ts b/apps/as/src/entities/auth/lib/token-manager.ts new file mode 100644 index 000000000..c5eeffc3a --- /dev/null +++ b/apps/as/src/entities/auth/lib/token-manager.ts @@ -0,0 +1,82 @@ +import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config"; +import { getCookie, removeCookie,setCookie } from "@/shared/lib"; + +export class TokenManager { + private static instance: TokenManager; + private refreshTimer: NodeJS.Timeout | null = null; + + private constructor() {} + + public static getInstance(): TokenManager { + if (!TokenManager.instance) { + TokenManager.instance = new TokenManager(); + } + return TokenManager.instance; + } + + public getToken(key: string): string | undefined { + return getCookie(key); + } + + public setToken(key: string, value: string): void { + const domain = this.getCookieDomain(); + + setCookie(key, value, { + secure: window.location.protocol === "https:", + sameSite: "lax", + domain: domain, + path: "/", // 모든 경로에서 접근 가능 + }); + } + + + private getCookieDomain(): string | undefined { + const hostname = window.location.hostname; + + // 개발 환경에서는 localhost 도메인 사용 + if (hostname === "localhost" || hostname === "127.0.0.1") { + return undefined; // localhost에서는 도메인 설정하지 않음 + } + + // 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용 + if (hostname.includes("hasuon.or.kr")) { + return ".hasuon.or.kr"; + } + + // 기타 환경에서는 현재 호스트명 사용 + return hostname; + } + + public removeToken(key: string): void { + const domain = this.getCookieDomain(); + removeCookie(key, { domain, path: "/" }); + } + + public setupRefreshTimer( + callback: () => Promise, + expirationTime: number, + ): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + this.refreshTimer = setTimeout(() => { + callback().catch((error) => { + console.error("Silent refresh failed:", error); + this.clearAuthToken(); + }); + }, expirationTime - 60000); + } + + public clearAuthToken(): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + + this.removeToken(CLAIM_NAME); + this.removeToken(AUTH_USER_ID); + } +} + +export const tokenManager = TokenManager.getInstance(); diff --git a/apps/as/src/entities/auth/model/login-interface.ts b/apps/as/src/entities/auth/model/login-interface.ts new file mode 100644 index 000000000..5aaf53044 --- /dev/null +++ b/apps/as/src/entities/auth/model/login-interface.ts @@ -0,0 +1,9 @@ +export interface ILogin { + userId: string; + password: string; +} + +export interface LoginService { + login(payload: ILogin): Promise; + silentRefresh(): Promise; +} diff --git a/apps/as/src/entities/instl-sncn/api/index.ts b/apps/as/src/entities/instl-sncn/api/index.ts deleted file mode 100644 index 2e4d9c20b..000000000 --- a/apps/as/src/entities/instl-sncn/api/index.ts +++ /dev/null @@ -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"; diff --git a/apps/as/src/entities/instl-sncn/api/instl-sncn-service-impl.ts b/apps/as/src/entities/instl-sncn/api/instl-sncn-service-impl.ts deleted file mode 100644 index 356f8a033..000000000 --- a/apps/as/src/entities/instl-sncn/api/instl-sncn-service-impl.ts +++ /dev/null @@ -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 { - const response = await apiClient.get<{ count: number }>( - "/fnrsc/cnfm/list/count", - { params: vo } - ); - return response.data.count; - } - - async selectInstlCnfmList(vo: FnrscVO): Promise { - const response = await apiClient.get("/fnrsc/cnfm/list", { - params: vo, - }); - return response.data; - } - - async listPageLoadCnfm(vo: FnrscVO): Promise { - const response = await apiClient.get( - "/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 { - const response = await apiClient.get( - "/fnrsc/cnfm/regist/pageLoad.do", - { - params: vo, - } - ); - return response.data; - } - - async insertInstlCnfm(vo: FnrscVO, files?: File[]): Promise { - 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 { - 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 { - const response = await apiClient.get("/fnrsc/cnfm/detail", { - params: vo, - }); - return response.data; - } - - async viewPageLoadCnfm(vo: FnrscVO): Promise { - const response = await apiClient.get( - "/fnrsc/cnfm/view/pageLoad.do", - { - params: vo, - } - ); - return response.data; - } - - async updateProgrsSttusCnfm(vo: FnrscVO): Promise { - await apiClient.post("/fnrsc/cnfm/update/updateProgrsSttus.do", vo); - } - - async saveInstlCnfmProgrsSttus(vo: FnrscVO, file?: File): Promise { - 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 { - await apiClient.post( - "/fnrsc/cnfm/update/saveInstlCnfmSanctnProgrsSttus.do", - vo - ); - } - - async deleteInstlCnfm(vo: FnrscVO): Promise { - await apiClient.post("/fnrsc/cnfm/regist/deleteInstlCnfm.do", vo); - } - - async selectEssntlInstlCnfmAtchFileList(vo: FnrscVO): Promise { - const response = await apiClient.get( - "/fnrsc/cnfm/files/essential", - { params: vo } - ); - return response.data; - } - - async selectInstlCnfmExmntFile(vo: FnrscVO): Promise { - const response = await apiClient.get( - "/fnrsc/cnfm/files/examination", - { params: vo } - ); - return response.data; - } - - async selectInstlCnfmEtcFile(vo: FnrscVO): Promise { - const response = await apiClient.get("/fnrsc/cnfm/files/etc", { - params: vo, - }); - return response.data; - } - - async selectInstlCnfmSanctnManage( - vo: FnrscVO - ): Promise> { - const response = await apiClient.get>( - "/fnrsc/cnfm/sanction/manage", - { params: vo } - ); - return response.data; - } - - async insertInstlCnfmSpl(vo: FnrscVO): Promise { - await apiClient.post("/fnrsc/cnfm/update/insertInstlCnfmSpl.do", vo); - } - - async selectInstlCnfmSpl(vo: FnrscVO): Promise { - const response = await apiClient.get( - "/fnrsc/cnfm/supplement", - { params: vo } - ); - return response.data; - } - - async selectInstlCnfmExmntResult(vo: FnrscVO): Promise { - const response = await apiClient.get( - "/fnrsc/cnfm/examination/result", - { params: vo } - ); - return response.data; - } - - async selectInstlCnfmOpinionList(vo: FnrscVO): Promise { - const response = await apiClient.get( - "/fnrsc/cnfm/opinion/list", - { params: vo } - ); - return response.data; - } -} - -export const instlSncnService = new InstlSncnServiceImpl(); diff --git a/apps/as/src/entities/instl-sncn/api/queries.ts b/apps/as/src/entities/instl-sncn/api/queries.ts deleted file mode 100644 index 26fa394da..000000000 --- a/apps/as/src/entities/instl-sncn/api/queries.ts +++ /dev/null @@ -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, - }); -} diff --git a/apps/as/src/entities/instl-sncn/index.ts b/apps/as/src/entities/instl-sncn/index.ts deleted file mode 100644 index 40b412b7f..000000000 --- a/apps/as/src/entities/instl-sncn/index.ts +++ /dev/null @@ -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"; diff --git a/apps/as/src/entities/instl-sncn/lib/format.ts b/apps/as/src/entities/instl-sncn/lib/format.ts deleted file mode 100644 index 3b16cff56..000000000 --- a/apps/as/src/entities/instl-sncn/lib/format.ts +++ /dev/null @@ -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; - } -} diff --git a/apps/as/src/entities/instl-sncn/lib/index.ts b/apps/as/src/entities/instl-sncn/lib/index.ts deleted file mode 100644 index 9333d229e..000000000 --- a/apps/as/src/entities/instl-sncn/lib/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -export { - fmtPrgrsStts, - fmtJobSeComma, - jobSeSpSummary, - parseJobSeSpArr, - fmtFileSize, - fmtDt, -} from "./format"; - -export { - vldtInstlSncnForm, - canAply, - canDel, - canMdfy, - canRqstSplmnt, - canAprv, - type VldtnErr, -} from "./validation"; diff --git a/apps/as/src/entities/instl-sncn/lib/validation.ts b/apps/as/src/entities/instl-sncn/lib/validation.ts deleted file mode 100644 index 97860ccaa..000000000 --- a/apps/as/src/entities/instl-sncn/lib/validation.ts +++ /dev/null @@ -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"; -} diff --git a/apps/as/src/entities/instl-sncn/model/constants.ts b/apps/as/src/entities/instl-sncn/model/constants.ts deleted file mode 100644 index e8bea4018..000000000 --- a/apps/as/src/entities/instl-sncn/model/constants.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { PrgrsStts } from "./types"; - -export const PRGRS_STTS_LABELS: Record = { - 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; diff --git a/apps/as/src/entities/instl-sncn/model/index.ts b/apps/as/src/entities/instl-sncn/model/index.ts deleted file mode 100644 index 7c8c73489..000000000 --- a/apps/as/src/entities/instl-sncn/model/index.ts +++ /dev/null @@ -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"; diff --git a/apps/as/src/entities/instl-sncn/model/instl-sncn-service.ts b/apps/as/src/entities/instl-sncn/model/instl-sncn-service.ts deleted file mode 100644 index 74a35cef9..000000000 --- a/apps/as/src/entities/instl-sncn/model/instl-sncn-service.ts +++ /dev/null @@ -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; - - /** - * 설치인가 목록 조회 - * @param vo 검색 조건 - * @returns 설치인가 목록 - */ - selectInstlCnfmList(vo: FnrscVO): Promise; - - /** - * 설치인가 목록 페이지 로드 (카운트 + 목록 + 페이지네이션) - * @param vo 검색 조건 - * @returns 목록 페이지 데이터 - */ - listPageLoadCnfm(vo: FnrscVO): Promise; - - /** - * 설치인가 등록 페이지 로드 (체크리스트 + 필수첨부파일) - * @param vo jobSeSp 포함 - * @returns 등록 페이지 데이터 - */ - registPageLoadCnfm(vo: FnrscVO): Promise; - - /** - * 설치인가 데이터 저장 (신규) - * @param vo 설치인가 VO - * @param files 첨부파일 목록 - */ - insertInstlCnfm(vo: FnrscVO, files?: File[]): Promise; - - /** - * 설치인가 데이터 병합 (수정) - * @param vo 설치인가 VO - * @param files 첨부파일 목록 - */ - mergeInstlCnfm(vo: FnrscVO, files?: File[]): Promise; - - /** - * 설치인가 상세 데이터 조회 - * @param vo instlCnfmDocNo 포함 - * @returns 설치인가 상세 정보 - */ - selectInstlCnfmDetail(vo: FnrscVO): Promise; - - /** - * 설치인가 상세/수정 페이지 로드 - * @param vo instlCnfmDocNo 포함 - * @returns 상세 페이지 데이터 - */ - viewPageLoadCnfm(vo: FnrscVO): Promise; - - /** - * 설치인가 진행상태 업데이트 - * @param vo instlCnfmDocNo, progrsSttus 포함 - */ - updateProgrsSttusCnfm(vo: FnrscVO): Promise; - - /** - * 설치인가 진행상태 저장 (검토의견 + 파일) - * @param vo instlCnfmDocNo, progrsSttus, exmntOpinionCn 포함 - * @param file 첨부파일 - */ - saveInstlCnfmProgrsSttus(vo: FnrscVO, file?: File): Promise; - - /** - * 설치인가 기술검토 최종결재 - * @param vo instlCnfmDocNo, progrsSttus 포함 - */ - saveInstlCnfmSanctnProgrsSttus(vo: FnrscVO): Promise; - - /** - * 설치인가 삭제 - * @param vo instlCnfmDocNo 포함 - */ - deleteInstlCnfm(vo: FnrscVO): Promise; - - /** - * 설치인가 신청 첨부파일 목록 - * @param vo instlCnfmDocNo 포함 - * @returns 필수 첨부파일 목록 - */ - selectEssntlInstlCnfmAtchFileList(vo: FnrscVO): Promise; - - /** - * 설치인가 의견 첨부파일 - * @param vo instlCnfmDocNo, exmntOpinionSeqNo 포함 - * @returns 검토의견 첨부파일 목록 - */ - selectInstlCnfmExmntFile(vo: FnrscVO): Promise; - - /** - * 설치인가 기타 첨부파일 - * @param vo instlCnfmDocNo 포함 - * @returns 기타 첨부파일 목록 - */ - selectInstlCnfmEtcFile(vo: FnrscVO): Promise; - - /** - * 설치인가 결재권자 조회 - * @param vo instlCnfmDocNo, regId 포함 - * @returns 결재권자 정보 - */ - selectInstlCnfmSanctnManage(vo: FnrscVO): Promise>; - - /** - * 설치인가 보완요청 등록 - * @param vo instlCnfmDocNo, splCn 포함 - */ - insertInstlCnfmSpl(vo: FnrscVO): Promise; - - /** - * 설치인가 보완요청 조회 - * @param vo instlCnfmDocNo 포함 - * @returns 보완요청 정보 - */ - selectInstlCnfmSpl(vo: FnrscVO): Promise; - - /** - * 설치인가 검토결과 조회 - * @param vo instlCnfmDocNo, jobSe 포함 - * @returns 검토결과 목록 - */ - selectInstlCnfmExmntResult(vo: FnrscVO): Promise; - - /** - * 설치인가 담당자 정보 조회 - * @param vo instlCnfmDocNo 포함 - * @returns 담당자 목록 - */ - selectInstlCnfmOpinionList(vo: FnrscVO): Promise; -} diff --git a/apps/as/src/entities/instl-sncn/model/types.ts b/apps/as/src/entities/instl-sncn/model/types.ts deleted file mode 100644 index ce61009c1..000000000 --- a/apps/as/src/entities/instl-sncn/model/types.ts +++ /dev/null @@ -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; - 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; - spl: SplmntRqst | null; - opinionList: FnrscVO[]; -} diff --git a/apps/as/src/entities/menu/index.ts b/apps/as/src/entities/menu/index.ts index 03aed8131..cb55d9a1c 100644 --- a/apps/as/src/entities/menu/index.ts +++ b/apps/as/src/entities/menu/index.ts @@ -1 +1 @@ -export { type MenuItem } from "./model"; +export { type MenuItem } from "./model/types"; diff --git a/apps/as/src/entities/menu/model/index.ts b/apps/as/src/entities/menu/model/index.ts deleted file mode 100644 index 89ebe7381..000000000 --- a/apps/as/src/entities/menu/model/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { type MenuItem } from "./types"; diff --git a/apps/as/src/pages/auth/login/index.ts b/apps/as/src/pages/auth/login/index.ts new file mode 100644 index 000000000..2093c3d8d --- /dev/null +++ b/apps/as/src/pages/auth/login/index.ts @@ -0,0 +1 @@ +export { LoginPage } from "./ui/login-page"; diff --git a/apps/as/src/pages/auth/login/ui/login-page.tsx b/apps/as/src/pages/auth/login/ui/login-page.tsx new file mode 100644 index 000000000..2926a2ebe --- /dev/null +++ b/apps/as/src/pages/auth/login/ui/login-page.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; + +import { LoginApi } from "@/entities/auth"; +import { images } from "@common/assets"; + +import "./login.css"; + +function LoginPage() { + const [errorMessage, setErrorMessage] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setErrorMessage(""); // 에러 메시지 초기화 + + const formData = new FormData(e.target as HTMLFormElement); + const userId = formData.get("id") as string; + const password = formData.get("password") as string; + + try { + await LoginApi.login({ userId, password }); + window.location.href = "/"; + } catch (error) { + const message = error instanceof Error ? error.message : "로그인에 실패했습니다."; + setErrorMessage(message); + } + }; + + return ( + + + + + 하수도정보시스템 + + CMS 통합관리시스템 + + 하수도정보시스템 CMS 통합관리시스템에 오신 것을 환영합니다. + + + + + + + + + + ID + + + + + + + PASSWORD + + + + + + + + + + 아이디저장 + + {/* 비밀번호 또는 아이디를 잃어버리셨나요? */} + + {errorMessage && ( + + {errorMessage} + + )} + + LOGIN + + + + copyright © 2023 KECO. All rights reserved. + + + + 개인정보처리방침 + + + 저작권정책안내 + + + 이메일무단수집거부 + + + + 주소 : (22689) 인천광역시 서구 환경로 42(경서동 종합환경연구단지) + 전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723, 3722 + 팩스 :032-590-3729 + + + + + + ); +} + +export { LoginPage }; diff --git a/apps/as/src/pages/auth/login/ui/login.css b/apps/as/src/pages/auth/login/ui/login.css new file mode 100755 index 000000000..e8029fdb1 --- /dev/null +++ b/apps/as/src/pages/auth/login/ui/login.css @@ -0,0 +1,325 @@ +/** +* OLYM Cascading Style Sheets +* File Name : login.css +* Description : 로그인 스타일시트 +* Author : Web business Team / Choi Hyun Mi +* Email : desingtj@olym.co.kr +* Date : 2023.07.20 +* Update : 2023.07.26 +* Copyright(c) 2023 OLYM Communications. All Rights Reserved. +*/ +html, +body { + background: #fff; +} +.fx { + display: flex; + -ms-display: flex; + -webkit-display: flex; + box-sizing: border-box; +} +.login { + width: 100%; + height: 100vh; +} +.login .left_box { + width: 100%; + background: url(../../../../../public/images/bg_login.jpg) no-repeat + center/cover; + flex-direction: column; + justify-content: space-between; + padding: 7% 5% 5% 5%; +} +.login .left_box .text h1 { + font-size: 53px; + color: #fff; +} +.login .left_box .text h1 span { + font-weight: 800; +} +.login .left_box .text p { + margin-top: 40px; + font-size: 18px; + color: #fff; +} +.login .login_box { + width: 100%; + flex-direction: column; + justify-content: center; + padding: 4% 12% 4% 6%; +} +.login .login_box > ul { + flex-direction: column; + gap: 40px; +} +.login .login_box > ul > li h2 { + font-size: 22px; + font-weight: 600; + color: #2640a5; + margin-bottom: 14px; +} +.login .login_box > ul > li .input_st { + position: relative; +} +.login .login_box > ul > li .input_st .diveicon { + position: absolute; + top: 50%; + left: 28px; + transform: translateY(-50%); + font-family: "Xeicon"; + font-size: 25px; + color: #003288; +} +.login .login_box > ul > li input { + width: 100%; + padding: 20px 20px 20px 70px; + background: #f7f7f7; + box-sizing: border-box; + border: none; + border-radius: 5px; + font-size: 18px; + font-weight: 700; + color: #000; + height: auto; +} +.login .login_box > ul > li input:focus { + outline: 1px solid #3c5fe5; +} +.login .login_box > ul > li input::placeholder { + color: #afabab; + font-weight: 500; +} +.login .login_box .bottom_box { + justify-content: space-between; + gap: 20px; + padding: 50px 0 45px 0; +} +.login .login_box .bottom_box .checks { + display: flex; + align-items: center; + gap: 8px; +} +.login .login_box .bottom_box .checks input[type="checkbox"] { + width: 18px; + height: 18px; +} +.login .login_box .bottom_box .checks label { + font-size: 14px; +} +.login .login_box .bottom_box .pw_message { + align-items: center; + gap: 5px; + color: #636363; + font-weight: 500; + border-bottom: 1px solid #ccc; + padding-bottom: 5px; +} +.login .login_box .bottom_box .pw_message i { + color: #327af5; + font-size: 20px; +} +.login .login_box .error_message { + margin: 20px 0; + padding: 12px 20px; + background: #fee; + border: 1px solid #fcc; + border-radius: 5px; + color: #c33; + font-size: 14px; + text-align: center; +} +.login .login_box .error_message span { + display: block; + font-weight: 500; +} +.login .login_box .login_btn { + position: relative; + border-radius: 5px; + padding: 24px 15px; + background: linear-gradient( + to left, + #1d3969 0%, + #264078 10%, + #172558 40%, + #162254 50%, + #111c43 60%, + #111c42 100% + ); + overflow: hidden; + justify-content: center; + align-items: center; + gap: 10px; + color: #fff; + cursor: pointer; +} +.login .login_box .login_btn span { + font-family: "Montserrat"; + font-size: 19px; + font-weight: 700; + position: relative; +} +.login .login_box .login_btn i { + font-size: 22px; + transform: translateY(-2px); +} +.login .login_box .login_btn:before { + display: block; + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + background: linear-gradient( + to right, + #1d3969 0%, + #264078 10%, + #172558 40%, + #162254 50%, + #111c43 60%, + #111c42 100% + ); + transition: opacity 0.5s; +} +.login .login_box .login_btn:hover::before { + opacity: 1; +} +.login .footer .copy { + margin-top: 36px; + justify-content: space-between; + align-items: center; + gap: 3%; +} +.login .footer .copy p { + font-size: 13px; + color: #d4d4d4; + width: 44%; + text-align: center; +} +.login .footer .copy::before, +.login .login_box .copy::after { + content: ""; + width: 20%; + height: 1px; + background: #d4d4d4; +} +.login .footer .fot_btn { + gap: 20px; + margin: 30px 0 10px 0; +} +.login .footer .fot_btn a { + color: #333; + font-weight: 700; + font-size: 13px; +} +.login .footer .fot_btn a:hover { + color: #003288; +} +.login .footer .addr { + color: #666; + font-size: 13px; + gap: 3px 20px; + flex-wrap: wrap; +} +/* 비밀번호 변경 팝업창 */ +.login .modal .modal_container { + width: 750px; +} +.login .pw_desc { + padding: 10px 10px 30px 10px; + color: #666; +} +.login .pw_desc p { + font-size: 1.25rem; + color: #000; + font-weight: 700; + margin-bottom: 10px; +} +.login .pw_desc span { + color: #f60; + font-weight: 700; +} +.login .modal .form_write { + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + padding: 20px 50px; +} +@media (max-width: 1024px) { + .login { + flex-direction: column; + height: auto; + } + .login .left_box { + padding: 30px; + flex-direction: column-reverse; + gap: 20px; + } + .login .left_box .text h1 { + font-size: 30px; + line-height: 1.2em; + } + .login .left_box .text p { + margin-top: 20px; + font-size: 14px; + } + .login .left_box .img img { + height: 40px; + } + .login .login_box { + padding: 30px; + } + .login .login_box > ul { + gap: 20px; + } + .login .login_box > ul > li h2 { + margin-bottom: 7px; + } + .login .login_box > ul > li .input_st::after { + left: 15px; + font-size: 20px; + } + .login .login_box > ul > li input { + padding: 15px 10px 15px 50px; + font-size: 16px; + } + .login .login_box .bottom_box { + flex-direction: column; + align-items: flex-start; + padding: 30px 0; + } + .login .login_box .bottom_box .id_save label { + font-size: 14px; + } + .login .login_box .login_btn { + padding: 15px; + } + .login .login_box .login_btn span { + font-size: 16px; + } + .login .login_box .login_btn i { + font-size: 18px; + transform: translateY(-1px); + } + .login .login_box .copy p { + width: 100%; + } + .login .login_box .copy::before, + .login .login_box .copy::after { + display: none; + } + .login .footer .addr { + font-size: 11px; + gap: 3px 10px; + } + .login .modal .modal_container { + width: 90%; + } + .login .pw_desc { + padding: 0 0 20px 0; + } + .login .modal .form_write { + padding: 10px; + } +} +/* // login */ diff --git a/apps/as/src/pages/home/index.ts b/apps/as/src/pages/home/index.ts new file mode 100644 index 000000000..97a463408 --- /dev/null +++ b/apps/as/src/pages/home/index.ts @@ -0,0 +1 @@ +export { MainPage } from "./ui/main-page"; diff --git a/apps/as/src/pages/home/ui/main-page.tsx b/apps/as/src/pages/home/ui/main-page.tsx new file mode 100644 index 000000000..f757224dc --- /dev/null +++ b/apps/as/src/pages/home/ui/main-page.tsx @@ -0,0 +1,9 @@ +"use client"; + +export function MainPage() { + return ( + + Home + + ); +} diff --git a/apps/as/src/pages/instl-sncn/index.ts b/apps/as/src/pages/instl-sncn/index.ts deleted file mode 100644 index b9127c3a1..000000000 --- a/apps/as/src/pages/instl-sncn/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { InstlSncnListPage } from "./ui/list-page"; diff --git a/apps/as/src/pages/instl-sncn/model/table-columns.tsx b/apps/as/src/pages/instl-sncn/model/table-columns.tsx deleted file mode 100644 index e53a7fad4..000000000 --- a/apps/as/src/pages/instl-sncn/model/table-columns.tsx +++ /dev/null @@ -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(); - -const LIST_STEPS = ["저장", "신청완료", "기술검토", "검토결과 회신"] as const; - -function stepIndex(status: string | undefined): number { - if (!status) return 0; - const map: Record = { - 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 ( - - {LIST_STEPS.map((label, i) => ( - <> - - {label} - - {i < LIST_STEPS.length - 1 && ( - - )} - > - ))} - - ); -} - -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) => , - }), - columnHelper.accessor("regDt", { - id: "regDt", - header: "시행일", - cell: (info) => info.getValue() ?? "-", - }), -]; diff --git a/apps/as/src/pages/instl-sncn/ui/list-page.tsx b/apps/as/src/pages/instl-sncn/ui/list-page.tsx deleted file mode 100644 index 6aecea351..000000000 --- a/apps/as/src/pages/instl-sncn/ui/list-page.tsx +++ /dev/null @@ -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("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 ( - - - - - 사업명 - - setBzNm(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch(e as unknown as React.FormEvent)} - /> - - - - 구분 - - { - setJobSeNm(v); - setPagination((p) => ({ ...p, pageIndex: 0 })); - }} - > - - - - - {JOB_SE_OPTIONS.map((opt) => ( - - {opt.label} - - ))} - - - - 조회 - - - - - - 검색된 리스트 ({totalCount}) - - - 신청 - - - - - {isPending && ( - - - - - - )} - {/* {isError && ( - - 목록을 불러오는 중 오류가 발생했습니다. - - )} */} - {!isPending && !isError && isEmpty && ( - - 목록이 존재하지 않습니다. - - )} - {!isPending && !isEmpty && ( - - - - )} - - - - ); -} diff --git a/apps/as/src/shared/api/client.ts b/apps/as/src/shared/api/client.ts index d7c4a8f1d..061e1de99 100644 --- a/apps/as/src/shared/api/client.ts +++ b/apps/as/src/shared/api/client.ts @@ -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, }); diff --git a/apps/as/src/shared/api/index.ts b/apps/as/src/shared/api/index.ts index c5eb98066..0b96ffaa1 100644 --- a/apps/as/src/shared/api/index.ts +++ b/apps/as/src/shared/api/index.ts @@ -1 +1 @@ -export { apiClient } from "./client"; +export { client } from "./client"; diff --git a/apps/as/src/shared/config/api.ts b/apps/as/src/shared/config/api.ts new file mode 100644 index 000000000..154aa1052 --- /dev/null +++ b/apps/as/src/shared/config/api.ts @@ -0,0 +1,9 @@ +export const ENDPOINT = { + USER_SERVICE: { + LOGIN: "/user-service/login", + SILENT_REFRESH: "/user-service/api/v1/users/token/refresh", + USERS: "/user-service/api/v1/users", + }, +}; + +export const JWT_EXPIRATION_TIME = 1800000; diff --git a/apps/as/src/shared/config/env.ts b/apps/as/src/shared/config/env.ts index 4f08c722a..4625722bd 100644 --- a/apps/as/src/shared/config/env.ts +++ b/apps/as/src/shared/config/env.ts @@ -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"; diff --git a/apps/as/src/shared/config/index.ts b/apps/as/src/shared/config/index.ts index c76f8cd47..856d35f87 100644 --- a/apps/as/src/shared/config/index.ts +++ b/apps/as/src/shared/config/index.ts @@ -1 +1,2 @@ +export * from "./api"; export * from "./env"; diff --git a/apps/as/src/shared/lib/cookie.ts b/apps/as/src/shared/lib/cookie.ts index 6557cb81d..8f5d0011b 100644 --- a/apps/as/src/shared/lib/cookie.ts +++ b/apps/as/src/shared/lib/cookie.ts @@ -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); +} diff --git a/apps/as/src/shared/lib/dayjs.ts b/apps/as/src/shared/lib/dayjs.ts index 28b3f908b..62c6cef6e 100644 --- a/apps/as/src/shared/lib/dayjs.ts +++ b/apps/as/src/shared/lib/dayjs.ts @@ -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); diff --git a/apps/as/src/shared/lib/index.ts b/apps/as/src/shared/lib/index.ts index 26a6d4551..013c429d1 100644 --- a/apps/as/src/shared/lib/index.ts +++ b/apps/as/src/shared/lib/index.ts @@ -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"; diff --git a/apps/as/src/shared/lib/use-user-store.ts b/apps/as/src/shared/lib/use-user-store.ts new file mode 100644 index 000000000..8aa5085a9 --- /dev/null +++ b/apps/as/src/shared/lib/use-user-store.ts @@ -0,0 +1,16 @@ +import { useContext } from "react"; + +import { useStore } from "zustand"; + +import { StoreContext } from "../providers"; +import type { UserStore } from "../store"; + +export const useUserStore = (selector: (store: UserStore) => T): T => { + const storeContext = useContext(StoreContext); + + if (!storeContext) { + throw new Error("useUserStore must be used within StoreProvider"); + } + + return useStore(storeContext.userStore, selector); +}; diff --git a/apps/as/src/shared/providers/index.ts b/apps/as/src/shared/providers/index.ts new file mode 100644 index 000000000..e19453b80 --- /dev/null +++ b/apps/as/src/shared/providers/index.ts @@ -0,0 +1 @@ +export { StoreContext, StoreProvider } from "./store-provider"; diff --git a/apps/as/src/shared/providers/store-provider.tsx b/apps/as/src/shared/providers/store-provider.tsx new file mode 100644 index 000000000..4ebb2151e --- /dev/null +++ b/apps/as/src/shared/providers/store-provider.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { createContext, useRef } from "react"; + +import { createUserStore } from "../store"; + +type StoreContextType = { + userStore: ReturnType; +}; + +export const StoreContext = createContext(undefined); + +export const StoreProvider = ({ children }: { children: React.ReactNode }) => { + const storeRef = useRef(null); + if (storeRef.current === null) { + storeRef.current = { + userStore: createUserStore(), + }; + } + + return {children}; +}; diff --git a/apps/as/src/shared/store/index.ts b/apps/as/src/shared/store/index.ts new file mode 100644 index 000000000..8a0f34588 --- /dev/null +++ b/apps/as/src/shared/store/index.ts @@ -0,0 +1 @@ +export { createUserStore, type IUserInfo, type UserStore } from "./user-store"; diff --git a/apps/as/src/shared/store/user-store.ts b/apps/as/src/shared/store/user-store.ts new file mode 100644 index 000000000..65d897cf1 --- /dev/null +++ b/apps/as/src/shared/store/user-store.ts @@ -0,0 +1,57 @@ +import { createStore } from "zustand/vanilla"; + +export interface IUserInfo { + regDt: string; + mdfcnDt: string; + rgtr: string; + mdfr: string; + adminRoleYn: string; + userUnqId: string; + userId: string; + userName: string; + email: string; + encryptedPassword: string; + refreshToken: string; + userStateCode: string; + lastLoginDate: string; + loginFailCount: number; + pswdHnt: string; + pswdAns: string; + mphn: string; + telno: string; + zip: string; + addr: string; + daddr: string; + laeSeCd: string; + ogdpSeCd: string; + aprvYn: string; + autzrId: string; + expryYmd: string; + lastPswdChgDt: string; + googleId: string; + kakaoId: string; + naverId: string; + isSocialUser: boolean; + hasPassword: boolean; +} + +export type UserState = { + userInfo: IUserInfo | null; +}; + +export type UserActions = { + setUserInfo: (userInfo: IUserInfo | null) => void; +}; + +export type UserStore = UserState & UserActions; + +export const defaultInitState: UserState = { + userInfo: null, +}; + +export const createUserStore = (initState: UserState = defaultInitState) => { + return createStore()((set) => ({ + ...initState, + setUserInfo: (userInfo: IUserInfo | null) => set(() => ({ userInfo })), + })); +}; diff --git a/apps/as/tailwind.config.ts b/apps/as/tailwind.config.ts index e51b76dd9..d19dec7ed 100644 --- a/apps/as/tailwind.config.ts +++ b/apps/as/tailwind.config.ts @@ -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: { diff --git a/apps/as/tsconfig.json b/apps/as/tsconfig.json index 8428fd5c2..dab9b0b8e 100644 --- a/apps/as/tsconfig.json +++ b/apps/as/tsconfig.json @@ -13,7 +13,11 @@ "isolatedModules": true, "jsx": "preserve", "incremental": true, - "plugins": [{ "name": "next" }], + "plugins": [ + { + "name": "next" + } + ], "paths": { "@/*": ["./src/*"], "@common/components": ["../../packages/common/components/dist/"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8cc097be..c9efd7b97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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
하수도정보시스템 CMS 통합관리시스템에 오신 것을 환영합니다.
copyright © 2023 KECO. All rights reserved.
+ 주소 : (22689) 인천광역시 서구 환경로 42(경서동 종합환경연구단지) + 전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723, 3722 + 팩스 :032-590-3729 +
- 검색된 리스트 ({totalCount}) -