diff --git a/backend/seed.py b/backend/seed.py new file mode 100644 index 0000000..5cc755c --- /dev/null +++ b/backend/seed.py @@ -0,0 +1,82 @@ +""" + docker compose exec backend python seed.py +""" + +import asyncio + +from sqlalchemy import select + +from app.core.security import hash_password +from app.database import AsyncSessionLocal +from app.models.models import Admin, Camera + + +# ── 시드 데이터 정의 ───────────────────────────────────────────────────────── + +ADMIN_ACCOUNTS = [ + { + "email": "admin@gateguard.com", + "password": "admin1234", + "employee_id": "EMP00001", + }, + { + "email": "station01@gateguard.com", + "password": "station1234", + "employee_id": "EMP00002", + }, +] + +# 신분당선 +_SHINBUNDANG_STATIONS = [ + "광교역", "광교중앙역", "상현역", +] + +TEST_CAMERAS = [ + {"location": f"개찰구 {g}번 게이트", "station_name": st, "is_active": True} + for st in _SHINBUNDANG_STATIONS + for g in (1, 2) +] + + +async def seed(): + async with AsyncSessionLocal() as session: + # ── 관리자 계정 시딩 ────────────────────────────────────────────── + for account in ADMIN_ACCOUNTS: + exists = await session.execute( + select(Admin).where(Admin.email == account["email"]) + ) + if exists.scalar_one_or_none(): + print(f" [SKIP] 관리자 이미 존재: {account['email']}") + continue + + admin = Admin( + email=account["email"], + password=hash_password(account["password"]), + employee_id=account["employee_id"], + ) + session.add(admin) + print(f" [ADD] 관리자 생성: {account['email']} ({account['employee_id']})") + + # ── 카메라 데이터 시딩 ──────────────────────────────────────────── + for cam in TEST_CAMERAS: + exists = await session.execute( + select(Camera).where( + Camera.location == cam["location"], + Camera.station_name == cam["station_name"], + ) + ) + if exists.scalar_one_or_none(): + print(f" [SKIP] 카메라 이미 존재: {cam['station_name']} - {cam['location']}") + continue + + camera = Camera(**cam) + session.add(camera) + print(f" [ADD] 카메라 생성: {cam['station_name']} - {cam['location']}") + + await session.commit() + print("\n 시딩 완료") + + +if __name__ == "__main__": + print("GateGuard 초기 데이터 시딩 시작...\n") + asyncio.run(seed()) \ No newline at end of file diff --git a/frontend/.env.example b/frontend/.env.example index d909022..44b13ca 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,2 +1,2 @@ VITE_API_BASE_URL=http://localhost:8000 -VITE_WS_URL=ws://localhost:8000/ws/events +VITE_WS_URL=ws://localhost:8000/ws/events \ No newline at end of file diff --git a/frontend/.expo/README.md b/frontend/.expo/README.md new file mode 100644 index 0000000..fd146b4 --- /dev/null +++ b/frontend/.expo/README.md @@ -0,0 +1,15 @@ +> Why do I have a folder named ".expo" in my project? + +The ".expo" folder is created when an Expo project is started using "expo start" command. + +> What do the files contain? + +- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds. +- "packager-info.json": contains port numbers and process PIDs that are used to serve the application to the mobile device/simulator. +- "settings.json": contains the server configuration that is used to serve the application manifest. + +> Should I commit the ".expo" folder? + +No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine. + +Upon project creation, the ".expo" folder is already added to your ".gitignore" file. diff --git a/frontend/.expo/settings.json b/frontend/.expo/settings.json new file mode 100644 index 0000000..92bc513 --- /dev/null +++ b/frontend/.expo/settings.json @@ -0,0 +1,8 @@ +{ + "hostType": "lan", + "lanType": "ip", + "dev": true, + "minify": false, + "urlRandomness": null, + "https": false +} diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..4baec66 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -7,10 +7,21 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* +# Dependencies node_modules + +# Build dist dist-ssr -*.local +build +coverage + +# Environment variables +.env +.env.local +.env.development +.env.production +.env.test # Editor directories and files .vscode/* @@ -22,3 +33,13 @@ dist-ssr *.njsproj *.sln *.sw? + +# OS +Thumbs.db + +# Local +*.local + +# Claude +.claude +.claudeignore \ No newline at end of file diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md deleted file mode 100644 index 33cfdaa..0000000 --- a/frontend/CLAUDE.md +++ /dev/null @@ -1,132 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -GateGuard is a subway fare evasion real-time detection system. This is the **frontend** (React + TypeScript + Vite) for an admin dashboard that receives WebSocket alerts from a FastAPI backend running at `http://localhost:8000`. - -## Commands - -```bash -npm run dev # Start dev server (Vite HMR) -npm run build # Type-check + production build (tsc -b && vite build) -npm run lint # ESLint -npm run preview # Preview production build -``` - -No test runner is configured yet. - -## Architecture - -### Auth Flow -- Login via `POST /api/auth/login` → receives `access_token` (JWT) -- Token stored in `localStorage` (remember me) or `sessionStorage` (session only) -- All API calls use the singleton `src/api/axios.ts` instance, which auto-injects the Bearer token via request interceptor and redirects to `/` on 401 -- **Route guard is NOT yet implemented** — all routes are accessible without a token - -### Routing (`src/router/index.tsx`) -- `/` → `LoginPage` (public) -- `/dashboard` → `DashboardPage` ✅ 구현완료 -- `/stats` → `StatsPage` ⚠️ placeholder ("준비 중" 텍스트만) -- `/events` → `EventsPage` ✅ 구현완료 -- `/settings` → `SettingsPage` ⚠️ placeholder ("준비 중" 텍스트만) - -### Layout Pattern -Dashboard pages share a consistent layout: `` (left, fixed w-64) + `
` (top) + `
` content. Assemble these manually in each page — no shared layout wrapper component. - -### Data Flow (DashboardPage) -`DashboardPage` is the single source of truth for all dashboard data: -- Owns 5 parallel API fetches on mount via `Promise.allSettled` (cameras, events, stats, cameraStats, notifications) -- `cameraMapRef`로 카메라 맵 관리 — WebSocket 핸들러에서도 최신 맵 참조 가능 -- Owns modal state (`selectedEvent`, `falseAlarmEvent`) -- Passes data down as props; children call `refresh()` after mutations -- WebSocket `NEW_EVENT` → 카메라 정보 조인 후 prepend to `events[]` (최대 10건) + optimistic stats increment - -### Component Organization -- `src/components/layout/` — `Sidebar`, `Header` -- `src/components/dashboard/` — `StatCards`, `StatCard`, `AlertList`, `AlertItem`, `CameraStats`, `FalseAlarmList`, `EventDetailModal`, `FalseAlarmModal` -- `src/components/events/` — `EventsFilter`, `EventsTable`, `EventsPagination` -- `src/components/ui/` — shadcn/ui primitives (generated via `npx shadcn add `) -- `src/contexts/AppContext.tsx` — 전역 상태 (`wsConnected`, `unconfirmedCount`) — Header·Sidebar에서 읽고 DashboardPage·EventsPage에서 설정 -- `src/hooks/` — `useWebSocket` (auto-reconnect, 3s delay, `connected` 반환, per-effect `let active` 패턴) -- `src/api/` — `axios.ts` (singleton), `events.ts`, `cameras.ts`, `notifications.ts` -- `src/types/index.ts` — 앱 전체 공유 타입 (`EventResponse`, `EventStats`, `CameraEventStats`, `NotificationResponse`) - -### Styling -- Tailwind CSS v4 (via `@tailwindcss/vite` plugin, no `tailwind.config.js`) -- Brand primary: `#4B73F7` -- shadcn/ui with `radix-nova` style, CSS variables enabled, `lucide-react` icons - -### Path Alias -`@/` → `src/` (configured in `vite.config.ts` and `tsconfig.app.json`) - -## Backend Integration - -- REST API base: `VITE_API_BASE_URL` 환경변수 (`src/api/axios.ts`) — always import from `@/api/axios`, never use raw `axios` -- WebSocket: `VITE_WS_URL` 환경변수 (`src/hooks/useWebSocket.ts`) -- 기본값은 `.env` 파일에 정의 (`http://localhost:8000`, `ws://localhost:8000/ws/events`) -- Run the full stack with `docker-compose up -d` from the repo root (`/Users/ijihyeon/Desktop/GateGuard/`) - -### 구현된 API (Swagger 확인 완료) -- `GET /api/cameras/` — 카메라 목록 -- `GET /api/events/` — 이벤트 목록 -- `PATCH /api/events/{event_id}/status` — 이벤트 상태 변경 (처리완료) ✅ -- `GET /api/notifications/` — 알림 목록 (인증 불필요) -- `PATCH /api/notifications/{notification_id}/read` — 알림 읽음 처리 - -### Backend M2 미구현 API (프론트 코드는 작성 완료, 호출 시 404 실패) -이 API들은 실패해도 각 컴포넌트가 null/빈 배열로 graceful fallback 처리: -- `GET /api/events/stats` → StatCards 데이터 (실패 시 카드 0으로 표시) -- `GET /api/events/stats/by-camera` → CameraStats 테이블 (실패 시 빈 테이블) -- `POST /api/events/{id}/false-alarm` → 오탐신고 (실패 시 신고 반영 안 됨) - -## 구현 현황 - -### ✅ 완료 -- 로그인 페이지 (JWT 인증, remember me) -- axios 공통 인스턴스 (토큰 자동 주입, 401 리다이렉트) -- WebSocket 훅 (`useWebSocket`) — 자동 재연결 -- 공통 TypeScript 타입 (`src/types/index.ts`) -- API 모듈 (`events.ts`, `notifications.ts`) -- Sidebar + Header 레이아웃 -- DashboardPage 전체 (API 연동, WebSocket, 4개 위젯, 2개 모달) - - StatCards — 오늘 감지, 미확인, 처리완료, 오탐 4개 카드 - - AlertList / AlertItem — 최신 알림 10건, 상세보기/오탐신고 버튼 - - CameraStats — 구간별 알림현황 테이블 (고위험/주의/정상 색상 분기) - - FalseAlarmList — 최근 오탐 신고 5건 - - EventDetailModal — 좌측 실시간 알림 목록 + 우측 상세 정보 + 역무원파견/처리완료/오탐신고 버튼 - - FalseAlarmModal — 오탐 사유 선택 + 직접입력 -- EventsPage (전체 발생내역 — 필터/페이지네이션, WebSocket 실시간 삽입, EventDetailModal·FalseAlarmModal 재사용) -- SettingsPage placeholder (`/settings` 라우트 등록) -- AppContext (`wsConnected`, `unconfirmedCount` 전역 공유) -- Sidebar 미확인 뱃지 (unconfirmedCount > 0 시 빨간 동그라미) -- Header WS 뱃지 (wsConnected 기반 on/off) -- 환경변수 분리 (`VITE_API_BASE_URL`, `VITE_WS_URL`) - -### ⚠️ 미구현 (우선순위 순) -1. **Auth route guard** — 토큰 없으면 `/`로 리다이렉트 (현재 모든 라우트 인증 없이 접근 가능) -2. **StatsPage** — ECharts 통계 시각화 (현재 placeholder) -3. **SettingsPage** — 설정 기능 (현재 placeholder) - -### 로그인 현황 및 블로커 -현재 로그인이 불가능하며 두 가지 문제가 모두 해결되어야 함: -1. **CORS 미해결** — 브라우저가 `POST /api/auth/login` 전 OPTIONS 프리플라이트 요청을 보내는데 백엔드가 `http://localhost:5173`을 허용하지 않아 400 반환. 실서버 연결 시 백엔드 `main.py`에 FastAPI `CORSMiddleware` 추가 필요 (`allow_origins=["http://localhost:5173"]`) -2. **DB 미연결** — CORS 해결 후에도 DB가 연결되지 않으면 로그인 쿼리가 hang → `await api.post('/api/auth/login')` 무한 대기 → "로그인 중..." 무한 로딩 -- 로그인 없이 `/dashboard` 직접 접근 시 토큰 없음 → 보호된 API 401 반환 (정상 동작, Auth route guard로 해결 예정) - -### EventStatus 값 -백엔드 확정 상태값 3종: `pending` (미처리) | `confirmed` (처리완료) | `false_alarm` (오탐) -- `pending` → 상세보기·오탐신고 버튼 활성화, 빨간 dot 표시 -- `confirmed` / `false_alarm` → 기록보기 버튼만 표시 - -### EventStats API 응답 필드명 -`GET /api/events/stats` 응답: `today_total`, `pending`, `confirmed`, `false_alarm` -(기존 `today_count`, `pending_count` 등과 다름 — 이미 `src/types/index.ts`에 반영 완료) - -### 백엔드 확정 후 수정 필요 -- `appearance_tags`, `description`, `event_type`, `assigned_to` 필드 실제 응답 포함 여부 -- `POST /api/events/{id}/false-alarm` 요청 바디 필드명 (`reason`, `memo?`) 확정 필요 -- `NotificationResponse`에 `event` 필드 embed 여부 확인 필요 -- clip_url S3 CORS 설정 (백엔드 담당) -- CameraStats 색상 임계값(현재 5/2) 기획 확정 필요 diff --git a/frontend/README.md b/frontend/README.md index 7dbf7eb..7882060 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,73 +1,51 @@ -# React + TypeScript + Vite +# GateGuard Frontend -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +지하철 무임승차 실시간 감지 시스템의 관리자 대시보드 프론트엔드. -Currently, two official plugins are available: +## 기술 스택 -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) +- React 18 + TypeScript + Vite +- Tailwind CSS v4 +- shadcn/ui (radix-nova) + lucide-react +- ECharts (echarts-for-react) — 통계 차트 +- sonner — 토스트 알림 -## React Compiler +## 실행 -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +```bash +npm install +npm run dev # 개발 서버 (HMR) +npm run build # 프로덕션 빌드 +npm run lint # ESLint +``` -## Expanding the ESLint configuration +## 환경 변수 (`.env`) -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: +``` +VITE_API_BASE_URL=http://localhost:8000 +VITE_WS_URL=ws://localhost:8000/ws/events +``` -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... +실서버: `https://gateguardsystems.com` - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, +## 전체 스택 실행 - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```bash +# 레포 루트에서 +docker-compose up -d ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: +## 주요 페이지 -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' +| 경로 | 설명 | +|------|------| +| `/` | 로그인 (회원가입 / 비밀번호 찾기 포함) | +| `/dashboard` | 실시간 대시보드 (WebSocket, 이벤트 카드, 오탐 신고) | +| `/events` | 전체 발생내역 (필터, 페이지네이션, CSV 내보내기) | +| `/stats` | 통계 시각화 (일별 추이, 시간대별, 유형별, 역별 순위) | +| `/settings` | 내 프로필 + 카메라 관리 | -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` +## 문서 + +- `CLAUDE.md` — Claude Code 작업 가이드 (아키텍처, 데이터 흐름, 구현 현황) +- `API.md` — 백엔드 API 연동 현황 diff --git a/frontend/docs/API.md b/frontend/docs/API.md new file mode 100644 index 0000000..ac480d6 --- /dev/null +++ b/frontend/docs/API.md @@ -0,0 +1,222 @@ +# GateGuard API 연동 현황 + +백엔드 Swagger 기준 구현된 API와 프론트엔드 연동 상태 정리. + +--- + +## 목차 + +- [공통 설정](#공통-설정) +- [auth](#auth) +- [cameras](#cameras) +- [events](#events) +- [notifications](#notifications) + +--- + +## 공통 설정 + +**Base URL**: `VITE_API_BASE_URL` 환경변수 (기본값 `http://localhost:8000`) + +**인증**: 모든 요청에 `Authorization: Bearer ` 자동 주입 (`src/api/axios.ts`) + +- 토큰은 `localStorage.token` 또는 `sessionStorage.token` 에서 탐색 +- 401 응답 시 토큰 삭제 후 `/` 로 리다이렉트 + +**API 모듈 위치**: `src/api/` — 모든 호출은 raw axios 대신 이 모듈 사용 + +--- + +## auth + +| Method | Endpoint | 프론트 연동 | 파일 | +| ------ | -------------------- | ----------- | ------------------------- | +| POST | `/api/auth/register` | ✅ | `src/pages/LoginPage.tsx` | +| POST | `/api/auth/login` | ✅ | `src/pages/LoginPage.tsx` | +| POST | `/api/auth/find-pw` | ✅ | `src/pages/LoginPage.tsx` | + +### POST /api/auth/login + +```ts +// Request +{ + employee_id: string; + password: string; +} + +// Response +{ + access_token: string; +} +``` + +로그인 성공 시 토큰을 localStorage(remember me) 또는 sessionStorage(세션)에 저장 후 `/dashboard` 로 이동. + +### POST /api/auth/find-pw + +```ts +// Query Params +{ + employee_id: string; + email: string; +} +``` + +--- + +## cameras + +| Method | Endpoint | 프론트 연동 | 파일 | +| ------ | --------------------------------- | ------------------- | -------------------- | +| GET | `/api/cameras/` | ✅ `getCameras()` | `src/api/cameras.ts` | +| POST | `/api/cameras/` | ✅ `createCamera()` | `src/api/cameras.ts` | +| PATCH | `/api/cameras/{camera_id}/toggle` | ✅ `toggleCamera()` | `src/api/cameras.ts` | + +### GET /api/cameras/ + +```ts +// Response +interface CameraResponse { + id: number; + location: string; // 게이트 번호 (예: "1번 게이트") + station_name: string; // 역 이름 (예: "강남역") + is_active: boolean; +} +[]; +``` + +> ⚠️ 백엔드 ORDER BY 없음 — 새로고침마다 순서 변동 가능. 백엔드 정렬 추가 요청 중. + +이벤트 API 응답에는 `camera_id` 만 있으므로, 이 API로 카메라 맵을 만든 뒤 이벤트와 조인하여 역이름/게이트 표시. + +--- + +## events + +| Method | Endpoint | 프론트 연동 | 파일 | 비고 | +| ------ | ------------------------------------ | ---------------------------- | ------------------- | ---- | +| GET | `/api/events/` | ✅ `getEvents()` | `src/api/events.ts` | | +| POST | `/api/events/` | ❌ 미사용 | — | AI용 | +| GET | `/api/events/stats` | ✅ `getEventStats()` | `src/api/events.ts` | | +| GET | `/api/events/stats/by-camera` | ✅ `getEventStatsByCamera()` | `src/api/events.ts` | | +| GET | `/api/events/{event_id}` | ✅ `getEventById(id)` | `src/api/events.ts` | | +| POST | `/api/events/{event_id}/false-alarm` | ✅ `reportFalseAlarm()` | `src/api/events.ts` | | +| PATCH | `/api/events/{event_id}/status` | ✅ `updateEventStatus()` | `src/api/events.ts` | | + +### GET /api/events/ + +```ts +// Query Params +{ + limit?: number; + offset?: number; + status?: "pending" | "confirmed" | "false_alarm"; + type?: string; // event_type 필터 + camera_id?: number; + date_from?: string; // ISO 8601 + date_to?: string; // ISO 8601 + search?: string; // EV-번호 / CAM-번호 / 역이름 / 게이트 통합검색 +} + +// Response +interface EventResponse { + id: number; + camera_id: number; + timestamp: string; // ISO 8601 + clip_url: string | null; + track_id: number | null; + confidence: number | null; // 0.0 ~ 1.0 + status: "pending" | "confirmed" | "false_alarm"; + event_type: string; // tailgating | jump | crawling | unpaid | unknown + reason: string | null; // 오탐 사유 (false_alarm 시) + handled_by: number | null; // 처리한 관리자 내부 ID + handled_at: string | null; // ISO 8601 +}[] +``` + +> AI 분류기 실제 출력: `tailgating | jump | crawling | unpaid` (4종) +> `unknown`은 DB 기본값 — AI가 event_type 없이 전송 시 저장됨 + +### GET /api/events/stats + +```ts +// Response +interface EventStats { + today_total: number; // 오늘 발생 건수 + pending: number; // 전체 미처리 + confirmed: number; // 전체 처리완료 + false_alarm: number; // 전체 오탐 +} +``` + +### GET /api/events/stats/by-camera + +```ts +// Response +{ + camera_id: number; + count: number; +} +[]; +``` + +> ⚠️ `station_name`, `location` 미포함 — 프론트에서 `GET /api/cameras/` 결과로 조인하여 표시. + +### POST /api/events/{event_id}/false-alarm + +```ts +// Request +{ + reason: string; +} + +// 사전 정의 reason 값 (FalseAlarmModal 기준) +// "기기 오작동" | "노인 무임혜택 미인식" | "장애인 혜택 미인식" | "기타" +``` + +### PATCH /api/events/{event_id}/status + +```ts +// Request +{ + status: "confirmed" | "false_alarm"; +} +``` + +--- + +## notifications + +| Method | Endpoint | 프론트 연동 | 파일 | 비고 | +| ------ | ------------------------------ | ----------------------------- | -------------------------- | ------------------ | +| GET | `/api/notifications/` | ✅ `getNotifications()` | `src/api/notifications.ts` | | +| PATCH | `/api/notifications/{id}/read` | ✅ `markNotificationRead(id)` | `src/api/notifications.ts` | | +| POST | `/api/notifications/read-all` | ❌ 미사용 | — | 프론트 불필요 판단 | + +### GET /api/notifications/ + +```ts +// Query Params +{ unread_only?: boolean } + +// Response +interface NotificationResponse { + id: number; + event_id: number; + sent_at: string; // ISO 8601 + read_at: string | null; // null = 미읽음 +}[] +``` + +--- + +## 백엔드 추가 요청 대기 중 + +| Endpoint | 용도 | 프론트 반영 예정 | +| ------------------------------------------ | ---------------------------- | ------------------------------------------ | +| `GET /api/events/` `station` 파라미터 추가 | 역 드롭다운 서버사이드 필터 | `useEventsPage.ts` 클라이언트 필터 제거 | +| `GET /api/events/` `total` 필드 응답 추가 | 전체 페이지 수 표시 | `EventsPagination` 페이지 번호 목록 | +| `GET /api/events/stats/daily` | 날짜별 발생 건수 (`days=12`) | `DailyTrendChart` 1000건 제한 해소 | +| `GET /api/events/stats/hourly` | 시간대별 발생 건수 | `HourlyDistributionChart` 1000건 제한 해소 | +| `GET /api/cameras/` ORDER BY id | 카메라 목록 정렬 고정 | `CamerasTab` 순서 안정화 | +| 비활성 카메라 이벤트 수신 시 400 반환 | 비활성 카메라 이벤트 차단 | — | diff --git a/frontend/docs/CLAUDE.md b/frontend/docs/CLAUDE.md new file mode 100644 index 0000000..17c2f5c --- /dev/null +++ b/frontend/docs/CLAUDE.md @@ -0,0 +1,237 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +GateGuard is a subway fare evasion real-time detection system. This is the **frontend** (React + TypeScript + Vite) for an admin dashboard that receives WebSocket alerts from a FastAPI backend at `https://gateguardsystems.com`. + +## Commands + +```bash +npm run dev # Start dev server (Vite HMR) +npm run build # Type-check + production build (tsc -b && vite build) +npm run lint # ESLint +npm run preview # Preview production build +``` + +No test runner is configured yet. + +## Architecture + +### Auth Flow + +- Login via `POST /api/auth/login` → receives `access_token` (JWT) +- Token stored in `localStorage` (remember me) or `sessionStorage` (session only) +- All API calls use the singleton `src/api/axios.ts` instance, which auto-injects the Bearer token via request interceptor and redirects to `/` on 401 +- Route guard: `src/router/index.tsx` — `PrivateRoute` 컴포넌트, 토큰 없으면 `/`로 리다이렉트 + +### Routing (`src/router/index.tsx`) + +- `/` → `LoginPage` (public) +- `/dashboard` → `DashboardPage` ✅ 구현완료 +- `/stats` → `StatsPage` ✅ 구현완료 +- `/events` → `EventsPage` ✅ 구현완료 +- `/settings` → `SettingsPage` ✅ 구현완료 (내 프로필 + 카메라 관리) + +### Layout Pattern + +Dashboard pages share a consistent layout: `` (left, fixed w-64) + `
` (top) + `
` content. Assemble these manually in each page — no shared layout wrapper component. + +### Data Flow (DashboardPage) + +`DashboardPage` is the single source of truth for all dashboard data: + +- Owns 5 parallel API fetches on mount via `Promise.allSettled` (cameras, events, stats, cameraStats, notifications) +- `cameraMapRef`로 카메라 맵 관리 — WebSocket 핸들러에서도 최신 맵 참조 가능 +- Owns modal state (`selectedEvent`, `falseAlarmEvent`) +- Passes data down as props; children call `refresh()` after mutations +- WebSocket `NEW_EVENT` → 카메라 정보 조인 후 prepend to `events[]` (최대 10건) + optimistic stats increment + +### Data Flow (StatsPage) + +- 4개 API 병렬 fetch: `GET /api/events/?limit=1000`, `GET /api/events/stats`, `GET /api/events/stats/by-camera`, `GET /api/cameras/` +- 이벤트 목록을 `useMemo`로 가공: 날짜별/시간대별/유형별/오탐사유별 집계 +- 상단 카드: 총 발생(events.length), 일평균(전체 기간 ÷ 일수), 오탐율(false_alarm/전체), 평균 처리 시간(confirmed의 handled_at-timestamp 평균) — 모두 최근 1000건 기준 +- `CameraRankingTable` — `GET /api/cameras/` 결과로 camera_id → station_name/location 조인 +- ⚠️ 모든 통계는 최대 1000건 제한. 백엔드 집계 API(`stats/daily`, `stats/hourly`) 추가되면 교체 예정 + +### Data Flow (EventsPage) + +- `src/hooks/useEventsPage.ts` 단일 훅이 데이터·필터·페이지네이션 모두 담당 +- **서버사이드 필터**: `status`, `type`, `camera_id`, `date_from`/`date_to`, `search` → 백엔드 전송 +- **서버사이드 페이지네이션**: `offset=(page-1)*pageSize`, `limit=pageSize+1` (hasNextPage 감지) +- **클라이언트 필터**: `station` → 현재 페이지 내에서만 적용 (백엔드 `station` 파라미터 미지원) +- `search` 입력 400ms 디바운스 적용 (`EventsFilter` 로컬 state → 지연 후 onChange 호출) +- WebSocket `NEW_EVENT` → AppContext `subscribeWsEvent` 구독 (전용 WS 연결 없음) +- WebSocket 수신 시 현재 필터+페이지 그대로 서버 re-fetch +- CSV 내보내기 → 클릭 시 `limit=10000`으로 전체 재조회 후 export + +### Component Organization + +- `src/components/layout/` — `Sidebar`, `Header` +- `src/components/dashboard/` — `StatCards`, `StatCard`, `AlertList`, `AlertItem`, `CameraStats`, `FalseAlarmList`, `EventDetailModal`, `FalseAlarmModal` + - `EventDetailModal` — 단일 이벤트 상세. FalseAlarmModal을 내부에서 직접 렌더링. 처리완료/오탐신고 완료 시 버튼 → 완료 문구로 전환 (서버 `handled_at` 재조회). 오탐신고 완료 시 reason도 completedInfo에 저장 후 표시 + - `FalseAlarmModal` — `onSubmitted(reason: string)` 콜백으로 reason 전달. EventDetailModal 경유 시 reason 활용, DashboardPage(AlertItem) 경유 시 `() => refresh()`로 reason은 버림 + - `EventInfoPanel` — 이벤트 상세 패널. `event_type`은 `labelEventType()`으로 한글 변환 후 표시 +- `src/components/events/` — `EventsFilter`, `EventsTable`, `EventsPagination` + - `EventsTable` — `onExport: () => Promise` 콜백으로 전체 export 지원 + - `EventsPagination` — `hasNextPage` 기반 이전/다음 방식 (서버가 total 미반환) +- `src/components/stats/` — `StatSummaryCards`, `DailyTrendChart`, `EventTypeChart`, `HourlyDistributionChart`, `FalseAlarmTable`, `CameraRankingTable` +- `src/components/ui/` — shadcn/ui primitives (generated via `npx shadcn add `) +- `src/constants/eventTypes.ts` — `EVENT_TYPE_LABEL` (영문→한글 맵), `EVENT_TYPE_OPTIONS` (필터 드롭다운용), `labelEventType(raw)` 함수 +- `src/contexts/AppContext.tsx` — 전역 상태 (`wsConnected`, `unconfirmedCount`, `loggedIn`). 마운트 시 `GET /api/events/stats`로 `pending` 건수 초기화. WS는 여기서 단일 연결 관리. `subscribeWsEvent`로 구독자(DashboardPage, EventsPage)에 NEW_EVENT 전달 +- `src/hooks/` — `useWebSocket` (auto-reconnect, 3s delay, `enabled` 파라미터로 로그인 상태 연동), `useEventsPage` (EventsPage 전용 통합 훅) +- `src/api/` — `axios.ts` (singleton), `events.ts`, `cameras.ts`, `notifications.ts` +- `src/types/index.ts` — 앱 전체 공유 타입 (`EventResponse`, `EventStats`, `CameraEventStats`, `NotificationResponse`) + +### Styling + +- Tailwind CSS v4 (via `@tailwindcss/vite` plugin, no `tailwind.config.js`) +- Brand primary: `#4B73F7` +- shadcn/ui with `radix-nova` style, CSS variables enabled, `lucide-react` icons +- 차트: `echarts` + `echarts-for-react` (StatsPage 전용) + +### Path Alias + +`@/` → `src/` (configured in `vite.config.ts` and `tsconfig.app.json`) + +## Backend Integration + +- REST API base: `VITE_API_BASE_URL` 환경변수 (`src/api/axios.ts`) — always import from `@/api/axios`, never use raw `axios` +- WebSocket: `VITE_WS_URL` 환경변수 (`src/hooks/useWebSocket.ts`) +- 기본값은 `.env` 파일에 정의 (`http://localhost:8000`, `ws://localhost:8000/ws/events`) +- 실서버: `https://gateguardsystems.com` +- Run the full stack with `docker-compose up -d` from the repo root (`/Users/ijihyeon/Desktop/GateGuard/`) +- 상세 API 문서: `API.md` 참고 + +### 구현된 API 전체 목록 + +**auth** + +- `POST /api/auth/login` — JWT 로그인 ✅ 프론트 연동 +- `POST /api/auth/register` — 회원가입 ✅ 프론트 연동 (LoginPage register step) +- `POST /api/auth/find-pw` — 비밀번호 찾기 ✅ 프론트 연동 (query params: `employee_id`, `email`) + +**cameras** + +- `GET /api/cameras/` — 카메라 목록 ✅ 프론트 연동 +- `POST /api/cameras/` — 카메라 등록 ✅ 프론트 연동 (SettingsPage) +- `PATCH /api/cameras/{camera_id}/toggle` — 카메라 활성화/비활성화 ✅ 프론트 연동 (SettingsPage) + +**events** + +- `GET /api/events/` — 이벤트 목록 ✅ 프론트 연동 (params: `status`, `type`, `camera_id`, `date_from`, `date_to`, `search`, `limit`, `offset`) +- `GET /api/events/stats` — 통계 카드 ✅ 프론트 연동 +- `GET /api/events/stats/by-camera` — 구간별 알림현황 ✅ 프론트 연동 +- `GET /api/events/{event_id}` — 이벤트 단건 조회 ✅ 프론트 연동 +- `PATCH /api/events/{event_id}/status` — 이벤트 상태 변경 ✅ 프론트 연동 +- `POST /api/events/{event_id}/false-alarm` — 오탐신고 ✅ 프론트 연동 (body: `{ reason: string }`) + +**notifications** + +- `GET /api/notifications/` — 알림 목록 ✅ 프론트 연동 +- `PATCH /api/notifications/{notification_id}/read` — 읽음 처리 ✅ 프론트 연동 +- `POST /api/notifications/read-all` — 전체 읽음 처리 (프론트 미사용) + +### GET /api/events/ 응답 필드 + +```ts +{ + (id, + camera_id, + timestamp, + clip_url, + track_id, + confidence, + status, + event_type, + reason, + handled_by, + handled_at); +} +``` + +### GET /api/events/stats 응답 필드 + +```ts +{ + (today_total, pending, confirmed, false_alarm); +} +``` + +### EventStatus 값 + +백엔드 확정 상태값 3종: `pending` (미처리) | `confirmed` (처리완료) | `false_alarm` (오탐) + +- `pending` → 상세보기·오탐신고 버튼 활성화, 빨간 dot 표시 +- `confirmed` / `false_alarm` → 버튼 없음, 완료 문구만 표시 + +### AI 이벤트 타입 값 (inference.py 출력 → event_type 필드) + +AI 분류기 실제 CLASSES: `tailgating | jump | crawling | unpaid` (4종만 사용) + +- `emergencydoor`, `normal`은 AI가 출력하지 않음 +- `unknown`은 DB 기본값 — AI가 event_type 없이 전송 시 저장됨 +- `EVENT_TYPE_OPTIONS` 필터 드롭다운은 실제 AI 출력 4종만 포함 +- `EVENT_TYPE_LABEL`은 레거시 데이터 대비용으로 전체 매핑 유지 + +## 구현 현황 + +### ✅ 완료 + +- 로그인 페이지 (JWT 인증, remember me) +- 회원가입 / 비밀번호 찾기 (LoginPage — `step: "login" | "register" | "findpw"` 멀티스텝 폼, API 연동) +- axios 공통 인스턴스 (토큰 자동 주입, 401 리다이렉트) +- WebSocket 훅 (`useWebSocket`) — 자동 재연결 +- 공통 TypeScript 타입 (`src/types/index.ts`) +- API 모듈 (`events.ts`, `cameras.ts`, `notifications.ts`) +- Sidebar + Header 레이아웃 +- DashboardPage 전체 (API 연동, WebSocket, 4개 위젯, 2개 모달) + - CameraStats — 역별 알림현황 (건수 내림차순, 최대 5개, WebSocket 실시간 낙관적 업데이트) + - EventDetailModal — 영상 + 상세정보 패널. 역무원파견(confirm→비활성화)/처리완료(confirm→PATCH)/오탐신고(FalseAlarmModal 내장). 처리 후 서버 `handled_at` 재조회 후 완료 문구 표시 + - FalseAlarmModal — `onSubmitted(reason)` 으로 reason 반환 + - WebSocket NEW_EVENT 시 stats + cameraStats 낙관적 업데이트 +- EventsPage (전체 발생내역) + - 서버사이드 필터: status, type, camera_id, 기간(date_from/date_to), search + - 서버사이드 페이지네이션: offset+limit, hasNextPage 방식 + - 클라이언트 필터: station (현재 페이지 내, 백엔드 파라미터 대기 중) + - search 입력 400ms 디바운스 + - WebSocket NEW_EVENT → AppContext subscribeWsEvent 경유 re-fetch (전용 WS 없음) + - CSV 내보내기 → limit=10000 전체 재조회 후 export + - EventDetailModal 재사용 + - 테이블 table-fixed 레이아웃 — 페이지 이동 시 컬럼 너비 고정 + - 담당자 컬럼: handled_by (숫자 ID) 표시 +- StatsPage (ECharts 통계 시각화) — 최근 1000건 기준 + - StatSummaryCards — 누적발생(events.length)/일평균/오탐율/평균처리시간 4개 카드 (모두 events 배열 기준) + - DailyTrendChart — 최근 12일 라인 차트 + - EventTypeChart — 감지 유형 비율 도넛 차트 (event_type 데이터 없으면 "데이터 없음") + - HourlyDistributionChart — 전체 기간 시간대별 발생 분포 가로 바 차트 + - FalseAlarmTable — 오탐 사유별 건수 + - CameraRankingTable — 역별/게이트별 발생 순위 (cameras API 조인으로 역이름 표시) +- SettingsPage (내 프로필 탭: JWT 디코딩으로 사원번호 표시 + 로그아웃 / 카메라 관리 탭: 목록 조회 + 등록 폼 + 활성화 토글) +- Auth route guard (`src/router/index.tsx` — `PrivateRoute` 컴포넌트) +- Header 아바타 JWT 연동 (localStorage/sessionStorage 토큰에서 `employee_id` 디코딩, 첫 글자 표시) +- AppContext (`wsConnected`, `unconfirmedCount` 전역 공유) +- `src/constants/eventTypes.ts` (감지 유형 한글 레이블, 필터 옵션) +- API 문서 (`API.md`) + +### ⚠️ 미구현 + +1. **역무원 파견** — confirm 후 버튼 비활성화(`dispatched` 로컬 state)까지만 구현. 백엔드 API 없어서 실제 파견 처리 불가. 모달 닫고 재열면 파견 상태 리셋됨 + +### 백엔드 추가 요청 대기 중 + +- `GET /api/cameras/` ORDER BY id 추가 — 현재 정렬 기준 없어 새로고침마다 순서 변동 +- `GET /api/events/`에 `station: Optional[str]` 파라미터 추가 → 완료되면 `useEventsPage.ts` 클라이언트 필터 제거 +- `GET /api/events/` 응답에 `total` 필드 추가 → 완료되면 `EventsPagination` 페이지 번호 목록 표시 가능 +- `GET /api/events/stats/daily` — 날짜별 발생 건수 (`days: int = 12`) → 완료되면 DailyTrendChart 1000건 제한 해소 +- `GET /api/events/stats/hourly` — 시간대별 발생 건수 → 완료되면 HourlyDistributionChart 1000건 제한 해소 +- 비활성 카메라(`is_active=false`) 이벤트 수신 시 `400` 반환 처리 추가 + +### 백엔드 완료되면 프론트 후속 작업 필요한 것들 + +- station 파라미터 → useEventsPage.ts 클라이언트 필터 제거 +- total 필드 → getEvents 반환 타입 변경 + EventsPagination 페이지 번호 UI 구현 +- stats/daily, stats/hourly → StatsPage fetch 교체, + buildDailyData/buildHourlyData 제거 diff --git a/frontend/docs/TEST.md b/frontend/docs/TEST.md new file mode 100644 index 0000000..e69fb81 --- /dev/null +++ b/frontend/docs/TEST.md @@ -0,0 +1,37 @@ +## 테스트 방법 + +### 1. 백엔드 실행 + +```bash +docker compose up -d +docker compose exec backend alembic upgrade head +docker compose exec backend python seed.py +``` + +### 2. 프론트엔드 실행 + +```bash +cd frontend +npm run dev +``` + +### 3. 로그인 + +프론트 실행 후 표시되는 주소로 접속하여 아래 계정으로 로그인합니다. + +- ID: `EMP00001` +- PW: `admin1234` + +### 4. 이벤트 데이터 생성 + +`http://localhost:8000/docs` 접속 후 `POST /api/events/` 요청을 아래와 같이 보냅니다. + +```json +{ + "camera_id": 1, + "event_type": "tailgating", + "clip_url": "https://demo.gateguard.com/clips/tailgate_001.mp4", + "track_id": 101, + "confidence": 0.72 +} +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 5e6b472..8b41388 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', 'src/components/ui/**']), { files: ['**/*.{ts,tsx}'], extends: [ diff --git a/frontend/index.html b/frontend/index.html index 0fca6f0..77802ae 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@ - + - frontend + GateGuard
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 99e3110..873ce52 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,12 +12,15 @@ "axios": "^1.14.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "echarts": "^6.0.0", + "echarts-for-react": "^3.0.6", "lucide-react": "^1.7.0", "radix-ui": "^1.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.13.2", "shadcn": "^4.1.1", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0" }, @@ -37,7 +40,7 @@ "tailwindcss": "^4.2.2", "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", - "vite": "^8.0.1" + "vite": "^8.0.14" } }, "node_modules/@babel/code-frame": { @@ -614,21 +617,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -637,9 +640,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -1123,9 +1126,9 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, @@ -1238,9 +1241,9 @@ "license": "MIT" }, "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", "dev": true, "license": "MIT", "funding": { @@ -2746,9 +2749,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", "cpu": [ "arm64" ], @@ -2763,9 +2766,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", "cpu": [ "arm64" ], @@ -2780,9 +2783,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", "cpu": [ "x64" ], @@ -2797,9 +2800,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", "cpu": [ "x64" ], @@ -2814,9 +2817,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", - "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", "cpu": [ "arm" ], @@ -2831,9 +2834,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", "cpu": [ "arm64" ], @@ -2848,9 +2851,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", "cpu": [ "arm64" ], @@ -2865,9 +2868,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", "cpu": [ "ppc64" ], @@ -2882,9 +2885,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", "cpu": [ "s390x" ], @@ -2899,9 +2902,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", "cpu": [ "x64" ], @@ -2916,9 +2919,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", "cpu": [ "x64" ], @@ -2933,9 +2936,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", "cpu": [ "arm64" ], @@ -2950,9 +2953,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", - "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", "cpu": [ "wasm32" ], @@ -2960,16 +2963,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", "cpu": [ "arm64" ], @@ -2984,9 +2989,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", "cpu": [ "x64" ], @@ -4573,6 +4578,36 @@ "node": ">= 0.4" } }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "node_modules/echarts-for-react": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz", + "integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "size-sensor": "^1.0.1" + }, + "peerDependencies": { + "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", + "react": "^15.0.0 || >=16.0.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/eciesjs": { "version": "0.4.18", "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.18.tgz", @@ -6653,9 +6688,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -7045,9 +7080,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -7064,7 +7099,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7545,14 +7580,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", - "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.12" + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -7561,27 +7596,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-x64": "1.0.0-rc.12", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", - "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -7889,6 +7924,22 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/size-sensor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz", + "integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==", + "license": "ISC" + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8080,14 +8131,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -8442,17 +8493,17 @@ } }, "node_modules/vite": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", - "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.12", - "tinyglobby": "^0.2.15" + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -8468,8 +8519,8 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -8780,6 +8831,21 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" } } } diff --git a/frontend/package.json b/frontend/package.json index 57b5098..b881875 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,12 +14,15 @@ "axios": "^1.14.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "echarts": "^6.0.0", + "echarts-for-react": "^3.0.6", "lucide-react": "^1.7.0", "radix-ui": "^1.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.13.2", "shadcn": "^4.1.1", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0" }, @@ -39,6 +42,6 @@ "tailwindcss": "^4.2.2", "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", - "vite": "^8.0.1" + "vite": "^8.0.14" } } diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 0000000..6452530 Binary files /dev/null and b/frontend/public/logo.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 837c89e..af65f30 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { RouterProvider } from 'react-router-dom' +import { Toaster } from 'sonner' import { router } from './router' import { AppProvider } from './contexts/AppContext' @@ -6,6 +7,7 @@ function App() { return ( + ) } diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts index 5a89d12..4c21df8 100644 --- a/frontend/src/api/axios.ts +++ b/frontend/src/api/axios.ts @@ -1,17 +1,3 @@ -/** - * @file axios.ts - * @description axios 공통 인스턴스 - * - * ## 기능 - * - 모든 요청에 Bearer 토큰 자동 주입 (localStorage → sessionStorage 순으로 탐색) - * - 401 응답 시 토큰 삭제 후 로그인(/)으로 리다이렉트 - * - * ## 주의사항 - * - 모든 API 호출은 기본 axios 대신 이 인스턴스(api) 사용할 것 - * - 토큰 저장/삭제는 이 파일과 LoginPage.tsx에서만 처리할 것 - * - baseURL은 VITE_API_BASE_URL 환경변수로 관리 (.env 파일 참고) - */ - import axios from "axios"; const api = axios.create({ @@ -21,7 +7,6 @@ const api = axios.create({ }, }); -// 요청할 때마다 토큰 자동으로 헤더에 추가 api.interceptors.request.use((config) => { const token = localStorage.getItem("token") || sessionStorage.getItem("token"); @@ -31,12 +16,14 @@ api.interceptors.request.use((config) => { return config; }); -// 토큰 만료(401) 시 로그인 페이지로 이동 +// auth 엔드포인트 제외하고 401이면 토큰 삭제 후 로그인으로 리다이렉트 api.interceptors.response.use( (response) => response, (error) => { - if (error.response?.status === 401) { + const isAuthRoute = error.config?.url?.includes("/api/auth/"); + if (error.response?.status === 401 && !isAuthRoute) { localStorage.removeItem("token"); + sessionStorage.removeItem("token"); window.location.href = "/"; } return Promise.reject(error); diff --git a/frontend/src/api/cameras.ts b/frontend/src/api/cameras.ts index 942f312..bd130d6 100644 --- a/frontend/src/api/cameras.ts +++ b/frontend/src/api/cameras.ts @@ -1,17 +1,11 @@ -/** - * @file api/cameras.ts - * @description 카메라 목록 조회 API - * - * ## 기능 - * - getCameras() GET /api/cameras/ 전체 카메라 목록 (station_name, location 포함) - * - * ## 주의사항 - * - 이벤트 API 응답에는 camera_id만 있고 위치 정보가 없으므로, - * 이벤트 표시 시 이 API로 가져온 카메라 맵과 조인하여 역이름/게이트 표시 - */ - import api from "./axios"; import type { CameraResponse } from "@/types"; export const getCameras = () => api.get("/api/cameras/").then((r) => r.data); + +export const toggleCamera = (cameraId: number) => + api.patch(`/api/cameras/${cameraId}/toggle`).then((r) => r.data); + +export const createCamera = (data: { location: string; station_name: string }) => + api.post("/api/cameras/", data).then((r) => r.data); diff --git a/frontend/src/api/events.ts b/frontend/src/api/events.ts index 5ea7297..05a0381 100644 --- a/frontend/src/api/events.ts +++ b/frontend/src/api/events.ts @@ -1,29 +1,15 @@ -/** - * @file api/events.ts - * @description 이벤트(무임승차 감지) 관련 API - * - * ## 기능 - * - getEvents(params?) GET /api/events/ 이벤트 목록 조회 - * - getEventById(id) GET /api/events/{id} 이벤트 단건 조회 - * - getEventStats() GET /api/events/stats 통계 카드 데이터 - * - getEventStatsByCamera() GET /api/events/stats/by-camera 구간별 알림현황 - * - updateEventStatus(id) PATCH /api/events/{id}/status 처리완료 상태 변경 - * - reportFalseAlarm(id) POST /api/events/{id}/false-alarm 오탐신고 - * - * ## 주의사항 - * - getEventStats / getEventStatsByCamera / reportFalseAlarm / updateEventStatus: 백엔드 M2 구현 예정, 실패 시 호출 측에서 fallback 처리 - * - * ## 백엔드 확정 후 수정 필요 - * - reportFalseAlarm 요청 바디 필드명 ({ reason, memo? } 로 임시 처리) - */ - import api from "./axios"; import type { EventResponse, EventStats, CameraEventStats } from "@/types"; export const getEvents = (params?: { limit?: number; + offset?: number; status?: string; camera_id?: number; + type?: string; + date_from?: string; + date_to?: string; + search?: string; }) => api.get("/api/events/", { params }).then((r) => r.data); export const getEventById = (id: number) => @@ -40,7 +26,5 @@ export const getEventStatsByCamera = () => export const updateEventStatus = (id: number, status: string) => api.patch(`/api/events/${id}/status`, { status }).then((r) => r.data); -export const reportFalseAlarm = ( - id: number, - body: { reason: string; memo?: string }, -) => api.post(`/api/events/${id}/false-alarm`, body).then((r) => r.data); +export const reportFalseAlarm = (id: number, body: { reason: string }) => + api.post(`/api/events/${id}/false-alarm`, body).then((r) => r.data); diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts index 410a0f7..dbc2122 100644 --- a/frontend/src/api/notifications.ts +++ b/frontend/src/api/notifications.ts @@ -1,19 +1,3 @@ -/** - * @file api/notifications.ts - * @description 알림(오탐 신고 내역) 관련 API - * - * ## 기능 - * - getNotifications(params?) GET /api/notifications/ 알림 목록 조회 - * - markNotificationRead(id) PATCH /api/notifications/{id}/read 읽음 처리 - * - * ## 주의사항 - * - GET /api/notifications/ 는 인증 불필요 API - * - * ## 백엔드 확정 후 수정 필요 - * - NotificationResponse에 event 정보 embed 여부 확인 필요 - * - markNotificationRead 호출 시점 확인 필요 (항목 클릭 시? 자동?) - */ - import api from "./axios"; import type { NotificationResponse } from "@/types"; diff --git a/frontend/src/components/auth/AuthLayout.tsx b/frontend/src/components/auth/AuthLayout.tsx new file mode 100644 index 0000000..28126b1 --- /dev/null +++ b/frontend/src/components/auth/AuthLayout.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from "react"; + +export default function AuthLayout({ children }: { children: ReactNode }) { + return ( +
+
+
+
+
+ {children} +
+
+ ); +} diff --git a/frontend/src/components/auth/FindPwForm.tsx b/frontend/src/components/auth/FindPwForm.tsx new file mode 100644 index 0000000..2f66e21 --- /dev/null +++ b/frontend/src/components/auth/FindPwForm.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import api from "@/api/axios"; +import axios from "axios"; + +interface FindPwFormProps { + onLogin: () => void; +} + +export default function FindPwForm({ onLogin }: FindPwFormProps) { + const [employeeId, setEmployeeId] = useState(""); + const [email, setEmail] = useState(""); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + try { + await api.post("/api/auth/find-pw", null, { + params: { employee_id: employeeId, email }, + }); + setSuccess("입력하신 이메일로 임시 비밀번호를 발송했습니다."); + setEmployeeId(""); + setEmail(""); + } catch (err) { + if (axios.isAxiosError(err) && err.response?.status === 404) { + setError("일치하는 계정을 찾을 수 없습니다."); + } else { + setError("비밀번호 찾기 중 오류가 발생했습니다."); + } + } finally { + setLoading(false); + } + }; + + return ( + <> +

+ 비밀번호 찾기 +

+

+ 가입 시 등록한 사원번호와 이메일을 입력하시면 +
임시 비밀번호를 발송해 드립니다. +

+
+
+ + setEmployeeId(e.target.value)} + required + className="w-full px-4 py-3 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#4B73F7]" + /> +
+
+ + setEmail(e.target.value)} + required + className="w-full px-4 py-3 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#4B73F7]" + /> +
+ {error &&

{error}

} + {success &&

{success}

} + +

