Skip to content

Commit 91666fd

Browse files
committed
feat(modal): เพิ่ม create alert จากกล้อง
1 parent 8e75445 commit 91666fd

File tree

5 files changed

+285
-7
lines changed

5 files changed

+285
-7
lines changed

client/my-app/src/app/components/Alertspopup.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,11 @@ export function ConfirmDialog({
168168
<AlertDialogHeader className="p-0">
169169
{/* Title/Description และ input ใช้ความกว้างเดียวกัน */}
170170
<div className={cn("mx-auto w-full max-w-[480px]")}>
171-
<AlertDialogTitle className="text-lg font-semibold leading-tight tracking-tight">
171+
<AlertDialogTitle className="text-lg font-semibold leading-tight tracking-tight text-center">
172172
{title}
173173
</AlertDialogTitle>
174174
{description ? (
175-
<AlertDialogDescription className="mt-0.5 text-slate-500 leading-snug">
175+
<AlertDialogDescription className="mt-0.5 text-slate-500 leading-snug text-center">
176176
{description}
177177
</AlertDialogDescription>
178178
) : null}

client/my-app/src/app/components/Cameras/FullScreenView.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@ import {
1414
import {
1515
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem,
1616
} from "@/components/ui/dropdown-menu";
17+
import CreateAlertForm from "@/app/components/Forms/CreateAlertForm";
18+
import { SuccessModal } from "@/app/components/Alertspopup";
1719

1820
export default function FullScreenView({ camera }: { camera: Camera }) {
1921
const [currentCamera, setCurrentCamera] = useState(camera);
22+
const [open, setOpen] = useState(false);
2023

2124
const containerRef = useRef<HTMLDivElement | null>(null);
2225
const videoRef = useRef<HTMLVideoElement | null>(null);
@@ -30,6 +33,10 @@ export default function FullScreenView({ camera }: { camera: Camera }) {
3033
window.history.back();
3134
}
3235

36+
const goEdit = () => {
37+
setOpen(true);
38+
};
39+
3340
const handleCapture = useCallback(async () => {
3441
const box = containerRef.current;
3542
if (!box) return;
@@ -146,8 +153,7 @@ export default function FullScreenView({ camera }: { camera: Camera }) {
146153
)}
147154
</div>
148155

149-
<div className="flex flex-wrap items-start gap-3 justify-center mb-3">
150-
{/* ▼▼ แทนที่บล็อก “Actions” เดิมตั้งแต่ label จนจบแถวนั้น ด้วยโค้ดนี้ ▼▼ */}
156+
<div className="flex flex-wrap items-start gap-3 justify-center my-3">
151157
<div
152158
className="
153159
sticky top-1 z-20 mb-3 rounded-xl border
@@ -204,6 +210,7 @@ export default function FullScreenView({ camera }: { camera: Camera }) {
204210
<TooltipTrigger asChild>
205211
<Button
206212
type="button"
213+
onClick={goEdit}
207214
className="shrink-0 bg-[var(--color-danger)] text-white hover:bg-[var(--color-danger-hard)]
208215
px-3 py-2 rounded-md flex items-center gap-2"
209216
>
@@ -238,6 +245,7 @@ export default function FullScreenView({ camera }: { camera: Camera }) {
238245
<span>Settings</span>
239246
</DropdownMenuItem>
240247
<DropdownMenuItem
248+
onClick={goEdit}
241249
className="text-[var(--color-danger)] focus:text-[var(--color-danger)]"
242250
>
243251
<TriangleAlert className="mr-2 h-4 w-4" />
@@ -250,7 +258,7 @@ export default function FullScreenView({ camera }: { camera: Camera }) {
250258
</div>
251259
</div>
252260

253-
<Separator className="bg-[var(--color-primary-bg)] my-3" />
261+
{/* <Separator className="bg-[var(--color-primary-bg)] my-3" /> */}
254262

255263
<label htmlFor="camerainfo" className="col-span-3 text-lg text-[var(--color-primary)]">
256264
Camera Information
@@ -291,6 +299,7 @@ export default function FullScreenView({ camera }: { camera: Camera }) {
291299
</TableBody>
292300
</Table>
293301
</div>
302+
<CreateAlertForm camera={currentCamera} open={open} setOpen={setOpen} />
294303
</div>
295304
);
296305
}
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
"use client";
2+
import { useState, useEffect } from "react";
3+
import {
4+
AlertDialog,
5+
AlertDialogContent,
6+
AlertDialogHeader,
7+
AlertDialogTitle,
8+
AlertDialogDescription,
9+
AlertDialogFooter,
10+
AlertDialogCancel,
11+
} from "@/components/ui/alert-dialog";
12+
import { Button } from "@/components/ui/button";
13+
import { Camera } from "@/app/models/cameras.model";
14+
15+
/* ✅ ใช้ shadcn Select เพื่อให้สไตล์เดียวกัน */
16+
import {
17+
Select,
18+
SelectTrigger,
19+
SelectValue,
20+
SelectContent,
21+
SelectItem,
22+
} from "@/components/ui/select";
23+
24+
/* ✅ ไอคอน lucide แบบไดนามิก */
25+
import * as Lucide from "lucide-react";
26+
27+
type Props = {
28+
camera: Camera;
29+
open: boolean;
30+
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
31+
};
32+
33+
type EventItem = { id: number; name: string; icon?: string };
34+
35+
type AlertForm = {
36+
severity: "Critical" | "High" | "Medium" | "Low";
37+
eventId: string;
38+
description: string;
39+
};
40+
41+
export default function EditCameraModal({ camera, open, setOpen }: Props) {
42+
const [events, setEvents] = useState<EventItem[]>([]);
43+
const [loadingEvents, setLoadingEvents] = useState(false);
44+
const [errMsg, setErrMsg] = useState<string | null>(null);
45+
46+
const [form, setForm] = useState<AlertForm>({
47+
severity: "High",
48+
eventId: "",
49+
description: "",
50+
});
51+
52+
// ช่วย normalize ชื่อไอคอน lucide
53+
function normalizeIconName(name?: string) {
54+
if (!name || typeof name !== "string") return undefined;
55+
const cleaned = name
56+
.replace(/[-_ ]+(\w)/g, (_, c) => (c ? c.toUpperCase() : ""))
57+
.replace(/^[a-z]/, (c) => c.toUpperCase());
58+
const alias: Record<string, string> = {
59+
TriangleAlert: "AlertTriangle",
60+
Alert: "AlertCircle",
61+
Cam: "Camera",
62+
};
63+
return alias[cleaned] ?? cleaned;
64+
}
65+
66+
useEffect(() => {
67+
if (!open) return;
68+
setLoadingEvents(true);
69+
setErrMsg(null);
70+
71+
fetch("/api/events")
72+
.then((res) => {
73+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
74+
return res.json();
75+
})
76+
.then((data: any[]) => {
77+
const normalized: EventItem[] = (Array.isArray(data) ? data : [])
78+
.map((e: any) => ({
79+
id: e?.id ?? e?.evt_id ?? e?.event_id,
80+
name: e?.name ?? e?.evt_name ?? e?.event_name,
81+
icon: e?.icon ?? e?.evt_icon ?? e?.event_icon,
82+
}))
83+
.filter((x: EventItem) => Number.isFinite(x.id) && !!x.name);
84+
85+
const uniq = [...new Map(normalized.map((v) => [v.id, v])).values()];
86+
setEvents(uniq);
87+
})
88+
.catch((err) => {
89+
console.error("Load events failed:", err);
90+
setErrMsg("Cannot load events.");
91+
})
92+
.finally(() => setLoadingEvents(false));
93+
}, [open]);
94+
95+
const camCode = `CAM${String(camera.id).padStart(3, "0")}`;
96+
97+
function onChange<K extends keyof AlertForm>(key: K, value: AlertForm[K]) {
98+
setForm((prev) => ({ ...prev, [key]: value }));
99+
}
100+
101+
async function handleSubmit(e: React.FormEvent) {
102+
e.preventDefault();
103+
setErrMsg(null);
104+
105+
if (!form.eventId) {
106+
setErrMsg("Please choose an event.");
107+
return;
108+
}
109+
110+
const payload = {
111+
severity: form.severity,
112+
camera_id: Number(camera.id),
113+
footage_id: 1,
114+
event_id: Number(form.eventId),
115+
description: form.description?.trim() ?? "",
116+
};
117+
118+
try {
119+
const res = await fetch(`/api/alerts`, {
120+
method: "POST",
121+
headers: { "Content-Type": "application/json" },
122+
body: JSON.stringify(payload),
123+
});
124+
125+
if (!res.ok) {
126+
const data = await res.json().catch(() => ({}));
127+
throw new Error(data?.message || `Create alert failed (${res.status})`);
128+
}
129+
130+
setOpen(false);
131+
window.location.href = "/alerts";
132+
} catch (err: any) {
133+
console.error(err);
134+
setErrMsg(err.message || "Create alert failed.");
135+
}
136+
}
137+
138+
return (
139+
<AlertDialog open={open} onOpenChange={setOpen}>
140+
<AlertDialogContent className="!top-[40%] !-translate-y-[40%]">
141+
<AlertDialogHeader>
142+
<AlertDialogTitle className="text-[var(--color-primary)]">
143+
New Alert — {camera?.name} ({camCode})
144+
</AlertDialogTitle>
145+
<AlertDialogDescription>
146+
Linking to #{camCode}. Set severity, category, and note, then Save.
147+
</AlertDialogDescription>
148+
</AlertDialogHeader>
149+
150+
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
151+
{/* Readonly camera info */}
152+
<div className="grid grid-cols-2 gap-2">
153+
<div className="grid gap-1">
154+
<label className="text-sm font-medium">Camera Name</label>
155+
<input
156+
value={camera?.name ?? ""}
157+
className="font-light w-full rounded-md border px-3 py-2 outline-none border-[var(--color-primary)] text-[var(--color-primary)] bg-[var(--color-primary-bg)]"
158+
disabled
159+
/>
160+
</div>
161+
<div className="grid gap-1">
162+
<label className="text-sm font-medium">Location</label>
163+
<input
164+
value={(camera as any)?.location?.name ?? ""}
165+
className="font-light w-full rounded-md border px-3 py-2 outline-none border-[var(--color-primary)] text-[var(--color-primary)] bg-[var(--color-primary-bg)]"
166+
disabled
167+
/>
168+
</div>
169+
</div>
170+
171+
{/* ✅ Severity ใช้ shadcn Select (สไตล์เดียวกับ Event Type) */}
172+
<div className="grid grid-cols-3 gap-2">
173+
<div className="grid gap-1">
174+
<label className="text-sm font-medium" htmlFor="severity">
175+
Severity
176+
</label>
177+
<Select
178+
value={form.severity}
179+
onValueChange={(val) =>
180+
onChange("severity", val as AlertForm["severity"])
181+
}
182+
>
183+
<SelectTrigger className="w-full">
184+
<SelectValue placeholder="Choose Severity" />
185+
</SelectTrigger>
186+
<SelectContent>
187+
<SelectItem value="Critical">Critical</SelectItem>
188+
<SelectItem value="High">High</SelectItem>
189+
<SelectItem value="Medium">Medium</SelectItem>
190+
<SelectItem value="Low">Low</SelectItem>
191+
</SelectContent>
192+
</Select>
193+
</div>
194+
195+
{/* ✅ Event Type + ไอคอนสี var(--color-primary) */}
196+
<div className="grid col-span-2 gap-1">
197+
<label className="text-sm font-medium" htmlFor="event">
198+
Event Type
199+
</label>
200+
<Select
201+
value={form.eventId}
202+
onValueChange={(val) => onChange("eventId", val)}
203+
>
204+
<SelectTrigger className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
205+
<SelectValue placeholder={loadingEvents ? "Loading..." : "Choose Event Type"} />
206+
</SelectTrigger>
207+
208+
<SelectContent className="w-[var(--radix-select-trigger-width)] max-h-64">
209+
{loadingEvents ? (
210+
<SelectItem value="__loading" disabled>
211+
Loading...
212+
</SelectItem>
213+
) : events.length === 0 ? (
214+
<SelectItem value="__empty" disabled>
215+
No events
216+
</SelectItem>
217+
) : (
218+
events.map((evt) => {
219+
const iconName = normalizeIconName(evt.icon);
220+
// @ts-ignore
221+
const IconComp = (iconName && (Lucide as any)[iconName]) || Lucide.Dot;
222+
return (
223+
<SelectItem key={evt.id} value={String(evt.id)} textValue={evt.name}>
224+
<div className="flex min-w-0 items-center gap-2">
225+
<IconComp className="h-4 w-4 shrink-0 text-[var(--color-primary)]" />
226+
<span className="truncate" title={evt.name}>{evt.name}</span>
227+
</div>
228+
</SelectItem>
229+
);
230+
})
231+
)}
232+
</SelectContent>
233+
</Select>
234+
</div>
235+
</div>
236+
237+
{/* Description */}
238+
<div className="grid gap-1">
239+
<label className="text-sm font-medium" htmlFor="description">
240+
Description
241+
</label>
242+
<textarea
243+
name="description"
244+
placeholder="Enter your description"
245+
className="font-light w-full rounded-md border px-3 py-3 outline-none focus-within:ring focus-within:ring-[var(--color-primary)]"
246+
value={form.description}
247+
onChange={(e) => onChange("description", e.target.value)}
248+
/>
249+
</div>
250+
251+
{errMsg && <div className="text-sm text-red-600 mt-2">{errMsg}</div>}
252+
253+
<AlertDialogFooter>
254+
<AlertDialogCancel className="border-gray-300 hover:bg-gray-50">
255+
Cancel
256+
</AlertDialogCancel>
257+
<Button
258+
type="submit"
259+
disabled={!form.eventId}
260+
className="bg-[var(--color-primary)] text-white hover:bg-[var(--color-secondary)] px-4 py-2 rounded-md disabled:opacity-50"
261+
>
262+
Save
263+
</Button>
264+
</AlertDialogFooter>
265+
</form>
266+
</AlertDialogContent>
267+
</AlertDialog>
268+
);
269+
}

client/my-app/src/app/components/Utilities/bottomCameraCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Eye, Pencil, Info, Trash2 } from "lucide-react"; // ใช้เมื
44
import "@/styles/camera-card.css";
55
import EditCameraModal from "../Cameras/EditCameraModal";
66
import { useState } from "react";
7-
import { DeleteConfirmModal } from "@/app/components/Alertspopup"; // ⬅️ เพิ่ม import
7+
import { DeleteConfirmModal } from "@/app/components/Alertspopup";
88

99
type IconSet = "fi" | "lucide";
1010

server/src/controllers/alerts.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ export async function distributionAnalytics(req: Request, res: Response, next: N
172172
export async function store(req: Request, res: Response, next: NextFunction) {
173173
try {
174174
const { severity, camera_id, footage_id, event_id, description } = req.body;
175-
if (!severity || !camera_id || !footage_id || !event_id || !description) {
175+
if (!severity || !camera_id || !footage_id || !event_id ) {
176176
return res.status(400).json({ error: "Missing required fields" });
177177
}
178178

0 commit comments

Comments
 (0)