+ 비밀번호 찾기 +
+
+ 가입 시 등록한 사원번호와 이메일을 입력하시면
+
임시 비밀번호를 발송해 드립니다.
+
+ 로그인 +
+ + > + ); +} diff --git a/frontend/src/components/auth/RegisterForm.tsx b/frontend/src/components/auth/RegisterForm.tsx new file mode 100644 index 0000000..4cd6600 --- /dev/null +++ b/frontend/src/components/auth/RegisterForm.tsx @@ -0,0 +1,125 @@ +import { useState } from "react"; +import api from "@/api/axios"; +import axios from "axios"; + +interface RegisterFormProps { + onLogin: () => void; +} + +export default function RegisterForm({ onLogin }: RegisterFormProps) { + const [employeeId, setEmployeeId] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + if (password !== passwordConfirm) { + setError("비밀번호가 일치하지 않습니다."); + return; + } + setLoading(true); + try { + await api.post("/api/auth/register", { + employee_id: employeeId, + email, + password, + }); + setSuccess("가입이 완료되었습니다. 로그인해 주세요."); + setTimeout(onLogin, 1500); + } catch (err) { + if (axios.isAxiosError(err) && err.response?.status === 400) { + setError("이미 사용 중인 사원번호 또는 이메일입니다."); + } else { + setError("가입 중 오류가 발생했습니다."); + } + } finally { + setLoading(false); + } + }; + + return ( + <> ++ 가입하기 +
+ + > + ); +} diff --git a/frontend/src/components/dashboard/AlertItem.tsx b/frontend/src/components/dashboard/AlertItem.tsx index 798c89f..de79996 100644 --- a/frontend/src/components/dashboard/AlertItem.tsx +++ b/frontend/src/components/dashboard/AlertItem.tsx @@ -1,26 +1,5 @@ -/** - * @file components/dashboard/AlertItem.tsx - * @description 최신알림 개별 카드 컴포넌트 - * - * ## 기능 - * - 미처리(pending/detected): 파란 [상세보기] + 파란 outline [오탐신고] 버튼 - * - 처리완료(confirmed/false_alarm): 파란 outline [기록보기] 버튼 - * - 심각도 뱃지: confidence 기반 (≥0.7 고위험 / <0.7 중간 / 처리 후 처리완료·오탐) - * - 위치: camera embed 시 "역명 게이트명", 루트 직접 필드 시에도 동일 표시, 없으면 "CAM-XX" - * - description 있으면 감지 유형 표시, 없으면 status 기반 기본 문구 fallback - * - * ## 주의사항 - * - isActive: pending → 상세보기·오탐신고 버튼 / confirmed | false_alarm → 기록보기 버튼 - * - getLocationLabel: camera 객체 embed 또는 루트 직접 필드(station_name/location) 모두 대응, 없으면 "CAM-XX" fallback - * - * ## TODO - * - [ ] 카메라 썸네일 실제 CCTV 스냅샷 연동 (clip_url or 별도 API) - * - * ## 백엔드 확정 후 수정 필요 - * - description, appearance_tags 필드명 확정 필요 - */ - import type { EventResponse } from "@/types"; +import { labelEventType } from "@/constants/eventTypes"; interface AlertItemProps { event: EventResponse; @@ -28,15 +7,15 @@ interface AlertItemProps { onFalseAlarm: (event: EventResponse) => void; } -/** confidence 기반 심각도 뱃지. 처리 완료 상태는 별도 레이블 반환 */ -function getSeverity(event: EventResponse): { label: string; color: string } { +function getSeverity(event: EventResponse): { label: string; cls: string } { if (event.status === "confirmed") - return { label: "처리완료", color: "bg-gray-100 text-gray-500" }; + return { label: "처리완료", cls: "bg-gray-100 text-gray-500" }; if (event.status === "false_alarm") - return { label: "오탐", color: "bg-gray-100 text-gray-400" }; - if ((event.confidence ?? 0) >= 0.7) - return { label: "고위험", color: "bg-red-100 text-red-500" }; - return { label: "중간", color: "bg-yellow-100 text-yellow-600" }; + return { label: "오탐", cls: "bg-gray-100 text-gray-400" }; + const conf = event.confidence ?? 0; + if (conf >= 0.7) return { label: "고위험", cls: "bg-red-100 text-red-500" }; + if (conf >= 0.4) return { label: "중간", cls: "bg-yellow-100 text-yellow-600" }; + return { label: "낮음", cls: "bg-green-100 text-green-600" }; } function formatTime(timestamp: string): string { @@ -56,11 +35,10 @@ function getLocationLabel(event: EventResponse): string { return `CAM-${String(event.camera_id).padStart(2, "0")}`; } -/** description 있으면 그 값, 없으면 status 기반 기본 문구 */ function getDescription(event: EventResponse): string { - if (event.description) return event.description; if (event.status === "confirmed") return "무임승차 확인 처리됨"; - if (event.status === "false_alarm") return "오탐으로 처리됨"; + if (event.status === "false_alarm") return "오탐으로 신고됨"; + if (event.event_type) return `${labelEventType(event.event_type)} 의심`; return "무임승차 의심 감지"; } @@ -80,7 +58,7 @@ export default function AlertItem({ CAM-{String(event.camera_id).padStart(2, "0")} {severity.label} @@ -96,7 +74,9 @@ export default function AlertItem({ {formatTime(event.timestamp)} -{getDescription(event)}
++ {getDescription(event)} +
구간별 알림현황
- ++ 역별 알림현황 +
++ ) : sorted.length === 0 ? ( +
데이터가 없습니다.
) : ( -| 역이름 | -알림현황 | -
|---|---|
|
- {row.station_name}
- {row.location}
- |
- - - - {row.count} - - | -
+ {row.station_name} +
++ {row.location} +
+실시간 알림
-- 이벤트 없음 -
- ) : ( - events.map((ev) => ( -- Event #{selected.id} - - {" "} - | {formatHMS(selected.timestamp)} | {getGateLabel(selected)} | - STATUS:{" "} - - - {getStatusText(selected.status)} - -
- - {badge.label} - -기록시각
-- {formatHMS(selected.timestamp)} -
-위치
-{locationText}
-인상착의
--
- {selected.appearance_tags.map((tag) => (
-
- - - {tag} - - ))} -
감지유형
-- {selected.event_type ?? selected.description} -
-- AI 신뢰도: {Math.round((selected.confidence ?? 0) * 100)}% -
- )} -- 다음 버튼 -
- - - -기록시각
+{formatHMS(event.timestamp)}
+위치
+{locationText}
+감지유형
+{labelEventType(event.event_type!)}
++ AI 신뢰도: {Math.round((event.confidence ?? 0) * 100)}% +
+ )} +{formatDateTime(completedInfo.at)}에 처리완료 되었습니다.
+ ) : ( + <> +{formatDateTime(completedInfo.at)}에 오탐신고 되었습니다.
+ {completedInfo.reason && ( +사유: {completedInfo.reason}
+ )} + > + )} +최근 오탐 신고
- - 전체보기 -최근 오탐 신고 내역이 없습니다.
) : (- {getLabel(n)} -
-- {formatRelativeTime(n.sent_at)} ·{" "} - {isResolved ? "오탐 확인" : "검토 중"} -
-+ {getLabel(e)} +
++ {formatRelativeTime(e.handled_at ?? e.timestamp)} + {e.reason && ` · ${e.reason}`} +
+