+ +

+
+ + ); +} diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..93737cf --- /dev/null +++ b/frontend/src/components/auth/LoginForm.tsx @@ -0,0 +1,111 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import api from "@/api/axios"; +import axios from "axios"; +import { useAppContext } from "@/hooks/useAppContext"; + +interface LoginFormProps { + onRegister: () => void; + onFindPw: () => void; +} + +export default function LoginForm({ onRegister, onFindPw }: LoginFormProps) { + const navigate = useNavigate(); + const { setLoggedIn } = useAppContext(); + const [employeeId, setEmployeeId] = useState(""); + const [password, setPassword] = useState(""); + const [remember, setRemember] = useState(true); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + try { + const res = await api.post("/api/auth/login", { employee_id: employeeId, password }); + const { access_token } = res.data; + if (remember) { + localStorage.setItem("token", access_token); + } else { + sessionStorage.setItem("token", access_token); + } + setLoggedIn(true); + navigate("/dashboard"); + } catch (err) { + if (axios.isAxiosError(err) && err.response?.status === 401) { + setError("사원번호 또는 비밀번호가 올바르지 않습니다."); + } else { + setError("로그인 중 오류가 발생했습니다."); + } + } finally { + setLoading(false); + } + }; + + return ( + <> +

+ 로그인 +

+
+
+ + setEmployeeId(e.target.value)} + required + className="w-full px-4 py-3 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#4B73F7]" + /> +
+
+
+ + +
+ setPassword(e.target.value)} + required + className="w-full px-4 py-3 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-[#4B73F7]" + /> +
+
+ setRemember(e.target.checked)} + className="w-4 h-4 accent-[#4B73F7]" + /> + +
+ {error &&

{error}

} + +

