Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff12938d7e | |||
|
|
c872985f96 | ||
| 98d2f970a5 | |||
|
|
32b48af34c | ||
| 2bc3fe87c8 | |||
|
|
3f97982e4f | ||
| b522f968ee | |||
|
|
34feab7fa9 | ||
| 0b2aa91a43 | |||
|
|
b4b6426ded | ||
| 683d0e6f30 | |||
|
|
21dcc6335d | ||
| e37066ce31 | |||
|
|
589a0d67ce | ||
| 22c6be0002 | |||
|
|
2549930a5a | ||
| ad6010320a | |||
|
|
aef2890474 | ||
| 8f753a668b | |||
|
|
bb4d531d8c | ||
| cff57b5fad | |||
|
|
cf7076f525 | ||
|
|
ca88d5ba08 | ||
| c3073f0e87 | |||
|
|
8b199803d6 | ||
| bd71547cc2 | |||
|
|
530e92240a | ||
| 6501676a35 | |||
|
|
31d967ffbf | ||
| f23e6cb4b2 | |||
|
|
6653e5da2a | ||
| b1957b1bfc | |||
|
|
4f8c282572 | ||
| 1b4fc79b2d | |||
|
|
9db9d87dea | ||
| c89cfeaa56 | |||
|
|
59c206e0c2 | ||
|
|
61508e25f7 | ||
|
|
af6ecab428 | ||
| 1e8d33102e | |||
|
|
37ac854bc8 | ||
|
|
2c4701939d | ||
|
|
bb82846d59 | ||
| e8edbb528c | |||
|
|
ccfda47b96 | ||
| db3a22bb57 | |||
|
|
fc9b0c0f75 |
30
Jenkinsfile
vendored
30
Jenkinsfile
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
def TARGET_FRONT_BUILD = ''
|
def TARGET_FRONT_BUILD = ''
|
||||||
def TARGET_RUN_SERVER = ''
|
def TARGET_RUN_SERVER = ''
|
||||||
def CONTAINER_SRC_PATH = '/src'
|
def FRONT_HOST_PATH = '/volume1/SPMS/PROJECT/Application/Front'
|
||||||
|
|
||||||
pipeline {
|
pipeline {
|
||||||
agent any
|
agent any
|
||||||
|
|
@ -13,7 +13,6 @@ pipeline {
|
||||||
echo "Current Branch: ${branchName}"
|
echo "Current Branch: ${branchName}"
|
||||||
|
|
||||||
if (branchName == 'develop') {
|
if (branchName == 'develop') {
|
||||||
// develop 브랜치 -> Debug 컨테이너
|
|
||||||
TARGET_FRONT_BUILD = 'spms-front-build-debug'
|
TARGET_FRONT_BUILD = 'spms-front-build-debug'
|
||||||
TARGET_RUN_SERVER = 'spms-run-debug'
|
TARGET_RUN_SERVER = 'spms-run-debug'
|
||||||
echo "[DEV Mode] Target: DEBUG Container"
|
echo "[DEV Mode] Target: DEBUG Container"
|
||||||
|
|
@ -38,19 +37,26 @@ pipeline {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [3단계] 컨테이너로 소스 복사
|
// [3단계] 실행 중인 임시 컨테이너를 통해 호스트 볼륨에 소스 복사
|
||||||
stage('Copy to Container') {
|
// (정지된 컨테이너에 docker cp하면 바인드 마운트에 반영 안 됨)
|
||||||
|
stage('Copy to Host Volume') {
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
def sourcePath = "${WORKSPACE}/react/."
|
def sourcePath = "${WORKSPACE}/react/."
|
||||||
echo "Copying from ${sourcePath} to ${TARGET_FRONT_BUILD}"
|
echo "Copying source to host volume (${FRONT_HOST_PATH})..."
|
||||||
def containerId = sh(script: "docker ps -aqf 'name=${TARGET_FRONT_BUILD}'", returnStdout: true).trim()
|
|
||||||
|
|
||||||
if (containerId) {
|
sh """
|
||||||
sh "docker cp ${sourcePath} ${TARGET_FRONT_BUILD}:${CONTAINER_SRC_PATH}"
|
docker rm -f spms-source-copy 2>/dev/null || true
|
||||||
} else {
|
docker run -d --name spms-source-copy \
|
||||||
error "Container ${TARGET_FRONT_BUILD} not found!"
|
-v ${FRONT_HOST_PATH}:/target \
|
||||||
}
|
alpine sleep 30
|
||||||
|
docker exec spms-source-copy sh -c \
|
||||||
|
'find /target -mindepth 1 -maxdepth 1 ! -name node_modules -exec rm -rf {} +'
|
||||||
|
docker cp ${sourcePath} spms-source-copy:/target/
|
||||||
|
docker rm -f spms-source-copy
|
||||||
|
"""
|
||||||
|
|
||||||
|
echo "Source copy complete."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +64,7 @@ pipeline {
|
||||||
stage('Build Frontend') {
|
stage('Build Frontend') {
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
echo "Starting React Build..."
|
echo "Starting React Build (npm install + build + deploy)..."
|
||||||
sh "docker start -a ${TARGET_FRONT_BUILD}"
|
sh "docker start -a ${TARGET_FRONT_BUILD}"
|
||||||
echo "React Build Complete."
|
echo "React Build Complete."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
react/.env.development
Normal file
2
react/.env.development
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
VITE_APP_TITLE=SPMS
|
||||||
2
react/.env.example
Normal file
2
react/.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
VITE_API_BASE_URL=http://localhost:8080/api
|
||||||
|
VITE_APP_TITLE=SPMS
|
||||||
23
react/components.json
Normal file
23
react/components.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,20 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>react</title>
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@100..900&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>SPMS</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
6752
react/package-lock.json
generated
6752
react/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -10,11 +10,27 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@tanstack/react-query": "^5.90.20",
|
||||||
|
"axios": "^1.13.5",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hook-form": "^7.71.1",
|
||||||
|
"react-router-dom": "^7.13.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|
@ -23,6 +39,10 @@
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"shadcn": "^3.8.4",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.46.4",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4"
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
#root {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 6em;
|
|
||||||
padding: 1.5em;
|
|
||||||
will-change: filter;
|
|
||||||
transition: filter 300ms;
|
|
||||||
}
|
|
||||||
.logo:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #646cffaa);
|
|
||||||
}
|
|
||||||
.logo.react:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes logo-spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
a:nth-of-type(2) .logo {
|
|
||||||
animation: logo-spin infinite 20s linear;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.read-the-docs {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +1,29 @@
|
||||||
import { useState } from 'react'
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import reactLogo from './assets/react.svg'
|
import { RouterProvider } from "react-router-dom";
|
||||||
import viteLogo from '/vite.svg'
|
import { ThemeProvider } from "next-themes";
|
||||||
import './App.css'
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { ErrorBoundary } from "@/components/feedback/ErrorBoundary";
|
||||||
|
import { router } from "@/routes";
|
||||||
|
|
||||||
function App() {
|
const queryClient = new QueryClient({
|
||||||
const [count, setCount] = useState(0)
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5분
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<>
|
<ErrorBoundary>
|
||||||
<div>
|
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||||
<a href="https://vite.dev" target="_blank">
|
<QueryClientProvider client={queryClient}>
|
||||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
<RouterProvider router={router} />
|
||||||
</a>
|
<Toaster />
|
||||||
<a href="https://react.dev" target="_blank">
|
</QueryClientProvider>
|
||||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
</ThemeProvider>
|
||||||
</a>
|
</ErrorBoundary>
|
||||||
</div>
|
);
|
||||||
<h1>Vite + React</h1>
|
|
||||||
<div className="card">
|
|
||||||
<button onClick={() => setCount((count) => count + 1)}>
|
|
||||||
count is {count}
|
|
||||||
</button>
|
|
||||||
<p>
|
|
||||||
Edit <code>src/App.tsx</code> and save to test HMR
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="read-the-docs">
|
|
||||||
Click on the Vite and React logos to learn more
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
|
||||||
|
|
|
||||||
41
react/src/api/account.api.ts
Normal file
41
react/src/api/account.api.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
import type { ApiResponse } from "@/types/api";
|
||||||
|
import type {
|
||||||
|
ProfileResponse,
|
||||||
|
UpdateProfileRequest,
|
||||||
|
ActivityListRequest,
|
||||||
|
ActivityListResponse,
|
||||||
|
ChangePasswordRequest,
|
||||||
|
ChangePasswordResponse,
|
||||||
|
} from "@/features/settings/types";
|
||||||
|
|
||||||
|
/** 내 프로필 조회 */
|
||||||
|
export function fetchProfile() {
|
||||||
|
return apiClient.post<ApiResponse<ProfileResponse>>(
|
||||||
|
"/v1/in/account/profile/info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 프로필 수정 */
|
||||||
|
export function updateProfile(data: UpdateProfileRequest) {
|
||||||
|
return apiClient.post<ApiResponse<ProfileResponse>>(
|
||||||
|
"/v1/in/account/profile/update",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 활동 내역 조회 */
|
||||||
|
export function fetchActivityList(data: ActivityListRequest) {
|
||||||
|
return apiClient.post<ApiResponse<ActivityListResponse>>(
|
||||||
|
"/v1/in/account/profile/activity/list",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 비밀번호 변경 */
|
||||||
|
export function changePassword(data: ChangePasswordRequest) {
|
||||||
|
return apiClient.post<ApiResponse<ChangePasswordResponse>>(
|
||||||
|
"/v1/in/auth/password/change",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
58
react/src/api/auth.api.ts
Normal file
58
react/src/api/auth.api.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
import type { ApiResponse } from "@/types/api";
|
||||||
|
import type {
|
||||||
|
SignupRequest,
|
||||||
|
SignupResponse,
|
||||||
|
EmailCheckRequest,
|
||||||
|
EmailCheckResponse,
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
EmailVerifyRequest,
|
||||||
|
EmailVerifyResponse,
|
||||||
|
EmailResendRequest,
|
||||||
|
EmailResendResponse,
|
||||||
|
TokenRefreshRequest,
|
||||||
|
TokenRefreshResponse,
|
||||||
|
LogoutResponse,
|
||||||
|
TempPasswordRequest,
|
||||||
|
} from "@/features/auth/types";
|
||||||
|
|
||||||
|
/** 회원가입 */
|
||||||
|
export function signup(data: SignupRequest) {
|
||||||
|
return apiClient.post<ApiResponse<SignupResponse>>("/v1/in/auth/signup", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이메일 중복 체크 */
|
||||||
|
export function checkEmail(data: EmailCheckRequest) {
|
||||||
|
return apiClient.post<ApiResponse<EmailCheckResponse>>("/v1/in/auth/email/check", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 로그인 */
|
||||||
|
export function login(data: LoginRequest) {
|
||||||
|
return apiClient.post<ApiResponse<LoginResponse>>("/v1/in/auth/login", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이메일 인증 */
|
||||||
|
export function verifyEmail(data: EmailVerifyRequest) {
|
||||||
|
return apiClient.post<ApiResponse<EmailVerifyResponse>>("/v1/in/auth/email/verify", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 인증코드 재전송 */
|
||||||
|
export function resendVerifyEmail(data: EmailResendRequest) {
|
||||||
|
return apiClient.post<ApiResponse<EmailResendResponse>>("/v1/in/auth/email/verify/resend", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 토큰 갱신 */
|
||||||
|
export function refreshToken(data: TokenRefreshRequest) {
|
||||||
|
return apiClient.post<ApiResponse<TokenRefreshResponse>>("/v1/in/auth/token/refresh", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 로그아웃 */
|
||||||
|
export function logout() {
|
||||||
|
return apiClient.post<ApiResponse<LogoutResponse>>("/v1/in/auth/logout");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 임시 비밀번호 발급 */
|
||||||
|
export function requestTempPassword(data: TempPasswordRequest) {
|
||||||
|
return apiClient.post<ApiResponse<null>>("/v1/in/account/password/temp", data);
|
||||||
|
}
|
||||||
31
react/src/api/client.ts
Normal file
31
react/src/api/client.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
/** Axios 인스턴스 */
|
||||||
|
export const apiClient = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE_URL ?? "",
|
||||||
|
timeout: 15000,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 요청 인터셉터 - 토큰 자동 추가 */
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem("accessToken");
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 응답 인터셉터 - 401 처리 */
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem("accessToken");
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
12
react/src/api/dashboard.api.ts
Normal file
12
react/src/api/dashboard.api.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
import type { ApiResponse } from "@/types/api";
|
||||||
|
import type { DashboardRequest, DashboardData } from "@/features/dashboard/types";
|
||||||
|
|
||||||
|
/** 대시보드 통합 조회 */
|
||||||
|
export function fetchDashboard(data: DashboardRequest, serviceCode?: string) {
|
||||||
|
return apiClient.post<ApiResponse<DashboardData>>(
|
||||||
|
"/v1/in/stats/dashboard",
|
||||||
|
data,
|
||||||
|
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
43
react/src/api/device.api.ts
Normal file
43
react/src/api/device.api.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
import type { ApiResponse } from "@/types/api";
|
||||||
|
import type {
|
||||||
|
DeviceListRequest,
|
||||||
|
DeviceListResponse,
|
||||||
|
DeviceDeleteRequest,
|
||||||
|
DeviceExportRequest,
|
||||||
|
} from "@/features/device/types";
|
||||||
|
|
||||||
|
/** 기기 목록 조회 (X-Service-Code 선택) */
|
||||||
|
export function fetchDevices(
|
||||||
|
data: DeviceListRequest,
|
||||||
|
serviceCode?: string,
|
||||||
|
) {
|
||||||
|
return apiClient.post<ApiResponse<DeviceListResponse>>(
|
||||||
|
"/v1/in/device/list",
|
||||||
|
data,
|
||||||
|
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기기 삭제 (비활성화) */
|
||||||
|
export function deleteDevice(
|
||||||
|
data: DeviceDeleteRequest,
|
||||||
|
serviceCode: string,
|
||||||
|
) {
|
||||||
|
return apiClient.post<ApiResponse<null>>(
|
||||||
|
"/v1/in/device/admin/delete",
|
||||||
|
data,
|
||||||
|
{ headers: { "X-Service-Code": serviceCode } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기기 내보내기 (xlsx blob) */
|
||||||
|
export function exportDevices(
|
||||||
|
data: DeviceExportRequest,
|
||||||
|
serviceCode: string,
|
||||||
|
) {
|
||||||
|
return apiClient.post("/v1/in/device/export", data, {
|
||||||
|
headers: { "X-Service-Code": serviceCode },
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
}
|
||||||
58
react/src/api/message.api.ts
Normal file
58
react/src/api/message.api.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
import type { ApiResponse } from "@/types/api";
|
||||||
|
import type {
|
||||||
|
MessageListRequest,
|
||||||
|
MessageListResponse,
|
||||||
|
MessageInfoRequest,
|
||||||
|
MessageInfoResponse,
|
||||||
|
MessageSaveRequest,
|
||||||
|
MessageDeleteRequest,
|
||||||
|
MessageValidateRequest,
|
||||||
|
} from "@/features/message/types";
|
||||||
|
|
||||||
|
/** 메시지 목록 조회 */
|
||||||
|
export function fetchMessages(data: MessageListRequest) {
|
||||||
|
return apiClient.post<ApiResponse<MessageListResponse>>(
|
||||||
|
"/v1/in/message/list",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 메시지 상세 조회 */
|
||||||
|
export function fetchMessageInfo(
|
||||||
|
data: MessageInfoRequest,
|
||||||
|
serviceCode: string,
|
||||||
|
) {
|
||||||
|
return apiClient.post<ApiResponse<MessageInfoResponse>>(
|
||||||
|
"/v1/in/message/info",
|
||||||
|
data,
|
||||||
|
{ headers: { "X-Service-Code": serviceCode } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 메시지 저장 */
|
||||||
|
export function saveMessage(data: MessageSaveRequest, serviceCode: string) {
|
||||||
|
return apiClient.post<ApiResponse<null>>("/v1/in/message/save", data, {
|
||||||
|
headers: { "X-Service-Code": serviceCode },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 메시지 삭제 */
|
||||||
|
export function deleteMessage(
|
||||||
|
data: MessageDeleteRequest,
|
||||||
|
serviceCode: string,
|
||||||
|
) {
|
||||||
|
return apiClient.post<ApiResponse<null>>("/v1/in/message/delete", data, {
|
||||||
|
headers: { "X-Service-Code": serviceCode },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 메시지 검증 */
|
||||||
|
export function validateMessage(
|
||||||
|
data: MessageValidateRequest,
|
||||||
|
serviceCode: string,
|
||||||
|
) {
|
||||||
|
return apiClient.post<ApiResponse<null>>("/v1/in/message/validate", data, {
|
||||||
|
headers: { "X-Service-Code": serviceCode },
|
||||||
|
});
|
||||||
|
}
|
||||||
88
react/src/api/service.api.ts
Normal file
88
react/src/api/service.api.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
import type { ApiResponse, PaginatedResponse } from "@/types/api";
|
||||||
|
import type {
|
||||||
|
ServiceListRequest,
|
||||||
|
ServiceSummary,
|
||||||
|
ServiceDetail,
|
||||||
|
ApiKeyResponse,
|
||||||
|
CreateServiceRequest,
|
||||||
|
CreateServiceResponse,
|
||||||
|
UpdateServiceRequest,
|
||||||
|
RegisterFcmRequest,
|
||||||
|
RegisterApnsRequest,
|
||||||
|
} from "@/features/service/types";
|
||||||
|
|
||||||
|
/** 서비스 목록 조회 */
|
||||||
|
export function fetchServices(data: ServiceListRequest) {
|
||||||
|
return apiClient.post<PaginatedResponse<ServiceSummary>>(
|
||||||
|
"/v1/in/service/list",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 서비스 상세 조회 */
|
||||||
|
export function fetchServiceDetail(serviceCode: string) {
|
||||||
|
return apiClient.post<ApiResponse<ServiceDetail>>(
|
||||||
|
`/v1/in/service/${serviceCode}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API Key 전체 조회 (마스킹 해제) */
|
||||||
|
export function fetchApiKey(serviceCode: string) {
|
||||||
|
return apiClient.post<ApiResponse<ApiKeyResponse>>(
|
||||||
|
`/v1/in/service/${serviceCode}/apikey/view`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 서비스 생성 */
|
||||||
|
export function createService(data: CreateServiceRequest) {
|
||||||
|
return apiClient.post<ApiResponse<CreateServiceResponse>>(
|
||||||
|
"/v1/in/service/create",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 서비스 수정 */
|
||||||
|
export function updateService(data: UpdateServiceRequest) {
|
||||||
|
return apiClient.post<ApiResponse<null>>(
|
||||||
|
"/v1/in/service/update",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FCM 인증서 등록 */
|
||||||
|
export function registerFcm(serviceCode: string, data: RegisterFcmRequest) {
|
||||||
|
return apiClient.post<ApiResponse<null>>(
|
||||||
|
`/v1/in/service/${serviceCode}/fcm`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FCM 인증서 삭제 */
|
||||||
|
export function deleteFcm(serviceCode: string) {
|
||||||
|
return apiClient.post<ApiResponse<null>>(
|
||||||
|
`/v1/in/service/${serviceCode}/fcm/delete`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** APNs 인증서 등록 */
|
||||||
|
export function registerApns(serviceCode: string, data: RegisterApnsRequest) {
|
||||||
|
return apiClient.post<ApiResponse<null>>(
|
||||||
|
`/v1/in/service/${serviceCode}/apns`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** APNs 인증서 삭제 */
|
||||||
|
export function deleteApns(serviceCode: string) {
|
||||||
|
return apiClient.post<ApiResponse<null>>(
|
||||||
|
`/v1/in/service/${serviceCode}/apns/delete`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 서비스 삭제 */
|
||||||
|
export function deleteService(serviceCode: string) {
|
||||||
|
return apiClient.post<ApiResponse<null>>("/v1/in/service/delete", {
|
||||||
|
service_code: serviceCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
82
react/src/api/statistics.api.ts
Normal file
82
react/src/api/statistics.api.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
import type { ApiResponse } from "@/types/api";
|
||||||
|
import type {
|
||||||
|
DailyStatRequest,
|
||||||
|
DailyStatResponse,
|
||||||
|
HourlyStatRequest,
|
||||||
|
HourlyStatResponse,
|
||||||
|
DeviceStatResponse,
|
||||||
|
HistoryListRequest,
|
||||||
|
HistoryListResponse,
|
||||||
|
HistoryDetailRequest,
|
||||||
|
HistoryDetailResponse,
|
||||||
|
HistoryExportRequest,
|
||||||
|
} from "@/features/statistics/types";
|
||||||
|
|
||||||
|
/** 일별 통계 조회 */
|
||||||
|
export function fetchDailyStats(
|
||||||
|
data: DailyStatRequest,
|
||||||
|
serviceCode?: string,
|
||||||
|
) {
|
||||||
|
return apiClient.post<ApiResponse<DailyStatResponse>>(
|
||||||
|
"/v1/in/stats/daily",
|
||||||
|
data,
|
||||||
|
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 시간대별 통계 조회 */
|
||||||
|
export function fetchHourlyStats(
|
||||||
|
data: HourlyStatRequest,
|
||||||
|
serviceCode?: string,
|
||||||
|
) {
|
||||||
|
return apiClient.post<ApiResponse<HourlyStatResponse>>(
|
||||||
|
"/v1/in/stats/hourly",
|
||||||
|
data,
|
||||||
|
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 디바이스 통계 조회 */
|
||||||
|
export function fetchDeviceStats(serviceCode?: string) {
|
||||||
|
return apiClient.post<ApiResponse<DeviceStatResponse>>(
|
||||||
|
"/v1/in/stats/device",
|
||||||
|
{},
|
||||||
|
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이력 목록 조회 */
|
||||||
|
export function fetchHistoryList(
|
||||||
|
data: HistoryListRequest,
|
||||||
|
serviceCode?: string,
|
||||||
|
) {
|
||||||
|
return apiClient.post<ApiResponse<HistoryListResponse>>(
|
||||||
|
"/v1/in/stats/history/list",
|
||||||
|
data,
|
||||||
|
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이력 상세 조회 */
|
||||||
|
export function fetchHistoryDetail(
|
||||||
|
data: HistoryDetailRequest,
|
||||||
|
serviceCode?: string,
|
||||||
|
) {
|
||||||
|
return apiClient.post<ApiResponse<HistoryDetailResponse>>(
|
||||||
|
"/v1/in/stats/history/detail",
|
||||||
|
data,
|
||||||
|
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이력 내보내기 (xlsx blob) */
|
||||||
|
export function exportHistory(
|
||||||
|
data: HistoryExportRequest,
|
||||||
|
serviceCode?: string,
|
||||||
|
) {
|
||||||
|
return apiClient.post("/v1/in/stats/history/export", data, {
|
||||||
|
...(serviceCode ? { headers: { "X-Service-Code": serviceCode } } : {}),
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
}
|
||||||
40
react/src/api/tag.api.ts
Normal file
40
react/src/api/tag.api.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
import type { ApiResponse } from "@/types/api";
|
||||||
|
import type {
|
||||||
|
TagListRequest,
|
||||||
|
TagListResponse,
|
||||||
|
TagResponse,
|
||||||
|
CreateTagRequest,
|
||||||
|
UpdateTagRequest,
|
||||||
|
DeleteTagRequest,
|
||||||
|
} from "@/features/tag/types";
|
||||||
|
|
||||||
|
/** 태그 목록 조회 (serviceCode 생략 시 전체 서비스) */
|
||||||
|
export function fetchTagList(data: TagListRequest, serviceCode?: string) {
|
||||||
|
return apiClient.post<ApiResponse<TagListResponse>>(
|
||||||
|
"/v1/in/tag/list",
|
||||||
|
data,
|
||||||
|
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 태그 생성 */
|
||||||
|
export function createTag(data: CreateTagRequest, serviceCode: string) {
|
||||||
|
return apiClient.post<ApiResponse<TagResponse>>("/v1/in/tag/create", data, {
|
||||||
|
headers: { "X-Service-Code": serviceCode },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 태그 수정 */
|
||||||
|
export function updateTag(data: UpdateTagRequest, serviceCode: string) {
|
||||||
|
return apiClient.post<ApiResponse<TagResponse>>("/v1/in/tag/update", data, {
|
||||||
|
headers: { "X-Service-Code": serviceCode },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 태그 삭제 */
|
||||||
|
export function deleteTag(data: DeleteTagRequest, serviceCode: string) {
|
||||||
|
return apiClient.post<ApiResponse<null>>("/v1/in/tag/delete", data, {
|
||||||
|
headers: { "X-Service-Code": serviceCode },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
28
react/src/components/common/CategoryBadge.tsx
Normal file
28
react/src/components/common/CategoryBadge.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
type CategoryVariant = "success" | "warning" | "error" | "info" | "default";
|
||||||
|
|
||||||
|
interface CategoryBadgeProps {
|
||||||
|
variant: CategoryVariant;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT_STYLES: Record<CategoryVariant, string> = {
|
||||||
|
success: "bg-green-50 text-green-700",
|
||||||
|
warning: "bg-amber-50 text-amber-700",
|
||||||
|
error: "bg-red-50 text-red-700",
|
||||||
|
info: "bg-blue-50 text-blue-700",
|
||||||
|
default: "bg-gray-100 text-gray-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 카테고리 뱃지 (아이콘 + 텍스트 패턴) */
|
||||||
|
export default function CategoryBadge({ variant, icon, label }: CategoryBadgeProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex flex-shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold ${VARIANT_STYLES[variant]}`}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: "10px" }}>{icon}</span>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
37
react/src/components/common/CopyButton.tsx
Normal file
37
react/src/components/common/CopyButton.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface CopyButtonProps {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 클립보드 복사 + 피드백 */
|
||||||
|
export default function CopyButton({ text }: CopyButtonProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
toast.success("클립보드에 복사되었습니다");
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
toast.error("복사에 실패했습니다");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="inline-flex items-center justify-center size-7 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
|
||||||
|
title="복사"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
{copied ? "check" : "content_copy"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
react/src/components/common/DateRangeInput.tsx
Normal file
74
react/src/components/common/DateRangeInput.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
interface DateRangeInputProps {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
onStartChange: (value: string) => void;
|
||||||
|
onEndChange: (value: string) => void;
|
||||||
|
startLabel?: string;
|
||||||
|
endLabel?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 시작일~종료일 날짜 입력 (자동 보정) */
|
||||||
|
export default function DateRangeInput({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
onStartChange,
|
||||||
|
onEndChange,
|
||||||
|
startLabel = "시작일",
|
||||||
|
endLabel = "종료일",
|
||||||
|
disabled,
|
||||||
|
}: DateRangeInputProps) {
|
||||||
|
const handleStartChange = (value: string) => {
|
||||||
|
onStartChange(value);
|
||||||
|
if (endDate && value > endDate) onEndChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEndChange = (value: string) => {
|
||||||
|
onEndChange(value);
|
||||||
|
if (startDate && value < startDate) onStartChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 시작일 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-xs font-medium text-gray-600 mb-1.5">
|
||||||
|
{startLabel}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 material-symbols-outlined text-gray-400 text-base">
|
||||||
|
calendar_today
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => handleStartChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-full h-[38px] pl-10 pr-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-gray-400 text-sm font-medium pb-2">~</span>
|
||||||
|
|
||||||
|
{/* 종료일 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-xs font-medium text-gray-600 mb-1.5">
|
||||||
|
{endLabel}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 material-symbols-outlined text-gray-400 text-base">
|
||||||
|
calendar_today
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => handleEndChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-full h-[38px] pl-10 pr-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
react/src/components/common/EmptyState.tsx
Normal file
27
react/src/components/common/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon?: string;
|
||||||
|
message: string;
|
||||||
|
description?: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 빈 데이터 상태 표시 */
|
||||||
|
export default function EmptyState({ icon = "inbox", message, description, action }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-gray-300 mb-4"
|
||||||
|
style={{ fontSize: "48px" }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm font-medium text-gray-500 mb-1">{message}</p>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-gray-400 mb-4">{description}</p>
|
||||||
|
)}
|
||||||
|
{action && <div className="mt-2">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
react/src/components/common/FilterDropdown.tsx
Normal file
73
react/src/components/common/FilterDropdown.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
interface FilterDropdownProps {
|
||||||
|
label?: string;
|
||||||
|
value: string;
|
||||||
|
options: string[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 커스텀 셀렉트 드롭다운 (외부클릭 닫기) */
|
||||||
|
export default function FilterDropdown({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
className = "",
|
||||||
|
disabled,
|
||||||
|
}: FilterDropdownProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 외부 클릭 시 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("click", handleClick);
|
||||||
|
return () => document.removeEventListener("click", handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`} ref={ref}>
|
||||||
|
{label && (
|
||||||
|
<label className="block text-xs font-medium text-gray-600 mb-1.5">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => !disabled && setOpen((v) => !v)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-full h-[38px] border border-gray-300 rounded-lg px-3 text-sm text-center flex items-center justify-between bg-white hover:border-gray-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:hover:border-gray-300"
|
||||||
|
>
|
||||||
|
<span className="truncate flex-1">{value}</span>
|
||||||
|
<span className="material-symbols-outlined text-gray-400 text-[18px] flex-shrink-0">
|
||||||
|
expand_more
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<ul className="absolute left-0 top-full mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg z-50 py-1 overflow-hidden">
|
||||||
|
{options.map((opt) => (
|
||||||
|
<li
|
||||||
|
key={opt}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(opt);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-2 text-sm text-center hover:bg-gray-50 cursor-pointer transition-colors ${
|
||||||
|
opt === value ? "text-primary font-medium" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
react/src/components/common/FilterResetButton.tsx
Normal file
32
react/src/components/common/FilterResetButton.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface FilterResetButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 초기화 버튼 (스핀 애니메이션 + 툴팁) */
|
||||||
|
export default function FilterResetButton({ onClick, disabled }: FilterResetButtonProps) {
|
||||||
|
const [spinning, setSpinning] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
onClick();
|
||||||
|
setSpinning(true);
|
||||||
|
setTimeout(() => setSpinning(false), 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className="filter-reset-btn h-[38px] w-[38px] flex-shrink-0 flex items-center justify-center border border-gray-300 rounded-lg text-gray-500 hover:text-red-500 hover:border-red-300 hover:bg-red-50 transition-colors relative disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-gray-500 disabled:hover:border-gray-300 disabled:hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<span className="reset-tooltip">필터 초기화</span>
|
||||||
|
<span
|
||||||
|
className={`material-symbols-outlined text-lg${spinning ? " spinning" : ""}`}
|
||||||
|
>
|
||||||
|
restart_alt
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
react/src/components/common/PageHeader.tsx
Normal file
22
react/src/components/common/PageHeader.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 페이지 제목 + 설명 + 우측 액션 버튼 */
|
||||||
|
export default function PageHeader({ title, description, action }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[#0f172a]">{title}</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="text-[#64748b] text-sm mt-1">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action && <div>{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
react/src/components/common/Pagination.tsx
Normal file
104
react/src/components/common/Pagination.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
interface PaginationProps {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalItems: number;
|
||||||
|
pageSize: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 테이블 페이지네이션 */
|
||||||
|
export default function Pagination({
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
totalItems,
|
||||||
|
pageSize,
|
||||||
|
onPageChange,
|
||||||
|
}: PaginationProps) {
|
||||||
|
const safeTotal = totalItems ?? 0;
|
||||||
|
const start = safeTotal > 0 ? (currentPage - 1) * pageSize + 1 : 0;
|
||||||
|
const end = Math.min(currentPage * pageSize, safeTotal);
|
||||||
|
|
||||||
|
/** 표시할 페이지 번호 목록 (최대 5개) */
|
||||||
|
const getPageNumbers = () => {
|
||||||
|
const pages: number[] = [];
|
||||||
|
let startPage = Math.max(1, currentPage - 2);
|
||||||
|
const endPage = Math.min(totalPages, startPage + 4);
|
||||||
|
startPage = Math.max(1, endPage - 4);
|
||||||
|
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (totalPages <= 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-6 py-3">
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
총 {safeTotal.toLocaleString()}건 중 {start.toLocaleString()}-{end.toLocaleString()} 표시
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* 처음 */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="size-8 flex items-center justify-center rounded-lg text-sm text-gray-500 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>
|
||||||
|
first_page
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 이전 */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="size-8 flex items-center justify-center rounded-lg text-sm text-gray-500 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>
|
||||||
|
chevron_left
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 페이지 번호 */}
|
||||||
|
{getPageNumbers().map((page) => (
|
||||||
|
<button
|
||||||
|
key={page}
|
||||||
|
onClick={() => onPageChange(page)}
|
||||||
|
className={`size-8 flex items-center justify-center rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
page === currentPage
|
||||||
|
? "bg-primary text-white"
|
||||||
|
: "text-gray-600 hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 다음 */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="size-8 flex items-center justify-center rounded-lg text-sm text-gray-500 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>
|
||||||
|
chevron_right
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 마지막 */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(totalPages)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="size-8 flex items-center justify-center rounded-lg text-sm text-gray-500 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>
|
||||||
|
last_page
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
react/src/components/common/PlatformBadge.tsx
Normal file
22
react/src/components/common/PlatformBadge.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
interface PlatformBadgeProps {
|
||||||
|
platform: "ios" | "android";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** iOS/Android 아이콘 뱃지 */
|
||||||
|
export default function PlatformBadge({ platform }: PlatformBadgeProps) {
|
||||||
|
if (platform === "ios") {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center justify-center w-8 h-6 rounded text-xs font-medium bg-gray-100 text-gray-700 border border-gray-200">
|
||||||
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center justify-center w-8 h-6 rounded text-xs font-medium bg-green-50 text-green-700 border border-green-200">
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: "16px", lineHeight: "1" }}>android</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
react/src/components/common/SearchInput.tsx
Normal file
39
react/src/components/common/SearchInput.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
interface SearchInputProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
label?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 검색 아이콘 + 텍스트 입력 */
|
||||||
|
export default function SearchInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "검색...",
|
||||||
|
label,
|
||||||
|
disabled,
|
||||||
|
}: SearchInputProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-xs font-medium text-gray-600 mb-1.5">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 material-symbols-outlined text-gray-400 text-base">
|
||||||
|
search
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-full h-[38px] pl-10 pr-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
react/src/components/common/StatusBadge.tsx
Normal file
44
react/src/components/common/StatusBadge.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
type StatusVariant = "success" | "error" | "warning" | "info" | "default";
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
variant: StatusVariant;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT_STYLES: Record<StatusVariant, { badge: string; dot: string }> = {
|
||||||
|
success: {
|
||||||
|
badge: "bg-green-50 text-green-700 border-green-200",
|
||||||
|
dot: "bg-green-500",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
badge: "bg-red-50 text-red-700 border-red-200",
|
||||||
|
dot: "bg-red-500",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
badge: "bg-yellow-50 text-yellow-700 border-yellow-200",
|
||||||
|
dot: "bg-yellow-500",
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
badge: "bg-blue-50 text-blue-700 border-blue-200",
|
||||||
|
dot: "bg-blue-500",
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
badge: "bg-gray-100 text-gray-600 border-gray-200",
|
||||||
|
dot: "bg-gray-400",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 상태 dot 뱃지 (완료/실패/진행/예약/활성/비활성) */
|
||||||
|
export default function StatusBadge({ variant, label }: StatusBadgeProps) {
|
||||||
|
// variant가 유효하지 않으면 default로 폴백
|
||||||
|
const style = VARIANT_STYLES[variant] ?? VARIANT_STYLES.default;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium border ${style.badge}`}
|
||||||
|
>
|
||||||
|
<span className={`size-1.5 rounded-full ${style.dot}`} />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
react/src/components/feedback/ErrorBoundary.tsx
Normal file
52
react/src/components/feedback/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** React Error Boundary */
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error("ErrorBoundary caught:", error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-6">
|
||||||
|
<h1 className="text-2xl font-bold text-destructive">오류가 발생했습니다</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{this.state.error?.message ?? "알 수 없는 오류"}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
228
react/src/components/layout/AppHeader.tsx
Normal file
228
react/src/components/layout/AppHeader.tsx
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import CategoryBadge from "@/components/common/CategoryBadge";
|
||||||
|
import {
|
||||||
|
MOCK_NOTIFICATIONS as SHARED_NOTIFICATIONS,
|
||||||
|
type NotificationType,
|
||||||
|
} from "@/features/settings/types";
|
||||||
|
|
||||||
|
/** 경로 → breadcrumb 레이블 매핑 (정적 경로) */
|
||||||
|
const pathLabels: Record<string, string> = {
|
||||||
|
"/dashboard": "대시보드",
|
||||||
|
"/services": "서비스 관리",
|
||||||
|
"/services/register": "서비스 등록",
|
||||||
|
"/messages": "메시지 관리",
|
||||||
|
"/messages/register": "메시지 등록",
|
||||||
|
"/statistics": "발송 통계",
|
||||||
|
"/statistics/history": "발송 이력",
|
||||||
|
"/devices": "기기 관리",
|
||||||
|
"/tags": "태그 관리",
|
||||||
|
"/settings": "마이 페이지",
|
||||||
|
"/settings/profile": "프로필 수정",
|
||||||
|
"/settings/notifications": "알림",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 그룹 경로 → 그룹 라벨 (사이드바 그룹명과 매핑) */
|
||||||
|
const groupLabels: Record<string, string> = {
|
||||||
|
"/statistics": "발송 관리",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 동적 경로 패턴 매칭 규칙
|
||||||
|
* pattern으로 pathname을 매칭 → crumbs 함수가 추가할 브레드크럼 배열 반환
|
||||||
|
*/
|
||||||
|
const dynamicPatterns: {
|
||||||
|
pattern: RegExp;
|
||||||
|
crumbs: (match: RegExpMatchArray) => { path: string; label: string }[];
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
// /services/:id 또는 /services/:id/edit (register 제외)
|
||||||
|
pattern: /^\/services\/(?!register$)([^/]+)(\/edit)?$/,
|
||||||
|
crumbs: (match) => {
|
||||||
|
const id = match[1];
|
||||||
|
const isEdit = !!match[2];
|
||||||
|
const result = [{ path: `/services/${id}`, label: "서비스 상세" }];
|
||||||
|
if (isEdit) {
|
||||||
|
result.push({ path: `/services/${id}/edit`, label: "서비스 수정" });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** pathname → breadcrumb 배열 생성 */
|
||||||
|
function buildBreadcrumbs(pathname: string) {
|
||||||
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
|
const crumbs: { path: string; label: string }[] = [];
|
||||||
|
|
||||||
|
// 1) 정적 경로 매칭 (누적 경로 기반)
|
||||||
|
const isLastSegment = (i: number) => i === segments.length - 1;
|
||||||
|
let currentPath = "";
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
currentPath += `/${segments[i]}`;
|
||||||
|
const groupLabel = groupLabels[currentPath];
|
||||||
|
if (groupLabel) {
|
||||||
|
crumbs.push({ path: currentPath, label: groupLabel });
|
||||||
|
// 마지막 세그먼트일 때만 페이지 라벨도 추가
|
||||||
|
if (isLastSegment(i)) {
|
||||||
|
const label = pathLabels[currentPath];
|
||||||
|
if (label) crumbs.push({ path: currentPath, label });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const label = pathLabels[currentPath];
|
||||||
|
if (label) crumbs.push({ path: currentPath, label });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 동적 경로 패턴 매칭
|
||||||
|
for (const { pattern, crumbs: buildDynamic } of dynamicPatterns) {
|
||||||
|
const match = pathname.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
crumbs.push(...buildDynamic(match));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return crumbs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── NotificationType → CategoryBadge 매핑 ── */
|
||||||
|
const NOTI_BADGE_MAP: Record<
|
||||||
|
NotificationType,
|
||||||
|
{ variant: "success" | "warning" | "error" | "info" | "default"; icon: string }
|
||||||
|
> = {
|
||||||
|
"발송": { variant: "success", icon: "check_circle" },
|
||||||
|
"인증서": { variant: "warning", icon: "verified" },
|
||||||
|
"실패": { variant: "error", icon: "error" },
|
||||||
|
"서비스": { variant: "info", icon: "cloud" },
|
||||||
|
"시스템": { variant: "default", icon: "settings" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 헤더 드롭다운에 표시할 최근 알림 (최대 5개) */
|
||||||
|
const HEADER_NOTIFICATIONS = SHARED_NOTIFICATIONS.slice(0, 5);
|
||||||
|
|
||||||
|
export default function AppHeader() {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const crumbs = buildBreadcrumbs(pathname);
|
||||||
|
const [notiOpen, setNotiOpen] = useState(false);
|
||||||
|
const notiRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 외부 클릭 시 드롭다운 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (notiRef.current && !notiRef.current.contains(e.target as Node)) {
|
||||||
|
setNotiOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("click", handleClick);
|
||||||
|
return () => document.removeEventListener("click", handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 페이지 이동 시 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
setNotiOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="fixed left-64 right-0 top-0 z-40 flex h-16 items-center justify-between border-b border-gray-200 bg-white px-10 shadow-sm">
|
||||||
|
{/* 브레드크럼 (다단계) */}
|
||||||
|
<nav className="flex min-w-0 flex-1 items-center overflow-hidden text-sm">
|
||||||
|
{crumbs.length === 0 ? (
|
||||||
|
<span className="font-medium text-foreground">Home</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link to="/" className="flex-shrink-0 whitespace-nowrap text-gray-500 transition-colors hover:text-primary">Home</Link>
|
||||||
|
{crumbs.map((crumb, i) => {
|
||||||
|
const isLast = i === crumbs.length - 1;
|
||||||
|
return (
|
||||||
|
<span key={crumb.path} className="flex flex-shrink-0 items-center">
|
||||||
|
<span className="material-symbols-outlined mx-2 text-base text-gray-300">chevron_right</span>
|
||||||
|
{isLast ? (
|
||||||
|
<span className="whitespace-nowrap font-medium text-foreground">{crumb.label}</span>
|
||||||
|
) : (
|
||||||
|
<Link to={crumb.path} className="whitespace-nowrap text-gray-500 transition-colors hover:text-primary">
|
||||||
|
{crumb.label}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* 우측 아이콘 */}
|
||||||
|
<div className="ml-auto flex flex-shrink-0 items-center gap-5 pl-4 text-gray-400">
|
||||||
|
{/* 알림 버튼 + 드롭다운 */}
|
||||||
|
<div className="relative" ref={notiRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setNotiOpen((v) => !v)}
|
||||||
|
className="group relative transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-2xl group-hover:animate-pulse">
|
||||||
|
notifications
|
||||||
|
</span>
|
||||||
|
<span className="absolute right-0.5 top-0.5 size-2 rounded-full border-2 border-white bg-red-500" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 알림 드롭다운 패널 */}
|
||||||
|
{notiOpen && (
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-96 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg z-50">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
|
||||||
|
<h3 className="text-sm font-bold text-foreground">알림</h3>
|
||||||
|
<Link
|
||||||
|
to="/settings/notifications"
|
||||||
|
className="text-xs font-medium text-primary transition-colors hover:text-[#1d4ed8]"
|
||||||
|
>
|
||||||
|
전체 보기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 알림 목록 */}
|
||||||
|
<div className="overflow-y-auto" style={{ maxHeight: 200, overscrollBehavior: "contain" }}>
|
||||||
|
{HEADER_NOTIFICATIONS.map((noti) => {
|
||||||
|
const badge = NOTI_BADGE_MAP[noti.type];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={noti.id}
|
||||||
|
onClick={() => {
|
||||||
|
setNotiOpen(false);
|
||||||
|
navigate("/settings/notifications", {
|
||||||
|
state: { notificationId: noti.id },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={`cursor-pointer border-b border-gray-50 px-4 py-3 transition-colors hover:bg-gray-50 ${
|
||||||
|
!noti.read ? "bg-blue-50/20" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center gap-2">
|
||||||
|
<CategoryBadge variant={badge.variant} icon={badge.icon} label={noti.type} />
|
||||||
|
<p className={`flex-1 truncate text-xs text-foreground ${!noti.read ? "font-bold" : "font-medium"}`}>
|
||||||
|
{noti.title}
|
||||||
|
</p>
|
||||||
|
<span className="flex-shrink-0 text-[10px] text-gray-400">{noti.time}</span>
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-[11px] text-gray-500">{noti.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="h-6 w-px bg-gray-200" />
|
||||||
|
|
||||||
|
{/* 프로필 */}
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className="flex items-center gap-2 transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-2xl">account_circle</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
react/src/components/layout/AppLayout.tsx
Normal file
111
react/src/components/layout/AppLayout.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import AppSidebar from "./AppSidebar";
|
||||||
|
import AppHeader from "./AppHeader";
|
||||||
|
import useBreadcrumbBack from "@/hooks/useBreadcrumbBack";
|
||||||
|
|
||||||
|
export default function AppLayout() {
|
||||||
|
useBreadcrumbBack();
|
||||||
|
const [termsModal, setTermsModal] = useState(false);
|
||||||
|
const [privacyModal, setPrivacyModal] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen w-full flex-row overflow-x-hidden">
|
||||||
|
{/* 사이드바 (w-64 고정) */}
|
||||||
|
<AppSidebar />
|
||||||
|
|
||||||
|
{/* 메인 영역 */}
|
||||||
|
<main className="ml-64 flex min-h-screen flex-1 flex-col bg-gray-50">
|
||||||
|
{/* 헤더 (h-16 고정) */}
|
||||||
|
<AppHeader />
|
||||||
|
|
||||||
|
{/* 컨텐츠 */}
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-10 pb-12 pt-28">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<footer className="mt-auto flex items-center justify-between border-t border-gray-100 px-10 py-6 text-sm text-gray-400">
|
||||||
|
<p>© 2026 Team.Stein. All rights reserved.</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setPrivacyModal(true)}
|
||||||
|
className="transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
개인정보처리방침
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-300">|</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setTermsModal(true)}
|
||||||
|
className="transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
이용약관
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* ───── 서비스 이용약관 모달 ───── */}
|
||||||
|
{termsModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setTermsModal(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100">
|
||||||
|
<span className="material-symbols-outlined text-xl text-blue-600">
|
||||||
|
description
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="flex-1 text-lg font-bold text-foreground">
|
||||||
|
서비스 이용약관
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTermsModal(false)}
|
||||||
|
className="flex-shrink-0 rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: "20px" }}>close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ───── 개인정보 처리방침 모달 ───── */}
|
||||||
|
{privacyModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setPrivacyModal(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100">
|
||||||
|
<span className="material-symbols-outlined text-xl text-blue-600">
|
||||||
|
shield
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="flex-1 text-lg font-bold text-foreground">
|
||||||
|
개인정보 처리방침
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPrivacyModal(false)}
|
||||||
|
className="flex-shrink-0 rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: "20px" }}>close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
react/src/components/layout/AppSidebar.tsx
Normal file
202
react/src/components/layout/AppSidebar.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Link, NavLink, useLocation } from "react-router-dom";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
|
||||||
|
/** 단일 메뉴 항목 */
|
||||||
|
interface NavItem {
|
||||||
|
to: string;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 드롭다운 메뉴 그룹 */
|
||||||
|
interface NavGroup {
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
children: { to: string; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type NavEntry =
|
||||||
|
| { type: "item"; item: NavItem }
|
||||||
|
| { type: "group"; group: NavGroup }
|
||||||
|
| { type: "separator" };
|
||||||
|
|
||||||
|
/** 네비게이션 메뉴 정의 (가이드라인 기준) */
|
||||||
|
const navEntries: NavEntry[] = [
|
||||||
|
{ type: "item", item: { to: "/dashboard", icon: "dashboard", label: "대시보드" } },
|
||||||
|
{ type: "item", item: { to: "/services", icon: "manage_accounts", label: "서비스 관리" } },
|
||||||
|
{ type: "item", item: { to: "/messages", icon: "mail", label: "메시지 관리" } },
|
||||||
|
{ type: "item", item: { to: "/devices", icon: "devices", label: "기기 관리" } },
|
||||||
|
{
|
||||||
|
type: "group",
|
||||||
|
group: {
|
||||||
|
icon: "send",
|
||||||
|
label: "발송 관리",
|
||||||
|
children: [
|
||||||
|
{ to: "/statistics", label: "발송 통계" },
|
||||||
|
{ to: "/statistics/history", label: "발송 이력" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "item", item: { to: "/tags", icon: "sell", label: "태그 관리" } },
|
||||||
|
{ type: "separator" },
|
||||||
|
{
|
||||||
|
type: "group",
|
||||||
|
group: {
|
||||||
|
icon: "settings",
|
||||||
|
label: "설정",
|
||||||
|
children: [
|
||||||
|
{ to: "/settings/notifications", label: "알림" },
|
||||||
|
{ to: "/settings", label: "마이 페이지" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 단일 메뉴 아이템 */
|
||||||
|
function SidebarItem({ item }: { item: NavItem }) {
|
||||||
|
const location = useLocation();
|
||||||
|
const isActive = location.pathname === item.to || location.pathname.startsWith(item.to + "/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
to={item.to}
|
||||||
|
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors group ${
|
||||||
|
isActive
|
||||||
|
? "bg-white/10 text-white"
|
||||||
|
: "text-gray-400 hover:bg-white/5 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xl transition-colors group-hover:text-white">
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 드롭다운 메뉴 그룹 */
|
||||||
|
function SidebarGroup({ group }: { group: NavGroup }) {
|
||||||
|
const location = useLocation();
|
||||||
|
const isChildActive = group.children.some(
|
||||||
|
(child) => location.pathname === child.to || location.pathname.startsWith(child.to + "/"),
|
||||||
|
);
|
||||||
|
const [isOpen, setIsOpen] = useState(isChildActive);
|
||||||
|
|
||||||
|
// 경로 변경 시: 해당 그룹 자식이면 열고, 아니면 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
setIsOpen(isChildActive);
|
||||||
|
}, [isChildActive]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors group ${
|
||||||
|
isChildActive
|
||||||
|
? "bg-white/10 text-white"
|
||||||
|
: "text-gray-400 hover:bg-white/5 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xl transition-colors group-hover:text-white">
|
||||||
|
{group.icon}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-left">{group.label}</span>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-base transition-transform"
|
||||||
|
style={{ transform: isOpen ? "rotate(180deg)" : "rotate(0deg)" }}
|
||||||
|
>
|
||||||
|
keyboard_arrow_down
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="mt-1 flex flex-col gap-1 pl-9">
|
||||||
|
{group.children.map((child) => {
|
||||||
|
const isActive = location.pathname === child.to;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={child.to}
|
||||||
|
to={child.to}
|
||||||
|
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "bg-white/5 text-primary"
|
||||||
|
: "text-gray-500 hover:bg-white/5 hover:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${
|
||||||
|
isActive ? "bg-primary" : "bg-gray-500"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span>{child.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppSidebar() {
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="fixed left-0 top-0 z-50 flex h-screen w-64 flex-shrink-0 flex-col justify-between border-r border-gray-800 bg-sidebar text-white">
|
||||||
|
<div className="flex flex-col gap-6 p-6">
|
||||||
|
{/* 로고 → 홈 이동 */}
|
||||||
|
<Link to="/" className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-lg border border-primary/30 bg-primary/20">
|
||||||
|
<span className="material-symbols-outlined text-2xl text-white">grid_view</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h1 className="text-base font-bold leading-none tracking-tight text-white">SPMS</h1>
|
||||||
|
<p className="mt-1 text-xs font-normal text-gray-400">Admin Console</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* 네비게이션 */}
|
||||||
|
<nav className="flex flex-col gap-1">
|
||||||
|
{navEntries.map((entry, idx) => {
|
||||||
|
if (entry.type === "separator") {
|
||||||
|
return <div key={idx} className="mx-3 my-2 h-px bg-gray-800" />;
|
||||||
|
}
|
||||||
|
if (entry.type === "item") {
|
||||||
|
return <SidebarItem key={entry.item.to} item={entry.item} />;
|
||||||
|
}
|
||||||
|
return <SidebarGroup key={entry.group.label} group={entry.group} />;
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사용자 프로필 */}
|
||||||
|
<div className="border-t border-gray-800 p-4">
|
||||||
|
<div className="flex items-center gap-3 px-2">
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className="flex size-9 items-center justify-center rounded-full bg-blue-600 text-xs font-bold tracking-wide text-white shadow-sm hover:bg-blue-500 transition-colors"
|
||||||
|
title="마이 페이지"
|
||||||
|
>
|
||||||
|
{user?.name?.slice(0, 2)?.toUpperCase() ?? "AU"}
|
||||||
|
</Link>
|
||||||
|
<div className="flex-1 overflow-hidden" style={{ containerType: "inline-size" }}>
|
||||||
|
<p className="truncate text-sm font-medium text-white">
|
||||||
|
{user?.name ?? "Admin User"}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-400" style={{ fontSize: "clamp(9px, 2.5cqi, 12px)" }}>
|
||||||
|
{user?.email ?? "admin@spms.com"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="rounded p-1 text-gray-400 transition-colors hover:bg-white/5 hover:text-white"
|
||||||
|
title="로그아웃"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[20px]">logout</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
react/src/components/layout/AuthLayout.tsx
Normal file
19
react/src/components/layout/AuthLayout.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function AuthLayout() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center bg-sidebar p-4 antialiased">
|
||||||
|
{/* 콘텐츠 중앙 정렬 */}
|
||||||
|
<div className="flex flex-1 w-full items-center justify-center">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 풋터 */}
|
||||||
|
<footer className="pb-6 w-full text-center">
|
||||||
|
<p className="text-xs font-medium tracking-wide text-gray-500">
|
||||||
|
© 2026 Stein Co., Ltd.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
react/src/components/ui/alert.tsx
Normal file
66
react/src/components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
107
react/src/components/ui/avatar.tsx
Normal file
107
react/src/components/ui/avatar.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||||
|
size?: "default" | "sm" | "lg"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn("aspect-square size-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar-badge"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full ring-2 select-none",
|
||||||
|
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||||
|
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||||
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group"
|
||||||
|
className={cn(
|
||||||
|
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroupCount({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarBadge,
|
||||||
|
AvatarGroup,
|
||||||
|
AvatarGroupCount,
|
||||||
|
}
|
||||||
48
react/src/components/ui/badge.tsx
Normal file
48
react/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
64
react/src/components/ui/button.tsx
Normal file
64
react/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
92
react/src/components/ui/card.tsx
Normal file
92
react/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
32
react/src/components/ui/checkbox.tsx
Normal file
32
react/src/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
158
react/src/components/ui/dialog.tsx
Normal file
158
react/src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
255
react/src/components/ui/dropdown-menu.tsx
Normal file
255
react/src/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
165
react/src/components/ui/form.tsx
Normal file
165
react/src/components/ui/form.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import type { Label as LabelPrimitive } from "radix-ui"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
useFormState,
|
||||||
|
type ControllerProps,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
const Form = FormProvider
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext)
|
||||||
|
const itemContext = React.useContext(FormItemContext)
|
||||||
|
const { getFieldState } = useFormContext()
|
||||||
|
const formState = useFormState({ name: fieldContext.name })
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState)
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div
|
||||||
|
data-slot="form-item"
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="form-label"
|
||||||
|
data-error={!!error}
|
||||||
|
className={cn("data-[error=true]:text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormControl({ ...props }: React.ComponentProps<typeof Slot.Root>) {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot.Root
|
||||||
|
data-slot="form-control"
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-description"
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message ?? "") : props.children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-message"
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-destructive text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
||||||
21
react/src/components/ui/input.tsx
Normal file
21
react/src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
24
react/src/components/ui/label.tsx
Normal file
24
react/src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Label as LabelPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
168
react/src/components/ui/navigation-menu.tsx
Normal file
168
react/src/components/ui/navigation-menu.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function NavigationMenu({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
viewport = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||||
|
viewport?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Root
|
||||||
|
data-slot="navigation-menu"
|
||||||
|
data-viewport={viewport}
|
||||||
|
className={cn(
|
||||||
|
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{viewport && <NavigationMenuViewport />}
|
||||||
|
</NavigationMenuPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.List
|
||||||
|
data-slot="navigation-menu-list"
|
||||||
|
className={cn(
|
||||||
|
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Item
|
||||||
|
data-slot="navigation-menu-item"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigationMenuTriggerStyle = cva(
|
||||||
|
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
function NavigationMenuTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Trigger
|
||||||
|
data-slot="navigation-menu-trigger"
|
||||||
|
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}{" "}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</NavigationMenuPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Content
|
||||||
|
data-slot="navigation-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||||
|
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuViewport({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-full left-0 isolate z-50 flex justify-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<NavigationMenuPrimitive.Viewport
|
||||||
|
data-slot="navigation-menu-viewport"
|
||||||
|
className={cn(
|
||||||
|
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuLink({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Link
|
||||||
|
data-slot="navigation-menu-link"
|
||||||
|
className={cn(
|
||||||
|
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuIndicator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Indicator
|
||||||
|
data-slot="navigation-menu-indicator"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||||
|
</NavigationMenuPrimitive.Indicator>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuContent,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
NavigationMenuLink,
|
||||||
|
NavigationMenuIndicator,
|
||||||
|
NavigationMenuViewport,
|
||||||
|
navigationMenuTriggerStyle,
|
||||||
|
}
|
||||||
188
react/src/components/ui/select.tsx
Normal file
188
react/src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
import { Select as SelectPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "item-aligned",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-slot="select-item-indicator"
|
||||||
|
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
28
react/src/components/ui/separator.tsx
Normal file
28
react/src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
141
react/src/components/ui/sheet.tsx
Normal file
141
react/src/components/ui/sheet.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
side === "right" &&
|
||||||
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||||
|
side === "left" &&
|
||||||
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||||
|
side === "top" &&
|
||||||
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
|
side === "bottom" &&
|
||||||
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
724
react/src/components/ui/sidebar.tsx
Normal file
724
react/src/components/ui/sidebar.tsx
Normal file
|
|
@ -0,0 +1,724 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { PanelLeftIcon } from "lucide-react"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
|
||||||
|
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||||
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||||
|
const SIDEBAR_WIDTH = "16rem"
|
||||||
|
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||||
|
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||||
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||||
|
|
||||||
|
type SidebarContextProps = {
|
||||||
|
state: "expanded" | "collapsed"
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
openMobile: boolean
|
||||||
|
setOpenMobile: (open: boolean) => void
|
||||||
|
isMobile: boolean
|
||||||
|
toggleSidebar: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||||
|
|
||||||
|
function useSidebar() {
|
||||||
|
const context = React.useContext(SidebarContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarProvider({
|
||||||
|
defaultOpen = true,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange: setOpenProp,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
defaultOpen?: boolean
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const [openMobile, setOpenMobile] = React.useState(false)
|
||||||
|
|
||||||
|
// This is the internal state of the sidebar.
|
||||||
|
// We use openProp and setOpenProp for control from outside the component.
|
||||||
|
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||||
|
const open = openProp ?? _open
|
||||||
|
const setOpen = React.useCallback(
|
||||||
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
|
const openState = typeof value === "function" ? value(open) : value
|
||||||
|
if (setOpenProp) {
|
||||||
|
setOpenProp(openState)
|
||||||
|
} else {
|
||||||
|
_setOpen(openState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This sets the cookie to keep the sidebar state.
|
||||||
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||||
|
},
|
||||||
|
[setOpenProp, open]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper to toggle the sidebar.
|
||||||
|
const toggleSidebar = React.useCallback(() => {
|
||||||
|
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||||
|
}, [isMobile, setOpen, setOpenMobile])
|
||||||
|
|
||||||
|
// Adds a keyboard shortcut to toggle the sidebar.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||||
|
(event.metaKey || event.ctrlKey)
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleSidebar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}, [toggleSidebar])
|
||||||
|
|
||||||
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||||
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
|
const state = open ? "expanded" : "collapsed"
|
||||||
|
|
||||||
|
const contextValue = React.useMemo<SidebarContextProps>(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
isMobile,
|
||||||
|
openMobile,
|
||||||
|
setOpenMobile,
|
||||||
|
toggleSidebar,
|
||||||
|
}),
|
||||||
|
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={contextValue}>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-wrapper"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH,
|
||||||
|
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||||
|
...style,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({
|
||||||
|
side = "left",
|
||||||
|
variant = "sidebar",
|
||||||
|
collapsible = "offcanvas",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
side?: "left" | "right"
|
||||||
|
variant?: "sidebar" | "floating" | "inset"
|
||||||
|
collapsible?: "offcanvas" | "icon" | "none"
|
||||||
|
}) {
|
||||||
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||||
|
|
||||||
|
if (collapsible === "none") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar"
|
||||||
|
className={cn(
|
||||||
|
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||||
|
<SheetContent
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar"
|
||||||
|
data-mobile="true"
|
||||||
|
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
side={side}
|
||||||
|
>
|
||||||
|
<SheetHeader className="sr-only">
|
||||||
|
<SheetTitle>Sidebar</SheetTitle>
|
||||||
|
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex h-full w-full flex-col">{children}</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group peer text-sidebar-foreground hidden md:block"
|
||||||
|
data-state={state}
|
||||||
|
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||||
|
data-variant={variant}
|
||||||
|
data-side={side}
|
||||||
|
data-slot="sidebar"
|
||||||
|
>
|
||||||
|
{/* This is what handles the sidebar gap on desktop */}
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-gap"
|
||||||
|
className={cn(
|
||||||
|
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||||
|
"group-data-[collapsible=offcanvas]:w-0",
|
||||||
|
"group-data-[side=right]:rotate-180",
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||||
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-container"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||||
|
side === "left"
|
||||||
|
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||||
|
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||||
|
// Adjust the padding for floating and inset variants.
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||||
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar-inner"
|
||||||
|
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarTrigger({
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-sidebar="trigger"
|
||||||
|
data-slot="sidebar-trigger"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn("size-7", className)}
|
||||||
|
onClick={(event) => {
|
||||||
|
onClick?.(event)
|
||||||
|
toggleSidebar()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<PanelLeftIcon />
|
||||||
|
<span className="sr-only">Toggle Sidebar</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
data-sidebar="rail"
|
||||||
|
data-slot="sidebar-rail"
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
title="Toggle Sidebar"
|
||||||
|
className={cn(
|
||||||
|
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||||
|
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||||
|
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||||
|
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||||
|
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||||
|
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
data-slot="sidebar-inset"
|
||||||
|
className={cn(
|
||||||
|
"bg-background relative flex w-full flex-1 flex-col",
|
||||||
|
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Input>) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="sidebar-input"
|
||||||
|
data-sidebar="input"
|
||||||
|
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-header"
|
||||||
|
data-sidebar="header"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-footer"
|
||||||
|
data-sidebar="footer"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="sidebar-separator"
|
||||||
|
data-sidebar="separator"
|
||||||
|
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-content"
|
||||||
|
data-sidebar="content"
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group"
|
||||||
|
data-sidebar="group"
|
||||||
|
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupLabel({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "div"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-group-label"
|
||||||
|
data-sidebar="group-label"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupAction({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-group-action"
|
||||||
|
data-sidebar="group-action"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group-content"
|
||||||
|
data-sidebar="group-content"
|
||||||
|
className={cn("w-full text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu"
|
||||||
|
data-sidebar="menu"
|
||||||
|
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-item"
|
||||||
|
data-sidebar="menu-item"
|
||||||
|
className={cn("group/menu-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarMenuButtonVariants = cva(
|
||||||
|
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
|
outline:
|
||||||
|
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-8 text-sm",
|
||||||
|
sm: "h-7 text-xs",
|
||||||
|
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function SidebarMenuButton({
|
||||||
|
asChild = false,
|
||||||
|
isActive = false,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
tooltip,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
isActive?: boolean
|
||||||
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||||
|
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
const { isMobile, state } = useSidebar()
|
||||||
|
|
||||||
|
const button = (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-button"
|
||||||
|
data-sidebar="menu-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!tooltip) {
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof tooltip === "string") {
|
||||||
|
tooltip = {
|
||||||
|
children: tooltip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
hidden={state !== "collapsed" || isMobile}
|
||||||
|
{...tooltip}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuAction({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
showOnHover = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
showOnHover?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-action"
|
||||||
|
data-sidebar="menu-action"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
showOnHover &&
|
||||||
|
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuBadge({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-badge"
|
||||||
|
data-sidebar="menu-badge"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||||
|
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||||
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSkeleton({
|
||||||
|
className,
|
||||||
|
showIcon = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showIcon?: boolean
|
||||||
|
}) {
|
||||||
|
// Random width between 50 to 90%.
|
||||||
|
const width = React.useMemo(() => {
|
||||||
|
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-skeleton"
|
||||||
|
data-sidebar="menu-skeleton"
|
||||||
|
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{showIcon && (
|
||||||
|
<Skeleton
|
||||||
|
className="size-4 rounded-md"
|
||||||
|
data-sidebar="menu-skeleton-icon"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Skeleton
|
||||||
|
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||||
|
data-sidebar="menu-skeleton-text"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--skeleton-width": width,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu-sub"
|
||||||
|
data-sidebar="menu-sub"
|
||||||
|
className={cn(
|
||||||
|
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-sub-item"
|
||||||
|
data-sidebar="menu-sub-item"
|
||||||
|
className={cn("group/menu-sub-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubButton({
|
||||||
|
asChild = false,
|
||||||
|
size = "md",
|
||||||
|
isActive = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
size?: "sm" | "md"
|
||||||
|
isActive?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-sub-button"
|
||||||
|
data-sidebar="menu-sub-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||||
|
size === "sm" && "text-xs",
|
||||||
|
size === "md" && "text-sm",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupAction,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInput,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuBadge,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSkeleton,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarRail,
|
||||||
|
SidebarSeparator,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
|
}
|
||||||
13
react/src/components/ui/skeleton.tsx
Normal file
13
react/src/components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
38
react/src/components/ui/sonner.tsx
Normal file
38
react/src/components/ui/sonner.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import {
|
||||||
|
CircleCheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
OctagonXIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
icons={{
|
||||||
|
success: <CircleCheckIcon className="size-4" />,
|
||||||
|
info: <InfoIcon className="size-4" />,
|
||||||
|
warning: <TriangleAlertIcon className="size-4" />,
|
||||||
|
error: <OctagonXIcon className="size-4" />,
|
||||||
|
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
"--border-radius": "var(--radius)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
114
react/src/components/ui/table.tsx
Normal file
114
react/src/components/ui/table.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
57
react/src/components/ui/tooltip.tsx
Normal file
57
react/src/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
0
react/src/features/auth/components/.gitkeep
Normal file
0
react/src/features/auth/components/.gitkeep
Normal file
136
react/src/features/auth/components/ResetPasswordModal.tsx
Normal file
136
react/src/features/auth/components/ResetPasswordModal.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import useShake from "@/hooks/useShake";
|
||||||
|
|
||||||
|
const resetSchema = z.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, "이메일을 입력해주세요.")
|
||||||
|
.email("올바른 이메일 형식을 입력해주세요."),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ResetForm = z.infer<typeof resetSchema>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResetPasswordModal({ open, onClose }: Props) {
|
||||||
|
const { triggerShake, cls } = useShake();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<ResetForm>({
|
||||||
|
resolver: zodResolver(resetSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 유효성 통과 → 발송 처리 */
|
||||||
|
const onSubmit = (_data: ResetForm) => {
|
||||||
|
// TODO: API 연동
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
toast.success("임시 비밀번호가 발송되었습니다.");
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 유효성 실패 → shake */
|
||||||
|
const onError = (fieldErrors: typeof errors) => {
|
||||||
|
triggerShake(Object.keys(fieldErrors));
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 닫기 */
|
||||||
|
const handleClose = () => {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) handleClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md overflow-hidden rounded-xl bg-white shadow-2xl">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="px-8 pt-8 pb-4">
|
||||||
|
<h2 className="mb-2 text-xl font-bold text-foreground">
|
||||||
|
비밀번호 재설정
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
|
가입하신 이메일 주소를 입력하시면 임시 비밀번호를 보내드립니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 폼 */}
|
||||||
|
<form
|
||||||
|
className="px-8 pb-8"
|
||||||
|
onSubmit={handleSubmit(onSubmit, onError)}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-bold text-gray-700">
|
||||||
|
이메일
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="user@spms.com"
|
||||||
|
className={`w-full rounded border bg-white px-4 py-3 font-sans text-sm text-gray-900 placeholder-gray-400 transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary ${
|
||||||
|
errors.email
|
||||||
|
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
|
||||||
|
: "border-gray-300"
|
||||||
|
} ${cls("email")}`}
|
||||||
|
{...register("email")}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span>{errors.email.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 rounded-lg border border-gray-300 bg-white py-3 px-4 text-sm font-bold text-foreground transition-all hover:bg-gray-50"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 rounded-lg bg-primary py-3 px-4 text-sm font-bold text-white shadow-lg shadow-blue-500/20 transition-all hover:bg-[#1d4ed8] active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
임시 비밀번호 발송
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 풋터 */}
|
||||||
|
<div className="border-t border-gray-100 bg-gray-50 px-8 py-4 text-center">
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
도움이 필요하신가요?{" "}
|
||||||
|
<button className="font-medium text-primary hover:underline">
|
||||||
|
관리자 문의하기
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
react/src/features/auth/hooks/.gitkeep
Normal file
0
react/src/features/auth/hooks/.gitkeep
Normal file
327
react/src/features/auth/pages/LoginPage.tsx
Normal file
327
react/src/features/auth/pages/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { login } from "@/api/auth.api";
|
||||||
|
import type { ApiError } from "@/types/api";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import useShake from "@/hooks/useShake";
|
||||||
|
import ResetPasswordModal from "../components/ResetPasswordModal";
|
||||||
|
|
||||||
|
/* ───── zod 스키마 ───── */
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, "이메일을 입력해주세요.")
|
||||||
|
.email("올바른 이메일 형식을 입력해주세요."),
|
||||||
|
password: z.string().min(1, "비밀번호를 입력해주세요."),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LoginForm = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const setAuth = useAuthStore((s) => s.setAuth);
|
||||||
|
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [resetOpen, setResetOpen] = useState(false);
|
||||||
|
const { triggerShake, cls } = useShake();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
|
const [loginSuccess, setLoginSuccess] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LoginForm>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 유효성 통과 → 로그인 처리 */
|
||||||
|
const onSubmit = async (data: LoginForm) => {
|
||||||
|
setLoginError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await login({ email: data.email, password: data.password });
|
||||||
|
const d = res.data.data;
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
setLoginSuccess(true);
|
||||||
|
|
||||||
|
switch (d.next_action) {
|
||||||
|
case "GO_DASHBOARD":
|
||||||
|
/* 토큰 & 유저 정보 저장 → 홈 이동 */
|
||||||
|
setAuth(
|
||||||
|
{
|
||||||
|
adminCode: d.admin.admin_code ?? "",
|
||||||
|
email: d.admin.email ?? "",
|
||||||
|
name: d.admin.name ?? "",
|
||||||
|
role: d.admin.role ?? "",
|
||||||
|
},
|
||||||
|
d.access_token ?? "",
|
||||||
|
d.refresh_token ?? "",
|
||||||
|
);
|
||||||
|
setTimeout(() => navigate("/", { replace: true }), 1000);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "VERIFY_EMAIL":
|
||||||
|
/* 이메일 미인증 → 인증 페이지 이동 */
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
navigate("/verify-email", {
|
||||||
|
replace: true,
|
||||||
|
state: {
|
||||||
|
verifySessionId: d.verify_session_id,
|
||||||
|
accessToken: d.access_token,
|
||||||
|
refreshToken: d.refresh_token,
|
||||||
|
admin: d.admin,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "CHANGE_PASSWORD":
|
||||||
|
/* 비밀번호 변경 필요 → 토큰 저장 후 비밀번호 변경으로 이동 */
|
||||||
|
setAuth(
|
||||||
|
{
|
||||||
|
adminCode: d.admin.admin_code ?? "",
|
||||||
|
email: d.admin.email ?? "",
|
||||||
|
name: d.admin.name ?? "",
|
||||||
|
role: d.admin.role ?? "",
|
||||||
|
},
|
||||||
|
d.access_token ?? "",
|
||||||
|
d.refresh_token ?? "",
|
||||||
|
);
|
||||||
|
setTimeout(() => navigate("/profile/edit", { replace: true }), 1000);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
setTimeout(() => navigate("/", { replace: true }), 1000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setIsLoading(false);
|
||||||
|
const axiosErr = err as AxiosError<ApiError>;
|
||||||
|
const msg = axiosErr.response?.data?.msg;
|
||||||
|
setLoginError(msg ?? "이메일 또는 비밀번호가 올바르지 않습니다.");
|
||||||
|
triggerShake(["email", "password"]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 유효성 실패 → shake */
|
||||||
|
const onError = (fieldErrors: typeof errors) => {
|
||||||
|
setLoginError(null);
|
||||||
|
triggerShake(Object.keys(fieldErrors));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="z-10 w-full max-w-[420px] rounded-xl bg-white p-10 shadow-2xl">
|
||||||
|
{/* 로고 */}
|
||||||
|
<div className="mb-10 text-center">
|
||||||
|
<h1 className="mb-2 text-4xl font-black tracking-tight text-primary">
|
||||||
|
SPMS
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm font-medium text-gray-500">
|
||||||
|
Stein Push Message Service
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 폼 */}
|
||||||
|
<form
|
||||||
|
className="space-y-6"
|
||||||
|
onSubmit={handleSubmit(onSubmit, onError)}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
{/* 이메일 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="block text-sm font-bold text-gray-700"
|
||||||
|
>
|
||||||
|
이메일
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="user@spms.com"
|
||||||
|
className={`w-full rounded-lg border bg-white px-4 py-3 text-sm text-gray-900 placeholder-gray-400 transition-all focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/50 ${
|
||||||
|
errors.email
|
||||||
|
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
|
||||||
|
: "border-gray-300"
|
||||||
|
} ${cls("email")}`}
|
||||||
|
{...register("email")}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span>{errors.email.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀번호 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-sm font-bold text-gray-700"
|
||||||
|
>
|
||||||
|
비밀번호
|
||||||
|
</label>
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className={`w-full rounded-lg border bg-white px-4 py-3 pr-12 text-sm text-gray-900 placeholder-gray-400 transition-all focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/50 ${
|
||||||
|
errors.password
|
||||||
|
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
|
||||||
|
: "border-gray-300"
|
||||||
|
} ${cls("password")}`}
|
||||||
|
{...register("password")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 flex items-center justify-center rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined block text-[20px] leading-none">
|
||||||
|
{showPassword ? "visibility_off" : "visibility"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.password && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span>{errors.password.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 로그인 실패 메시지 */}
|
||||||
|
{loginError && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-red-50 px-4 py-3">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0 text-lg text-red-500">
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-red-600">
|
||||||
|
{loginError}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 로그인 버튼 */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full rounded-lg bg-primary py-3.5 px-4 font-bold text-white shadow-lg shadow-blue-500/20 transition-all hover:bg-[#1d4ed8] active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="size-5 animate-spin"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
로그인 중...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"로그인"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀번호 찾기 */}
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-sm font-medium text-gray-400 transition-colors hover:text-primary hover:underline"
|
||||||
|
onClick={() => setResetOpen(true)}
|
||||||
|
>
|
||||||
|
비밀번호를 잊으셨나요?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 회원가입 */}
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-sm text-gray-400">계정이 없으신가요?</span>
|
||||||
|
<Link
|
||||||
|
to="/signup"
|
||||||
|
className="ml-1 text-sm font-semibold text-primary transition-colors hover:text-[#1d4ed8] hover:underline"
|
||||||
|
>
|
||||||
|
회원가입
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀번호 재설정 모달 */}
|
||||||
|
<ResetPasswordModal
|
||||||
|
open={resetOpen}
|
||||||
|
onClose={() => setResetOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 로그인 성공 오버레이 */}
|
||||||
|
{loginSuccess && (
|
||||||
|
<div className="fixed inset-0 z-[100] animate-[fadeIn_0.3s_ease]">
|
||||||
|
<div className="absolute inset-0 bg-white/60" />
|
||||||
|
<div className="absolute left-1/2 top-6 z-10 -translate-x-1/2">
|
||||||
|
<div className="flex items-center gap-3 rounded-xl bg-green-600 px-6 py-3.5 text-white shadow-lg">
|
||||||
|
<svg
|
||||||
|
className="size-5 flex-shrink-0 animate-spin"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium">로그인 성공</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
510
react/src/features/auth/pages/SignupPage.tsx
Normal file
510
react/src/features/auth/pages/SignupPage.tsx
Normal file
|
|
@ -0,0 +1,510 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { signup } from "@/api/auth.api";
|
||||||
|
import type { ApiError } from "@/types/api";
|
||||||
|
import useShake from "@/hooks/useShake";
|
||||||
|
|
||||||
|
/* ───── 정규식 ───── */
|
||||||
|
const pwRegex =
|
||||||
|
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,}$/;
|
||||||
|
const nameRegex = /^[가-힣]{2,}$/;
|
||||||
|
const phoneRegex = /^\d{2,3}-\d{3,4}-\d{4}$/;
|
||||||
|
|
||||||
|
/* ───── zod 스키마 ───── */
|
||||||
|
const signupSchema = z
|
||||||
|
.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, "이메일을 입력해주세요.")
|
||||||
|
.email("올바른 이메일 형식이 아닙니다."),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(1, "비밀번호를 입력해주세요.")
|
||||||
|
.regex(pwRegex, "8자 이상, 영문/숫자/특수문자 포함"),
|
||||||
|
passwordConfirm: z.string().min(1, "비밀번호 확인을 입력해주세요."),
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, "이름을 입력해주세요.")
|
||||||
|
.regex(nameRegex, "한글 2자 이상 입력해주세요."),
|
||||||
|
phone: z
|
||||||
|
.string()
|
||||||
|
.min(1, "전화번호를 입력해주세요.")
|
||||||
|
.regex(phoneRegex, "올바른 전화번호 형식이 아닙니다."),
|
||||||
|
agree: z.literal(true, { message: "약관에 동의해주세요." }),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.passwordConfirm, {
|
||||||
|
message: "비밀번호가 일치하지 않습니다.",
|
||||||
|
path: ["passwordConfirm"],
|
||||||
|
});
|
||||||
|
|
||||||
|
type SignupForm = z.infer<typeof signupSchema>;
|
||||||
|
|
||||||
|
export default function SignupPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { triggerShake, cls } = useShake();
|
||||||
|
const [emailSentModal, setEmailSentModal] = useState(false);
|
||||||
|
const [termsModal, setTermsModal] = useState(false);
|
||||||
|
const [privacyModal, setPrivacyModal] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
setError,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<SignupForm>({
|
||||||
|
resolver: zodResolver(signupSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: { agree: false as unknown as true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const password = watch("password");
|
||||||
|
const passwordConfirm = watch("passwordConfirm");
|
||||||
|
|
||||||
|
/* 비밀번호 일치 상태 */
|
||||||
|
const pwMatchStatus =
|
||||||
|
!passwordConfirm
|
||||||
|
? null
|
||||||
|
: passwordConfirm === password
|
||||||
|
? "match"
|
||||||
|
: "mismatch";
|
||||||
|
|
||||||
|
/* 전화번호 자동 하이픈 */
|
||||||
|
const handlePhoneInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
let v = e.target.value.replace(/[^0-9]/g, "");
|
||||||
|
if (v.length > 3 && v.length <= 7) {
|
||||||
|
v = v.slice(0, 3) + "-" + v.slice(3);
|
||||||
|
} else if (v.length > 7) {
|
||||||
|
v = v.slice(0, 3) + "-" + v.slice(3, 7) + "-" + v.slice(7, 11);
|
||||||
|
}
|
||||||
|
setValue("phone", v, { shouldValidate: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 가입 요청 */
|
||||||
|
const onSubmit = async (data: SignupForm) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setServerError(null);
|
||||||
|
try {
|
||||||
|
await signup({
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
name: data.name,
|
||||||
|
phone: data.phone,
|
||||||
|
agreeTerms: data.agree,
|
||||||
|
agreePrivacy: data.agree,
|
||||||
|
});
|
||||||
|
setEmailSentModal(true);
|
||||||
|
} catch (err) {
|
||||||
|
const axiosErr = err as AxiosError<ApiError>;
|
||||||
|
const code = axiosErr.response?.data?.code;
|
||||||
|
const msg = axiosErr.response?.data?.msg;
|
||||||
|
|
||||||
|
if (axiosErr.response?.status === 409 || code === "EMAIL_DUPLICATE") {
|
||||||
|
setError("email", { message: msg ?? "이미 사용 중인 이메일입니다." });
|
||||||
|
triggerShake(["email"]);
|
||||||
|
} else {
|
||||||
|
setServerError(msg ?? "회원가입에 실패했습니다. 다시 시도해주세요.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 유효성 실패 → shake */
|
||||||
|
const onError = (fieldErrors: typeof errors) => {
|
||||||
|
triggerShake(Object.keys(fieldErrors));
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 힌트 스타일 헬퍼 */
|
||||||
|
const hintClass = (field: keyof SignupForm, defaultMsg: string) => {
|
||||||
|
const err = errors[field];
|
||||||
|
const isError = !!err;
|
||||||
|
return {
|
||||||
|
className: `mt-1.5 flex items-center gap-1.5 text-xs ${isError ? "text-red-600" : "text-gray-500"}`,
|
||||||
|
icon: isError ? "error" : "info",
|
||||||
|
message: isError ? (err.message as string) : defaultMsg,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 입력 필드 공통 className */
|
||||||
|
const inputClass = (field: keyof SignupForm) =>
|
||||||
|
`w-full rounded border bg-white px-4 py-3 font-sans text-sm text-gray-900 placeholder-gray-400 transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary ${
|
||||||
|
errors[field]
|
||||||
|
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
|
||||||
|
: "border-gray-300"
|
||||||
|
} ${cls(field)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="my-auto flex w-full max-w-[500px] flex-col items-center">
|
||||||
|
{/* 로고 */}
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<h1 className="mb-2 text-4xl font-black tracking-tight text-primary">
|
||||||
|
SPMS
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm font-medium text-gray-400">
|
||||||
|
Stein Push Message Service
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 */}
|
||||||
|
<div className="w-full rounded-xl bg-white p-8 shadow-2xl md:p-10">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">회원가입</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
사용자 정보를 입력해주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="rounded-lg p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-2xl">close</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 폼 */}
|
||||||
|
<form
|
||||||
|
className="space-y-5"
|
||||||
|
onSubmit={handleSubmit(onSubmit, onError)}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
{/* 서버 에러 */}
|
||||||
|
{serverError && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span>{serverError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 이메일 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-bold text-gray-700">
|
||||||
|
이메일
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="user@spms.com"
|
||||||
|
className={inputClass("email")}
|
||||||
|
{...register("email")}
|
||||||
|
/>
|
||||||
|
{(() => {
|
||||||
|
const h = hintClass(
|
||||||
|
"email",
|
||||||
|
"인증 메일이 발송되므로 사용 가능한 이메일을 입력해주세요.",
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className={h.className}>
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
{h.icon}
|
||||||
|
</span>
|
||||||
|
<span>{h.message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀번호 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-bold text-gray-700">
|
||||||
|
비밀번호
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="비밀번호를 입력하세요"
|
||||||
|
className={inputClass("password")}
|
||||||
|
{...register("password")}
|
||||||
|
/>
|
||||||
|
{(() => {
|
||||||
|
const h = hintClass(
|
||||||
|
"password",
|
||||||
|
"8자 이상, 영문/숫자/특수문자 포함",
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className={h.className}>
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
{h.icon}
|
||||||
|
</span>
|
||||||
|
<span>{h.message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀번호 확인 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-bold text-gray-700">
|
||||||
|
비밀번호 확인
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="비밀번호를 다시 입력하세요"
|
||||||
|
className={inputClass("passwordConfirm")}
|
||||||
|
{...register("passwordConfirm")}
|
||||||
|
/>
|
||||||
|
{pwMatchStatus === "mismatch" && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span>비밀번호가 일치하지 않습니다.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pwMatchStatus === "match" && !errors.passwordConfirm && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-green-600">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
check_circle
|
||||||
|
</span>
|
||||||
|
<span>비밀번호가 일치합니다.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="my-1 border-t border-gray-200" />
|
||||||
|
|
||||||
|
{/* 이름 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-bold text-gray-700">
|
||||||
|
이름
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder="홍길동"
|
||||||
|
className={inputClass("name")}
|
||||||
|
{...register("name")}
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span>{errors.name.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 전화번호 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-bold text-gray-700">
|
||||||
|
전화번호
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
maxLength={13}
|
||||||
|
placeholder="010-0000-0000"
|
||||||
|
className={inputClass("phone")}
|
||||||
|
{...register("phone", { onChange: handlePhoneInput })}
|
||||||
|
/>
|
||||||
|
{errors.phone && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span>{errors.phone.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 약관 동의 */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<label className="group flex cursor-pointer items-center gap-2.5">
|
||||||
|
<div className="relative flex flex-shrink-0 items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className={`peer size-5 cursor-pointer appearance-none rounded border-2 transition-all checked:border-primary checked:bg-primary ${
|
||||||
|
errors.agree ? "border-red-500" : "border-gray-300"
|
||||||
|
} ${cls("agree")}`}
|
||||||
|
{...register("agree")}
|
||||||
|
/>
|
||||||
|
<span className="material-symbols-outlined pointer-events-none absolute text-sm text-white opacity-0 peer-checked:opacity-100">
|
||||||
|
check
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-sm transition-colors ${
|
||||||
|
errors.agree ? "text-red-500" : "text-gray-600"
|
||||||
|
} ${cls("agree")}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTermsModal(true)}
|
||||||
|
className="font-semibold text-primary underline hover:text-[#1d4ed8]"
|
||||||
|
>
|
||||||
|
서비스 이용약관
|
||||||
|
</button>
|
||||||
|
{" 및 "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPrivacyModal(true)}
|
||||||
|
className="font-semibold text-primary underline hover:text-[#1d4ed8]"
|
||||||
|
>
|
||||||
|
개인정보 처리방침
|
||||||
|
</button>
|
||||||
|
에 동의합니다.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 가입 버튼 */}
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary py-3.5 px-4 font-bold text-white shadow-lg shadow-blue-500/20 transition-all hover:bg-[#1d4ed8] active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<span className="material-symbols-outlined animate-spin text-lg">
|
||||||
|
progress_activity
|
||||||
|
</span>
|
||||||
|
가입 처리 중…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"가입하기"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 하단 로그인 링크 */}
|
||||||
|
<div className="mt-8 border-t border-gray-100 pt-6 text-center">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
이미 계정이 있으신가요?
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="ml-1 font-semibold text-primary hover:text-[#1d4ed8] hover:underline"
|
||||||
|
>
|
||||||
|
로그인하기
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ───── 인증 메일 전송 모달 ───── */}
|
||||||
|
{emailSentModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-green-100">
|
||||||
|
<span className="material-symbols-outlined text-xl text-green-600">
|
||||||
|
mark_email_read
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-foreground">
|
||||||
|
인증 메일을 전송했습니다
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-sm text-foreground">
|
||||||
|
입력하신 이메일로 인증 링크를 보내드렸습니다.
|
||||||
|
</p>
|
||||||
|
<div className="mb-5 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-blue-700">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>메일을 확인하여 가입을 완료해주세요.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/login", { replace: true })}
|
||||||
|
className="rounded bg-primary px-5 py-2.5 text-sm font-medium text-white shadow-sm shadow-primary/30 transition hover:bg-[#1d4ed8]"
|
||||||
|
>
|
||||||
|
로그인하러 가기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ───── 서비스 이용약관 모달 ───── */}
|
||||||
|
{termsModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setTermsModal(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100">
|
||||||
|
<span className="material-symbols-outlined text-xl text-blue-600">
|
||||||
|
description
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="flex-1 text-lg font-bold text-foreground">
|
||||||
|
서비스 이용약관
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTermsModal(false)}
|
||||||
|
className="flex-shrink-0 rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: "20px" }}>close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ───── 개인정보 처리방침 모달 ───── */}
|
||||||
|
{privacyModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setPrivacyModal(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100">
|
||||||
|
<span className="material-symbols-outlined text-xl text-blue-600">
|
||||||
|
shield
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="flex-1 text-lg font-bold text-foreground">
|
||||||
|
개인정보 처리방침
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPrivacyModal(false)}
|
||||||
|
className="flex-shrink-0 rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: "20px" }}>close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
403
react/src/features/auth/pages/VerifyEmailPage.tsx
Normal file
403
react/src/features/auth/pages/VerifyEmailPage.tsx
Normal file
|
|
@ -0,0 +1,403 @@
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { verifyEmail, resendVerifyEmail } from "@/api/auth.api";
|
||||||
|
import type { ApiError } from "@/types/api";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import useShake from "@/hooks/useShake";
|
||||||
|
|
||||||
|
interface LocationState {
|
||||||
|
verifySessionId?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
admin?: { admin_code: string | null; email: string | null; name: string | null; role: string | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VerifyEmailPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const setAuth = useAuthStore((s) => s.setAuth);
|
||||||
|
const state = (location.state as LocationState) ?? {};
|
||||||
|
const verifySessionId = state.verifySessionId ?? null;
|
||||||
|
|
||||||
|
const [code, setCode] = useState<string[]>(Array(6).fill(""));
|
||||||
|
const [codeError, setCodeError] = useState<string | null>(null);
|
||||||
|
const { triggerShake, cls } = useShake();
|
||||||
|
const [successModal, setSuccessModal] = useState(false);
|
||||||
|
const [resendModal, setResendModal] = useState(false);
|
||||||
|
const [homeOverlay, setHomeOverlay] = useState(false);
|
||||||
|
const [resendCooldown, setResendCooldown] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||||
|
|
||||||
|
/* 첫 번째 칸에 포커스 */
|
||||||
|
useEffect(() => {
|
||||||
|
inputRefs.current[0]?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* 재전송 쿨다운 타이머 */
|
||||||
|
useEffect(() => {
|
||||||
|
if (resendCooldown <= 0) return;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setResendCooldown((prev) => prev - 1);
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [resendCooldown]);
|
||||||
|
|
||||||
|
const isFilled = code.every((c) => c.length === 1);
|
||||||
|
|
||||||
|
/* 입력 처리 */
|
||||||
|
const handleInput = (index: number, value: string) => {
|
||||||
|
const digit = value.replace(/[^0-9]/g, "");
|
||||||
|
const newCode = [...code];
|
||||||
|
newCode[index] = digit;
|
||||||
|
setCode(newCode);
|
||||||
|
setCodeError(null);
|
||||||
|
|
||||||
|
if (digit && index < 5) {
|
||||||
|
inputRefs.current[index + 1]?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 포커스 시 해당 칸부터 끝까지 초기화 */
|
||||||
|
const handleFocus = (index: number) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setCode((prev) => {
|
||||||
|
const newCode = [...prev];
|
||||||
|
for (let i = index; i < 6; i++) {
|
||||||
|
newCode[i] = "";
|
||||||
|
}
|
||||||
|
return newCode;
|
||||||
|
});
|
||||||
|
setCodeError(null);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 백스페이스 → 이전 칸 */
|
||||||
|
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Backspace" && !code[index] && index > 0) {
|
||||||
|
inputRefs.current[index - 1]?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 붙여넣기 */
|
||||||
|
const handlePaste = (e: React.ClipboardEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const paste = e.clipboardData.getData("text").replace(/[^0-9]/g, "");
|
||||||
|
if (paste.length >= 6) {
|
||||||
|
const newCode = paste.slice(0, 6).split("");
|
||||||
|
setCode(newCode);
|
||||||
|
setCodeError(null);
|
||||||
|
inputRefs.current[5]?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 인증하기 */
|
||||||
|
const handleVerify = async () => {
|
||||||
|
const fullCode = code.join("");
|
||||||
|
if (fullCode.length !== 6) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await verifyEmail({
|
||||||
|
code: fullCode,
|
||||||
|
verify_session_id: verifySessionId,
|
||||||
|
});
|
||||||
|
setSuccessModal(true);
|
||||||
|
} catch (err) {
|
||||||
|
const axiosErr = err as AxiosError<ApiError>;
|
||||||
|
const msg = axiosErr.response?.data?.msg;
|
||||||
|
setCodeError(msg ?? "인증 코드가 올바르지 않습니다.");
|
||||||
|
triggerShake(["code"]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 재전송 */
|
||||||
|
const handleResend = async () => {
|
||||||
|
if (resendCooldown > 0 || !verifySessionId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await resendVerifyEmail({
|
||||||
|
verify_session_id: verifySessionId,
|
||||||
|
});
|
||||||
|
const d = res.data.data;
|
||||||
|
setResendModal(true);
|
||||||
|
setResendCooldown(d.cooldown_seconds || 60);
|
||||||
|
setCode(Array(6).fill(""));
|
||||||
|
setCodeError(null);
|
||||||
|
inputRefs.current[0]?.focus();
|
||||||
|
} catch (err) {
|
||||||
|
const axiosErr = err as AxiosError<ApiError>;
|
||||||
|
const msg = axiosErr.response?.data?.msg;
|
||||||
|
setCodeError(msg ?? "재전송에 실패했습니다. 잠시 후 다시 시도해주세요.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 재전송 모달 닫기 */
|
||||||
|
const closeResendModal = () => {
|
||||||
|
setResendModal(false);
|
||||||
|
inputRefs.current[0]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 홈 이동 */
|
||||||
|
const goHome = () => {
|
||||||
|
setSuccessModal(false);
|
||||||
|
setHomeOverlay(true);
|
||||||
|
|
||||||
|
/* 인증 완료 → 인증 상태 저장 */
|
||||||
|
if (state.admin) {
|
||||||
|
setAuth(
|
||||||
|
{
|
||||||
|
adminCode: state.admin.admin_code ?? "",
|
||||||
|
email: state.admin.email ?? "",
|
||||||
|
name: state.admin.name ?? "",
|
||||||
|
role: state.admin.role ?? "",
|
||||||
|
},
|
||||||
|
state.accessToken ?? "",
|
||||||
|
state.refreshToken ?? "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => navigate("/", { replace: true }), 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex w-full max-w-[460px] flex-col items-center">
|
||||||
|
{/* 로고 */}
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<h1 className="mb-2 text-4xl font-black tracking-tight text-primary">
|
||||||
|
SPMS
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm font-medium text-gray-400">
|
||||||
|
Stein Push Message Service
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 인증 카드 */}
|
||||||
|
<div className="w-full rounded-xl bg-white p-8 shadow-2xl md:p-10">
|
||||||
|
{/* 아이콘 + 제목 */}
|
||||||
|
<div className="mb-2 flex items-center gap-3">
|
||||||
|
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100">
|
||||||
|
<span className="material-symbols-outlined text-xl text-blue-600">
|
||||||
|
mark_email_read
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-bold text-foreground">이메일 인증</h2>
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-sm text-foreground">
|
||||||
|
가입하신 이메일로 전송된 인증 코드 6자리를 입력해주세요.
|
||||||
|
</p>
|
||||||
|
<div className="mb-6 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-blue-700">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>인증 코드는 발송 후 5분간 유효합니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 코드 입력 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="mb-1.5 block text-sm font-bold text-gray-700">
|
||||||
|
인증 코드
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{code.map((digit, i) => (
|
||||||
|
<input
|
||||||
|
key={i}
|
||||||
|
ref={(el) => {
|
||||||
|
inputRefs.current[i] = el;
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete={i === 0 ? "one-time-code" : "off"}
|
||||||
|
maxLength={1}
|
||||||
|
value={digit}
|
||||||
|
onChange={(e) => handleInput(i, e.target.value)}
|
||||||
|
onFocus={() => handleFocus(i)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(i, e)}
|
||||||
|
onPaste={i === 0 ? handlePaste : undefined}
|
||||||
|
className={`h-14 w-full rounded-lg border bg-white text-center font-sans text-2xl font-bold tracking-widest text-gray-900 transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary ${
|
||||||
|
codeError
|
||||||
|
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
|
||||||
|
: "border-gray-300"
|
||||||
|
} ${cls("code")}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{codeError && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span>{codeError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 인증하기 버튼 */}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!isFilled || isLoading}
|
||||||
|
onClick={handleVerify}
|
||||||
|
className="rounded bg-primary px-5 py-2.5 text-sm font-medium text-white shadow-sm shadow-primary/30 transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? "인증 중…" : "인증하기"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 재전송 */}
|
||||||
|
<div className="mt-5 border-t border-gray-100 pt-5 text-center">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
메일을 받지 못하셨나요?
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResend}
|
||||||
|
className={`ml-1 font-semibold text-primary transition hover:text-[#1d4ed8] hover:underline ${
|
||||||
|
resendCooldown > 0
|
||||||
|
? "pointer-events-none opacity-50"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
인증 코드 재전송
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
{resendCooldown > 0 && (
|
||||||
|
<p className="mt-1 text-xs text-gray-400">
|
||||||
|
{resendCooldown}초 후 재전송 가능
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 풋터 */}
|
||||||
|
<footer className="mt-12 text-xs font-medium tracking-wide text-gray-500">
|
||||||
|
© 2026 Stein Co., Ltd. All rights reserved.
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 인증 완료 모달 */}
|
||||||
|
{successModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-green-100">
|
||||||
|
<span className="material-symbols-outlined text-xl text-green-600">
|
||||||
|
check_circle
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-foreground">
|
||||||
|
인증이 완료되었습니다
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-sm text-foreground">
|
||||||
|
이메일 인증이 성공적으로 완료되었습니다.
|
||||||
|
</p>
|
||||||
|
<div className="mb-5 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-blue-700">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>이제 모든 서비스를 이용하실 수 있습니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goHome}
|
||||||
|
className="rounded bg-primary px-5 py-2.5 text-sm font-medium text-white shadow-sm shadow-primary/30 transition hover:bg-[#1d4ed8]"
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 재전송 완료 모달 */}
|
||||||
|
{resendModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-green-100">
|
||||||
|
<span className="material-symbols-outlined text-xl text-green-600">
|
||||||
|
forward_to_inbox
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-foreground">
|
||||||
|
인증 코드가 재전송되었습니다
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-sm text-foreground">
|
||||||
|
입력하신 이메일로 새로운 인증 코드를 발송했습니다.
|
||||||
|
</p>
|
||||||
|
<div className="mb-5 space-y-1.5 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-blue-700">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>이전 인증 코드는 더 이상 유효하지 않습니다.</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-blue-700">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}>
|
||||||
|
schedule
|
||||||
|
</span>
|
||||||
|
<span>{resendCooldown}초 후 재전송이 가능합니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeResendModal}
|
||||||
|
className="rounded bg-primary px-5 py-2.5 text-sm font-medium text-white shadow-sm shadow-primary/30 transition hover:bg-[#1d4ed8]"
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 홈 이동 오버레이 */}
|
||||||
|
{homeOverlay && (
|
||||||
|
<div className="fixed inset-0 z-[100] animate-[fadeIn_0.3s_ease]">
|
||||||
|
<div className="absolute inset-0 bg-white/60" />
|
||||||
|
<div className="absolute left-1/2 top-6 z-10 -translate-x-1/2">
|
||||||
|
<div className="flex items-center gap-3 rounded-xl bg-green-600 px-6 py-3.5 text-white shadow-lg">
|
||||||
|
<svg
|
||||||
|
className="size-5 flex-shrink-0 animate-spin"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium">인증 완료</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
react/src/features/auth/types.ts
Normal file
102
react/src/features/auth/types.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
/* ───── 회원가입 ───── */
|
||||||
|
|
||||||
|
export interface SignupRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
agreeTerms: boolean;
|
||||||
|
agreePrivacy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignupResponse {
|
||||||
|
admin_code: string | null;
|
||||||
|
email: string | null;
|
||||||
|
verify_session_id: string | null;
|
||||||
|
email_sent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── 이메일 중복 체크 ───── */
|
||||||
|
|
||||||
|
export interface EmailCheckRequest {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailCheckResponse {
|
||||||
|
email: string | null;
|
||||||
|
is_available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── 로그인 ───── */
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
access_token: string | null;
|
||||||
|
refresh_token: string | null;
|
||||||
|
expires_in: number;
|
||||||
|
next_action: "GO_DASHBOARD" | "VERIFY_EMAIL" | "CHANGE_PASSWORD" | null;
|
||||||
|
email_verified: boolean;
|
||||||
|
verify_session_id: string | null;
|
||||||
|
email_sent: boolean | null;
|
||||||
|
must_change_password: boolean | null;
|
||||||
|
admin: AdminInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminInfo {
|
||||||
|
admin_code: string | null;
|
||||||
|
email: string | null;
|
||||||
|
name: string | null;
|
||||||
|
role: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── 이메일 인증 ───── */
|
||||||
|
|
||||||
|
export interface EmailVerifyRequest {
|
||||||
|
code: string;
|
||||||
|
verify_session_id?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailVerifyResponse {
|
||||||
|
verified: boolean;
|
||||||
|
next_action: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailResendRequest {
|
||||||
|
verify_session_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailResendResponse {
|
||||||
|
resent: boolean;
|
||||||
|
cooldown_seconds: number;
|
||||||
|
expires_in_seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── 토큰 ───── */
|
||||||
|
|
||||||
|
export interface TokenRefreshRequest {
|
||||||
|
refresh_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenRefreshResponse {
|
||||||
|
access_token: string | null;
|
||||||
|
refresh_token: string | null;
|
||||||
|
expires_in: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── 로그아웃 ───── */
|
||||||
|
|
||||||
|
export interface LogoutResponse {
|
||||||
|
logged_out: boolean;
|
||||||
|
redirect_to: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── 비밀번호 ───── */
|
||||||
|
|
||||||
|
export interface TempPasswordRequest {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
67
react/src/features/dashboard/components/DashboardFilter.tsx
Normal file
67
react/src/features/dashboard/components/DashboardFilter.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import DateRangeInput from "@/components/common/DateRangeInput";
|
||||||
|
import FilterResetButton from "@/components/common/FilterResetButton";
|
||||||
|
|
||||||
|
export interface DashboardFilterValues {
|
||||||
|
dateStart: string;
|
||||||
|
dateEnd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardFilterProps {
|
||||||
|
onSearch?: (filter: DashboardFilterValues) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 오늘 날짜 YYYY-MM-DD */
|
||||||
|
function today() {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
/** 30일 전 */
|
||||||
|
function thirtyDaysAgo() {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - 30);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardFilter({ onSearch, loading }: DashboardFilterProps) {
|
||||||
|
const [dateStart, setDateStart] = useState(thirtyDaysAgo);
|
||||||
|
const [dateEnd, setDateEnd] = useState(today);
|
||||||
|
|
||||||
|
// 초기화
|
||||||
|
const handleReset = () => {
|
||||||
|
setDateStart(thirtyDaysAgo());
|
||||||
|
setDateEnd(today());
|
||||||
|
};
|
||||||
|
|
||||||
|
// 조회
|
||||||
|
const handleSearch = () => {
|
||||||
|
onSearch?.({ dateStart, dateEnd });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
|
||||||
|
<div className="flex items-end gap-4">
|
||||||
|
{/* 날짜 범위 */}
|
||||||
|
<DateRangeInput
|
||||||
|
startDate={dateStart}
|
||||||
|
endDate={dateEnd}
|
||||||
|
onStartChange={setDateStart}
|
||||||
|
onEndChange={setDateEnd}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 초기화 */}
|
||||||
|
<FilterResetButton onClick={handleReset} disabled={loading} />
|
||||||
|
|
||||||
|
{/* 조회 */}
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={loading}
|
||||||
|
className="h-[38px] flex-shrink-0 bg-gray-800 hover:bg-gray-900 active:scale-95 text-white rounded-lg px-5 text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
조회
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
react/src/features/dashboard/components/PlatformDonut.tsx
Normal file
87
react/src/features/dashboard/components/PlatformDonut.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
interface PlatformData {
|
||||||
|
ios: number;
|
||||||
|
android: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlatformDonutProps {
|
||||||
|
data?: PlatformData;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DATA: PlatformData = { ios: 45, android: 55 };
|
||||||
|
|
||||||
|
export default function PlatformDonut({ data = DEFAULT_DATA, loading }: PlatformDonutProps) {
|
||||||
|
const { ios, android } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg:col-span-1 bg-white rounded-xl shadow-sm border border-gray-200 p-6 flex flex-col relative">
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70 rounded-xl">
|
||||||
|
<svg className="size-7 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h2 className="text-base font-bold text-[#0f172a] mb-6">플랫폼 비율</h2>
|
||||||
|
|
||||||
|
{/* 도넛 차트 */}
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center relative">
|
||||||
|
<div className="relative size-48">
|
||||||
|
<svg className="size-full" viewBox="0 0 36 36">
|
||||||
|
{/* 배경 원 */}
|
||||||
|
<path
|
||||||
|
className="text-gray-100"
|
||||||
|
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
/>
|
||||||
|
{/* Android (teal) */}
|
||||||
|
<path
|
||||||
|
className="text-teal-400"
|
||||||
|
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeDasharray={`${android}, 100`}
|
||||||
|
strokeWidth="3"
|
||||||
|
/>
|
||||||
|
{/* iOS (primary blue) */}
|
||||||
|
<path
|
||||||
|
className="text-primary"
|
||||||
|
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeDasharray={`${ios}, 100`}
|
||||||
|
strokeDashoffset={`${-android}`}
|
||||||
|
strokeWidth="3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/* 중앙 텍스트 */}
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-3xl font-bold text-gray-900">Total</span>
|
||||||
|
<span className="text-sm text-gray-500">Devices</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 범례 */}
|
||||||
|
<div className="mt-8 flex flex-col gap-3 w-full px-4">
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="size-3 rounded-full bg-primary flex-shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-gray-600">iOS</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-bold text-gray-900">{ios}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="size-3 rounded-full bg-teal-400 flex-shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-gray-600">Android</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-bold text-gray-900">{android}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
react/src/features/dashboard/components/RecentMessages.tsx
Normal file
94
react/src/features/dashboard/components/RecentMessages.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import StatusBadge from "@/components/common/StatusBadge";
|
||||||
|
|
||||||
|
type StatusVariant = "success" | "error" | "warning" | "default";
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, StatusVariant> = {
|
||||||
|
완료: "success",
|
||||||
|
실패: "error",
|
||||||
|
진행: "warning",
|
||||||
|
예약: "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
type MessageStatus = keyof typeof STATUS_MAP;
|
||||||
|
|
||||||
|
interface RecentMessage {
|
||||||
|
template: string;
|
||||||
|
targetCount: string;
|
||||||
|
status: MessageStatus;
|
||||||
|
sentAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentMessagesProps {
|
||||||
|
messages?: RecentMessage[];
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MESSAGES: RecentMessage[] = [
|
||||||
|
{ template: "가을맞이 프로모션 알림", targetCount: "12,405", status: "완료", sentAt: "2026-02-15 14:00" },
|
||||||
|
{ template: "정기 점검 안내", targetCount: "45,100", status: "완료", sentAt: "2026-02-15 10:30" },
|
||||||
|
{ template: "비밀번호 변경 알림", targetCount: "1", status: "실패", sentAt: "2026-02-15 09:15" },
|
||||||
|
{ template: "신규 서비스 런칭", targetCount: "8,500", status: "진행", sentAt: "2026-02-15 09:00" },
|
||||||
|
{ template: "야간 푸시 마케팅", targetCount: "3,200", status: "예약", sentAt: "2026-02-15 20:00" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function RecentMessages({ messages = DEFAULT_MESSAGES, loading }: RecentMessagesProps) {
|
||||||
|
return (
|
||||||
|
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-200 flex flex-col overflow-hidden relative">
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70">
|
||||||
|
<svg className="size-7 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-5 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-bold text-[#0f172a]">최근 발송 내역</h2>
|
||||||
|
<Link
|
||||||
|
to="/statistics/history"
|
||||||
|
className="text-primary hover:text-[#1d4ed8] text-sm font-medium"
|
||||||
|
>
|
||||||
|
전체 보기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto flex-1">
|
||||||
|
<table className="w-full text-sm text-left h-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
템플릿명
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
타겟 수
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
상태
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
발송 시간
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<tr key={i} className="hover:bg-gray-50 transition-colors">
|
||||||
|
<td className="px-6 py-3.5 font-medium text-gray-900 text-center">
|
||||||
|
{msg.template}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3.5 text-center text-gray-600">{msg.targetCount}</td>
|
||||||
|
<td className="px-6 py-3.5 text-center">
|
||||||
|
<StatusBadge
|
||||||
|
variant={STATUS_MAP[msg.status] ?? "default"}
|
||||||
|
label={msg.status}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3.5 text-center text-gray-500">{msg.sentAt}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
react/src/features/dashboard/components/StatsCards.tsx
Normal file
84
react/src/features/dashboard/components/StatsCards.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
interface StatCard {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
/** 값 뒤에 붙는 단위 (예: "%") */
|
||||||
|
unit?: string;
|
||||||
|
/** 우상단 뱃지 */
|
||||||
|
badge?: { type: "trend"; value: string } | { type: "icon"; icon: string; color: string };
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatsCardsProps {
|
||||||
|
cards?: StatCard[];
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 하드코딩 기본 데이터 */
|
||||||
|
const DEFAULT_CARDS: StatCard[] = [
|
||||||
|
{
|
||||||
|
label: "오늘 발송 수",
|
||||||
|
value: "12,847",
|
||||||
|
badge: { type: "trend", value: "15%" },
|
||||||
|
link: "/statistics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "성공률",
|
||||||
|
value: "98.7",
|
||||||
|
unit: "%",
|
||||||
|
badge: { type: "icon", icon: "check_circle", color: "bg-green-100 text-green-600" },
|
||||||
|
link: "/statistics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "등록 기기 수",
|
||||||
|
value: "45,230",
|
||||||
|
badge: { type: "icon", icon: "devices", color: "bg-blue-50 text-primary" },
|
||||||
|
link: "/devices",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "활성 서비스",
|
||||||
|
value: "8",
|
||||||
|
badge: { type: "icon", icon: "grid_view", color: "bg-purple-50 text-purple-600" },
|
||||||
|
link: "/services",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function StatsCards({ cards = DEFAULT_CARDS, loading }: StatsCardsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<Link
|
||||||
|
key={card.label}
|
||||||
|
to={card.link}
|
||||||
|
className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 flex flex-col justify-between h-28 hover:shadow-md hover:border-primary/30 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-xs font-medium text-gray-500">{card.label}</h4>
|
||||||
|
{card.badge?.type === "trend" && (
|
||||||
|
<div className="bg-green-50 text-green-700 text-[10px] font-bold px-1.5 py-0.5 rounded-full inline-flex items-center gap-0.5">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: "10px" }}>trending_up</span>
|
||||||
|
{card.badge.value}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{card.badge?.type === "icon" && (
|
||||||
|
<div className={`${card.badge.color} rounded-full size-6 inline-flex items-center justify-center`}>
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: "14px" }}>{card.badge.icon}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-8 w-24 rounded bg-gray-100 animate-pulse" />
|
||||||
|
) : (
|
||||||
|
<div className="text-2xl font-bold text-gray-900">
|
||||||
|
{card.value}
|
||||||
|
{card.unit && (
|
||||||
|
<span className="text-base text-gray-400 ml-0.5 font-normal">{card.unit}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
300
react/src/features/dashboard/components/WeeklyChart.tsx
Normal file
300
react/src/features/dashboard/components/WeeklyChart.tsx
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
import { useRef, useEffect, useCallback, useState } from "react";
|
||||||
|
|
||||||
|
interface ChartDataPoint {
|
||||||
|
label: string;
|
||||||
|
/** Y축 비율 (0=최상단, 1=최하단) — 0이 최대값 */
|
||||||
|
blue: number;
|
||||||
|
green: number;
|
||||||
|
sent: string;
|
||||||
|
reach: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeeklyChartProps {
|
||||||
|
data?: ChartDataPoint[];
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 하드코딩 기본 데이터 (HTML 시안과 동일) */
|
||||||
|
const DEFAULT_DATA: ChartDataPoint[] = [
|
||||||
|
{ label: "02.09", blue: 0.75, green: 0.8, sent: "3,750", reach: "3,000" },
|
||||||
|
{ label: "02.10", blue: 0.6, green: 0.675, sent: "6,000", reach: "4,875" },
|
||||||
|
{ label: "02.11", blue: 0.4, green: 0.475, sent: "9,000", reach: "7,875" },
|
||||||
|
{ label: "02.12", blue: 0.5, green: 0.575, sent: "7,500", reach: "6,375" },
|
||||||
|
{ label: "02.13", blue: 0.2, green: 0.275, sent: "12,000", reach: "10,875" },
|
||||||
|
{ label: "02.14", blue: 0.3, green: 0.35, sent: "10,500", reach: "9,750" },
|
||||||
|
{ label: "Today", blue: 0.1, green: 0.175, sent: "13,500", reach: "12,375" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MARGIN = 0.02;
|
||||||
|
|
||||||
|
export default function WeeklyChart({ data = DEFAULT_DATA, loading }: WeeklyChartProps) {
|
||||||
|
const areaRef = useRef<HTMLDivElement>(null);
|
||||||
|
const animatedRef = useRef(false);
|
||||||
|
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const count = data.length;
|
||||||
|
const xRatios =
|
||||||
|
count === 1
|
||||||
|
? [MARGIN]
|
||||||
|
: data.map((_, i) => MARGIN + (i / (count - 1)) * (1 - MARGIN * 2));
|
||||||
|
|
||||||
|
const drawChart = useCallback(() => {
|
||||||
|
const area = areaRef.current;
|
||||||
|
if (!area || count === 0) return;
|
||||||
|
|
||||||
|
// 기존 SVG + 점 제거
|
||||||
|
area.querySelectorAll("svg, .chart-dot").forEach((el) => el.remove());
|
||||||
|
|
||||||
|
const W = area.offsetWidth;
|
||||||
|
const H = area.offsetHeight;
|
||||||
|
if (W === 0 || H === 0) return;
|
||||||
|
|
||||||
|
const toPixel = (xr: number, yr: number): [number, number] => [
|
||||||
|
Math.round(xr * W * 10) / 10,
|
||||||
|
Math.round(yr * H * 10) / 10,
|
||||||
|
];
|
||||||
|
|
||||||
|
const bluePoints = xRatios.map((x, i) => toPixel(x, data[i].blue));
|
||||||
|
const greenPoints = xRatios.map((x, i) => toPixel(x, data[i].green));
|
||||||
|
|
||||||
|
const NS = "http://www.w3.org/2000/svg";
|
||||||
|
const svg = document.createElementNS(NS, "svg");
|
||||||
|
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
||||||
|
svg.style.cssText = "position:absolute;inset:0;width:100%;height:100%;";
|
||||||
|
|
||||||
|
function makePath(points: [number, number][], color: string) {
|
||||||
|
if (points.length < 2) return null;
|
||||||
|
const path = document.createElementNS(NS, "path");
|
||||||
|
path.setAttribute(
|
||||||
|
"d",
|
||||||
|
points.map((p, i) => `${i ? "L" : "M"}${p[0]},${p[1]}`).join(" "),
|
||||||
|
);
|
||||||
|
path.setAttribute("fill", "none");
|
||||||
|
path.setAttribute("stroke", color);
|
||||||
|
path.setAttribute("stroke-width", "2.5");
|
||||||
|
path.setAttribute("stroke-linecap", "round");
|
||||||
|
path.setAttribute("stroke-linejoin", "round");
|
||||||
|
svg.appendChild(path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bluePath = makePath(bluePoints, "#2563EB");
|
||||||
|
const greenPath = makePath(greenPoints, "#22C55E");
|
||||||
|
area.appendChild(svg);
|
||||||
|
|
||||||
|
// 애니메이션 (최초 1회만)
|
||||||
|
const DURATION = 2000;
|
||||||
|
|
||||||
|
function getCumulativeRatios(pts: [number, number][]) {
|
||||||
|
const d = [0];
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
const dx = pts[i][0] - pts[i - 1][0];
|
||||||
|
const dy = pts[i][1] - pts[i - 1][1];
|
||||||
|
d.push(d[i - 1] + Math.sqrt(dx * dx + dy * dy));
|
||||||
|
}
|
||||||
|
const t = d[d.length - 1];
|
||||||
|
return t > 0 ? d.map((v) => v / t) : d.map(() => 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function easeOutTime(r: number) {
|
||||||
|
return 1 - Math.sqrt(1 - r);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLine(
|
||||||
|
path: SVGPathElement | null,
|
||||||
|
points: [number, number][],
|
||||||
|
color: string,
|
||||||
|
) {
|
||||||
|
if (path && !animatedRef.current) {
|
||||||
|
const len = path.getTotalLength();
|
||||||
|
path.style.strokeDasharray = String(len);
|
||||||
|
path.style.strokeDashoffset = String(len);
|
||||||
|
path.getBoundingClientRect(); // 강제 리플로
|
||||||
|
path.style.transition = `stroke-dashoffset ${DURATION}ms ease-out`;
|
||||||
|
path.style.strokeDashoffset = "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratios = getCumulativeRatios(points);
|
||||||
|
points.forEach(([px, py], i) => {
|
||||||
|
const dot = document.createElement("div");
|
||||||
|
dot.className = "chart-dot";
|
||||||
|
dot.style.left = px + "px";
|
||||||
|
dot.style.top = py + "px";
|
||||||
|
dot.style.borderColor = color;
|
||||||
|
area!.appendChild(dot);
|
||||||
|
|
||||||
|
if (!animatedRef.current && path) {
|
||||||
|
setTimeout(() => dot.classList.add("show"), easeOutTime(ratios[i]) * DURATION);
|
||||||
|
} else {
|
||||||
|
dot.classList.add("show");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLine(bluePath, bluePoints, "#2563EB");
|
||||||
|
renderLine(greenPath, greenPoints, "#22C55E");
|
||||||
|
|
||||||
|
animatedRef.current = true;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
/* data가 바뀌면 애니메이션 재생 */
|
||||||
|
useEffect(() => {
|
||||||
|
animatedRef.current = false;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
drawChart();
|
||||||
|
|
||||||
|
let resizeTimer: ReturnType<typeof setTimeout>;
|
||||||
|
const handleResize = () => {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(drawChart, 150);
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
};
|
||||||
|
}, [drawChart]);
|
||||||
|
|
||||||
|
// 호버존 계산 (CSS 기반)
|
||||||
|
const getHoverZoneStyle = (i: number): React.CSSProperties => {
|
||||||
|
if (count <= 1) return { left: 0, width: "100%" };
|
||||||
|
const halfGap =
|
||||||
|
i === 0
|
||||||
|
? ((xRatios[1] - xRatios[0]) / 2) * 100
|
||||||
|
: i === count - 1
|
||||||
|
? ((xRatios[count - 1] - xRatios[count - 2]) / 2) * 100
|
||||||
|
: ((xRatios[i + 1] - xRatios[i - 1]) / 4) * 100;
|
||||||
|
return {
|
||||||
|
left: `${(xRatios[i] * 100 - halfGap).toFixed(2)}%`,
|
||||||
|
width: `${(halfGap * 2).toFixed(2)}%`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-base font-bold text-[#0f172a]">최근 7일 발송 추이</h2>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-72 flex items-center justify-center">
|
||||||
|
<p className="text-sm text-gray-400">발송 내역이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-base font-bold text-[#0f172a]">최근 7일 발송 추이</h2>
|
||||||
|
<div className="flex items-center gap-4 text-xs font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="size-3 rounded-full bg-primary" />
|
||||||
|
<span className="text-gray-600">발송</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="size-3 rounded-full bg-green-500" />
|
||||||
|
<span className="text-gray-600">도달</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 차트 영역 */}
|
||||||
|
<div className="w-full h-72 relative">
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70 rounded-lg">
|
||||||
|
<svg className="size-7 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Y축 라벨 + 그리드 */}
|
||||||
|
<div className="absolute inset-0 flex flex-col justify-between text-xs text-gray-400 pb-6 pr-4 pointer-events-none">
|
||||||
|
<div className="flex items-center w-full">
|
||||||
|
<span className="w-8 text-right mr-2">15k</span>
|
||||||
|
<div className="flex-1 h-px bg-gray-100" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center w-full">
|
||||||
|
<span className="w-8 text-right mr-2">10k</span>
|
||||||
|
<div className="flex-1 h-px bg-gray-100" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center w-full">
|
||||||
|
<span className="w-8 text-right mr-2">5k</span>
|
||||||
|
<div className="flex-1 h-px bg-gray-100" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center w-full">
|
||||||
|
<span className="w-8 text-right mr-2">0</span>
|
||||||
|
<div className="flex-1 h-px bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SVG 차트 영역 */}
|
||||||
|
<div ref={areaRef} className="absolute top-1 bottom-6 left-10 right-4">
|
||||||
|
{/* 호버존 */}
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<div
|
||||||
|
key={d.label}
|
||||||
|
className="absolute top-0 bottom-0 cursor-pointer z-[5]"
|
||||||
|
style={getHoverZoneStyle(i)}
|
||||||
|
onMouseEnter={() => setHoverIndex(i)}
|
||||||
|
onMouseLeave={() => setHoverIndex(null)}
|
||||||
|
>
|
||||||
|
{/* 가이드라인 */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 w-px left-1/2 bg-gray-300 pointer-events-none transition-opacity duration-150"
|
||||||
|
style={{ opacity: hoverIndex === i ? 1 : 0 }}
|
||||||
|
/>
|
||||||
|
{/* 툴팁 */}
|
||||||
|
<div
|
||||||
|
className="absolute left-1/2 pointer-events-none z-10 transition-all duration-150"
|
||||||
|
style={{
|
||||||
|
top: `${Math.max(0, Math.min(d.blue, d.green) * 100 - 5)}%`,
|
||||||
|
transform: `translateX(-50%) translateY(${hoverIndex === i ? 0 : 4}px)`,
|
||||||
|
opacity: hoverIndex === i ? 1 : 0,
|
||||||
|
visibility: hoverIndex === i ? "visible" : "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg shadow-lg px-4 py-3 min-w-[140px]">
|
||||||
|
<p className="text-xs font-bold text-gray-900 mb-2 pb-1.5 border-b border-gray-100">
|
||||||
|
{d.label} 발송 추이
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="size-2.5 rounded-full bg-[#2563EB] flex-shrink-0" />
|
||||||
|
<span className="text-xs text-gray-500 flex-1">발송</span>
|
||||||
|
<span className="text-xs font-semibold text-gray-900">{d.sent}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="size-2.5 rounded-full bg-[#22C55E] flex-shrink-0" />
|
||||||
|
<span className="text-xs text-gray-500 flex-1">도달</span>
|
||||||
|
<span className="text-xs font-semibold text-gray-900">{d.reach}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* X축 날짜 라벨 */}
|
||||||
|
<div className="absolute bottom-0 left-10 right-4 h-5 text-xs text-gray-400">
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<span
|
||||||
|
key={d.label}
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
left: `${xRatios[i] * 100}%`,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{d.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
react/src/features/dashboard/hooks/.gitkeep
Normal file
0
react/src/features/dashboard/hooks/.gitkeep
Normal file
249
react/src/features/dashboard/pages/DashboardPage.tsx
Normal file
249
react/src/features/dashboard/pages/DashboardPage.tsx
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import PageHeader from "@/components/common/PageHeader";
|
||||||
|
import DashboardFilter from "../components/DashboardFilter";
|
||||||
|
import type { DashboardFilterValues } from "../components/DashboardFilter";
|
||||||
|
import StatsCards from "../components/StatsCards";
|
||||||
|
import WeeklyChart from "../components/WeeklyChart";
|
||||||
|
import RecentMessages from "../components/RecentMessages";
|
||||||
|
import PlatformDonut from "../components/PlatformDonut";
|
||||||
|
import { fetchDashboard } from "@/api/dashboard.api";
|
||||||
|
import { formatNumber } from "@/utils/format";
|
||||||
|
import type { DashboardData } from "../types";
|
||||||
|
|
||||||
|
/** KPI → StatsCards props 변환 */
|
||||||
|
function mapCards(data: DashboardData) {
|
||||||
|
const { kpi } = data;
|
||||||
|
const successRate =
|
||||||
|
kpi.total_send > 0
|
||||||
|
? +(kpi.total_success / kpi.total_send * 100).toFixed(1)
|
||||||
|
: 0;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "오늘 발송 수",
|
||||||
|
value: formatNumber(kpi.total_send),
|
||||||
|
badge: { type: "trend" as const, value: `${Math.abs(kpi.today_sent_change_rate)}%` },
|
||||||
|
link: "/statistics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "성공률",
|
||||||
|
value: successRate.toFixed(1),
|
||||||
|
unit: "%",
|
||||||
|
badge: { type: "icon" as const, icon: "check_circle", color: "bg-green-100 text-green-600" },
|
||||||
|
link: "/statistics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "등록 기기 수",
|
||||||
|
value: formatNumber(kpi.total_devices),
|
||||||
|
badge: { type: "icon" as const, icon: "devices", color: "bg-blue-50 text-primary" },
|
||||||
|
link: "/devices",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "활성 서비스",
|
||||||
|
value: String(kpi.active_service_count),
|
||||||
|
badge: { type: "icon" as const, icon: "grid_view", color: "bg-purple-50 text-purple-600" },
|
||||||
|
link: "/services",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** daily → WeeklyChart props 변환 (최근 7일 기준, 없는 날짜는 0으로 채움) */
|
||||||
|
function mapChart(data: DashboardData) {
|
||||||
|
const allTrends = data.daily ?? [];
|
||||||
|
|
||||||
|
// 최근 7일 날짜 목록 생성 (오늘 포함)
|
||||||
|
const today = new Date();
|
||||||
|
const todayStr = today.toISOString().slice(0, 10);
|
||||||
|
const last7Days = Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(today.getDate() - 6 + i);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataMap = new Map(allTrends.map((t) => [t.stat_date, t]));
|
||||||
|
|
||||||
|
// 없는 날짜는 send_count 0으로 채움
|
||||||
|
const trends = last7Days.map(
|
||||||
|
(date) =>
|
||||||
|
dataMap.get(date) ?? {
|
||||||
|
stat_date: date,
|
||||||
|
send_count: 0,
|
||||||
|
success_count: 0,
|
||||||
|
fail_count: 0,
|
||||||
|
open_count: 0,
|
||||||
|
ctr: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const maxVal = Math.max(
|
||||||
|
...trends.map((t) => Math.max(t.send_count, t.success_count)),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
return trends.map((t) => {
|
||||||
|
const dateStr = t.stat_date ?? "";
|
||||||
|
const isToday = dateStr === todayStr;
|
||||||
|
const mm = dateStr.slice(5, 7);
|
||||||
|
const dd = dateStr.slice(8, 10);
|
||||||
|
return {
|
||||||
|
label: isToday ? "Today" : `${mm}.${dd}`,
|
||||||
|
blue: 1 - t.send_count / maxVal, // Y축 비율 (0=최상단)
|
||||||
|
green: 1 - t.success_count / maxVal,
|
||||||
|
sent: formatNumber(t.send_count),
|
||||||
|
reach: formatNumber(t.success_count),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** top_messages → RecentMessages props 변환 */
|
||||||
|
function mapMessages(data: DashboardData) {
|
||||||
|
return (data.top_messages ?? []).map((m) => ({
|
||||||
|
template: m.title ?? "",
|
||||||
|
targetCount: formatNumber(m.total_send_count),
|
||||||
|
status: (m.status ?? "") as "완료" | "실패" | "진행" | "예약",
|
||||||
|
sentAt: "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** platform_share → PlatformDonut props 변환 */
|
||||||
|
function mapPlatform(data: DashboardData) {
|
||||||
|
const shares = data.platform_share ?? [];
|
||||||
|
return {
|
||||||
|
ios: shares.find((p) => p.platform?.toLowerCase() === "ios")?.ratio ?? 0,
|
||||||
|
android: shares.find((p) => p.platform?.toLowerCase() === "android")?.ratio ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [empty, setEmpty] = useState(false); // API 성공이나 데이터 없음
|
||||||
|
const [cards, setCards] = useState<ReturnType<typeof mapCards> | undefined>();
|
||||||
|
const [chart, setChart] = useState<ReturnType<typeof mapChart> | undefined>();
|
||||||
|
const [messages, setMessages] = useState<ReturnType<typeof mapMessages> | undefined>();
|
||||||
|
const [platform, setPlatform] = useState<ReturnType<typeof mapPlatform> | undefined>();
|
||||||
|
|
||||||
|
// 필터 상태 보관 (초기 로드 + 조회 버튼)
|
||||||
|
const [filter, setFilter] = useState<DashboardFilterValues>({
|
||||||
|
dateStart: (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - 30);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
})(),
|
||||||
|
dateEnd: new Date().toISOString().slice(0, 10),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadDashboard = useCallback(async (f: DashboardFilterValues) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
setEmpty(false);
|
||||||
|
try {
|
||||||
|
const res = await fetchDashboard(
|
||||||
|
{ start_date: f.dateStart, end_date: f.dateEnd },
|
||||||
|
);
|
||||||
|
const d = res.data.data;
|
||||||
|
|
||||||
|
// 데이터 비어있는지 판단 (서비스·기기가 있으면 KPI 표시)
|
||||||
|
const hasData =
|
||||||
|
d.kpi.total_send > 0 ||
|
||||||
|
d.kpi.total_devices > 0 ||
|
||||||
|
d.kpi.active_service_count > 0 ||
|
||||||
|
(d.daily ?? []).length > 0 ||
|
||||||
|
(d.top_messages ?? []).length > 0;
|
||||||
|
|
||||||
|
if (!hasData) {
|
||||||
|
setEmpty(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCards(mapCards(d));
|
||||||
|
setChart(mapChart(d));
|
||||||
|
setMessages(mapMessages(d));
|
||||||
|
setPlatform(mapPlatform(d));
|
||||||
|
} catch {
|
||||||
|
setError(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
loadDashboard(filter);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 조회 버튼 핸들러
|
||||||
|
const handleSearch = useCallback(
|
||||||
|
(f: DashboardFilterValues) => {
|
||||||
|
setFilter(f);
|
||||||
|
loadDashboard(f);
|
||||||
|
},
|
||||||
|
[loadDashboard],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 스켈레톤 표시 조건
|
||||||
|
const showSkeleton = loading || error || empty || !cards;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 페이지 헤더 */}
|
||||||
|
<PageHeader
|
||||||
|
title="대시보드"
|
||||||
|
description="서비스 발송 현황과 주요 지표를 한눈에 확인할 수 있습니다."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 필터 */}
|
||||||
|
<DashboardFilter onSearch={handleSearch} loading={loading} />
|
||||||
|
|
||||||
|
{/* 데이터 영역 */}
|
||||||
|
<div className="relative">
|
||||||
|
<StatsCards cards={showSkeleton ? undefined : cards} loading={showSkeleton} />
|
||||||
|
<WeeklyChart data={showSkeleton ? undefined : chart} loading={showSkeleton} />
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<RecentMessages messages={showSkeleton ? undefined : messages} loading={showSkeleton} />
|
||||||
|
<PlatformDonut data={showSkeleton ? undefined : platform} loading={showSkeleton} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API 에러 오버레이 */}
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/90 rounded-xl">
|
||||||
|
<div className="text-center">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-gray-400"
|
||||||
|
style={{ fontSize: "48px" }}
|
||||||
|
>
|
||||||
|
cloud_off
|
||||||
|
</span>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-gray-600">
|
||||||
|
데이터를 불러올 수 없습니다
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
네트워크 상태를 확인하거나 다시 조회해 주세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 조회 결과 없음 오버레이 */}
|
||||||
|
{empty && !loading && (
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/90 rounded-xl">
|
||||||
|
<div className="text-center">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-gray-400"
|
||||||
|
style={{ fontSize: "48px" }}
|
||||||
|
>
|
||||||
|
inbox
|
||||||
|
</span>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-gray-600">
|
||||||
|
조회된 데이터가 없습니다
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
기간이나 서비스를 변경하여 다시 조회해 주세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
react/src/features/dashboard/pages/HomePage.tsx
Normal file
109
react/src/features/dashboard/pages/HomePage.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
|
||||||
|
const shortcuts = [
|
||||||
|
{
|
||||||
|
to: "/dashboard",
|
||||||
|
icon: "dashboard",
|
||||||
|
label: "대시보드",
|
||||||
|
desc: "발송 현황 한눈에 보기",
|
||||||
|
color: "blue" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/services",
|
||||||
|
icon: "manage_accounts",
|
||||||
|
label: "서비스 관리",
|
||||||
|
desc: "서비스 목록 및 설정",
|
||||||
|
color: "blue" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/services/register",
|
||||||
|
icon: "add_circle",
|
||||||
|
label: "서비스 등록",
|
||||||
|
desc: "새 서비스 등록하기",
|
||||||
|
color: "green" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/statistics/history",
|
||||||
|
icon: "send",
|
||||||
|
label: "발송 내역",
|
||||||
|
desc: "최근 메시지 발송 기록",
|
||||||
|
color: "blue" as const,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const userName = useAuthStore((s) => s.user?.name) ?? "관리자";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center pt-16">
|
||||||
|
<div className="text-center max-w-lg px-8">
|
||||||
|
{/* 아이콘 */}
|
||||||
|
<div className="mx-auto mb-8 size-20 rounded-2xl bg-primary/10 flex items-center justify-center border border-primary/20">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-primary"
|
||||||
|
style={{ fontSize: "50px" }}
|
||||||
|
>
|
||||||
|
grid_view
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 인사말 */}
|
||||||
|
<h1 className="text-3xl font-bold text-foreground tracking-tight mb-3">
|
||||||
|
안녕하세요, {userName}님
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-base leading-relaxed mb-10">
|
||||||
|
SPMS Admin Console에 오신 것을 환영합니다.
|
||||||
|
<br />
|
||||||
|
아래 바로가기를 통해 원하는 메뉴로 이동하세요.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 바로가기 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{shortcuts.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className="group flex items-center gap-3 p-4 bg-white border border-gray-200 rounded-xl hover:border-primary/30 hover:shadow-md transition-all"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`size-10 rounded-lg flex items-center justify-center flex-shrink-0 transition-colors ${
|
||||||
|
item.color === "green"
|
||||||
|
? "bg-green-50 group-hover:bg-green-100"
|
||||||
|
: "bg-blue-50 group-hover:bg-primary/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`material-symbols-outlined text-xl ${
|
||||||
|
item.color === "green" ? "text-green-600" : "text-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p
|
||||||
|
className={`text-sm font-semibold text-foreground transition-colors ${
|
||||||
|
item.color === "green"
|
||||||
|
? "group-hover:text-green-600"
|
||||||
|
: "group-hover:text-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{item.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버전 */}
|
||||||
|
<p className="text-xs text-gray-400 mt-10">
|
||||||
|
Stein Push Messaging Service v1.0
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
react/src/features/dashboard/types.ts
Normal file
74
react/src/features/dashboard/types.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// 대시보드 API 요청
|
||||||
|
export interface DashboardRequest {
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기간별 통계
|
||||||
|
export interface PeriodStat {
|
||||||
|
send_count: number;
|
||||||
|
success_count: number;
|
||||||
|
open_count: number;
|
||||||
|
ctr: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// KPI 지표
|
||||||
|
export interface DashboardKpi {
|
||||||
|
total_devices: number;
|
||||||
|
active_devices: number;
|
||||||
|
total_messages: number;
|
||||||
|
total_send: number;
|
||||||
|
total_success: number;
|
||||||
|
total_open: number;
|
||||||
|
avg_ctr: number;
|
||||||
|
active_service_count: number;
|
||||||
|
success_rate_change: number;
|
||||||
|
device_count_change: number;
|
||||||
|
today_sent_change_rate: number;
|
||||||
|
today: PeriodStat | null;
|
||||||
|
this_month: PeriodStat | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일별 통계
|
||||||
|
export interface DailyStat {
|
||||||
|
stat_date: string | null;
|
||||||
|
send_count: number;
|
||||||
|
success_count: number;
|
||||||
|
fail_count: number;
|
||||||
|
open_count: number;
|
||||||
|
ctr: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시간대별 통계
|
||||||
|
export interface HourlyStat {
|
||||||
|
hour: number;
|
||||||
|
send_count: number;
|
||||||
|
open_count: number;
|
||||||
|
ctr: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 플랫폼별 통계
|
||||||
|
export interface PlatformStat {
|
||||||
|
platform: string | null;
|
||||||
|
count: number;
|
||||||
|
ratio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상위 메시지
|
||||||
|
export interface TopMessage {
|
||||||
|
message_code: string | null;
|
||||||
|
title: string | null;
|
||||||
|
service_name: string | null;
|
||||||
|
total_send_count: number;
|
||||||
|
success_count: number;
|
||||||
|
status: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 대시보드 통합 응답
|
||||||
|
export interface DashboardData {
|
||||||
|
kpi: DashboardKpi;
|
||||||
|
daily: DailyStat[] | null;
|
||||||
|
hourly: HourlyStat[] | null;
|
||||||
|
platform_share: PlatformStat[] | null;
|
||||||
|
top_messages: TopMessage[] | null;
|
||||||
|
}
|
||||||
372
react/src/features/device/components/DeviceSlidePanel.tsx
Normal file
372
react/src/features/device/components/DeviceSlidePanel.tsx
Normal file
|
|
@ -0,0 +1,372 @@
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import CopyButton from "@/components/common/CopyButton";
|
||||||
|
import { deleteDevice } from "@/api/device.api";
|
||||||
|
import { formatDate } from "@/utils/format";
|
||||||
|
import type { DeviceListItem } from "../types";
|
||||||
|
|
||||||
|
interface DeviceSlidePanelProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
device: DeviceListItem | null;
|
||||||
|
serviceCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeviceSlidePanel({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
device,
|
||||||
|
serviceCode,
|
||||||
|
}: DeviceSlidePanelProps) {
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const bodyRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 패널 열릴 때 스크롤 최상단 리셋
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
bodyRef.current?.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
}, [isOpen, device]);
|
||||||
|
|
||||||
|
// ESC 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && isOpen) onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
// body 스크롤 잠금
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// 기기 삭제
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!device || !serviceCode) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteDevice({ device_id: device.device_id }, serviceCode);
|
||||||
|
toast.success("기기가 삭제되었습니다.");
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
toast.error("기기 삭제에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 플랫폼 아이콘 렌더링
|
||||||
|
const renderPlatformIcon = () => {
|
||||||
|
if (!device) return null;
|
||||||
|
if (device.platform?.toLowerCase() === "ios") {
|
||||||
|
return (
|
||||||
|
<div className="size-10 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="size-5 text-gray-700" viewBox="0 0 384 512" fill="currentColor">
|
||||||
|
<path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="size-10 rounded-lg bg-green-50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="material-symbols-outlined text-green-700 text-xl">
|
||||||
|
android
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 수신 동의 박스
|
||||||
|
const ConsentBox = ({
|
||||||
|
label,
|
||||||
|
consented,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
consented: boolean;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg p-3 border text-center ${
|
||||||
|
consented
|
||||||
|
? "bg-green-50 border-green-200"
|
||||||
|
: "bg-gray-50 border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-1.5 mb-1">
|
||||||
|
<span
|
||||||
|
className={`material-symbols-outlined ${consented ? "text-green-500" : "text-gray-400"}`}
|
||||||
|
style={{ fontSize: "16px" }}
|
||||||
|
>
|
||||||
|
{consented ? "check_circle" : "cancel"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-gray-600">{label}</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-bold ${consented ? "text-green-700" : "text-gray-500"}`}
|
||||||
|
>
|
||||||
|
{consented ? "동의" : "미동의"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 태그명 배열
|
||||||
|
const tagNames = device?.tags?.map((t) => t.tag_name) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 오버레이 */}
|
||||||
|
<div
|
||||||
|
className={`fixed inset-0 bg-black/30 z-50 transition-opacity duration-300 ${
|
||||||
|
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 패널 */}
|
||||||
|
<aside
|
||||||
|
className={`fixed top-0 right-0 h-full w-[480px] bg-white shadow-2xl z-50 flex flex-col transition-transform duration-300 ${
|
||||||
|
isOpen ? "translate-x-0" : "translate-x-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-100 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{renderPlatformIcon()}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-[#0f172a]">
|
||||||
|
{device?.model}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{device?.os_version}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-lg hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xl">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 */}
|
||||||
|
<div
|
||||||
|
ref={bodyRef}
|
||||||
|
className="flex-1 overflow-y-auto px-6 py-5 space-y-5"
|
||||||
|
style={{ overscrollBehavior: "contain" }}
|
||||||
|
>
|
||||||
|
{device ? (
|
||||||
|
<>
|
||||||
|
{/* 소속 서비스 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 block">
|
||||||
|
소속 서비스
|
||||||
|
</label>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="material-symbols-outlined text-[#2563EB] text-lg">
|
||||||
|
apps
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-[#0f172a]">
|
||||||
|
{device.service_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<code className="text-[11px] text-gray-500 bg-white px-2 py-0.5 rounded border border-gray-200 font-mono">
|
||||||
|
{device.service_code}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device ID */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 block">
|
||||||
|
Device ID
|
||||||
|
</label>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs text-gray-700 font-mono break-all leading-relaxed flex-1">
|
||||||
|
{String(device.device_id)}
|
||||||
|
</code>
|
||||||
|
<CopyButton text={String(device.device_id)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Push Token */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 block">
|
||||||
|
Push Token
|
||||||
|
</label>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<code className="text-xs text-gray-700 font-mono break-all leading-relaxed flex-1">
|
||||||
|
{device.device_token ?? ""}
|
||||||
|
</code>
|
||||||
|
<CopyButton text={device.device_token ?? ""} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수신 동의 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 block">
|
||||||
|
수신 동의
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<ConsentBox label="푸시 수신" consented={device.push_agreed} />
|
||||||
|
<ConsentBox label="광고 수신" consented={device.marketing_agreed} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 태그 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 block">
|
||||||
|
태그
|
||||||
|
</label>
|
||||||
|
{tagNames.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tagNames.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-blue-50 text-blue-700 border border-blue-200"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400 italic">
|
||||||
|
등록된 태그 없음
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기기 정보 */}
|
||||||
|
<div className="border-t border-gray-100 pt-5">
|
||||||
|
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3 block">
|
||||||
|
기기 정보
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] text-gray-400 mb-0.5">등록일</p>
|
||||||
|
<p className="text-sm font-medium text-[#0f172a]">
|
||||||
|
{formatDate(device.created_at ?? "")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] text-gray-400 mb-0.5">
|
||||||
|
마지막 활동
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium text-[#0f172a]">
|
||||||
|
{formatDate(device.last_active_at ?? "")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] text-gray-400 mb-0.5">앱 버전</p>
|
||||||
|
<p className="text-sm font-medium text-[#0f172a]">
|
||||||
|
{device.app_version}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] text-gray-400 mb-0.5">플랫폼</p>
|
||||||
|
<p className="text-sm font-medium text-[#0f172a]">
|
||||||
|
{device.platform?.toLowerCase() === "ios" ? "iOS" : "Android"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
|
||||||
|
기기를 선택해주세요
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
{device && (
|
||||||
|
<div className="flex-shrink-0 border-t border-gray-100 px-6 py-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="w-full h-10 border border-red-200 rounded-lg text-sm font-medium text-red-500 hover:bg-red-50 transition-colors flex items-center justify-center"
|
||||||
|
>
|
||||||
|
기기 삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 삭제 확인 모달 */}
|
||||||
|
{showDeleteConfirm && device && (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="size-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="material-symbols-outlined text-red-600 text-xl">
|
||||||
|
warning
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-[#0f172a]">기기 삭제</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[#0f172a] mb-2">
|
||||||
|
선택한 기기를 삭제하시겠습니까?
|
||||||
|
</p>
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 mb-5 flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-red-700 text-xs font-medium">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>삭제된 기기는 복구할 수 없습니다.</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-red-700 text-xs font-medium">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>해당 기기로의 푸시 발송이 즉시 중단됩니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
disabled={deleting}
|
||||||
|
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="bg-red-500 hover:bg-red-600 text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? "삭제 중..." : "삭제"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
react/src/features/device/components/SecretToggleCell.tsx
Normal file
100
react/src/features/device/components/SecretToggleCell.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { useRef, useEffect, useState } from "react";
|
||||||
|
import CopyButton from "@/components/common/CopyButton";
|
||||||
|
|
||||||
|
interface SecretToggleCellProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
dropdownKey: string;
|
||||||
|
openKey: string | null;
|
||||||
|
onToggle: (key: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Device ID / Push Token 토글 팝오버 셀 */
|
||||||
|
export default function SecretToggleCell({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
dropdownKey,
|
||||||
|
openKey,
|
||||||
|
onToggle,
|
||||||
|
}: SecretToggleCellProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isOpen = openKey === dropdownKey;
|
||||||
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
|
||||||
|
// 외부 클릭 시 닫힘
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
containerRef.current &&
|
||||||
|
!containerRef.current.contains(e.target as Node) &&
|
||||||
|
popoverRef.current &&
|
||||||
|
!popoverRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
onToggle(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
|
}, [isOpen, onToggle]);
|
||||||
|
|
||||||
|
// 스크롤 시 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const close = () => onToggle(null);
|
||||||
|
window.addEventListener("scroll", close, true);
|
||||||
|
return () => window.removeEventListener("scroll", close, true);
|
||||||
|
}, [isOpen, onToggle]);
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (isOpen) {
|
||||||
|
onToggle(null);
|
||||||
|
} else {
|
||||||
|
const rect = buttonRef.current?.getBoundingClientRect();
|
||||||
|
if (rect) {
|
||||||
|
setPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 });
|
||||||
|
}
|
||||||
|
onToggle(dropdownKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative inline-block"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
className="text-xs text-[#2563EB] hover:text-[#1d4ed8] font-medium transition-colors inline-flex items-center gap-1"
|
||||||
|
onClick={handleToggle}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<span
|
||||||
|
className={`material-symbols-outlined transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
keyboard_arrow_down
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* fixed로 렌더링해서 테이블 overflow 밖으로 벗어남 */}
|
||||||
|
{isOpen && pos && (
|
||||||
|
<div
|
||||||
|
ref={popoverRef}
|
||||||
|
className="fixed w-[340px] bg-gray-50 border border-gray-200 rounded-lg p-3 shadow-lg z-[9999]"
|
||||||
|
style={{ top: pos.top, left: pos.left, transform: "translateX(-50%)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-[11px] text-gray-600 font-mono break-all leading-relaxed flex-1">
|
||||||
|
{value}
|
||||||
|
</code>
|
||||||
|
<CopyButton text={value} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
react/src/features/device/hooks/.gitkeep
Normal file
0
react/src/features/device/hooks/.gitkeep
Normal file
478
react/src/features/device/pages/DeviceListPage.tsx
Normal file
478
react/src/features/device/pages/DeviceListPage.tsx
Normal file
|
|
@ -0,0 +1,478 @@
|
||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import PageHeader from "@/components/common/PageHeader";
|
||||||
|
import SearchInput from "@/components/common/SearchInput";
|
||||||
|
import FilterDropdown from "@/components/common/FilterDropdown";
|
||||||
|
import FilterResetButton from "@/components/common/FilterResetButton";
|
||||||
|
import Pagination from "@/components/common/Pagination";
|
||||||
|
import EmptyState from "@/components/common/EmptyState";
|
||||||
|
import PlatformBadge from "@/components/common/PlatformBadge";
|
||||||
|
import SecretToggleCell from "../components/SecretToggleCell";
|
||||||
|
import DeviceSlidePanel from "../components/DeviceSlidePanel";
|
||||||
|
import { formatDate } from "@/utils/format";
|
||||||
|
import { fetchDevices, exportDevices } from "@/api/device.api";
|
||||||
|
import { fetchServices } from "@/api/service.api";
|
||||||
|
import {
|
||||||
|
PLATFORM_FILTER_OPTIONS,
|
||||||
|
PUSH_CONSENT_FILTER_OPTIONS,
|
||||||
|
} from "../types";
|
||||||
|
import type { DeviceListItem } from "../types";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
// 테이블 컬럼 헤더
|
||||||
|
const COLUMNS = [
|
||||||
|
"소속 서비스",
|
||||||
|
"플랫폼",
|
||||||
|
"Device ID",
|
||||||
|
"Push Token",
|
||||||
|
"푸시 수신",
|
||||||
|
"광고 수신",
|
||||||
|
"태그",
|
||||||
|
"등록일",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DeviceListPage() {
|
||||||
|
// 필터 입력 상태
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [serviceFilter, setServiceFilter] = useState("전체 서비스");
|
||||||
|
const [platformFilter, setPlatformFilter] = useState("전체");
|
||||||
|
const [pushFilter, setPushFilter] = useState("전체");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 적용된 필터
|
||||||
|
const [appliedSearch, setAppliedSearch] = useState("");
|
||||||
|
const [appliedService, setAppliedService] = useState("전체 서비스");
|
||||||
|
const [appliedPlatform, setAppliedPlatform] = useState("전체");
|
||||||
|
const [appliedPush, setAppliedPush] = useState("전체");
|
||||||
|
|
||||||
|
// 서비스 목록 (API에서 로드)
|
||||||
|
const [serviceFilterOptions, setServiceFilterOptions] = useState<string[]>([
|
||||||
|
"전체 서비스",
|
||||||
|
]);
|
||||||
|
const [serviceCodeMap, setServiceCodeMap] = useState<
|
||||||
|
Record<string, string>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
// 데이터
|
||||||
|
const [items, setItems] = useState<DeviceListItem[]>([]);
|
||||||
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
|
||||||
|
// 슬라이드 패널
|
||||||
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
|
const [selectedDevice, setSelectedDevice] = useState<DeviceListItem | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// SecretToggleCell 배타적 관리
|
||||||
|
const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 서비스 목록 로드 (초기화 시 1회)
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchServices({ page: 1, pageSize: 100 });
|
||||||
|
const svcItems = res.data.data.items ?? [];
|
||||||
|
const names = svcItems.map((s) => s.serviceName);
|
||||||
|
setServiceFilterOptions(["전체 서비스", ...names]);
|
||||||
|
const codeMap: Record<string, string> = {};
|
||||||
|
svcItems.forEach((s) => {
|
||||||
|
codeMap[s.serviceName] = s.serviceCode;
|
||||||
|
});
|
||||||
|
setServiceCodeMap(codeMap);
|
||||||
|
} catch {
|
||||||
|
// 서비스 목록 로드 실패 시 기본값 유지
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadData = useCallback(
|
||||||
|
async (
|
||||||
|
page: number,
|
||||||
|
keyword: string,
|
||||||
|
serviceName: string,
|
||||||
|
platform: string,
|
||||||
|
push: string,
|
||||||
|
) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const serviceCode =
|
||||||
|
serviceName !== "전체 서비스"
|
||||||
|
? serviceCodeMap[serviceName] || undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const res = await fetchDevices(
|
||||||
|
{
|
||||||
|
page,
|
||||||
|
size: PAGE_SIZE,
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
platform: platform !== "전체" ? platform : undefined,
|
||||||
|
push_agreed:
|
||||||
|
push === "동의" ? true : push === "미동의" ? false : undefined,
|
||||||
|
},
|
||||||
|
serviceCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = res.data.data;
|
||||||
|
setItems(data.items ?? []);
|
||||||
|
setTotalItems(data.totalCount ?? 0);
|
||||||
|
setTotalPages(data.totalPages ?? 1);
|
||||||
|
} catch {
|
||||||
|
setItems([]);
|
||||||
|
setTotalItems(0);
|
||||||
|
setTotalPages(1);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[serviceCodeMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
loadData(1, "", "전체 서비스", "전체", "전체");
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// 조회
|
||||||
|
const handleQuery = () => {
|
||||||
|
setAppliedSearch(search);
|
||||||
|
setAppliedService(serviceFilter);
|
||||||
|
setAppliedPlatform(platformFilter);
|
||||||
|
setAppliedPush(pushFilter);
|
||||||
|
setCurrentPage(1);
|
||||||
|
loadData(1, search, serviceFilter, platformFilter, pushFilter);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터 초기화
|
||||||
|
const handleReset = () => {
|
||||||
|
setSearch("");
|
||||||
|
setServiceFilter("전체 서비스");
|
||||||
|
setPlatformFilter("전체");
|
||||||
|
setPushFilter("전체");
|
||||||
|
setAppliedSearch("");
|
||||||
|
setAppliedService("전체 서비스");
|
||||||
|
setAppliedPlatform("전체");
|
||||||
|
setAppliedPush("전체");
|
||||||
|
setCurrentPage(1);
|
||||||
|
loadData(1, "", "전체 서비스", "전체", "전체");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지 변경
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
loadData(page, appliedSearch, appliedService, appliedPlatform, appliedPush);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 행 클릭
|
||||||
|
const handleRowClick = (device: DeviceListItem) => {
|
||||||
|
setOpenDropdownKey(null);
|
||||||
|
setSelectedDevice(device);
|
||||||
|
setPanelOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 패널 닫기 (삭제 후 목록 새로고침)
|
||||||
|
const handlePanelClose = () => {
|
||||||
|
setPanelOpen(false);
|
||||||
|
loadData(
|
||||||
|
currentPage,
|
||||||
|
appliedSearch,
|
||||||
|
appliedService,
|
||||||
|
appliedPlatform,
|
||||||
|
appliedPush,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엑셀 내보내기
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (appliedService === "전체 서비스") {
|
||||||
|
toast.error("엑셀 다운로드를 위해 서비스를 선택해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const serviceCode = serviceCodeMap[appliedService];
|
||||||
|
if (!serviceCode) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await exportDevices(
|
||||||
|
{
|
||||||
|
keyword: appliedSearch || undefined,
|
||||||
|
platform:
|
||||||
|
appliedPlatform !== "전체" ? appliedPlatform : undefined,
|
||||||
|
push_agreed:
|
||||||
|
appliedPush === "동의"
|
||||||
|
? true
|
||||||
|
: appliedPush === "미동의"
|
||||||
|
? false
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
serviceCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
const blob = new Blob([res.data], {
|
||||||
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `devices_${new Date().toISOString().split("T")[0]}.xlsx`;
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
toast.error("엑셀 다운로드에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 체크/취소 아이콘
|
||||||
|
const StatusIcon = ({ active }: { active: boolean }) => (
|
||||||
|
<span
|
||||||
|
className={`material-symbols-outlined ${active ? "text-green-500" : "text-red-400"}`}
|
||||||
|
style={{ fontSize: "20px" }}
|
||||||
|
>
|
||||||
|
{active ? "check_circle" : "cancel"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 태그 아이콘 (있으면 체크, 없으면 dash)
|
||||||
|
const TagIcon = ({ hasTags }: { hasTags: boolean }) =>
|
||||||
|
hasTags ? (
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-green-500"
|
||||||
|
style={{ fontSize: "20px" }}
|
||||||
|
>
|
||||||
|
check_circle
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-red-400"
|
||||||
|
style={{ fontSize: "20px" }}
|
||||||
|
>
|
||||||
|
cancel
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 테이블 헤더 렌더링
|
||||||
|
const renderTableHead = () => (
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
{COLUMNS.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col}
|
||||||
|
className={`px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center ${
|
||||||
|
["푸시 수신", "광고 수신", "태그"].includes(col)
|
||||||
|
? "w-[100px] whitespace-nowrap"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="기기 관리"
|
||||||
|
description="등록된 디바이스 현황을 조회하고 관리할 수 있습니다."
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
className="flex items-center justify-center gap-2 min-w-[154px] px-5 py-2.5 bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg">download</span>
|
||||||
|
엑셀 다운로드
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 필터바 */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
|
||||||
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
|
<SearchInput
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
placeholder="Device ID 또는 Push Token 검색"
|
||||||
|
label="검색어"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<FilterDropdown
|
||||||
|
label="서비스"
|
||||||
|
value={serviceFilter}
|
||||||
|
options={serviceFilterOptions}
|
||||||
|
onChange={setServiceFilter}
|
||||||
|
className="w-[140px] flex-shrink-0"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<FilterDropdown
|
||||||
|
label="플랫폼"
|
||||||
|
value={platformFilter}
|
||||||
|
options={PLATFORM_FILTER_OPTIONS}
|
||||||
|
onChange={setPlatformFilter}
|
||||||
|
className="w-[120px] flex-shrink-0"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<FilterDropdown
|
||||||
|
label="푸시 수신"
|
||||||
|
value={pushFilter}
|
||||||
|
options={PUSH_CONSENT_FILTER_OPTIONS}
|
||||||
|
onChange={setPushFilter}
|
||||||
|
className="w-[120px] flex-shrink-0"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<FilterResetButton onClick={handleReset} disabled={loading} />
|
||||||
|
<button
|
||||||
|
onClick={handleQuery}
|
||||||
|
disabled={loading}
|
||||||
|
className="h-[38px] flex-shrink-0 bg-gray-800 hover:bg-gray-900 active:scale-95 text-white rounded-lg px-5 text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
조회
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
{loading ? (
|
||||||
|
/* 로딩 스켈레톤 */
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
{renderTableHead()}
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<tr
|
||||||
|
key={i}
|
||||||
|
className={i < 4 ? "border-b border-gray-100" : ""}
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-14 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-16 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-16 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-6 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-6 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-6 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : items.length > 0 ? (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
{renderTableHead()}
|
||||||
|
<tbody>
|
||||||
|
{items.map((device, idx) => (
|
||||||
|
<tr
|
||||||
|
key={device.device_id}
|
||||||
|
className={`${idx < items.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`}
|
||||||
|
onClick={() => handleRowClick(device)}
|
||||||
|
>
|
||||||
|
{/* 소속 서비스 */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
{device.service_name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{/* 플랫폼 */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<PlatformBadge
|
||||||
|
platform={
|
||||||
|
device.platform?.toLowerCase() === "ios" ? "ios" : "android"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{/* Device ID */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<SecretToggleCell
|
||||||
|
label="ID 확인"
|
||||||
|
value={String(device.device_id)}
|
||||||
|
dropdownKey={`${device.device_id}-id`}
|
||||||
|
openKey={openDropdownKey}
|
||||||
|
onToggle={setOpenDropdownKey}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{/* Push Token */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<SecretToggleCell
|
||||||
|
label="토큰 확인"
|
||||||
|
value={device.device_token ?? ""}
|
||||||
|
dropdownKey={`${device.device_id}-token`}
|
||||||
|
openKey={openDropdownKey}
|
||||||
|
onToggle={setOpenDropdownKey}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{/* 푸시 수신 */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<StatusIcon active={device.push_agreed} />
|
||||||
|
</td>
|
||||||
|
{/* 광고 수신 */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<StatusIcon active={device.marketing_agreed} />
|
||||||
|
</td>
|
||||||
|
{/* 태그 */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<TagIcon hasTags={(device.tags?.length ?? 0) > 0} />
|
||||||
|
</td>
|
||||||
|
{/* 등록일 */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{formatDate(device.created_at ?? "")}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalItems={totalItems}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon="search_off"
|
||||||
|
message="검색 결과가 없습니다"
|
||||||
|
description="다른 검색어를 입력하거나 필터를 변경해보세요."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 슬라이드 패널 */}
|
||||||
|
<DeviceSlidePanel
|
||||||
|
isOpen={panelOpen}
|
||||||
|
onClose={handlePanelClose}
|
||||||
|
device={selectedDevice}
|
||||||
|
serviceCode={
|
||||||
|
selectedDevice?.service_code ??
|
||||||
|
(appliedService !== "전체 서비스"
|
||||||
|
? serviceCodeMap[appliedService]
|
||||||
|
: undefined)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
react/src/features/device/types.ts
Normal file
69
react/src/features/device/types.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
// 플랫폼 타입
|
||||||
|
export const PLATFORM = { IOS: "iOS", ANDROID: "Android" } as const;
|
||||||
|
export type Platform = (typeof PLATFORM)[keyof typeof PLATFORM];
|
||||||
|
|
||||||
|
// 필터 옵션 상수
|
||||||
|
export const PLATFORM_FILTER_OPTIONS = ["전체", "iOS", "Android"];
|
||||||
|
export const PUSH_CONSENT_FILTER_OPTIONS = ["전체", "동의", "미동의"];
|
||||||
|
|
||||||
|
// --- swagger 기준 요청/응답 타입 (snake_case) ---
|
||||||
|
|
||||||
|
/** 기기 목록 요청 */
|
||||||
|
export interface DeviceListRequest {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
platform?: string | null;
|
||||||
|
push_agreed?: boolean | null;
|
||||||
|
marketing_agreed?: boolean | null;
|
||||||
|
tags?: number[] | null;
|
||||||
|
is_active?: boolean | null;
|
||||||
|
keyword?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기기 태그 아이템 */
|
||||||
|
export interface DeviceTagItem {
|
||||||
|
tag_id: number;
|
||||||
|
tag_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기기 목록 아이템 */
|
||||||
|
export interface DeviceListItem {
|
||||||
|
device_id: number;
|
||||||
|
device_token: string | null;
|
||||||
|
platform: string | null;
|
||||||
|
model: string | null;
|
||||||
|
os_version: string | null;
|
||||||
|
app_version: string | null;
|
||||||
|
service_name: string | null;
|
||||||
|
service_code: string | null;
|
||||||
|
push_agreed: boolean;
|
||||||
|
marketing_agreed: boolean;
|
||||||
|
tags: DeviceTagItem[] | null;
|
||||||
|
created_at: string | null;
|
||||||
|
last_active_at: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기기 목록 응답 */
|
||||||
|
export interface DeviceListResponse {
|
||||||
|
items: DeviceListItem[] | null;
|
||||||
|
totalCount: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기기 삭제 요청 */
|
||||||
|
export interface DeviceDeleteRequest {
|
||||||
|
device_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기기 내보내기 요청 */
|
||||||
|
export interface DeviceExportRequest {
|
||||||
|
platform?: string | null;
|
||||||
|
push_agreed?: boolean | null;
|
||||||
|
marketing_agreed?: boolean | null;
|
||||||
|
tags?: number[] | null;
|
||||||
|
is_active?: boolean | null;
|
||||||
|
keyword?: string | null;
|
||||||
|
}
|
||||||
379
react/src/features/message/components/MessagePreview.tsx
Normal file
379
react/src/features/message/components/MessagePreview.tsx
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface MessagePreviewProps {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
hasImage: boolean;
|
||||||
|
appName?: string;
|
||||||
|
variant?: "large" | "small";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 폰 배경 이미지 (밝은 그래디언트 배경)
|
||||||
|
const PHONE_BG =
|
||||||
|
"https://lh3.googleusercontent.com/aida-public/AB6AXuDmbD5msJ9uegWOcy0256wH6JsipGzrgtab3foEKiGVs_a4SbUCTPti6BVDJOQEP4ZCvbcAw9hI3C7QuUdPxrBf3jJm3VgKkWoqSzl--ZEbPIzimbYnM1HQEsRbil7nmWG_XscwPP30V3OFnyleVY_R7Urk0UYbrL8P1OJwW1xwYfBDJv4htBuICd9GR2NIJlSShaBxfF9Kgp59Cte3VapdHxCz9p2Cb9tf1t13xc2LV348V-kfyQNtL8XCZNP3LMrrUIR4SrV3cGM";
|
||||||
|
|
||||||
|
export default function MessagePreview({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
hasImage,
|
||||||
|
appName = "SPMS",
|
||||||
|
variant = "large",
|
||||||
|
}: MessagePreviewProps) {
|
||||||
|
const [tab, setTab] = useState<"ios" | "android">("ios");
|
||||||
|
|
||||||
|
const isLarge = variant === "large";
|
||||||
|
const phoneWidth = isLarge ? "w-[300px]" : "w-[240px]";
|
||||||
|
const truncatedBody =
|
||||||
|
body.length > 50 ? body.substring(0, 50) + "..." : body;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 탭 */}
|
||||||
|
<div className="flex border-b border-gray-200 mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex-1 px-3 py-2 text-xs font-medium border-b-2 transition-colors cursor-pointer ${
|
||||||
|
tab === "ios"
|
||||||
|
? "text-[#0f172a] border-[#2563EB]"
|
||||||
|
: "text-gray-400 border-transparent hover:text-[#0f172a]"
|
||||||
|
}`}
|
||||||
|
onClick={() => setTab("ios")}
|
||||||
|
>
|
||||||
|
iOS
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex-1 px-3 py-2 text-xs font-medium border-b-2 transition-colors cursor-pointer ${
|
||||||
|
tab === "android"
|
||||||
|
? "text-[#0f172a] border-[#2563EB]"
|
||||||
|
: "text-gray-400 border-transparent hover:text-[#0f172a]"
|
||||||
|
}`}
|
||||||
|
onClick={() => setTab("android")}
|
||||||
|
>
|
||||||
|
Android
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* iOS 프리뷰 */}
|
||||||
|
{tab === "ios" && (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{/* Banner Notification */}
|
||||||
|
<div className="min-h-[130px]">
|
||||||
|
<p className="text-[10px] uppercase font-bold text-gray-400 tracking-wider mb-2">
|
||||||
|
Banner Notification
|
||||||
|
</p>
|
||||||
|
<div className="bg-white/90 backdrop-blur-md rounded-2xl p-3 shadow-lg border border-gray-200">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="size-8 bg-[#2563EB] rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-white"
|
||||||
|
style={{ fontSize: "16px" }}
|
||||||
|
>
|
||||||
|
shopping_bag
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-bold text-[#0f172a] break-words">
|
||||||
|
{title || "메시지 제목을 입력하세요"}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-gray-600 mt-0.5 break-words">
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1 flex-shrink-0 self-start">
|
||||||
|
<p className="text-[10px] text-gray-400">now</p>
|
||||||
|
{hasImage && (
|
||||||
|
<div className="w-8 h-8 bg-gray-100 border border-dashed border-gray-300 rounded flex items-center justify-center">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-gray-300"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
image
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full Screen Preview - iOS */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase font-bold text-gray-400 tracking-wider mb-2">
|
||||||
|
Full Screen Preview
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className={`relative ${phoneWidth}`}>
|
||||||
|
<div
|
||||||
|
className={`${isLarge ? "border-[10px]" : "border-[8px]"} border-gray-800 bg-gray-800 ${isLarge ? "rounded-[2.5rem]" : "rounded-[2rem]"} overflow-hidden shadow-xl`}
|
||||||
|
style={{ aspectRatio: "9/19.5" }}
|
||||||
|
>
|
||||||
|
{/* 노치 */}
|
||||||
|
<div
|
||||||
|
className={`absolute top-0 left-1/2 -translate-x-1/2 ${isLarge ? "w-32 h-7" : "w-24 h-5"} bg-black rounded-b-2xl z-10`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-full h-full bg-cover bg-center overflow-hidden relative"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url('${PHONE_BG}')`,
|
||||||
|
filter: "brightness(1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 상태바 */}
|
||||||
|
<div className="px-4 pt-2 pb-1 flex justify-between items-center text-white text-[10px] font-medium bg-black/20">
|
||||||
|
<span>9:41</span>
|
||||||
|
<div className="flex gap-0.5 items-center">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
>
|
||||||
|
signal_cellular_alt
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
>
|
||||||
|
wifi
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
>
|
||||||
|
battery_full
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 알림 카드 */}
|
||||||
|
<div
|
||||||
|
className={`absolute ${isLarge ? "top-16 left-3 right-3" : "top-12 left-2 right-2"} bg-white/80 backdrop-blur-md rounded-lg shadow-lg border border-white/30 overflow-hidden`}
|
||||||
|
>
|
||||||
|
<div className="p-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="size-6 bg-[#2563EB] rounded-md flex items-center justify-center flex-shrink-0">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-white"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
>
|
||||||
|
shopping_bag
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-[10px] font-bold text-[#0f172a] break-words">
|
||||||
|
{title || "메시지 제목을 입력하세요"}
|
||||||
|
</p>
|
||||||
|
<p className="text-[8px] text-gray-600 mt-0.5 break-words">
|
||||||
|
{truncatedBody}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-[8px] text-gray-400 flex-shrink-0 self-start">
|
||||||
|
now
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasImage && (
|
||||||
|
<div
|
||||||
|
className="w-full bg-gray-100 border-t border-dashed border-gray-200 flex items-center justify-center"
|
||||||
|
style={{ height: isLarge ? 150 : 120 }}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-gray-300 text-2xl">
|
||||||
|
image
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 홈 인디케이터 */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-5 flex items-center justify-center">
|
||||||
|
<div className="w-24 h-1 bg-white/60 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Android 프리뷰 */}
|
||||||
|
{tab === "android" && (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{/* Banner Notification */}
|
||||||
|
<div className="min-h-[130px]">
|
||||||
|
<p className="text-[10px] uppercase font-bold text-gray-400 tracking-wider mb-2">
|
||||||
|
Banner Notification
|
||||||
|
</p>
|
||||||
|
<div className="bg-white rounded-2xl p-3 shadow-lg border border-gray-200">
|
||||||
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
|
<div className="size-3.5 bg-[#2563EB] rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-white"
|
||||||
|
style={{ fontSize: "8px" }}
|
||||||
|
>
|
||||||
|
shopping_bag
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-500 font-medium">{appName}</p>
|
||||||
|
<span className="text-[10px] text-gray-300 mx-0.5">·</span>
|
||||||
|
<p className="text-[10px] text-gray-400">now</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-bold text-[#0f172a] break-words">
|
||||||
|
{title || "메시지 제목을 입력하세요"}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-gray-600 mt-0.5 break-words">
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{hasImage && (
|
||||||
|
<div className="w-8 h-8 bg-gray-100 border border-dashed border-gray-300 rounded flex items-center justify-center flex-shrink-0">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-gray-300"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
image
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full Screen Preview - Android */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase font-bold text-gray-400 tracking-wider mb-2">
|
||||||
|
Full Screen Preview
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className={`relative ${phoneWidth}`}>
|
||||||
|
<div
|
||||||
|
className={`${isLarge ? "border-[10px]" : "border-[8px]"} border-gray-800 bg-gray-800 rounded-[2rem] overflow-hidden shadow-xl`}
|
||||||
|
style={{ aspectRatio: "9/19.5" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full h-full bg-cover bg-center overflow-hidden relative"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url('${PHONE_BG}')`,
|
||||||
|
filter: "brightness(1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 상태바 */}
|
||||||
|
<div className="px-4 pt-2 pb-1 flex justify-between items-center text-white text-[10px] font-medium bg-black/20">
|
||||||
|
<span>12:30</span>
|
||||||
|
<div className="flex gap-0.5 items-center">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
>
|
||||||
|
signal_cellular_alt
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
>
|
||||||
|
wifi
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
>
|
||||||
|
battery_full
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 알림 카드 */}
|
||||||
|
<div
|
||||||
|
className={`absolute ${isLarge ? "top-14" : "top-11"} left-2 right-2 bg-white/90 rounded-xl shadow-lg overflow-hidden`}
|
||||||
|
>
|
||||||
|
<div className="px-2.5 pt-2 pb-1.5">
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
<div className="size-3 bg-[#2563EB] rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-white"
|
||||||
|
style={{ fontSize: "7px" }}
|
||||||
|
>
|
||||||
|
shopping_bag
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[8px] text-gray-500 font-medium">
|
||||||
|
{appName}
|
||||||
|
</p>
|
||||||
|
<span className="text-[8px] text-gray-300 mx-0.5">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<p className="text-[8px] text-gray-400">now</p>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-gray-400 ml-auto"
|
||||||
|
style={{ fontSize: "10px" }}
|
||||||
|
>
|
||||||
|
expand_more
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-[10px] font-bold text-[#0f172a] break-words">
|
||||||
|
{title || "메시지 제목을 입력하세요"}
|
||||||
|
</p>
|
||||||
|
<p className="text-[8px] text-gray-600 mt-0.5 break-words">
|
||||||
|
{truncatedBody}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{hasImage && (
|
||||||
|
<div className="w-8 h-8 bg-gray-100 border border-dashed border-gray-200 rounded flex items-center justify-center flex-shrink-0">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-gray-300"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
>
|
||||||
|
image
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasImage && (
|
||||||
|
<div
|
||||||
|
className="w-full bg-gray-100 border-t border-dashed border-gray-200 flex items-center justify-center"
|
||||||
|
style={{ height: isLarge ? 150 : 120 }}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-gray-300 text-2xl">
|
||||||
|
image
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 네비게이션 바 */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-4 flex items-center justify-center gap-10 bg-black/10">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-white/60"
|
||||||
|
style={{ fontSize: "13px" }}
|
||||||
|
>
|
||||||
|
arrow_back
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-white/60"
|
||||||
|
style={{ fontSize: "13px" }}
|
||||||
|
>
|
||||||
|
circle
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-white/60"
|
||||||
|
style={{ fontSize: "13px" }}
|
||||||
|
>
|
||||||
|
crop_square
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-[10px] text-gray-400 leading-relaxed text-center mt-4">
|
||||||
|
이 미리보기는 예상 디자인입니다.
|
||||||
|
<br />
|
||||||
|
실제 표시는 기기 설정 및 OS 버전에 따라 다를 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
325
react/src/features/message/components/MessageSlidePanel.tsx
Normal file
325
react/src/features/message/components/MessageSlidePanel.tsx
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import CopyButton from "@/components/common/CopyButton";
|
||||||
|
import MessagePreview from "./MessagePreview";
|
||||||
|
import { fetchMessageInfo, deleteMessage } from "@/api/message.api";
|
||||||
|
import type { MessageInfoResponse } from "../types";
|
||||||
|
|
||||||
|
interface MessageSlidePanelProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
messageCode: string | null;
|
||||||
|
serviceCode: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageSlidePanel({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
messageCode,
|
||||||
|
serviceCode,
|
||||||
|
}: MessageSlidePanelProps) {
|
||||||
|
const [detail, setDetail] = useState<MessageInfoResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const bodyRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 메시지 상세 조회
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !messageCode || !serviceCode) {
|
||||||
|
setDetail(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetchMessageInfo(
|
||||||
|
{ message_code: messageCode },
|
||||||
|
serviceCode,
|
||||||
|
);
|
||||||
|
setDetail(res.data.data);
|
||||||
|
} catch {
|
||||||
|
setDetail(null);
|
||||||
|
toast.error("메시지 상세 조회에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [isOpen, messageCode, serviceCode]);
|
||||||
|
|
||||||
|
// 패널 열릴 때 스크롤 최상단으로 리셋
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
bodyRef.current?.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
}, [isOpen, messageCode]);
|
||||||
|
|
||||||
|
// ESC 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && isOpen) onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
// 패널 열릴 때 body 스크롤 잠금
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// 삭제 처리
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!detail?.message_code || !serviceCode) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteMessage(
|
||||||
|
{ message_code: detail.message_code },
|
||||||
|
serviceCode,
|
||||||
|
);
|
||||||
|
toast.success(`${detail.message_code} 메시지가 삭제되었습니다.`);
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
toast.error("메시지 삭제에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기타 정보 문자열 변환
|
||||||
|
const extraText =
|
||||||
|
detail?.data != null
|
||||||
|
? typeof detail.data === "string"
|
||||||
|
? detail.data
|
||||||
|
: JSON.stringify(detail.data, null, 2)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 오버레이 */}
|
||||||
|
<div
|
||||||
|
className={`fixed inset-0 bg-black/30 z-50 transition-opacity duration-300 ${
|
||||||
|
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 패널 */}
|
||||||
|
<aside
|
||||||
|
className={`fixed top-0 right-0 h-full w-[480px] bg-white shadow-2xl z-50 flex flex-col transition-transform duration-300 ${
|
||||||
|
isOpen ? "translate-x-0" : "translate-x-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 패널 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-bold text-[#0f172a]">메시지 상세</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-lg hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xl">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 패널 본문 */}
|
||||||
|
<div ref={bodyRef} className="flex-1 overflow-y-auto p-6">
|
||||||
|
{loading ? (
|
||||||
|
/* 로딩 스켈레톤 */
|
||||||
|
<div className="space-y-5">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse mb-2" />
|
||||||
|
<div className="h-9 w-full rounded bg-gray-100 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : detail ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* 메시지 ID */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider flex-shrink-0">
|
||||||
|
메시지 ID
|
||||||
|
</label>
|
||||||
|
<code className="text-sm font-medium text-[#2563EB] bg-[#2563EB]/5 px-2 py-0.5 rounded">
|
||||||
|
{detail.message_code}
|
||||||
|
</code>
|
||||||
|
<CopyButton text={detail.message_code ?? ""} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-gray-100" />
|
||||||
|
|
||||||
|
{/* 서비스 선택 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
서비스 선택
|
||||||
|
</label>
|
||||||
|
<p className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700">
|
||||||
|
{detail.service_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 제목 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
제목
|
||||||
|
</label>
|
||||||
|
<p className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700">
|
||||||
|
{detail.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 내용 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
내용
|
||||||
|
</label>
|
||||||
|
<div className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700 leading-relaxed min-h-[120px]">
|
||||||
|
{detail.body}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이미지 URL */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
이미지 URL
|
||||||
|
</label>
|
||||||
|
<p
|
||||||
|
className={`w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm truncate ${
|
||||||
|
detail.image_url
|
||||||
|
? "text-gray-500"
|
||||||
|
: "italic text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{detail.image_url || "등록된 이미지 없음"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 링크 URL */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<label className="text-sm font-medium text-[#0f172a]">
|
||||||
|
링크 URL
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
className={`text-[11px] font-medium px-1.5 py-0.5 rounded ${
|
||||||
|
detail.link_type === "deeplink"
|
||||||
|
? "bg-purple-100 text-purple-700"
|
||||||
|
: "bg-blue-100 text-blue-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{detail.link_type === "deeplink" ? "딥링크" : "웹 링크"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-500 truncate">
|
||||||
|
{detail.link_url || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기타 정보 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
기타 정보
|
||||||
|
</label>
|
||||||
|
<div className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700 leading-relaxed min-h-[80px] whitespace-pre-line">
|
||||||
|
{extraText || "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-gray-100" />
|
||||||
|
|
||||||
|
{/* 프리뷰 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-3">
|
||||||
|
프리뷰
|
||||||
|
</label>
|
||||||
|
<MessagePreview
|
||||||
|
title={detail.title ?? ""}
|
||||||
|
body={detail.body ?? ""}
|
||||||
|
hasImage={!!detail.image_url}
|
||||||
|
appName={detail.service_name ?? ""}
|
||||||
|
variant="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-gray-100" />
|
||||||
|
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="w-full flex items-center justify-center border border-red-300 text-red-600 hover:bg-red-50 px-5 py-2.5 rounded text-sm font-medium transition"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
|
||||||
|
메시지를 선택해주세요
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 삭제 확인 모달 */}
|
||||||
|
{showDeleteConfirm && detail && (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="size-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="material-symbols-outlined text-red-600 text-xl">
|
||||||
|
warning
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-[#0f172a]">메시지 삭제</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[#0f172a] mb-2">
|
||||||
|
<strong>{detail.message_code}</strong> 메시지를 삭제하시겠습니까?
|
||||||
|
</p>
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 mb-5">
|
||||||
|
<div className="flex items-center gap-1.5 text-red-700 text-xs font-medium">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>삭제 후 복구가 불가능합니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
disabled={deleting}
|
||||||
|
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-base">
|
||||||
|
delete
|
||||||
|
</span>
|
||||||
|
<span>{deleting ? "삭제 중..." : "삭제"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
react/src/features/message/hooks/.gitkeep
Normal file
0
react/src/features/message/hooks/.gitkeep
Normal file
333
react/src/features/message/pages/MessageListPage.tsx
Normal file
333
react/src/features/message/pages/MessageListPage.tsx
Normal file
|
|
@ -0,0 +1,333 @@
|
||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
|
import PageHeader from "@/components/common/PageHeader";
|
||||||
|
import SearchInput from "@/components/common/SearchInput";
|
||||||
|
import FilterDropdown from "@/components/common/FilterDropdown";
|
||||||
|
import FilterResetButton from "@/components/common/FilterResetButton";
|
||||||
|
import Pagination from "@/components/common/Pagination";
|
||||||
|
import EmptyState from "@/components/common/EmptyState";
|
||||||
|
import CopyButton from "@/components/common/CopyButton";
|
||||||
|
import MessageSlidePanel from "../components/MessageSlidePanel";
|
||||||
|
import { formatDate } from "@/utils/format";
|
||||||
|
import { fetchMessages } from "@/api/message.api";
|
||||||
|
import { fetchServices } from "@/api/service.api";
|
||||||
|
import type { MessageListItem } from "../types";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
export default function MessageListPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
// 필터 입력 상태
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [serviceFilter, setServiceFilter] = useState("전체 서비스");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 실제 적용될 필터 (조회 버튼 클릭 시 반영)
|
||||||
|
const [appliedSearch, setAppliedSearch] = useState("");
|
||||||
|
const [appliedService, setAppliedService] = useState("전체 서비스");
|
||||||
|
|
||||||
|
// 데이터 상태
|
||||||
|
const [items, setItems] = useState<MessageListItem[]>([]);
|
||||||
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
|
||||||
|
// 서비스 필터 옵션
|
||||||
|
const [serviceFilterOptions, setServiceFilterOptions] = useState<string[]>([
|
||||||
|
"전체 서비스",
|
||||||
|
]);
|
||||||
|
const [serviceCodeMap, setServiceCodeMap] = useState<
|
||||||
|
Record<string, string>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
// 슬라이드 패널 상태
|
||||||
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
|
const [selectedMessageCode, setSelectedMessageCode] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [selectedServiceCode, setSelectedServiceCode] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 서비스 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchServices({ page: 1, pageSize: 100 });
|
||||||
|
const svcItems = res.data.data.items ?? [];
|
||||||
|
const names = svcItems.map((s) => s.serviceName);
|
||||||
|
setServiceFilterOptions(["전체 서비스", ...names]);
|
||||||
|
const codeMap: Record<string, string> = {};
|
||||||
|
svcItems.forEach((s) => {
|
||||||
|
codeMap[s.serviceName] = s.serviceCode;
|
||||||
|
});
|
||||||
|
setServiceCodeMap(codeMap);
|
||||||
|
} catch {
|
||||||
|
// 서비스 목록 로드 실패 시 기본값 유지
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadData = useCallback(
|
||||||
|
async (page: number, keyword: string, serviceName: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// 서비스명 → 서비스코드 변환
|
||||||
|
const serviceCode =
|
||||||
|
serviceName !== "전체 서비스"
|
||||||
|
? serviceCodeMap[serviceName] || undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const res = await fetchMessages({
|
||||||
|
page,
|
||||||
|
size: PAGE_SIZE,
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
service_code: serviceCode,
|
||||||
|
});
|
||||||
|
const data = res.data.data;
|
||||||
|
setItems(data.items ?? []);
|
||||||
|
setTotalItems(data.totalCount ?? 0);
|
||||||
|
setTotalPages(data.totalPages ?? 1);
|
||||||
|
} catch {
|
||||||
|
setItems([]);
|
||||||
|
setTotalItems(0);
|
||||||
|
setTotalPages(1);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[serviceCodeMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
loadData(1, "", "전체 서비스");
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// URL 쿼리 파라미터로 messageId가 넘어온 경우 자동 검색
|
||||||
|
useEffect(() => {
|
||||||
|
const messageId = searchParams.get("messageId");
|
||||||
|
if (messageId) {
|
||||||
|
setSearch(messageId);
|
||||||
|
setAppliedSearch(messageId);
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
loadData(1, messageId, "전체 서비스");
|
||||||
|
}
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// 조회 버튼
|
||||||
|
const handleQuery = () => {
|
||||||
|
setAppliedSearch(search);
|
||||||
|
setAppliedService(serviceFilter);
|
||||||
|
setCurrentPage(1);
|
||||||
|
loadData(1, search, serviceFilter);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터 초기화
|
||||||
|
const handleReset = () => {
|
||||||
|
setSearch("");
|
||||||
|
setServiceFilter("전체 서비스");
|
||||||
|
setAppliedSearch("");
|
||||||
|
setAppliedService("전체 서비스");
|
||||||
|
setCurrentPage(1);
|
||||||
|
loadData(1, "", "전체 서비스");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지 변경
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
loadData(page, appliedSearch, appliedService);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 행 클릭 → 슬라이드 패널
|
||||||
|
const handleRowClick = (item: MessageListItem) => {
|
||||||
|
setSelectedMessageCode(item.message_code);
|
||||||
|
setSelectedServiceCode(item.service_code);
|
||||||
|
setPanelOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="메시지 목록"
|
||||||
|
description="시스템에서 발송된 모든 메시지 내역을 관리합니다."
|
||||||
|
action={
|
||||||
|
<Link
|
||||||
|
to="/messages/register"
|
||||||
|
className="flex items-center justify-center gap-2 min-w-[154px] px-5 py-2.5 bg-[#2563EB] hover:bg-[#1d4ed8] text-white rounded-lg text-sm font-medium transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg">add</span>
|
||||||
|
새 메시지
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 필터바 */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
|
||||||
|
<div className="flex items-end gap-4">
|
||||||
|
<SearchInput
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
placeholder="검색어를 입력하세요"
|
||||||
|
label="메시지 ID / 제목"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<FilterDropdown
|
||||||
|
label="서비스 구분"
|
||||||
|
value={serviceFilter}
|
||||||
|
options={serviceFilterOptions}
|
||||||
|
onChange={setServiceFilter}
|
||||||
|
className="w-[140px] flex-shrink-0"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<FilterResetButton onClick={handleReset} disabled={loading} />
|
||||||
|
<button
|
||||||
|
onClick={handleQuery}
|
||||||
|
disabled={loading}
|
||||||
|
className="h-[38px] flex-shrink-0 bg-gray-800 hover:bg-gray-900 active:scale-95 text-white rounded-lg px-5 text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
조회
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
{loading ? (
|
||||||
|
/* 로딩 스켈레톤 */
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
메시지 ID
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
메시지 제목
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
서비스
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
작성일
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<tr
|
||||||
|
key={i}
|
||||||
|
className={i < 4 ? "border-b border-gray-100" : ""}
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="h-4 w-48 rounded bg-gray-100 animate-pulse" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-24 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : items.length > 0 ? (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
메시지 ID
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
메시지 제목
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
서비스
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
작성일
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((msg, idx) => (
|
||||||
|
<tr
|
||||||
|
key={msg.message_code ?? idx}
|
||||||
|
className={`${idx < items.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`}
|
||||||
|
onClick={() => handleRowClick(msg)}
|
||||||
|
>
|
||||||
|
{/* 메시지 ID */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center gap-2"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<code className="text-sm text-gray-700 font-medium">
|
||||||
|
{msg.message_code}
|
||||||
|
</code>
|
||||||
|
<CopyButton text={msg.message_code ?? ""} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/* 메시지 제목 */}
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="text-sm text-gray-700 truncate max-w-xs block">
|
||||||
|
{msg.title}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{/* 서비스 */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{msg.service_name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{/* 작성일 */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{formatDate(msg.created_at ?? "")}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalItems={totalItems}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon="search_off"
|
||||||
|
message="검색 결과가 없습니다"
|
||||||
|
description="다른 검색어를 입력하거나 필터를 변경해보세요."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 슬라이드 패널 */}
|
||||||
|
<MessageSlidePanel
|
||||||
|
isOpen={panelOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setPanelOpen(false);
|
||||||
|
// 삭제 후 목록 새로고침
|
||||||
|
loadData(currentPage, appliedSearch, appliedService);
|
||||||
|
}}
|
||||||
|
messageCode={selectedMessageCode}
|
||||||
|
serviceCode={selectedServiceCode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
455
react/src/features/message/pages/MessageRegisterPage.tsx
Normal file
455
react/src/features/message/pages/MessageRegisterPage.tsx
Normal file
|
|
@ -0,0 +1,455 @@
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import PageHeader from "@/components/common/PageHeader";
|
||||||
|
import useShake from "@/hooks/useShake";
|
||||||
|
import MessagePreview from "../components/MessagePreview";
|
||||||
|
import { LINK_TYPE } from "../types";
|
||||||
|
import type { LinkType } from "../types";
|
||||||
|
import { fetchServices } from "@/api/service.api";
|
||||||
|
import { validateMessage, saveMessage } from "@/api/message.api";
|
||||||
|
|
||||||
|
interface ServiceOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageRegisterPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { triggerShake, cls } = useShake();
|
||||||
|
|
||||||
|
// 서비스 옵션 (API에서 로드)
|
||||||
|
const [serviceOptions, setServiceOptions] = useState<ServiceOption[]>([]);
|
||||||
|
|
||||||
|
// 폼 상태
|
||||||
|
const [service, setService] = useState("");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [imageUrl, setImageUrl] = useState("");
|
||||||
|
const [linkUrl, setLinkUrl] = useState("");
|
||||||
|
const [linkType, setLinkType] = useState<LinkType>(LINK_TYPE.WEB);
|
||||||
|
const [extra, setExtra] = useState("");
|
||||||
|
|
||||||
|
// 서비스 드롭다운 상태
|
||||||
|
const [serviceOpen, setServiceOpen] = useState(false);
|
||||||
|
const serviceDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 필드 ref (스크롤용)
|
||||||
|
const serviceRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 에러 메시지 상태 (shake와 별도로 유지)
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 확인 모달 상태
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 서비스 드롭다운 외부 클릭 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (
|
||||||
|
serviceDropdownRef.current &&
|
||||||
|
!serviceDropdownRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setServiceOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("click", handleClick);
|
||||||
|
return () => document.removeEventListener("click", handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 서비스 옵션 로드
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchServices({ page: 1, pageSize: 100 });
|
||||||
|
const items = res.data.data.items ?? [];
|
||||||
|
setServiceOptions(
|
||||||
|
items.map((s) => ({ value: s.serviceCode, label: s.serviceName })),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// 로드 실패 시 빈 배열 유지
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
const validate = (): boolean => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
const shakeFields: string[] = [];
|
||||||
|
|
||||||
|
if (!service) {
|
||||||
|
newErrors.service = "필수 입력 항목입니다.";
|
||||||
|
shakeFields.push("service");
|
||||||
|
}
|
||||||
|
if (!title.trim()) {
|
||||||
|
newErrors.title = "필수 입력 항목입니다.";
|
||||||
|
shakeFields.push("title");
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
if (shakeFields.length > 0) {
|
||||||
|
triggerShake(shakeFields);
|
||||||
|
// 첫 번째 에러 필드로 스크롤 + 포커스
|
||||||
|
const firstRef = shakeFields[0] === "service" ? serviceRef : titleRef;
|
||||||
|
firstRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
firstRef.current?.focus({ preventScroll: true });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장 버튼 클릭
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!validate()) return;
|
||||||
|
setShowConfirm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 확인 → 저장 실행
|
||||||
|
const handleConfirmSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// 서버 검증
|
||||||
|
await validateMessage(
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
image_url: imageUrl || null,
|
||||||
|
link_url: linkUrl || null,
|
||||||
|
link_type: linkType || null,
|
||||||
|
data: extra || undefined,
|
||||||
|
},
|
||||||
|
service,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
await saveMessage(
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
body: body || null,
|
||||||
|
image_url: imageUrl || null,
|
||||||
|
link_url: linkUrl || null,
|
||||||
|
link_type: linkType || null,
|
||||||
|
data: extra || null,
|
||||||
|
},
|
||||||
|
service,
|
||||||
|
);
|
||||||
|
|
||||||
|
setShowConfirm(false);
|
||||||
|
toast.success("저장이 완료되었습니다.");
|
||||||
|
setTimeout(() => navigate("/messages"), 600);
|
||||||
|
} catch {
|
||||||
|
setShowConfirm(false);
|
||||||
|
toast.error("저장에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 입력 시 해당 필드 에러 제거
|
||||||
|
const clearError = (field: string) => {
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[field];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="메시지 작성"
|
||||||
|
description="새로운 푸시 메시지를 작성하고 실시간 미리보기를 확인하세요."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 12-col 그리드 */}
|
||||||
|
<div className="grid grid-cols-12 gap-8">
|
||||||
|
{/* 좌측: 폼 (col-span-7) */}
|
||||||
|
<div className="col-span-7">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
{/* 카드 헤더 */}
|
||||||
|
<div className="flex items-center gap-3 px-6 py-4 border-b border-gray-100 bg-gray-50">
|
||||||
|
<span className="material-symbols-outlined text-xl text-[#2563EB]">
|
||||||
|
edit_note
|
||||||
|
</span>
|
||||||
|
<h2 className="text-base font-bold text-[#0f172a]">새 메시지</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 폼 */}
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* 1. 서비스 선택 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
서비스 선택 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative" ref={serviceDropdownRef}>
|
||||||
|
<button
|
||||||
|
ref={serviceRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setServiceOpen((v) => !v)}
|
||||||
|
className={`w-full h-[42px] border rounded px-4 text-sm flex items-center justify-between bg-white hover:border-gray-400 transition-colors cursor-pointer ${
|
||||||
|
!service ? "text-gray-400" : "text-[#0f172a]"
|
||||||
|
} ${errors.service ? "border-red-500 ring-2 ring-red-500/15" : "border-gray-300"} ${cls("service")}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{service
|
||||||
|
? serviceOptions.find((o) => o.value === service)
|
||||||
|
?.label
|
||||||
|
: "서비스를 선택하세요"}
|
||||||
|
</span>
|
||||||
|
<span className="material-symbols-outlined text-gray-400 text-[18px] flex-shrink-0">
|
||||||
|
expand_more
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{serviceOpen && (
|
||||||
|
<ul className="absolute left-0 top-full mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg z-50 py-1 overflow-hidden">
|
||||||
|
{serviceOptions.map((opt) => (
|
||||||
|
<li
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => {
|
||||||
|
setService(opt.value);
|
||||||
|
clearError("service");
|
||||||
|
setServiceOpen(false);
|
||||||
|
}}
|
||||||
|
className={`px-4 py-2.5 text-sm hover:bg-gray-50 cursor-pointer transition-colors ${
|
||||||
|
opt.value === service
|
||||||
|
? "text-[#2563EB] font-medium"
|
||||||
|
: "text-[#0f172a]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors.service && (
|
||||||
|
<p className="flex items-center gap-1 mt-1.5 text-red-500 text-xs">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
{errors.service}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. 제목 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
제목 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={titleRef}
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTitle(e.target.value);
|
||||||
|
clearError("title");
|
||||||
|
}}
|
||||||
|
placeholder="메시지 제목을 입력하세요"
|
||||||
|
className={`w-full px-4 py-2.5 bg-white border rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 text-[#0f172a] ${
|
||||||
|
errors.title
|
||||||
|
? "border-red-500 ring-2 ring-red-500/15"
|
||||||
|
: "border-gray-300"
|
||||||
|
} ${cls("title")}`}
|
||||||
|
/>
|
||||||
|
{errors.title && (
|
||||||
|
<p className="flex items-center gap-1 mt-1.5 text-red-500 text-xs">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
{errors.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. 내용 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
내용
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
placeholder="메시지 내용을 입력하세요"
|
||||||
|
rows={6}
|
||||||
|
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 text-[#0f172a] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 4. 이미지 URL */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
이미지 URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={imageUrl}
|
||||||
|
onChange={(e) => setImageUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 text-[#0f172a]"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-gray-400 mt-1.5 flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
URL을 입력하면 프리뷰에서 이미지 표시 위치를 확인할 수
|
||||||
|
있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5. 링크 URL + 라디오 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
링크 URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={linkUrl}
|
||||||
|
onChange={(e) => setLinkUrl(e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 text-[#0f172a] mb-3"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="linkType"
|
||||||
|
value={LINK_TYPE.WEB}
|
||||||
|
checked={linkType === LINK_TYPE.WEB}
|
||||||
|
onChange={() => setLinkType(LINK_TYPE.WEB)}
|
||||||
|
className="w-4 h-4 text-[#2563EB] cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-[#0f172a] font-medium">
|
||||||
|
웹 URL
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="linkType"
|
||||||
|
value={LINK_TYPE.DEEPLINK}
|
||||||
|
checked={linkType === LINK_TYPE.DEEPLINK}
|
||||||
|
onChange={() => setLinkType(LINK_TYPE.DEEPLINK)}
|
||||||
|
className="w-4 h-4 text-[#2563EB] cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-[#0f172a] font-medium">
|
||||||
|
딥링크
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 6. 기타 정보 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
기타 정보
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={extra}
|
||||||
|
onChange={(e) => setExtra(e.target.value)}
|
||||||
|
placeholder="추가 정보를 입력하세요"
|
||||||
|
rows={6}
|
||||||
|
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 text-[#0f172a] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 저장 버튼 */}
|
||||||
|
<div className="pt-4 border-t border-gray-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex items-center justify-center gap-2 bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-6 py-2.5 rounded text-sm font-medium transition shadow-sm shadow-[#2563EB]/30 w-full"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 프리뷰 (col-span-5, sticky) */}
|
||||||
|
<div className="col-span-5">
|
||||||
|
<div
|
||||||
|
className="sticky bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden p-6"
|
||||||
|
style={{ top: "6rem" }}
|
||||||
|
>
|
||||||
|
<MessagePreview
|
||||||
|
title={title}
|
||||||
|
body={body}
|
||||||
|
hasImage={!!imageUrl.trim()}
|
||||||
|
appName={serviceOptions.find((o) => o.value === service)?.label}
|
||||||
|
variant="large"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 확인 모달 */}
|
||||||
|
{showConfirm && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/40 z-[100] flex items-center justify-center"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setShowConfirm(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-xl shadow-2xl max-w-md w-full p-6 border border-gray-200">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="size-10 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="material-symbols-outlined text-amber-600 text-xl">
|
||||||
|
warning
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-[#0f172a]">저장 확인</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[#0f172a] mb-2">
|
||||||
|
입력한 내용으로 저장하시겠습니까?
|
||||||
|
</p>
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-5">
|
||||||
|
<div className="flex items-center gap-1.5 text-amber-700 text-xs font-medium">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>저장 후 수정이 불가능합니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm(false)}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirmSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm shadow-[#2563EB]/30 flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-base">
|
||||||
|
check
|
||||||
|
</span>
|
||||||
|
<span>{saving ? "저장 중..." : "확인"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
react/src/features/message/types.ts
Normal file
93
react/src/features/message/types.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
// 링크 타입
|
||||||
|
export const LINK_TYPE = {
|
||||||
|
WEB: "web",
|
||||||
|
DEEPLINK: "deeplink",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type LinkType = (typeof LINK_TYPE)[keyof typeof LINK_TYPE];
|
||||||
|
|
||||||
|
// ── 목록 ──
|
||||||
|
|
||||||
|
/** 목록 요청 */
|
||||||
|
export interface MessageListRequest {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
keyword?: string | null;
|
||||||
|
is_active?: boolean | null;
|
||||||
|
service_code?: string | null;
|
||||||
|
send_status?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 목록 응답 아이템 */
|
||||||
|
export interface MessageListItem {
|
||||||
|
message_code: string | null;
|
||||||
|
title: string | null;
|
||||||
|
service_name: string | null;
|
||||||
|
service_code: string | null;
|
||||||
|
send_status: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 목록 응답 */
|
||||||
|
export interface MessageListResponse {
|
||||||
|
items: MessageListItem[] | null;
|
||||||
|
totalCount: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 상세 ──
|
||||||
|
|
||||||
|
/** 상세 요청 */
|
||||||
|
export interface MessageInfoRequest {
|
||||||
|
message_code: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 상세 응답 */
|
||||||
|
export interface MessageInfoResponse {
|
||||||
|
message_code: string | null;
|
||||||
|
title: string | null;
|
||||||
|
body: string | null;
|
||||||
|
image_url: string | null;
|
||||||
|
link_url: string | null;
|
||||||
|
link_type: string | null;
|
||||||
|
data: unknown;
|
||||||
|
service_name: string | null;
|
||||||
|
service_code: string | null;
|
||||||
|
created_by_name: string | null;
|
||||||
|
latest_send_status: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 저장 ──
|
||||||
|
|
||||||
|
/** 저장 요청 */
|
||||||
|
export interface MessageSaveRequest {
|
||||||
|
title: string | null;
|
||||||
|
body: string | null;
|
||||||
|
image_url: string | null;
|
||||||
|
link_url: string | null;
|
||||||
|
link_type: string | null;
|
||||||
|
data: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 삭제 ──
|
||||||
|
|
||||||
|
/** 삭제 요청 */
|
||||||
|
export interface MessageDeleteRequest {
|
||||||
|
message_code: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 검증 ──
|
||||||
|
|
||||||
|
/** 검증 요청 */
|
||||||
|
export interface MessageValidateRequest {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
image_url?: string | null;
|
||||||
|
link_url?: string | null;
|
||||||
|
link_type?: string | null;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
1182
react/src/features/service/components/PlatformManagement.tsx
Normal file
1182
react/src/features/service/components/PlatformManagement.tsx
Normal file
File diff suppressed because it is too large
Load Diff
356
react/src/features/service/components/PlatformSelector.tsx
Normal file
356
react/src/features/service/components/PlatformSelector.tsx
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
// Apple 로고 SVG
|
||||||
|
const AppleLogo = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface PlatformSelectorProps {
|
||||||
|
androidChecked: boolean;
|
||||||
|
iosChecked: boolean;
|
||||||
|
onAndroidChange: (checked: boolean) => void;
|
||||||
|
onIosChange: (checked: boolean) => void;
|
||||||
|
iosAuthType: "p8" | "p12";
|
||||||
|
onIosAuthTypeChange: (type: "p8" | "p12") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlatformSelector({
|
||||||
|
androidChecked,
|
||||||
|
iosChecked,
|
||||||
|
onAndroidChange,
|
||||||
|
onIosChange,
|
||||||
|
iosAuthType,
|
||||||
|
onIosAuthTypeChange,
|
||||||
|
}: PlatformSelectorProps) {
|
||||||
|
const [androidFile, setAndroidFile] = useState<File | null>(null);
|
||||||
|
const [iosFile, setIosFile] = useState<File | null>(null);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-3">
|
||||||
|
플랫폼
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* ── Android 카드 ── */}
|
||||||
|
<div
|
||||||
|
onClick={() => { if (!androidChecked) onAndroidChange(true); }}
|
||||||
|
className={`p-4 rounded-lg border border-gray-200 bg-white transition-opacity ${androidChecked ? "opacity-100" : "opacity-50 cursor-pointer hover:opacity-70"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-3" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="android-checkbox"
|
||||||
|
checked={androidChecked}
|
||||||
|
onChange={(e) => {
|
||||||
|
onAndroidChange(e.target.checked);
|
||||||
|
if (!e.target.checked) setAndroidFile(null);
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 text-[#2563EB] focus:ring-[#2563EB] cursor-pointer"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="android-checkbox"
|
||||||
|
className="flex items-center gap-2 text-sm font-medium text-[#0f172a] cursor-pointer flex-1"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[#22C55E]" style={{ fontSize: "20px" }}>
|
||||||
|
android
|
||||||
|
</span>
|
||||||
|
<span>Android</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 파일 업로드 */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-[#64748b] mb-2">
|
||||||
|
인증서 파일
|
||||||
|
</p>
|
||||||
|
{!androidFile ? (
|
||||||
|
androidChecked ? (
|
||||||
|
<label
|
||||||
|
className="flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg p-6 text-center transition-colors cursor-pointer hover:border-[#2563EB] hover:bg-[#2563EB]/5"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-gray-400 text-3xl mb-2">
|
||||||
|
upload_file
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-[#64748b] font-medium">
|
||||||
|
클릭하거나 파일을 드래그하여 업로드
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
.json 파일만 업로드 가능합니다
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept=".json"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) setAndroidFile(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg p-6 text-center"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-gray-400 text-3xl mb-2">
|
||||||
|
lock
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-[#64748b] font-medium">
|
||||||
|
플랫폼을 선택하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3 bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
|
<span className="material-symbols-outlined text-[#2563EB] text-xl flex-shrink-0">
|
||||||
|
description
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-[#0f172a] truncate">
|
||||||
|
{androidFile.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#64748b]">
|
||||||
|
{(androidFile.size / 1024).toFixed(1)} KB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAndroidFile(null)}
|
||||||
|
className="text-gray-400 hover:text-red-500 transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── iOS 카드 ── */}
|
||||||
|
<div
|
||||||
|
onClick={() => { if (!iosChecked) onIosChange(true); }}
|
||||||
|
className={`p-4 rounded-lg border border-gray-200 bg-white transition-opacity ${iosChecked ? "opacity-100" : "opacity-50 cursor-pointer hover:opacity-70"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-3" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="ios-checkbox"
|
||||||
|
checked={iosChecked}
|
||||||
|
onChange={(e) => {
|
||||||
|
onIosChange(e.target.checked);
|
||||||
|
if (!e.target.checked) {
|
||||||
|
setIosFile(null);
|
||||||
|
setShowPassword(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 text-[#2563EB] focus:ring-[#2563EB] cursor-pointer"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="ios-checkbox"
|
||||||
|
className="flex items-center gap-2 text-sm font-medium text-[#0f172a] cursor-pointer flex-1"
|
||||||
|
>
|
||||||
|
<AppleLogo className="w-5 h-5" />
|
||||||
|
<span>iOS</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* iOS 미선택 시 — Android와 동일 */}
|
||||||
|
{!iosChecked && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-[#64748b] mb-2">
|
||||||
|
인증서 파일
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-gray-400 text-3xl flex justify-center mb-2">
|
||||||
|
lock
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-[#64748b] font-medium">
|
||||||
|
플랫폼을 선택하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* iOS 선택됨 — 상세 페이지 모달과 동일 레이아웃 */}
|
||||||
|
{iosChecked && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* 인증 방식 선택 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
인증 방식
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onIosAuthTypeChange("p8"); setIosFile(null); }}
|
||||||
|
className={`p-3 rounded-lg border-2 text-left transition ${
|
||||||
|
iosAuthType === "p8"
|
||||||
|
? "border-[#2563EB] bg-[#2563EB]/5"
|
||||||
|
: "border-gray-200 bg-white hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span
|
||||||
|
className={`material-symbols-outlined ${iosAuthType === "p8" ? "text-[#2563EB]" : "text-gray-500"}`}
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
vpn_key
|
||||||
|
</span>
|
||||||
|
<span className={`text-sm font-medium ${iosAuthType === "p8" ? "text-[#2563EB]" : "text-[#0f172a]"}`}>
|
||||||
|
Token (.p8)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-[#64748b]">
|
||||||
|
APNs Auth Key 기반, 만료 없음 (권장)
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onIosAuthTypeChange("p12"); setIosFile(null); }}
|
||||||
|
className={`p-3 rounded-lg border-2 text-left transition ${
|
||||||
|
iosAuthType === "p12"
|
||||||
|
? "border-[#2563EB] bg-[#2563EB]/5"
|
||||||
|
: "border-gray-200 bg-white hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span
|
||||||
|
className={`material-symbols-outlined ${iosAuthType === "p12" ? "text-[#2563EB]" : "text-gray-500"}`}
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
badge
|
||||||
|
</span>
|
||||||
|
<span className={`text-sm font-medium ${iosAuthType === "p12" ? "text-[#2563EB]" : "text-[#0f172a]"}`}>
|
||||||
|
Certificate (.p12)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-[#64748b]">
|
||||||
|
인증서 기반, 1년마다 갱신 필요
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 인증서 파일 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-2">
|
||||||
|
인증서 파일
|
||||||
|
</label>
|
||||||
|
{!iosFile ? (
|
||||||
|
<label className="flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg p-6 cursor-pointer hover:border-[#2563EB] hover:bg-[#2563EB]/5 transition text-center">
|
||||||
|
<span className="material-symbols-outlined text-gray-400 text-3xl mb-2">
|
||||||
|
upload_file
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-[#0f172a] font-medium">
|
||||||
|
클릭하거나 파일을 드래그하여 업로드
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#64748b] mt-1">
|
||||||
|
{iosAuthType === "p8"
|
||||||
|
? ".p8 파일만 업로드 가능합니다"
|
||||||
|
: ".p12 파일만 업로드 가능합니다"}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept={iosAuthType === "p8" ? ".p8" : ".p12"}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) setIosFile(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3 bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
|
<span className="material-symbols-outlined text-[#2563EB] text-xl flex-shrink-0">
|
||||||
|
description
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-[#0f172a] truncate">
|
||||||
|
{iosFile.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#64748b]">
|
||||||
|
{(iosFile.size / 1024).toFixed(1)} KB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIosFile(null)}
|
||||||
|
className="text-gray-400 hover:text-red-500 transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* P8: Key ID + Team ID */}
|
||||||
|
{iosAuthType === "p8" && (
|
||||||
|
<div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||||
|
Key ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="예: ABC123DEFG"
|
||||||
|
className="w-full px-3 py-2.5 border border-gray-300 rounded text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||||
|
Team ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="예: 9ABCDEFGH1"
|
||||||
|
className="w-full px-3 py-2.5 border border-gray-300 rounded text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-[#64748b] text-xs mt-2">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>Apple Developer 포털에서 확인할 수 있습니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* P12: 비밀번호 */}
|
||||||
|
{iosAuthType === "p12" && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||||
|
P12 비밀번호
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="P12 인증서 비밀번호를 입력하세요"
|
||||||
|
className="w-full px-3 py-2.5 pr-10 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg">
|
||||||
|
{showPassword ? "visibility" : "visibility_off"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
import type { PlatformCredentialSummary } from "../types";
|
||||||
|
|
||||||
|
// Apple 로고 SVG
|
||||||
|
const AppleLogo = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 상태별 dot 색상
|
||||||
|
const DOT_STYLES = {
|
||||||
|
warn: "bg-amber-500",
|
||||||
|
error: "bg-red-500",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// 상태별 툴팁 스타일
|
||||||
|
const TOOLTIP_STYLES = {
|
||||||
|
warn: "bg-[#fffbeb] text-[#b45309] border border-[#fde68a]",
|
||||||
|
error: "bg-[#fef2f2] text-[#dc2626] border border-[#fecaca]",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const TOOLTIP_ARROW = {
|
||||||
|
warn: "border-b-[#fde68a]",
|
||||||
|
error: "border-b-[#fecaca]",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const TOOLTIP_LABEL = {
|
||||||
|
warn: "주의",
|
||||||
|
error: "경고",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// 상태별 배지 스타일 (error 상태일 때 배지 자체도 회색)
|
||||||
|
const BADGE_ACTIVE = {
|
||||||
|
android: "bg-green-50 text-green-700 border-green-200",
|
||||||
|
ios: "bg-slate-100 text-slate-700 border-slate-200",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const BADGE_INACTIVE = "bg-gray-100 text-gray-400 border-gray-200";
|
||||||
|
|
||||||
|
interface PlatformStatusIndicatorProps {
|
||||||
|
platform: "android" | "ios";
|
||||||
|
credential: PlatformCredentialSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlatformStatusIndicator({
|
||||||
|
platform,
|
||||||
|
credential,
|
||||||
|
}: PlatformStatusIndicatorProps) {
|
||||||
|
if (!credential?.registered) return null;
|
||||||
|
|
||||||
|
const hasIssue =
|
||||||
|
credential.credentialStatus === "warn" ||
|
||||||
|
credential.credentialStatus === "error";
|
||||||
|
const badgeClass =
|
||||||
|
credential.credentialStatus === "error"
|
||||||
|
? BADGE_INACTIVE
|
||||||
|
: BADGE_ACTIVE[platform];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-flex group">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center w-8 h-6 rounded text-xs font-medium border ${badgeClass}`}
|
||||||
|
>
|
||||||
|
{platform === "android" ? (
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>android</span>
|
||||||
|
) : (
|
||||||
|
<AppleLogo className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{hasIssue && (credential.credentialStatus === "warn" || credential.credentialStatus === "error") && (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`absolute -top-1 -right-1 size-2.5 rounded-full border-2 border-white ${DOT_STYLES[credential.credentialStatus]}`}
|
||||||
|
/>
|
||||||
|
{/* 호버 툴팁 */}
|
||||||
|
<span
|
||||||
|
className={`absolute top-full left-1/2 -translate-x-1/2 mt-1.5 text-[11px] font-medium px-2.5 py-0.5 rounded-md whitespace-nowrap opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-150 pointer-events-none z-50 ${TOOLTIP_STYLES[credential.credentialStatus]}`}
|
||||||
|
>
|
||||||
|
{/* 위쪽 화살표 */}
|
||||||
|
<span
|
||||||
|
className={`absolute bottom-full left-1/2 -translate-x-1/2 border-4 border-transparent ${TOOLTIP_ARROW[credential.credentialStatus]}`}
|
||||||
|
/>
|
||||||
|
{TOOLTIP_LABEL[credential.credentialStatus]}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
react/src/features/service/components/ServiceHeaderCard.tsx
Normal file
139
react/src/features/service/components/ServiceHeaderCard.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import StatusBadge from "@/components/common/StatusBadge";
|
||||||
|
import CopyButton from "@/components/common/CopyButton";
|
||||||
|
import PlatformStatusIndicator from "./PlatformStatusIndicator";
|
||||||
|
import { formatNumber } from "@/utils/format";
|
||||||
|
import type { ServiceDetail } from "../types";
|
||||||
|
import { SERVICE_STATUS } from "../types";
|
||||||
|
|
||||||
|
interface ServiceHeaderCardProps {
|
||||||
|
service: ServiceDetail;
|
||||||
|
onShowApiKey: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServiceHeaderCard({
|
||||||
|
service,
|
||||||
|
onShowApiKey,
|
||||||
|
onDelete,
|
||||||
|
}: ServiceHeaderCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm p-6 mb-6">
|
||||||
|
{/* 상단 행 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="size-14 rounded-xl bg-[#2563EB]/10 flex items-center justify-center">
|
||||||
|
<span className="material-symbols-outlined text-[#2563EB] text-3xl">
|
||||||
|
{service.serviceIcon || "hub"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-2xl font-bold text-[#0f172a]">
|
||||||
|
{service.serviceName}
|
||||||
|
</h2>
|
||||||
|
<StatusBadge
|
||||||
|
variant={
|
||||||
|
service.status === SERVICE_STATUS.ACTIVE ? "success" : "error"
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
service.status === SERVICE_STATUS.ACTIVE ? "활성" : "비활성"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 mt-1.5">
|
||||||
|
<div className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="text-sm text-[#64748b] font-mono">
|
||||||
|
{service.serviceCode}
|
||||||
|
</span>
|
||||||
|
<CopyButton text={service.serviceCode} />
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-300">|</span>
|
||||||
|
<button
|
||||||
|
onClick={onShowApiKey}
|
||||||
|
className="flex items-center gap-1 text-sm text-[#2563EB] hover:text-[#1d4ed8] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
vpn_key
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">API 키 확인</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className="border border-red-200 text-red-500 hover:bg-red-50 px-5 py-2.5 rounded text-sm font-medium transition flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-base">delete</span>
|
||||||
|
<span>삭제하기</span>
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to={`/services/${service.serviceCode}/edit`}
|
||||||
|
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-base">edit</span>
|
||||||
|
<span>수정하기</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메타 정보 */}
|
||||||
|
<div className="mt-6 pt-5 border-t border-gray-100 grid grid-cols-2 sm:grid-cols-4 gap-6">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
|
||||||
|
플랫폼
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2.5 mt-1.5">
|
||||||
|
{service.platforms ? (
|
||||||
|
<>
|
||||||
|
<PlatformStatusIndicator
|
||||||
|
platform="android"
|
||||||
|
credential={service.platforms.android}
|
||||||
|
/>
|
||||||
|
<PlatformStatusIndicator
|
||||||
|
platform="ios"
|
||||||
|
credential={service.platforms.ios}
|
||||||
|
/>
|
||||||
|
{!service.platforms?.android?.registered &&
|
||||||
|
!service.platforms?.ios?.registered && (
|
||||||
|
<span className="text-xs text-gray-400">없음</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">없음</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
|
||||||
|
등록 기기
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
|
||||||
|
{formatNumber(service.deviceCount)}대
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
|
||||||
|
생성일
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
|
||||||
|
{service.createdAt?.slice(0, 10)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
|
||||||
|
마지막 업데이트
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
|
||||||
|
{service.updatedAt?.slice(0, 10) ?? "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
react/src/features/service/components/ServiceStatsCards.tsx
Normal file
133
react/src/features/service/components/ServiceStatsCards.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
interface StatCard {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub: { type: "trend" | "stable"; text: string; color?: string };
|
||||||
|
icon: string;
|
||||||
|
iconBg: string;
|
||||||
|
iconColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServiceStatsCardsProps {
|
||||||
|
totalSent: number;
|
||||||
|
successRate: number;
|
||||||
|
deviceCount: number;
|
||||||
|
todaySent: number;
|
||||||
|
sentChangeRate?: number;
|
||||||
|
successRateChange?: number;
|
||||||
|
deviceCountChange?: number;
|
||||||
|
todaySentChangeRate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServiceStatsCards({
|
||||||
|
totalSent,
|
||||||
|
successRate,
|
||||||
|
deviceCount,
|
||||||
|
todaySent,
|
||||||
|
sentChangeRate = 0,
|
||||||
|
successRateChange = 0,
|
||||||
|
deviceCountChange = 0,
|
||||||
|
todaySentChangeRate = 0,
|
||||||
|
}: ServiceStatsCardsProps) {
|
||||||
|
const noChange: StatCard["sub"] = { type: "stable", text: "변동 없음" };
|
||||||
|
|
||||||
|
const cards: StatCard[] = [
|
||||||
|
{
|
||||||
|
label: "총 발송 수",
|
||||||
|
value: totalSent.toLocaleString(),
|
||||||
|
sub: sentChangeRate === 0
|
||||||
|
? noChange
|
||||||
|
: sentChangeRate > 0
|
||||||
|
? { type: "trend", text: `+${sentChangeRate.toLocaleString()}`, color: "text-indigo-600" }
|
||||||
|
: { type: "trend", text: `${sentChangeRate.toLocaleString()}`, color: "text-red-500" },
|
||||||
|
icon: "equalizer",
|
||||||
|
iconBg: "bg-indigo-50",
|
||||||
|
iconColor: "text-indigo-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "성공률",
|
||||||
|
value: `${successRate}%`,
|
||||||
|
sub: successRateChange === 0
|
||||||
|
? noChange
|
||||||
|
: successRateChange > 0
|
||||||
|
? { type: "trend", text: `+${successRateChange.toFixed(1)}%`, color: "text-emerald-600" }
|
||||||
|
: { type: "trend", text: `${successRateChange.toFixed(1)}%`, color: "text-red-500" },
|
||||||
|
icon: "check_circle",
|
||||||
|
iconBg: "bg-emerald-50",
|
||||||
|
iconColor: "text-emerald-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "등록 기기 수",
|
||||||
|
value: deviceCount.toLocaleString(),
|
||||||
|
sub: deviceCountChange === 0
|
||||||
|
? noChange
|
||||||
|
: deviceCountChange > 0
|
||||||
|
? { type: "trend", text: `+${deviceCountChange.toLocaleString()} today`, color: "text-amber-600" }
|
||||||
|
: { type: "trend", text: `${deviceCountChange.toLocaleString()} today`, color: "text-red-500" },
|
||||||
|
icon: "devices",
|
||||||
|
iconBg: "bg-amber-50",
|
||||||
|
iconColor: "text-amber-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "오늘 발송",
|
||||||
|
value: todaySent.toLocaleString(),
|
||||||
|
sub: todaySentChangeRate === 0
|
||||||
|
? noChange
|
||||||
|
: todaySentChangeRate > 0
|
||||||
|
? { type: "trend", text: `+${todaySentChangeRate.toLocaleString()}`, color: "text-[#2563EB]" }
|
||||||
|
: { type: "trend", text: `${todaySentChangeRate.toLocaleString()}`, color: "text-red-500" },
|
||||||
|
icon: "today",
|
||||||
|
iconBg: "bg-[#2563EB]/5",
|
||||||
|
iconColor: "text-[#2563EB]",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<div
|
||||||
|
key={card.label}
|
||||||
|
className="bg-white border border-gray-200 rounded-lg shadow-sm p-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
|
||||||
|
{card.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-[#0f172a] mt-2">
|
||||||
|
{card.value}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
{card.sub.type === "trend" && (
|
||||||
|
<p className={`text-xs ${card.sub.color ?? "text-green-600"} font-medium flex items-center gap-1`}>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
{card.sub.text.startsWith("-") ? "trending_down" : "trending_up"}
|
||||||
|
</span>
|
||||||
|
<span>{card.sub.text}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{card.sub.type === "stable" && (
|
||||||
|
<p className="text-xs text-gray-600 font-medium flex items-center gap-1">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-gray-400" />
|
||||||
|
<span>{card.sub.text}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`size-12 rounded-lg ${card.iconBg} flex items-center justify-center flex-shrink-0`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`material-symbols-outlined ${card.iconColor} text-2xl`}
|
||||||
|
>
|
||||||
|
{card.icon}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
313
react/src/features/service/pages/ServiceDetailPage.tsx
Normal file
313
react/src/features/service/pages/ServiceDetailPage.tsx
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import PageHeader from "@/components/common/PageHeader";
|
||||||
|
import CopyButton from "@/components/common/CopyButton";
|
||||||
|
import ServiceHeaderCard from "../components/ServiceHeaderCard";
|
||||||
|
import ServiceStatsCards from "../components/ServiceStatsCards";
|
||||||
|
import PlatformManagement from "../components/PlatformManagement";
|
||||||
|
import { fetchServiceDetail, fetchApiKey, deleteService } from "@/api/service.api";
|
||||||
|
import { fetchDashboard } from "@/api/dashboard.api";
|
||||||
|
import type { ServiceDetail } from "../types";
|
||||||
|
import type { DashboardKpi } from "@/features/dashboard/types";
|
||||||
|
|
||||||
|
export default function ServiceDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 데이터 상태
|
||||||
|
const [service, setService] = useState<ServiceDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
// 통계 상태
|
||||||
|
const [stats, setStats] = useState<DashboardKpi | null>(null);
|
||||||
|
|
||||||
|
// API 키 모달 상태
|
||||||
|
const [showApiKey, setShowApiKey] = useState(false);
|
||||||
|
const [fullApiKey, setFullApiKey] = useState<string | null>(null);
|
||||||
|
const [apiKeyLoading, setApiKeyLoading] = useState(false);
|
||||||
|
|
||||||
|
// 삭제 모달 상태
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
// 서비스 상세 + 통계 로드
|
||||||
|
const loadData = useCallback(async (serviceCode: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
|
||||||
|
// 오늘 날짜 기준 통계 요청
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [serviceRes, dashboardRes] = await Promise.allSettled([
|
||||||
|
fetchServiceDetail(serviceCode),
|
||||||
|
fetchDashboard({ start_date: today, end_date: today }, serviceCode),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (serviceRes.status === "fulfilled") {
|
||||||
|
setService(serviceRes.value.data.data);
|
||||||
|
} else {
|
||||||
|
setError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dashboardRes.status === "fulfilled") {
|
||||||
|
setStats(dashboardRes.value.data.data.kpi);
|
||||||
|
}
|
||||||
|
// 통계 실패 시 null 유지 → 0으로 폴백
|
||||||
|
} catch {
|
||||||
|
setError(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
loadData(id);
|
||||||
|
}, [id, loadData]);
|
||||||
|
|
||||||
|
// 플랫폼 변경 후 서비스 데이터 갱신 (로딩 스켈레톤 없이 조용히)
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
if (!id) return;
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
try {
|
||||||
|
const [serviceRes, dashboardRes] = await Promise.allSettled([
|
||||||
|
fetchServiceDetail(id),
|
||||||
|
fetchDashboard({ start_date: today, end_date: today }, id),
|
||||||
|
]);
|
||||||
|
if (serviceRes.status === "fulfilled") {
|
||||||
|
setService(serviceRes.value.data.data);
|
||||||
|
}
|
||||||
|
if (dashboardRes.status === "fulfilled") {
|
||||||
|
setStats(dashboardRes.value.data.data.kpi);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 리프레시 실패는 무시 (기존 데이터 유지)
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
// API 키 조회
|
||||||
|
const handleShowApiKey = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setApiKeyLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetchApiKey(id);
|
||||||
|
setFullApiKey(res.data.data.apiKey);
|
||||||
|
setShowApiKey(true);
|
||||||
|
} catch {
|
||||||
|
// 실패 시 마스킹된 키라도 보여줌
|
||||||
|
setFullApiKey(service?.apiKey ?? null);
|
||||||
|
setShowApiKey(true);
|
||||||
|
} finally {
|
||||||
|
setApiKeyLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseApiKey = () => {
|
||||||
|
setShowApiKey(false);
|
||||||
|
setFullApiKey(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 서비스 삭제
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteService(id);
|
||||||
|
toast.success("서비스가 삭제되었습니다.");
|
||||||
|
navigate("/services");
|
||||||
|
} catch {
|
||||||
|
toast.error("서비스 삭제에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 로딩 스켈레톤
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="서비스 상세 정보" />
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm p-6 mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="size-14 rounded-xl bg-gray-100 animate-pulse" />
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="h-6 w-48 rounded bg-gray-100 animate-pulse" />
|
||||||
|
<div className="h-4 w-32 rounded bg-gray-100 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 pt-5 border-t border-gray-100 grid grid-cols-4 gap-6">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex flex-col gap-2">
|
||||||
|
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
|
||||||
|
<div className="h-4 w-24 rounded bg-gray-100 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 에러 상태
|
||||||
|
if (error || !service) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="서비스 상세 정보" />
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm p-12 flex flex-col items-center justify-center gap-3">
|
||||||
|
<span className="material-symbols-outlined text-4xl text-gray-300">
|
||||||
|
cloud_off
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
서비스 정보를 불러오지 못했습니다.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="text-sm text-[#2563EB] hover:underline font-medium"
|
||||||
|
>
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 오늘 발송 = kpi.total_send (서비스별 조회 시 오늘 기간)
|
||||||
|
const todaySent = stats?.total_send ?? 0;
|
||||||
|
const successRate =
|
||||||
|
stats && stats.total_send > 0
|
||||||
|
? +(stats.total_success / stats.total_send * 100).toFixed(1)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="서비스 상세 정보" />
|
||||||
|
|
||||||
|
<ServiceHeaderCard
|
||||||
|
service={service}
|
||||||
|
onShowApiKey={handleShowApiKey}
|
||||||
|
onDelete={() => setShowDeleteConfirm(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ServiceStatsCards
|
||||||
|
totalSent={stats?.total_send ?? 0}
|
||||||
|
successRate={successRate}
|
||||||
|
deviceCount={service.deviceCount}
|
||||||
|
todaySent={todaySent}
|
||||||
|
sentChangeRate={stats?.today_sent_change_rate}
|
||||||
|
successRateChange={stats?.success_rate_change}
|
||||||
|
deviceCountChange={stats?.device_count_change}
|
||||||
|
todaySentChangeRate={stats?.today_sent_change_rate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PlatformManagement service={service} onRefresh={handleRefresh} />
|
||||||
|
|
||||||
|
{/* 삭제 확인 모달 */}
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="size-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="material-symbols-outlined text-red-600 text-xl">
|
||||||
|
warning
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-[#0f172a]">서비스 삭제</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[#0f172a] mb-2">
|
||||||
|
<span className="font-semibold">{service.serviceName}</span> 서비스를 삭제하시겠습니까?
|
||||||
|
</p>
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 mb-5 flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-red-700 text-xs font-medium">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: "14px" }}>info</span>
|
||||||
|
<span>삭제된 서비스는 복구할 수 없습니다.</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-red-700 text-xs font-medium">
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: "14px" }}>info</span>
|
||||||
|
<span>해당 서비스의 모든 기기 및 발송 이력이 비활성화됩니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
disabled={deleting}
|
||||||
|
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="bg-red-500 hover:bg-red-600 text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? "삭제 중..." : "삭제"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* API 키 확인 모달 */}
|
||||||
|
{showApiKey && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={handleCloseApiKey}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-white rounded-xl shadow-2xl max-w-lg w-full mx-4 p-6 border border-gray-200">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="size-10 rounded-full bg-[#2563EB]/10 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="material-symbols-outlined text-[#2563EB] text-xl">
|
||||||
|
vpn_key
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-[#0f172a]">API 키</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseApiKey}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xl">
|
||||||
|
close
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-4">
|
||||||
|
<div className="flex items-center gap-1.5 text-amber-700 text-xs font-medium">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
warning
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
API 키는 외부에 노출되지 않도록 주의해 주세요.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 flex items-center gap-3">
|
||||||
|
{apiKeyLoading ? (
|
||||||
|
<div className="flex-1 h-5 rounded bg-gray-200 animate-pulse" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<code className="flex-1 text-sm font-mono text-[#0f172a] break-all select-all">
|
||||||
|
{fullApiKey ?? service.apiKey}
|
||||||
|
</code>
|
||||||
|
<CopyButton text={fullApiKey ?? service.apiKey} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
570
react/src/features/service/pages/ServiceEditPage.tsx
Normal file
570
react/src/features/service/pages/ServiceEditPage.tsx
Normal file
|
|
@ -0,0 +1,570 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import PageHeader from "@/components/common/PageHeader";
|
||||||
|
import PlatformStatusIndicator from "../components/PlatformStatusIndicator";
|
||||||
|
import useShake from "@/hooks/useShake";
|
||||||
|
import { formatNumber } from "@/utils/format";
|
||||||
|
import { fetchServiceDetail, updateService } from "@/api/service.api";
|
||||||
|
import type { ApiError } from "@/types/api";
|
||||||
|
import type { ServiceDetail } from "../types";
|
||||||
|
import { SERVICE_STATUS } from "../types";
|
||||||
|
|
||||||
|
// Apple 로고 SVG
|
||||||
|
const AppleLogo = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function ServiceEditPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 데이터 상태
|
||||||
|
const [service, setService] = useState<ServiceDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
// 폼 상태 (service 로드 후 초기화)
|
||||||
|
const [serviceName, setServiceName] = useState("");
|
||||||
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
|
||||||
|
// 에러 상태
|
||||||
|
const [nameError, setNameError] = useState(false);
|
||||||
|
const [nameErrorMsg, setNameErrorMsg] = useState("필수 입력 항목입니다.");
|
||||||
|
const { triggerShake, cls } = useShake();
|
||||||
|
|
||||||
|
// 제출 상태
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||||
|
const [showCancelModal, setShowCancelModal] = useState(false);
|
||||||
|
|
||||||
|
// 서비스 상세 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
try {
|
||||||
|
const res = await fetchServiceDetail(id!);
|
||||||
|
if (!cancelled) {
|
||||||
|
setService(res.data.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
// service 로드 후 폼 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (service) {
|
||||||
|
setServiceName(service.serviceName);
|
||||||
|
setIsActive(service.status === SERVICE_STATUS.ACTIVE);
|
||||||
|
setDescription(service.description ?? "");
|
||||||
|
}
|
||||||
|
}, [service]);
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = () => {
|
||||||
|
const trimmed = serviceName.trim();
|
||||||
|
if (!trimmed || trimmed.length < 2) {
|
||||||
|
setNameError(true);
|
||||||
|
setNameErrorMsg(
|
||||||
|
!trimmed ? "필수 입력 항목입니다." : "서비스 명은 2자 이상이어야 합니다.",
|
||||||
|
);
|
||||||
|
triggerShake(["name"]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowSaveModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveConfirm = async () => {
|
||||||
|
setShowSaveModal(false);
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateService({
|
||||||
|
serviceCode: id!,
|
||||||
|
serviceName: serviceName.trim(),
|
||||||
|
description: description.trim() || null,
|
||||||
|
status: isActive ? 0 : 1,
|
||||||
|
});
|
||||||
|
toast.success("변경사항이 저장되었습니다.");
|
||||||
|
navigate(`/services/${id}`);
|
||||||
|
} catch (err) {
|
||||||
|
const axiosErr = err as AxiosError<ApiError>;
|
||||||
|
const status = axiosErr.response?.status;
|
||||||
|
const msg = axiosErr.response?.data?.msg;
|
||||||
|
|
||||||
|
if (status === 409) {
|
||||||
|
toast.error(msg ?? "이미 사용 중인 서비스 명입니다.");
|
||||||
|
setNameError(true);
|
||||||
|
setNameErrorMsg(msg ?? "이미 사용 중인 서비스 명입니다.");
|
||||||
|
triggerShake(["name"]);
|
||||||
|
} else if (status === 400) {
|
||||||
|
toast.error(msg ?? "변경된 내용이 없습니다.");
|
||||||
|
} else {
|
||||||
|
toast.error(msg ?? "저장에 실패했습니다. 다시 시도해주세요.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 로딩 스켈레톤
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="서비스 수정" />
|
||||||
|
<div className="border border-gray-200 rounded-lg bg-white shadow-sm p-8">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<div className="h-4 w-20 bg-gray-200 rounded animate-pulse mb-2" />
|
||||||
|
<div className="h-10 bg-gray-100 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-4 w-16 bg-gray-200 rounded animate-pulse mb-2" />
|
||||||
|
<div className="h-10 bg-gray-100 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-4 w-12 bg-gray-200 rounded animate-pulse mb-2" />
|
||||||
|
<div className="h-24 bg-gray-100 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 에러 상태
|
||||||
|
if (error || !service) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="서비스 수정" />
|
||||||
|
<div className="border border-gray-200 rounded-lg bg-white shadow-sm p-12 text-center">
|
||||||
|
<span className="material-symbols-outlined text-gray-300 text-5xl mb-3 block">
|
||||||
|
error_outline
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
서비스 정보를 불러올 수 없습니다.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/services")}
|
||||||
|
className="text-sm text-[#2563EB] hover:underline font-medium"
|
||||||
|
>
|
||||||
|
목록으로 돌아가기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="서비스 수정"
|
||||||
|
description="서비스 정보를 수정하고 저장하세요."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 폼 카드 */}
|
||||||
|
<div className="border border-gray-200 rounded-lg bg-white shadow-sm overflow-hidden mb-8">
|
||||||
|
<div className="p-8 space-y-6">
|
||||||
|
{/* 1. 서비스 명 + 상태 토글 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||||
|
서비스 명 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={serviceName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setServiceName(e.target.value);
|
||||||
|
if (e.target.value.trim()) setNameError(false);
|
||||||
|
}}
|
||||||
|
className={`flex-1 px-3 py-2.5 bg-white border rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 ${
|
||||||
|
nameError ? "border-red-500 ring-2 ring-red-500/15" : "border-gray-300"
|
||||||
|
} ${cls("name")}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsActive(!isActive)}
|
||||||
|
className={`w-[100px] inline-flex items-center justify-center gap-1.5 rounded px-3 py-2 text-xs font-medium cursor-pointer transition-colors flex-shrink-0 border ${
|
||||||
|
isActive
|
||||||
|
? "bg-green-50 border-green-200 text-green-700 hover:bg-green-100"
|
||||||
|
: "bg-gray-100 border-gray-300 text-gray-500 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
{isActive ? "check_circle" : "cancel"}
|
||||||
|
</span>
|
||||||
|
<span>{isActive ? "활성" : "비활성"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{nameError && (
|
||||||
|
<div className="flex items-center gap-1.5 text-red-600 text-xs mt-1.5">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span>{nameErrorMsg}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. 서비스 ID (읽기 전용) */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||||
|
서비스 ID
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={service.serviceCode}
|
||||||
|
disabled
|
||||||
|
className="flex-1 px-3 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-500 cursor-not-allowed font-mono"
|
||||||
|
/>
|
||||||
|
<div className="w-[100px] flex items-center justify-center gap-1.5 bg-amber-50 border border-amber-200 rounded px-2 py-2 flex-shrink-0">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-amber-600"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
lock
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-amber-700 font-medium whitespace-nowrap">
|
||||||
|
변경 불가
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. 설명 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||||
|
설명
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
className="w-full px-3 py-2.5 bg-white border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메타 정보 (읽기 전용) */}
|
||||||
|
<div className="pt-4 border-t border-gray-100">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-6">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
|
||||||
|
플랫폼
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1.5 mt-1.5">
|
||||||
|
{service.platforms ? (
|
||||||
|
<>
|
||||||
|
<PlatformStatusIndicator
|
||||||
|
platform="android"
|
||||||
|
credential={service.platforms.android}
|
||||||
|
/>
|
||||||
|
<PlatformStatusIndicator
|
||||||
|
platform="ios"
|
||||||
|
credential={service.platforms.ios}
|
||||||
|
/>
|
||||||
|
{!service.platforms?.android?.registered &&
|
||||||
|
!service.platforms?.ios?.registered && (
|
||||||
|
<span className="text-xs text-gray-400">없음</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">없음</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
|
||||||
|
등록 기기
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
|
||||||
|
{formatNumber(service.deviceCount)}대
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
|
||||||
|
생성일
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
|
||||||
|
{service.createdAt?.slice(0, 10)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
|
||||||
|
마지막 업데이트
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
|
||||||
|
{service.updatedAt?.slice(0, 10) ?? "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 액션바 */}
|
||||||
|
<div className="px-8 py-5 bg-gray-50 border-t border-gray-200 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCancelModal(true)}
|
||||||
|
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={submitting}
|
||||||
|
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{submitting ? "저장 중..." : "저장하기"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 플랫폼 관리 (읽기 전용) */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||||
|
<div className="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-bold text-[#0f172a]">플랫폼 관리</h3>
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs text-[#64748b] bg-gray-50 border border-gray-200 px-3 py-1.5 rounded-full">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
서비스 상세에서 관리 가능
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Android */}
|
||||||
|
{service.platforms?.android?.registered && (
|
||||||
|
<div className="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4 flex-1">
|
||||||
|
<div className="size-12 rounded-lg bg-green-50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="material-symbols-outlined text-green-600 text-2xl">
|
||||||
|
android
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-sm font-bold text-[#0f172a]">Android</h4>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">
|
||||||
|
Google FCM을 통한 Android 기기 관리
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
{service.platforms.android.credentialStatus === "error" ? (
|
||||||
|
<>
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-50 text-red-700 border border-red-200">
|
||||||
|
미인증
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-red-600 font-medium">
|
||||||
|
{service.platforms.android.statusReason}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : service.platforms.android.credentialStatus === "warn" ? (
|
||||||
|
<>
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-700 border border-amber-200">
|
||||||
|
조치 필요
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-amber-600 font-medium">
|
||||||
|
{service.platforms.android.statusReason}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 border border-green-200">
|
||||||
|
인증됨
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* iOS */}
|
||||||
|
{service.platforms?.ios?.registered && (
|
||||||
|
<div className="px-6 py-5 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4 flex-1">
|
||||||
|
<div className="size-12 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<AppleLogo className="w-6 h-6 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-sm font-bold text-[#0f172a]">iOS</h4>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">
|
||||||
|
Apple APNs를 통한 iOS 기기 관리
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
{service.platforms.ios.credentialStatus === "error" ? (
|
||||||
|
<>
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-50 text-red-700 border border-red-200">
|
||||||
|
미인증
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-red-600 font-medium">
|
||||||
|
{service.platforms.ios.statusReason}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : service.platforms.ios.credentialStatus === "warn" ? (
|
||||||
|
<>
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-700 border border-amber-200">
|
||||||
|
조치 필요
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-amber-600 font-medium">
|
||||||
|
{service.platforms.ios.statusReason}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 border border-green-200">
|
||||||
|
인증됨
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 빈 상태 */}
|
||||||
|
{!service.platforms?.android?.registered &&
|
||||||
|
!service.platforms?.ios?.registered && (
|
||||||
|
<div className="px-6 py-12 text-center">
|
||||||
|
<span className="material-symbols-outlined text-gray-300 text-5xl mb-3 block">
|
||||||
|
devices
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
등록된 플랫폼이 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 저장 확인 모달 */}
|
||||||
|
{showSaveModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={() => setShowSaveModal(false)}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="size-10 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="material-symbols-outlined text-blue-600 text-xl">
|
||||||
|
save
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-[#0f172a]">
|
||||||
|
변경사항 저장
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[#0f172a] mb-2">
|
||||||
|
수정한 내용을 저장하시겠습니까?
|
||||||
|
</p>
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg px-4 py-3 mb-5">
|
||||||
|
<div className="flex items-center gap-1.5 text-blue-700 text-xs font-medium">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>저장 후 서비스 상세 페이지로 이동합니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSaveModal(false)}
|
||||||
|
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveConfirm}
|
||||||
|
disabled={submitting}
|
||||||
|
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 취소 확인 모달 */}
|
||||||
|
{showCancelModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={() => setShowCancelModal(false)}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="size-10 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="material-symbols-outlined text-amber-600 text-xl">
|
||||||
|
warning
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-[#0f172a]">수정 취소</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[#0f172a] mb-2">
|
||||||
|
수정을 취소하시겠습니까?
|
||||||
|
</p>
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-5">
|
||||||
|
<div className="flex items-center gap-1.5 text-amber-700 text-xs font-medium">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>저장하지 않은 변경사항은 모두 사라집니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCancelModal(false)}
|
||||||
|
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
|
||||||
|
>
|
||||||
|
계속 수정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/services/${id}`)}
|
||||||
|
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-base">
|
||||||
|
check
|
||||||
|
</span>
|
||||||
|
<span>확인</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
344
react/src/features/service/pages/ServiceListPage.tsx
Normal file
344
react/src/features/service/pages/ServiceListPage.tsx
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import PageHeader from "@/components/common/PageHeader";
|
||||||
|
import SearchInput from "@/components/common/SearchInput";
|
||||||
|
import FilterDropdown from "@/components/common/FilterDropdown";
|
||||||
|
import FilterResetButton from "@/components/common/FilterResetButton";
|
||||||
|
import StatusBadge from "@/components/common/StatusBadge";
|
||||||
|
import Pagination from "@/components/common/Pagination";
|
||||||
|
import EmptyState from "@/components/common/EmptyState";
|
||||||
|
import CopyButton from "@/components/common/CopyButton";
|
||||||
|
import PlatformStatusIndicator from "../components/PlatformStatusIndicator";
|
||||||
|
import { formatDate, formatNumber } from "@/utils/format";
|
||||||
|
import { fetchServices } from "@/api/service.api";
|
||||||
|
import { SERVICE_STATUS } from "../types";
|
||||||
|
import type { ServiceSummary } from "../types";
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = ["전체 상태", "활성", "비활성"];
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
// 상태 필터 → API status 값 매핑
|
||||||
|
function mapStatusFilter(filter: string): number | undefined {
|
||||||
|
if (filter === "활성") return 0;
|
||||||
|
if (filter === "비활성") return 1;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태값 배지
|
||||||
|
function getStatusBadge(status: ServiceSummary["status"]) {
|
||||||
|
if (status === SERVICE_STATUS.ACTIVE) {
|
||||||
|
return <StatusBadge variant="success" label="활성" />;
|
||||||
|
}
|
||||||
|
return <StatusBadge variant="error" label="비활성" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServiceListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 필터 상태
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("전체 상태");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
// 데이터 상태
|
||||||
|
const [items, setItems] = useState<ServiceSummary[]>([]);
|
||||||
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
// 실제 적용될 필터 (조회 버튼 클릭 시 반영)
|
||||||
|
const [appliedSearch, setAppliedSearch] = useState("");
|
||||||
|
const [appliedStatus, setAppliedStatus] = useState("전체 상태");
|
||||||
|
|
||||||
|
// API 호출
|
||||||
|
const loadData = useCallback(
|
||||||
|
async (page: number, searchKeyword: string, statusFilterValue: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
try {
|
||||||
|
const res = await fetchServices({
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
searchKeyword: searchKeyword || undefined,
|
||||||
|
status: mapStatusFilter(statusFilterValue),
|
||||||
|
});
|
||||||
|
const data = res.data.data;
|
||||||
|
setItems(data.items ?? []);
|
||||||
|
setTotalItems(data.totalCount ?? 0);
|
||||||
|
setTotalPages(data.totalPages ?? 1);
|
||||||
|
} catch {
|
||||||
|
setError(true);
|
||||||
|
setItems([]);
|
||||||
|
setTotalItems(0);
|
||||||
|
setTotalPages(1);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
loadData(1, "", "전체 상태");
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// 조회 버튼
|
||||||
|
const handleQuery = () => {
|
||||||
|
setAppliedSearch(search);
|
||||||
|
setAppliedStatus(statusFilter);
|
||||||
|
setCurrentPage(1);
|
||||||
|
loadData(1, search, statusFilter);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터 초기화
|
||||||
|
const handleReset = () => {
|
||||||
|
setSearch("");
|
||||||
|
setStatusFilter("전체 상태");
|
||||||
|
setAppliedSearch("");
|
||||||
|
setAppliedStatus("전체 상태");
|
||||||
|
setCurrentPage(1);
|
||||||
|
loadData(1, "", "전체 상태");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지 변경
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
loadData(page, appliedSearch, appliedStatus);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="서비스 관리"
|
||||||
|
description="등록된 서비스의 현황을 조회하고 관리할 수 있습니다."
|
||||||
|
action={
|
||||||
|
<Link
|
||||||
|
to="/services/register"
|
||||||
|
className="flex items-center justify-center gap-2 min-w-[154px] px-5 py-2.5 bg-[#2563EB] hover:bg-[#1d4ed8] text-white rounded-lg text-sm font-medium transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg">add</span>
|
||||||
|
서비스 등록
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 필터바 */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
|
||||||
|
<div className="flex items-end gap-4">
|
||||||
|
<SearchInput
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
placeholder="검색어를 입력하세요"
|
||||||
|
label="서비스명 / ID"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<FilterDropdown
|
||||||
|
label="상태"
|
||||||
|
value={statusFilter}
|
||||||
|
options={STATUS_OPTIONS}
|
||||||
|
onChange={setStatusFilter}
|
||||||
|
className="w-[140px] flex-shrink-0"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<FilterResetButton onClick={handleReset} disabled={loading} />
|
||||||
|
<button
|
||||||
|
onClick={handleQuery}
|
||||||
|
disabled={loading}
|
||||||
|
className="h-[38px] flex-shrink-0 bg-gray-800 hover:bg-gray-900 active:scale-95 text-white rounded-lg px-5 text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
조회
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 상태 */}
|
||||||
|
{error ? (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm p-12 flex flex-col items-center justify-center gap-3">
|
||||||
|
<span className="material-symbols-outlined text-4xl text-gray-300">
|
||||||
|
cloud_off
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
데이터를 불러오지 못했습니다.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => loadData(currentPage, appliedSearch, appliedStatus)}
|
||||||
|
className="text-sm text-[#2563EB] hover:underline font-medium"
|
||||||
|
>
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
/* 로딩 스켈레톤 */
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
서비스명
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
서비스 ID
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
플랫폼
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
기기 수
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
상태
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
등록일
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<tr
|
||||||
|
key={i}
|
||||||
|
className={i < 4 ? "border-b border-gray-100" : ""}
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="size-8 rounded-lg bg-gray-100 animate-pulse" />
|
||||||
|
<div className="h-4 w-24 rounded bg-gray-100 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-28 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1.5">
|
||||||
|
<div className="w-8 h-6 rounded bg-gray-100 animate-pulse" />
|
||||||
|
<div className="w-8 h-6 rounded bg-gray-100 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-12 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-5 w-14 rounded-full bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : items.length > 0 ? (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
서비스명
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
서비스 ID
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
플랫폼
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
기기 수
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
상태
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
등록일
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((svc, idx) => (
|
||||||
|
<tr
|
||||||
|
key={svc.serviceCode}
|
||||||
|
className={`${idx < items.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`}
|
||||||
|
onClick={() => navigate(`/services/${svc.serviceCode}`)}
|
||||||
|
>
|
||||||
|
{/* 서비스명 */}
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="size-8 rounded-lg bg-[#2563EB]/10 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="material-symbols-outlined text-[#2563EB] text-base">
|
||||||
|
{svc.serviceIcon || "hub"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-[#0f172a]">
|
||||||
|
{svc.serviceName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/* 서비스 ID */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<code className="text-sm text-gray-700 font-medium">
|
||||||
|
{svc.serviceCode}
|
||||||
|
</code>
|
||||||
|
<CopyButton text={svc.serviceCode} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/* 플랫폼 */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1.5">
|
||||||
|
{svc.platforms ? (
|
||||||
|
<>
|
||||||
|
<PlatformStatusIndicator
|
||||||
|
platform="android"
|
||||||
|
credential={svc.platforms.android}
|
||||||
|
/>
|
||||||
|
<PlatformStatusIndicator
|
||||||
|
platform="ios"
|
||||||
|
credential={svc.platforms.ios}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/* 기기 수 */}
|
||||||
|
<td className="px-6 py-4 text-center text-sm text-gray-700 font-medium">
|
||||||
|
{formatNumber(svc.deviceCount)}
|
||||||
|
</td>
|
||||||
|
{/* 상태 */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
{getStatusBadge(svc.status)}
|
||||||
|
</td>
|
||||||
|
{/* 등록일 */}
|
||||||
|
<td className="px-6 py-4 text-center text-sm text-gray-500">
|
||||||
|
{formatDate(svc.createdAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalItems={totalItems}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon="search_off"
|
||||||
|
message="검색 결과가 없습니다"
|
||||||
|
description="다른 검색어를 입력하거나 필터를 변경해보세요."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
309
react/src/features/service/pages/ServiceRegisterPage.tsx
Normal file
309
react/src/features/service/pages/ServiceRegisterPage.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import PageHeader from "@/components/common/PageHeader";
|
||||||
|
import CopyButton from "@/components/common/CopyButton";
|
||||||
|
import PlatformSelector from "../components/PlatformSelector";
|
||||||
|
import useShake from "@/hooks/useShake";
|
||||||
|
import { createService } from "@/api/service.api";
|
||||||
|
import type { ApiError } from "@/types/api";
|
||||||
|
import type { CreateServiceResponse } from "../types";
|
||||||
|
|
||||||
|
export default function ServiceRegisterPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 폼 상태
|
||||||
|
const [serviceName, setServiceName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [relatedLink, setRelatedLink] = useState("");
|
||||||
|
const [androidChecked, setAndroidChecked] = useState(false);
|
||||||
|
const [iosChecked, setIosChecked] = useState(false);
|
||||||
|
const [iosAuthType, setIosAuthType] = useState<"p8" | "p12">("p8");
|
||||||
|
|
||||||
|
// 에러 상태
|
||||||
|
const [nameError, setNameError] = useState(false);
|
||||||
|
const [nameErrorMsg, setNameErrorMsg] = useState("서비스 명을 입력해주세요.");
|
||||||
|
const { triggerShake, cls } = useShake();
|
||||||
|
|
||||||
|
// 제출 상태
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// 모달
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
|
// API Key 모달
|
||||||
|
const [apiKeyResult, setApiKeyResult] = useState<CreateServiceResponse | null>(null);
|
||||||
|
const [showApiKeyModal, setShowApiKeyModal] = useState(false);
|
||||||
|
|
||||||
|
// 등록 버튼 클릭
|
||||||
|
const handleRegister = () => {
|
||||||
|
const trimmed = serviceName.trim();
|
||||||
|
if (!trimmed || trimmed.length < 2) {
|
||||||
|
setNameError(true);
|
||||||
|
setNameErrorMsg(
|
||||||
|
!trimmed ? "서비스 명을 입력해주세요." : "서비스 명은 2자 이상이어야 합니다.",
|
||||||
|
);
|
||||||
|
triggerShake(["name"]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowConfirm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 등록 확인
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
setShowConfirm(false);
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await createService({
|
||||||
|
serviceName: serviceName.trim(),
|
||||||
|
description: description.trim() || null,
|
||||||
|
});
|
||||||
|
setApiKeyResult(res.data.data);
|
||||||
|
setShowApiKeyModal(true);
|
||||||
|
} catch (err) {
|
||||||
|
const axiosErr = err as AxiosError<ApiError>;
|
||||||
|
const status = axiosErr.response?.status;
|
||||||
|
const msg = axiosErr.response?.data?.msg;
|
||||||
|
|
||||||
|
if (status === 409) {
|
||||||
|
toast.error(msg ?? "이미 사용 중인 서비스 명입니다.");
|
||||||
|
setNameError(true);
|
||||||
|
setNameErrorMsg(msg ?? "이미 사용 중인 서비스 명입니다.");
|
||||||
|
triggerShake(["name"]);
|
||||||
|
} else {
|
||||||
|
toast.error(msg ?? "서비스 등록에 실패했습니다. 다시 시도해주세요.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API Key 모달 닫기 → 상세 페이지 이동
|
||||||
|
const handleApiKeyModalClose = () => {
|
||||||
|
setShowApiKeyModal(false);
|
||||||
|
if (apiKeyResult) {
|
||||||
|
navigate(`/services/${apiKeyResult.serviceCode}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="서비스 등록"
|
||||||
|
description="새로운 플랫폼 서비스를 등록하고 API 권한을 설정하세요."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 폼 카드 */}
|
||||||
|
<div className="border border-gray-200 rounded-lg bg-white shadow-sm overflow-hidden">
|
||||||
|
<div className="p-8 space-y-6">
|
||||||
|
{/* 1. 서비스 명 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||||
|
서비스 명 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={serviceName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setServiceName(e.target.value);
|
||||||
|
if (e.target.value.trim()) setNameError(false);
|
||||||
|
}}
|
||||||
|
placeholder="서비스 명을 입력하세요"
|
||||||
|
className={`w-full px-3 py-2.5 bg-white border rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 ${
|
||||||
|
nameError
|
||||||
|
? "border-red-500 ring-2 ring-red-500/15"
|
||||||
|
: "border-gray-300"
|
||||||
|
} ${cls("name")}`}
|
||||||
|
/>
|
||||||
|
{nameError && (
|
||||||
|
<div className="flex items-center gap-1.5 text-red-600 text-xs mt-1.5">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span>{nameErrorMsg}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. 플랫폼 선택 */}
|
||||||
|
<PlatformSelector
|
||||||
|
androidChecked={androidChecked}
|
||||||
|
iosChecked={iosChecked}
|
||||||
|
onAndroidChange={setAndroidChecked}
|
||||||
|
onIosChange={setIosChecked}
|
||||||
|
iosAuthType={iosAuthType}
|
||||||
|
onIosAuthTypeChange={setIosAuthType}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 4. 설명 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||||
|
설명
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="서비스에 대한 간략한 설명을 입력하세요"
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5. 관련 링크 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||||
|
관련 링크
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={relatedLink}
|
||||||
|
onChange={(e) => setRelatedLink(e.target.value)}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
className="w-full px-3 py-2.5 bg-white border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition-shadow placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1.5 text-gray-500 text-xs mt-1.5">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>서비스와 관련된 웹사이트나 문서 링크를 입력하세요.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 액션바 */}
|
||||||
|
<div className="px-8 py-5 bg-gray-50 border-t border-gray-200 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/services")}
|
||||||
|
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRegister}
|
||||||
|
disabled={submitting}
|
||||||
|
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{submitting ? "등록 중..." : "등록하기"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 등록 확인 모달 */}
|
||||||
|
{showConfirm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={() => setShowConfirm(false)}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="size-10 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="material-symbols-outlined text-amber-600 text-xl">
|
||||||
|
warning
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-[#0f172a]">
|
||||||
|
서비스 등록 확인
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[#0f172a] mb-2">
|
||||||
|
입력한 내용으로 서비스를 등록하시겠습니까?
|
||||||
|
</p>
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg px-4 py-3 mb-5">
|
||||||
|
<div className="flex items-center gap-1.5 text-blue-700 text-xs font-medium">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>서비스 ID는 등록 후 자동으로 생성됩니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm(false)}
|
||||||
|
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-base">
|
||||||
|
check
|
||||||
|
</span>
|
||||||
|
<span>등록</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* API Key 결과 모달 */}
|
||||||
|
{showApiKeyModal && apiKeyResult && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/40" />
|
||||||
|
<div className="relative bg-white rounded-xl shadow-2xl max-w-lg w-full mx-4 p-6 border border-gray-200">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="size-10 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="material-symbols-outlined text-green-600 text-xl">
|
||||||
|
check_circle
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-[#0f172a]">
|
||||||
|
서비스 등록 완료
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleApiKeyModalClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xl">
|
||||||
|
close
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-4">
|
||||||
|
<div className="flex items-center gap-1.5 text-amber-700 text-xs font-medium">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
warning
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
API 키는 외부에 노출되지 않도록 주의해 주세요.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 flex items-center gap-3 mb-4">
|
||||||
|
<code className="flex-1 text-sm font-mono text-[#0f172a] break-all select-all">
|
||||||
|
{apiKeyResult.apiKey}
|
||||||
|
</code>
|
||||||
|
<CopyButton text={apiKeyResult.apiKey} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={handleApiKeyModalClose}
|
||||||
|
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm"
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
react/src/features/service/types.ts
Normal file
114
react/src/features/service/types.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
// 서비스 상태
|
||||||
|
export const SERVICE_STATUS = {
|
||||||
|
ACTIVE: "Active",
|
||||||
|
SUSPENDED: "Suspended",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ServiceStatus = (typeof SERVICE_STATUS)[keyof typeof SERVICE_STATUS];
|
||||||
|
|
||||||
|
// 인증서 상태
|
||||||
|
export const CREDENTIAL_STATUS = {
|
||||||
|
OK: "ok",
|
||||||
|
WARN: "warn",
|
||||||
|
ERROR: "error",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CredentialStatus =
|
||||||
|
(typeof CREDENTIAL_STATUS)[keyof typeof CREDENTIAL_STATUS];
|
||||||
|
|
||||||
|
// 플랫폼 인증 요약
|
||||||
|
export interface PlatformCredentialSummary {
|
||||||
|
registered: boolean;
|
||||||
|
credentialStatus: CredentialStatus | null;
|
||||||
|
statusReason: string | null;
|
||||||
|
expiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서비스 목록 항목
|
||||||
|
export interface ServiceSummary {
|
||||||
|
serviceId: number;
|
||||||
|
serviceCode: string;
|
||||||
|
serviceName: string;
|
||||||
|
serviceIcon?: string;
|
||||||
|
description: string | null;
|
||||||
|
status: ServiceStatus;
|
||||||
|
createdAt: string;
|
||||||
|
deviceCount: number;
|
||||||
|
platforms: {
|
||||||
|
android: PlatformCredentialSummary;
|
||||||
|
ios: PlatformCredentialSummary;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서비스 상세
|
||||||
|
export interface ServiceDetail extends ServiceSummary {
|
||||||
|
apiKey: string;
|
||||||
|
apiKeyCreatedAt: string;
|
||||||
|
apnsAuthType: "p8" | "p12" | null;
|
||||||
|
hasApnsKey: boolean;
|
||||||
|
hasFcmCredentials: boolean;
|
||||||
|
createdByName: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
apnsBundleId?: string | null;
|
||||||
|
apnsKeyId?: string | null;
|
||||||
|
apnsTeamId?: string | null;
|
||||||
|
webhookUrl?: string | null;
|
||||||
|
tags?: string | null;
|
||||||
|
subTier?: string | null;
|
||||||
|
subStartedAt?: string | null;
|
||||||
|
allowedIps?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서비스 목록 요청
|
||||||
|
export interface ServiceListRequest {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
searchKeyword?: string;
|
||||||
|
status?: number; // 0=Active, 1=Suspended, undefined=전체
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 키 응답 (view/refresh 공용)
|
||||||
|
export interface ApiKeyResponse {
|
||||||
|
serviceCode: string;
|
||||||
|
apiKey: string;
|
||||||
|
apiKeyCreatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서비스 생성 요청
|
||||||
|
export interface CreateServiceRequest {
|
||||||
|
serviceName: string;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서비스 생성 응답 (API Key 1회 표시)
|
||||||
|
export interface CreateServiceResponse {
|
||||||
|
serviceCode: string;
|
||||||
|
apiKey: string;
|
||||||
|
apiKeyCreatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서비스 수정 요청
|
||||||
|
export interface UpdateServiceRequest {
|
||||||
|
serviceCode: string;
|
||||||
|
serviceName?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
status?: number | null; // 0=Active, 1=Suspended
|
||||||
|
}
|
||||||
|
|
||||||
|
// FCM 인증서 등록 요청
|
||||||
|
export interface RegisterFcmRequest {
|
||||||
|
serviceAccountJson: string; // JSON 파일 내용 (문자열)
|
||||||
|
}
|
||||||
|
|
||||||
|
// APNs 인증서 등록 요청
|
||||||
|
export interface RegisterApnsRequest {
|
||||||
|
authType: "p8" | "p12";
|
||||||
|
bundleId: string;
|
||||||
|
// p8 전용
|
||||||
|
keyId?: string;
|
||||||
|
teamId?: string;
|
||||||
|
privateKey?: string;
|
||||||
|
// p12 전용
|
||||||
|
certificateBase64?: string;
|
||||||
|
certPassword?: string;
|
||||||
|
}
|
||||||
0
react/src/features/settings/components/.gitkeep
Normal file
0
react/src/features/settings/components/.gitkeep
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import type { Notification, NotificationType } from "../types";
|
||||||
|
|
||||||
|
interface NotificationSlidePanelProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
notification: Notification | null;
|
||||||
|
onMarkRead: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 타입별 배지 스타일 */
|
||||||
|
const BADGE_STYLE: Record<
|
||||||
|
NotificationType,
|
||||||
|
{ bg: string; text: string; icon: string }
|
||||||
|
> = {
|
||||||
|
"발송": { bg: "bg-green-50", text: "text-green-700", icon: "check_circle" },
|
||||||
|
"인증서": { bg: "bg-amber-50", text: "text-amber-700", icon: "warning" },
|
||||||
|
"서비스": { bg: "bg-blue-50", text: "text-blue-700", icon: "add_circle" },
|
||||||
|
"실패": { bg: "bg-red-50", text: "text-red-600", icon: "error" },
|
||||||
|
"시스템": { bg: "bg-gray-100", text: "text-gray-600", icon: "settings" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 알림 상세 슬라이드 패널 */
|
||||||
|
export default function NotificationSlidePanel({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
notification,
|
||||||
|
onMarkRead,
|
||||||
|
}: NotificationSlidePanelProps) {
|
||||||
|
const bodyRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 패널 열릴 때 스크롤 최상단 리셋
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
bodyRef.current?.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
}, [isOpen, notification]);
|
||||||
|
|
||||||
|
// ESC 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && isOpen) onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
// body 스크롤 잠금
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const badge = notification ? BADGE_STYLE[notification.type] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 오버레이 */}
|
||||||
|
<div
|
||||||
|
className={`fixed inset-0 bg-black/30 z-50 transition-opacity duration-300 ${
|
||||||
|
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 패널 */}
|
||||||
|
<aside
|
||||||
|
className={`fixed top-0 right-0 h-full w-[480px] bg-white shadow-2xl z-50 flex flex-col transition-transform duration-300 ${
|
||||||
|
isOpen ? "translate-x-0" : "translate-x-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 flex-shrink-0">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900">알림 상세</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="size-8 flex items-center justify-center text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xl">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 */}
|
||||||
|
<div
|
||||||
|
ref={bodyRef}
|
||||||
|
className="flex-1 overflow-y-auto p-6 space-y-6"
|
||||||
|
style={{ overscrollBehavior: "contain" }}
|
||||||
|
>
|
||||||
|
{notification && badge ? (
|
||||||
|
<>
|
||||||
|
{/* 타입 배지 + 시간 + 날짜 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded ${badge.bg} ${badge.text} text-xs font-semibold`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "13px" }}
|
||||||
|
>
|
||||||
|
{badge.icon}
|
||||||
|
</span>
|
||||||
|
{notification.type}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{notification.date} {notification.time}
|
||||||
|
</span>
|
||||||
|
{/* 읽음 상태 */}
|
||||||
|
{notification.read ? (
|
||||||
|
<span className="ml-auto inline-flex items-center gap-1 text-xs text-gray-400">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
done_all
|
||||||
|
</span>
|
||||||
|
읽음
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="ml-auto inline-flex items-center gap-1 text-xs text-[#2563EB]">
|
||||||
|
<span className="size-2 rounded-full bg-[#2563EB]" />
|
||||||
|
미읽음
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 제목 */}
|
||||||
|
<h3 className="text-lg font-bold text-gray-900">
|
||||||
|
{notification.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 설명 (전체 표시) */}
|
||||||
|
<p className="text-sm text-gray-600 leading-relaxed">
|
||||||
|
{notification.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 상세 내용 */}
|
||||||
|
{notification.detail && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
|
||||||
|
상세 내용
|
||||||
|
</h4>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<pre className="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap font-sans">
|
||||||
|
{notification.detail}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
|
||||||
|
알림을 선택해주세요
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<div className="flex-shrink-0 px-6 py-4 border-t border-gray-200 flex gap-3">
|
||||||
|
{notification && !notification.read && (
|
||||||
|
<button
|
||||||
|
onClick={() => onMarkRead(notification.id)}
|
||||||
|
className="flex-1 px-4 py-2.5 bg-[#2563EB] text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
읽음 처리
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className={`px-4 py-2.5 border border-gray-200 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-50 transition-colors ${
|
||||||
|
notification && !notification.read ? "" : "w-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
react/src/features/settings/hooks/.gitkeep
Normal file
0
react/src/features/settings/hooks/.gitkeep
Normal file
302
react/src/features/settings/pages/MyPage.tsx
Normal file
302
react/src/features/settings/pages/MyPage.tsx
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import PageHeader from "@/components/common/PageHeader";
|
||||||
|
import Pagination from "@/components/common/Pagination";
|
||||||
|
import { fetchProfile, fetchActivityList } from "@/api/account.api";
|
||||||
|
import { formatDate, formatDateTime, formatRelativeTime } from "@/utils/format";
|
||||||
|
import {
|
||||||
|
ROLE_LABELS,
|
||||||
|
type UserProfile,
|
||||||
|
type Activity,
|
||||||
|
type ProfileResponse,
|
||||||
|
type ActivityItem,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
// 활동 타입별 아이콘 + 색상 매핑
|
||||||
|
const ACTIVITY_STYLE: Record<
|
||||||
|
Activity["type"],
|
||||||
|
{ icon: string; bg: string; text: string }
|
||||||
|
> = {
|
||||||
|
send: { icon: "send", bg: "bg-green-50", text: "text-green-600" },
|
||||||
|
service: { icon: "cloud", bg: "bg-blue-50", text: "text-blue-600" },
|
||||||
|
device: { icon: "verified", bg: "bg-amber-50", text: "text-amber-600" },
|
||||||
|
auth: { icon: "login", bg: "bg-gray-50", text: "text-gray-500" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 5;
|
||||||
|
|
||||||
|
/** ProfileResponse → UserProfile 뷰모델 변환 */
|
||||||
|
function mapProfile(res: ProfileResponse): UserProfile {
|
||||||
|
return {
|
||||||
|
name: res.name ?? "",
|
||||||
|
email: res.email ?? "",
|
||||||
|
role: ROLE_LABELS[res.role] ?? `역할(${res.role})`,
|
||||||
|
team: res.organization ?? "",
|
||||||
|
phone: res.phone ?? "",
|
||||||
|
joinDate: res.created_at ? formatDate(res.created_at) : "",
|
||||||
|
lastLogin: res.last_login_at ? formatDateTime(res.last_login_at) : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 활동 타입 매핑 */
|
||||||
|
function mapActivityType(type: string | null): Activity["type"] {
|
||||||
|
if (type === "send" || type === "service" || type === "device" || type === "auth") return type;
|
||||||
|
return "auth"; // fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ActivityItem → Activity 뷰모델 변환 */
|
||||||
|
function mapActivity(item: ActivityItem, index: number): Activity {
|
||||||
|
return {
|
||||||
|
id: index,
|
||||||
|
type: mapActivityType(item.activity_type),
|
||||||
|
title: item.title ?? "",
|
||||||
|
detail: item.description ?? "",
|
||||||
|
time: item.occurred_at ? formatRelativeTime(item.occurred_at) : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MyPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 프로필 상태
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
|
|
||||||
|
// 활동 내역 상태
|
||||||
|
const [activities, setActivities] = useState<Activity[]>([]);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
|
||||||
|
// 프로필 로드
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchProfile();
|
||||||
|
setProfile(mapProfile(res.data.data));
|
||||||
|
} catch {
|
||||||
|
// 에러 시 빈 프로필 유지
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 활동 내역 로드
|
||||||
|
const loadActivities = useCallback(async (page: number) => {
|
||||||
|
try {
|
||||||
|
const res = await fetchActivityList({ page, size: PAGE_SIZE });
|
||||||
|
const data = res.data.data;
|
||||||
|
setActivities((data.items ?? []).map(mapActivity));
|
||||||
|
setTotalItems(data.pagination.total_count);
|
||||||
|
setTotalPages(data.pagination.total_pages || 1);
|
||||||
|
} catch {
|
||||||
|
setActivities([]);
|
||||||
|
setTotalItems(0);
|
||||||
|
setTotalPages(1);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadActivities(currentPage);
|
||||||
|
}, [currentPage, loadActivities]);
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 프로필 로딩 중
|
||||||
|
if (!profile) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="마이페이지"
|
||||||
|
description="내 계정 정보와 활동 내역을 확인하고 관리할 수 있습니다"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-center py-20 text-sm text-gray-400">
|
||||||
|
불러오는 중...
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initials = profile.name
|
||||||
|
.split(" ")
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase() || "?";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="마이페이지"
|
||||||
|
description="내 계정 정보와 활동 내역을 확인하고 관리할 수 있습니다"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/settings/profile")}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm font-medium text-[#64748b] hover:bg-gray-50 hover:text-[#0f172a] transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-base">edit</span>
|
||||||
|
프로필 수정
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 프로필 카드 */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm mb-6">
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
{/* 아바타 */}
|
||||||
|
<div className="size-20 rounded-full bg-[#2563EB] flex items-center justify-center text-white text-2xl font-bold tracking-wide shadow-md flex-shrink-0">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<h2 className="text-xl font-bold text-[#0f172a]">
|
||||||
|
{profile.name}
|
||||||
|
</h2>
|
||||||
|
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-[#2563EB]/10 text-[#2563EB] border border-[#2563EB]/20">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
>
|
||||||
|
shield
|
||||||
|
</span>
|
||||||
|
{profile.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[#64748b]">{profile.email}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
마지막 로그인: {profile.lastLogin} (KST)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2열 그리드 */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
|
{/* 계정 정보 */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||||
|
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
|
||||||
|
<span className="material-symbols-outlined text-base text-[#2563EB]">
|
||||||
|
person
|
||||||
|
</span>
|
||||||
|
계정 정보
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{[
|
||||||
|
{ label: "소속", value: profile.team },
|
||||||
|
{ label: "연락처", value: profile.phone },
|
||||||
|
{ label: "가입일", value: profile.joinDate },
|
||||||
|
{ label: "최근 로그인", value: profile.lastLogin },
|
||||||
|
].map((item, i, arr) => (
|
||||||
|
<div key={item.label}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-[#64748b]">{item.label}</span>
|
||||||
|
<span className="text-sm font-medium text-[#0f172a]">
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{i < arr.length - 1 && (
|
||||||
|
<div className="h-px bg-gray-100 mt-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 보안 설정 (추후 개발) */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
|
||||||
|
<span className="material-symbols-outlined text-base text-[#2563EB]">
|
||||||
|
security
|
||||||
|
</span>
|
||||||
|
보안 설정
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-gray-400 bg-gray-100 px-2.5 py-1 rounded-full">
|
||||||
|
추후 개발 예정
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 최근 활동 내역 */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm mb-6">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||||
|
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
|
||||||
|
<span className="material-symbols-outlined text-base text-[#2563EB]">
|
||||||
|
history
|
||||||
|
</span>
|
||||||
|
최근 활동 내역
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-[#64748b]">최근 7일</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{activities.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
|
||||||
|
활동 내역이 없습니다
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
activities.map((activity) => {
|
||||||
|
const style = ACTIVITY_STYLE[activity.type];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={activity.id}
|
||||||
|
className="flex items-center gap-4 px-6 py-4 hover:bg-gray-50/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`size-9 rounded-full ${style.bg} flex items-center justify-center flex-shrink-0`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`material-symbols-outlined ${style.text} text-lg`}
|
||||||
|
>
|
||||||
|
{style.icon}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-[#0f172a] truncate">
|
||||||
|
{activity.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
|
{activity.detail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||||
|
{activity.time}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
<div className="border-t border-gray-100">
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalItems={totalItems}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 접속 기기 (추후 개발) */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
|
||||||
|
<span className="material-symbols-outlined text-base text-[#2563EB]">
|
||||||
|
devices
|
||||||
|
</span>
|
||||||
|
접속 기기
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-gray-400 bg-gray-100 px-2.5 py-1 rounded-full">
|
||||||
|
추후 개발 예정
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
react/src/features/settings/pages/NotificationsPage.tsx
Normal file
248
react/src/features/settings/pages/NotificationsPage.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import PageHeader from "@/components/common/PageHeader";
|
||||||
|
import { MOCK_NOTIFICATIONS, type Notification, type NotificationType } from "../types";
|
||||||
|
import NotificationSlidePanel from "../components/NotificationSlidePanel";
|
||||||
|
|
||||||
|
// 타입별 배지 스타일
|
||||||
|
const BADGE_STYLE: Record<
|
||||||
|
NotificationType,
|
||||||
|
{ bg: string; text: string; icon: string }
|
||||||
|
> = {
|
||||||
|
"발송": { bg: "bg-green-50", text: "text-green-700", icon: "check_circle" },
|
||||||
|
"인증서": { bg: "bg-amber-50", text: "text-amber-700", icon: "warning" },
|
||||||
|
"서비스": { bg: "bg-blue-50", text: "text-blue-700", icon: "add_circle" },
|
||||||
|
"실패": { bg: "bg-red-50", text: "text-red-600", icon: "error" },
|
||||||
|
"시스템": { bg: "bg-gray-100", text: "text-gray-600", icon: "settings" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 날짜 그룹 레이블 → 표시 날짜
|
||||||
|
const DATE_LABELS: Record<string, string> = {
|
||||||
|
"오늘": "2026-02-19",
|
||||||
|
"어제": "2026-02-18",
|
||||||
|
"이번 주": "2026.02.16 ~ 02.17",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 5;
|
||||||
|
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>(
|
||||||
|
() => [...MOCK_NOTIFICATIONS],
|
||||||
|
);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [selectedNotification, setSelectedNotification] =
|
||||||
|
useState<Notification | null>(null);
|
||||||
|
|
||||||
|
// 헤더 드롭다운에서 넘어온 경우 해당 알림 패널 자동 오픈
|
||||||
|
useEffect(() => {
|
||||||
|
const state = location.state as { notificationId?: number } | null;
|
||||||
|
if (state?.notificationId) {
|
||||||
|
const target = notifications.find((n) => n.id === state.notificationId);
|
||||||
|
if (target) {
|
||||||
|
setSelectedNotification(target);
|
||||||
|
// 읽음 처리
|
||||||
|
if (!target.read) {
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((n) =>
|
||||||
|
n.id === target.id ? { ...n, read: true } : n,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setSelectedNotification({ ...target, read: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// state 소비 후 제거 (새로고침 시 재트리거 방지)
|
||||||
|
navigate(location.pathname, { replace: true, state: null });
|
||||||
|
}
|
||||||
|
}, [location.state]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
const totalItems = notifications.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE));
|
||||||
|
const pagedNotifications = useMemo(
|
||||||
|
() =>
|
||||||
|
notifications.slice(
|
||||||
|
(currentPage - 1) * PAGE_SIZE,
|
||||||
|
currentPage * PAGE_SIZE,
|
||||||
|
),
|
||||||
|
[notifications, currentPage],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 페이징된 알림을 날짜별로 그룹핑
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, Notification[]>();
|
||||||
|
for (const n of pagedNotifications) {
|
||||||
|
const arr = map.get(n.date) ?? [];
|
||||||
|
arr.push(n);
|
||||||
|
map.set(n.date, arr);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [pagedNotifications]);
|
||||||
|
|
||||||
|
// 전체 읽음
|
||||||
|
const handleReadAll = useCallback(() => {
|
||||||
|
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||||
|
toast.success("모든 알림을 읽음 처리했습니다");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 개별 읽음
|
||||||
|
const handleReadOne = useCallback((id: number) => {
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((n) => (n.id === id ? { ...n, read: true } : n)),
|
||||||
|
);
|
||||||
|
setSelectedNotification((prev) =>
|
||||||
|
prev?.id === id ? { ...prev, read: true } : prev,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="알림"
|
||||||
|
description="시스템 알림과 발송 결과를 확인할 수 있습니다"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={handleReadAll}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm font-medium text-[#64748b] hover:bg-gray-50 hover:text-[#0f172a] transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-base">
|
||||||
|
done_all
|
||||||
|
</span>
|
||||||
|
전체 읽음
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 날짜별 그룹 */}
|
||||||
|
{Array.from(grouped.entries()).map(([dateLabel, items]) => (
|
||||||
|
<div key={dateLabel} className="mb-8">
|
||||||
|
{/* 그룹 헤더 */}
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<h2 className="text-xs font-bold text-[#64748b] uppercase tracking-wider">
|
||||||
|
{dateLabel}
|
||||||
|
</h2>
|
||||||
|
<div className="flex-1 h-px bg-gray-200" />
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{DATE_LABELS[dateLabel] ?? ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 알림 카드 목록 */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
|
||||||
|
{items.map((notification, idx) => {
|
||||||
|
const badge = BADGE_STYLE[notification.type];
|
||||||
|
const isUnread = !notification.read;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={notification.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedNotification(notification);
|
||||||
|
handleReadOne(notification.id);
|
||||||
|
}}
|
||||||
|
className={`flex items-start gap-4 px-5 py-4 cursor-pointer transition-colors ${
|
||||||
|
idx < items.length - 1
|
||||||
|
? "border-b border-gray-100"
|
||||||
|
: ""
|
||||||
|
} ${
|
||||||
|
isUnread
|
||||||
|
? "bg-blue-50/20 hover:bg-blue-50/30"
|
||||||
|
: "hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 읽음 상태 점 */}
|
||||||
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
|
<div
|
||||||
|
className={`size-2.5 rounded-full ${
|
||||||
|
isUnread ? "bg-[#2563EB]" : "bg-gray-200"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* 콘텐츠 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded ${badge.bg} ${badge.text} text-[10px] font-semibold`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "11px" }}
|
||||||
|
>
|
||||||
|
{badge.icon}
|
||||||
|
</span>
|
||||||
|
{notification.type}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{notification.time}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={`text-sm truncate ${
|
||||||
|
isUnread
|
||||||
|
? "font-semibold text-[#0f172a]"
|
||||||
|
: "font-medium text-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{notification.title}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`text-xs truncate mt-0.5 ${
|
||||||
|
isUnread ? "text-gray-500" : "text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{notification.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
<div className="flex items-center justify-center gap-1 mt-10">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="size-9 flex items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xl">
|
||||||
|
chevron_left
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||||
|
<button
|
||||||
|
key={page}
|
||||||
|
onClick={() => setCurrentPage(page)}
|
||||||
|
className={`size-9 flex items-center justify-center rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
page === currentPage
|
||||||
|
? "bg-[#2563EB] text-white"
|
||||||
|
: "text-gray-600 hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="size-9 flex items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xl">
|
||||||
|
chevron_right
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 알림 상세 패널 */}
|
||||||
|
<NotificationSlidePanel
|
||||||
|
isOpen={selectedNotification !== null}
|
||||||
|
onClose={() => setSelectedNotification(null)}
|
||||||
|
notification={selectedNotification}
|
||||||
|
onMarkRead={handleReadOne}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
658
react/src/features/settings/pages/ProfileEditPage.tsx
Normal file
658
react/src/features/settings/pages/ProfileEditPage.tsx
Normal file
|
|
@ -0,0 +1,658 @@
|
||||||
|
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import PageHeader from "@/components/common/PageHeader";
|
||||||
|
import useShake from "@/hooks/useShake";
|
||||||
|
import { fetchProfile, updateProfile, changePassword } from "@/api/account.api";
|
||||||
|
|
||||||
|
// 비밀번호 규칙 정의
|
||||||
|
const PASSWORD_RULES = [
|
||||||
|
{ key: "length", label: "8자 이상 입력", test: (pw: string) => pw.length >= 8 },
|
||||||
|
{ key: "upper", label: "영문 대문자 포함", test: (pw: string) => /[A-Z]/.test(pw) },
|
||||||
|
{ key: "lower", label: "영문 소문자 포함", test: (pw: string) => /[a-z]/.test(pw) },
|
||||||
|
{ key: "number", label: "숫자 포함", test: (pw: string) => /\d/.test(pw) },
|
||||||
|
{ key: "special", label: "특수문자 포함", test: (pw: string) => /[^a-zA-Z0-9]/.test(pw) },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// 강도 레벨 산정
|
||||||
|
function getStrengthLevel(pw: string): number {
|
||||||
|
if (!pw) return 0;
|
||||||
|
const checks = PASSWORD_RULES.filter((r) => r.test(pw)).length;
|
||||||
|
if (checks <= 2) return 1;
|
||||||
|
if (checks <= 4) return 2;
|
||||||
|
if (checks === 5 && pw.length >= 12) return 4;
|
||||||
|
if (checks === 5) return 3;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STRENGTH_CONFIG = [
|
||||||
|
{ label: "", color: "" },
|
||||||
|
{ label: "매우 약함", color: "bg-red-500 text-red-500" },
|
||||||
|
{ label: "약함", color: "bg-amber-500 text-amber-500" },
|
||||||
|
{ label: "보통", color: "bg-blue-500 text-blue-500" },
|
||||||
|
{ label: "강함", color: "bg-green-500 text-green-500" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STRENGTH_ICONS = ["", "error", "warning", "info", "check_circle"];
|
||||||
|
|
||||||
|
/** 원본 프로필 (초기화용) */
|
||||||
|
interface OriginalProfile {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
team: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileEditPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 검증
|
||||||
|
const [nameError, setNameError] = useState(false);
|
||||||
|
const { triggerShake, cls } = useShake();
|
||||||
|
|
||||||
|
// 기본 정보 폼
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [team, setTeam] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [originalProfile, setOriginalProfile] = useState<OriginalProfile | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// 비밀번호 섹션
|
||||||
|
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
||||||
|
const [currentPw, setCurrentPw] = useState("");
|
||||||
|
const [newPw, setNewPw] = useState("");
|
||||||
|
const [confirmPw, setConfirmPw] = useState("");
|
||||||
|
const [showCurrentPw, setShowCurrentPw] = useState(false);
|
||||||
|
const [showNewPw, setShowNewPw] = useState(false);
|
||||||
|
const [showConfirmPw, setShowConfirmPw] = useState(false);
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||||
|
const [showPwModal, setShowPwModal] = useState(false);
|
||||||
|
|
||||||
|
// 프로필 로드
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchProfile();
|
||||||
|
const data = res.data.data;
|
||||||
|
const profile: OriginalProfile = {
|
||||||
|
name: data.name ?? "",
|
||||||
|
phone: data.phone ?? "",
|
||||||
|
team: data.organization ?? "",
|
||||||
|
email: data.email ?? "",
|
||||||
|
};
|
||||||
|
setName(profile.name);
|
||||||
|
setPhone(profile.phone);
|
||||||
|
setTeam(profile.team);
|
||||||
|
setEmail(profile.email);
|
||||||
|
setOriginalProfile(profile);
|
||||||
|
} catch {
|
||||||
|
toast.error("프로필 정보를 불러오지 못했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 비밀번호 규칙 통과 여부
|
||||||
|
const ruleResults = useMemo(
|
||||||
|
() => PASSWORD_RULES.map((r) => ({ ...r, pass: newPw ? r.test(newPw) : false })),
|
||||||
|
[newPw],
|
||||||
|
);
|
||||||
|
const strengthLevel = useMemo(() => getStrengthLevel(newPw), [newPw]);
|
||||||
|
const allRulesPass = ruleResults.every((r) => r.pass);
|
||||||
|
const passwordMatch = confirmPw.length > 0 && newPw === confirmPw;
|
||||||
|
const passwordMismatch = confirmPw.length > 0 && newPw !== confirmPw;
|
||||||
|
|
||||||
|
// 비밀번호 토글
|
||||||
|
const handleTogglePasswordForm = useCallback(() => {
|
||||||
|
if (showPasswordForm) {
|
||||||
|
// 닫기: 입력 초기화
|
||||||
|
setCurrentPw("");
|
||||||
|
setNewPw("");
|
||||||
|
setConfirmPw("");
|
||||||
|
setShowCurrentPw(false);
|
||||||
|
setShowNewPw(false);
|
||||||
|
setShowConfirmPw(false);
|
||||||
|
}
|
||||||
|
setShowPasswordForm((prev) => !prev);
|
||||||
|
}, [showPasswordForm]);
|
||||||
|
|
||||||
|
// 저장 확인
|
||||||
|
const handleSaveConfirm = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await updateProfile({
|
||||||
|
name: name.trim(),
|
||||||
|
phone,
|
||||||
|
organization: team,
|
||||||
|
});
|
||||||
|
toast.success("변경사항이 저장되었습니다");
|
||||||
|
navigate("/settings");
|
||||||
|
} catch {
|
||||||
|
toast.error("프로필 수정에 실패했습니다.");
|
||||||
|
}
|
||||||
|
setShowSaveModal(false);
|
||||||
|
}, [name, phone, team, navigate]);
|
||||||
|
|
||||||
|
// 비밀번호 변경 확인
|
||||||
|
const handlePwConfirm = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await changePassword({
|
||||||
|
currentPassword: currentPw,
|
||||||
|
newPassword: newPw,
|
||||||
|
});
|
||||||
|
if (res.data.data.re_login_required) {
|
||||||
|
toast.success("비밀번호가 변경되었습니다. 다시 로그인해주세요.");
|
||||||
|
navigate("/auth/login");
|
||||||
|
} else {
|
||||||
|
toast.success("비밀번호가 변경되었습니다.");
|
||||||
|
navigate("/settings");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("비밀번호 변경에 실패했습니다.");
|
||||||
|
}
|
||||||
|
setShowPwModal(false);
|
||||||
|
}, [currentPw, newPw, navigate]);
|
||||||
|
|
||||||
|
// 초기화
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
if (originalProfile) {
|
||||||
|
setName(originalProfile.name);
|
||||||
|
setPhone(originalProfile.phone);
|
||||||
|
setTeam(originalProfile.team);
|
||||||
|
}
|
||||||
|
setNameError(false);
|
||||||
|
}, [originalProfile]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="프로필 수정"
|
||||||
|
description="계정 정보와 보안 설정을 수정할 수 있습니다"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-center py-20 text-sm text-gray-400">
|
||||||
|
불러오는 중...
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="프로필 수정"
|
||||||
|
description="계정 정보와 보안 설정을 수정할 수 있습니다"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 기본 정보 카드 */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm mb-6">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||||
|
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
|
||||||
|
<span className="material-symbols-outlined text-base text-[#2563EB]">
|
||||||
|
person
|
||||||
|
</span>
|
||||||
|
기본 정보
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* 이름 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||||
|
이름 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value);
|
||||||
|
if (e.target.value.trim()) setNameError(false);
|
||||||
|
}}
|
||||||
|
className={`w-full px-4 py-2.5 border rounded-lg text-sm text-[#0f172a] focus:outline-none focus:ring-2 transition-colors placeholder:text-gray-300 ${
|
||||||
|
nameError
|
||||||
|
? "border-red-500 ring-2 ring-red-500/15 focus:ring-red-500/20 focus:border-red-500"
|
||||||
|
: "border-gray-200 focus:ring-[#2563EB]/20 focus:border-[#2563EB]"
|
||||||
|
} ${cls("name")}`}
|
||||||
|
placeholder="이름을 입력해주세요"
|
||||||
|
/>
|
||||||
|
{nameError && (
|
||||||
|
<div className="flex items-center gap-1.5 text-red-600 text-xs mt-1.5">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span>이름을 입력해주세요.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 이메일 (읽기전용) */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||||
|
이메일 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
readOnly
|
||||||
|
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-sm text-[#0f172a] bg-gray-50 cursor-not-allowed focus:outline-none"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1.5 text-gray-500 text-xs mt-1">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>이메일은 관리자만 변경할 수 있습니다</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 연락처 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||||
|
연락처
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-sm text-[#0f172a] focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition-colors placeholder:text-gray-300"
|
||||||
|
placeholder="010-0000-0000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* 소속 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||||
|
소속
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={team}
|
||||||
|
onChange={(e) => setTeam(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-sm text-[#0f172a] focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition-colors placeholder:text-gray-300"
|
||||||
|
placeholder="소속을 입력해주세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀번호 변경 카드 */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm mb-6">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||||
|
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
|
||||||
|
<span className="material-symbols-outlined text-base text-[#2563EB]">
|
||||||
|
lock
|
||||||
|
</span>
|
||||||
|
비밀번호 변경
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleTogglePasswordForm}
|
||||||
|
className="flex items-center gap-1 text-xs font-medium text-[#2563EB] hover:text-[#1d4ed8] transition-colors"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
{showPasswordForm ? "close" : "edit"}
|
||||||
|
</span>
|
||||||
|
{showPasswordForm ? "취소" : "비밀번호 변경하기"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPasswordForm ? (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="max-w-md space-y-5">
|
||||||
|
{/* 현재 비밀번호 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||||
|
현재 비밀번호 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showCurrentPw ? "text" : "password"}
|
||||||
|
value={currentPw}
|
||||||
|
onChange={(e) => setCurrentPw(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 pr-10 border border-gray-200 rounded-lg text-sm text-[#0f172a] focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition-colors placeholder:text-gray-300"
|
||||||
|
placeholder="현재 비밀번호를 입력해주세요"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCurrentPw((v) => !v)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-[#0f172a] transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg">
|
||||||
|
{showCurrentPw ? "visibility" : "visibility_off"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 새 비밀번호 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||||
|
새 비밀번호 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showNewPw ? "text" : "password"}
|
||||||
|
value={newPw}
|
||||||
|
onChange={(e) => setNewPw(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 pr-10 border border-gray-200 rounded-lg text-sm text-[#0f172a] focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition-colors placeholder:text-gray-300"
|
||||||
|
placeholder="새 비밀번호를 입력해주세요"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNewPw((v) => !v)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-[#0f172a] transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg">
|
||||||
|
{showNewPw ? "visibility" : "visibility_off"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀번호 규칙 */}
|
||||||
|
{newPw && (
|
||||||
|
<div className="mt-2 space-y-0.5">
|
||||||
|
{ruleResults
|
||||||
|
.filter((r) => !r.pass)
|
||||||
|
.map((rule) => (
|
||||||
|
<div
|
||||||
|
key={rule.key}
|
||||||
|
className="flex items-center gap-1.5 text-gray-500 text-xs"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
keyboard
|
||||||
|
</span>
|
||||||
|
<span>{rule.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{ruleResults
|
||||||
|
.filter((r) => r.pass)
|
||||||
|
.map((rule) => (
|
||||||
|
<div
|
||||||
|
key={rule.key}
|
||||||
|
className="flex items-center gap-1.5 text-green-600 text-xs"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
check_circle
|
||||||
|
</span>
|
||||||
|
<span className="line-through">{rule.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 강도 바 */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex gap-1 mb-1">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`h-1 flex-1 rounded-sm transition-colors ${
|
||||||
|
newPw && i <= strengthLevel
|
||||||
|
? STRENGTH_CONFIG[strengthLevel].color.split(" ")[0]
|
||||||
|
: "bg-gray-200"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1.5 text-xs ${
|
||||||
|
newPw
|
||||||
|
? STRENGTH_CONFIG[strengthLevel].color.split(" ")[1]
|
||||||
|
: "text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
{newPw ? STRENGTH_ICONS[strengthLevel] : "info"}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{newPw
|
||||||
|
? STRENGTH_CONFIG[strengthLevel].label
|
||||||
|
: "모든 조건을 충족해야 변경할 수 있습니다"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 새 비밀번호 확인 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||||
|
새 비밀번호 확인 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showConfirmPw ? "text" : "password"}
|
||||||
|
value={confirmPw}
|
||||||
|
onChange={(e) => setConfirmPw(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 pr-10 border border-gray-200 rounded-lg text-sm text-[#0f172a] focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition-colors placeholder:text-gray-300"
|
||||||
|
placeholder="새 비밀번호를 다시 입력해주세요"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirmPw((v) => !v)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-[#0f172a] transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg">
|
||||||
|
{showConfirmPw ? "visibility" : "visibility_off"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{passwordMatch && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs mt-1 text-green-600">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
check_circle
|
||||||
|
</span>
|
||||||
|
<span>비밀번호가 일치합니다</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{passwordMismatch && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs mt-1 text-red-600">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span>비밀번호가 일치하지 않습니다</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀번호 변경 버튼 */}
|
||||||
|
<div className="flex justify-end pt-4 mt-4 border-t border-gray-100">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!currentPw || !newPw || !confirmPw) return;
|
||||||
|
if (!allRulesPass) return;
|
||||||
|
if (newPw !== confirmPw) return;
|
||||||
|
setShowPwModal(true);
|
||||||
|
}}
|
||||||
|
disabled={!currentPw || !allRulesPass || !passwordMatch}
|
||||||
|
className="px-5 py-2 bg-[#2563EB] text-white rounded-lg text-sm font-medium hover:bg-[#1d4ed8] transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
비밀번호 변경
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="material-symbols-outlined text-green-500 text-lg">
|
||||||
|
check_circle
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#0f172a]">
|
||||||
|
비밀번호가 설정되어 있습니다
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
|
마지막 변경: 2026-01-15
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 보안 설정 (추후 개발) */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm mb-6">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
|
||||||
|
<span className="material-symbols-outlined text-base text-[#2563EB]">
|
||||||
|
security
|
||||||
|
</span>
|
||||||
|
보안 설정
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-gray-400 bg-gray-100 px-2.5 py-1 rounded-full">
|
||||||
|
추후 개발 예정
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 버튼 바 */}
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/settings")}
|
||||||
|
className="flex items-center gap-1.5 px-5 py-2.5 border border-gray-200 rounded-lg text-sm font-medium text-[#64748b] hover:bg-gray-50 hover:text-[#0f172a] transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="px-5 py-2.5 border border-gray-200 rounded-lg text-sm font-medium text-[#64748b] hover:bg-gray-50 hover:text-[#0f172a] transition-colors"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
setNameError(true);
|
||||||
|
triggerShake(["name"]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowSaveModal(true);
|
||||||
|
}}
|
||||||
|
className="px-6 py-2.5 bg-[#2563EB] text-white rounded-lg text-sm font-medium hover:bg-[#1d4ed8] transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
변경사항 저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 변경사항 저장 확인 모달 */}
|
||||||
|
{showSaveModal && (
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={() => setShowSaveModal(false)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-center min-h-screen p-4">
|
||||||
|
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-md p-6 z-10 border border-gray-200">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="size-10 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="material-symbols-outlined text-amber-600 text-xl">
|
||||||
|
warning
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-[#0f172a]">
|
||||||
|
변경사항 저장
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[#0f172a] mb-5">
|
||||||
|
입력한 내용으로 프로필 정보를 변경하시겠습니까?
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSaveModal(false)}
|
||||||
|
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveConfirm}
|
||||||
|
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm"
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 비밀번호 변경 확인 모달 */}
|
||||||
|
{showPwModal && (
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={() => setShowPwModal(false)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-center min-h-screen p-4">
|
||||||
|
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-md p-6 z-10 border border-gray-200">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="size-10 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="material-symbols-outlined text-amber-600 text-xl">
|
||||||
|
warning
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-[#0f172a]">
|
||||||
|
비밀번호 변경 확인
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[#0f172a] mb-2">
|
||||||
|
정말로 비밀번호를 변경하시겠습니까?
|
||||||
|
</p>
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-5">
|
||||||
|
<div className="flex items-center gap-1.5 text-amber-700 text-xs font-medium">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
변경 후 모든 기기에서 로그아웃되며, 다시 로그인해야 합니다.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPwModal(false)}
|
||||||
|
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handlePwConfirm}
|
||||||
|
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm"
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
223
react/src/features/settings/types.ts
Normal file
223
react/src/features/settings/types.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
// Settings feature 타입 정의
|
||||||
|
|
||||||
|
// 사용자 프로필
|
||||||
|
export interface UserProfile {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
team: string;
|
||||||
|
phone: string;
|
||||||
|
joinDate: string;
|
||||||
|
lastLogin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최근 활동
|
||||||
|
export interface Activity {
|
||||||
|
id: number;
|
||||||
|
type: "send" | "service" | "device" | "auth";
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 알림 타입
|
||||||
|
export const NOTIFICATION_TYPES = {
|
||||||
|
SEND: "발송",
|
||||||
|
CERT: "인증서",
|
||||||
|
SERVICE: "서비스",
|
||||||
|
FAIL: "실패",
|
||||||
|
SYSTEM: "시스템",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type NotificationType =
|
||||||
|
(typeof NOTIFICATION_TYPES)[keyof typeof NOTIFICATION_TYPES];
|
||||||
|
|
||||||
|
// 알림
|
||||||
|
export interface Notification {
|
||||||
|
id: number;
|
||||||
|
type: NotificationType;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
time: string;
|
||||||
|
read: boolean;
|
||||||
|
date: string; // 그룹핑용 (오늘/어제/이번 주)
|
||||||
|
detail?: string; // 패널에서 보여줄 상세 내용
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API 응답 타입 ---
|
||||||
|
|
||||||
|
/** 프로필 응답 (ProfileResponseDto) */
|
||||||
|
export interface ProfileResponse {
|
||||||
|
admin_code: string | null;
|
||||||
|
email: string | null;
|
||||||
|
name: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
role: number;
|
||||||
|
created_at: string;
|
||||||
|
last_login_at: string | null;
|
||||||
|
organization: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 역할 매핑 */
|
||||||
|
export const ROLE_LABELS: Record<number, string> = {
|
||||||
|
1: "관리자",
|
||||||
|
2: "운영자",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// --- API 요청 타입 ---
|
||||||
|
|
||||||
|
/** 프로필 수정 요청 */
|
||||||
|
export interface UpdateProfileRequest {
|
||||||
|
name?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
organization?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 활동 내역 요청 */
|
||||||
|
export interface ActivityListRequest {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
from?: string | null;
|
||||||
|
to?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 활동 내역 항목 (ActivityItemDto) */
|
||||||
|
export interface ActivityItem {
|
||||||
|
activity_type: string | null;
|
||||||
|
title: string | null;
|
||||||
|
description: string | null;
|
||||||
|
ip_address: string | null;
|
||||||
|
occurred_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 활동 내역 페이지네이션 */
|
||||||
|
export interface ActivityPagination {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
total_count: number;
|
||||||
|
total_pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 활동 내역 응답 */
|
||||||
|
export interface ActivityListResponse {
|
||||||
|
items: ActivityItem[] | null;
|
||||||
|
pagination: ActivityPagination;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 비밀번호 변경 요청 */
|
||||||
|
export interface ChangePasswordRequest {
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 비밀번호 변경 응답 */
|
||||||
|
export interface ChangePasswordResponse {
|
||||||
|
re_login_required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 목데이터: 알림
|
||||||
|
export const MOCK_NOTIFICATIONS: Notification[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: "발송",
|
||||||
|
title: "발송 완료: 마케팅 캠페인 알림",
|
||||||
|
description:
|
||||||
|
"2026-02-19 14:30에 1,234건 발송이 완료되었습니다. 대상 서비스: (주) 미래테크",
|
||||||
|
time: "14:30",
|
||||||
|
read: false,
|
||||||
|
date: "오늘",
|
||||||
|
detail:
|
||||||
|
"발송 채널: FCM (Android)\n전체 대상: 1,234건\n성공: 1,224건 (99.2%)\n실패: 10건 (0.8%)\n\n실패 사유:\n- 유효하지 않은 토큰: 7건\n- 네트워크 타임아웃: 3건",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: "인증서",
|
||||||
|
title: "인증서 만료 예정: iOS Production",
|
||||||
|
description:
|
||||||
|
"7일 후 만료 예정입니다. 서비스 중단 방지를 위해 갱신을 진행해주세요",
|
||||||
|
time: "11:20",
|
||||||
|
read: false,
|
||||||
|
date: "오늘",
|
||||||
|
detail:
|
||||||
|
"인증서 정보:\n- 타입: iOS Production (P8)\n- 발급일: 2025-02-26\n- 만료일: 2026-02-26\n- 남은 일수: 7일\n\n갱신하지 않으면 iOS 푸시 발송이 중단됩니다. [서비스 관리 > 인증서]에서 갱신해주세요.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
type: "서비스",
|
||||||
|
title: "서비스 등록 완료: (주) 미래테크",
|
||||||
|
description:
|
||||||
|
"새 서비스가 정상적으로 등록되었습니다. 플랫폼 인증을 진행해주세요",
|
||||||
|
time: "09:45",
|
||||||
|
read: false,
|
||||||
|
date: "오늘",
|
||||||
|
detail:
|
||||||
|
"서비스명: (주) 미래테크\n서비스 ID: SVC-2026-0042\n등록일: 2026-02-19\n\n등록 플랫폼:\n- Android (FCM): 인증 완료\n- iOS (APNs): 인증 대기\n\niOS 인증서를 등록하면 iOS 기기에도 푸시를 발송할 수 있습니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
type: "실패",
|
||||||
|
title: "발송 실패: 긴급 공지 알림 (3건)",
|
||||||
|
description: "유효하지 않은 토큰으로 인해 발송에 실패했습니다",
|
||||||
|
time: "08:15",
|
||||||
|
read: true,
|
||||||
|
date: "오늘",
|
||||||
|
detail:
|
||||||
|
"발송 ID: SEND-20260219-003\n대상 서비스: Smart Factory\n실패 건수: 3건\n\n실패 상세:\n- device_token_expired: 2건\n- invalid_registration: 1건\n\n해당 기기 토큰을 갱신하거나 삭제 후 재발송해주세요.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
type: "시스템",
|
||||||
|
title: "시스템 점검 완료",
|
||||||
|
description: "정기 점검이 완료되어 정상 운영 중입니다",
|
||||||
|
time: "23:00",
|
||||||
|
read: true,
|
||||||
|
date: "어제",
|
||||||
|
detail:
|
||||||
|
"점검 시간: 2026-02-18 22:00 ~ 23:00\n점검 내용: 서버 보안 패치 및 DB 최적화\n\n영향 범위: 전체 서비스 일시 중단\n현재 상태: 정상 운영 중\n\n다음 정기 점검 예정: 2026-03-18",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
type: "발송",
|
||||||
|
title: "발송 완료: 앱 업데이트 공지",
|
||||||
|
description: "5,678건 발송 완료. 대상 서비스: Smart Factory",
|
||||||
|
time: "16:45",
|
||||||
|
read: true,
|
||||||
|
date: "어제",
|
||||||
|
detail:
|
||||||
|
"발송 채널: FCM (Android) + APNs (iOS)\n전체 대상: 5,678건\n성공: 5,650건 (99.5%)\n실패: 28건 (0.5%)\n\n서비스: Smart Factory\n메시지 제목: 앱 업데이트 v2.5.0 안내",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
type: "인증서",
|
||||||
|
title: "인증서 갱신 완료: Android FCM",
|
||||||
|
description: "FCM 서버 키가 정상적으로 갱신되었습니다",
|
||||||
|
time: "10:00",
|
||||||
|
read: true,
|
||||||
|
date: "어제",
|
||||||
|
detail:
|
||||||
|
"인증서 정보:\n- 타입: Android FCM (Server Key)\n- 이전 만료일: 2026-02-20\n- 갱신 후 만료일: 2027-02-18\n\n갱신은 자동으로 적용되었으며, 별도 조치가 필요하지 않습니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
type: "발송",
|
||||||
|
title: "발송 완료: 주간 리포트 알림",
|
||||||
|
description: "892건 발송 완료. 대상 서비스: (주) 미래테크",
|
||||||
|
time: "02.17 09:30",
|
||||||
|
read: true,
|
||||||
|
date: "이번 주",
|
||||||
|
detail:
|
||||||
|
"발송 채널: FCM (Android)\n전체 대상: 892건\n성공: 890건 (99.8%)\n실패: 2건 (0.2%)\n\n서비스: (주) 미래테크\n메시지 제목: 주간 리포트 (2026.02.10 ~ 02.16)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
type: "시스템",
|
||||||
|
title: "정기 점검 예정 안내",
|
||||||
|
description:
|
||||||
|
"2026-02-18 22:00 ~ 23:00 정기 점검이 예정되어 있습니다",
|
||||||
|
time: "02.16 02:00",
|
||||||
|
read: true,
|
||||||
|
date: "이번 주",
|
||||||
|
detail:
|
||||||
|
"점검 예정 시간: 2026-02-18 22:00 ~ 23:00\n점검 내용: 서버 보안 패치 및 DB 최적화\n\n영향 범위:\n- 전체 서비스 일시 중단\n- API 호출 불가\n- 관리자 콘솔 접속 불가\n\n점검 중 발송 예약 건은 점검 완료 후 자동 재개됩니다.",
|
||||||
|
},
|
||||||
|
];
|
||||||
0
react/src/features/statistics/components/.gitkeep
Normal file
0
react/src/features/statistics/components/.gitkeep
Normal file
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user