From b741caf74cc0f7860219d044c14e02acc7b132bc Mon Sep 17 00:00:00 2001 From: jihyun808 Date: Mon, 11 May 2026 13:20:41 +0900 Subject: [PATCH 01/31] =?UTF-8?q?feat:=20ECharts=20=ED=86=B5=EA=B3=84=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/.expo/README.md | 15 ++ frontend/.expo/settings.json | 8 + frontend/.gitignore | 23 +- frontend/API.md | 221 ++++++++++++++++++ frontend/CLAUDE.md | 136 ++++++----- frontend/package-lock.json | 53 +++++ frontend/package.json | 2 + .../src/components/dashboard/CameraStats.tsx | 106 +++------ .../components/stats/CameraRankingTable.tsx | 56 +++++ .../src/components/stats/DailyTrendChart.tsx | 53 +++++ .../src/components/stats/EventTypeChart.tsx | 58 +++++ .../src/components/stats/FalseAlarmTable.tsx | 32 +++ .../stats/HourlyDistributionChart.tsx | 57 +++++ .../src/components/stats/StatSummaryCards.tsx | 85 +++++++ frontend/src/pages/DashboardPage.tsx | 11 +- frontend/src/pages/StatsPage.tsx | 194 ++++++++++++++- frontend/src/types/index.ts | 3 + 17 files changed, 966 insertions(+), 147 deletions(-) create mode 100644 frontend/.expo/README.md create mode 100644 frontend/.expo/settings.json create mode 100644 frontend/API.md create mode 100644 frontend/src/components/stats/CameraRankingTable.tsx create mode 100644 frontend/src/components/stats/DailyTrendChart.tsx create mode 100644 frontend/src/components/stats/EventTypeChart.tsx create mode 100644 frontend/src/components/stats/FalseAlarmTable.tsx create mode 100644 frontend/src/components/stats/HourlyDistributionChart.tsx create mode 100644 frontend/src/components/stats/StatSummaryCards.tsx 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/API.md b/frontend/API.md new file mode 100644 index 0000000..9b8a5bd --- /dev/null +++ b/frontend/API.md @@ -0,0 +1,221 @@ +# GateGuard API 연동 현황 + +백엔드 Swagger 기준 구현된 API와 프론트엔드 연동 상태 정리. + +--- + +## 목차 + +- [공통 설정](#공통-설정) +- [auth](#auth) +- [cameras](#cameras) +- [events](#events) +- [notifications](#notifications) +- [통계 화면 구현 가능성](#통계-화면-구현-가능성) +- [미사용 API 활용 방안](#미사용-api-활용-방안) + +--- + +## 공통 설정 + +**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` | ❌ 미사용 | — | +| POST | `/api/auth/login` | ✅ | `src/pages/LoginPage.tsx` | +| POST | `/api/auth/find-pw` | ❌ 미사용 | — | + +### POST /api/auth/login + +```ts +// Request +{ + email: string; + password: string; +} + +// Response +{ + access_token: string; +} +``` + +로그인 성공 시 토큰을 localStorage(remember me) 또는 sessionStorage(세션)에 저장 후 `/dashboard` 로 이동. + +--- + +## cameras + +| Method | Endpoint | 프론트 연동 | 파일 | +| ------ | --------------------------------- | ----------------- | -------------------- | +| GET | `/api/cameras/` | ✅ `getCameras()` | `src/api/cameras.ts` | +| POST | `/api/cameras/` | ❌ 미사용 | — | +| PATCH | `/api/cameras/{camera_id}/toggle` | ❌ 미사용 | — | + +### GET /api/cameras/ + +```ts +// Response +interface CameraResponse { + id: number; + location: string; // 게이트 번호 (예: "1번 게이트") + station_name: string; // 역 이름 (예: "수원역") + is_active: boolean; +} +``` + +이벤트 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; + status?: "pending" | "confirmed" | "false_alarm"; + camera_id?: number; +} + +// Response +interface EventResponse { + id: number; + camera_id: number; + timestamp: string; // ISO 8601 + clip_url: string | null; // S3 영상 URL + track_id: number | null; + confidence: number | null; // 0.0 ~ 1.0 + status: "pending" | "confirmed" | "false_alarm"; + description?: string; // AI 감지 설명 — 백엔드 포함 여부 미확정 + appearance_tags?: string[]; // 인상착의 태그 — 백엔드 포함 여부 미확정 + event_type?: string; // 감지 유형 — 백엔드 포함 여부 미확정 + assigned_to?: string; // 담당자 — 백엔드 포함 여부 미확정 +} +``` + +> **미확정 필드**: `description`, `appearance_tags`, `event_type`, `assigned_to` 는 실제 응답 포함 여부 백엔드 확인 필요. 현재 프론트는 optional로 선언 후 없으면 fallback 처리. + +### 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 +interface CameraEventStats { + camera_id: number; + station_name: string; + location: string; + count: number; +} +[]; +``` + +### POST /api/events/{event_id}/false-alarm + +```ts +// Request +{ reason: string; memo?: string } + +// 사전 정의 reason 값 (FalseAlarmModal 기준) +// "기기 오작동" | "노인 무임혜택 미인식" | "장애인 혜택 미인식" | "기타" +``` + +> **미확정**: `reason`, `memo` 필드명 백엔드 확정 필요. + +### 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 = 미읽음 + event?: EventResponse; // 백엔드 embed 여부 미확정 +}[] +``` + +--- + +## 통계 화면 구현 가능성 + +| 섹션 | 가능 여부 | 방법 | 비고 | +| --------------------------------------- | --------- | ----------------------------------------------- | ------------------------------------------------------- | +| 총 발생 / 미확인 / 처리완료 / 오탐 카드 | ✅ | `GET /api/events/stats` | | +| 오탐율 계산 | ✅ | `false_alarm / today_total` 프론트 계산 | | +| 역별 / 게이트별 발생 순위 | ✅ | `GET /api/events/stats/by-camera` | | +| 시간대별 발생 분포 | ✅ 조건부 | `GET /api/events/` 대량 fetch 후 timestamp 집계 | 데이터 증가 시 성능 고려 필요 | +| 일별 발생 추이 | ✅ 조건부 | `GET /api/events/` 날짜별 집계 | 동일 | +| 감지 유형 비율 (파이 차트) | ⚠️ 미확정 | `event_type` 필드 집계 | 백엔드 `event_type` 응답 포함 여부 확인 필요 | +| 오탐신고 사유별 현황 | ⚠️ 미확정 | false_alarm 이벤트의 `reason` 집계 | `EventResponse`에 `reason` 필드 없음 — 백엔드 추가 필요 | +| 전일 / 전월 비교 수치 | ❌ | — | 기간 비교 파라미터 또는 별도 API 필요 | +| 평균 처리 시간 | ❌ | — | `resolved_at` 필드 없음 — 백엔드 추가 필요 | + +--- + +## 미사용 API 활용 방안 + +| Endpoint | 활용 가능 위치 | +| ---------------------------------- | ---------------------------------------- | +| `POST /api/auth/register` | 관리자 계정 생성 기능 (SettingsPage) | +| `POST /api/auth/find-pw` | 로그인 페이지 "비밀번호 찾기" 링크 | +| `POST /api/cameras/` | SettingsPage 카메라 등록 폼 | +| `PATCH /api/cameras/{id}/toggle` | SettingsPage 카메라 활성화/비활성화 토글 | +| `POST /api/notifications/read-all` | Header 알림 패널 "전체 읽음" 버튼 | diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 33cfdaa..0b6b245 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 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`. +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 @@ -23,12 +23,12 @@ No test runner is configured yet. - 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 +- **Route guard is NOT yet implemented** — 백엔드 켜진 상태에서는 401 인터셉터가 사실상 guard 역할. 백엔드 꺼진 상태에서는 토큰 없이 모든 라우트 접근 가능 ### Routing (`src/router/index.tsx`) - `/` → `LoginPage` (public) - `/dashboard` → `DashboardPage` ✅ 구현완료 -- `/stats` → `StatsPage` ⚠️ placeholder ("준비 중" 텍스트만) +- `/stats` → `StatsPage` ✅ 구현완료 - `/events` → `EventsPage` ✅ 구현완료 - `/settings` → `SettingsPage` ⚠️ placeholder ("준비 중" 텍스트만) @@ -43,10 +43,20 @@ Dashboard pages share a consistent layout: `` (left, fixed w-64) + `< - Passes data down as props; children call `refresh()` after mutations - WebSocket `NEW_EVENT` → 카메라 정보 조인 후 prepend to `events[]` (최대 10건) + optimistic stats increment +### Data Flow (StatsPage) +- 3개 API 병렬 fetch: `GET /api/events/?limit=1000`, `GET /api/events/stats`, `GET /api/events/stats/by-camera` +- 이벤트 목록을 `useMemo`로 가공: 날짜별/시간대별/유형별/오탐사유별 집계 +- 평균 처리 시간: `confirmed` 이벤트의 `handled_at - timestamp` 평균 (분 단위) + +### Data Flow (EventsPage) +- `GET /api/events/?limit=500` 한 번에 fetch → 클라이언트 필터링/페이지네이션 +- **한계**: 최신 500건만 조회 가능. 데모 단계이므로 유지. 실데이터 붙는 시점에 서버사이드 페이지네이션으로 전환 예정 (백엔드에 `skip`/`limit` + `total` 응답 추가 필요) + ### 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/stats/` — `StatSummaryCards`, `DailyTrendChart`, `EventTypeChart`, `HourlyDistributionChart`, `FalseAlarmTable`, `CameraRankingTable` - `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` 패턴) @@ -57,6 +67,7 @@ Dashboard pages share a consistent layout: `` (left, fixed w-64) + `< - 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`) @@ -66,20 +77,54 @@ Dashboard pages share a consistent layout: `` (left, fixed w-64) + `< - 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 전체 목록 (Swagger 확인 완료) + +**auth** +- `POST /api/auth/login` — JWT 로그인 ✅ 프론트 연동 +- `POST /api/auth/register` — 회원가입 (프론트 미사용) +- `POST /api/auth/find-pw` — 비밀번호 찾기 (프론트 미사용) + +**cameras** +- `GET /api/cameras/` — 카메라 목록 ✅ 프론트 연동 +- `POST /api/cameras/` — 카메라 등록 (프론트 미사용) +- `PATCH /api/cameras/{camera_id}/toggle` — 카메라 활성화/비활성화 (프론트 미사용) + +**events** +- `GET /api/events/` — 이벤트 목록 ✅ 프론트 연동 +- `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` — 오탐신고 ✅ 프론트 연동 + +**notifications** +- `GET /api/notifications/` — 알림 목록 ✅ 프론트 연동 (인증 불필요) +- `PATCH /api/notifications/{notification_id}/read` — 읽음 처리 ✅ 프론트 연동 +- `POST /api/notifications/read-all` — 전체 읽음 처리 (프론트 미사용) + +### GET /api/events/ 실제 응답 필드 (Swagger + curl 확인) +```ts +{ + id, camera_id, timestamp, clip_url, track_id, + confidence, status, handled_by, handled_at +} +``` +- `event_type`: DB에 저장되나 API 응답에 미포함 → 백엔드 추가 요청 필요 +- `reason`: DB/API 모두 없음 → 백엔드 추가 요청 필요 (false_alarm 이벤트에만 포함) -### 구현된 API (Swagger 확인 완료) -- `GET /api/cameras/` — 카메라 목록 -- `GET /api/events/` — 이벤트 목록 -- `PATCH /api/events/{event_id}/status` — 이벤트 상태 변경 (처리완료) ✅ -- `GET /api/notifications/` — 알림 목록 (인증 불필요) -- `PATCH /api/notifications/{notification_id}/read` — 알림 읽음 처리 +### GET /api/events/stats 실제 응답 필드 (curl 확인) +```ts +{ today_total, pending, confirmed, false_alarm } +``` -### 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` → 오탐신고 (실패 시 신고 반영 안 됨) +### EventStatus 값 +백엔드 확정 상태값 3종: `pending` (미처리) | `confirmed` (처리완료) | `false_alarm` (오탐) +- `pending` → 상세보기·오탐신고 버튼 활성화, 빨간 dot 표시 +- `confirmed` / `false_alarm` → 기록보기 버튼만 표시 ## 구현 현황 @@ -88,45 +133,30 @@ Dashboard pages share a consistent layout: `` (left, fixed w-64) + `< - axios 공통 인스턴스 (토큰 자동 주입, 401 리다이렉트) - WebSocket 훅 (`useWebSocket`) — 자동 재연결 - 공통 TypeScript 타입 (`src/types/index.ts`) -- API 모듈 (`events.ts`, `notifications.ts`) +- API 모듈 (`events.ts`, `cameras.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` 라우트 등록) +- EventsPage (전체 발생내역 — 필터/클라이언트 페이지네이션, WebSocket 실시간 삽입) +- StatsPage (ECharts 통계 시각화) + - StatSummaryCards — 총발생/일평균/오탐율/평균처리시간 4개 카드 + - DailyTrendChart — 최근 12일 라인 차트 + - EventTypeChart — 감지 유형 비율 도넛 차트 (event_type 필드 백엔드 추가 시 실데이터) + - HourlyDistributionChart — 시간대별 발생 분포 가로 바 차트 + - FalseAlarmTable — 오탐 사유별 건수 (reason 필드 백엔드 추가 시 실데이터) + - CameraRankingTable — 역별/게이트별 발생 순위 - 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) 기획 확정 필요 +- API 문서 (`API.md`) + +### ⚠️ 미구현 +1. **Auth route guard** — 토큰 없으면 `/`로 리다이렉트 (백엔드 켜진 상태에서는 401로 사실상 동작) +2. **SettingsPage** — 설정 기능 (현재 placeholder) +3. **역무원 파견** — `EventDetailModal` 버튼 클릭 시 alert()만 뜸, API 없음 +4. **FalseAlarmList 항목 클릭** — 클릭 시 상세 모달 미연결 (`NotificationResponse`에 `event` embed 백엔드 확인 필요) +5. **회원가입 / 비밀번호 찾기** — `LoginPage` 버튼 UI만 존재 +6. **지도보기** — `CameraStats` 버튼 핸들러 없음 +7. **Header 아바타** — "관" 하드코딩, 로그인 유저 정보 연동 필요 + +### 백엔드에 추가 요청 필요한 항목 +- `GET /api/events/` 응답에 `event_type: string` 추가 → 감지 유형 파이 차트 실데이터 +- `GET /api/events/` 응답에 `reason: string | null` 추가 → 오탐신고 현황 실데이터 (`false_alarm`일 때만 값, 나머지 `null`) +- 서버사이드 페이지네이션: `skip` 파라미터 + 응답에 `total` 포함 → EventsPage 500건 제한 해소 (데모 이후) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 99e3110..d8a1112 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,8 @@ "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", @@ -4573,6 +4575,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", @@ -7889,6 +7921,12 @@ "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/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8780,6 +8818,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..18b2a6c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,8 @@ "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", diff --git a/frontend/src/components/dashboard/CameraStats.tsx b/frontend/src/components/dashboard/CameraStats.tsx index 01e0bb3..e4773b9 100644 --- a/frontend/src/components/dashboard/CameraStats.tsx +++ b/frontend/src/components/dashboard/CameraStats.tsx @@ -1,24 +1,3 @@ -/** - * @file components/dashboard/CameraStats.tsx - * @description 구간별 알림현황 테이블 컴포넌트 - * - * ## 기능 - * - CameraEventStats[] 를 테이블로 렌더링 - * - count ≥ 5 → 고위험(빨강) / ≥ 2 → 주의(노랑) / < 2 → 정상(회색) - * - loading 시 스켈레톤 / 빈 배열 시 안내 문구 표시 - * - * ## 주의사항 - * - GET /api/events/stats/by-camera 는 백엔드 M2 구현 예정, 전까지 빈 테이블 표시 - * - * ## TODO - * - [ ] 지도보기 버튼 기능 구현 - * - [ ] 행 클릭 시 해당 카메라 이벤트 필터링 이동 - * - * ## 협의 - * - 색상 분기 임계값(5, 2) 기획 확정 후 수정 필요 - */ - -import { MapPin } from "lucide-react"; import type { CameraEventStats } from "@/types"; interface CameraStatsProps { @@ -26,71 +5,46 @@ interface CameraStatsProps { loading?: boolean; } -function getDotColor(count: number): string { - if (count >= 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/stats/CameraRankingTable.tsx b/frontend/src/components/stats/CameraRankingTable.tsx new file mode 100644 index 0000000..540c116 --- /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..1b788c8 --- /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..53d7c99 --- /dev/null +++ b/frontend/src/components/stats/StatSummaryCards.tsx @@ -0,0 +1,85 @@ +import type { EventStats } from "@/types"; + +interface Props { + stats: EventStats | null; + avgDaily: number | null; + avgProcessMin: number | null; + loading: boolean; +} + +const cards = [ + { + key: "today_total" as const, + label: "총 발생 건수", + color: "text-red-400", + bg: "bg-red-50", + suffix: "건", + }, + { + key: "avg_daily" as const, + label: "일평균 발생", + color: "text-orange-400", + bg: "bg-orange-50", + suffix: "건", + }, + { + key: "false_alarm_rate" as const, + label: "오탐율", + color: "text-green-500", + bg: "bg-green-50", + suffix: "%", + }, + { + key: "avg_process" as const, + label: "평균 처리 시간", + color: "text-gray-400", + bg: "bg-gray-50", + suffix: "분", + }, +]; + +export default function StatSummaryCards({ stats, avgDaily, avgProcessMin, loading }: Props) { + const falseAlarmRate = + stats && stats.today_total > 0 + ? ((stats.false_alarm / stats.today_total) * 100).toFixed(1) + : "0.0"; + + const getValue = (key: (typeof cards)[number]["key"]) => { + if (!stats) return "—"; + if (key === "today_total") return 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/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 0420677..b4b47d6 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -119,13 +119,14 @@ export default function DashboardPage() { // 통계 카드 낙관적 업데이트 setStats((prev) => prev - ? { - ...prev, - today_total: prev.today_total + 1, - pending: prev.pending + 1, - } + ? { ...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, + ), + ); } }); diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index 1a08b5c..0e1f9b9 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -1,23 +1,193 @@ -/** - * @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 type { EventResponse, EventStats, CameraEventStats } from "@/types"; + +const DAILY_DAYS = 12; + +function buildDailyData(events: EventResponse[]) { + 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) => { + const d = new Date(e.timestamp); + const key = `${d.getMonth() + 1}/${d.getDate()}`; + if (key in result) result[key]++; + }); + return result; +} + +function buildHourlyData(events: EventResponse[]) { + 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) => { + 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; +} + +function buildTypeData(events: EventResponse[]) { + const result: Record = {}; + events.forEach((e) => { + const type = e.event_type ?? "기타"; + result[type] = (result[type] ?? 0) + 1; + }); + return result; +} + +function buildFalseAlarmData(events: EventResponse[]) { + 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 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] = await Promise.allSettled([ + getEvents({ limit: 1000 }), + getEventStats(), + getEventStatsByCamera(), + ]); + if (evResult.status === "fulfilled") setEvents(evResult.value); + if (statsResult.status === "fulfilled") setStats(statsResult.value); + if (camStatsResult.status === "fulfilled") setCameraStats(camStatsResult.value); + 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(() => { + const days = Object.values(dailyData); + const total = days.reduce((s, v) => s + v, 0); + const activeDays = days.filter((v) => v > 0).length; + return activeDays > 0 ? total / activeDays : null; + }, [dailyData]); + + 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 (
-
+
-
-

