From 1d78c8227f67ec519a9bb52662bdb2ed34cb5e15 Mon Sep 17 00:00:00 2001 From: CHOSOOGEUN <241898164+CHOSOOGEUN@users.noreply.github.com> Date: Sat, 23 May 2026 12:47:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend,mobile,ai):=20PR=20#30=20#31=20?= =?UTF-8?q?=EC=9D=98=20=EC=83=88=20API/=ED=97=A4=EB=8D=94=20=ED=99=9C?= =?UTF-8?q?=EC=9A=A9=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #30(station/카메라검증/정렬) + PR #31(X-Total-Count, stats 3종) 이 이미 prod 에 떠 있지만 클라이언트가 활용하지 않아 효과 0 이던 부분을 일괄 연결. frontend - types/index.ts: EventTypeStat/HourlyStat/DailyStat/EventsPagedResult 추가 - api/events.ts: getEvents 에 station 추가, getEventsPaged 신규 (X-Total-Count 헤더 파싱), getEventStatsByType/Hourly/Daily 신규 - hooks/useEventsPage.ts: station 백엔드 전달(클라 후필터 제거), getEventsPaged 사용해 total/totalPages 노출 - components/events/EventsPagination.tsx: totalPages/total props 추가, 'page / total 페이지 (총 N건)' 표시 - pages/EventsPage.tsx: total/totalPages 를 EventsPagination 에 전달 - pages/StatsPage.tsx: 차트 3종(daily/hourly/type) 을 서버 집계로 교체. 오탐 사유/평균 처리시간만 events 1000건 유지 - docs/CLAUDE.md: stale 진단(클라 station 필터, read-all, '백엔드 대기 중') 정리 ai - inference.py: POST /api/events/ payload 에 event_type 추가 (분류기 학습 전이라 line crossing 트리거 → 'unpaid' placeholder) mobile - services/eventService.ts: EventsQuery 타입화(station/search 포함), getEventsPaged + stats 3종 함수 추가 Co-Authored-By: Claude Opus 4.7 (1M context) --- ai/inference.py | 5 +- frontend/docs/CLAUDE.md | 33 ++---- frontend/src/api/events.ts | 36 ++++++- .../components/events/EventsPagination.tsx | 8 +- frontend/src/hooks/useEventsPage.ts | 31 +++--- frontend/src/pages/EventsPage.tsx | 4 + frontend/src/pages/StatsPage.tsx | 100 ++++++++++++++---- frontend/src/types/index.ts | 20 ++++ mobile/services/eventService.ts | 33 +++++- 9 files changed, 208 insertions(+), 62 deletions(-) diff --git a/ai/inference.py b/ai/inference.py index 4d4388e..e97ef0e 100644 --- a/ai/inference.py +++ b/ai/inference.py @@ -75,11 +75,14 @@ async def _report_event(self, track_id: int, confidence: float, clip_frames: lis # 1. 일단 사건 발생 동시 보고 local_clip_path = self._save_clip(clip_frames, f"{track_id}_{int(datetime.datetime.now().timestamp())}") + # 분류기(ai/classifier) 학습 전이라 line crossing 트리거를 단순 placeholder 로 매핑. + # 학습 후 EfficientNet 분류 결과(jump/crawling/tailgating/unpaid)로 교체. payload = { "camera_id": self.config.camera_id, "track_id": track_id, "confidence": round(float(confidence), 3), - "clip_url": local_clip_path + "clip_url": local_clip_path, + "event_type": "unpaid", } headers = {"Authorization": f"Bearer {token}"} diff --git a/frontend/docs/CLAUDE.md b/frontend/docs/CLAUDE.md index 17c2f5c..9beb5c4 100644 --- a/frontend/docs/CLAUDE.md +++ b/frontend/docs/CLAUDE.md @@ -59,9 +59,8 @@ Dashboard pages share a consistent layout: `` (left, fixed w-64) + `< ### Data Flow (EventsPage) - `src/hooks/useEventsPage.ts` 단일 훅이 데이터·필터·페이지네이션 모두 담당 -- **서버사이드 필터**: `status`, `type`, `camera_id`, `date_from`/`date_to`, `search` → 백엔드 전송 -- **서버사이드 페이지네이션**: `offset=(page-1)*pageSize`, `limit=pageSize+1` (hasNextPage 감지) -- **클라이언트 필터**: `station` → 현재 페이지 내에서만 적용 (백엔드 `station` 파라미터 미지원) +- **서버사이드 필터**: `status`, `type`, `camera_id`, `station`, `date_from`/`date_to`, `search` → 백엔드 전송 +- **서버사이드 페이지네이션**: `offset=(page-1)*pageSize`, `limit=pageSize`. 응답 헤더 `X-Total-Count` 로 총 건수 받아 `totalPages` 계산 (`getEventsPaged` 사용) - `search` 입력 400ms 디바운스 적용 (`EventsFilter` 로컬 state → 지연 후 onChange 호출) - WebSocket `NEW_EVENT` → AppContext `subscribeWsEvent` 구독 (전용 WS 연결 없음) - WebSocket 수신 시 현재 필터+페이지 그대로 서버 re-fetch @@ -132,7 +131,6 @@ Dashboard pages share a consistent layout: `` (left, fixed w-64) + `< - `GET /api/notifications/` — 알림 목록 ✅ 프론트 연동 - `PATCH /api/notifications/{notification_id}/read` — 읽음 처리 ✅ 프론트 연동 -- `POST /api/notifications/read-all` — 전체 읽음 처리 (프론트 미사용) ### GET /api/events/ 응답 필드 @@ -193,16 +191,15 @@ AI 분류기 실제 CLASSES: `tailgating | jump | crawling | unpaid` (4종만 - FalseAlarmModal — `onSubmitted(reason)` 으로 reason 반환 - WebSocket NEW_EVENT 시 stats + cameraStats 낙관적 업데이트 - EventsPage (전체 발생내역) - - 서버사이드 필터: status, type, camera_id, 기간(date_from/date_to), search - - 서버사이드 페이지네이션: offset+limit, hasNextPage 방식 - - 클라이언트 필터: station (현재 페이지 내, 백엔드 파라미터 대기 중) + - 서버사이드 필터: status, type, camera_id, station, 기간(date_from/date_to), search + - 서버사이드 페이지네이션: offset+limit, X-Total-Count 헤더 기반 totalPages 표시 - search 입력 400ms 디바운스 - WebSocket NEW_EVENT → AppContext subscribeWsEvent 경유 re-fetch (전용 WS 없음) - CSV 내보내기 → limit=10000 전체 재조회 후 export - EventDetailModal 재사용 - 테이블 table-fixed 레이아웃 — 페이지 이동 시 컬럼 너비 고정 - 담당자 컬럼: handled_by (숫자 ID) 표시 -- StatsPage (ECharts 통계 시각화) — 최근 1000건 기준 +- StatsPage (ECharts 통계 시각화) — 차트는 서버 집계(`/stats/by-type|hourly|daily`), 오탐 사유·평균 처리시간은 events 1000건 기준 - StatSummaryCards — 누적발생(events.length)/일평균/오탐율/평균처리시간 4개 카드 (모두 events 배열 기준) - DailyTrendChart — 최근 12일 라인 차트 - EventTypeChart — 감지 유형 비율 도넛 차트 (event_type 데이터 없으면 "데이터 없음") @@ -220,18 +217,10 @@ AI 분류기 실제 CLASSES: `tailgating | jump | crawling | unpaid` (4종만 1. **역무원 파견** — confirm 후 버튼 비활성화(`dispatched` 로컬 state)까지만 구현. 백엔드 API 없어서 실제 파견 처리 불가. 모달 닫고 재열면 파견 상태 리셋됨 -### 백엔드 추가 요청 대기 중 +### 백엔드 연동 완료 (PR #30, #31) -- `GET /api/cameras/` ORDER BY id 추가 — 현재 정렬 기준 없어 새로고침마다 순서 변동 -- `GET /api/events/`에 `station: Optional[str]` 파라미터 추가 → 완료되면 `useEventsPage.ts` 클라이언트 필터 제거 -- `GET /api/events/` 응답에 `total` 필드 추가 → 완료되면 `EventsPagination` 페이지 번호 목록 표시 가능 -- `GET /api/events/stats/daily` — 날짜별 발생 건수 (`days: int = 12`) → 완료되면 DailyTrendChart 1000건 제한 해소 -- `GET /api/events/stats/hourly` — 시간대별 발생 건수 → 완료되면 HourlyDistributionChart 1000건 제한 해소 -- 비활성 카메라(`is_active=false`) 이벤트 수신 시 `400` 반환 처리 추가 - -### 백엔드 완료되면 프론트 후속 작업 필요한 것들 - -- station 파라미터 → useEventsPage.ts 클라이언트 필터 제거 -- total 필드 → getEvents 반환 타입 변경 + EventsPagination 페이지 번호 UI 구현 -- stats/daily, stats/hourly → StatsPage fetch 교체, - buildDailyData/buildHourlyData 제거 +- ✅ `GET /api/cameras/` ORDER BY id (PR #30) +- ✅ `GET /api/events/?station=<역이름>` 서버사이드 필터 (PR #30 / useEventsPage 가 직접 전달) +- ✅ `GET /api/events/` 응답 `X-Total-Count` 헤더 (PR #31 / `getEventsPaged`) +- ✅ `GET /api/events/stats/by-type` `stats/hourly` `stats/daily?days=N` (PR #31 / StatsPage 차트는 서버 집계 사용) +- ✅ 비활성 카메라 이벤트 거부 — 미존재 404 / `is_active=false` 400 (PR #30) diff --git a/frontend/src/api/events.ts b/frontend/src/api/events.ts index 05a0381..c6893df 100644 --- a/frontend/src/api/events.ts +++ b/frontend/src/api/events.ts @@ -1,16 +1,36 @@ import api from "./axios"; -import type { EventResponse, EventStats, CameraEventStats } from "@/types"; +import type { + EventResponse, + EventStats, + CameraEventStats, + EventTypeStat, + HourlyStat, + DailyStat, + EventsPagedResult, +} from "@/types"; -export const getEvents = (params?: { +export interface EventsQuery { limit?: number; offset?: number; status?: string; camera_id?: number; type?: string; + station?: string; // 서버사이드 역이름 부분일치 (PR #30) date_from?: string; date_to?: string; search?: string; -}) => api.get("/api/events/", { params }).then((r) => r.data); +} + +export const getEvents = (params?: EventsQuery) => + api.get("/api/events/", { params }).then((r) => r.data); + +// X-Total-Count 헤더까지 같이 받는 버전 — 페이지네이션 메타 필요할 때 사용 (PR #31) +export const getEventsPaged = async (params?: EventsQuery): Promise => { + const res = await api.get("/api/events/", { params }); + const totalHeader = res.headers["x-total-count"]; + const total = totalHeader ? Number(totalHeader) : res.data.length; + return { items: res.data, total: Number.isFinite(total) ? total : res.data.length }; +}; export const getEventById = (id: number) => api.get(`/api/events/${id}`).then((r) => r.data); @@ -23,6 +43,16 @@ export const getEventStatsByCamera = () => .get("/api/events/stats/by-camera") .then((r) => r.data); +// 서버 통계 3종 (PR #31) — StatsPage 가 클라 1000건 집계 대신 사용 +export const getEventStatsByType = () => + api.get("/api/events/stats/by-type").then((r) => r.data); + +export const getEventStatsHourly = (params?: { date_from?: string; date_to?: string }) => + api.get("/api/events/stats/hourly", { params }).then((r) => r.data); + +export const getEventStatsDaily = (days = 30) => + api.get("/api/events/stats/daily", { params: { days } }).then((r) => r.data); + export const updateEventStatus = (id: number, status: string) => api.patch(`/api/events/${id}/status`, { status }).then((r) => r.data); diff --git a/frontend/src/components/events/EventsPagination.tsx b/frontend/src/components/events/EventsPagination.tsx index 21968d7..72b24ce 100644 --- a/frontend/src/components/events/EventsPagination.tsx +++ b/frontend/src/components/events/EventsPagination.tsx @@ -4,6 +4,8 @@ interface EventsPaginationProps { page: number; pageSize: number; hasNextPage: boolean; + totalPages?: number; // PR #31 — X-Total-Count 기반. 있으면 "page/total" 표시 + total?: number; // 전체 건수 onPageChange: (page: number) => void; onPageSizeChange: (size: number) => void; } @@ -14,13 +16,17 @@ export default function EventsPagination({ page, pageSize, hasNextPage, + totalPages, + total, onPageChange, onPageSizeChange, }: EventsPaginationProps) { return (