+ 아직 가입을 안하셨나요?{" "} + +

+
+ + ); +} diff --git a/frontend/src/components/auth/RegisterForm.tsx b/frontend/src/components/auth/RegisterForm.tsx new file mode 100644 index 0000000..4cd6600 --- /dev/null +++ b/frontend/src/components/auth/RegisterForm.tsx @@ -0,0 +1,125 @@ +import { useState } from "react"; +import api from "@/api/axios"; +import axios from "axios"; + +interface RegisterFormProps { + onLogin: () => void; +} + +export default function RegisterForm({ onLogin }: RegisterFormProps) { + const [employeeId, setEmployeeId] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + if (password !== passwordConfirm) { + setError("비밀번호가 일치하지 않습니다."); + return; + } + setLoading(true); + try { + await api.post("/api/auth/register", { + employee_id: employeeId, + email, + password, + }); + setSuccess("가입이 완료되었습니다. 로그인해 주세요."); + setTimeout(onLogin, 1500); + } catch (err) { + if (axios.isAxiosError(err) && err.response?.status === 400) { + setError("이미 사용 중인 사원번호 또는 이메일입니다."); + } else { + setError("가입 중 오류가 발생했습니다."); + } + } finally { + setLoading(false); + } + }; + + return ( + <> +

+ 가입하기 +

+
+
+ + setEmployeeId(e.target.value)} + required + className="w-full px-4 py-3 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#4B73F7]" + /> +
+
+ + setEmail(e.target.value)} + required + className="w-full px-4 py-3 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#4B73F7]" + /> +
+
+ + setPassword(e.target.value)} + required + className="w-full px-4 py-3 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-[#4B73F7]" + /> +
+
+ + setPasswordConfirm(e.target.value)} + required + className="w-full px-4 py-3 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-[#4B73F7]" + /> +
+ {error &&

{error}

} + {success &&

{success}

} + +