통계 리포트 준비 중...

+
+ {/* 요약 카드 */} + + + {/* 일별 추이 + 감지 유형 */} +
+
+
+ 일별 발생 추이 + 최근 {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/types/index.ts b/frontend/src/types/index.ts index 8728021..c417637 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -33,6 +33,9 @@ export interface EventResponse { camera?: CameraResponse; // 프론트에서 카메라 API 조인 후 주입 (백엔드 응답에는 camera_id만 있음) event_type?: string; // 감지유형 (예: '태그 없이 통행', '테일게이팅') — 백엔드 확정 필요 assigned_to?: string; // 담당자 — 백엔드 확정 필요 + reason?: string; // 오탐신고 사유 (status=false_alarm 인 경우) + handled_by?: number | null; // 처리한 사용자 ID + handled_at?: string | null; // 처리 완료 시각 ISO 8601 (평균 처리 시간 계산용) } // 백엔드 GET /api/events/stats 응답 필드명과 일치 From 148f6f80267740346e4a7b62f584e48612e50c90 Mon Sep 17 00:00:00 2001 From: jihyun808 Date: Mon, 11 May 2026 13:40:51 +0900 Subject: [PATCH 02/31] =?UTF-8?q?chore:=20=EC=83=81=EC=84=B8=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=9D=BC=EB=B6=80=20=EC=98=81=EC=97=AD=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/dashboard/EventDetailModal.tsx | 358 +++++------------- frontend/src/pages/DashboardPage.tsx | 3 +- frontend/src/pages/EventsPage.tsx | 3 +- 3 files changed, 90 insertions(+), 274 deletions(-) diff --git a/frontend/src/components/dashboard/EventDetailModal.tsx b/frontend/src/components/dashboard/EventDetailModal.tsx index 0ab18aa..2928ab7 100644 --- a/frontend/src/components/dashboard/EventDetailModal.tsx +++ b/frontend/src/components/dashboard/EventDetailModal.tsx @@ -1,41 +1,15 @@ -/** - * @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 { X, Clock, MapPin, Zap, Video } from "lucide-react"; import type { EventResponse } from "@/types"; import { updateEventStatus } from "@/api/events"; 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()] @@ -43,114 +17,32 @@ function formatHMS(timestamp: string): string { .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; -} { - if (event.status === "confirmed") - 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" }; +function getSeverityBadge(event: EventResponse): { label: string; cls: string } { + if (event.status === "confirmed") 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 ( - - ); -} - -// ── 메인 모달 ───────────────────────────────────────── - export default function EventDetailModal({ - events, - initialEvent, + event, onClose, onFalseAlarm, onConfirmed, }: EventDetailModalProps) { - const [selected, setSelected] = useState(initialEvent); const [confirming, setConfirming] = useState(false); - 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"; + const badge = getSeverityBadge(event); + const station = event.camera?.station_name ?? ""; + const gate = event.camera?.location ?? ""; const locationText = - station && gate - ? `${station} ${gate}` - : station || `카메라 #${selected.camera_id}`; + station && gate ? `${station} ${gate}` : station || `카메라 #${event.camera_id}`; const handleConfirm = async () => { setConfirming(true); try { - await updateEventStatus(selected.id, "confirmed"); + await updateEventStatus(event.id, "confirmed"); onConfirmed(); onClose(); } catch { @@ -161,7 +53,6 @@ export default function EventDetailModal({ }; const handleDispatch = () => { - // TODO: 역무원 파견 API 연동 (백엔드 스펙 미정) alert("역무원 파견 기능은 준비 중입니다."); }; @@ -171,178 +62,105 @@ export default function EventDetailModal({ onClick={onClose} >
e.stopPropagation()} > - {/* ── 좌측: 실시간 알림 목록 (md 미만 숨김) ── */} -
-
-

실시간 알림

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

- 이벤트 없음 -

- ) : ( - events.map((ev) => ( - setSelected(ev)} - /> - )) - )} -
+ {/* 영상 영역 */} +
+ {event.clip_url ? ( +
- {/* ── 우측: 상세 정보 ── */} -
+ {/* 정보 패널 */} +
{/* 헤더 */} -
+
-

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

- + + Event #{event.id} + + {badge.label}
- {/* 콘텐츠: 이미지 + 정보 */} -
- {/* 이미지/영상 */} -
- {selected.clip_url ? ( -
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index b4b47d6..fc89b65 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -182,8 +182,7 @@ export default function DashboardPage() { {/* 이벤트 상세 모달 */} {selectedEvent && ( setSelectedEvent(null)} onFalseAlarm={handleOpenFalseAlarm} onConfirmed={refresh} diff --git a/frontend/src/pages/EventsPage.tsx b/frontend/src/pages/EventsPage.tsx index a09e7c9..eea2580 100644 --- a/frontend/src/pages/EventsPage.tsx +++ b/frontend/src/pages/EventsPage.tsx @@ -237,8 +237,7 @@ export default function EventsPage() { {/* 이벤트 상세 모달 (재사용) */} {selectedEvent && ( setSelectedEvent(null)} onFalseAlarm={handleOpenFalseAlarm} onConfirmed={fetchAll} From 467a9cba764efde5d0e11a949f4f45d27904d7a3 Mon Sep 17 00:00:00 2001 From: jihyun808 Date: Mon, 11 May 2026 14:53:50 +0900 Subject: [PATCH 03/31] =?UTF-8?q?feat:=20=EC=83=81=EC=84=B8=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EB=AA=A8=EB=8B=AC=20=EB=B2=84=ED=8A=BC=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=EC=99=80=20=EC=B2=98=EB=A6=AC=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/CLAUDE.md | 10 +- .../components/dashboard/EventDetailModal.tsx | 243 +++++++++++------- .../components/dashboard/FalseAlarmModal.tsx | 14 +- frontend/src/pages/DashboardPage.tsx | 13 +- frontend/src/pages/EventsPage.tsx | 28 +- 5 files changed, 169 insertions(+), 139 deletions(-) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 0b6b245..bfaff2e 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -55,6 +55,8 @@ Dashboard pages share a consistent layout: `` (left, fixed w-64) + `< ### Component Organization - `src/components/layout/` — `Sidebar`, `Header` - `src/components/dashboard/` — `StatCards`, `StatCard`, `AlertList`, `AlertItem`, `CameraStats`, `FalseAlarmList`, `EventDetailModal`, `FalseAlarmModal` + - `EventDetailModal` — 단일 이벤트 상세. FalseAlarmModal을 내부에서 직접 렌더링. 처리완료/오탐신고 완료 시 버튼 → 완료 문구로 전환 (서버 `handled_at` 재조회) + - `FalseAlarmModal` — `onSubmitted(reason: string)` 콜백으로 reason 전달. AlertItem 경유 시 DashboardPage가 렌더링, EventDetailModal 경유 시 EventDetailModal이 내부 렌더링 - `src/components/events/` — `EventsFilter`, `EventsTable`, `EventsPagination` - `src/components/stats/` — `StatSummaryCards`, `DailyTrendChart`, `EventTypeChart`, `HourlyDistributionChart`, `FalseAlarmTable`, `CameraRankingTable` - `src/components/ui/` — shadcn/ui primitives (generated via `npx shadcn add `) @@ -124,7 +126,7 @@ Dashboard pages share a consistent layout: `` (left, fixed w-64) + `< ### EventStatus 값 백엔드 확정 상태값 3종: `pending` (미처리) | `confirmed` (처리완료) | `false_alarm` (오탐) - `pending` → 상세보기·오탐신고 버튼 활성화, 빨간 dot 표시 -- `confirmed` / `false_alarm` → 기록보기 버튼만 표시 +- `confirmed` / `false_alarm` → 버튼 없음, 완료 문구만 표시 ## 구현 현황 @@ -136,6 +138,10 @@ Dashboard pages share a consistent layout: `` (left, fixed w-64) + `< - 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 반환. EventDetailModal 내부 렌더링 (AlertItem 직접 접근 시 DashboardPage 렌더링) + - WebSocket NEW_EVENT 시 stats + cameraStats 낙관적 업데이트 - EventsPage (전체 발생내역 — 필터/클라이언트 페이지네이션, WebSocket 실시간 삽입) - StatsPage (ECharts 통계 시각화) - StatSummaryCards — 총발생/일평균/오탐율/평균처리시간 4개 카드 @@ -150,7 +156,7 @@ Dashboard pages share a consistent layout: `` (left, fixed w-64) + `< ### ⚠️ 미구현 1. **Auth route guard** — 토큰 없으면 `/`로 리다이렉트 (백엔드 켜진 상태에서는 401로 사실상 동작) 2. **SettingsPage** — 설정 기능 (현재 placeholder) -3. **역무원 파견** — `EventDetailModal` 버튼 클릭 시 alert()만 뜸, API 없음 +3. **역무원 파견** — confirm 후 버튼 비활성화까지만 구현. 백엔드 API 없어서 실제 파견 처리 불가. 모달 닫고 재열면 파견 상태 리셋됨 (로컬 state) 4. **FalseAlarmList 항목 클릭** — 클릭 시 상세 모달 미연결 (`NotificationResponse`에 `event` embed 백엔드 확인 필요) 5. **회원가입 / 비밀번호 찾기** — `LoginPage` 버튼 UI만 존재 6. **지도보기** — `CameraStats` 버튼 핸들러 없음 diff --git a/frontend/src/components/dashboard/EventDetailModal.tsx b/frontend/src/components/dashboard/EventDetailModal.tsx index 2928ab7..e6190f5 100644 --- a/frontend/src/components/dashboard/EventDetailModal.tsx +++ b/frontend/src/components/dashboard/EventDetailModal.tsx @@ -1,15 +1,26 @@ import { useState } from "react"; import { X, Clock, MapPin, Zap, Video } from "lucide-react"; import type { EventResponse } from "@/types"; -import { updateEventStatus } from "@/api/events"; +import { updateEventStatus, getEventById } from "@/api/events"; +import FalseAlarmModal from "./FalseAlarmModal"; interface EventDetailModalProps { event: EventResponse; onClose: () => void; - onFalseAlarm: (event: EventResponse) => void; onConfirmed: () => void; } +interface CompletedInfo { + type: "confirmed" | "false_alarm"; + at: string; + reason?: string; +} + +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()] @@ -27,24 +38,34 @@ function getSeverityBadge(event: EventResponse): { label: string; cls: string } export default function EventDetailModal({ event, onClose, - onFalseAlarm, onConfirmed, }: EventDetailModalProps) { + const [dispatched, setDispatched] = useState(false); const [confirming, setConfirming] = useState(false); + const [showFalseAlarm, setShowFalseAlarm] = useState(false); + const [completedInfo, setCompletedInfo] = useState(null); - const isActive = event.status === "pending"; + const isActive = event.status === "pending" && completedInfo === null; const badge = getSeverityBadge(event); const station = event.camera?.station_name ?? ""; const gate = event.camera?.location ?? ""; const locationText = station && gate ? `${station} ${gate}` : 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(event.id, "confirmed"); + const updated = await getEventById(event.id); + setCompletedInfo({ type: "confirmed", at: updated.handled_at ?? new Date().toISOString() }); onConfirmed(); - onClose(); } catch { alert("처리 중 오류가 발생했습니다."); } finally { @@ -52,117 +73,147 @@ export default function EventDetailModal({ } }; - const handleDispatch = () => { - 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} > - {/* 영상 영역 */} -
- {event.clip_url ? ( -
- - {/* 정보 패널 */} -
- {/* 헤더 */} -
-
- - Event #{event.id} - - - {badge.label} - -
- +
e.stopPropagation()} + > + {/* 영상 영역 */} +
+ {event.clip_url ? ( +
- {/* 상세 정보 */} -
-
- -
-

기록시각

-

{formatHMS(event.timestamp)}

+ {/* 정보 패널 */} +
+ {/* 헤더 */} +
+
+ + Event #{event.id} + + + {badge.label} +
+
-
- -
-

위치

-

{locationText}

+ {/* 상세 정보 */} +
+
+ +
+

기록시각

+

{formatHMS(event.timestamp)}

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

감지유형

-

{event.event_type}

+

위치

+

{locationText}

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

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

- )} -
+ {event.event_type && ( +
+ +
+

감지유형

+

{event.event_type}

+
+
+ )} - {/* 액션 버튼 */} - {isActive && ( -
- - - + {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}
- )} +
-
+ + {showFalseAlarm && ( + setShowFalseAlarm(false)} + onSubmitted={handleFalseAlarmSubmitted} + /> + )} + ); } diff --git a/frontend/src/components/dashboard/FalseAlarmModal.tsx b/frontend/src/components/dashboard/FalseAlarmModal.tsx index c6c2d85..6ea57f6 100644 --- a/frontend/src/components/dashboard/FalseAlarmModal.tsx +++ b/frontend/src/components/dashboard/FalseAlarmModal.tsx @@ -5,14 +5,8 @@ * ## 기능 * - 오탐 사유 4가지 라디오 선택 (기타 선택 시 직접 입력) * - POST /api/events/{id}/false-alarm { reason, memo? } 호출 후 닫기 - * - AlertItem 또는 EventDetailModal의 오탐신고 버튼으로 진입 - * - * ## 주의사항 - * - 오탐신고 완료 후 onSubmitted() 콜백으로 DashboardPage/EventsPage의 refresh() 호출 - * - POST /api/events/{id}/false-alarm 백엔드 M2 구현 예정 - * - * ## 백엔드 확정 후 수정 필요 - * - 요청 바디 필드명 ({ reason, memo? }) 확정 필요 + * - EventDetailModal 내부에서 렌더링 (DashboardPage의 AlertItem 경유 시 DashboardPage가 직접 렌더링) + * - 제출 완료 시 onSubmitted(reason) 콜백으로 reason 전달 */ import { useState } from "react"; @@ -23,7 +17,7 @@ import { reportFalseAlarm } from "@/api/events"; interface FalseAlarmModalProps { event: EventResponse; onClose: () => void; - onSubmitted: () => void; + onSubmitted: (reason: string) => void; } const REASONS = [ @@ -64,7 +58,7 @@ export default function FalseAlarmModal({ reason, memo: selectedReason === "기타" ? memo.trim() : undefined, }); - onSubmitted(); + onSubmitted(reason); onClose(); } catch { setError("오탐 신고 중 오류가 발생했습니다."); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index fc89b65..52e2d50 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -7,17 +7,17 @@ * - GET /api/cameras/ 카메라 목록 (위치 정보 조인용) * - GET /api/events/?limit=10 최신 이벤트 10건 * - GET /api/events/stats 통계 카드 데이터 - * - GET /api/events/stats/by-camera 구간별 알림현황 + * - GET /api/events/stats/by-camera 역별 알림현황 * - GET /api/notifications/?unread_only=false 오탐 신고 목록 * - cameraMapRef로 카메라 맵 관리: WebSocket 핸들러에서도 최신 맵을 참조하여 역이름/게이트 조인 - * - WebSocket NEW_EVENT 수신 시 카메라 정보 조인 후 목록 맨 앞 삽입, 최대 10건 유지, stats 낙관적 업데이트 + * - WebSocket NEW_EVENT 수신 시 카메라 정보 조인 후 목록 맨 앞 삽입, 최대 10건 유지 + * stats + cameraStats 낙관적 업데이트 (해당 camera_id count +1) * - 처리완료 / 오탐신고 완료 후 refresh()로 전체 상태 재동기화 + * - FalseAlarmModal은 AlertItem 경유 시 DashboardPage가 직접 렌더링 + * (EventDetailModal 경유 시는 EventDetailModal 내부에서 관리) * * ## 주의사항 - * - 백엔드 미구현 API (stats, stats/by-camera, false-alarm, status) 실패 시 null/빈 배열로 graceful fallback * - 401 응답은 axios interceptor가 자동으로 `/`로 리다이렉트 처리 - * - * ## 주의사항 (추가) * - AppContext를 통해 wsConnected, unconfirmedCount를 Sidebar/Header에 공유 */ @@ -184,7 +184,6 @@ export default function DashboardPage() { setSelectedEvent(null)} - onFalseAlarm={handleOpenFalseAlarm} onConfirmed={refresh} /> )} @@ -194,7 +193,7 @@ export default function DashboardPage() { setFalseAlarmEvent(null)} - onSubmitted={refresh} + onSubmitted={() => refresh()} /> )}
diff --git a/frontend/src/pages/EventsPage.tsx b/frontend/src/pages/EventsPage.tsx index eea2580..216140c 100644 --- a/frontend/src/pages/EventsPage.tsx +++ b/frontend/src/pages/EventsPage.tsx @@ -4,19 +4,15 @@ * * ## 기능 * - GET /api/cameras/ + GET /api/events/?limit=500 병렬 호출 후 카메라 정보 조인 - * - 클라이언트사이드 필터: 텍스트 검색(EV-번호/CAM-번호/역/게이트/인상착의/설명) / 기간 / 감지유형 / 카메라 / 상태 / 역 + * - 클라이언트사이드 필터: 텍스트 검색 / 기간 / 감지유형 / 카메라 / 상태 / 역 * - 클라이언트사이드 페이지네이션: 기본 8건, 선택 가능 (8 / 16 / 32) - * - EventDetailModal / FalseAlarmModal 재사용 (DashboardPage와 동일 컴포넌트) - * - EventDetailModal에는 allEvents 전달 (필터된 배열 아님) — 모달 내 이전/다음 탐색을 위해 + * - EventDetailModal 재사용 (FalseAlarmModal은 EventDetailModal 내부에서 관리) * - WebSocket NEW_EVENT 수신 시 카메라 정보 조인 후 allEvents 앞에 실시간 삽입 * - AppContext를 통해 wsConnected 상태 공유 * * ## 주의사항 * - 현재 클라이언트사이드 페이지네이션 (limit=500 fetch) - * → 이벤트 수 대규모 시 GET /api/events/?skip=N&limit=M 서버사이드로 전환 필요 - * - * ## TODO - * - [ ] 서버사이드 페이지네이션 전환 (백엔드 skip/limit 파라미터 지원 확인 후) + * → 데모 단계 유지. 실데이터 시 GET /api/events/?skip=N&limit=M 서버사이드로 전환 필요 */ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; @@ -29,7 +25,6 @@ import 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"; @@ -45,7 +40,6 @@ export default function EventsPage() { const cameraMapRef = useRef>(new Map()); const [pageSize, setPageSize] = useState(8); const [selectedEvent, setSelectedEvent] = useState(null); - const [falseAlarmEvent, setFalseAlarmEvent] = useState(null); const fetchAll = useCallback(async () => { setLoading(true); @@ -190,12 +184,6 @@ export default function EventsPage() { return Array.from(stations).sort(); }, [allEvents]); - // ── 모달 핸들러 ──────────────────────────────────── - const handleOpenFalseAlarm = (event: EventResponse) => { - setSelectedEvent(null); - setFalseAlarmEvent(event); - }; - return (
@@ -239,19 +227,11 @@ export default function EventsPage() { setSelectedEvent(null)} - onFalseAlarm={handleOpenFalseAlarm} onConfirmed={fetchAll} /> )} - {/* 오탐신고 모달 (재사용) */} - {falseAlarmEvent && ( - setFalseAlarmEvent(null)} - onSubmitted={fetchAll} - /> - )} +
); } From bb3c7e1cfd045d25697978e2ae0f8a5636597f00 Mon Sep 17 00:00:00 2001 From: jihyun808 Date: Mon, 11 May 2026 15:32:37 +0900 Subject: [PATCH 04/31] =?UTF-8?q?chore:=20event=5Ftype=20=ED=95=9C?= =?UTF-8?q?=EA=B8=80=20=EB=A7=A4=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/events/EventsFilter.tsx | 9 ++------ .../src/components/events/EventsTable.tsx | 5 +++-- frontend/src/constants/eventTypes.ts | 22 +++++++++++++++++++ frontend/src/pages/EventsPage.tsx | 4 +--- frontend/src/pages/StatsPage.tsx | 6 +++-- frontend/src/types/index.ts | 6 ++--- 6 files changed, 35 insertions(+), 17 deletions(-) create mode 100644 frontend/src/constants/eventTypes.ts diff --git a/frontend/src/components/events/EventsFilter.tsx b/frontend/src/components/events/EventsFilter.tsx index 5cc9b31..9dfc90f 100644 --- a/frontend/src/components/events/EventsFilter.tsx +++ b/frontend/src/components/events/EventsFilter.tsx @@ -54,13 +54,8 @@ const PERIOD_OPTIONS = [ { value: "month", label: "이번 달" }, ]; -const TYPE_OPTIONS = [ - { value: "", label: "전체 유형" }, - { value: "태그 없이 통행", label: "태그 없이 통행" }, - { value: "테일게이팅", label: "테일게이팅" }, - { value: "비상문 강제 진입", label: "비상문 강제 진입" }, - { value: "역방향 진입", label: "역방향 진입" }, -]; +import { EVENT_TYPE_OPTIONS } from "@/constants/eventTypes"; +const TYPE_OPTIONS = EVENT_TYPE_OPTIONS; const STATUS_OPTIONS = [ { value: "", label: "전체 상태" }, diff --git a/frontend/src/components/events/EventsTable.tsx b/frontend/src/components/events/EventsTable.tsx index 0480c74..2680484 100644 --- a/frontend/src/components/events/EventsTable.tsx +++ b/frontend/src/components/events/EventsTable.tsx @@ -24,6 +24,7 @@ import { Download } from "lucide-react"; import type { EventResponse } from "@/types"; +import { labelEventType } from "@/constants/eventTypes"; interface EventsTableProps { /** 현재 페이지에 표시할 이벤트 (페이지네이션 적용 후) */ @@ -78,7 +79,7 @@ function exportToCSV(events: EventResponse[]) { `EV-${String(e.id).padStart(4, "0")}`, formatTime(e.timestamp), [e.camera?.station_name, e.camera?.location].filter(Boolean).join(" "), - e.event_type ?? e.description ?? "", + e.event_type ? labelEventType(e.event_type) : "", getSeverity(e).label, (e.appearance_tags ?? []).join(" "), `CAM-${String(e.camera_id).padStart(2, "0")}`, @@ -169,7 +170,7 @@ export default function EventsTable({ `CAM-${String(event.camera_id).padStart(2, "0")}`; const gate = event.camera?.location ?? ""; const camLabel = `CAM-${String(event.camera_id).padStart(2, "0")}`; - const detectionType = event.event_type ?? event.description ?? "—"; + const detectionType = event.event_type ? labelEventType(event.event_type) : "—"; const appearance = event.appearance_tags && event.appearance_tags.length > 0 ? event.appearance_tags.join(" ") diff --git a/frontend/src/constants/eventTypes.ts b/frontend/src/constants/eventTypes.ts new file mode 100644 index 0000000..f3760e2 --- /dev/null +++ b/frontend/src/constants/eventTypes.ts @@ -0,0 +1,22 @@ +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: "태그 없이 통행" }, + { value: "emergencydoor", label: "비상문 진입" }, +]; + +export function labelEventType(raw: string): string { + return EVENT_TYPE_LABEL[raw] ?? raw; +} diff --git a/frontend/src/pages/EventsPage.tsx b/frontend/src/pages/EventsPage.tsx index 216140c..8606b53 100644 --- a/frontend/src/pages/EventsPage.tsx +++ b/frontend/src/pages/EventsPage.tsx @@ -132,10 +132,8 @@ export default function EventsPage() { } } - // 감지유형 필터 (event_type 우선, description fallback) if (filters.type) { - const eventType = e.event_type ?? e.description ?? ""; - if (!eventType.includes(filters.type)) return false; + if ((e.event_type ?? "") !== filters.type) return false; } // 카메라 필터 diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index 0e1f9b9..326985f 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -8,6 +8,7 @@ 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 { labelEventType } from "@/constants/eventTypes"; import type { EventResponse, EventStats, CameraEventStats } from "@/types"; const DAILY_DAYS = 12; @@ -45,8 +46,9 @@ function buildHourlyData(events: EventResponse[]) { function buildTypeData(events: EventResponse[]) { const result: Record = {}; events.forEach((e) => { - const type = e.event_type ?? "기타"; - result[type] = (result[type] ?? 0) + 1; + 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; } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c417637..77b8ed9 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -31,9 +31,9 @@ export interface EventResponse { description?: string; // AI 분석 설명 / 감지 유형 텍스트 (백엔드 응답에 포함 시 활용) appearance_tags?: string[]; // 인상착의 태그 (백엔드 응답에 포함 시 활용) camera?: CameraResponse; // 프론트에서 카메라 API 조인 후 주입 (백엔드 응답에는 camera_id만 있음) - event_type?: string; // 감지유형 (예: '태그 없이 통행', '테일게이팅') — 백엔드 확정 필요 - assigned_to?: string; // 담당자 — 백엔드 확정 필요 - reason?: string; // 오탐신고 사유 (status=false_alarm 인 경우) + 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 (평균 처리 시간 계산용) } From 06f635b00f972a663ce4a909253094306a2f68dc Mon Sep 17 00:00:00 2001 From: jihyun808 Date: Mon, 11 May 2026 17:20:38 +0900 Subject: [PATCH 05/31] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85,=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/LoginPage.tsx | 404 +++++++++++++++++++++++-------- 1 file changed, 308 insertions(+), 96 deletions(-) diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index e19c236..c864328 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,48 +1,55 @@ -/** - * @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"; +type Step = "login" | "register" | "findpw"; + export default function LoginPage() { const navigate = useNavigate(); - const [email, setEmail] = useState(""); + const [step, setStep] = useState("login"); + + // login + const [employeeId, setEmployeeId] = useState(""); const [password, setPassword] = useState(""); const [remember, setRemember] = useState(true); + + // register + const [regEmployeeId, setRegEmployeeId] = useState(""); + const [regEmail, setRegEmail] = useState(""); + const [regPassword, setRegPassword] = useState(""); + const [regPasswordConfirm, setRegPasswordConfirm] = useState(""); + + // find-pw + const [fpEmployeeId, setFpEmployeeId] = useState(""); + const [fpEmail, setFpEmail] = useState(""); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); const [loading, setLoading] = useState(false); - const handleSubmit = async (e: React.FormEvent) => { + function switchStep(next: Step) { + setError(""); + setSuccess(""); + setStep(next); + } + + const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); setError(""); setLoading(true); - try { - const res = await api.post("/api/auth/login", { email, password }); + 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); } - navigate("/dashboard"); } catch (err) { if (axios.isAxiosError(err) && err.response?.status === 401) { - setError("이메일 또는 비밀번호가 올바르지 않습니다."); + setError("사원번호 또는 비밀번호가 올바르지 않습니다."); } else { setError("로그인 중 오류가 발생했습니다."); } @@ -51,93 +58,298 @@ export default function LoginPage() { } }; + const handleRegister = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + if (regPassword !== regPasswordConfirm) { + setError("비밀번호가 일치하지 않습니다."); + return; + } + setLoading(true); + try { + await api.post("/api/auth/register", { + employee_id: regEmployeeId, + email: regEmail, + password: regPassword, + }); + setSuccess("가입이 완료되었습니다. 로그인해 주세요."); + setRegEmployeeId(""); + setRegEmail(""); + setRegPassword(""); + setRegPasswordConfirm(""); + setTimeout(() => switchStep("login"), 1500); + } catch (err) { + if (axios.isAxiosError(err) && err.response?.status === 409) { + setError("이미 사용 중인 사원번호 또는 이메일입니다."); + } else { + setError("가입 중 오류가 발생했습니다."); + } + } finally { + setLoading(false); + } + }; + + const handleFindPw = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + try { + await api.post("/api/auth/find-pw", { + employee_id: fpEmployeeId, + email: fpEmail, + }); + setSuccess("입력하신 이메일로 임시 비밀번호를 발송했습니다."); + setFpEmployeeId(""); + setFpEmail(""); + } catch (err) { + if (axios.isAxiosError(err) && err.response?.status === 404) { + setError("일치하는 계정을 찾을 수 없습니다."); + } else { + setError("비밀번호 찾기 중 오류가 발생했습니다."); + } + } finally { + setLoading(false); + } + }; + 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]" - /> -
- - {/* 비밀번호 */} -
-
- + {/* ── 로그인 ── */} + {step === "login" && ( + <> +

+ 로그인 +

+ +
+ + 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}

} + -
- 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 === "register" && ( + <> +

가입하기 - -

- +

+
+
+ + setRegEmployeeId(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]" + /> +
+ +
+ + setRegEmail(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]" + /> +
+ +
+ + setRegPassword(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]" + /> +
+ +
+ + setRegPasswordConfirm(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}

} + + + +

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

+
+ + )} + + {/* ── 비밀번호 찾기 ── */} + {step === "findpw" && ( + <> +

+ 비밀번호 찾기 +

+

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

+
+
+ + setFpEmployeeId(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]" + /> +
+ +
+ + setFpEmail(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}

} + + + +

+ +

+
+ + )}
); From 8b099238527fa27c9e31b73b1cfe434d3a993a95 Mon Sep 17 00:00:00 2001 From: jihyun808 Date: Mon, 18 May 2026 14:12:19 +0900 Subject: [PATCH 06/31] =?UTF-8?q?chore:=20=EC=A4=91=EB=B3=B5=20=EB=B0=8F?= =?UTF-8?q?=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/CLAUDE.md | 34 +-- frontend/src/api/axios.ts | 21 +- frontend/src/api/cameras.ts | 18 +- frontend/src/api/events.ts | 19 -- frontend/src/api/notifications.ts | 16 -- .../src/components/dashboard/AlertItem.tsx | 35 +-- .../src/components/dashboard/AlertList.tsx | 13 - .../components/dashboard/FalseAlarmList.tsx | 26 +- .../components/dashboard/FalseAlarmModal.tsx | 11 - .../src/components/dashboard/StatCards.tsx | 18 -- .../src/components/events/EventsFilter.tsx | 28 +- .../components/events/EventsPagination.tsx | 14 - .../src/components/events/EventsTable.tsx | 24 -- frontend/src/components/layout/Header.tsx | 32 +-- frontend/src/components/layout/Sidebar.tsx | 11 - frontend/src/contexts/AppContext.tsx | 14 - frontend/src/hooks/useWebSocket.ts | 21 -- frontend/src/pages/DashboardPage.tsx | 23 -- frontend/src/pages/EventsPage.tsx | 17 -- frontend/src/pages/LoginPage.tsx | 7 +- frontend/src/pages/SettingsPage.tsx | 266 +++++++++++++++++- frontend/src/router/index.tsx | 32 +-- frontend/src/types/index.ts | 12 - 23 files changed, 320 insertions(+), 392 deletions(-) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index bfaff2e..0c5e65d 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -30,7 +30,7 @@ No test runner is configured yet. - `/dashboard` → `DashboardPage` ✅ 구현완료 - `/stats` → `StatsPage` ✅ 구현완료 - `/events` → `EventsPage` ✅ 구현완료 -- `/settings` → `SettingsPage` ⚠️ placeholder ("준비 중" 텍스트만) +- `/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. @@ -55,11 +55,12 @@ Dashboard pages share a consistent layout: `` (left, fixed w-64) + `< ### Component Organization - `src/components/layout/` — `Sidebar`, `Header` - `src/components/dashboard/` — `StatCards`, `StatCard`, `AlertList`, `AlertItem`, `CameraStats`, `FalseAlarmList`, `EventDetailModal`, `FalseAlarmModal` - - `EventDetailModal` — 단일 이벤트 상세. FalseAlarmModal을 내부에서 직접 렌더링. 처리완료/오탐신고 완료 시 버튼 → 완료 문구로 전환 (서버 `handled_at` 재조회) - - `FalseAlarmModal` — `onSubmitted(reason: string)` 콜백으로 reason 전달. AlertItem 경유 시 DashboardPage가 렌더링, EventDetailModal 경유 시 EventDetailModal이 내부 렌더링 + - `EventDetailModal` — 단일 이벤트 상세. FalseAlarmModal을 내부에서 직접 렌더링. 처리완료/오탐신고 완료 시 버튼 → 완료 문구로 전환 (서버 `handled_at` 재조회). 오탐신고 완료 시 reason도 completedInfo에 저장 후 표시 + - `FalseAlarmModal` — `onSubmitted(reason: string)` 콜백으로 reason 전달. EventDetailModal 경유 시 reason 활용, DashboardPage(AlertItem) 경유 시 `() => refresh()`로 reason은 버림 - `src/components/events/` — `EventsFilter`, `EventsTable`, `EventsPagination` - `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`) — 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` @@ -87,13 +88,13 @@ Dashboard pages share a consistent layout: `` (left, fixed w-64) + `< **auth** - `POST /api/auth/login` — JWT 로그인 ✅ 프론트 연동 -- `POST /api/auth/register` — 회원가입 (프론트 미사용) -- `POST /api/auth/find-pw` — 비밀번호 찾기 (프론트 미사용) +- `POST /api/auth/register` — 회원가입 ✅ 프론트 연동 (LoginPage register step) +- `POST /api/auth/find-pw` — 비밀번호 찾기 ✅ 프론트 연동 (LoginPage findpw step) **cameras** - `GET /api/cameras/` — 카메라 목록 ✅ 프론트 연동 -- `POST /api/cameras/` — 카메라 등록 (프론트 미사용) -- `PATCH /api/cameras/{camera_id}/toggle` — 카메라 활성화/비활성화 (프론트 미사용) +- `POST /api/cameras/` — 카메라 등록 ✅ 프론트 연동 (SettingsPage) +- `PATCH /api/cameras/{camera_id}/toggle` — 카메라 활성화/비활성화 ✅ 프론트 연동 (SettingsPage) **events** - `GET /api/events/` — 이벤트 목록 ✅ 프론트 연동 @@ -132,17 +133,18 @@ Dashboard pages share a consistent layout: `` (left, fixed w-64) + `< ### ✅ 완료 - 로그인 페이지 (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 실시간 순위 변동) + - CameraStats — 역별 알림현황 (건수 내림차순, 최대 5개, WebSocket 실시간 낙관적 업데이트) - EventDetailModal — 영상 + 상세정보 패널. 역무원파견(confirm→비활성화)/처리완료(confirm→PATCH)/오탐신고(FalseAlarmModal 내장). 처리 후 서버 `handled_at` 재조회 후 완료 문구 표시 - - FalseAlarmModal — `onSubmitted(reason)` 으로 reason 반환. EventDetailModal 내부 렌더링 (AlertItem 직접 접근 시 DashboardPage 렌더링) + - FalseAlarmModal — `onSubmitted(reason)` 으로 reason 반환. EventDetailModal 내부 렌더링 시 reason 활용, AlertItem 경유(DashboardPage 렌더링) 시 reason 버리고 refresh()만 호출 - WebSocket NEW_EVENT 시 stats + cameraStats 낙관적 업데이트 -- EventsPage (전체 발생내역 — 필터/클라이언트 페이지네이션, WebSocket 실시간 삽입) +- EventsPage (전체 발생내역 — 텍스트/기간/유형/카메라/역/상태 필터, 클라이언트 페이지네이션 8/16/32건, WebSocket 실시간 삽입, EventDetailModal 재사용) - StatsPage (ECharts 통계 시각화) - StatSummaryCards — 총발생/일평균/오탐율/평균처리시간 4개 카드 - DailyTrendChart — 최근 12일 라인 차트 @@ -150,17 +152,15 @@ Dashboard pages share a consistent layout: `` (left, fixed w-64) + `< - HourlyDistributionChart — 시간대별 발생 분포 가로 바 차트 - FalseAlarmTable — 오탐 사유별 건수 (reason 필드 백엔드 추가 시 실데이터) - CameraRankingTable — 역별/게이트별 발생 순위 +- 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. **Auth route guard** — 토큰 없으면 `/`로 리다이렉트 (백엔드 켜진 상태에서는 401로 사실상 동작) -2. **SettingsPage** — 설정 기능 (현재 placeholder) -3. **역무원 파견** — confirm 후 버튼 비활성화까지만 구현. 백엔드 API 없어서 실제 파견 처리 불가. 모달 닫고 재열면 파견 상태 리셋됨 (로컬 state) -4. **FalseAlarmList 항목 클릭** — 클릭 시 상세 모달 미연결 (`NotificationResponse`에 `event` embed 백엔드 확인 필요) -5. **회원가입 / 비밀번호 찾기** — `LoginPage` 버튼 UI만 존재 -6. **지도보기** — `CameraStats` 버튼 핸들러 없음 -7. **Header 아바타** — "관" 하드코딩, 로그인 유저 정보 연동 필요 +1. **역무원 파견** — confirm 후 버튼 비활성화(`dispatched` 로컬 state)까지만 구현. 백엔드 API 없어서 실제 파견 처리 불가. 모달 닫고 재열면 파견 상태 리셋됨 ### 백엔드에 추가 요청 필요한 항목 - `GET /api/events/` 응답에 `event_type: string` 추가 → 감지 유형 파이 차트 실데이터 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..49b0745 100644 --- a/frontend/src/api/events.ts +++ b/frontend/src/api/events.ts @@ -1,22 +1,3 @@ -/** - * @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"; 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/dashboard/AlertItem.tsx b/frontend/src/components/dashboard/AlertItem.tsx index 798c89f..3c2f260 100644 --- a/frontend/src/components/dashboard/AlertItem.tsx +++ b/frontend/src/components/dashboard/AlertItem.tsx @@ -1,25 +1,3 @@ -/** - * @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"; interface AlertItemProps { @@ -28,15 +6,14 @@ 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" }; + return { label: "오탐", cls: "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-red-100 text-red-500" }; + return { label: "중간", cls: "bg-yellow-100 text-yellow-600" }; } function formatTime(timestamp: string): string { @@ -80,7 +57,7 @@ export default function AlertItem({ CAM-{String(event.camera_id).padStart(2, "0")}
{severity.label} diff --git a/frontend/src/components/dashboard/AlertList.tsx b/frontend/src/components/dashboard/AlertList.tsx index 89b0153..3141939 100644 --- a/frontend/src/components/dashboard/AlertList.tsx +++ b/frontend/src/components/dashboard/AlertList.tsx @@ -1,16 +1,3 @@ -/** - * @file components/dashboard/AlertList.tsx - * @description 대시보드 최신알림 목록 컴포넌트 - * - * ## 기능 - * - events 배열 렌더링 (데이터 페칭은 DashboardPage에서 담당) - * - loading 시 스켈레톤 3개 / 빈 배열 시 안내 문구 표시 - * - * ## 주의사항 - * - 전체보기 버튼은 Link to="/events"로 연결됨 - * - 최대 10건 표시 (DashboardPage에서 limit=10 페칭 + WebSocket .slice(0, 10) 유지) - */ - import { Link } from "react-router-dom"; import AlertItem from "./AlertItem"; import type { EventResponse } from "@/types"; diff --git a/frontend/src/components/dashboard/FalseAlarmList.tsx b/frontend/src/components/dashboard/FalseAlarmList.tsx index 9e678f4..d03c203 100644 --- a/frontend/src/components/dashboard/FalseAlarmList.tsx +++ b/frontend/src/components/dashboard/FalseAlarmList.tsx @@ -1,24 +1,3 @@ -/** - * @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"; @@ -52,11 +31,8 @@ export default function FalseAlarmList({ }: FalseAlarmListProps) { return (
-
+

최근 오탐 신고

- - 전체보기 -
{loading ? ( diff --git a/frontend/src/components/dashboard/FalseAlarmModal.tsx b/frontend/src/components/dashboard/FalseAlarmModal.tsx index 6ea57f6..876159d 100644 --- a/frontend/src/components/dashboard/FalseAlarmModal.tsx +++ b/frontend/src/components/dashboard/FalseAlarmModal.tsx @@ -1,14 +1,3 @@ -/** - * @file components/dashboard/FalseAlarmModal.tsx - * @description 오탐신고 모달 컴포넌트 - * - * ## 기능 - * - 오탐 사유 4가지 라디오 선택 (기타 선택 시 직접 입력) - * - POST /api/events/{id}/false-alarm { reason, memo? } 호출 후 닫기 - * - EventDetailModal 내부에서 렌더링 (DashboardPage의 AlertItem 경유 시 DashboardPage가 직접 렌더링) - * - 제출 완료 시 onSubmitted(reason) 콜백으로 reason 전달 - */ - import { useState } from "react"; import { X } from "lucide-react"; import type { EventResponse } from "@/types"; diff --git a/frontend/src/components/dashboard/StatCards.tsx b/frontend/src/components/dashboard/StatCards.tsx index f20909a..fac41e9 100644 --- a/frontend/src/components/dashboard/StatCards.tsx +++ b/frontend/src/components/dashboard/StatCards.tsx @@ -1,21 +1,3 @@ -/** - * @file components/dashboard/StatCards.tsx - * @description 대시보드 상단 통계 카드 4개 컴포넌트 - * - * ## 기능 - * - EventStats 데이터를 받아 오늘 발생 / 확인 대기 / 처리 완료 / 오탐 신고 카드 렌더링 - * - loading 시 스켈레톤 4개 / stats=null 시 카운트 0으로 fallback - * - * ## 주의사항 - * - GET /api/events/stats 는 백엔드 M2 구현 예정, 전까지 0으로 표시 - * - * ## 주의사항 - * - WebSocket NEW_EVENT 수신 시 today_count/pending_count 낙관적 업데이트는 DashboardPage에서 처리 - * - * ## TODO - * - [ ] 어제 대비 증감 표시 (백엔드 응답에 비교 데이터 포함 여부 확인 필요) - */ - import StatCard from "./StatCard"; import type { EventStats } from "@/types"; diff --git a/frontend/src/components/events/EventsFilter.tsx b/frontend/src/components/events/EventsFilter.tsx index 9dfc90f..938bf4b 100644 --- a/frontend/src/components/events/EventsFilter.tsx +++ b/frontend/src/components/events/EventsFilter.tsx @@ -1,26 +1,5 @@ -/** - * @file components/events/EventsFilter.tsx - * @description 전체 발생내역 필터 바 컴포넌트 - * - * ## 기능 - * - 텍스트 검색: EV-번호 / CAM-번호 / 역이름 / 게이트이름 / 인상착의 / 설명 대상 실시간 필터링 - * - 기간 드롭다운: 전체 / 오늘 / 이번 주 / 이번 달 - * - 유형 드롭다운: event_type 또는 description 기반 필터링 - * - 카메라 드롭다운: 로드된 이벤트에서 추출한 CAM-XX 목록 - * - 상태 드롭다운: 미확인(pending) / 처리완료(confirmed) / 오탐(false_alarm) - * - 역 드롭다운: 로드된 이벤트에서 추출한 역이름 목록 - * - 초기화 버튼: 모든 필터 초기화 - * - * ## 주의사항 - * - cameraOptions, stationOptions는 EventsPage에서 allEvents 기반으로 추출해서 전달 - * - 유형 필터는 event_type 필드가 없으면 description으로 fallback - * - * ## TODO - * - [ ] 기간 필터 직접 선택(날짜 picker) 기능 추가 - * - [ ] 감지유형 목록 백엔드 스펙 확정 후 수정 - */ - import { Search, ChevronDown } from "lucide-react"; +import { EVENT_TYPE_OPTIONS } from "@/constants/eventTypes"; export interface EventFilters { search: string; @@ -54,9 +33,6 @@ const PERIOD_OPTIONS = [ { value: "month", label: "이번 달" }, ]; -import { EVENT_TYPE_OPTIONS } from "@/constants/eventTypes"; -const TYPE_OPTIONS = EVENT_TYPE_OPTIONS; - const STATUS_OPTIONS = [ { value: "", label: "전체 상태" }, { value: "pending", label: "미확인" }, @@ -135,7 +111,7 @@ export default function EventsFilter({ update({ type: v })} - options={TYPE_OPTIONS} + options={EVENT_TYPE_OPTIONS} /> {/* 카메라 */} diff --git a/frontend/src/components/events/EventsPagination.tsx b/frontend/src/components/events/EventsPagination.tsx index 5b0ceaa..6d87116 100644 --- a/frontend/src/components/events/EventsPagination.tsx +++ b/frontend/src/components/events/EventsPagination.tsx @@ -1,17 +1,3 @@ -/** - * @file components/events/EventsPagination.tsx - * @description 전체 발생내역 페이지네이션 컴포넌트 - * - * ## 기능 - * - 좌측: "총 N건 중 A-B 표시" - * - 중앙: 이전/다음 버튼 + 페이지 번호 버튼 (7개 초과 시 슬라이딩 윈도우 + 말줄임) - * - 우측: 페이지당 건수 선택 (8 / 16 / 32) - * - * ## 주의사항 - * - 클라이언트사이드 페이지네이션 기준 (EventsPage에서 filteredEvents.slice 후 전달) - * - 서버사이드 전환 시 onPageChange / onPageSizeChange 시그니처 그대로 유지 가능 - */ - import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react"; interface EventsPaginationProps { diff --git a/frontend/src/components/events/EventsTable.tsx b/frontend/src/components/events/EventsTable.tsx index 2680484..8583f6c 100644 --- a/frontend/src/components/events/EventsTable.tsx +++ b/frontend/src/components/events/EventsTable.tsx @@ -1,27 +1,3 @@ -/** - * @file components/events/EventsTable.tsx - * @description 전체 발생내역 테이블 컴포넌트 - * - * ## 기능 - * - 10개 컬럼 테이블: #, 발생시각, 역/게이트, 감지유형, 심각도, 인상착의, 카메라, 상태, 담당자, 대응 - * - 심각도 3단계: confidence ≥ 0.7 → 고위험(빨강) / ≥ 0.4 → 중간(노랑) / < 0.4 → 낮음(초록) - * - 대응 컬럼: pending → "상세" 버튼 / confirmed → "기록" 버튼 / false_alarm → "기록" 버튼 + "오탐" 텍스트 - * - 액셀 내보내기: 필터링된 이벤트 전체를 UTF-8 BOM CSV로 다운로드 (엑셀 한글 정상 표시) - * - loading 시 스켈레톤 / 빈 배열 시 안내 문구 - * - * ## 주의사항 - * - 감지유형: event_type 필드 우선, 없으면 description fallback - * - 담당자: assigned_to 필드 우선, 없으면 "—" 표시 - * - 액셀 내보내기는 현재 페이지가 아닌 필터링된 전체 데이터 기준 - * - * ## TODO - * - [ ] event_type, assigned_to 필드 백엔드 확정 후 수정 - * - [ ] .xlsx 포맷 필요 시 xlsx 라이브러리 설치 (npm install xlsx) - * - * ## 협의 - * - 감지유형 enum 값 목록 백엔드(조수근) 확정 필요 - */ - import { Download } from "lucide-react"; import type { EventResponse } from "@/types"; import { labelEventType } from "@/constants/eventTypes"; diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 6e5d177..e6a5acb 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,26 +1,21 @@ -/** - * @file Header.tsx - * @description 대시보드 헤더 컴포넌트 - * - * ## 기능 - * - 현재 경로 기반 페이지 타이틀 자동 표시 - * - AppContext의 wsConnected 상태에 따라 실시간 모니터링 뱃지 on(초록)/off(회색) 전환 - * - 톱니바퀴 아이콘 클릭 시 /settings 이동 - * - 다크모드: OS/브라우저 prefers-color-scheme 자동 연동 (수동 토글 없음) - * - * ## 주의사항 - * - 프로필 아바타는 하드코딩 ("관"), API 사용자 정보 연동 미구현 - * - * ## TODO - * - [ ] 프로필 아바타 → API 사용자 정보로 교체 - */ - import { Settings } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { useLocation, useNavigate } from "react-router-dom"; import { useAppContext } from "@/contexts/AppContext"; +function getAvatarLabel(): string { + const token = localStorage.getItem("token") || sessionStorage.getItem("token"); + if (!token) return "관"; + try { + const payload = JSON.parse(atob(token.split(".")[1])); + const id: string = payload.employee_id ?? ""; + return id.charAt(0).toUpperCase() || "관"; + } catch { + return "관"; + } +} + const pageTitles: Record = { "/dashboard": "대시보드", "/stats": "통계 리포트", @@ -33,6 +28,7 @@ export default function Header() { const navigate = useNavigate(); const { wsConnected } = useAppContext(); const title = pageTitles[pathname] ?? "대시보드"; + const avatarLabel = getAvatarLabel(); return (
@@ -65,7 +61,7 @@ export default function Header() { {/* 프로필 아바타 */} - 관 + {avatarLabel}
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index f551940..adc4e33 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,14 +1,3 @@ -/** - * @file Sidebar.tsx - * @description 대시보드 사이드바 컴포넌트 - * - * ## 기능 - * - AppContext의 unconfirmedCount > 0 이면 대시보드 메뉴 항목에 빨간 뱃지 표시 (최대 99+) - * - * ## TODO - * - [ ] 로고 이미지 파일 생기면 Shield 아이콘 → 로 교체 - */ - import { NavLink } from "react-router-dom"; import { LayoutDashboard, diff --git a/frontend/src/contexts/AppContext.tsx b/frontend/src/contexts/AppContext.tsx index dbff399..73e42d3 100644 --- a/frontend/src/contexts/AppContext.tsx +++ b/frontend/src/contexts/AppContext.tsx @@ -1,17 +1,3 @@ -/** - * @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 type { ReactNode } from "react"; diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index a5e9cfb..ba82855 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -1,18 +1,3 @@ -/** - * @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"; const WS_URL = import.meta.env.VITE_WS_URL as string; @@ -22,12 +7,6 @@ export interface WsMessage { data: unknown; } -/** - * WebSocket 연결을 관리하는 훅. - * 연결이 끊어지면 3초 후 자동 재연결. - * onMessage 콜백은 ref로 관리하여 리렌더링 없이 최신 참조 유지. - * @returns { connected } — 현재 WebSocket 연결 여부 - */ export function useWebSocket(onMessage: (msg: WsMessage) => void): { connected: boolean; } { diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 52e2d50..78d721c 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,26 +1,3 @@ -/** - * @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 + cameraStats 낙관적 업데이트 (해당 camera_id count +1) - * - 처리완료 / 오탐신고 완료 후 refresh()로 전체 상태 재동기화 - * - FalseAlarmModal은 AlertItem 경유 시 DashboardPage가 직접 렌더링 - * (EventDetailModal 경유 시는 EventDetailModal 내부에서 관리) - * - * ## 주의사항 - * - 401 응답은 axios interceptor가 자동으로 `/`로 리다이렉트 처리 - * - AppContext를 통해 wsConnected, unconfirmedCount를 Sidebar/Header에 공유 - */ - import { useState, useEffect, useCallback, useRef } from "react"; import { useAppContext } from "@/contexts/AppContext"; import Sidebar from "@/components/layout/Sidebar"; diff --git a/frontend/src/pages/EventsPage.tsx b/frontend/src/pages/EventsPage.tsx index 8606b53..cd735e0 100644 --- a/frontend/src/pages/EventsPage.tsx +++ b/frontend/src/pages/EventsPage.tsx @@ -1,20 +1,3 @@ -/** - * @file pages/EventsPage.tsx - * @description 전체 발생내역 페이지 - * - * ## 기능 - * - GET /api/cameras/ + GET /api/events/?limit=500 병렬 호출 후 카메라 정보 조인 - * - 클라이언트사이드 필터: 텍스트 검색 / 기간 / 감지유형 / 카메라 / 상태 / 역 - * - 클라이언트사이드 페이지네이션: 기본 8건, 선택 가능 (8 / 16 / 32) - * - EventDetailModal 재사용 (FalseAlarmModal은 EventDetailModal 내부에서 관리) - * - WebSocket NEW_EVENT 수신 시 카메라 정보 조인 후 allEvents 앞에 실시간 삽입 - * - AppContext를 통해 wsConnected 상태 공유 - * - * ## 주의사항 - * - 현재 클라이언트사이드 페이지네이션 (limit=500 fetch) - * → 데모 단계 유지. 실데이터 시 GET /api/events/?skip=N&limit=M 서버사이드로 전환 필요 - */ - import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import Sidebar from "@/components/layout/Sidebar"; import Header from "@/components/layout/Header"; diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index c864328..3ed85c4 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -79,7 +79,7 @@ export default function LoginPage() { setRegPasswordConfirm(""); setTimeout(() => switchStep("login"), 1500); } catch (err) { - if (axios.isAxiosError(err) && err.response?.status === 409) { + if (axios.isAxiosError(err) && err.response?.status === 400) { setError("이미 사용 중인 사원번호 또는 이메일입니다."); } else { setError("가입 중 오류가 발생했습니다."); @@ -94,9 +94,8 @@ export default function LoginPage() { setError(""); setLoading(true); try { - await api.post("/api/auth/find-pw", { - employee_id: fpEmployeeId, - email: fpEmail, + await api.post("/api/auth/find-pw", null, { + params: { employee_id: fpEmployeeId, email: fpEmail }, }); setSuccess("입력하신 이메일로 임시 비밀번호를 발송했습니다."); setFpEmployeeId(""); diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 68ab8f8..83430a4 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1,22 +1,266 @@ -/** - * @file pages/SettingsPage.tsx - * @description 설정 페이지 - * - * ## TODO - * - [ ] 설정 기능 구현 (사용자 관리, 알림 설정, 카메라 관리 등) - */ - +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { LogOut, Plus, X } from "lucide-react"; import Sidebar from "@/components/layout/Sidebar"; import Header from "@/components/layout/Header"; +import { getCameras, toggleCamera, createCamera } from "@/api/cameras"; +import type { CameraResponse } from "@/types"; + +type Tab = "profile" | "cameras"; + +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 SettingsPage() { + const navigate = useNavigate(); + const [tab, setTab] = useState("profile"); + + // 프로필 + const profile = decodeToken(); + + // 카메라 관리 + const [cameras, setCameras] = useState([]); + const [camLoading, setCamLoading] = useState(false); + const [togglingId, setTogglingId] = useState(null); + + // 카메라 등록 폼 + const [showForm, setShowForm] = useState(false); + const [formStation, setFormStation] = useState(""); + const [formLocation, setFormLocation] = useState(""); + const [formLoading, setFormLoading] = useState(false); + const [formError, setFormError] = useState(""); + + useEffect(() => { + if (tab === "cameras") loadCameras(); + }, [tab]); + + async function loadCameras() { + setCamLoading(true); + try { + const data = await getCameras(); + setCameras(data); + } finally { + setCamLoading(false); + } + } + + async function handleToggle(id: number) { + setTogglingId(id); + try { + const updated = await toggleCamera(id); + setCameras((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); + } finally { + setTogglingId(null); + } + } + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + setFormError(""); + setFormLoading(true); + try { + const created = await createCamera({ station_name: formStation, location: formLocation }); + setCameras((prev) => [...prev, created]); + setFormStation(""); + setFormLocation(""); + setShowForm(false); + } catch { + setFormError("카메라 등록 중 오류가 발생했습니다."); + } finally { + setFormLoading(false); + } + } + + function handleLogout() { + localStorage.removeItem("token"); + sessionStorage.removeItem("token"); + navigate("/"); + } + return (
-
+
-
-

설정 준비 중...

+
+ {/* 탭 */} +
+ {(["profile", "cameras"] as Tab[]).map((t) => ( + + ))} +
+ + {/* ── 내 프로필 ── */} + {tab === "profile" && ( +
+

내 프로필

+ +
+ 사원번호 + + {profile?.employee_id ?? "—"} + +
+ +
+ +
+
+ )} + + {/* ── 카메라 관리 ── */} + {tab === "cameras" && ( +
+
+
+

+ 카메라 목록 + {!camLoading && ( + + {cameras.length}대 + + )} +

+ +
+ + {/* 등록 폼 */} + {showForm && ( +
+
+
+ + 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}

} +
+ +
+
+ )} + + {/* 카메라 목록 */} + {camLoading ? ( +
+ {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/router/index.tsx b/frontend/src/router/index.tsx index 5f2ae50..d4d8999 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -1,24 +1,16 @@ -/** - * @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 { createBrowserRouter, Navigate } 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 type { ReactNode } from "react"; + +function PrivateRoute({ children }: { children: ReactNode }) { + const token = localStorage.getItem("token") || sessionStorage.getItem("token"); + if (!token) return ; + return <>{children}; +} export const router = createBrowserRouter([ { @@ -27,18 +19,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 77b8ed9..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; From 9f6fa2c71db8c846cce895fd2497eb9b807a0a2f Mon Sep 17 00:00:00 2001 From: jihyun808 Date: Mon, 18 May 2026 14:21:00 +0900 Subject: [PATCH 07/31] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B1=85=EC=9E=84=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/auth/AuthLayout.tsx | 14 + frontend/src/components/auth/FindPwForm.tsx | 87 +++++ frontend/src/components/auth/LoginForm.tsx | 108 ++++++ frontend/src/components/auth/RegisterForm.tsx | 109 ++++++ frontend/src/pages/LoginPage.tsx | 359 +----------------- 5 files changed, 332 insertions(+), 345 deletions(-) create mode 100644 frontend/src/components/auth/AuthLayout.tsx create mode 100644 frontend/src/components/auth/FindPwForm.tsx create mode 100644 frontend/src/components/auth/LoginForm.tsx create mode 100644 frontend/src/components/auth/RegisterForm.tsx 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..8cf491c --- /dev/null +++ b/frontend/src/components/auth/LoginForm.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import api from "@/api/axios"; +import axios from "axios"; + +interface LoginFormProps { + onRegister: () => void; + onFindPw: () => void; +} + +export default function LoginForm({ onRegister, onFindPw }: LoginFormProps) { + const navigate = useNavigate(); + 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); + } + 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..b090d3d --- /dev/null +++ b/frontend/src/components/auth/RegisterForm.tsx @@ -0,0 +1,109 @@ +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/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 3ed85c4..ea17b95 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,355 +1,24 @@ import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import api from "@/api/axios"; -import axios from "axios"; +import AuthLayout from "@/components/auth/AuthLayout"; +import LoginForm from "@/components/auth/LoginForm"; +import RegisterForm from "@/components/auth/RegisterForm"; +import FindPwForm from "@/components/auth/FindPwForm"; type Step = "login" | "register" | "findpw"; export default function LoginPage() { - const navigate = useNavigate(); const [step, setStep] = useState("login"); - // login - const [employeeId, setEmployeeId] = useState(""); - const [password, setPassword] = useState(""); - const [remember, setRemember] = useState(true); - - // register - const [regEmployeeId, setRegEmployeeId] = useState(""); - const [regEmail, setRegEmail] = useState(""); - const [regPassword, setRegPassword] = useState(""); - const [regPasswordConfirm, setRegPasswordConfirm] = useState(""); - - // find-pw - const [fpEmployeeId, setFpEmployeeId] = useState(""); - const [fpEmail, setFpEmail] = useState(""); - - const [error, setError] = useState(""); - const [success, setSuccess] = useState(""); - const [loading, setLoading] = useState(false); - - function switchStep(next: Step) { - setError(""); - setSuccess(""); - setStep(next); - } - - const handleLogin = 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); - } - navigate("/dashboard"); - } catch (err) { - if (axios.isAxiosError(err) && err.response?.status === 401) { - setError("사원번호 또는 비밀번호가 올바르지 않습니다."); - } else { - setError("로그인 중 오류가 발생했습니다."); - } - } finally { - setLoading(false); - } - }; - - const handleRegister = async (e: React.FormEvent) => { - e.preventDefault(); - setError(""); - if (regPassword !== regPasswordConfirm) { - setError("비밀번호가 일치하지 않습니다."); - return; - } - setLoading(true); - try { - await api.post("/api/auth/register", { - employee_id: regEmployeeId, - email: regEmail, - password: regPassword, - }); - setSuccess("가입이 완료되었습니다. 로그인해 주세요."); - setRegEmployeeId(""); - setRegEmail(""); - setRegPassword(""); - setRegPasswordConfirm(""); - setTimeout(() => switchStep("login"), 1500); - } catch (err) { - if (axios.isAxiosError(err) && err.response?.status === 400) { - setError("이미 사용 중인 사원번호 또는 이메일입니다."); - } else { - setError("가입 중 오류가 발생했습니다."); - } - } finally { - setLoading(false); - } - }; - - const handleFindPw = async (e: React.FormEvent) => { - e.preventDefault(); - setError(""); - setLoading(true); - try { - await api.post("/api/auth/find-pw", null, { - params: { employee_id: fpEmployeeId, email: fpEmail }, - }); - setSuccess("입력하신 이메일로 임시 비밀번호를 발송했습니다."); - setFpEmployeeId(""); - setFpEmail(""); - } catch (err) { - if (axios.isAxiosError(err) && err.response?.status === 404) { - setError("일치하는 계정을 찾을 수 없습니다."); - } else { - setError("비밀번호 찾기 중 오류가 발생했습니다."); - } - } finally { - setLoading(false); - } - }; - return ( -
- {/* 배경 블롭 — 항상 고정 */} -
-
-
- -
- {/* ── 로그인 ── */} - {step === "login" && ( - <> -

- 로그인 -

-
-
- - 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}

} - - - -

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

-
- - )} - - {/* ── 가입하기 ── */} - {step === "register" && ( - <> -

- 가입하기 -

-
-
- - setRegEmployeeId(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]" - /> -
- -
- - setRegEmail(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]" - /> -
- -
- - setRegPassword(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]" - /> -
- -
- - setRegPasswordConfirm(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}

} - - - -

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

-
- - )} - - {/* ── 비밀번호 찾기 ── */} - {step === "findpw" && ( - <> -

- 비밀번호 찾기 -

-

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

-
-
- - setFpEmployeeId(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]" - /> -
- -
- - setFpEmail(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}

} - - - -

- -

-
- - )} -
-
+ + {step === "login" && ( + setStep("register")} + onFindPw={() => setStep("findpw")} + /> + )} + {step === "register" && setStep("login")} />} + {step === "findpw" && setStep("login")} />} + ); } From ddaabd5d51f54f6d91f1f29555d85fb7f123fedb Mon Sep 17 00:00:00 2001 From: jihyun808 Date: Mon, 18 May 2026 14:39:15 +0900 Subject: [PATCH 08/31] =?UTF-8?q?feat:=20=EC=84=B8=ED=8C=85=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/settings/CamerasTab.tsx | 166 ++++++++++++ .../src/components/settings/ProfileTab.tsx | 45 ++++ frontend/src/pages/SettingsPage.tsx | 238 +----------------- 3 files changed, 216 insertions(+), 233 deletions(-) create mode 100644 frontend/src/components/settings/CamerasTab.tsx create mode 100644 frontend/src/components/settings/ProfileTab.tsx diff --git a/frontend/src/components/settings/CamerasTab.tsx b/frontend/src/components/settings/CamerasTab.tsx new file mode 100644 index 0000000..125ce22 --- /dev/null +++ b/frontend/src/components/settings/CamerasTab.tsx @@ -0,0 +1,166 @@ +import { useState, useEffect } from "react"; +import { Plus, X } from "lucide-react"; +import { getCameras, toggleCamera, createCamera } from "@/api/cameras"; +import type { CameraResponse } from "@/types"; + +export default function CamerasTab() { + const [cameras, setCameras] = useState([]); + const [loading, setLoading] = useState(false); + const [togglingId, setTogglingId] = useState(null); + const [showForm, setShowForm] = useState(false); + const [formStation, setFormStation] = useState(""); + const [formLocation, setFormLocation] = useState(""); + const [formLoading, setFormLoading] = useState(false); + const [formError, setFormError] = useState(""); + + useEffect(() => { + loadCameras(); + }, []); + + async function loadCameras() { + setLoading(true); + try { + const data = await getCameras(); + setCameras(data); + } finally { + setLoading(false); + } + } + + async function handleToggle(id: number) { + setTogglingId(id); + try { + const updated = await toggleCamera(id); + setCameras((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); + } finally { + setTogglingId(null); + } + } + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + setFormError(""); + setFormLoading(true); + try { + const created = await createCamera({ station_name: formStation, location: formLocation }); + setCameras((prev) => [...prev, created]); + setFormStation(""); + setFormLocation(""); + setShowForm(false); + } catch { + setFormError("카메라 등록 중 오류가 발생했습니다."); + } finally { + setFormLoading(false); + } + } + + return ( +
+
+
+

+ 카메라 목록 + {!loading && ( + {cameras.length}대 + )} +

+ +
+ + {showForm && ( +
+
+
+ + 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..05e8a67 --- /dev/null +++ b/frontend/src/components/settings/ProfileTab.tsx @@ -0,0 +1,45 @@ +import { useNavigate } from "react-router-dom"; +import { LogOut } from "lucide-react"; + +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 profile = decodeToken(); + + function handleLogout() { + localStorage.removeItem("token"); + sessionStorage.removeItem("token"); + navigate("/"); + } + + return ( +
+

내 프로필

+
+ 사원번호 + + {profile?.employee_id ?? "—"} + +
+
+ +
+
+ ); +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 83430a4..53b675b 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1,97 +1,20 @@ -import { useState, useEffect } from "react"; -import { useNavigate } from "react-router-dom"; -import { LogOut, Plus, X } from "lucide-react"; +import { useState } from "react"; import Sidebar from "@/components/layout/Sidebar"; import Header from "@/components/layout/Header"; -import { getCameras, toggleCamera, createCamera } from "@/api/cameras"; -import type { CameraResponse } from "@/types"; +import ProfileTab from "@/components/settings/ProfileTab"; +import CamerasTab from "@/components/settings/CamerasTab"; type Tab = "profile" | "cameras"; -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 SettingsPage() { - const navigate = useNavigate(); const [tab, setTab] = useState("profile"); - // 프로필 - const profile = decodeToken(); - - // 카메라 관리 - const [cameras, setCameras] = useState([]); - const [camLoading, setCamLoading] = useState(false); - const [togglingId, setTogglingId] = useState(null); - - // 카메라 등록 폼 - const [showForm, setShowForm] = useState(false); - const [formStation, setFormStation] = useState(""); - const [formLocation, setFormLocation] = useState(""); - const [formLoading, setFormLoading] = useState(false); - const [formError, setFormError] = useState(""); - - useEffect(() => { - if (tab === "cameras") loadCameras(); - }, [tab]); - - async function loadCameras() { - setCamLoading(true); - try { - const data = await getCameras(); - setCameras(data); - } finally { - setCamLoading(false); - } - } - - async function handleToggle(id: number) { - setTogglingId(id); - try { - const updated = await toggleCamera(id); - setCameras((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); - } finally { - setTogglingId(null); - } - } - - async function handleCreate(e: React.FormEvent) { - e.preventDefault(); - setFormError(""); - setFormLoading(true); - try { - const created = await createCamera({ station_name: formStation, location: formLocation }); - setCameras((prev) => [...prev, created]); - setFormStation(""); - setFormLocation(""); - setShowForm(false); - } catch { - setFormError("카메라 등록 중 오류가 발생했습니다."); - } finally { - setFormLoading(false); - } - } - - function handleLogout() { - localStorage.removeItem("token"); - sessionStorage.removeItem("token"); - navigate("/"); - } - return (
- {/* 탭 */}
{(["profile", "cameras"] as Tab[]).map((t) => ( -
-
- )} - - {/* ── 카메라 관리 ── */} - {tab === "cameras" && ( -
-
-
-

- 카메라 목록 - {!camLoading && ( - - {cameras.length}대 - - )} -

- -
- - {/* 등록 폼 */} - {showForm && ( -
-
-
- - 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}

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

    - {cam.station_name} -

    -

    {cam.location}

    -
    -
    -
    - - {cam.is_active ? "활성" : "비활성"} - - -
    -
  • - ))} -
- )} -
-
- )} + {tab === "profile" && } + {tab === "cameras" && }
From bf4357fdc2ccb426292a3e727626dc49c6d0a270 Mon Sep 17 00:00:00 2001 From: jihyun808 Date: Mon, 18 May 2026 14:43:04 +0900 Subject: [PATCH 09/31] =?UTF-8?q?feat:=20=ED=86=B5=EA=B3=84=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=83=81=EB=8B=A8=20=EC=B9=B4=EB=93=9C=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/lib/stats.ts | 57 +++++++++++++++++++++++++++++ frontend/src/pages/StatsPage.tsx | 61 ++++---------------------------- 2 files changed, 64 insertions(+), 54 deletions(-) create mode 100644 frontend/src/lib/stats.ts diff --git a/frontend/src/lib/stats.ts b/frontend/src/lib/stats.ts new file mode 100644 index 0000000..deccf8a --- /dev/null +++ b/frontend/src/lib/stats.ts @@ -0,0 +1,57 @@ +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) => { + 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) => { + 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.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/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index 326985f..8705d20 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -8,62 +8,15 @@ 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 { labelEventType } from "@/constants/eventTypes"; +import { + buildDailyData, + buildHourlyData, + buildTypeData, + buildFalseAlarmData, + DAILY_DAYS, +} from "@/lib/stats"; import type { EventResponse, EventStats, CameraEventStats } from "@/types"; -const DAILY_DAYS = 12; - -function buildDailyData(events: EventResponse[]) { - 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) => { - const d = new Date(e.timestamp); - const key = `${d.getMonth() + 1}/${d.getDate()}`; - if (key in result) result[key]++; - }); - return result; -} - -function buildHourlyData(events: EventResponse[]) { - 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) => { - 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; -} - -function buildTypeData(events: EventResponse[]) { - const result: Record = {}; - events.forEach((e) => { - 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; -} - -function buildFalseAlarmData(events: EventResponse[]) { - 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 default function StatsPage() { const [events, setEvents] = useState([]); const [stats, setStats] = useState(null); From 40f81fd14bc356c3868907077a49c1dc32f9e99e Mon Sep 17 00:00:00 2001 From: jihyun808 Date: Mon, 18 May 2026 14:47:00 +0900 Subject: [PATCH 10/31] =?UTF-8?q?feat:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=EB=B3=B4=EA=B8=B0=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=B1=85=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/hooks/useEventsData.ts | 54 ++++++++ frontend/src/hooks/useEventsFilter.ts | 90 +++++++++++++ frontend/src/pages/EventsPage.tsx | 176 ++------------------------ 3 files changed, 156 insertions(+), 164 deletions(-) create mode 100644 frontend/src/hooks/useEventsData.ts create mode 100644 frontend/src/hooks/useEventsFilter.ts diff --git a/frontend/src/hooks/useEventsData.ts b/frontend/src/hooks/useEventsData.ts new file mode 100644 index 0000000..cae8ce7 --- /dev/null +++ b/frontend/src/hooks/useEventsData.ts @@ -0,0 +1,54 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { getEvents } from "@/api/events"; +import { getCameras } from "@/api/cameras"; +import { useWebSocket } from "./useWebSocket"; +import type { EventResponse, CameraResponse } from "@/types"; + +export function useEventsData(setWsConnected: (v: boolean) => void) { + const [allEvents, setAllEvents] = useState([]); + const [loading, setLoading] = useState(true); + const cameraMapRef = useRef>(new Map()); + + const fetchAll = useCallback(async () => { + setLoading(true); + try { + const [camResult, evResult] = await Promise.allSettled([ + getCameras(), + getEvents({ limit: 500 }), + ]); + 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, + })), + ); + } + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchAll(); + }, [fetchAll]); + + const { connected } = useWebSocket((msg) => { + if (msg.type === "NEW_EVENT") { + const newEvent = msg.data as EventResponse; + setAllEvents((prev) => [ + { ...newEvent, camera: cameraMapRef.current.get(newEvent.camera_id) ?? newEvent.camera }, + ...prev, + ]); + } + }); + + useEffect(() => { + setWsConnected(connected); + }, [connected, setWsConnected]); + + return { allEvents, loading, fetchAll }; +} diff --git a/frontend/src/hooks/useEventsFilter.ts b/frontend/src/hooks/useEventsFilter.ts new file mode 100644 index 0000000..8cbf095 --- /dev/null +++ b/frontend/src/hooks/useEventsFilter.ts @@ -0,0 +1,90 @@ +import { useState, useMemo } from "react"; +import { DEFAULT_FILTERS } from "@/components/events/EventsFilter"; +import type { EventFilters } from "@/components/events/EventsFilter"; +import type { EventResponse } from "@/types"; + +export function useEventsFilter(allEvents: EventResponse[]) { + const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(8); + + function handleFiltersChange(newFilters: EventFilters) { + setFilters(newFilters); + setPage(1); + } + + const filteredEvents = useMemo(() => { + return allEvents.filter((e) => { + 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" && eventDate.toDateString() !== now.toDateString()) return false; + if (filters.period === "week" && eventDate < new Date(now.getTime() - 7 * 86400_000)) return false; + if (filters.period === "month" && eventDate < new Date(now.getTime() - 30 * 86400_000)) return false; + } + if (filters.type && (e.event_type ?? "") !== 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]); + + const cameraOptions = useMemo(() => { + const map = new Map(); + allEvents.forEach((e) => { + if (!map.has(e.camera_id)) { + 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]); + + return { + filters, + handleFiltersChange, + filteredEvents, + paginatedEvents, + cameraOptions, + stationOptions, + page, + setPage, + pageSize, + setPageSize, + }; +} diff --git a/frontend/src/pages/EventsPage.tsx b/frontend/src/pages/EventsPage.tsx index cd735e0..c5b085e 100644 --- a/frontend/src/pages/EventsPage.tsx +++ b/frontend/src/pages/EventsPage.tsx @@ -1,193 +1,44 @@ -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 { 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 { useEventsData } from "@/hooks/useEventsData"; +import { useEventsFilter } from "@/hooks/useEventsFilter"; +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 { allEvents, loading, fetchAll } = useEventsData(setWsConnected); + const { + filters, handleFiltersChange, + filteredEvents, paginatedEvents, + cameraOptions, stationOptions, + page, setPage, pageSize, setPageSize, + } = useEventsFilter(allEvents); const [selectedEvent, setSelectedEvent] = 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; - } - } - - if (filters.type) { - if ((e.event_type ?? "") !== 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]); - return (
- {/* 필터 바 */} - - {/* 테이블 */} - - {/* 페이지네이션 */} {!loading && (
- {/* 이벤트 상세 모달 (재사용) */} {selectedEvent && ( )} - -
); } From 5c5e43096dbd093e44997783f1147aa40130e2ae Mon Sep 17 00:00:00 2001 From: jihyun808 Date: Mon, 18 May 2026 14:55:16 +0900 Subject: [PATCH 11/31] =?UTF-8?q?feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=9B=85=EA=B3=BC=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/hooks/useDashboardData.ts | 110 +++++++++++++++++++++ frontend/src/pages/DashboardPage.tsx | 130 ++----------------------- 2 files changed, 119 insertions(+), 121 deletions(-) create mode 100644 frontend/src/hooks/useDashboardData.ts diff --git a/frontend/src/hooks/useDashboardData.ts b/frontend/src/hooks/useDashboardData.ts new file mode 100644 index 0000000..13badf4 --- /dev/null +++ b/frontend/src/hooks/useDashboardData.ts @@ -0,0 +1,110 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useAppContext } from "@/contexts/AppContext"; +import { getEvents, getEventStats, getEventStatsByCamera } from "@/api/events"; +import { getNotifications } from "@/api/notifications"; +import { getCameras } from "@/api/cameras"; +import { useWebSocket } from "./useWebSocket"; +import type { + EventResponse, + EventStats, + CameraEventStats, + CameraResponse, + NotificationResponse, +} from "@/types"; + +export function useDashboardData() { + 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 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]); + + 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, + }; + 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, + ), + ); + } + }); + + useEffect(() => { + setWsConnected(connected); + }, [connected, setWsConnected]); + + const unconfirmedCount = events.filter((e) => e.status === "pending").length; + + useEffect(() => { + setUnconfirmedCount(unconfirmedCount); + }, [unconfirmedCount, setUnconfirmedCount]); + + return { + events, + stats, + cameraStats, + notifications, + loadingEvents, + loadingStats, + loadingCamera, + loadingNotif, + unconfirmedCount, + refresh, + }; +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 78d721c..d4996c5 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,5 +1,4 @@ -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"; @@ -8,117 +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, notifications, + loadingEvents, loadingStats, loadingCamera, loadingNotif, + 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, - ); - setCameraStats((prev) => - prev.map((c) => - c.camera_id === newEvent.camera_id ? { ...c, count: c.count + 1 } : c, - ), - ); - } - }); - - 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); @@ -130,12 +30,8 @@ export default function DashboardPage() {
- {/* 통계 카드 */} - - {/* 메인 콘텐츠 */}
- {/* 최신알림 */} - - {/* lg 미만에서는 하단으로: 구간별 알림현황 + 최근 오탐 신고 */}
- +
- {/* 이벤트 상세 모달 */} {selectedEvent && ( )} - - {/* 오탐신고 모달 */} {falseAlarmEvent && ( Date: Mon, 18 May 2026 15:11:20 +0900 Subject: [PATCH 12/31] =?UTF-8?q?feat:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EB=AA=A8=EB=8B=AC=20=EC=98=81=EC=97=AD?= =?UTF-8?q?=EB=B3=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/dashboard/EventDetailModal.tsx | 149 ++---------------- .../components/dashboard/EventInfoPanel.tsx | 143 +++++++++++++++++ .../components/dashboard/EventVideoPanel.tsx | 16 ++ 3 files changed, 175 insertions(+), 133 deletions(-) create mode 100644 frontend/src/components/dashboard/EventInfoPanel.tsx create mode 100644 frontend/src/components/dashboard/EventVideoPanel.tsx diff --git a/frontend/src/components/dashboard/EventDetailModal.tsx b/frontend/src/components/dashboard/EventDetailModal.tsx index e6190f5..7b4d620 100644 --- a/frontend/src/components/dashboard/EventDetailModal.tsx +++ b/frontend/src/components/dashboard/EventDetailModal.tsx @@ -1,8 +1,9 @@ import { useState } from "react"; -import { X, Clock, MapPin, Zap, Video } from "lucide-react"; import type { EventResponse } from "@/types"; import { updateEventStatus, getEventById } from "@/api/events"; import FalseAlarmModal from "./FalseAlarmModal"; +import EventVideoPanel from "./EventVideoPanel"; +import EventInfoPanel, { type CompletedInfo } from "./EventInfoPanel"; interface EventDetailModalProps { event: EventResponse; @@ -10,24 +11,6 @@ interface EventDetailModalProps { onConfirmed: () => void; } -interface CompletedInfo { - type: "confirmed" | "false_alarm"; - at: string; - reason?: string; -} - -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(":"); -} - function getSeverityBadge(event: EventResponse): { label: string; cls: string } { if (event.status === "confirmed") return { label: "처리완료", cls: "bg-green-100 text-green-600" }; if (event.status === "false_alarm") return { label: "오탐", cls: "bg-gray-100 text-gray-500" }; @@ -90,120 +73,20 @@ export default function EventDetailModal({ style={{ maxHeight: "92vh" }} onClick={(e) => e.stopPropagation()} > - {/* 영상 영역 */} -
- {event.clip_url ? ( -
- - {/* 정보 패널 */} -
- {/* 헤더 */} -
-
- - Event #{event.id} - - - {badge.label} - -
- -
- - {/* 상세 정보 */} -
-
- -
-

기록시각

-

{formatHMS(event.timestamp)}

-
-
- -
- -
-

위치

-

{locationText}

-
-
- - {event.event_type && ( -
- -
-

감지유형

-

{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} -
-
+ + setShowFalseAlarm(true)} + />
diff --git a/frontend/src/components/dashboard/EventInfoPanel.tsx b/frontend/src/components/dashboard/EventInfoPanel.tsx new file mode 100644 index 0000000..81dd85b --- /dev/null +++ b/frontend/src/components/dashboard/EventInfoPanel.tsx @@ -0,0 +1,143 @@ +import { X, Clock, MapPin, Zap } from "lucide-react"; +import type { EventResponse } from "@/types"; + +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 && ( +
+ +
+

감지유형

+

{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 ? ( +
+ ); +} From 4f5eaeb9c4643946c7467f499de83c3651626b19 Mon Sep 17 00:00:00 2001 From: jihyun808 Date: Mon, 18 May 2026 15:34:12 +0900 Subject: [PATCH 13/31] =?UTF-8?q?fix:=20lint=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/eslint.config.js | 2 +- .../src/components/events/EventsFilter.tsx | 19 +---------------- .../components/events/eventFiltersConfig.ts | 17 +++++++++++++++ frontend/src/components/layout/Header.tsx | 2 +- frontend/src/components/layout/Sidebar.tsx | 2 +- frontend/src/contexts/AppContext.tsx | 19 ++--------------- frontend/src/contexts/appContextDef.ts | 15 +++++++++++++ frontend/src/hooks/useAppContext.ts | 4 ++++ frontend/src/hooks/useDashboardData.ts | 21 +++++++++++-------- frontend/src/hooks/useEventsFilter.ts | 3 +-- frontend/src/hooks/useWebSocket.ts | 6 ++++-- frontend/src/pages/EventsPage.tsx | 2 +- frontend/src/router/PrivateRoute.tsx | 8 +++++++ frontend/src/router/index.tsx | 10 ++------- 14 files changed, 70 insertions(+), 60 deletions(-) create mode 100644 frontend/src/components/events/eventFiltersConfig.ts create mode 100644 frontend/src/contexts/appContextDef.ts create mode 100644 frontend/src/hooks/useAppContext.ts create mode 100644 frontend/src/router/PrivateRoute.tsx 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/src/components/events/EventsFilter.tsx b/frontend/src/components/events/EventsFilter.tsx index 938bf4b..469edd3 100644 --- a/frontend/src/components/events/EventsFilter.tsx +++ b/frontend/src/components/events/EventsFilter.tsx @@ -1,23 +1,6 @@ import { Search, ChevronDown } from "lucide-react"; import { EVENT_TYPE_OPTIONS } from "@/constants/eventTypes"; - -export interface EventFilters { - search: string; - period: "all" | "today" | "week" | "month"; - type: string; - cameraId: string; - status: string; - station: string; -} - -export const DEFAULT_FILTERS: EventFilters = { - search: "", - period: "all", - type: "", - cameraId: "", - status: "", - station: "", -}; +import { DEFAULT_FILTERS, type EventFilters } from "./eventFiltersConfig"; interface EventsFilterProps { filters: EventFilters; diff --git a/frontend/src/components/events/eventFiltersConfig.ts b/frontend/src/components/events/eventFiltersConfig.ts new file mode 100644 index 0000000..6199275 --- /dev/null +++ b/frontend/src/components/events/eventFiltersConfig.ts @@ -0,0 +1,17 @@ +export interface EventFilters { + search: string; + period: "all" | "today" | "week" | "month"; + type: string; + cameraId: string; + status: string; + station: string; +} + +export const DEFAULT_FILTERS: EventFilters = { + search: "", + period: "all", + type: "", + cameraId: "", + status: "", + station: "", +}; diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index e6a5acb..511984a 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -2,7 +2,7 @@ import { Settings } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { useLocation, useNavigate } from "react-router-dom"; -import { useAppContext } from "@/contexts/AppContext"; +import { useAppContext } from "@/hooks/useAppContext"; function getAvatarLabel(): string { const token = localStorage.getItem("token") || sessionStorage.getItem("token"); diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index adc4e33..88fb363 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -6,7 +6,7 @@ import { Settings, Shield, } from "lucide-react"; -import { useAppContext } from "@/contexts/AppContext"; +import { useAppContext } from "@/hooks/useAppContext"; const navItems = [ { to: "/dashboard", icon: LayoutDashboard, label: "대시보드" }, diff --git a/frontend/src/contexts/AppContext.tsx b/frontend/src/contexts/AppContext.tsx index 73e42d3..67ab869 100644 --- a/frontend/src/contexts/AppContext.tsx +++ b/frontend/src/contexts/AppContext.tsx @@ -1,19 +1,6 @@ -import { createContext, useContext, useState } from "react"; +import { useState } 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 { AppContext } from "./appContextDef"; export function AppProvider({ children }: { children: ReactNode }) { const [wsConnected, setWsConnected] = useState(false); @@ -27,5 +14,3 @@ export function AppProvider({ children }: { children: ReactNode }) { ); } - -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..4e91e44 --- /dev/null +++ b/frontend/src/contexts/appContextDef.ts @@ -0,0 +1,15 @@ +import { createContext } from "react"; + +export interface AppContextValue { + wsConnected: boolean; + setWsConnected: (v: boolean) => void; + unconfirmedCount: number; + setUnconfirmedCount: (v: number) => void; +} + +export const AppContext = createContext({ + wsConnected: false, + setWsConnected: () => {}, + unconfirmedCount: 0, + setUnconfirmedCount: () => {}, +}); 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 index 13badf4..990a02f 100644 --- a/frontend/src/hooks/useDashboardData.ts +++ b/frontend/src/hooks/useDashboardData.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useRef } from "react"; -import { useAppContext } from "@/contexts/AppContext"; +import { useAppContext } from "@/hooks/useAppContext"; import { getEvents, getEventStats, getEventStatsByCamera } from "@/api/events"; import { getNotifications } from "@/api/notifications"; import { getCameras } from "@/api/cameras"; @@ -24,12 +24,7 @@ export function useDashboardData() { const [loadingNotif, setLoadingNotif] = useState(true); const cameraMapRef = useRef>(new Map()); - const refresh = useCallback(async () => { - setLoadingEvents(true); - setLoadingStats(true); - setLoadingCamera(true); - setLoadingNotif(true); - + const doFetch = useCallback(async () => { const [camResult, evResult, statsResult, camStatsResult, notifResult] = await Promise.allSettled([ getCameras(), @@ -62,9 +57,17 @@ export function useDashboardData() { setLoadingNotif(false); }, []); + const refresh = useCallback(async () => { + setLoadingEvents(true); + setLoadingStats(true); + setLoadingCamera(true); + setLoadingNotif(true); + await doFetch(); + }, [doFetch]); + useEffect(() => { - refresh(); - }, [refresh]); + doFetch(); + }, [doFetch]); const { connected } = useWebSocket((msg) => { if (msg.type === "NEW_EVENT") { diff --git a/frontend/src/hooks/useEventsFilter.ts b/frontend/src/hooks/useEventsFilter.ts index 8cbf095..1236aa0 100644 --- a/frontend/src/hooks/useEventsFilter.ts +++ b/frontend/src/hooks/useEventsFilter.ts @@ -1,6 +1,5 @@ import { useState, useMemo } from "react"; -import { DEFAULT_FILTERS } from "@/components/events/EventsFilter"; -import type { EventFilters } from "@/components/events/EventsFilter"; +import { DEFAULT_FILTERS, type EventFilters } from "@/components/events/eventFiltersConfig"; import type { EventResponse } from "@/types"; export function useEventsFilter(allEvents: EventResponse[]) { diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index ba82855..2544c71 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; const WS_URL = import.meta.env.VITE_WS_URL as string; @@ -11,7 +11,9 @@ export function useWebSocket(onMessage: (msg: WsMessage) => void): { connected: boolean; } { const onMessageRef = useRef(onMessage); - onMessageRef.current = onMessage; // 매 렌더마다 최신 콜백으로 갱신 + useLayoutEffect(() => { + onMessageRef.current = onMessage; + }); const [connected, setConnected] = useState(false); diff --git a/frontend/src/pages/EventsPage.tsx b/frontend/src/pages/EventsPage.tsx index c5b085e..33b8516 100644 --- a/frontend/src/pages/EventsPage.tsx +++ b/frontend/src/pages/EventsPage.tsx @@ -5,7 +5,7 @@ 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 { useAppContext } from "@/contexts/AppContext"; +import { useAppContext } from "@/hooks/useAppContext"; import { useEventsData } from "@/hooks/useEventsData"; import { useEventsFilter } from "@/hooks/useEventsFilter"; import type { EventResponse } from "@/types"; 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 d4d8999..868bfdf 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -1,16 +1,10 @@ -import { createBrowserRouter, Navigate } from "react-router-dom"; +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 type { ReactNode } from "react"; - -function PrivateRoute({ children }: { children: ReactNode }) { - const token = localStorage.getItem("token") || sessionStorage.getItem("token"); - if (!token) return ; - return <>{children}; -} +import PrivateRoute from "./PrivateRoute"; export const router = createBrowserRouter([ { From 9f3cdc31228bd53bfa53c42f26d1cb48c75f2230 Mon Sep 17 00:00:00 2001 From: jihyun808 Date: Mon, 18 May 2026 15:36:55 +0900 Subject: [PATCH 14/31] =?UTF-8?q?chore:=20=EA=B0=80=EC=9E=85=20=EC=98=81?= =?UTF-8?q?=EC=96=B4=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/auth/RegisterForm.tsx | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/auth/RegisterForm.tsx b/frontend/src/components/auth/RegisterForm.tsx index b090d3d..4cd6600 100644 --- a/frontend/src/components/auth/RegisterForm.tsx +++ b/frontend/src/components/auth/RegisterForm.tsx @@ -24,7 +24,11 @@ export default function RegisterForm({ onLogin }: RegisterFormProps) { } setLoading(true); try { - await api.post("/api/auth/register", { employee_id: employeeId, email, password }); + await api.post("/api/auth/register", { + employee_id: employeeId, + email, + password, + }); setSuccess("가입이 완료되었습니다. 로그인해 주세요."); setTimeout(onLogin, 1500); } catch (err) { @@ -45,7 +49,9 @@ export default function RegisterForm({ onLogin }: RegisterFormProps) {
- +
- +
- +
- + - {loading ? "가입 중..." : "가입하기"} + {loading ? "가입 중..." : "Sign up"}

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

From 2bc9f12bc23c4ab7dde648d7e195a3ae770254d6 Mon Sep 17 00:00:00 2001 From: jihyun808 Date: Thu, 21 May 2026 22:55:05 +0900 Subject: [PATCH 15/31] =?UTF-8?q?feat:=20api=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=A0=90=EA=B2=80=20=EB=B0=8F=20=EC=84=9C=EB=B2=84=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/events.ts | 10 +- .../components/dashboard/FalseAlarmModal.tsx | 5 +- .../components/events/EventsPagination.tsx | 79 ++------- frontend/src/hooks/useEventsData.ts | 54 ------ frontend/src/hooks/useEventsFilter.ts | 89 ---------- frontend/src/hooks/useEventsPage.ts | 158 ++++++++++++++++++ frontend/src/pages/EventsPage.tsx | 35 ++-- 7 files changed, 198 insertions(+), 232 deletions(-) delete mode 100644 frontend/src/hooks/useEventsData.ts delete mode 100644 frontend/src/hooks/useEventsFilter.ts create mode 100644 frontend/src/hooks/useEventsPage.ts diff --git a/frontend/src/api/events.ts b/frontend/src/api/events.ts index 49b0745..38c3439 100644 --- a/frontend/src/api/events.ts +++ b/frontend/src/api/events.ts @@ -3,8 +3,12 @@ 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; }) => api.get("/api/events/", { params }).then((r) => r.data); export const getEventById = (id: number) => @@ -21,7 +25,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/components/dashboard/FalseAlarmModal.tsx b/frontend/src/components/dashboard/FalseAlarmModal.tsx index 876159d..8242f6e 100644 --- a/frontend/src/components/dashboard/FalseAlarmModal.tsx +++ b/frontend/src/components/dashboard/FalseAlarmModal.tsx @@ -43,10 +43,7 @@ export default function FalseAlarmModal({ setLoading(true); try { const reason = selectedReason === "기타" ? memo.trim() : selectedReason; - await reportFalseAlarm(event.id, { - reason, - memo: selectedReason === "기타" ? memo.trim() : undefined, - }); + await reportFalseAlarm(event.id, { reason }); onSubmitted(reason); onClose(); } catch { diff --git a/frontend/src/components/events/EventsPagination.tsx b/frontend/src/components/events/EventsPagination.tsx index 6d87116..21968d7 100644 --- a/frontend/src/components/events/EventsPagination.tsx +++ b/frontend/src/components/events/EventsPagination.tsx @@ -1,107 +1,56 @@ import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react"; interface EventsPaginationProps { - total: number; - page: number; // 1-based + page: number; pageSize: number; + hasNextPage: boolean; onPageChange: (page: number) => void; onPageSizeChange: (size: number) => void; } const PAGE_SIZE_OPTIONS = [8, 16, 32]; -function getPageNumbers(page: number, totalPages: number): (number | "...")[] { - if (totalPages <= 7) { - return Array.from({ length: totalPages }, (_, i) => i + 1); - } - - const delta = 2; - const left = Math.max(2, page - delta); - const right = Math.min(totalPages - 1, page + delta); - const middle: number[] = []; - for (let i = left; i <= right; i++) middle.push(i); - - const result: (number | "...")[] = [1]; - if (left > 2) result.push("..."); - result.push(...middle); - if (right < totalPages - 1) result.push("..."); - if (totalPages > 1) result.push(totalPages); - return result; -} - export default function EventsPagination({ - total, page, pageSize, + hasNextPage, onPageChange, onPageSizeChange, }: EventsPaginationProps) { - const totalPages = Math.max(1, Math.ceil(total / pageSize)); - const startItem = total === 0 ? 0 : (page - 1) * pageSize + 1; - const endItem = Math.min(page * pageSize, total); - const pageNumbers = getPageNumbers(page, totalPages); - return (
- {/* 좌측: 건수 표시 */}

- 총 {total}건 중 {startItem}-{endItem} 표시 + {page}페이지

- {/* 중앙: 페이지 버튼 */} -
+
- - {pageNumbers.map((p, i) => - p === "..." ? ( - - ... - - ) : ( - - ), - )} - + + {page} +
- {/* 우측: 페이지당 건수 */}
페이지당
update({ search: e.target.value })} + value={searchInput} + onChange={(e) => setSearchInput(e.target.value)} placeholder="EV-번호, 역이름, 게이트, CAM-번호 검색..." className="w-full pl-9 pr-4 py-2 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 text-sm text-gray-700 dark:text-gray-300 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#4B73F7]" /> diff --git a/frontend/src/constants/eventTypes.ts b/frontend/src/constants/eventTypes.ts index f3760e2..81996c6 100644 --- a/frontend/src/constants/eventTypes.ts +++ b/frontend/src/constants/eventTypes.ts @@ -15,6 +15,7 @@ export const EVENT_TYPE_OPTIONS = [ { value: "crawling", label: "기어서 통과" }, { value: "unpaid", label: "태그 없이 통행" }, { value: "emergencydoor", label: "비상문 진입" }, + { value: "unknown", label: "알 수 없음" }, ]; export function labelEventType(raw: string): string { diff --git a/frontend/src/contexts/AppContext.tsx b/frontend/src/contexts/AppContext.tsx index 5c5876c..2b472e9 100644 --- a/frontend/src/contexts/AppContext.tsx +++ b/frontend/src/contexts/AppContext.tsx @@ -5,7 +5,6 @@ import { AppContext } from "./appContextDef"; import type { WsEventListener } from "./appContextDef"; import { useWebSocket } from "@/hooks/useWebSocket"; import type { EventResponse } from "@/types"; -import { normalizeEvent } from "@/api/transform"; import { getEventStats } from "@/api/events"; export function AppProvider({ children }: { children: ReactNode }) { @@ -29,7 +28,7 @@ export function AppProvider({ children }: { children: ReactNode }) { const { connected } = useWebSocket((msg) => { if (msg.type !== "NEW_EVENT") return; - const event = normalizeEvent(msg.data as EventResponse); + const event = msg.data as EventResponse; setUnconfirmedCount((prev) => prev + 1); diff --git a/frontend/src/hooks/useEventsPage.ts b/frontend/src/hooks/useEventsPage.ts index 8718ad3..8ac48e2 100644 --- a/frontend/src/hooks/useEventsPage.ts +++ b/frontend/src/hooks/useEventsPage.ts @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { getEvents } from "@/api/events"; import { getCameras } from "@/api/cameras"; -import { useWebSocket } from "./useWebSocket"; +import { useAppContext } from "./useAppContext"; import type { EventResponse, CameraResponse } from "@/types"; import { DEFAULT_FILTERS, type EventFilters } from "@/components/events/eventFiltersConfig"; @@ -20,7 +20,8 @@ function periodToDates(period: EventFilters["period"]): { date_from?: string; da return { date_from: from.toISOString() }; } -export function useEventsPage(setWsConnected: (v: boolean) => void) { +export function useEventsPage() { + const { subscribeWsEvent } = useAppContext(); const [filters, setFilters] = useState(DEFAULT_FILTERS); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(8); @@ -130,15 +131,11 @@ export function useEventsPage(setWsConnected: (v: boolean) => void) { })); }, [filters]); - const { connected } = useWebSocket((msg) => { - if (msg.type === "NEW_EVENT") { - doFetch(filters, page, pageSize); - } - }); - useEffect(() => { - setWsConnected(connected); - }, [connected, setWsConnected]); + return subscribeWsEvent(() => { + doFetch(filters, page, pageSize); + }); + }, [subscribeWsEvent, doFetch, filters, page, pageSize]); return { filters, diff --git a/frontend/src/pages/EventsPage.tsx b/frontend/src/pages/EventsPage.tsx index fbffe45..cb5c12c 100644 --- a/frontend/src/pages/EventsPage.tsx +++ b/frontend/src/pages/EventsPage.tsx @@ -5,12 +5,10 @@ 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 { useAppContext } from "@/hooks/useAppContext"; import { useEventsPage } from "@/hooks/useEventsPage"; import type { EventResponse } from "@/types"; export default function EventsPage() { - const { setWsConnected } = useAppContext(); const { filters, handleFiltersChange, @@ -25,7 +23,7 @@ export default function EventsPage() { stationOptions, refetch, exportAll, - } = useEventsPage(setWsConnected); + } = useEventsPage(); const [selectedEvent, setSelectedEvent] = useState(null); return ( From 7e283de2a973a3d9d6cd6e14cd2707bff9b614a1 Mon Sep 17 00:00:00 2001 From: jihyun808 Date: Sat, 23 May 2026 06:21:01 +0900 Subject: [PATCH 26/31] =?UTF-8?q?fix:=20=ED=86=B5=EA=B3=84=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20API=20=EB=B0=8F=20UI=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 통계 페이지 API 연동 수정 - 안내 문구 추가 - 대시보드 텍스트 색상 수정 --- .../src/components/dashboard/CameraStats.tsx | 17 ++-- .../src/components/stats/StatSummaryCards.tsx | 40 ++++++--- frontend/src/constants/eventTypes.ts | 2 - frontend/src/pages/StatsPage.tsx | 90 +++++++++++++++---- 4 files changed, 114 insertions(+), 35 deletions(-) diff --git a/frontend/src/components/dashboard/CameraStats.tsx b/frontend/src/components/dashboard/CameraStats.tsx index e4773b9..cf5fd14 100644 --- a/frontend/src/components/dashboard/CameraStats.tsx +++ b/frontend/src/components/dashboard/CameraStats.tsx @@ -10,20 +10,27 @@ export default function CameraStats({ data, loading }: CameraStatsProps) { return (
-

역별 알림현황

+

+ 역별 알림현황 +

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

데이터가 없습니다.

+

+ 데이터가 없습니다. +

) : (
{sorted.map((row) => ( diff --git a/frontend/src/components/stats/StatSummaryCards.tsx b/frontend/src/components/stats/StatSummaryCards.tsx index 53d7c99..549c366 100644 --- a/frontend/src/components/stats/StatSummaryCards.tsx +++ b/frontend/src/components/stats/StatSummaryCards.tsx @@ -1,7 +1,8 @@ -import type { EventStats } from "@/types"; +import type { EventStats, EventResponse } from "@/types"; interface Props { stats: EventStats | null; + events: EventResponse[]; avgDaily: number | null; avgProcessMin: number | null; loading: boolean; @@ -10,7 +11,7 @@ interface Props { const cards = [ { key: "today_total" as const, - label: "총 발생 건수", + label: "누적 발생 건수", color: "text-red-400", bg: "bg-red-50", suffix: "건", @@ -38,18 +39,34 @@ const cards = [ }, ]; -export default function StatSummaryCards({ stats, avgDaily, avgProcessMin, loading }: Props) { +export default function StatSummaryCards({ + stats, + events, + avgDaily, + avgProcessMin, + loading, +}: Props) { const falseAlarmRate = - stats && stats.today_total > 0 - ? ((stats.false_alarm / stats.today_total) * 100).toFixed(1) + 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 (!stats) return "—"; - if (key === "today_total") return stats.today_total.toString(); - if (key === "avg_daily") return avgDaily !== null ? avgDaily.toFixed(1) : "—"; + 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) : "—"; + if (key === "avg_process") + return avgProcessMin !== null ? avgProcessMin.toFixed(1) : "—"; return "—"; }; @@ -57,7 +74,10 @@ export default function StatSummaryCards({ stats, avgDaily, avgProcessMin, loadi return (
{cards.map((c) => ( -
+
))}
); diff --git a/frontend/src/constants/eventTypes.ts b/frontend/src/constants/eventTypes.ts index 81996c6..b2d40bb 100644 --- a/frontend/src/constants/eventTypes.ts +++ b/frontend/src/constants/eventTypes.ts @@ -14,8 +14,6 @@ export const EVENT_TYPE_OPTIONS = [ { value: "jump", label: "점프 통과" }, { value: "crawling", label: "기어서 통과" }, { value: "unpaid", label: "태그 없이 통행" }, - { value: "emergencydoor", label: "비상문 진입" }, - { value: "unknown", label: "알 수 없음" }, ]; export function labelEventType(raw: string): string { diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index 8705d20..95670f0 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -8,6 +8,7 @@ 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, @@ -15,7 +16,7 @@ import { buildFalseAlarmData, DAILY_DAYS, } from "@/lib/stats"; -import type { EventResponse, EventStats, CameraEventStats } from "@/types"; +import type { EventResponse, EventStats, CameraEventStats, CameraResponse } from "@/types"; export default function StatsPage() { const [events, setEvents] = useState([]); @@ -26,14 +27,28 @@ export default function StatsPage() { useEffect(() => { const fetch = async () => { setLoading(true); - const [evResult, statsResult, camStatsResult] = await Promise.allSettled([ + 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") setCameraStats(camStatsResult.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 ?? "", + })) + ); + } setLoading(false); }; fetch(); @@ -45,11 +60,18 @@ export default function StatsPage() { const falseAlarmData = useMemo(() => buildFalseAlarmData(events), [events]); const avgDaily = useMemo(() => { - const days = Object.values(dailyData); - const total = days.reduce((s, v) => s + v, 0); - const activeDays = days.filter((v) => v > 0).length; - return activeDays > 0 ? total / activeDays : null; - }, [dailyData]); + 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( @@ -57,7 +79,10 @@ export default function StatsPage() { ); 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()); + return ( + sum + + (new Date(e.handled_at!).getTime() - new Date(e.timestamp).getTime()) + ); }, 0); return totalMs / handled.length / 60000; // ms → 분 }, [events]); @@ -68,15 +93,30 @@ export default function StatsPage() {
+ {/* 데이터 범위 안내 */} +

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

+ {/* 요약 카드 */} - + {/* 일별 추이 + 감지 유형 */}
- 일별 발생 추이 - 최근 {DAILY_DAYS}일 + + 일별 발생 추이 + + + 최근 {DAILY_DAYS}일 +
{loading ? (
@@ -87,7 +127,9 @@ export default function StatsPage() {
- 감지 유형 비율 + + 감지 유형 비율 + 전체 기간
{loading ? ( @@ -102,7 +144,9 @@ export default function StatsPage() {
- 시간대별 발생 분포 + + 시간대별 발생 분포 + 0시 — 23시
{loading ? ( @@ -114,12 +158,17 @@ export default function StatsPage() {
- 오탐신고 현황 + + 오탐신고 현황 +
{loading ? (
{Array.from({ length: 4 }).map((_, i) => ( -
+
))}
) : ( @@ -131,12 +180,17 @@ export default function StatsPage() { {/* 역별/게이트별 발생 순위 */}
- 역별 / 게이트별 발생 순위 + + 역별 / 게이트별 발생 순위 +
{loading ? (
{Array.from({ length: 5 }).map((_, i) => ( -
+
))}
) : ( From d8cbabf55478dc4f34a6c63dcd656fac4f7dc3d9 Mon Sep 17 00:00:00 2001 From: jihyun808 Date: Sat, 23 May 2026 06:32:27 +0900 Subject: [PATCH 27/31] =?UTF-8?q?docs:=20README=20=EB=B0=8F=20API=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/.env.example | 4 +- frontend/API.md | 121 ++++++++++++++-------------- frontend/CLAUDE.md | 115 +++++++++++++++++--------- frontend/README.md | 96 +++++++++------------- frontend/src/hooks/useEventsPage.ts | 3 +- 5 files changed, 177 insertions(+), 162 deletions(-) diff --git a/frontend/.env.example b/frontend/.env.example index d909022..34a2b43 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_API_BASE_URL=http주소 +VITE_WS_URL=ws주소 diff --git a/frontend/API.md b/frontend/API.md index 9b8a5bd..ac480d6 100644 --- a/frontend/API.md +++ b/frontend/API.md @@ -11,8 +11,6 @@ - [cameras](#cameras) - [events](#events) - [notifications](#notifications) -- [통계 화면 구현 가능성](#통계-화면-구현-가능성) -- [미사용 API 활용 방안](#미사용-api-활용-방안) --- @@ -33,16 +31,16 @@ | Method | Endpoint | 프론트 연동 | 파일 | | ------ | -------------------- | ----------- | ------------------------- | -| POST | `/api/auth/register` | ❌ 미사용 | — | +| POST | `/api/auth/register` | ✅ | `src/pages/LoginPage.tsx` | | POST | `/api/auth/login` | ✅ | `src/pages/LoginPage.tsx` | -| POST | `/api/auth/find-pw` | ❌ 미사용 | — | +| POST | `/api/auth/find-pw` | ✅ | `src/pages/LoginPage.tsx` | ### POST /api/auth/login ```ts // Request { - email: string; + employee_id: string; password: string; } @@ -54,15 +52,25 @@ 로그인 성공 시 토큰을 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/` | ❌ 미사용 | — | -| PATCH | `/api/cameras/{camera_id}/toggle` | ❌ 미사용 | — | +| 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/ @@ -71,11 +79,14 @@ interface CameraResponse { id: number; location: string; // 게이트 번호 (예: "1번 게이트") - station_name: string; // 역 이름 (예: "수원역") + station_name: string; // 역 이름 (예: "강남역") is_active: boolean; } +[]; ``` +> ⚠️ 백엔드 ORDER BY 없음 — 새로고침마다 순서 변동 가능. 백엔드 정렬 추가 요청 중. + 이벤트 API 응답에는 `camera_id` 만 있으므로, 이 API로 카메라 맵을 만든 뒤 이벤트와 조인하여 역이름/게이트 표시. --- @@ -98,8 +109,13 @@ interface CameraResponse { // 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 @@ -107,28 +123,29 @@ interface EventResponse { id: number; camera_id: number; timestamp: string; // ISO 8601 - clip_url: string | null; // S3 영상 URL + clip_url: string | null; track_id: number | null; confidence: number | null; // 0.0 ~ 1.0 status: "pending" | "confirmed" | "false_alarm"; - description?: string; // AI 감지 설명 — 백엔드 포함 여부 미확정 - appearance_tags?: string[]; // 인상착의 태그 — 백엔드 포함 여부 미확정 - event_type?: string; // 감지 유형 — 백엔드 포함 여부 미확정 - assigned_to?: string; // 담당자 — 백엔드 포함 여부 미확정 -} + event_type: string; // tailgating | jump | crawling | unpaid | unknown + reason: string | null; // 오탐 사유 (false_alarm 시) + handled_by: number | null; // 처리한 관리자 내부 ID + handled_at: string | null; // ISO 8601 +}[] ``` -> **미확정 필드**: `description`, `appearance_tags`, `event_type`, `assigned_to` 는 실제 응답 포함 여부 백엔드 확인 필요. 현재 프론트는 optional로 선언 후 없으면 fallback 처리. +> 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; + today_total: number; // 오늘 발생 건수 + pending: number; // 전체 미처리 + confirmed: number; // 전체 처리완료 + false_alarm: number; // 전체 오탐 } ``` @@ -136,27 +153,27 @@ interface EventStats { ```ts // Response -interface CameraEventStats { +{ camera_id: number; - station_name: string; - location: string; count: number; } []; ``` +> ⚠️ `station_name`, `location` 미포함 — 프론트에서 `GET /api/cameras/` 결과로 조인하여 표시. + ### POST /api/events/{event_id}/false-alarm ```ts // Request -{ reason: string; memo?: string } +{ + reason: string; +} // 사전 정의 reason 값 (FalseAlarmModal 기준) // "기기 오작동" | "노인 무임혜택 미인식" | "장애인 혜택 미인식" | "기타" ``` -> **미확정**: `reason`, `memo` 필드명 백엔드 확정 필요. - ### PATCH /api/events/{event_id}/status ```ts @@ -170,11 +187,11 @@ interface CameraEventStats { ## 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` | ❌ 미사용 | — | 전체 읽음 처리 | +| 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/ @@ -186,36 +203,20 @@ interface CameraEventStats { interface NotificationResponse { id: number; event_id: number; - sent_at: string; // ISO 8601 - read_at: string | null; // null = 미읽음 - event?: EventResponse; // 백엔드 embed 여부 미확정 + sent_at: string; // ISO 8601 + read_at: string | null; // null = 미읽음 }[] ``` --- -## 통계 화면 구현 가능성 - -| 섹션 | 가능 여부 | 방법 | 비고 | -| --------------------------------------- | --------- | ----------------------------------------------- | ------------------------------------------------------- | -| 총 발생 / 미확인 / 처리완료 / 오탐 카드 | ✅ | `GET /api/events/stats` | | -| 오탐율 계산 | ✅ | `false_alarm / today_total` 프론트 계산 | | -| 역별 / 게이트별 발생 순위 | ✅ | `GET /api/events/stats/by-camera` | | -| 시간대별 발생 분포 | ✅ 조건부 | `GET /api/events/` 대량 fetch 후 timestamp 집계 | 데이터 증가 시 성능 고려 필요 | -| 일별 발생 추이 | ✅ 조건부 | `GET /api/events/` 날짜별 집계 | 동일 | -| 감지 유형 비율 (파이 차트) | ⚠️ 미확정 | `event_type` 필드 집계 | 백엔드 `event_type` 응답 포함 여부 확인 필요 | -| 오탐신고 사유별 현황 | ⚠️ 미확정 | false_alarm 이벤트의 `reason` 집계 | `EventResponse`에 `reason` 필드 없음 — 백엔드 추가 필요 | -| 전일 / 전월 비교 수치 | ❌ | — | 기간 비교 파라미터 또는 별도 API 필요 | -| 평균 처리 시간 | ❌ | — | `resolved_at` 필드 없음 — 백엔드 추가 필요 | - ---- - -## 미사용 API 활용 방안 +## 백엔드 추가 요청 대기 중 -| Endpoint | 활용 가능 위치 | -| ---------------------------------- | ---------------------------------------- | -| `POST /api/auth/register` | 관리자 계정 생성 기능 (SettingsPage) | -| `POST /api/auth/find-pw` | 로그인 페이지 "비밀번호 찾기" 링크 | -| `POST /api/cameras/` | SettingsPage 카메라 등록 폼 | -| `PATCH /api/cameras/{id}/toggle` | SettingsPage 카메라 활성화/비활성화 토글 | -| `POST /api/notifications/read-all` | Header 알림 패널 "전체 읽음" 버튼 | +| 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/CLAUDE.md b/frontend/CLAUDE.md index a523ee6..17c2f5c 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -20,12 +20,14 @@ 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` ✅ 구현완료 @@ -33,10 +35,13 @@ No test runner is configured yet. - `/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`) @@ -44,20 +49,26 @@ Dashboard pages share a consistent layout: `` (left, fixed w-64) + `< - WebSocket `NEW_EVENT` → 카메라 정보 조인 후 prepend to `events[]` (최대 10건) + optimistic stats increment ### Data Flow (StatsPage) -- 3개 API 병렬 fetch: `GET /api/events/?limit=1000`, `GET /api/events/stats`, `GET /api/events/stats/by-camera` + +- 4개 API 병렬 fetch: `GET /api/events/?limit=1000`, `GET /api/events/stats`, `GET /api/events/stats/by-camera`, `GET /api/cameras/` - 이벤트 목록을 `useMemo`로 가공: 날짜별/시간대별/유형별/오탐사유별 집계 -- 평균 처리 시간: `confirmed` 이벤트의 `handled_at - timestamp` 평균 (분 단위) -- `event_type`, `reason` 필드 실데이터 정상 수신 중 → EventTypeChart, FalseAlarmTable 실데이터 표시 +- 상단 카드: 총 발생(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` → 백엔드 전송 +- **서버사이드 필터**: `status`, `type`, `camera_id`, `date_from`/`date_to`, `search` → 백엔드 전송 - **서버사이드 페이지네이션**: `offset=(page-1)*pageSize`, `limit=pageSize+1` (hasNextPage 감지) -- **클라이언트 필터**: `search`, `station` → 현재 페이지 내에서만 적용 (백엔드 미지원, 아래 대기 항목 참고) -- WebSocket `NEW_EVENT` → 현재 필터+페이지 그대로 서버 re-fetch (필터 매칭 보장) +- **클라이언트 필터**: `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에 저장 후 표시 @@ -69,18 +80,20 @@ Dashboard pages share a consistent layout: `` (left, fixed w-64) + `< - `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`) — Header·Sidebar에서 읽고 DashboardPage·EventsPage에서 설정 -- `src/hooks/` — `useWebSocket` (auto-reconnect, 3s delay, `connected` 반환, per-effect `let active` 패턴), `useEventsPage` (EventsPage 전용 통합 훅) +- `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 @@ -95,17 +108,20 @@ Dashboard pages share a consistent layout: `` (left, fixed w-64) + `< ### 구현된 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`, `limit`, `offset`) + +- `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}` — 이벤트 단건 조회 ✅ 프론트 연동 @@ -113,36 +129,57 @@ Dashboard pages share a consistent layout: `` (left, fixed w-64) + `< - `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 + (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 } +{ + (today_total, pending, confirmed, false_alarm); +} ``` ### EventStatus 값 + 백엔드 확정 상태값 3종: `pending` (미처리) | `confirmed` (처리완료) | `false_alarm` (오탐) + - `pending` → 상세보기·오탐신고 버튼 활성화, 빨간 dot 표시 - `confirmed` / `false_alarm` → 버튼 없음, 완료 문구만 표시 ### AI 이벤트 타입 값 (inference.py 출력 → event_type 필드) -AI가 내보내는 `events[].name` 값이 그대로 `event_type`으로 저장됨. -프론트 `EVENT_TYPE_LABEL` 매핑: `tailgating | jump | crawling | unpaid | emergencydoor | normal | unknown` + +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 리다이렉트) @@ -156,19 +193,22 @@ AI가 내보내는 `events[].name` 값이 그대로 `event_type`으로 저장됨 - FalseAlarmModal — `onSubmitted(reason)` 으로 reason 반환 - WebSocket NEW_EVENT 시 stats + cameraStats 낙관적 업데이트 - EventsPage (전체 발생내역) - - 서버사이드 필터: status, type, camera_id, 기간(date_from/date_to) + - 서버사이드 필터: status, type, camera_id, 기간(date_from/date_to), search - 서버사이드 페이지네이션: offset+limit, hasNextPage 방식 - - 클라이언트 필터: search, station (현재 페이지 내) - - WebSocket NEW_EVENT → 현재 필터+페이지 re-fetch + - 클라이언트 필터: station (현재 페이지 내, 백엔드 파라미터 대기 중) + - search 입력 400ms 디바운스 + - WebSocket NEW_EVENT → AppContext subscribeWsEvent 경유 re-fetch (전용 WS 없음) - CSV 내보내기 → limit=10000 전체 재조회 후 export - EventDetailModal 재사용 -- StatsPage (ECharts 통계 시각화) — 전 차트 실데이터 - - StatSummaryCards — 총발생/일평균/오탐율/평균처리시간 4개 카드 + - 테이블 table-fixed 레이아웃 — 페이지 이동 시 컬럼 너비 고정 + - 담당자 컬럼: handled_by (숫자 ID) 표시 +- StatsPage (ECharts 통계 시각화) — 최근 1000건 기준 + - StatSummaryCards — 누적발생(events.length)/일평균/오탐율/평균처리시간 4개 카드 (모두 events 배열 기준) - DailyTrendChart — 최근 12일 라인 차트 - - EventTypeChart — 감지 유형 비율 도넛 차트 (event_type 실데이터) - - HourlyDistributionChart — 시간대별 발생 분포 가로 바 차트 - - FalseAlarmTable — 오탐 사유별 건수 (reason 실데이터) - - CameraRankingTable — 역별/게이트별 발생 순위 + - EventTypeChart — 감지 유형 비율 도넛 차트 (event_type 데이터 없으면 "데이터 없음") + - HourlyDistributionChart — 전체 기간 시간대별 발생 분포 가로 바 차트 + - FalseAlarmTable — 오탐 사유별 건수 + - CameraRankingTable — 역별/게이트별 발생 순위 (cameras API 조인으로 역이름 표시) - SettingsPage (내 프로필 탭: JWT 디코딩으로 사원번호 표시 + 로그아웃 / 카메라 관리 탭: 목록 조회 + 등록 폼 + 활성화 토글) - Auth route guard (`src/router/index.tsx` — `PrivateRoute` 컴포넌트) - Header 아바타 JWT 연동 (localStorage/sessionStorage 토큰에서 `employee_id` 디코딩, 첫 글자 표시) @@ -177,24 +217,21 @@ AI가 내보내는 `events[].name` 값이 그대로 `event_type`으로 저장됨 - API 문서 (`API.md`) ### ⚠️ 미구현 + 1. **역무원 파견** — confirm 후 버튼 비활성화(`dispatched` 로컬 state)까지만 구현. 백엔드 API 없어서 실제 파견 처리 불가. 모달 닫고 재열면 파견 상태 리셋됨 ### 백엔드 추가 요청 대기 중 -- `GET /api/events/`에 `search: Optional[str]` 파라미터 추가 → 완료되면 `useEventsPage.ts`의 `doFetch`에 `search` 파라미터 추가하고 `EventsFilter`의 search를 서버사이드로 전환. `station` 드롭다운도 동일하게 처리 가능 - -## 페이지별 버그 및 수정 이력 -### 대시보드 +- `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` 반환 처리 추가 -**프론트 수정 완료** -- WS를 `AppContext`로 이동 — 다른 페이지에서도 실시간 알림 수신 + 토스트 표시 -- `src/api/transform.ts` 추가 — 백엔드 `dismissed` 상태를 `false_alarm`으로 정규화 (백엔드 수정 시 파일 삭제) -- `useDashboardData` — cameraStats, falseAlarmEvents에 camera join 추가, false_alarm 이벤트 별도 fetch로 교체 -- `EventDetailModal` — `completedInfo` 초기값을 `event.status`로 설정해 재오픈 시 완료 문구 유지 -- `FalseAlarmList` — 데이터 소스를 notifications 필터에서 `GET /api/events/?status=false_alarm` 직접 fetch로 교체 -- 신뢰도 뱃지 3단계 통일 — `AlertItem`, `EventDetailModal`에 "낮음"(`confidence < 0.4`) 추가 -- 상단 카드 오늘 발생(노랑) / 확인 대기(빨강) 색상 교체 +### 백엔드 완료되면 프론트 후속 작업 필요한 것들 -**백엔드 수정 대기** -- `reason` 컬럼 미존재 — 오탐 사유 DB 미저장, 모달 재오픈 시 사유 사라짐 -- API/WS 응답에 `camera` 객체 미포함 — 역 이름 표시 불안정 (현재 프론트 join으로 우회 중) +- station 파라미터 → useEventsPage.ts 클라이언트 필터 제거 +- total 필드 → getEvents 반환 타입 변경 + EventsPagination 페이지 번호 UI 구현 +- stats/daily, stats/hourly → StatsPage fetch 교체, + buildDailyData/buildHourlyData 제거 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/src/hooks/useEventsPage.ts b/frontend/src/hooks/useEventsPage.ts index 8ac48e2..eb35261 100644 --- a/frontend/src/hooks/useEventsPage.ts +++ b/frontend/src/hooks/useEventsPage.ts @@ -89,8 +89,7 @@ export function useEventsPage() { setPage(1); }, []); - // station 은 서버 파라미터가 없으므로 현재 페이지 내에서 클라이언트 필터 - // search 는 서버사이드로 처리됨 + // station: 백엔드 파라미터 미지원 → 현재 페이지 내 클라이언트 필터 const displayEvents = useMemo(() => { if (!filters.station) return rawEvents; return rawEvents.filter( From b77724f861328b658af47780ae6231f187173cf8 Mon Sep 17 00:00:00 2001 From: jihyun808 Date: Sat, 23 May 2026 07:17:42 +0900 Subject: [PATCH 28/31] =?UTF-8?q?feat:=20=ED=86=B5=EA=B3=84=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20false=20alarm=20=EC=B0=A8=ED=8A=B8=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/lib/stats.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/lib/stats.ts b/frontend/src/lib/stats.ts index deccf8a..e7ab380 100644 --- a/frontend/src/lib/stats.ts +++ b/frontend/src/lib/stats.ts @@ -11,6 +11,7 @@ export function buildDailyData(events: EventResponse[]): Record 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]++; @@ -25,6 +26,7 @@ export function buildHourlyData(events: EventResponse[]): Record ]; 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")}`; @@ -36,6 +38,7 @@ export function buildHourlyData(events: EventResponse[]): Record 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; From bfda2faed9a350df7e0a0ba00a58dc23751073ad Mon Sep 17 00:00:00 2001 From: jihyun808 Date: Sat, 23 May 2026 07:32:26 +0900 Subject: [PATCH 29/31] =?UTF-8?q?fix:=20=EC=B0=A8=ED=8A=B8=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=B0=8F=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/dashboard/FalseAlarmModal.tsx | 20 ++++++++++++++----- frontend/src/pages/StatsPage.tsx | 12 ++++++----- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/dashboard/FalseAlarmModal.tsx b/frontend/src/components/dashboard/FalseAlarmModal.tsx index 8242f6e..72b9bb5 100644 --- a/frontend/src/components/dashboard/FalseAlarmModal.tsx +++ b/frontend/src/components/dashboard/FalseAlarmModal.tsx @@ -64,7 +64,9 @@ export default function FalseAlarmModal({ > {/* 헤더 */}
-

오탐신고

+

+ 오탐신고 +