- {page}페이지 + {totalPages != null + ? `${page} / ${totalPages} 페이지${total != null ? ` (총 ${total.toLocaleString()}건)` : ""}` + : `${page}페이지`}

diff --git a/frontend/src/hooks/useEventsPage.ts b/frontend/src/hooks/useEventsPage.ts index eb35261..61ac2d1 100644 --- a/frontend/src/hooks/useEventsPage.ts +++ b/frontend/src/hooks/useEventsPage.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; -import { getEvents } from "@/api/events"; +import { getEvents, getEventsPaged } from "@/api/events"; import { getCameras } from "@/api/cameras"; import { useAppContext } from "./useAppContext"; import type { EventResponse, CameraResponse } from "@/types"; @@ -27,6 +27,7 @@ export function useEventsPage() { const [pageSize, setPageSize] = useState(8); const [rawEvents, setRawEvents] = useState([]); const [hasNextPage, setHasNextPage] = useState(false); + const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); const cameraMapRef = useRef>(new Map()); const [cameras, setCameras] = useState([]); @@ -51,21 +52,23 @@ export function useEventsPage() { const doFetch = useCallback(async (f: EventFilters, p: number, ps: number) => { setLoading(true); const { date_from, date_to } = periodToDates(f.period); - const params: Parameters[0] = { - limit: ps + 1, // 다음 페이지 존재 여부 판단용 +1 + const params: Parameters[0] = { + limit: ps, offset: (p - 1) * ps, ...(f.status ? { status: f.status } : {}), ...(f.type ? { type: f.type } : {}), ...(f.cameraId ? { camera_id: Number(f.cameraId) } : {}), + ...(f.station ? { station: f.station } : {}), // PR #30 — 백엔드 서버사이드 필터 ...(date_from ? { date_from } : {}), ...(date_to ? { date_to } : {}), ...(f.search ? { search: f.search } : {}), }; try { - const result = await getEvents(params); - setHasNextPage(result.length > ps); + const { items, total: t } = await getEventsPaged(params); + setTotal(t); + setHasNextPage(p * ps < t); setRawEvents( - result.slice(0, ps).map((e) => ({ + items.map((e) => ({ ...e, camera: cameraMapRef.current.get(e.camera_id) ?? e.camera, })) @@ -89,13 +92,12 @@ export function useEventsPage() { setPage(1); }, []); - // station: 백엔드 파라미터 미지원 → 현재 페이지 내 클라이언트 필터 - const displayEvents = useMemo(() => { - if (!filters.station) return rawEvents; - return rawEvents.filter( - (e) => e.camera?.station_name === filters.station - ); - }, [rawEvents, filters.station]); + // station 필터는 백엔드(PR #30)에서 처리되므로 클라 후필터 불필요 + const displayEvents = rawEvents; + const totalPages = useMemo( + () => Math.max(1, Math.ceil(total / pageSize)), + [total, pageSize] + ); const cameraOptions = useMemo( () => @@ -119,6 +121,7 @@ export function useEventsPage() { ...(filters.status ? { status: filters.status } : {}), ...(filters.type ? { type: filters.type } : {}), ...(filters.cameraId ? { camera_id: Number(filters.cameraId) } : {}), + ...(filters.station ? { station: filters.station } : {}), ...(date_from ? { date_from } : {}), ...(date_to ? { date_to } : {}), ...(filters.search ? { search: filters.search } : {}), @@ -146,6 +149,8 @@ export function useEventsPage() { pageSize, setPageSize: handleSetPageSize, hasNextPage, + total, + totalPages, cameraOptions, stationOptions, refetch: () => doFetch(filters, page, pageSize), diff --git a/frontend/src/pages/EventsPage.tsx b/frontend/src/pages/EventsPage.tsx index cb5c12c..652747e 100644 --- a/frontend/src/pages/EventsPage.tsx +++ b/frontend/src/pages/EventsPage.tsx @@ -19,6 +19,8 @@ export default function EventsPage() { pageSize, setPageSize, hasNextPage, + total, + totalPages, cameraOptions, stationOptions, refetch, @@ -49,6 +51,8 @@ export default function EventsPage() { page={page} pageSize={pageSize} hasNextPage={hasNextPage} + totalPages={totalPages} + total={total} onPageChange={setPage} onPageSizeChange={setPageSize} /> diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index a61f4bd..d6c6ad5 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -7,34 +7,61 @@ import EventTypeChart from "@/components/stats/EventTypeChart"; import HourlyDistributionChart from "@/components/stats/HourlyDistributionChart"; import FalseAlarmTable from "@/components/stats/FalseAlarmTable"; import CameraRankingTable from "@/components/stats/CameraRankingTable"; -import { getEvents, getEventStats, getEventStatsByCamera } from "@/api/events"; -import { getCameras } from "@/api/cameras"; import { - buildDailyData, - buildHourlyData, - buildTypeData, - buildFalseAlarmData, - DAILY_DAYS, -} from "@/lib/stats"; -import type { EventResponse, EventStats, CameraEventStats, CameraResponse } from "@/types"; + getEvents, + getEventStats, + getEventStatsByCamera, + getEventStatsByType, + getEventStatsHourly, + getEventStatsDaily, +} from "@/api/events"; +import { getCameras } from "@/api/cameras"; +import { buildFalseAlarmData, DAILY_DAYS } from "@/lib/stats"; +import { labelEventType } from "@/constants/eventTypes"; +import type { + EventResponse, + EventStats, + CameraEventStats, + CameraResponse, + EventTypeStat, + HourlyStat, + DailyStat, +} from "@/types"; + +const HOUR_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", +]; export default function StatsPage() { + // falseAlarm reason 집계 + 평균값 계산용 (서버 엔드포인트 없음) const [events, setEvents] = useState([]); const [stats, setStats] = useState(null); const [cameraStats, setCameraStats] = useState([]); + // 서버 집계 결과 (PR #31) + const [typeStats, setTypeStats] = useState([]); + const [hourlyStats, setHourlyStats] = useState([]); + const [dailyStats, setDailyStats] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { const fetch = async () => { setLoading(true); - const [evResult, statsResult, camStatsResult, camResult] = await Promise.allSettled([ - getEvents({ limit: 1000 }), - getEventStats(), - getEventStatsByCamera(), - getCameras(), - ]); + const [evResult, statsResult, camStatsResult, camResult, typeResult, hourlyResult, dailyResult] = + await Promise.allSettled([ + getEvents({ limit: 1000 }), + getEventStats(), + getEventStatsByCamera(), + getCameras(), + getEventStatsByType(), + getEventStatsHourly(), + getEventStatsDaily(DAILY_DAYS), + ]); if (evResult.status === "fulfilled") setEvents(evResult.value); if (statsResult.status === "fulfilled") setStats(statsResult.value); + if (typeResult.status === "fulfilled") setTypeStats(typeResult.value); + if (hourlyResult.status === "fulfilled") setHourlyStats(hourlyResult.value); + if (dailyResult.status === "fulfilled") setDailyStats(dailyResult.value); if (camStatsResult.status === "fulfilled") { const cameraMap = new Map( camResult.status === "fulfilled" @@ -56,9 +83,44 @@ export default function StatsPage() { fetch(); }, []); - const dailyData = useMemo(() => buildDailyData(events), [events]); - const hourlyData = useMemo(() => buildHourlyData(events), [events]); - const typeData = useMemo(() => buildTypeData(events), [events]); + // 일별 추이 — 서버 응답을 `MM/DD` 키 record 로 변환 (최근 DAILY_DAYS 일) + const dailyData = useMemo(() => { + 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; + } + dailyStats.forEach((s) => { + const d = new Date(s.day); + const key = `${d.getMonth() + 1}/${d.getDate()}`; + if (key in result) result[key] = s.count; + }); + return result; + }, [dailyStats]); + + // 시간대(서버 0~23) → 2시간 슬롯 record 로 합산 + const hourlyData = useMemo(() => { + const result: Record = Object.fromEntries(HOUR_SLOTS.map((s) => [s, 0])); + hourlyStats.forEach((h) => { + const start = Math.floor(h.hour / 2) * 2; + const key = `${String(start).padStart(2, "0")}-${String(start + 2).padStart(2, "0")}`; + if (key in result) result[key] += h.count; + }); + return result; + }, [hourlyStats]); + + // event_type → 한글 label + const typeData = useMemo(() => { + const result: Record = {}; + typeStats.forEach((t) => { + const label = labelEventType(t.event_type); + result[label] = (result[label] ?? 0) + t.count; + }); + return result; + }, [typeStats]); + + // 오탐 reason 집계 — 서버 엔드포인트 없어 events 1000건 기준 클라 집계 const falseAlarmData = useMemo(() => buildFalseAlarmData(events), [events]); const avgDaily = useMemo(() => { @@ -97,7 +159,7 @@ export default function StatsPage() {
{/* 데이터 범위 안내 */}

- ※ 통계는 최근 수집된 최대 1,000건의 이벤트를 기준으로 산출됩니다. + ※ 차트(일별·시간대별·유형별)는 서버에서 전체 데이터 기준으로 집계됩니다. 오탐 사유·평균 처리시간은 최근 1,000건 기준.

{/* 요약 카드 */} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ff05c98..2913418 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -41,6 +41,26 @@ export interface CameraEventStats { count: number; } +// 서버 통계 3종 (PR #31) +export interface EventTypeStat { + event_type: string; + count: number; +} +export interface HourlyStat { + hour: number; // 0..23 + count: number; +} +export interface DailyStat { + day: string; // YYYY-MM-DD + count: number; +} + +// 페이지네이션 메타가 붙은 events 응답 (X-Total-Count 헤더 활용) +export interface EventsPagedResult { + items: EventResponse[]; + total: number; +} + export interface NotificationResponse { id: number; event_id: number; diff --git a/mobile/services/eventService.ts b/mobile/services/eventService.ts index 72679cb..8cb97da 100644 --- a/mobile/services/eventService.ts +++ b/mobile/services/eventService.ts @@ -2,8 +2,7 @@ import api from './api'; import type { EventResponse, EventStats, CameraEventStats } from '@/types'; -/** GET /api/events/ */ -export const getEvents = (params?: { +export interface EventsQuery { limit?: number; offset?: number; status?: string; @@ -11,9 +10,22 @@ export const getEvents = (params?: { date_from?: string; // YYYY-MM-DD date_to?: string; // YYYY-MM-DD type?: string; // 감지 유형 필터 -}) => + station?: string; // 역이름 부분일치 (백엔드 PR #30) + search?: string; // 통합 검색 (백엔드 PR #26) +} + +/** GET /api/events/ */ +export const getEvents = (params?: EventsQuery) => api.get('/api/events/', { params }).then((r) => r.data); +/** GET /api/events/ + 총 건수 (X-Total-Count 헤더, 백엔드 PR #31) */ +export const getEventsPaged = async (params?: EventsQuery) => { + const res = await api.get('/api/events/', { params }); + const totalHeader = res.headers['x-total-count']; + const total = totalHeader ? Number(totalHeader) : res.data.length; + return { items: res.data, total: Number.isFinite(total) ? total : res.data.length }; +}; + /** GET /api/events/{id} */ export const getEventById = (id: number) => api.get(`/api/events/${id}`).then((r) => r.data); @@ -26,6 +38,21 @@ export const getEventStats = () => export const getEventStatsByCamera = () => api.get('/api/events/stats/by-camera').then((r) => r.data); +/** GET /api/events/stats/by-type — 유형별 집계 (백엔드 PR #31) */ +export interface EventTypeStat { event_type: string; count: number } +export const getEventStatsByType = () => + api.get('/api/events/stats/by-type').then((r) => r.data); + +/** GET /api/events/stats/hourly — 시간대(0~23)별 집계 (백엔드 PR #31) */ +export interface HourlyStat { hour: number; count: number } +export const getEventStatsHourly = (params?: { date_from?: string; date_to?: string }) => + api.get('/api/events/stats/hourly', { params }).then((r) => r.data); + +/** GET /api/events/stats/daily — 최근 N일 일자별 집계 (백엔드 PR #31) */ +export interface DailyStat { day: string; count: number } +export const getEventStatsDaily = (days = 30) => + api.get('/api/events/stats/daily', { params: { days } }).then((r) => r.data); + /** PATCH /api/events/{id}/status — 처리완료 */ export const updateEventStatus = (id: number, status: 'confirmed' | 'false_alarm') => api.patch(`/api/events/${id}/status`, { status }).then((r) => r.data);