+ 이미 계정이 있으신가요?{" "} + +

+
+ + ); +} diff --git a/frontend/src/components/dashboard/AlertItem.tsx b/frontend/src/components/dashboard/AlertItem.tsx index 798c89f..de79996 100644 --- a/frontend/src/components/dashboard/AlertItem.tsx +++ b/frontend/src/components/dashboard/AlertItem.tsx @@ -1,26 +1,5 @@ -/** - * @file components/dashboard/AlertItem.tsx - * @description 최신알림 개별 카드 컴포넌트 - * - * ## 기능 - * - 미처리(pending/detected): 파란 [상세보기] + 파란 outline [오탐신고] 버튼 - * - 처리완료(confirmed/false_alarm): 파란 outline [기록보기] 버튼 - * - 심각도 뱃지: confidence 기반 (≥0.7 고위험 / <0.7 중간 / 처리 후 처리완료·오탐) - * - 위치: camera embed 시 "역명 게이트명", 루트 직접 필드 시에도 동일 표시, 없으면 "CAM-XX" - * - description 있으면 감지 유형 표시, 없으면 status 기반 기본 문구 fallback - * - * ## 주의사항 - * - isActive: pending → 상세보기·오탐신고 버튼 / confirmed | false_alarm → 기록보기 버튼 - * - getLocationLabel: camera 객체 embed 또는 루트 직접 필드(station_name/location) 모두 대응, 없으면 "CAM-XX" fallback - * - * ## TODO - * - [ ] 카메라 썸네일 실제 CCTV 스냅샷 연동 (clip_url or 별도 API) - * - * ## 백엔드 확정 후 수정 필요 - * - description, appearance_tags 필드명 확정 필요 - */ - import type { EventResponse } from "@/types"; +import { labelEventType } from "@/constants/eventTypes"; interface AlertItemProps { event: EventResponse; @@ -28,15 +7,15 @@ interface AlertItemProps { onFalseAlarm: (event: EventResponse) => void; } -/** confidence 기반 심각도 뱃지. 처리 완료 상태는 별도 레이블 반환 */ -function getSeverity(event: EventResponse): { label: string; color: string } { +function getSeverity(event: EventResponse): { label: string; cls: string } { if (event.status === "confirmed") - return { label: "처리완료", color: "bg-gray-100 text-gray-500" }; + return { label: "처리완료", cls: "bg-gray-100 text-gray-500" }; if (event.status === "false_alarm") - return { label: "오탐", color: "bg-gray-100 text-gray-400" }; - if ((event.confidence ?? 0) >= 0.7) - return { label: "고위험", color: "bg-red-100 text-red-500" }; - return { label: "중간", color: "bg-yellow-100 text-yellow-600" }; + return { label: "오탐", cls: "bg-gray-100 text-gray-400" }; + const conf = event.confidence ?? 0; + if (conf >= 0.7) return { label: "고위험", cls: "bg-red-100 text-red-500" }; + if (conf >= 0.4) return { label: "중간", cls: "bg-yellow-100 text-yellow-600" }; + return { label: "낮음", cls: "bg-green-100 text-green-600" }; } function formatTime(timestamp: string): string { @@ -56,11 +35,10 @@ function getLocationLabel(event: EventResponse): string { return `CAM-${String(event.camera_id).padStart(2, "0")}`; } -/** description 있으면 그 값, 없으면 status 기반 기본 문구 */ function getDescription(event: EventResponse): string { - if (event.description) return event.description; if (event.status === "confirmed") return "무임승차 확인 처리됨"; - if (event.status === "false_alarm") return "오탐으로 처리됨"; + if (event.status === "false_alarm") return "오탐으로 신고됨"; + if (event.event_type) return `${labelEventType(event.event_type)} 의심`; return "무임승차 의심 감지"; } @@ -80,7 +58,7 @@ export default function AlertItem({ CAM-{String(event.camera_id).padStart(2, "0")}
{severity.label} @@ -96,7 +74,9 @@ export default function AlertItem({ {formatTime(event.timestamp)}
-

{getDescription(event)}

+

+ {getDescription(event)} +

{event.appearance_tags?.map((tag) => ( = 5) return "bg-red-500"; - if (count >= 2) return "bg-yellow-400"; - return "bg-gray-300"; -} - -function getCountColor(count: number): string { - if (count >= 5) return "text-red-500"; - if (count >= 2) return "text-yellow-500"; - return "text-gray-500"; -} - export default function CameraStats({ data, loading }: CameraStatsProps) { + const sorted = [...data].sort((a, b) => b.count - a.count).slice(0, 5); + return (
-
-

구간별 알림현황

- +

+ 역별 알림현황 +

+
+ 역이름 + 알림현황
{loading ? ( -
+
{[...Array(3)].map((_, i) => ( -
+
))}
- ) : data.length === 0 ? ( -

+ ) : sorted.length === 0 ? ( +

데이터가 없습니다.

) : ( - - - - - - - - - {data.map((row) => ( - - - - - ))} - -
역이름알림현황
-
{row.station_name}
-
{row.location}
-
- - - {row.count} - -
+
+ {sorted.map((row) => ( +
+
+

+ {row.station_name} +

+

+ {row.location} +

+
+ + {row.count} + +
+ ))} +
)}
); diff --git a/frontend/src/components/dashboard/EventDetailModal.tsx b/frontend/src/components/dashboard/EventDetailModal.tsx index 0ab18aa..0448cce 100644 --- a/frontend/src/components/dashboard/EventDetailModal.tsx +++ b/frontend/src/components/dashboard/EventDetailModal.tsx @@ -1,58 +1,16 @@ -/** - * @file components/dashboard/EventDetailModal.tsx - * @description 알림 상세보기 모달 - * - * ## 기능 - * - 좌측: events 목록, 클릭 시 우측 상세 전환 - * - 우측: clip_url 영상 + 기록시각 / 위치 / 인상착의 / AI 신뢰도 - * - 처리완료: PATCH /api/events/{id}/status { status: 'confirmed' } 후 닫기 - * - 오탐신고: FalseAlarmModal로 전환 - * - status === "pending" 일 때 역무원파견/처리완료/오탐신고 버튼 표시, 그 외 미표시 - * - * ## 주의사항 - * - event 데이터는 AlertList의 events 배열 그대로 사용 (별도 단건 조회 없음) - * - * ## TODO - * - [ ] 역무원 파견 API 연동 (백엔드 스펙 미정) - * - [ ] GET /api/events/{id} 백엔드 구현 후 상세 데이터 별도 조회로 전환 - * - * ## 협의 - * - clip_url S3 CORS 설정 백엔드(조수근) 확인 필요 - * - appearance_tags, description 필드명 백엔드 확정 후 수정 필요 - */ - import { useState } from "react"; -import { X, Clock, MapPin, Tag, Video, Zap } from "lucide-react"; import type { EventResponse } from "@/types"; -import { updateEventStatus } from "@/api/events"; +import { updateEventStatus, getEventById } from "@/api/events"; +import FalseAlarmModal from "./FalseAlarmModal"; +import EventVideoPanel from "./EventVideoPanel"; +import EventInfoPanel, { type CompletedInfo } from "./EventInfoPanel"; interface EventDetailModalProps { - events: EventResponse[]; - initialEvent: EventResponse; + event: EventResponse; onClose: () => void; - onFalseAlarm: (event: EventResponse) => void; onConfirmed: () => void; } -// ── 유틸 ────────────────────────────────────────────── - -function formatHMS(timestamp: string): string { - const d = new Date(timestamp); - return [d.getHours(), d.getMinutes(), d.getSeconds()] - .map((n) => String(n).padStart(2, "0")) - .join(":"); -} - -function getStatusText(status: EventResponse["status"]): string { - if (status === "confirmed") return "CONFIRMED"; - if (status === "false_alarm") return "FALSE ALARM"; - return "UNCONFIRMED"; -} - -function getGateLabel(event: EventResponse): string { - return event.camera?.location ?? `GATE ${event.camera_id}`; -} - function getSeverityBadge(event: EventResponse): { label: string; cls: string; @@ -61,98 +19,69 @@ function getSeverityBadge(event: EventResponse): { return { label: "처리완료", cls: "bg-green-100 text-green-600" }; if (event.status === "false_alarm") return { label: "오탐", cls: "bg-gray-100 text-gray-500" }; - if ((event.confidence ?? 0) >= 0.7) - return { label: "고위험", cls: "bg-red-100 text-red-500" }; - return { label: "중간", cls: "bg-yellow-100 text-yellow-600" }; -} - -// ── 좌측 패널: 이벤트 목록 아이템 ────────────────────── - -function ListItem({ - event, - isSelected, - onClick, -}: { - event: EventResponse; - isSelected: boolean; - onClick: () => void; -}) { - const isUnconfirmed = event.status === "pending"; - - return ( - - ); + const conf = event.confidence ?? 0; + if (conf >= 0.7) return { label: "고위험", cls: "bg-red-100 text-red-500" }; + if (conf >= 0.4) + return { label: "중간", cls: "bg-yellow-100 text-yellow-600" }; + return { label: "낮음", cls: "bg-green-100 text-green-600" }; } -// ── 메인 모달 ───────────────────────────────────────── - export default function EventDetailModal({ - events, - initialEvent, + event, onClose, - onFalseAlarm, onConfirmed, }: EventDetailModalProps) { - const [selected, setSelected] = useState(initialEvent); + const [dispatched, setDispatched] = useState(false); const [confirming, setConfirming] = useState(false); + const [showFalseAlarm, setShowFalseAlarm] = useState(false); + const [completedInfo, setCompletedInfo] = useState( + () => { + if (event.status === "confirmed") + return { + type: "confirmed", + at: event.handled_at ?? new Date().toISOString(), + }; + if (event.status === "false_alarm") + return { + type: "false_alarm", + at: event.handled_at ?? new Date().toISOString(), + reason: event.reason ?? undefined, + }; + return null; + }, + ); - const isActive = selected.status === "pending"; - const badge = getSeverityBadge(selected); - const station = selected.camera?.station_name ?? ""; - const gate = selected.camera?.location ?? ""; + const isActive = event.status === "pending" && completedInfo === null; + const badge = + completedInfo?.type === "confirmed" + ? { label: "처리완료", cls: "bg-green-100 text-green-600" } + : completedInfo?.type === "false_alarm" + ? { label: "오탐", cls: "bg-gray-100 text-gray-500" } + : getSeverityBadge(event); + const station = event.camera?.station_name ?? ""; + const gate = event.camera?.location ?? ""; const locationText = station && gate ? `${station} ${gate}` - : station || `카메라 #${selected.camera_id}`; + : station || `카메라 #${event.camera_id}`; + + const handleDispatch = () => { + const ok = window.confirm("역무원을 파견하시겠습니까?"); + if (ok) setDispatched(true); + }; const handleConfirm = async () => { + const ok = window.confirm("이벤트를 완료 처리 하시겠습니까?"); + if (!ok) return; setConfirming(true); try { - await updateEventStatus(selected.id, "confirmed"); + await updateEventStatus(event.id, "confirmed"); + const updated = await getEventById(event.id); + setCompletedInfo({ + type: "confirmed", + at: updated.handled_at ?? new Date().toISOString(), + }); onConfirmed(); - onClose(); } catch { alert("처리 중 오류가 발생했습니다."); } finally { @@ -160,191 +89,51 @@ export default function EventDetailModal({ } }; - const handleDispatch = () => { - // TODO: 역무원 파견 API 연동 (백엔드 스펙 미정) - alert("역무원 파견 기능은 준비 중입니다."); + const handleFalseAlarmSubmitted = async (reason: string) => { + const updated = await getEventById(event.id).catch(() => null); + setCompletedInfo({ + type: "false_alarm", + at: updated?.handled_at ?? new Date().toISOString(), + reason, + }); + onConfirmed(); }; return ( -
+ <>
e.stopPropagation()} + className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" + onClick={onClose} > - {/* ── 좌측: 실시간 알림 목록 (md 미만 숨김) ── */} -
-
-

실시간 알림

-
-
- {events.length === 0 ? ( -

- 이벤트 없음 -

- ) : ( - events.map((ev) => ( - setSelected(ev)} - /> - )) - )} -
-
- - {/* ── 우측: 상세 정보 ── */} -
- {/* 헤더 */} -
-
-

- Event #{selected.id} - - {" "} - | {formatHMS(selected.timestamp)} | {getGateLabel(selected)} | - STATUS:{" "} - - - {getStatusText(selected.status)} - -

- - {badge.label} - -
- -
- - {/* 콘텐츠: 이미지 + 정보 */} -
- {/* 이미지/영상 */} -
- {selected.clip_url ? ( -
- - {/* 정보 + 버튼 */} -
- {/* 정보 섹션 */} -
-
- -
-

기록시각

-

- {formatHMS(selected.timestamp)} -

-
-
- -
- -
-

위치

-

{locationText}

-
-
- - {selected.appearance_tags && - selected.appearance_tags.length > 0 && ( -
- -
-

인상착의

-
    - {selected.appearance_tags.map((tag) => ( -
  • - - {tag} -
  • - ))} -
-
-
- )} - - {(selected.event_type ?? selected.description) && ( -
- -
-

감지유형

-

- {selected.event_type ?? selected.description} -

-
-
- )} - - {selected.confidence !== null && ( -

- AI 신뢰도: {Math.round((selected.confidence ?? 0) * 100)}% -

- )} -
- - {/* 액션 버튼 */} - {isActive && ( -
-

- 다음 버튼 -

- - - -
- )} -
-
+
e.stopPropagation()} + > + + setShowFalseAlarm(true)} + />
-
+ + {showFalseAlarm && ( + setShowFalseAlarm(false)} + onSubmitted={handleFalseAlarmSubmitted} + /> + )} + ); } diff --git a/frontend/src/components/dashboard/EventInfoPanel.tsx b/frontend/src/components/dashboard/EventInfoPanel.tsx new file mode 100644 index 0000000..fe7a3f1 --- /dev/null +++ b/frontend/src/components/dashboard/EventInfoPanel.tsx @@ -0,0 +1,144 @@ +import { X, Clock, MapPin, Zap } from "lucide-react"; +import type { EventResponse } from "@/types"; +import { labelEventType } from "@/constants/eventTypes"; + +export interface CompletedInfo { + type: "confirmed" | "false_alarm"; + at: string; + reason?: string; +} + +interface EventInfoPanelProps { + event: EventResponse; + badge: { label: string; cls: string }; + locationText: string; + completedInfo: CompletedInfo | null; + isActive: boolean; + dispatched: boolean; + confirming: boolean; + onClose: () => void; + onDispatch: () => void; + onConfirm: () => void; + onFalseAlarm: () => void; +} + +function formatDateTime(iso: string): string { + const d = new Date(iso); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +} + +function formatHMS(timestamp: string): string { + const d = new Date(timestamp); + return [d.getHours(), d.getMinutes(), d.getSeconds()] + .map((n) => String(n).padStart(2, "0")) + .join(":"); +} + +export default function EventInfoPanel({ + event, + badge, + locationText, + completedInfo, + isActive, + dispatched, + confirming, + onClose, + onDispatch, + onConfirm, + onFalseAlarm, +}: EventInfoPanelProps) { + return ( +
+
+
+ + Event #{event.id} + + + {badge.label} + +
+ +
+ +
+
+ +
+

기록시각

+

{formatHMS(event.timestamp)}

+
+
+ +
+ +
+

위치

+

{locationText}

+
+
+ + {event.event_type && ( +
+ +
+

감지유형

+

{labelEventType(event.event_type!)}

+
+
+ )} + + {event.confidence !== null && ( +

+ AI 신뢰도: {Math.round((event.confidence ?? 0) * 100)}% +

+ )} +
+ +
+ {completedInfo ? ( +
+ {completedInfo.type === "confirmed" ? ( +

{formatDateTime(completedInfo.at)}에 처리완료 되었습니다.

+ ) : ( + <> +

{formatDateTime(completedInfo.at)}에 오탐신고 되었습니다.

+ {completedInfo.reason && ( +

사유: {completedInfo.reason}

+ )} + + )} +
+ ) : isActive ? ( +
+ + + +
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/EventVideoPanel.tsx b/frontend/src/components/dashboard/EventVideoPanel.tsx new file mode 100644 index 0000000..7b41cb2 --- /dev/null +++ b/frontend/src/components/dashboard/EventVideoPanel.tsx @@ -0,0 +1,16 @@ +import { Video } from "lucide-react"; + +export default function EventVideoPanel({ clipUrl }: { clipUrl: string | null }) { + return ( +
+ {clipUrl ? ( +
+ ); +} diff --git a/frontend/src/components/dashboard/FalseAlarmList.tsx b/frontend/src/components/dashboard/FalseAlarmList.tsx index 9e678f4..feb3586 100644 --- a/frontend/src/components/dashboard/FalseAlarmList.tsx +++ b/frontend/src/components/dashboard/FalseAlarmList.tsx @@ -1,62 +1,34 @@ -/** - * @file components/dashboard/FalseAlarmList.tsx - * @description 대시보드 최근 오탐 신고 목록 컴포넌트 - * - * ## 기능 - * - GET /api/notifications/?unread_only=false 응답 최대 5건 표시 - * - read_at === null → 검토 중 (노란 아이콘) / read_at !== null → 오탐 확인 (초록 아이콘) - * - loading 시 스켈레톤 / 빈 배열 시 안내 문구 표시 - * - * ## 주의사항 - * - GET /api/notifications/ 는 인증 불필요 API - * - * ## TODO - * - [ ] 전체보기 버튼 → 오탐 신고 전체 목록 페이지 라우팅 연결 - * - [ ] 항목 클릭 시 해당 이벤트 상세보기 Modal 연동 - * - * ## 협의 - * - NotificationResponse에 event 정보 embed 여부 백엔드(조수근) 확인 필요 - */ - -import { Link } from "react-router-dom"; -import { AlertTriangle, CheckCircle } from "lucide-react"; -import type { NotificationResponse } from "@/types"; +import { AlertTriangle } from "lucide-react"; +import type { EventResponse } from "@/types"; interface FalseAlarmListProps { - notifications: NotificationResponse[]; + events: EventResponse[]; loading?: boolean; } function formatRelativeTime(timestamp: string): string { const d = new Date(timestamp); const now = new Date(); - const diffDays = Math.floor( - (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24), - ); + const diffDays = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)); const timeStr = `${d.getHours()}시 ${String(d.getMinutes()).padStart(2, "0")}분`; if (diffDays === 0) return `오늘 ${timeStr}`; if (diffDays === 1) return `어제 ${timeStr}`; return `${diffDays}일 전 ${timeStr}`; } -function getLabel(n: NotificationResponse): string { - if (n.event?.camera) { - return `${n.event.camera.station_name} CAM-${String(n.event.camera_id).padStart(2, "0")} - 오탐 신고`; - } - return `이벤트 #${n.event_id} - 오탐 신고`; +function getLabel(e: EventResponse): string { + const station = e.camera?.station_name; + const gate = e.camera?.location; + const location = station && gate ? `${station} ${gate}` : station ?? `카메라 #${e.camera_id}`; + const reason = e.reason ?? "기타"; + return `${location} — ${reason}`; } -export default function FalseAlarmList({ - notifications, - loading, -}: FalseAlarmListProps) { +export default function FalseAlarmList({ events, loading }: FalseAlarmListProps) { return (
-
+

최근 오탐 신고

- - 전체보기 -
{loading ? ( @@ -65,39 +37,28 @@ export default function FalseAlarmList({
))}
- ) : notifications.length === 0 ? ( + ) : events.length === 0 ? (

최근 오탐 신고 내역이 없습니다.

) : (
- {notifications.slice(0, 5).map((n) => { - const isResolved = n.read_at !== null; - return ( -
-
- {isResolved ? ( - - ) : ( - - )} -
-
-

- {getLabel(n)} -

-

- {formatRelativeTime(n.sent_at)} ·{" "} - {isResolved ? "오탐 확인" : "검토 중"} -

-
+ {events.map((e) => ( +
+
+
- ); - })} +
+

+ {getLabel(e)} +

+

+ {formatRelativeTime(e.handled_at ?? e.timestamp)} + {e.reason && ` · ${e.reason}`} +

+
+
+ ))}
)}
diff --git a/frontend/src/components/dashboard/FalseAlarmModal.tsx b/frontend/src/components/dashboard/FalseAlarmModal.tsx index c6c2d85..72b9bb5 100644 --- a/frontend/src/components/dashboard/FalseAlarmModal.tsx +++ b/frontend/src/components/dashboard/FalseAlarmModal.tsx @@ -1,20 +1,3 @@ -/** - * @file components/dashboard/FalseAlarmModal.tsx - * @description 오탐신고 모달 컴포넌트 - * - * ## 기능 - * - 오탐 사유 4가지 라디오 선택 (기타 선택 시 직접 입력) - * - POST /api/events/{id}/false-alarm { reason, memo? } 호출 후 닫기 - * - AlertItem 또는 EventDetailModal의 오탐신고 버튼으로 진입 - * - * ## 주의사항 - * - 오탐신고 완료 후 onSubmitted() 콜백으로 DashboardPage/EventsPage의 refresh() 호출 - * - POST /api/events/{id}/false-alarm 백엔드 M2 구현 예정 - * - * ## 백엔드 확정 후 수정 필요 - * - 요청 바디 필드명 ({ reason, memo? }) 확정 필요 - */ - import { useState } from "react"; import { X } from "lucide-react"; import type { EventResponse } from "@/types"; @@ -23,7 +6,7 @@ import { reportFalseAlarm } from "@/api/events"; interface FalseAlarmModalProps { event: EventResponse; onClose: () => void; - onSubmitted: () => void; + onSubmitted: (reason: string) => void; } const REASONS = [ @@ -60,11 +43,8 @@ export default function FalseAlarmModal({ setLoading(true); try { const reason = selectedReason === "기타" ? memo.trim() : selectedReason; - await reportFalseAlarm(event.id, { - reason, - memo: selectedReason === "기타" ? memo.trim() : undefined, - }); - onSubmitted(); + await reportFalseAlarm(event.id, { reason }); + onSubmitted(reason); onClose(); } catch { setError("오탐 신고 중 오류가 발생했습니다."); @@ -84,7 +64,9 @@ export default function FalseAlarmModal({ > {/* 헤더 */}
-

오탐신고

+

+ 오탐신고 +

- - {pageNumbers.map((p, i) => - p === "..." ? ( - - ... - - ) : ( - - ), - )} - + + {page} +
- {/* 우측: 페이지당 건수 */}
페이지당
setFormStation(e.target.value)} + required + className="w-full px-3 py-2 text-sm rounded-lg bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-[#4B73F7]" + /> +
+
+ + setFormLocation(e.target.value)} + required + className="w-full px-3 py-2 text-sm rounded-lg bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-[#4B73F7]" + /> +
+
+ {formError &&

{formError}

} +
+ +
+ + )} + + {loading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ ) : cameras.length === 0 ? ( +
+ 등록된 카메라가 없습니다. +
+ ) : ( +
    + {cameras.map((cam) => ( +
  • +
    + +
    +

    {cam.station_name}

    +

    {cam.location}

    +
    +
    +
    + + {cam.is_active ? "활성" : "비활성"} + + +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/settings/ProfileTab.tsx b/frontend/src/components/settings/ProfileTab.tsx new file mode 100644 index 0000000..e084161 --- /dev/null +++ b/frontend/src/components/settings/ProfileTab.tsx @@ -0,0 +1,48 @@ +import { useNavigate } from "react-router-dom"; +import { LogOut } from "lucide-react"; +import { useAppContext } from "@/hooks/useAppContext"; + +function decodeToken(): { employee_id: string } | null { + const token = localStorage.getItem("token") || sessionStorage.getItem("token"); + if (!token) return null; + try { + const payload = JSON.parse(atob(token.split(".")[1])); + return { employee_id: payload.employee_id ?? "—" }; + } catch { + return null; + } +} + +export default function ProfileTab() { + const navigate = useNavigate(); + const { setLoggedIn } = useAppContext(); + const profile = decodeToken(); + + function handleLogout() { + localStorage.removeItem("token"); + sessionStorage.removeItem("token"); + setLoggedIn(false); + navigate("/"); + } + + return ( +
+

내 프로필

+
+ 사원번호 + + {profile?.employee_id ?? "—"} + +
+
+ +
+
+ ); +} diff --git a/frontend/src/components/stats/CameraRankingTable.tsx b/frontend/src/components/stats/CameraRankingTable.tsx new file mode 100644 index 0000000..71d3e2f --- /dev/null +++ b/frontend/src/components/stats/CameraRankingTable.tsx @@ -0,0 +1,56 @@ +import type { CameraEventStats } from "@/types"; + +interface Props { + data: CameraEventStats[]; +} + +const BAR_COLORS = ["#ef4444", "#f59e0b", "#4B73F7", "#10b981", "#9ca3af"]; + +export default function CameraRankingTable({ data }: Props) { + if (data.length === 0) { + return ( +

데이터 없음

+ ); + } + + const maxCount = Math.max(...data.map((d) => d.count), 1); + + return ( + + + + + + + + + + + {data.map((row, i) => { + const pct = Math.round((row.count / maxCount) * 100); + const color = BAR_COLORS[i] ?? "#9ca3af"; + return ( + + + + + + + ); + })} + +
순위역 · 게이트건수비율
{i + 1} + {row.station_name} {row.location} + {row.count} +
+ {pct}% +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/stats/DailyTrendChart.tsx b/frontend/src/components/stats/DailyTrendChart.tsx new file mode 100644 index 0000000..fc7489b --- /dev/null +++ b/frontend/src/components/stats/DailyTrendChart.tsx @@ -0,0 +1,53 @@ +import ReactECharts from "echarts-for-react"; + +interface Props { + data: Record; +} + +export default function DailyTrendChart({ data }: Props) { + const dates = Object.keys(data); + const counts = Object.values(data); + + const option = { + grid: { top: 16, right: 8, bottom: 28, left: 32 }, + xAxis: { + type: "category", + data: dates, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: "#9ca3af", fontSize: 11 }, + }, + yAxis: { + type: "value", + minInterval: 1, + splitLine: { lineStyle: { color: "#f3f4f6" } }, + axisLabel: { color: "#9ca3af", fontSize: 11 }, + }, + series: [ + { + type: "line", + data: counts, + smooth: true, + symbol: "none", + lineStyle: { color: "#4B73F7", width: 2 }, + areaStyle: { + color: { + type: "linear", + x: 0, y: 0, x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: "rgba(75,115,247,0.18)" }, + { offset: 1, color: "rgba(75,115,247,0)" }, + ], + }, + }, + }, + ], + tooltip: { + trigger: "axis", + formatter: (params: { name: string; value: number }[]) => + `${params[0].name}: ${params[0].value}건`, + }, + }; + + return ; +} diff --git a/frontend/src/components/stats/EventTypeChart.tsx b/frontend/src/components/stats/EventTypeChart.tsx new file mode 100644 index 0000000..db6db68 --- /dev/null +++ b/frontend/src/components/stats/EventTypeChart.tsx @@ -0,0 +1,58 @@ +import ReactECharts from "echarts-for-react"; + +interface Props { + data: Record; +} + +const COLORS = ["#4B73F7", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#6b7280"]; + +export default function EventTypeChart({ data }: Props) { + const total = Object.values(data).reduce((s, v) => s + v, 0); + + if (total === 0) { + return ( +
+ 데이터 없음 +
+ ); + } + + const seriesData = Object.entries(data).map(([name, value], i) => ({ + name, + value, + itemStyle: { color: COLORS[i % COLORS.length] }, + })); + + const option = { + tooltip: { + trigger: "item", + formatter: "{b}: {c}건 ({d}%)", + }, + legend: { + orient: "vertical", + right: 0, + top: "center", + textStyle: { fontSize: 11, color: "#6b7280" }, + itemWidth: 10, + itemHeight: 10, + }, + series: [ + { + type: "pie", + radius: ["38%", "68%"], + center: ["38%", "50%"], + data: seriesData, + label: { + formatter: "{d}%", + fontSize: 11, + color: "#fff", + position: "inside", + }, + labelLine: { show: false }, + itemStyle: { borderRadius: 3, borderWidth: 2, borderColor: "#fff" }, + }, + ], + }; + + return ; +} diff --git a/frontend/src/components/stats/FalseAlarmTable.tsx b/frontend/src/components/stats/FalseAlarmTable.tsx new file mode 100644 index 0000000..edee21f --- /dev/null +++ b/frontend/src/components/stats/FalseAlarmTable.tsx @@ -0,0 +1,32 @@ +interface Props { + data: Record; +} + +export default function FalseAlarmTable({ data }: Props) { + const rows = Object.entries(data).sort((a, b) => b[1] - a[1]); + + if (rows.length === 0) { + return ( +

오탐 신고 없음

+ ); + } + + return ( + + + + + + + + + {rows.map(([reason, count]) => ( + + + + + ))} + +
오탐사유건수
{reason}{count}건
+ ); +} diff --git a/frontend/src/components/stats/HourlyDistributionChart.tsx b/frontend/src/components/stats/HourlyDistributionChart.tsx new file mode 100644 index 0000000..bb6e713 --- /dev/null +++ b/frontend/src/components/stats/HourlyDistributionChart.tsx @@ -0,0 +1,57 @@ +import ReactECharts from "echarts-for-react"; + +interface Props { + data: Record; +} + +const SLOTS = [ + "00-02","02-04","04-06","06-08","08-10","10-12", + "12-14","14-16","16-18","18-20","20-22","22-24", +]; + +function slotColor(count: number) { + if (count >= 25) return "#ef4444"; + if (count >= 15) return "#f59e0b"; + return "#4B73F7"; +} + +export default function HourlyDistributionChart({ data }: Props) { + const slots = [...SLOTS].reverse(); + const counts = slots.map((s) => data[s] ?? 0); + + const option = { + grid: { top: 4, right: 48, bottom: 4, left: 8, containLabel: true }, + xAxis: { type: "value", show: false }, + yAxis: { + type: "category", + data: slots, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: "#6b7280", fontSize: 11 }, + }, + series: [ + { + type: "bar", + data: counts.map((v) => ({ + value: v, + itemStyle: { color: slotColor(v), borderRadius: [0, 3, 3, 0] }, + })), + barMaxWidth: 18, + label: { + show: true, + position: "right", + color: "#6b7280", + fontSize: 11, + formatter: "{c}건", + }, + }, + ], + tooltip: { + trigger: "axis", + formatter: (params: { name: string; value: number }[]) => + `${params[0].name}시: ${params[0].value}건`, + }, + }; + + return ; +} diff --git a/frontend/src/components/stats/StatSummaryCards.tsx b/frontend/src/components/stats/StatSummaryCards.tsx new file mode 100644 index 0000000..91eb955 --- /dev/null +++ b/frontend/src/components/stats/StatSummaryCards.tsx @@ -0,0 +1,105 @@ +import type { EventStats, EventResponse } from "@/types"; + +interface Props { + stats: EventStats | null; + events: EventResponse[]; + avgDaily: number | null; + avgProcessMin: number | null; + loading: boolean; +} + +const cards = [ + { + key: "today_total" as const, + label: "누적 발생 건수", + color: "text-red-400", + bg: "bg-red-50 dark:bg-red-900/20", + suffix: "건", + }, + { + key: "avg_daily" as const, + label: "일평균 발생", + color: "text-orange-400", + bg: "bg-orange-50 dark:bg-orange-900/20", + suffix: "건", + }, + { + key: "false_alarm_rate" as const, + label: "오탐율", + color: "text-green-500", + bg: "bg-green-50 dark:bg-green-900/20", + suffix: "%", + }, + { + key: "avg_process" as const, + label: "평균 처리 시간", + color: "text-blue-500", + bg: "bg-blue-50 dark:bg-blue-900/20", + suffix: "분", + }, +]; + +export default function StatSummaryCards({ + stats, + events, + avgDaily, + avgProcessMin, + loading, +}: Props) { + const falseAlarmRate = + events.length > 0 + ? ( + (events.filter((e) => e.status === "false_alarm").length / + events.length) * + 100 + ).toFixed(1) + : "0.0"; + + const getValue = (key: (typeof cards)[number]["key"]) => { + if (key === "today_total") + return events.length > 0 + ? events.length.toString() + : stats + ? stats.today_total.toString() + : "—"; + if (key === "avg_daily") + return avgDaily !== null ? avgDaily.toFixed(1) : "—"; + if (key === "false_alarm_rate") return falseAlarmRate; + if (key === "avg_process") + return avgProcessMin !== null ? avgProcessMin.toFixed(1) : "—"; + return "—"; + }; + + if (loading) { + return ( +
+ {cards.map((c) => ( +
+ ))} +
+ ); + } + + return ( +
+ {cards.map((c) => ( +
+

{c.label}

+
+ + {getValue(c.key)} + + {getValue(c.key) !== "—" && ( + + {c.suffix} + + )} +
+
+ ))} +
+ ); +} diff --git a/frontend/src/constants/eventTypes.ts b/frontend/src/constants/eventTypes.ts new file mode 100644 index 0000000..b2d40bb --- /dev/null +++ b/frontend/src/constants/eventTypes.ts @@ -0,0 +1,21 @@ +export const EVENT_TYPE_LABEL: Record = { + tailgating: "테일게이팅", + jump: "점프 통과", + crawling: "기어서 통과", + unpaid: "태그 없이 통행", + emergencydoor: "비상문 진입", + normal: "정상", + unknown: "알 수 없음", +}; + +export const EVENT_TYPE_OPTIONS = [ + { value: "", label: "전체 유형" }, + { value: "tailgating", label: "테일게이팅" }, + { value: "jump", label: "점프 통과" }, + { value: "crawling", label: "기어서 통과" }, + { value: "unpaid", label: "태그 없이 통행" }, +]; + +export function labelEventType(raw: string): string { + return EVENT_TYPE_LABEL[raw] ?? raw; +} diff --git a/frontend/src/contexts/AppContext.tsx b/frontend/src/contexts/AppContext.tsx index dbff399..2b472e9 100644 --- a/frontend/src/contexts/AppContext.tsx +++ b/frontend/src/contexts/AppContext.tsx @@ -1,45 +1,57 @@ -/** - * @file contexts/AppContext.tsx - * @description 앱 전역 상태 컨텍스트 - * - * ## 공유 상태 - * - wsConnected: WebSocket 연결 여부 → Header 실시간 모니터링 뱃지 on/off - * - unconfirmedCount: 미확인 이벤트 수 → Sidebar 빨간 뱃지 - * - * ## 사용처 - * - wsConnected 설정: DashboardPage, EventsPage (useWebSocket 연결 시) - * - unconfirmedCount 설정: DashboardPage - * - 읽기: Header (wsConnected), Sidebar (unconfirmedCount) - */ - -import { createContext, useContext, useState } from "react"; +import { useState, useRef, useCallback, useEffect } from "react"; import type { ReactNode } from "react"; - -interface AppContextValue { - wsConnected: boolean; - setWsConnected: (v: boolean) => void; - unconfirmedCount: number; - setUnconfirmedCount: (v: number) => void; -} - -const AppContext = createContext({ - wsConnected: false, - setWsConnected: () => {}, - unconfirmedCount: 0, - setUnconfirmedCount: () => {}, -}); +import { toast } from "sonner"; +import { AppContext } from "./appContextDef"; +import type { WsEventListener } from "./appContextDef"; +import { useWebSocket } from "@/hooks/useWebSocket"; +import type { EventResponse } from "@/types"; +import { getEventStats } from "@/api/events"; export function AppProvider({ children }: { children: ReactNode }) { - const [wsConnected, setWsConnected] = useState(false); const [unconfirmedCount, setUnconfirmedCount] = useState(0); + const [loggedIn, setLoggedIn] = useState( + () => !!(localStorage.getItem("token") || sessionStorage.getItem("token")), + ); + + useEffect(() => { + if (!loggedIn) return; + getEventStats().then((stats) => setUnconfirmedCount(stats.pending)).catch(() => {}); + }, [loggedIn]); + const listenersRef = useRef>(new Set()); + + const subscribeWsEvent = useCallback((cb: WsEventListener) => { + listenersRef.current.add(cb); + return () => { + listenersRef.current.delete(cb); + }; + }, []); + + const { connected } = useWebSocket((msg) => { + if (msg.type !== "NEW_EVENT") return; + const event = msg.data as EventResponse; + + setUnconfirmedCount((prev) => prev + 1); + + toast.error("새로운 이벤트 감지됨", { + description: "대시보드에서 확인하세요.", + duration: 5000, + }); + + listenersRef.current.forEach((cb) => cb(event)); + }, loggedIn); return ( {}, + unconfirmedCount, + setUnconfirmedCount, + subscribeWsEvent, + setLoggedIn, + }} > {children} ); } - -export const useAppContext = () => useContext(AppContext); diff --git a/frontend/src/contexts/appContextDef.ts b/frontend/src/contexts/appContextDef.ts new file mode 100644 index 0000000..c663eb8 --- /dev/null +++ b/frontend/src/contexts/appContextDef.ts @@ -0,0 +1,22 @@ +import { createContext } from "react"; +import type { EventResponse } from "@/types"; + +export type WsEventListener = (event: EventResponse) => void; + +export interface AppContextValue { + wsConnected: boolean; + setWsConnected: (v: boolean) => void; + unconfirmedCount: number; + setUnconfirmedCount: (v: number) => void; + subscribeWsEvent: (cb: WsEventListener) => () => void; + setLoggedIn: (v: boolean) => void; +} + +export const AppContext = createContext({ + wsConnected: false, + setWsConnected: () => {}, + unconfirmedCount: 0, + setUnconfirmedCount: () => {}, + subscribeWsEvent: () => () => {}, + setLoggedIn: () => {}, +}); diff --git a/frontend/src/hooks/useAppContext.ts b/frontend/src/hooks/useAppContext.ts new file mode 100644 index 0000000..7427046 --- /dev/null +++ b/frontend/src/hooks/useAppContext.ts @@ -0,0 +1,4 @@ +import { useContext } from "react"; +import { AppContext } from "@/contexts/appContextDef"; + +export const useAppContext = () => useContext(AppContext); diff --git a/frontend/src/hooks/useDashboardData.ts b/frontend/src/hooks/useDashboardData.ts new file mode 100644 index 0000000..dbd2153 --- /dev/null +++ b/frontend/src/hooks/useDashboardData.ts @@ -0,0 +1,117 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useAppContext } from "@/hooks/useAppContext"; +import { getEvents, getEventStats, getEventStatsByCamera } from "@/api/events"; +import { getCameras } from "@/api/cameras"; +import type { + EventResponse, + EventStats, + CameraEventStats, + CameraResponse, +} from "@/types"; + +export function useDashboardData() { + const { setUnconfirmedCount, subscribeWsEvent } = useAppContext(); + const [events, setEvents] = useState([]); + const [stats, setStats] = useState(null); + const [cameraStats, setCameraStats] = useState([]); + const [falseAlarmEvents, setFalseAlarmEvents] = useState([]); + const [loadingEvents, setLoadingEvents] = useState(true); + const [loadingStats, setLoadingStats] = useState(true); + const [loadingCamera, setLoadingCamera] = useState(true); + const [loadingFalseAlarm, setLoadingFalseAlarm] = useState(true); + const cameraMapRef = useRef>(new Map()); + + const joinCamera = useCallback((e: EventResponse): EventResponse => ({ + ...e, + camera: cameraMapRef.current.get(e.camera_id) ?? e.camera, + }), []); + + const doFetch = useCallback(async () => { + const [camResult, evResult, statsResult, camStatsResult, falseAlarmResult] = + await Promise.allSettled([ + getCameras(), + getEvents({ limit: 10 }), + getEventStats(), + getEventStatsByCamera(), + getEvents({ status: "false_alarm", limit: 5 }), + ]); + + if (camResult.status === "fulfilled") { + cameraMapRef.current = new Map(camResult.value.map((c) => [c.id, c])); + } + if (evResult.status === "fulfilled") { + setEvents(evResult.value.map(joinCamera)); + } + setLoadingEvents(false); + + if (statsResult.status === "fulfilled") setStats(statsResult.value); + setLoadingStats(false); + + if (camStatsResult.status === "fulfilled") { + setCameraStats( + camStatsResult.value.map((cs) => { + const cam = cameraMapRef.current.get(cs.camera_id); + return { + ...cs, + station_name: cam?.station_name ?? cs.station_name ?? "", + location: cam?.location ?? cs.location ?? "", + }; + }), + ); + } + setLoadingCamera(false); + + if (falseAlarmResult.status === "fulfilled") { + setFalseAlarmEvents(falseAlarmResult.value.map(joinCamera)); + } + setLoadingFalseAlarm(false); + }, [joinCamera]); + + const refresh = useCallback(async () => { + setLoadingEvents(true); + setLoadingStats(true); + setLoadingCamera(true); + setLoadingFalseAlarm(true); + await doFetch(); + }, [doFetch]); + + useEffect(() => { + doFetch(); + }, [doFetch]); + + useEffect(() => { + return subscribeWsEvent((newEvent) => { + const enriched = joinCamera(newEvent); + setEvents((prev) => [enriched, ...prev].slice(0, 10)); + setStats((prev) => + prev + ? { ...prev, today_total: prev.today_total + 1, pending: prev.pending + 1 } + : prev, + ); + setCameraStats((prev) => + prev.map((c) => + c.camera_id === newEvent.camera_id ? { ...c, count: c.count + 1 } : c, + ), + ); + }); + }, [subscribeWsEvent, joinCamera]); + + const unconfirmedCount = stats?.pending ?? events.filter((e) => e.status === "pending").length; + + useEffect(() => { + setUnconfirmedCount(unconfirmedCount); + }, [unconfirmedCount, setUnconfirmedCount]); + + return { + events, + stats, + cameraStats, + falseAlarmEvents, + loadingEvents, + loadingStats, + loadingCamera, + loadingFalseAlarm, + unconfirmedCount, + refresh, + }; +} diff --git a/frontend/src/hooks/useEventsPage.ts b/frontend/src/hooks/useEventsPage.ts new file mode 100644 index 0000000..eb35261 --- /dev/null +++ b/frontend/src/hooks/useEventsPage.ts @@ -0,0 +1,154 @@ +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { getEvents } from "@/api/events"; +import { getCameras } from "@/api/cameras"; +import { useAppContext } from "./useAppContext"; +import type { EventResponse, CameraResponse } from "@/types"; +import { DEFAULT_FILTERS, type EventFilters } from "@/components/events/eventFiltersConfig"; + +function periodToDates(period: EventFilters["period"]): { date_from?: string; date_to?: string } { + if (period === "all") return {}; + const now = new Date(); + if (period === "today") { + const from = new Date(now); + from.setHours(0, 0, 0, 0); + const to = new Date(now); + to.setHours(23, 59, 59, 999); + return { date_from: from.toISOString(), date_to: to.toISOString() }; + } + const days = period === "week" ? 7 : 30; + const from = new Date(now.getTime() - days * 86400_000); + return { date_from: from.toISOString() }; +} + +export function useEventsPage() { + const { subscribeWsEvent } = useAppContext(); + const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(8); + const [rawEvents, setRawEvents] = useState([]); + const [hasNextPage, setHasNextPage] = useState(false); + const [loading, setLoading] = useState(true); + const cameraMapRef = useRef>(new Map()); + const [cameras, setCameras] = useState([]); + + // 카메라 목록 초기 로드 — 이벤트에 camera 정보 조인용 + useEffect(() => { + getCameras() + .then((cams) => { + cameraMapRef.current = new Map(cams.map((c) => [c.id, c])); + setCameras(cams); + // 이미 fetch된 이벤트에 카메라 정보 후주입 + setRawEvents((prev) => + prev.map((e) => ({ + ...e, + camera: cameraMapRef.current.get(e.camera_id) ?? e.camera, + })) + ); + }) + .catch(() => {}); + }, []); + + const doFetch = useCallback(async (f: EventFilters, p: number, ps: number) => { + setLoading(true); + const { date_from, date_to } = periodToDates(f.period); + const params: Parameters[0] = { + limit: ps + 1, // 다음 페이지 존재 여부 판단용 +1 + offset: (p - 1) * ps, + ...(f.status ? { status: f.status } : {}), + ...(f.type ? { type: f.type } : {}), + ...(f.cameraId ? { camera_id: Number(f.cameraId) } : {}), + ...(date_from ? { date_from } : {}), + ...(date_to ? { date_to } : {}), + ...(f.search ? { search: f.search } : {}), + }; + try { + const result = await getEvents(params); + setHasNextPage(result.length > ps); + setRawEvents( + result.slice(0, ps).map((e) => ({ + ...e, + camera: cameraMapRef.current.get(e.camera_id) ?? e.camera, + })) + ); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + doFetch(filters, page, pageSize); + }, [filters, page, pageSize, doFetch]); + + const handleFiltersChange = useCallback((newFilters: EventFilters) => { + setFilters(newFilters); + setPage(1); + }, []); + + const handleSetPageSize = useCallback((ps: number) => { + setPageSize(ps); + setPage(1); + }, []); + + // station: 백엔드 파라미터 미지원 → 현재 페이지 내 클라이언트 필터 + const displayEvents = useMemo(() => { + if (!filters.station) return rawEvents; + return rawEvents.filter( + (e) => e.camera?.station_name === filters.station + ); + }, [rawEvents, filters.station]); + + const cameraOptions = useMemo( + () => + [...cameras] + .sort((a, b) => a.id - b.id) + .map((c) => ({ id: c.id, label: `${c.station_name} ${c.location}` })), + [cameras] + ); + + const stationOptions = useMemo( + () => + ([...new Set(cameras.map((c) => c.station_name).filter(Boolean))] as string[]).sort(), + [cameras] + ); + + const exportAll = useCallback(async (): Promise => { + const { date_from, date_to } = periodToDates(filters.period); + const params: Parameters[0] = { + limit: 10000, + offset: 0, + ...(filters.status ? { status: filters.status } : {}), + ...(filters.type ? { type: filters.type } : {}), + ...(filters.cameraId ? { camera_id: Number(filters.cameraId) } : {}), + ...(date_from ? { date_from } : {}), + ...(date_to ? { date_to } : {}), + ...(filters.search ? { search: filters.search } : {}), + }; + const result = await getEvents(params); + return result.map((e) => ({ + ...e, + camera: cameraMapRef.current.get(e.camera_id) ?? e.camera, + })); + }, [filters]); + + useEffect(() => { + return subscribeWsEvent(() => { + doFetch(filters, page, pageSize); + }); + }, [subscribeWsEvent, doFetch, filters, page, pageSize]); + + return { + filters, + handleFiltersChange, + displayEvents, + loading, + page, + setPage, + pageSize, + setPageSize: handleSetPageSize, + hasNextPage, + cameraOptions, + stationOptions, + refetch: () => doFetch(filters, page, pageSize), + exportAll, + }; +} diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index a5e9cfb..9ab3e61 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -1,19 +1,4 @@ -/** - * @file hooks/useWebSocket.ts - * @description WebSocket 연결 및 실시간 이벤트 수신 훅 - * - * ## 기능 - * - VITE_WS_URL 연결, NEW_EVENT 메시지 수신 - * - 연결 끊김 시 3초 후 자동 재연결 - * - connected 상태 반환 → AppContext를 통해 Header 뱃지 연동 - * - per-effect `let active` 패턴으로 React StrictMode 이중 마운트 시 WebSocket 이중 연결 방지 - * - onMessage 콜백을 ref로 관리하여 리렌더링 없이 최신 핸들러 유지 - * - * ## 주의사항 - * - WS_URL은 VITE_WS_URL 환경변수로 관리 (.env 파일 참고) - */ - -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; const WS_URL = import.meta.env.VITE_WS_URL as string; @@ -22,21 +7,22 @@ export interface WsMessage { data: unknown; } -/** - * WebSocket 연결을 관리하는 훅. - * 연결이 끊어지면 3초 후 자동 재연결. - * onMessage 콜백은 ref로 관리하여 리렌더링 없이 최신 참조 유지. - * @returns { connected } — 현재 WebSocket 연결 여부 - */ -export function useWebSocket(onMessage: (msg: WsMessage) => void): { +export function useWebSocket( + onMessage: (msg: WsMessage) => void, + enabled = true, +): { connected: boolean; } { const onMessageRef = useRef(onMessage); - onMessageRef.current = onMessage; // 매 렌더마다 최신 콜백으로 갱신 + useLayoutEffect(() => { + onMessageRef.current = onMessage; + }); const [connected, setConnected] = useState(false); useEffect(() => { + if (!enabled) return; + let active = true; function connect(): WebSocket { diff --git a/frontend/src/lib/stats.ts b/frontend/src/lib/stats.ts new file mode 100644 index 0000000..e7ab380 --- /dev/null +++ b/frontend/src/lib/stats.ts @@ -0,0 +1,60 @@ +import { labelEventType } from "@/constants/eventTypes"; +import type { EventResponse } from "@/types"; + +const DAILY_DAYS = 12; + +export function buildDailyData(events: EventResponse[]): Record { + const result: Record = {}; + for (let i = DAILY_DAYS - 1; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + result[`${d.getMonth() + 1}/${d.getDate()}`] = 0; + } + events.forEach((e) => { + if (e.status === "false_alarm") return; + const d = new Date(e.timestamp); + const key = `${d.getMonth() + 1}/${d.getDate()}`; + if (key in result) result[key]++; + }); + return result; +} + +export function buildHourlyData(events: EventResponse[]): Record { + const slots = [ + "00-02", "02-04", "04-06", "06-08", "08-10", "10-12", + "12-14", "14-16", "16-18", "18-20", "20-22", "22-24", + ]; + const result: Record = Object.fromEntries(slots.map((s) => [s, 0])); + events.forEach((e) => { + if (e.status === "false_alarm") return; + const h = new Date(e.timestamp).getHours(); + const start = Math.floor(h / 2) * 2; + const key = `${String(start).padStart(2, "0")}-${String(start + 2).padStart(2, "0")}`; + if (key in result) result[key]++; + }); + return result; +} + +export function buildTypeData(events: EventResponse[]): Record { + const result: Record = {}; + events.forEach((e) => { + if (e.status === "false_alarm") return; + if (!e.event_type || e.event_type === "normal" || e.event_type === "unknown") return; + const label = labelEventType(e.event_type); + result[label] = (result[label] ?? 0) + 1; + }); + return result; +} + +export function buildFalseAlarmData(events: EventResponse[]): Record { + const result: Record = {}; + events + .filter((e) => e.status === "false_alarm") + .forEach((e) => { + const reason = e.reason ?? "기타"; + result[reason] = (result[reason] ?? 0) + 1; + }); + return result; +} + +export { DAILY_DAYS }; diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 0420677..4c52efd 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,28 +1,4 @@ -/** - * @file pages/DashboardPage.tsx - * @description 대시보드 메인 페이지 — 데이터 페칭 및 상태 관리 총괄 - * - * ## 기능 - * - 마운트 시 5개 API 병렬 호출 (Promise.allSettled): - * - GET /api/cameras/ 카메라 목록 (위치 정보 조인용) - * - GET /api/events/?limit=10 최신 이벤트 10건 - * - GET /api/events/stats 통계 카드 데이터 - * - GET /api/events/stats/by-camera 구간별 알림현황 - * - GET /api/notifications/?unread_only=false 오탐 신고 목록 - * - cameraMapRef로 카메라 맵 관리: WebSocket 핸들러에서도 최신 맵을 참조하여 역이름/게이트 조인 - * - WebSocket NEW_EVENT 수신 시 카메라 정보 조인 후 목록 맨 앞 삽입, 최대 10건 유지, stats 낙관적 업데이트 - * - 처리완료 / 오탐신고 완료 후 refresh()로 전체 상태 재동기화 - * - * ## 주의사항 - * - 백엔드 미구현 API (stats, stats/by-camera, false-alarm, status) 실패 시 null/빈 배열로 graceful fallback - * - 401 응답은 axios interceptor가 자동으로 `/`로 리다이렉트 처리 - * - * ## 주의사항 (추가) - * - AppContext를 통해 wsConnected, unconfirmedCount를 Sidebar/Header에 공유 - */ - -import { useState, useEffect, useCallback, useRef } from "react"; -import { useAppContext } from "@/contexts/AppContext"; +import { useState } from "react"; import Sidebar from "@/components/layout/Sidebar"; import Header from "@/components/layout/Header"; import StatCards from "@/components/dashboard/StatCards"; @@ -31,116 +7,18 @@ import CameraStats from "@/components/dashboard/CameraStats"; import FalseAlarmList from "@/components/dashboard/FalseAlarmList"; import EventDetailModal from "@/components/dashboard/EventDetailModal"; import FalseAlarmModal from "@/components/dashboard/FalseAlarmModal"; -import { getEvents, getEventStats, getEventStatsByCamera } from "@/api/events"; -import { getNotifications } from "@/api/notifications"; -import { getCameras } from "@/api/cameras"; -import { useWebSocket } from "@/hooks/useWebSocket"; -import type { - EventResponse, - EventStats, - CameraEventStats, - CameraResponse, - NotificationResponse, -} from "@/types"; +import { useDashboardData } from "@/hooks/useDashboardData"; +import type { EventResponse } from "@/types"; export default function DashboardPage() { - const { setWsConnected, setUnconfirmedCount } = useAppContext(); - const [events, setEvents] = useState([]); - const [stats, setStats] = useState(null); - const [cameraStats, setCameraStats] = useState([]); - const [notifications, setNotifications] = useState([]); - - const [loadingEvents, setLoadingEvents] = useState(true); - const [loadingStats, setLoadingStats] = useState(true); - const [loadingCamera, setLoadingCamera] = useState(true); - const [loadingNotif, setLoadingNotif] = useState(true); - + const { + events, stats, cameraStats, falseAlarmEvents, + loadingEvents, loadingStats, loadingCamera, loadingFalseAlarm, + unconfirmedCount, refresh, + } = useDashboardData(); const [selectedEvent, setSelectedEvent] = useState(null); const [falseAlarmEvent, setFalseAlarmEvent] = useState(null); - // WebSocket 핸들러에서도 최신 카메라 맵을 참조하기 위해 ref 사용 - const cameraMapRef = useRef>(new Map()); - - const refresh = useCallback(async () => { - setLoadingEvents(true); - setLoadingStats(true); - setLoadingCamera(true); - setLoadingNotif(true); - - const [camResult, evResult, statsResult, camStatsResult, notifResult] = - await Promise.allSettled([ - getCameras(), - getEvents({ limit: 10 }), - getEventStats(), - getEventStatsByCamera(), - getNotifications({ unread_only: false }), - ]); - - // 카메라 맵 먼저 구성 (이벤트 조인에 필요) - if (camResult.status === "fulfilled") { - cameraMapRef.current = new Map(camResult.value.map((c) => [c.id, c])); - } - - // 이벤트에 카메라 정보 조인 - if (evResult.status === "fulfilled") { - setEvents( - evResult.value.map((e) => ({ - ...e, - camera: cameraMapRef.current.get(e.camera_id) ?? e.camera, - })), - ); - } - setLoadingEvents(false); - - if (statsResult.status === "fulfilled") setStats(statsResult.value); - setLoadingStats(false); - - if (camStatsResult.status === "fulfilled") setCameraStats(camStatsResult.value); - setLoadingCamera(false); - - if (notifResult.status === "fulfilled") setNotifications(notifResult.value); - setLoadingNotif(false); - }, []); - - useEffect(() => { - refresh(); - }, [refresh]); - - // WebSocket 연결 상태 → AppContext 동기화 - const { connected } = useWebSocket((msg) => { - if (msg.type === "NEW_EVENT") { - const newEvent = msg.data as EventResponse; - // 카메라 정보 조인 후 목록 맨 앞 삽입, 최대 10건 유지 - const enriched: EventResponse = { - ...newEvent, - camera: cameraMapRef.current.get(newEvent.camera_id) ?? newEvent.camera, - }; - setEvents((prev) => [enriched, ...prev].slice(0, 10)); - // 통계 카드 낙관적 업데이트 - setStats((prev) => - prev - ? { - ...prev, - today_total: prev.today_total + 1, - pending: prev.pending + 1, - } - : prev, - ); - } - }); - - const unconfirmedCount = events.filter( - (e) => e.status === "pending", - ).length; - - useEffect(() => { - setWsConnected(connected); - }, [connected, setWsConnected]); - - useEffect(() => { - setUnconfirmedCount(unconfirmedCount); - }, [unconfirmedCount, setUnconfirmedCount]); - const handleOpenFalseAlarm = (event: EventResponse) => { setSelectedEvent(null); setFalseAlarmEvent(event); @@ -152,12 +30,8 @@ export default function DashboardPage() {
- {/* 통계 카드 */} - - {/* 메인 콘텐츠 */}
- {/* 최신알림 */} - - {/* lg 미만에서는 하단으로: 구간별 알림현황 + 최근 오탐 신고 */}
- +
- {/* 이벤트 상세 모달 */} {selectedEvent && ( setSelectedEvent(null)} - onFalseAlarm={handleOpenFalseAlarm} onConfirmed={refresh} /> )} - - {/* 오탐신고 모달 */} {falseAlarmEvent && ( setFalseAlarmEvent(null)} - onSubmitted={refresh} + onSubmitted={() => refresh()} /> )}
diff --git a/frontend/src/pages/EventsPage.tsx b/frontend/src/pages/EventsPage.tsx index a09e7c9..cb5c12c 100644 --- a/frontend/src/pages/EventsPage.tsx +++ b/frontend/src/pages/EventsPage.tsx @@ -1,200 +1,30 @@ -/** - * @file pages/EventsPage.tsx - * @description 전체 발생내역 페이지 - * - * ## 기능 - * - GET /api/cameras/ + GET /api/events/?limit=500 병렬 호출 후 카메라 정보 조인 - * - 클라이언트사이드 필터: 텍스트 검색(EV-번호/CAM-번호/역/게이트/인상착의/설명) / 기간 / 감지유형 / 카메라 / 상태 / 역 - * - 클라이언트사이드 페이지네이션: 기본 8건, 선택 가능 (8 / 16 / 32) - * - EventDetailModal / FalseAlarmModal 재사용 (DashboardPage와 동일 컴포넌트) - * - EventDetailModal에는 allEvents 전달 (필터된 배열 아님) — 모달 내 이전/다음 탐색을 위해 - * - WebSocket NEW_EVENT 수신 시 카메라 정보 조인 후 allEvents 앞에 실시간 삽입 - * - AppContext를 통해 wsConnected 상태 공유 - * - * ## 주의사항 - * - 현재 클라이언트사이드 페이지네이션 (limit=500 fetch) - * → 이벤트 수 대규모 시 GET /api/events/?skip=N&limit=M 서버사이드로 전환 필요 - * - * ## TODO - * - [ ] 서버사이드 페이지네이션 전환 (백엔드 skip/limit 파라미터 지원 확인 후) - */ - -import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { useState } from "react"; import Sidebar from "@/components/layout/Sidebar"; import Header from "@/components/layout/Header"; -import EventsFilter, { - DEFAULT_FILTERS, - type EventFilters, -} from "@/components/events/EventsFilter"; +import EventsFilter from "@/components/events/EventsFilter"; import EventsTable from "@/components/events/EventsTable"; import EventsPagination from "@/components/events/EventsPagination"; import EventDetailModal from "@/components/dashboard/EventDetailModal"; -import FalseAlarmModal from "@/components/dashboard/FalseAlarmModal"; -import { getEvents } from "@/api/events"; -import { getCameras } from "@/api/cameras"; -import { useWebSocket } from "@/hooks/useWebSocket"; -import { useAppContext } from "@/contexts/AppContext"; -import type { EventResponse, CameraResponse } from "@/types"; +import { useEventsPage } from "@/hooks/useEventsPage"; +import type { EventResponse } from "@/types"; export default function EventsPage() { - const { setWsConnected } = useAppContext(); - const [allEvents, setAllEvents] = useState([]); - const [loading, setLoading] = useState(true); - const [filters, setFilters] = useState(DEFAULT_FILTERS); - const [page, setPage] = useState(1); - const cameraMapRef = useRef>(new Map()); - const [pageSize, setPageSize] = useState(8); + const { + filters, + handleFiltersChange, + displayEvents, + loading, + page, + setPage, + pageSize, + setPageSize, + hasNextPage, + cameraOptions, + stationOptions, + refetch, + exportAll, + } = useEventsPage(); const [selectedEvent, setSelectedEvent] = useState(null); - const [falseAlarmEvent, setFalseAlarmEvent] = useState(null); - - const fetchAll = useCallback(async () => { - setLoading(true); - try { - const [camResult, evResult] = await Promise.allSettled([ - getCameras(), - getEvents({ limit: 500 }), - ]); - - // 카메라 맵 구성 (WebSocket 핸들러에서도 참조하기 위해 ref에 저장) - if (camResult.status === "fulfilled") { - cameraMapRef.current = new Map(camResult.value.map((c) => [c.id, c])); - } - - // 이벤트에 카메라 정보 조인 - if (evResult.status === "fulfilled") { - setAllEvents( - evResult.value.map((e) => ({ - ...e, - camera: cameraMapRef.current.get(e.camera_id) ?? e.camera, - })), - ); - } - } catch { - // 오류 시 빈 배열 유지 - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchAll(); - }, [fetchAll]); - - // WebSocket: 새 이벤트 실시간 수신 - const { connected } = useWebSocket((msg) => { - if (msg.type === "NEW_EVENT") { - const newEvent = msg.data as EventResponse; - const enriched: EventResponse = { - ...newEvent, - camera: cameraMapRef.current.get(newEvent.camera_id) ?? newEvent.camera, - }; - setAllEvents((prev) => [enriched, ...prev]); - } - }); - - useEffect(() => { - setWsConnected(connected); - }, [connected, setWsConnected]); - - const handleFiltersChange = (newFilters: EventFilters) => { - setFilters(newFilters); - setPage(1); // 필터 변경 시 1페이지로 리셋 - }; - - // ── 클라이언트사이드 필터링 ────────────────────────── - const filteredEvents = useMemo(() => { - return allEvents.filter((e) => { - // 텍스트 검색: 이벤트 ID / 역이름 / 게이트 / 카메라 / 인상착의 / 설명 - if (filters.search) { - const q = filters.search.toLowerCase(); - const eventId = `ev-${String(e.id).padStart(4, "0")}`; - const station = (e.camera?.station_name ?? "").toLowerCase(); - const gate = (e.camera?.location ?? "").toLowerCase(); - const camLabel = `cam-${String(e.camera_id).padStart(2, "0")}`; - const tags = (e.appearance_tags ?? []).join(" ").toLowerCase(); - const desc = (e.description ?? "").toLowerCase(); - if ( - !eventId.includes(q) && - !station.includes(q) && - !gate.includes(q) && - !camLabel.includes(q) && - !tags.includes(q) && - !desc.includes(q) - ) { - return false; - } - } - - // 기간 필터 - if (filters.period !== "all") { - const eventDate = new Date(e.timestamp); - const now = new Date(); - if (filters.period === "today") { - if (eventDate.toDateString() !== now.toDateString()) return false; - } else if (filters.period === "week") { - if (eventDate < new Date(now.getTime() - 7 * 86400_000)) return false; - } else if (filters.period === "month") { - if (eventDate < new Date(now.getTime() - 30 * 86400_000)) return false; - } - } - - // 감지유형 필터 (event_type 우선, description fallback) - if (filters.type) { - const eventType = e.event_type ?? e.description ?? ""; - if (!eventType.includes(filters.type)) return false; - } - - // 카메라 필터 - if (filters.cameraId && String(e.camera_id) !== filters.cameraId) - return false; - - // 상태 필터 - if (filters.status && e.status !== filters.status) return false; - - // 역 필터 - if (filters.station && e.camera?.station_name !== filters.station) - return false; - - return true; - }); - }, [allEvents, filters]); - - // ── 페이지네이션 ──────────────────────────────────── - const paginatedEvents = useMemo(() => { - const start = (page - 1) * pageSize; - return filteredEvents.slice(start, start + pageSize); - }, [filteredEvents, page, pageSize]); - - // ── 드롭다운 옵션 (allEvents에서 추출) ────────────── - const cameraOptions = useMemo(() => { - const map = new Map(); - allEvents.forEach((e) => { - if (!map.has(e.camera_id)) { - // 카메라 조인 후라면 역이름+게이트 표시, 아니면 CAM-XX - const label = e.camera - ? `${e.camera.station_name} ${e.camera.location}` - : `CAM-${String(e.camera_id).padStart(2, "0")}`; - map.set(e.camera_id, label); - } - }); - return Array.from(map.entries()) - .sort((a, b) => a[0] - b[0]) - .map(([id, label]) => ({ id, label })); - }, [allEvents]); - - const stationOptions = useMemo(() => { - const stations = new Set(); - allEvents.forEach((e) => { - if (e.camera?.station_name) stations.add(e.camera.station_name); - }); - return Array.from(stations).sort(); - }, [allEvents]); - - // ── 모달 핸들러 ──────────────────────────────────── - const handleOpenFalseAlarm = (event: EventResponse) => { - setSelectedEvent(null); - setFalseAlarmEvent(event); - }; return (
@@ -202,55 +32,35 @@ export default function EventsPage() {
- {/* 필터 바 */} - - {/* 테이블 */} - - {/* 페이지네이션 */} {!loading && ( { - setPageSize(size); - setPage(1); - }} + onPageSizeChange={setPageSize} /> )}
- {/* 이벤트 상세 모달 (재사용) */} {selectedEvent && ( setSelectedEvent(null)} - onFalseAlarm={handleOpenFalseAlarm} - onConfirmed={fetchAll} - /> - )} - - {/* 오탐신고 모달 (재사용) */} - {falseAlarmEvent && ( - setFalseAlarmEvent(null)} - onSubmitted={fetchAll} + onConfirmed={refetch} /> )}
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index e19c236..ea17b95 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,144 +1,24 @@ -/** - * @file LoginPage.tsx - * @description 관리자 로그인 페이지 - * - * ## 기능 - * - POST /api/auth/login (email + password) - * - "비밀번호 기억하기" 체크 시 localStorage, 미체크 시 sessionStorage에 토큰 저장 - * - 로그인 성공 시 /dashboard 이동 - * - * ## 주의사항 - * - 회원가입·비밀번호 찾기 버튼은 UI만 존재, 기능 미구현 - */ - import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import api from "@/api/axios"; -import axios from "axios"; - -export default function LoginPage() { - const navigate = useNavigate(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [remember, setRemember] = useState(true); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(""); - setLoading(true); +import AuthLayout from "@/components/auth/AuthLayout"; +import LoginForm from "@/components/auth/LoginForm"; +import RegisterForm from "@/components/auth/RegisterForm"; +import FindPwForm from "@/components/auth/FindPwForm"; - try { - const res = await api.post("/api/auth/login", { email, password }); - const { access_token } = res.data; +type Step = "login" | "register" | "findpw"; - if (remember) { - localStorage.setItem("token", access_token); - } else { - sessionStorage.setItem("token", access_token); - } - - navigate("/dashboard"); - } catch (err) { - if (axios.isAxiosError(err) && err.response?.status === 401) { - setError("이메일 또는 비밀번호가 올바르지 않습니다."); - } else { - setError("로그인 중 오류가 발생했습니다."); - } - } finally { - setLoading(false); - } - }; +export default function LoginPage() { + const [step, setStep] = useState("login"); return ( -
- {/* 배경 블롭 */} -
-
-
- -
-

- 로그인 -

- -
- {/* 이메일 */} -
- - setEmail(e.target.value)} - required - className="w-full px-4 py-3 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#4B73F7]" - /> -
- - {/* 비밀번호 */} -
-
- - -
- setPassword(e.target.value)} - required - className="w-full px-4 py-3 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-[#4B73F7]" - /> -
- - {/* 비밀번호 기억하기 */} -
- setRemember(e.target.checked)} - className="w-4 h-4 accent-[#4B73F7]" - /> - -
- - {/* 에러 메시지 */} - {error &&

{error}

} - - {/* 로그인 버튼 */} - - - {/* 가입하기 */} -

- 아직 가입을 안하셨나요?{" "} - -

-
-
-
+ + {step === "login" && ( + setStep("register")} + onFindPw={() => setStep("findpw")} + /> + )} + {step === "register" && setStep("login")} />} + {step === "findpw" && setStep("login")} />} + ); } diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 68ab8f8..53b675b 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1,22 +1,38 @@ -/** - * @file pages/SettingsPage.tsx - * @description 설정 페이지 - * - * ## TODO - * - [ ] 설정 기능 구현 (사용자 관리, 알림 설정, 카메라 관리 등) - */ - +import { useState } from "react"; import Sidebar from "@/components/layout/Sidebar"; import Header from "@/components/layout/Header"; +import ProfileTab from "@/components/settings/ProfileTab"; +import CamerasTab from "@/components/settings/CamerasTab"; + +type Tab = "profile" | "cameras"; export default function SettingsPage() { + const [tab, setTab] = useState("profile"); + return (
-
+
-
-

설정 준비 중...

+
+
+ {(["profile", "cameras"] as Tab[]).map((t) => ( + + ))} +
+ + {tab === "profile" && } + {tab === "cameras" && }
diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index 1a08b5c..a61f4bd 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -1,23 +1,204 @@ -/** - * @file pages/StatsPage.tsx - * @description 통계 리포트 페이지 - * - * ## TODO - * - [ ] ECharts 통계 시각화 구현 - * - [ ] GET /api/events/stats 데이터 연동 - */ - +import { useState, useEffect, useMemo } from "react"; import Sidebar from "@/components/layout/Sidebar"; import Header from "@/components/layout/Header"; +import StatSummaryCards from "@/components/stats/StatSummaryCards"; +import DailyTrendChart from "@/components/stats/DailyTrendChart"; +import EventTypeChart from "@/components/stats/EventTypeChart"; +import HourlyDistributionChart from "@/components/stats/HourlyDistributionChart"; +import FalseAlarmTable from "@/components/stats/FalseAlarmTable"; +import CameraRankingTable from "@/components/stats/CameraRankingTable"; +import { getEvents, getEventStats, getEventStatsByCamera } from "@/api/events"; +import { getCameras } from "@/api/cameras"; +import { + buildDailyData, + buildHourlyData, + buildTypeData, + buildFalseAlarmData, + DAILY_DAYS, +} from "@/lib/stats"; +import type { EventResponse, EventStats, CameraEventStats, CameraResponse } from "@/types"; export default function StatsPage() { + const [events, setEvents] = useState([]); + const [stats, setStats] = useState(null); + const [cameraStats, setCameraStats] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetch = async () => { + setLoading(true); + const [evResult, statsResult, camStatsResult, camResult] = await Promise.allSettled([ + getEvents({ limit: 1000 }), + getEventStats(), + getEventStatsByCamera(), + getCameras(), + ]); + if (evResult.status === "fulfilled") setEvents(evResult.value); + if (statsResult.status === "fulfilled") setStats(statsResult.value); + if (camStatsResult.status === "fulfilled") { + const cameraMap = new Map( + camResult.status === "fulfilled" + ? camResult.value.map((c) => [c.id, c]) + : [] + ); + setCameraStats( + camStatsResult.value + .map((s) => ({ + ...s, + station_name: cameraMap.get(s.camera_id)?.station_name ?? `CAM-${s.camera_id}`, + location: cameraMap.get(s.camera_id)?.location ?? "", + })) + .sort((a, b) => b.count - a.count) + ); + } + setLoading(false); + }; + fetch(); + }, []); + + const dailyData = useMemo(() => buildDailyData(events), [events]); + const hourlyData = useMemo(() => buildHourlyData(events), [events]); + const typeData = useMemo(() => buildTypeData(events), [events]); + const falseAlarmData = useMemo(() => buildFalseAlarmData(events), [events]); + + const avgDaily = useMemo(() => { + if (events.length === 0) return null; + const timestamps = events.map((e) => new Date(e.timestamp).getTime()); + const minDay = new Date(Math.min(...timestamps)); + const maxDay = new Date(Math.max(...timestamps)); + minDay.setHours(0, 0, 0, 0); + maxDay.setHours(0, 0, 0, 0); + const totalDays = Math.max( + 1, + Math.round((maxDay.getTime() - minDay.getTime()) / 86400000) + 1, + ); + return events.length / totalDays; + }, [events]); + + const avgProcessMin = useMemo(() => { + const handled = events.filter( + (e) => e.status === "confirmed" && e.handled_at && e.timestamp, + ); + if (handled.length === 0) return null; + const totalMs = handled.reduce((sum, e) => { + return ( + sum + + (new Date(e.handled_at!).getTime() - new Date(e.timestamp).getTime()) + ); + }, 0); + return totalMs / handled.length / 60000; // ms → 분 + }, [events]); + return (
-
+
-
-

통계 리포트 준비 중...

+
+ {/* 데이터 범위 안내 */} +

+ ※ 통계는 최근 수집된 최대 1,000건의 이벤트를 기준으로 산출됩니다. +

+ + {/* 요약 카드 */} + + + {/* 일별 추이 + 감지 유형 */} +
+
+
+ + 일별 발생 추이 + + + 최근 {DAILY_DAYS}일 + +
+ {loading ? ( +
+ ) : ( + + )} +
+ +
+
+ + 감지 유형 비율 + + 전체 기간 +
+ {loading ? ( +
+ ) : ( + + )} +
+
+ + {/* 시간대별 분포 + 오탐신고 현황 */} +
+
+
+ + 시간대별 발생 분포 + + 0시 — 23시 +
+ {loading ? ( +
+ ) : ( + + )} +
+ +
+
+ + 오탐신고 현황 + +
+ {loading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ ) : ( + + )} +
+
+ + {/* 역별/게이트별 발생 순위 */} +
+
+ + 역별 / 게이트별 발생 순위 + +
+ {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) : ( + + )} +
diff --git a/frontend/src/router/PrivateRoute.tsx b/frontend/src/router/PrivateRoute.tsx new file mode 100644 index 0000000..68b1bd3 --- /dev/null +++ b/frontend/src/router/PrivateRoute.tsx @@ -0,0 +1,8 @@ +import { Navigate } from "react-router-dom"; +import type { ReactNode } from "react"; + +export default function PrivateRoute({ children }: { children: ReactNode }) { + const token = localStorage.getItem("token") || sessionStorage.getItem("token"); + if (!token) return ; + return <>{children}; +} diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 5f2ae50..868bfdf 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -1,24 +1,10 @@ -/** - * @file router/index.tsx - * @description 클라이언트 라우터 설정 - * - * ## 라우트 목록 - * - / → LoginPage (공개) - * - /dashboard → DashboardPage - * - /stats → StatsPage (placeholder) - * - /events → EventsPage - * - /settings → SettingsPage (placeholder) - * - * ## 주의사항 - * - Auth route guard 미구현 — 토큰 없이도 모든 라우트 접근 가능 - */ - import { createBrowserRouter } from "react-router-dom"; import LoginPage from "../pages/LoginPage"; import DashboardPage from "../pages/DashboardPage"; import StatsPage from "../pages/StatsPage"; import EventsPage from "../pages/EventsPage"; import SettingsPage from "../pages/SettingsPage"; +import PrivateRoute from "./PrivateRoute"; export const router = createBrowserRouter([ { @@ -27,18 +13,18 @@ export const router = createBrowserRouter([ }, { path: "/dashboard", - element: , + element: , }, { path: "/stats", - element: , + element: , }, { path: "/events", - element: , + element: , }, { path: "/settings", - element: , + element: , }, ]); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8728021..ff05c98 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,15 +1,3 @@ -/** - * @file types/index.ts - * @description 앱 전체 공유 TypeScript 인터페이스 정의 - * - * ## 주의사항 - * - description, appearance_tags 는 백엔드 응답에 포함 시 자동 활용, 없으면 fallback 처리 - * - camera, event 필드는 백엔드 embed 여부에 따라 활용 - * - * ## 백엔드 확정 후 수정 필요 - * - description, appearance_tags 필드 포함 여부 확인 필요 (현재 optional로 선언, 없으면 fallback 처리) - */ - export interface CameraResponse { id: number; location: string; @@ -31,8 +19,11 @@ export interface EventResponse { description?: string; // AI 분석 설명 / 감지 유형 텍스트 (백엔드 응답에 포함 시 활용) appearance_tags?: string[]; // 인상착의 태그 (백엔드 응답에 포함 시 활용) camera?: CameraResponse; // 프론트에서 카메라 API 조인 후 주입 (백엔드 응답에는 camera_id만 있음) - event_type?: string; // 감지유형 (예: '태그 없이 통행', '테일게이팅') — 백엔드 확정 필요 - assigned_to?: string; // 담당자 — 백엔드 확정 필요 + event_type?: string; // 감지유형 영문값 (tailgating | jump | crawling | unpaid | emergencydoor | normal | unknown) + assigned_to?: string; // 담당자 + reason?: string | null; // 오탐신고 사유 (status=false_alarm 일 때만 값, 나머지 null) + handled_by?: number | null; // 처리한 사용자 ID + handled_at?: string | null; // 처리 완료 시각 ISO 8601 (평균 처리 시간 계산용) } // 백엔드 GET /api/events/stats 응답 필드명과 일치