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_RUN_SERVER = ''
|
||||
def CONTAINER_SRC_PATH = '/src'
|
||||
def FRONT_HOST_PATH = '/volume1/SPMS/PROJECT/Application/Front'
|
||||
|
||||
pipeline {
|
||||
agent any
|
||||
|
|
@ -13,7 +13,6 @@ pipeline {
|
|||
echo "Current Branch: ${branchName}"
|
||||
|
||||
if (branchName == 'develop') {
|
||||
// develop 브랜치 -> Debug 컨테이너
|
||||
TARGET_FRONT_BUILD = 'spms-front-build-debug'
|
||||
TARGET_RUN_SERVER = 'spms-run-debug'
|
||||
echo "[DEV Mode] Target: DEBUG Container"
|
||||
|
|
@ -38,19 +37,26 @@ pipeline {
|
|||
}
|
||||
}
|
||||
|
||||
// [3단계] 컨테이너로 소스 복사
|
||||
stage('Copy to Container') {
|
||||
// [3단계] 실행 중인 임시 컨테이너를 통해 호스트 볼륨에 소스 복사
|
||||
// (정지된 컨테이너에 docker cp하면 바인드 마운트에 반영 안 됨)
|
||||
stage('Copy to Host Volume') {
|
||||
steps {
|
||||
script {
|
||||
def sourcePath = "${WORKSPACE}/react/."
|
||||
echo "Copying from ${sourcePath} to ${TARGET_FRONT_BUILD}"
|
||||
def containerId = sh(script: "docker ps -aqf 'name=${TARGET_FRONT_BUILD}'", returnStdout: true).trim()
|
||||
echo "Copying source to host volume (${FRONT_HOST_PATH})..."
|
||||
|
||||
if (containerId) {
|
||||
sh "docker cp ${sourcePath} ${TARGET_FRONT_BUILD}:${CONTAINER_SRC_PATH}"
|
||||
} else {
|
||||
error "Container ${TARGET_FRONT_BUILD} not found!"
|
||||
}
|
||||
sh """
|
||||
docker rm -f spms-source-copy 2>/dev/null || true
|
||||
docker run -d --name spms-source-copy \
|
||||
-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') {
|
||||
steps {
|
||||
script {
|
||||
echo "Starting React Build..."
|
||||
echo "Starting React Build (npm install + build + deploy)..."
|
||||
sh "docker start -a ${TARGET_FRONT_BUILD}"
|
||||
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>
|
||||
<html lang="en">
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<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>
|
||||
<body>
|
||||
<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"
|
||||
},
|
||||
"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-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": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
|
@ -23,6 +39,10 @@
|
|||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"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-eslint": "^8.46.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 reactLogo from './assets/react.svg'
|
||||
import viteLogo from '/vite.svg'
|
||||
import './App.css'
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { ErrorBoundary } from "@/components/feedback/ErrorBoundary";
|
||||
import { router } from "@/routes";
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000, // 5분
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://react.dev" target="_blank">
|
||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||
</a>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster />
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
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