Merge branch 'iipbs_cs' of https://www.onasys.co.kr:3000/ONAANDALL/frontend
This commit is contained in:
@@ -134,7 +134,7 @@ function DashboardPage() {
|
||||
{/* 전체 게시물 카드 */}
|
||||
<div className="card card-border h-80 !p-0">
|
||||
<div className="relative h-full">
|
||||
<div className="absolute left-0 top-0 p-6 tracking-tighter">
|
||||
<div className="absolute left-0 top-0 p-6 tracking-tighter text-foreground">
|
||||
<h3 className="text-2xl font-medium">전체 게시물</h3>
|
||||
<div className="mt-2 text-6xl font-bold">
|
||||
{data?.pstTotCnt ? data.pstTotCnt.toLocaleString() : "-"}
|
||||
@@ -157,7 +157,7 @@ function DashboardPage() {
|
||||
{/* 오늘 접속자 수 카드 */}
|
||||
<div className="card card-border h-80 !p-0">
|
||||
<div className="relative h-full">
|
||||
<div className="absolute left-0 top-0 p-6 tracking-tighter">
|
||||
<div className="absolute left-0 top-0 p-6 tracking-tighter text-foreground">
|
||||
<h3 className="text-2xl font-medium">오늘 접속자 수</h3>
|
||||
<div className="mt-2 text-6xl font-bold">
|
||||
{data?.accessTodayCnt
|
||||
@@ -184,7 +184,7 @@ function DashboardPage() {
|
||||
{/* API 호출 건수 카드 */}
|
||||
<div className="card card-border h-80 !p-0">
|
||||
<div className="relative h-full">
|
||||
<div className="absolute left-0 top-0 p-6 tracking-tighter">
|
||||
<div className="absolute left-0 top-0 p-6 tracking-tighter text-foreground">
|
||||
<h3 className="text-2xl font-medium">API 호출 건수</h3>
|
||||
<div className="mt-2 text-6xl font-bold">
|
||||
{data?.apiTodayCnt ? data.apiTodayCnt.toLocaleString() : "-"}
|
||||
@@ -206,13 +206,13 @@ function DashboardPage() {
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{/* 승인 대기 알림 */}
|
||||
<div className="card card-border">
|
||||
<div className="card-header">
|
||||
<div className="card-header text-foreground">
|
||||
<h3 className="text-2xl font-medium">승인 대기 알림</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex gap-4">
|
||||
{/* 사용자 승인 */}
|
||||
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6">
|
||||
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6 text-foreground">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-4 flex h-10 w-10 items-center justify-center rounded-lg bg-red-100">
|
||||
<svg
|
||||
@@ -240,13 +240,13 @@ function DashboardPage() {
|
||||
: "0"}
|
||||
건
|
||||
</div>
|
||||
<div className="mt-2 text-2xl text-gray-500">
|
||||
<div className="mt-2 text-2xl opacity-50">
|
||||
신규 가입 승인 대기
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 매뉴 승인 */}
|
||||
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6">
|
||||
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6 text-foreground">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-4 flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
|
||||
<svg
|
||||
@@ -273,13 +273,13 @@ function DashboardPage() {
|
||||
<div className="text-5xl font-bold">
|
||||
{(3).toLocaleString()}건
|
||||
</div>
|
||||
<div className="mt-2 text-2xl text-gray-500">
|
||||
<div className="mt-2 text-2xl opacity-50">
|
||||
매뉴 수정 승인 대기
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 컨텐츠 승인 */}
|
||||
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6">
|
||||
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6 text-foreground">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-4 flex h-10 w-10 items-center justify-center rounded-lg bg-yellow-100">
|
||||
<svg
|
||||
@@ -307,7 +307,7 @@ function DashboardPage() {
|
||||
: "0"}
|
||||
건
|
||||
</div>
|
||||
<div className="mt-2 text-2xl text-gray-500">
|
||||
<div className="mt-2 text-2xl opacity-50">
|
||||
컨텐츠 수정 승인 대기
|
||||
</div>
|
||||
</div>
|
||||
@@ -318,11 +318,11 @@ function DashboardPage() {
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{/* 실시간 알림 */}
|
||||
<div className="card card-border">
|
||||
<div className="card-header">
|
||||
<div className="card-header text-foreground">
|
||||
<h3 className="text-2xl font-medium">실시간 알림</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 text-foreground">
|
||||
<div className="flex items-center justify-between border-b border-border pb-4">
|
||||
<span className="font-medium">신규 사용자 가입</span>
|
||||
<span>{(1).toLocaleString()}분 전</span>
|
||||
@@ -344,11 +344,11 @@ function DashboardPage() {
|
||||
</div>
|
||||
{/* 일정 알림 */}
|
||||
<div className="card card-border">
|
||||
<div className="card-header">
|
||||
<div className="card-header text-foreground">
|
||||
<h3 className="text-2xl font-medium">일정 알림</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 text-foreground">
|
||||
<div className="flex items-center justify-between border-b border-border pb-4">
|
||||
<span className="font-medium">
|
||||
{(3).toLocaleString()}월 정기 시스템 점검
|
||||
@@ -377,7 +377,7 @@ function DashboardPage() {
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{/* 접속자 통계 */}
|
||||
<div className="card card-border">
|
||||
<div className="card-header">
|
||||
<div className="card-header text-foreground">
|
||||
<h3 className="text-2xl font-medium">접속자 통계</h3>
|
||||
</div>
|
||||
<div className="h-80 p-4">
|
||||
@@ -392,7 +392,7 @@ function DashboardPage() {
|
||||
</div>
|
||||
{/* 메뉴별 접속 통계 */}
|
||||
<div className="card card-border">
|
||||
<div className="card-header">
|
||||
<div className="card-header text-foreground">
|
||||
<h3 className="text-2xl font-medium">메뉴별 접속 통계</h3>
|
||||
</div>
|
||||
<div className="h-80 p-4">
|
||||
@@ -412,14 +412,14 @@ function DashboardPage() {
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{/* 공지사항 */}
|
||||
<div className="card card-border !p-0">
|
||||
<div className="card-header !m-0 !border-none">
|
||||
<div className="card-header !m-0 !border-none text-foreground">
|
||||
<h3 className="text-2xl font-medium">공지사항</h3>
|
||||
</div>
|
||||
{noticePostTableComponent}
|
||||
</div>
|
||||
{/* 신규 게시글 */}
|
||||
<div className="card card-border !p-0">
|
||||
<div className="card-header !m-0 !border-none">
|
||||
<div className="card-header !m-0 !border-none text-foreground">
|
||||
<h3 className="text-2xl font-medium">신규 게시글</h3>
|
||||
</div>
|
||||
{recentPostTableComponent}
|
||||
|
||||
@@ -10,12 +10,18 @@ import {
|
||||
Button,
|
||||
Switch,
|
||||
Input,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@common/components";
|
||||
import {
|
||||
RoleAuthorizationsApi,
|
||||
type RoleAuthorizationsData,
|
||||
type RoleAuthorizationsByRoleId,
|
||||
} from "@/entities/role-authorization";
|
||||
import { SiteSelect } from "@/entities/site";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore, useMutationWrapper, useDataTable } from "@/shared/lib";
|
||||
|
||||
@@ -107,17 +113,27 @@ function MappingRoleDialog({
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">권한맵핑</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[80rem]" aria-describedby={undefined}>
|
||||
<DialogContent className="max-w-[96rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>모듈 목록</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-2">
|
||||
<SiteSelect enabledAllSite />
|
||||
<Select>
|
||||
<SelectTrigger className="w-[12rem]">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="authorizationName">API명</SelectItem>
|
||||
<SelectItem value="urlPatternValue">API URL</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
name="keyword"
|
||||
className="w-[32rem]"
|
||||
className="w-[24rem]"
|
||||
/>
|
||||
<Button type="submit">검색</Button>
|
||||
<Button type="button" color="white" onClick={handleReset}>
|
||||
@@ -145,7 +161,7 @@ const createColumns = (
|
||||
accessorKey: "authorizationName",
|
||||
header: "API명",
|
||||
meta: {
|
||||
width: 130,
|
||||
width: 320,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -42,7 +42,7 @@ function RoleForm({ roleId, form, handleSave, ...props }: RoleFormProps) {
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.SITE_AUTHORITY, roleId],
|
||||
queryFn: () =>
|
||||
SiteAuthorityApi.getSiteAuthorityList({
|
||||
authrtId: roleId,
|
||||
authrtId: roleId ?? "",
|
||||
page: 0,
|
||||
size: 100,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
# CS 프로젝트 구조 문서
|
||||
|
||||
## 개요
|
||||
|
||||
CS 프로젝트는 Next.js App Router를 기반으로 하는 웹 애플리케이션입니다. 이 문서는 프로젝트의 구조와 작동 방식에 대해 설명합니다.
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
CS 프로젝트는 다음과 같은 주요 디렉토리 구조를 가지고 있습니다:
|
||||
|
||||
```
|
||||
/apps/cs/
|
||||
├── app/ # Next.js App Router 디렉토리
|
||||
│ ├── (with-base-layout)/ # 기본 레이아웃을 사용하는 페이지들
|
||||
│ ├── (without-base-layout)/ # 기본 레이아웃을 사용하지 않는 페이지들
|
||||
│ ├── api/ # API 라우트
|
||||
│ ├── auth/ # 인증 관련(로그인) 페이지
|
||||
│ └── layout.tsx # 루트 레이아웃
|
||||
├── src/ # 소스 코드
|
||||
│ ├── app/ # 앱 설정 및 프로바이더
|
||||
│ ├── entities/ # 비즈니스 엔티티 (pages에서 두 번 이상 사용하는 비즈니스 코드)
|
||||
│ ├── pages/ # 페이지 컴포넌트
|
||||
│ ├── shared/ # 공유 유틸리티 및 컴포넌트
|
||||
│ └── widgets/ # 위젯 컴포넌트 (shared 컴포넌트의 조합)
|
||||
├── public/ # 정적 파일
|
||||
└── middleware.ts # Next.js 미들웨어
|
||||
```
|
||||
|
||||
## App과 Pages의 1:1 매칭 구조
|
||||
|
||||
CS 프로젝트에서는 `app/` 디렉토리와 `src/pages/` 디렉토리가 1:1 매칭 구조를 가지고 있습니다.
|
||||
|
||||
### 작동 방식
|
||||
|
||||
1. `app/` 디렉토리는 Next.js의 App Router 기능을 사용하여 라우팅을 처리합니다.
|
||||
2. `app/` 디렉토리의 각 페이지는 `src/pages/` 디렉토리에 있는 해당 페이지 컴포넌트를 임포트하여 사용합니다.
|
||||
|
||||
예를 들어, `app/(with-base-layout)/dashboard/page.tsx`는 `src/pages/dashboard/ui/dashboard-page.tsx`를 임포트합니다.
|
||||
|
||||
### 예시 코드
|
||||
|
||||
```tsx
|
||||
// app/(with-base-layout)/dashboard/page.tsx
|
||||
import { DashboardPage } from "@/pages/dashboard";
|
||||
export default DashboardPage;
|
||||
```
|
||||
|
||||
```tsx
|
||||
// src/pages/dashboard/index.ts
|
||||
export { DashboardPage } from "./ui/dashboard-page";
|
||||
```
|
||||
|
||||
## 레이아웃 구조
|
||||
|
||||
CS 프로젝트는 다음과 같은 레이아웃 구조를 제공합니다:
|
||||
|
||||
1. **루트 레이아웃**: `app/layout.tsx`에서 정의되며, 모든 페이지에 적용됩니다.
|
||||
2. **기본 레이아웃**: `app/(with-base-layout)/layout.tsx`에서 정의되며, `@common/components`의 헤더, 사이드바, 푸터를 포함합니다.
|
||||
3. **레이아웃 없음**: `app/(without-base-layout)/`에 있는 페이지들은 기본 레이아웃 없이 렌더링됩니다.
|
||||
|
||||
## Common 프로젝트와의 연동
|
||||
|
||||
CS 프로젝트는 `@common/` 패키지와 연동되어 있습니다. 이 패키지들은 여러 프로젝트에서 공유되는 컴포넌트와 유틸리티를 제공합니다.
|
||||
|
||||
### Common 패키지 구조
|
||||
|
||||
```
|
||||
/packages/common/
|
||||
├── components/ # 공통 UI 컴포넌트
|
||||
├── assets/ # 공통 에셋 (이미지, 아이콘 등)
|
||||
├── config/ # 공통 설정 (환경 변수)
|
||||
└── network/ # 네트워크 관련 유틸리티
|
||||
```
|
||||
|
||||
### Config 패키지 상세 설명
|
||||
|
||||
`@common/config` 패키지는 애플리케이션의 공통 환경 변수를 관리합니다. 주요 파일인 `initialize-config.ts`는 다음과 같은 기능을 제공합니다:
|
||||
|
||||
#### 1. 환경 변수 인터페이스 정의
|
||||
|
||||
```typescript
|
||||
export interface EnvConfig {
|
||||
PORT?: string;
|
||||
PROXY_HOST?: string;
|
||||
SERVER_API_URL?: string;
|
||||
SITE_ID?: string;
|
||||
CLAIM_NAME?: string;
|
||||
ACCESS_TOKEN?: string;
|
||||
REFRESH_TOKEN?: string;
|
||||
AUTH_USER_ID?: string;
|
||||
}
|
||||
```
|
||||
|
||||
이 인터페이스는 애플리케이션에서 사용하는 공통 환경 변수의 타입을 정의합니다.
|
||||
|
||||
#### 2. 기본 설정값
|
||||
|
||||
```typescript
|
||||
const defaultConfig: EnvConfig = {
|
||||
PORT: "3000",
|
||||
PROXY_HOST: "",
|
||||
SERVER_API_URL: "",
|
||||
SITE_ID: "",
|
||||
CLAIM_NAME: "Authorization",
|
||||
ACCESS_TOKEN: "access-token",
|
||||
REFRESH_TOKEN: "refresh-token",
|
||||
AUTH_USER_ID: "token-id",
|
||||
};
|
||||
```
|
||||
|
||||
환경 변수가 제공되지 않을 경우 사용할 기본값을 정의합니다.
|
||||
|
||||
#### 3. 환경 설정 생성 함수
|
||||
|
||||
```typescript
|
||||
export function generateEnvConfig(values: Partial<EnvConfig>): EnvConfig {
|
||||
// 유효한 값만 필터링
|
||||
const filteredValues = Object.entries(values).reduce((acc, [key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
acc[key as keyof EnvConfig] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Partial<EnvConfig>);
|
||||
|
||||
// PROXY_HOST가 없으면 자동 생성
|
||||
if (!filteredValues.PROXY_HOST) {
|
||||
filteredValues.PROXY_HOST = `http://localhost:${filteredValues.PORT || defaultConfig.PORT}`;
|
||||
}
|
||||
|
||||
return { ...defaultConfig, ...filteredValues };
|
||||
}
|
||||
```
|
||||
|
||||
이 함수는 입력받은 환경 변수 중 유효한 값만 필터링하고, 기본값과 병합하여 최종 설정을 반환합니다.
|
||||
|
||||
#### 4. 환경별 설정 가져오기
|
||||
|
||||
```typescript
|
||||
// Next.js 환경 변수
|
||||
export function getNextConfig(): EnvConfig {
|
||||
const env: Partial<EnvConfig> = {
|
||||
PORT: process.env.NEXT_PUBLIC_PORT,
|
||||
PROXY_HOST: process.env.NEXT_PUBLIC_PROXY_HOST,
|
||||
SERVER_API_URL: process.env.NEXT_PUBLIC_SERVER_API_URL,
|
||||
// 기타 환경 변수들...
|
||||
};
|
||||
return generateEnvConfig(env);
|
||||
}
|
||||
|
||||
export function getConfig(): EnvConfig {
|
||||
return getNextConfig();
|
||||
}
|
||||
```
|
||||
|
||||
Next.js에서 환경 변수를 가져오는 함수를 제공하며, `getConfig()` 함수를 통해 현재 실행 중인 애플리케이션의 환경 변수를 `@common/` 패키지에서 가져올 수 있습니다.
|
||||
|
||||
#### 5. 사용 방법
|
||||
|
||||
```typescript
|
||||
import { getConfig } from "@common/config";
|
||||
|
||||
const config = getConfig();
|
||||
const serverUrl = config.SERVER_API_URL;
|
||||
const authHeader = config.CLAIM_NAME;
|
||||
|
||||
// API 요청 등에 사용
|
||||
```
|
||||
|
||||
## 애플리케이션 시작점 및 작동 방식
|
||||
|
||||
### 1. 애플리케이션 시작점
|
||||
|
||||
애플리케이션의 시작점은 `app/layout.tsx` 파일입니다:
|
||||
|
||||
```tsx
|
||||
import type { Metadata } from "next";
|
||||
import { App } from "@/app";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "유역하수도 통합 정보플랫폼",
|
||||
};
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
### 2. 앱 구성 요소
|
||||
|
||||
`src/app/index.tsx`에서 기본 앱 구성 요소를 정의합니다:
|
||||
|
||||
```tsx
|
||||
import "./styles/base.css";
|
||||
import "./styles/components.css";
|
||||
import "@fontsource-variable/noto-sans-kr";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { AppProvider } from "./providers/app-provider";
|
||||
|
||||
const App = async ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
const theme = (await cookies()).get("site-theme-key");
|
||||
|
||||
return (
|
||||
<html lang="ko" data-theme={theme?.value}>
|
||||
<body>
|
||||
<AppProvider>{children}</AppProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
|
||||
export { App };
|
||||
```
|
||||
|
||||
### 3. 프로바이더 설정
|
||||
|
||||
`src/app/providers/app-provider.tsx`에서 애플리케이션에 필요한 모든 프로바이더를 설정합니다:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { MultiBackend, getBackendOptions } from "@minoru/react-dnd-treeview";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
|
||||
import { AuthWrapper } from "@/entities/auth";
|
||||
import { MenuWrapper } from "@/entities/menu";
|
||||
import { Toaster } from "@/shared/ui/sonner";
|
||||
import { AlertDialog } from "@/shared/ui/alert-dialog";
|
||||
import { PromptDialog } from "@/shared/ui/prompt-dialog";
|
||||
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
|
||||
import { SelectPromptDialog } from "@/shared/ui/select-prompt-dialog";
|
||||
import { PromptProvider, StoreProvider } from "@/shared/providers";
|
||||
|
||||
const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
const queryClient = new QueryClient();
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<StoreProvider>
|
||||
<PromptProvider>
|
||||
<ConfirmProvider>
|
||||
<AuthWrapper>
|
||||
<MenuWrapper>
|
||||
<DndProvider
|
||||
backend={MultiBackend}
|
||||
options={getBackendOptions()}
|
||||
>
|
||||
{children}
|
||||
</DndProvider>
|
||||
</MenuWrapper>
|
||||
<AlertDialog />
|
||||
<PromptDialog />
|
||||
<ConfirmDialog />
|
||||
<SelectPromptDialog />
|
||||
<Toaster position="top-center" />
|
||||
</AuthWrapper>
|
||||
</ConfirmProvider>
|
||||
</PromptProvider>
|
||||
</StoreProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export { AppProvider };
|
||||
```
|
||||
|
||||
### 4. 기본 레이아웃 구성
|
||||
|
||||
`app/(with-base-layout)/layout.tsx`에서 기본 레이아웃을 구성합니다:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { BaseLayout } from "@common/components";
|
||||
import type { MenuItem } from "@/entities/menu";
|
||||
import { useMenuStore } from "@/shared/lib";
|
||||
import { PageLayout } from "@/shared/ui/page-layout";
|
||||
|
||||
export default function WithBaseLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo);
|
||||
|
||||
const handleMenuClick = (item: MenuItem) => {
|
||||
setCurrentMenuInfo(item);
|
||||
router.push(item.urlPath);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
queryClient={queryClient}
|
||||
currentPath={pathname ?? ""}
|
||||
handleMenuClick={handleMenuClick}
|
||||
>
|
||||
<PageLayout>{children}</PageLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 미들웨어 처리
|
||||
|
||||
`middleware.ts`에서 API 요청 리다이렉션 및 인증 처리를 담당합니다:
|
||||
|
||||
```tsx
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CLAIM_NAME,
|
||||
AUTH_USER_ID,
|
||||
PORT,
|
||||
PROXY_HOST,
|
||||
SERVER_API_URL,
|
||||
SITE_ID,
|
||||
} from "./src/shared/config";
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const pathname = req.nextUrl.pathname;
|
||||
|
||||
// 서버 API 요청 처리
|
||||
if (pathname.startsWith("/server")) {
|
||||
// API 요청을 실제 서버로 리다이렉션
|
||||
}
|
||||
|
||||
// 첨부 파일 서비스 처리
|
||||
if (pathname.startsWith("/attach-service")) {
|
||||
// 인증 토큰을 헤더에 추가하여 요청 처리
|
||||
}
|
||||
|
||||
// 루트 경로를 대시보드로 리다이렉션
|
||||
if (pathname === "/") {
|
||||
return NextResponse.redirect(new URL("/dashboard", req.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 페이지 렌더링 흐름
|
||||
|
||||
1. 사용자가 URL 접근 시 미들웨어에서 요청 처리
|
||||
2. App Router가 해당 경로에 맞는 페이지 컴포넌트 로드
|
||||
3. 루트 레이아웃 → 기본 레이아웃 → 페이지 컴포넌트 순으로 렌더링
|
||||
4. 페이지 컴포넌트는 `src/pages/` 디렉토리에서 가져와 렌더링
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ENDPOINT } from "../config";
|
||||
import { getApiClient } from "./client";
|
||||
|
||||
interface PageableParams {
|
||||
page?: number;
|
||||
size?: number;
|
||||
sort?: string[];
|
||||
}
|
||||
|
||||
interface SortConfig {
|
||||
empty: boolean;
|
||||
sorted: boolean;
|
||||
unsorted: boolean;
|
||||
}
|
||||
|
||||
interface PageableConfig {
|
||||
sort: SortConfig;
|
||||
offset: number;
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
paged: boolean;
|
||||
unpaged: boolean;
|
||||
}
|
||||
|
||||
interface PageableList<T> {
|
||||
content: T[];
|
||||
pageable: PageableConfig;
|
||||
sort: SortConfig;
|
||||
totalPages: number;
|
||||
totalElements: number;
|
||||
number: number;
|
||||
numberOfElements: number;
|
||||
size: number;
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
empty: boolean;
|
||||
}
|
||||
|
||||
interface NoticeUserListParams extends PageableParams {
|
||||
recvUnqId?: string;
|
||||
senderUnqId?: string;
|
||||
notiType?: string;
|
||||
notiTtlKeyword?: string;
|
||||
notiCnKeyword?: string;
|
||||
notReadYn?: string;
|
||||
delYn?: string;
|
||||
}
|
||||
|
||||
interface NoticeUser {
|
||||
notiId: number;
|
||||
recvUnqId: string;
|
||||
senderUnqId: string;
|
||||
notiType: string;
|
||||
notiTtl: string;
|
||||
notReadYn: string;
|
||||
regDt: string;
|
||||
}
|
||||
|
||||
const client = getApiClient();
|
||||
|
||||
const getNoticeUserList = async (
|
||||
recvUnqId: string,
|
||||
params?: NoticeUserListParams,
|
||||
) => {
|
||||
const response = await client.get<PageableList<NoticeUser>>(
|
||||
`${ENDPOINT.CMS_SERVICE.NOTICE_USER}/${recvUnqId}/list`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getUnreadNoticeCount = async (recvUnqId: string) => {
|
||||
const response = await client.get<PageableList<NoticeUser>>(
|
||||
`${ENDPOINT.CMS_SERVICE.NOTICE_USER}/${recvUnqId}/unread/count`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const readNotice = async (notiId: number) => {
|
||||
const response = await client.post(
|
||||
`${ENDPOINT.CMS_SERVICE.NOTICE}/${notiId}/read`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export { getNoticeUserList, getUnreadNoticeCount, readNotice };
|
||||
@@ -7,6 +7,8 @@ export const ENDPOINT = {
|
||||
POPUPS: "/cms-service/api/v1/popups",
|
||||
IPS: "/cms-service/api/v1/site-ips",
|
||||
CONNECTION_LOG: "/cms-service/api/v1/cntnlog",
|
||||
NOTICE: "/cms-service/api/v1/noti",
|
||||
NOTICE_USER: "/cms-service/api/v1/noti/user",
|
||||
},
|
||||
USER_SERVICE: {
|
||||
USERS: "/user-service/api/v1/users",
|
||||
|
||||
@@ -24,7 +24,7 @@ export function Footer() {
|
||||
const plugin = useRef(Autoplay({ delay: 2000, stopOnInteraction: true }));
|
||||
|
||||
return (
|
||||
<footer className="flex w-full shrink-0 items-start border-t border-solid border-border bg-background px-6 py-8">
|
||||
<footer className="flex w-full shrink-0 items-start border-t border-solid border-border bg-background px-6 py-8 text-foreground">
|
||||
<div className="w-1/2">
|
||||
<h6 className="mb-4 text-2xl font-bold">{siteInfo?.siteNm ?? ""}</h6>
|
||||
<p className="my-1 text-xl opacity-60">{siteInfo?.siteAddr ?? ""}</p>
|
||||
@@ -32,10 +32,6 @@ export function Footer() {
|
||||
전화: {siteInfo?.telNo ?? ""} / 팩스: {siteInfo?.faxNumber ?? ""}
|
||||
</p>
|
||||
<p className="my-1 text-xl opacity-60">{siteInfo?.lwndCn ?? ""}</p>
|
||||
<p className="my-1 text-xl opacity-60">
|
||||
본 시스템은 Chrome, Firefox, Safari, Edge 브라우저에 최적화되어
|
||||
있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-1/2 flex-col">
|
||||
<div className="flex justify-end gap-4">
|
||||
|
||||
@@ -13,16 +13,12 @@ import {
|
||||
getUserInfo,
|
||||
type Popup,
|
||||
} from "../../api";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "../dropdown-menu";
|
||||
import { Button } from "../button";
|
||||
import { SidebarTrigger } from "../sidebar";
|
||||
import { Skeleton } from "../skeleton";
|
||||
import { SidebarTrigger } from "../sidebar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
|
||||
import { SearchForm } from "./search-form";
|
||||
import { NoticePopover } from "./notice-popover";
|
||||
|
||||
const appConfig = getConfig();
|
||||
const siteId = appConfig.SITE_ID ?? "";
|
||||
@@ -140,30 +136,28 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) {
|
||||
<SearchForm />
|
||||
{mounted ? (
|
||||
isLoggedIn && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="group m-0 flex h-[3rem] items-center !bg-background px-4 py-0 text-label"
|
||||
data-trigger="dropdown"
|
||||
>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button className="group m-0 flex h-[3rem] items-center !bg-background px-4 py-0 text-label">
|
||||
<i className="diveicon di-account-circle static translate-y-0.5 pr-2 text-3xl" />
|
||||
{userInfo?.userName}
|
||||
<i className="diveicon di-chevron-down static p-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>프로필 설정</DropdownMenuItem>
|
||||
<div className="flex flex-col items-start gap-0 px-4 py-3">
|
||||
<p className="text-base text-label">최종접속일</p>
|
||||
<p>{userInfo?.lastLoginDate}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-0 px-4 py-3">
|
||||
<p className="text-base text-label">접속 IP</p>
|
||||
<p></p>
|
||||
</div>
|
||||
<DropdownMenuItem onClick={logout}>로그아웃</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-48 border-border p-1">
|
||||
<ul>
|
||||
<li className="cursor-pointer rounded-sm p-3 text-xl hover:bg-accent">
|
||||
<NoticePopover />
|
||||
</li>
|
||||
<li
|
||||
onClick={logout}
|
||||
className="cursor-pointer rounded-sm p-3 text-xl hover:bg-accent"
|
||||
>
|
||||
로그아웃
|
||||
</li>
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
) : (
|
||||
<Skeleton className="mx-4 h-[3rem] w-[15rem]" />
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getConfig } from "@common/config";
|
||||
import { ENDPOINT } from "../../config";
|
||||
import { cn, getCookie } from "../../lib";
|
||||
import {
|
||||
getNoticeUserList,
|
||||
getUnreadNoticeCount,
|
||||
readNotice,
|
||||
} from "../../api/notice-user";
|
||||
import { Label } from "../label";
|
||||
import { Badge } from "../badge";
|
||||
import { Button } from "../button";
|
||||
import { Checkbox } from "../checkbox";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import "dayjs/locale/ko";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.locale("ko");
|
||||
|
||||
const appConfig = getConfig();
|
||||
const userId = getCookie(appConfig.AUTH_USER_ID ?? "");
|
||||
|
||||
export function NoticePopover() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedNoticeIds, setSelectedNoticeIds] = useState<number[]>([]);
|
||||
|
||||
const { data: noticeUserList } = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.NOTICE_USER],
|
||||
queryFn: () => getNoticeUserList(userId!),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const { data: unreadNoticeCount } = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.NOTICE],
|
||||
queryFn: () => getUnreadNoticeCount(userId!),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const handleSelectNotice = (notiId: number) => {
|
||||
setSelectedNoticeIds((prevIds) => {
|
||||
if (prevIds.includes(notiId)) {
|
||||
return prevIds.filter((id) => id !== notiId);
|
||||
} else {
|
||||
return [...prevIds, notiId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (noticeUserList?.content) {
|
||||
if (selectedNoticeIds.length === noticeUserList.content.length) {
|
||||
setSelectedNoticeIds([]);
|
||||
} else {
|
||||
const allIds = noticeUserList.content.map((item) => item.notiId);
|
||||
setSelectedNoticeIds(allIds);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReadNotice = async () => {
|
||||
if (selectedNoticeIds.length === 0) return;
|
||||
try {
|
||||
await Promise.all(selectedNoticeIds.map((notiId) => readNotice(notiId)));
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.NOTICE_USER],
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.NOTICE],
|
||||
});
|
||||
setSelectedNoticeIds([]);
|
||||
} catch (error) {
|
||||
console.error("알림 읽음 처리 중 오류 발생:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const isAllSelected = noticeUserList?.content
|
||||
? selectedNoticeIds.length === noticeUserList.content.length &&
|
||||
noticeUserList.content.length > 0
|
||||
: false;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setSelectedNoticeIds([]);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="flex w-full items-center gap-2 text-left">
|
||||
알림 <Badge>{unreadNoticeCount?.totalElements ?? 0}</Badge>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[32rem] border-border px-0">
|
||||
<div className="flex items-center gap-4 p-4 pt-0">
|
||||
<Checkbox
|
||||
id="all-select"
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
<Label htmlFor="all-select" className="text-[1.4rem]">
|
||||
전체 선택
|
||||
</Label>
|
||||
</div>
|
||||
<ul>
|
||||
{noticeUserList?.content?.map((item) => (
|
||||
<li
|
||||
key={item.notiId}
|
||||
className={cn(
|
||||
"flex items-center gap-4 border-t border-border p-4",
|
||||
item.notReadYn === "N" && "bg-primary-transparent",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedNoticeIds.includes(item.notiId)}
|
||||
onCheckedChange={() => handleSelectNotice(item.notiId)}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-[1.2rem]">{item.notiType}</span>
|
||||
<p className="text-[1.4rem]">{item.notiTtl}</p>
|
||||
<span className="text-[1.2rem] opacity-50">
|
||||
{dayjs(item.regDt).fromNow()}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div
|
||||
className="border-t border-border pt-4 text-center"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<Button onClick={handleReadNotice}>확인</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user