DeeptiYadav10648 commited on
Commit
bcd59c6
·
1 Parent(s): 45efbb3

Frontend version 1 complete

Browse files
Frontend/.env.example ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ NEXT_PUBLIC_API_URL=http://localhost:8000
2
+ NEXT_PUBLIC_SUPABASE_URL=
3
+ NEXT_PUBLIC_SUPABASE_ANON_KEY=
Frontend/.gitignore DELETED
@@ -1,41 +0,0 @@
1
- # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
-
3
- # dependencies
4
- /node_modules
5
- /.pnp
6
- .pnp.*
7
- .yarn/*
8
- !.yarn/patches
9
- !.yarn/plugins
10
- !.yarn/releases
11
- !.yarn/versions
12
-
13
- # testing
14
- /coverage
15
-
16
- # next.js
17
- /.next/
18
- /out/
19
-
20
- # production
21
- /build
22
-
23
- # misc
24
- .DS_Store
25
- *.pem
26
-
27
- # debug
28
- npm-debug.log*
29
- yarn-debug.log*
30
- yarn-error.log*
31
- .pnpm-debug.log*
32
-
33
- # env files (can opt-in for committing if needed)
34
- .env*
35
-
36
- # vercel
37
- .vercel
38
-
39
- # typescript
40
- *.tsbuildinfo
41
- next-env.d.ts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Frontend/README.md DELETED
@@ -1,36 +0,0 @@
1
- This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
2
-
3
- ## Getting Started
4
-
5
- First, run the development server:
6
-
7
- ```bash
8
- npm run dev
9
- # or
10
- yarn dev
11
- # or
12
- pnpm dev
13
- # or
14
- bun dev
15
- ```
16
-
17
- Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18
-
19
- You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20
-
21
- This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22
-
23
- ## Learn More
24
-
25
- To learn more about Next.js, take a look at the following resources:
26
-
27
- - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28
- - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29
-
30
- You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31
-
32
- ## Deploy on Vercel
33
-
34
- The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35
-
36
- Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Frontend/app/admin/departments/[id]/page.tsx ADDED
@@ -0,0 +1,456 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import { useParams, useRouter } from "next/navigation";
4
+
5
+ export const runtime = "edge";
6
+ import { apiGet, apiPost } from "@/lib/api";
7
+ import {
8
+ ArrowLeft,
9
+ Users,
10
+ Plus,
11
+ Mail,
12
+ Phone,
13
+ Briefcase,
14
+ Shield,
15
+ MapPin,
16
+ Trash2,
17
+ MoreHorizontal,
18
+ } from "lucide-react";
19
+ import Link from "next/link";
20
+
21
+ interface Department {
22
+ id: string;
23
+ name: string;
24
+ code: string;
25
+ description: string;
26
+ default_sla_hours: number;
27
+ is_active: boolean;
28
+ member_count: number;
29
+ }
30
+
31
+ interface Member {
32
+ id: string;
33
+ name: string;
34
+ email: string;
35
+ role: string;
36
+ current_workload: number;
37
+ max_workload: number;
38
+ is_active: boolean;
39
+ phone?: string;
40
+ city?: string;
41
+ }
42
+
43
+ export default function DepartmentDetailPage() {
44
+ const params = useParams();
45
+ const router = useRouter();
46
+ const [department, setDepartment] = useState<Department | null>(null);
47
+ const [members, setMembers] = useState<Member[]>([]);
48
+ const [loading, setLoading] = useState(true);
49
+ const [showAddMember, setShowAddMember] = useState(false);
50
+
51
+ // Form State
52
+ const [newMember, setNewMember] = useState({
53
+ name: "",
54
+ email: "",
55
+ role: "worker",
56
+ password: "", // Required by backend
57
+ phone: "",
58
+ city: "",
59
+ max_workload: 10,
60
+ });
61
+
62
+ useEffect(() => {
63
+ if (params.id) {
64
+ loadData(params.id as string);
65
+ }
66
+ }, [params.id]);
67
+
68
+ const loadData = async (deptId: string) => {
69
+ try {
70
+ const [deptData, membersData] = await Promise.all([
71
+ apiGet<Department>(`/admin/departments/${deptId}`),
72
+ apiGet<Member[]>(`/admin/members?department_id=${deptId}`),
73
+ ]);
74
+ setDepartment(deptData);
75
+ setMembers(membersData);
76
+ } catch (error) {
77
+ console.error("Failed to load department data:", error);
78
+ } finally {
79
+ setLoading(false);
80
+ }
81
+ };
82
+
83
+ const handleAddMember = async (e: React.FormEvent) => {
84
+ e.preventDefault();
85
+ if (!department) return;
86
+
87
+ try {
88
+ await apiPost("/admin/members", {
89
+ ...newMember,
90
+ department_id: department.id,
91
+ locality: "General", // Default for now
92
+ });
93
+ setShowAddMember(false);
94
+ setNewMember({
95
+ name: "",
96
+ email: "",
97
+ role: "worker",
98
+ password: "",
99
+ phone: "",
100
+ city: "",
101
+ max_workload: 10,
102
+ });
103
+ loadData(department.id); // Refresh list
104
+ alert("Member added successfully!");
105
+ } catch (error: any) {
106
+ alert(error.message || "Failed to add member");
107
+ }
108
+ };
109
+
110
+ const handleDeleteMember = async (memberId: string) => {
111
+ if (!confirm("Are you sure you want to remove this member?")) return;
112
+ try {
113
+ // Assuming there is a delete endpoint, though strictly not in the original brief, it's good UX
114
+ // backend/api/routes/admin.py lines 612-624 supports DELETE /members/{member_id}
115
+ const token = localStorage.getItem("supabase_token");
116
+ await fetch(
117
+ `${process.env.NEXT_PUBLIC_API_URL}/admin/members/${memberId}`,
118
+ {
119
+ method: "DELETE",
120
+ headers: { Authorization: `Bearer ${token}` },
121
+ },
122
+ );
123
+ setMembers(members.filter((m) => m.id !== memberId));
124
+ } catch (error) {
125
+ console.error("Delete failed", error);
126
+ }
127
+ };
128
+
129
+ if (loading) {
130
+ return (
131
+ <div className="p-8 text-center text-slate-500">
132
+ Loading Department...
133
+ </div>
134
+ );
135
+ }
136
+
137
+ if (!department) {
138
+ return <div className="p-8 text-center">Department not found</div>;
139
+ }
140
+
141
+ return (
142
+ <div className="max-w-6xl mx-auto space-y-8 p-6">
143
+ {/* Header */}
144
+ <div>
145
+ <Link
146
+ href="/admin/departments"
147
+ className="inline-flex items-center gap-2 text-slate-500 hover:text-urban-primary mb-4"
148
+ >
149
+ <ArrowLeft className="w-4 h-4" /> Back to Departments
150
+ </Link>
151
+ <div className="flex justify-between items-start">
152
+ <div>
153
+ <div className="flex items-center gap-3 mb-1">
154
+ <h1 className="text-3xl font-bold text-slate-900">
155
+ {department.name}
156
+ </h1>
157
+ <span className="bg-slate-100 text-slate-600 px-2 py-1 rounded text-sm font-mono font-bold border border-slate-200">
158
+ {department.code}
159
+ </span>
160
+ </div>
161
+ <p className="text-slate-500">
162
+ {department.description || "No description provided."}
163
+ </p>
164
+ </div>
165
+ <div className="flex gap-3">
166
+ <div className="text-right">
167
+ <div className="text-xs text-slate-400 font-bold uppercase tracking-wider mb-1">
168
+ SLA Limit
169
+ </div>
170
+ <div className="font-mono font-bold text-slate-700 bg-slate-50 px-3 py-1 rounded border border-slate-200">
171
+ {department.default_sla_hours}h
172
+ </div>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ </div>
177
+
178
+ {/* Stats Cards */}
179
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
180
+ <div className="card border-l-4 border-l-blue-500 p-6">
181
+ <div className="flex justify-between items-center">
182
+ <div>
183
+ <p className="text-sm font-bold text-slate-400 uppercase tracking-wider">
184
+ Total Staff
185
+ </p>
186
+ <h3 className="text-3xl font-bold text-slate-900 mt-1">
187
+ {members.length}
188
+ </h3>
189
+ </div>
190
+ <div className="h-10 w-10 bg-blue-50 rounded-lg flex items-center justify-center text-blue-600">
191
+ <Users className="w-6 h-6" />
192
+ </div>
193
+ </div>
194
+ </div>
195
+ {/* Add more stats if available */}
196
+ </div>
197
+
198
+ {/* Members Section */}
199
+ <div className="space-y-6">
200
+ <div className="flex justify-between items-center">
201
+ <h2 className="text-xl font-bold text-slate-900 flex items-center gap-2">
202
+ <Users className="w-5 h-5 text-slate-400" />
203
+ Department Members
204
+ </h2>
205
+ <button
206
+ onClick={() => setShowAddMember(true)}
207
+ className="btn-primary flex items-center gap-2"
208
+ >
209
+ <Plus className="w-4 h-4" /> Add Worker
210
+ </button>
211
+ </div>
212
+
213
+ {/* Add Member Form (Inline/Expandable) */}
214
+ {showAddMember && (
215
+ <div className="card bg-slate-50 border-slate-200 animate-in fade-in slide-in-from-top-4">
216
+ <div className="flex justify-between items-center mb-6 pb-4 border-b border-slate-200">
217
+ <h3 className="text-lg font-bold text-slate-800">
218
+ Add New Member
219
+ </h3>
220
+ <button
221
+ onClick={() => setShowAddMember(false)}
222
+ className="text-slate-400 hover:text-slate-600"
223
+ >
224
+ Cancel
225
+ </button>
226
+ </div>
227
+ <form
228
+ onSubmit={handleAddMember}
229
+ className="grid grid-cols-1 md:grid-cols-2 gap-6"
230
+ >
231
+ <div>
232
+ <label className="text-sm font-bold text-slate-700 mb-1 block">
233
+ Full Name
234
+ </label>
235
+ <input
236
+ required
237
+ className="input w-full bg-white"
238
+ placeholder="John Doe"
239
+ value={newMember.name}
240
+ onChange={(e) =>
241
+ setNewMember({ ...newMember, name: e.target.value })
242
+ }
243
+ />
244
+ </div>
245
+ <div>
246
+ <label className="text-sm font-bold text-slate-700 mb-1 block">
247
+ Email Address
248
+ </label>
249
+ <input
250
+ required
251
+ type="email"
252
+ className="input w-full bg-white"
253
+ placeholder="john@city.gov"
254
+ value={newMember.email}
255
+ onChange={(e) =>
256
+ setNewMember({ ...newMember, email: e.target.value })
257
+ }
258
+ />
259
+ </div>
260
+ <div>
261
+ <label className="text-sm font-bold text-slate-700 mb-1 block">
262
+ Role
263
+ </label>
264
+ <select
265
+ className="input w-full bg-white"
266
+ value={newMember.role}
267
+ onChange={(e) =>
268
+ setNewMember({ ...newMember, role: e.target.value })
269
+ }
270
+ >
271
+ <option value="worker">Field Worker</option>
272
+ <option value="officer">Department Officer</option>
273
+ <option value="admin">Admin (Restricted)</option>
274
+ </select>
275
+ </div>
276
+ <div>
277
+ <label className="text-sm font-bold text-slate-700 mb-1 block">
278
+ Initial Password
279
+ </label>
280
+ <input
281
+ required
282
+ type="password"
283
+ className="input w-full bg-white"
284
+ placeholder="••••••••"
285
+ value={newMember.password}
286
+ onChange={(e) =>
287
+ setNewMember({ ...newMember, password: e.target.value })
288
+ }
289
+ />
290
+ </div>
291
+ <div>
292
+ <label className="text-sm font-bold text-slate-700 mb-1 block">
293
+ Phone (Optional)
294
+ </label>
295
+ <input
296
+ className="input w-full bg-white"
297
+ placeholder="+1 234..."
298
+ value={newMember.phone}
299
+ onChange={(e) =>
300
+ setNewMember({ ...newMember, phone: e.target.value })
301
+ }
302
+ />
303
+ </div>
304
+ <div>
305
+ <label className="text-sm font-bold text-slate-700 mb-1 block">
306
+ City (Optional)
307
+ </label>
308
+ <input
309
+ className="input w-full bg-white"
310
+ placeholder="East District"
311
+ value={newMember.city}
312
+ onChange={(e) =>
313
+ setNewMember({ ...newMember, city: e.target.value })
314
+ }
315
+ />
316
+ </div>
317
+ <div className="md:col-span-2 pt-4 flex justify-end gap-3">
318
+ <button
319
+ type="button"
320
+ onClick={() => setShowAddMember(false)}
321
+ className="btn-secondary"
322
+ >
323
+ Cancel
324
+ </button>
325
+ <button type="submit" className="btn-primary">
326
+ Create Member Account
327
+ </button>
328
+ </div>
329
+ </form>
330
+ </div>
331
+ )}
332
+
333
+ {/* Members List */}
334
+ <div className="card p-0 overflow-hidden">
335
+ <table className="w-full text-left">
336
+ <thead className="bg-slate-50 border-b border-slate-200">
337
+ <tr>
338
+ <th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-wider">
339
+ Name / Email
340
+ </th>
341
+ <th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-wider">
342
+ Role
343
+ </th>
344
+ <th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-wider">
345
+ Workload
346
+ </th>
347
+ <th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-wider">
348
+ Location
349
+ </th>
350
+ <th className="px-6 py-4 text-right text-xs font-bold text-slate-500 uppercase tracking-wider">
351
+ Actions
352
+ </th>
353
+ </tr>
354
+ </thead>
355
+ <tbody className="divide-y divide-slate-100">
356
+ {members.map((member) => (
357
+ <tr
358
+ key={member.id}
359
+ className="hover:bg-slate-50/50 transition-colors"
360
+ >
361
+ <td className="px-6 py-4">
362
+ <div className="flex items-center gap-3">
363
+ <div
364
+ className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm ${
365
+ member.role === "admin"
366
+ ? "bg-purple-100 text-purple-600"
367
+ : member.role === "officer"
368
+ ? "bg-blue-100 text-blue-600"
369
+ : "bg-emerald-100 text-emerald-600"
370
+ }`}
371
+ >
372
+ {member.name.charAt(0)}
373
+ </div>
374
+ <div>
375
+ <div className="font-bold text-slate-900">
376
+ {member.name}
377
+ </div>
378
+ <div className="text-xs text-slate-500 flex items-center gap-1">
379
+ <Mail className="w-3 h-3" /> {member.email}
380
+ </div>
381
+ </div>
382
+ </div>
383
+ </td>
384
+ <td className="px-6 py-4">
385
+ <span
386
+ className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wide border ${
387
+ member.role === "worker"
388
+ ? "bg-emerald-50 text-emerald-700 border-emerald-100"
389
+ : member.role === "officer"
390
+ ? "bg-blue-50 text-blue-700 border-blue-100"
391
+ : "bg-purple-50 text-purple-700 border-purple-100"
392
+ }`}
393
+ >
394
+ {member.role === "worker" ? (
395
+ <Briefcase className="w-3 h-3" />
396
+ ) : (
397
+ <Shield className="w-3 h-3" />
398
+ )}
399
+ {member.role}
400
+ </span>
401
+ </td>
402
+ <td className="px-6 py-4">
403
+ <div className="flex items-center gap-2">
404
+ <div className="w-24 h-2 bg-slate-100 rounded-full overflow-hidden">
405
+ <div
406
+ className={`h-full rounded-full ${member.current_workload > 5 ? "bg-orange-500" : "bg-green-500"}`}
407
+ style={{
408
+ width: `${(member.current_workload / (member.max_workload || 10)) * 100}%`,
409
+ }}
410
+ ></div>
411
+ </div>
412
+ <span className="text-xs font-mono text-slate-500">
413
+ {member.current_workload}/{member.max_workload || 10}
414
+ </span>
415
+ </div>
416
+ </td>
417
+ <td className="px-6 py-4">
418
+ {member.city ? (
419
+ <div className="flex items-center gap-1.5 text-sm text-slate-600">
420
+ <MapPin className="w-4 h-4 text-slate-400" />
421
+ {member.city}
422
+ </div>
423
+ ) : (
424
+ <span className="text-slate-400 text-xs italic">
425
+ Unassigned
426
+ </span>
427
+ )}
428
+ </td>
429
+ <td className="px-6 py-4 text-right">
430
+ <button
431
+ onClick={() => handleDeleteMember(member.id)}
432
+ className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
433
+ title="Remove Member"
434
+ >
435
+ <Trash2 className="w-4 h-4" />
436
+ </button>
437
+ </td>
438
+ </tr>
439
+ ))}
440
+ {members.length === 0 && (
441
+ <tr>
442
+ <td
443
+ colSpan={5}
444
+ className="px-6 py-12 text-center text-slate-500"
445
+ >
446
+ No members found in this department.
447
+ </td>
448
+ </tr>
449
+ )}
450
+ </tbody>
451
+ </table>
452
+ </div>
453
+ </div>
454
+ </div>
455
+ );
456
+ }
Frontend/app/admin/departments/page.tsx ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import { apiGet, apiPost } from "@/lib/api";
4
+ import { Building2, Plus, Search } from "lucide-react";
5
+
6
+ interface Department {
7
+ id: string;
8
+ name: string;
9
+ code: string;
10
+ description: string;
11
+ default_sla_hours: number;
12
+ is_active: boolean;
13
+ member_count: number;
14
+ }
15
+
16
+ export default function DepartmentsPage() {
17
+ const [departments, setDepartments] = useState<Department[]>([]);
18
+ const [loading, setLoading] = useState(true);
19
+ const [showForm, setShowForm] = useState(false);
20
+ const [formData, setFormData] = useState({
21
+ name: "",
22
+ code: "",
23
+ description: "",
24
+ default_sla_hours: 48,
25
+ });
26
+
27
+ useEffect(() => {
28
+ fetchDepartments();
29
+ }, []);
30
+
31
+ const fetchDepartments = async () => {
32
+ try {
33
+ const data = await apiGet<Department[]>("/admin/departments");
34
+ setDepartments(data);
35
+ } catch (error) {
36
+ console.error("Failed to fetch departments:", error);
37
+ } finally {
38
+ setLoading(false);
39
+ }
40
+ };
41
+
42
+ const handleSubmit = async (e: React.FormEvent) => {
43
+ e.preventDefault();
44
+ try {
45
+ await apiPost("/admin/departments", formData);
46
+ setShowForm(false);
47
+ setFormData({
48
+ name: "",
49
+ code: "",
50
+ description: "",
51
+ default_sla_hours: 48,
52
+ });
53
+ fetchDepartments();
54
+ } catch (error: unknown) {
55
+ const message =
56
+ error instanceof Error ? error.message : "Failed to create department";
57
+ alert(message);
58
+ }
59
+ };
60
+
61
+ if (loading) {
62
+ return (
63
+ <div className="text-slate-600 font-medium">Loading Departments...</div>
64
+ );
65
+ }
66
+
67
+ return (
68
+ <div className="space-y-6">
69
+ <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
70
+ <div>
71
+ <h2 className="text-2xl font-bold text-slate-900">Departments</h2>
72
+ <p className="text-sm text-slate-500">
73
+ Organizational units and SLA configurations.
74
+ </p>
75
+ </div>
76
+ <button
77
+ onClick={() => setShowForm(true)}
78
+ className="px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition shadow-sm flex items-center gap-2"
79
+ >
80
+ <Plus className="w-4 h-4" /> New Department
81
+ </button>
82
+ </div>
83
+
84
+ {showForm && (
85
+ <div className="bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden animate-in fade-in slide-in-from-top-4">
86
+ <div className="bg-slate-50 px-6 py-4 border-b border-slate-200">
87
+ <h2 className="text-lg font-bold text-slate-800">
88
+ Create New Department
89
+ </h2>
90
+ </div>
91
+ <form onSubmit={handleSubmit} className="p-6 space-y-6">
92
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
93
+ <div>
94
+ <label className="block text-sm font-medium text-slate-700 mb-1">
95
+ Department Name
96
+ </label>
97
+ <input
98
+ type="text"
99
+ value={formData.name}
100
+ onChange={(e) =>
101
+ setFormData({ ...formData, name: e.target.value })
102
+ }
103
+ className="w-full px-4 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
104
+ placeholder="e.g., Public Works Department"
105
+ required
106
+ />
107
+ </div>
108
+ <div>
109
+ <label className="block text-sm font-medium text-slate-700 mb-1">
110
+ Code
111
+ </label>
112
+ <input
113
+ type="text"
114
+ value={formData.code}
115
+ onChange={(e) =>
116
+ setFormData({
117
+ ...formData,
118
+ code: e.target.value.toUpperCase(),
119
+ })
120
+ }
121
+ className="w-full px-4 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
122
+ placeholder="e.g., PWD"
123
+ required
124
+ />
125
+ </div>
126
+ </div>
127
+
128
+ <div>
129
+ <label
130
+ className="block text-sm font-medium text-slate-700 mb-1"
131
+ htmlFor="dept-desc"
132
+ >
133
+ Description
134
+ </label>
135
+ <textarea
136
+ id="dept-desc"
137
+ value={formData.description}
138
+ onChange={(e) =>
139
+ setFormData({ ...formData, description: e.target.value })
140
+ }
141
+ className="w-full px-4 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
142
+ rows={2}
143
+ placeholder="Brief description of responsibilities..."
144
+ />
145
+ </div>
146
+
147
+ <div>
148
+ <label
149
+ className="block text-sm font-medium text-slate-700 mb-1"
150
+ htmlFor="dept-sla"
151
+ >
152
+ Default SLA (Hours)
153
+ </label>
154
+ <input
155
+ id="dept-sla"
156
+ type="number"
157
+ value={formData.default_sla_hours}
158
+ onChange={(e) =>
159
+ setFormData({
160
+ ...formData,
161
+ default_sla_hours: parseInt(e.target.value),
162
+ })
163
+ }
164
+ className="w-32 px-4 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
165
+ />
166
+ </div>
167
+
168
+ <div className="flex gap-3 pt-2">
169
+ <button
170
+ type="submit"
171
+ className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition shadow-sm"
172
+ >
173
+ Create Department
174
+ </button>
175
+ <button
176
+ type="button"
177
+ onClick={() => setShowForm(false)}
178
+ className="px-6 py-2 bg-white text-slate-700 font-medium rounded-lg border border-slate-300 hover:bg-slate-50 transition"
179
+ >
180
+ Cancel
181
+ </button>
182
+ </div>
183
+ </form>
184
+ </div>
185
+ )}
186
+
187
+ <div className="space-y-4">
188
+ {departments.length === 0 ? (
189
+ <div className="text-center py-16 bg-white rounded-xl border border-slate-200">
190
+ <Building2 className="w-12 h-12 mx-auto text-slate-300" />
191
+ <p className="text-slate-500 mt-4 text-lg">No departments found.</p>
192
+ <p className="text-slate-400 text-sm">
193
+ Create your first organizational unit to get started.
194
+ </p>
195
+ </div>
196
+ ) : (
197
+ <div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
198
+ <table className="min-w-full divide-y divide-slate-200">
199
+ <thead className="bg-slate-50">
200
+ <tr>
201
+ <th className="px-6 py-4 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
202
+ Department
203
+ </th>
204
+ <th className="px-6 py-4 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
205
+ Details
206
+ </th>
207
+ <th className="px-6 py-4 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
208
+ Status
209
+ </th>
210
+ <th className="px-6 py-4 text-right text-xs font-semibold text-slate-500 uppercase tracking-wider">
211
+ Staff Count
212
+ </th>
213
+ </tr>
214
+ </thead>
215
+ <tbody className="divide-y divide-slate-200">
216
+ {departments.map((dept) => (
217
+ <tr key={dept.id} className="hover:bg-slate-50 transition">
218
+ <td className="px-6 py-4 whitespace-nowrap">
219
+ <div className="flex items-center">
220
+ <div className="shrink-0 h-10 w-10 rounded-lg bg-blue-100 text-blue-600 flex items-center justify-center font-bold text-sm">
221
+ {dept.code}
222
+ </div>
223
+ <div className="ml-4">
224
+ <div className="text-sm font-bold text-slate-900">
225
+ {dept.name}
226
+ </div>
227
+ <div className="text-xs text-slate-500">
228
+ Code: {dept.code}
229
+ </div>
230
+ </div>
231
+ </div>
232
+ </td>
233
+ <td className="px-6 py-4">
234
+ <p className="text-sm text-slate-600 line-clamp-1">
235
+ {dept.description || "-"}
236
+ </p>
237
+ <p className="text-xs text-slate-400 mt-0.5">
238
+ SLA: {dept.default_sla_hours}h
239
+ </p>
240
+ </td>
241
+ <td className="px-6 py-4 whitespace-nowrap">
242
+ <span
243
+ className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
244
+ dept.is_active
245
+ ? "bg-green-100 text-green-800"
246
+ : "bg-red-100 text-red-800"
247
+ }`}
248
+ >
249
+ {dept.is_active ? "Active" : "Inactive"}
250
+ </span>
251
+ </td>
252
+ <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
253
+ <span className="text-slate-900 font-bold">
254
+ {dept.member_count}
255
+ </span>
256
+ <span className="text-slate-500 ml-1">staff</span>
257
+ </td>
258
+ </tr>
259
+ ))}
260
+ </tbody>
261
+ </table>
262
+ </div>
263
+ )}
264
+ </div>
265
+ </div>
266
+ );
267
+ }
Frontend/app/admin/heatmap/page.tsx ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import { apiGet } from "@/lib/api";
4
+ import { Map } from "lucide-react";
5
+
6
+ interface HeatmapData {
7
+ city: string;
8
+ count: number;
9
+ priority_avg: number;
10
+ }
11
+
12
+ export default function HeatmapPage() {
13
+ const [data, setData] = useState<HeatmapData[]>([]);
14
+ const [loading, setLoading] = useState(true);
15
+
16
+ useEffect(() => {
17
+ fetchHeatmap();
18
+ }, []);
19
+
20
+ const fetchHeatmap = async () => {
21
+ try {
22
+ const heatmapData = await apiGet<HeatmapData[]>("/admin/stats/heatmap");
23
+ setData(heatmapData);
24
+ } catch (error) {
25
+ console.error("Failed to fetch heatmap:", error);
26
+ } finally {
27
+ setLoading(false);
28
+ }
29
+ };
30
+
31
+ const getIntensityColor = (count: number, max: number) => {
32
+ const intensity = count / max;
33
+ if (intensity > 0.75) return "bg-red-600";
34
+ if (intensity > 0.5) return "bg-orange-500";
35
+ if (intensity > 0.25) return "bg-amber-400";
36
+ return "bg-emerald-500";
37
+ };
38
+
39
+ if (loading) {
40
+ return <div className="text-slate-600 font-medium">Loading Analytics...</div>;
41
+ }
42
+
43
+ const maxCount = Math.max(...data.map((d) => d.count), 1);
44
+
45
+ return (
46
+ <div className="space-y-6">
47
+ <div>
48
+ <h2 className="text-2xl font-bold text-slate-900">Geographic Heatmap</h2>
49
+ <p className="text-sm text-slate-500">Distribution of issues across city districts.</p>
50
+ </div>
51
+
52
+ <div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm mb-8">
53
+ <div className="flex items-center justify-between">
54
+ <h2 className="text-lg font-bold text-slate-900">Issue Density by City</h2>
55
+ <div className="flex gap-4 text-xs font-semibold uppercase text-slate-500">
56
+ <div className="flex items-center gap-2"><div className="w-3 h-3 rounded-full bg-emerald-500"></div> Low</div>
57
+ <div className="flex items-center gap-2"><div className="w-3 h-3 rounded-full bg-amber-400"></div> Medium</div>
58
+ <div className="flex items-center gap-2"><div className="w-3 h-3 rounded-full bg-orange-500"></div> High</div>
59
+ <div className="flex items-center gap-2"><div className="w-3 h-3 rounded-full bg-red-600"></div> Critical</div>
60
+ </div>
61
+ </div>
62
+ </div>
63
+
64
+ {data.length === 0 ? (
65
+ <div className="text-center py-16 bg-white rounded-xl border border-slate-200">
66
+ <Map className="w-12 h-12 mx-auto text-slate-300" />
67
+ <p className="text-slate-500 mt-2">No location data available yet.</p>
68
+ </div>
69
+ ) : (
70
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
71
+ {data.map((item) => (
72
+ <div
73
+ key={item.city}
74
+ className="group relative bg-white overflow-hidden rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-all"
75
+ >
76
+ <div className="absolute top-0 left-0 w-full h-1.5 bg-slate-100">
77
+ <div
78
+ className={`h-full ${getIntensityColor(item.count, maxCount)}`}
79
+ style={{ width: `${(item.count / maxCount) * 100}%` }}
80
+ ></div>
81
+ </div>
82
+
83
+ <div className="p-6">
84
+ <div className="flex justify-between items-start mb-4">
85
+ <h3 className="text-lg font-bold text-slate-900">{item.city}</h3>
86
+ <span className={`w-8 h-8 flex items-center justify-center rounded-lg text-white font-bold text-sm ${getIntensityColor(item.count, maxCount)}`}>
87
+ {Math.round((item.count / maxCount) * 10)}
88
+ </span>
89
+ </div>
90
+
91
+ <div className="flex items-baseline gap-1">
92
+ <span className="text-3xl font-extrabold text-slate-900">{item.count}</span>
93
+ <span className="text-sm font-medium text-slate-500">issues</span>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ ))}
98
+ </div>
99
+ )}
100
+ </div>
101
+ );
102
+ }
Frontend/app/admin/issues/[id]/page.tsx ADDED
@@ -0,0 +1,660 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ export const runtime = "edge";
4
+
5
+ import { useEffect, useState } from "react";
6
+ import { apiGet, apiPost } from "@/lib/api";
7
+ import { useParams, useRouter } from "next/navigation";
8
+ import {
9
+ ArrowLeft,
10
+ MapPin,
11
+ Clock,
12
+ AlertTriangle,
13
+ CheckCircle2,
14
+ User,
15
+ Building2,
16
+ Calendar,
17
+ Activity,
18
+ Layers,
19
+ Image as ImageIcon,
20
+ ThumbsUp,
21
+ ThumbsDown,
22
+ MoreHorizontal,
23
+ Pencil,
24
+ Save,
25
+ X,
26
+ } from "lucide-react";
27
+ import Link from "next/link";
28
+
29
+ interface IssueDetail {
30
+ issue: {
31
+ id: string;
32
+ description: string;
33
+ state: string;
34
+ priority: number;
35
+ latitude: number;
36
+ longitude: number;
37
+ city: string;
38
+ locality: string;
39
+ full_address: string;
40
+ image_urls: string[];
41
+ annotated_urls: string[];
42
+ proof_image_url: string | null;
43
+ created_at: string;
44
+ confidence: number;
45
+ category: string;
46
+ validation_source: string;
47
+ validation_reason: string;
48
+ is_duplicate: boolean;
49
+ sla_deadline: string;
50
+ assigned_member_id?: string;
51
+ };
52
+ department: {
53
+ id: string;
54
+ name: string;
55
+ } | null;
56
+ worker: {
57
+ id: string;
58
+ name: string;
59
+ email: string;
60
+ } | null;
61
+ events: {
62
+ id: string;
63
+ type: string;
64
+ agent: string;
65
+ data: string;
66
+ created_at: string;
67
+ }[];
68
+ duplicates: any[];
69
+ }
70
+
71
+ interface Worker {
72
+ id: string;
73
+ name: string;
74
+ }
75
+
76
+ export default function IssueDetailPage() {
77
+ const { id } = useParams();
78
+ const router = useRouter();
79
+ const [data, setData] = useState<IssueDetail | null>(null);
80
+ const [loading, setLoading] = useState(true);
81
+ const [actionLoading, setActionLoading] = useState(false);
82
+
83
+ const [workers, setWorkers] = useState<Worker[]>([]);
84
+ const [editingAssignment, setEditingAssignment] = useState(false);
85
+ const [editingPriority, setEditingPriority] = useState(false);
86
+ const [selectedWorker, setSelectedWorker] = useState("");
87
+ const [selectedPriority, setSelectedPriority] = useState(0);
88
+
89
+ const API_URL = process.env.NEXT_PUBLIC_API_URL;
90
+ if (!API_URL) throw new Error("Missing NEXT_PUBLIC_API_URL");
91
+
92
+ useEffect(() => {
93
+ if (id) {
94
+ fetchIssueDetails();
95
+ fetchWorkers();
96
+ }
97
+ }, [id]);
98
+
99
+ const fetchWorkers = async () => {
100
+ try {
101
+ const res = await apiGet<Worker[]>("/admin/members?role=worker");
102
+ setWorkers(res || []);
103
+ } catch (e) {
104
+ console.error("Failed to fetch workers", e);
105
+ }
106
+ };
107
+
108
+ const fetchIssueDetails = async () => {
109
+ try {
110
+ const result = await apiGet<IssueDetail>(`/admin/issues/${id}/details`);
111
+ setData(result);
112
+ if (result) {
113
+ setSelectedPriority(result.issue.priority || 3);
114
+ setSelectedWorker(result.worker?.id || "");
115
+ }
116
+ } catch (error) {
117
+ console.error("Failed to fetch details:", error);
118
+ } finally {
119
+ setLoading(false);
120
+ }
121
+ };
122
+
123
+ const handleUpdate = async (updateData: any) => {
124
+ setActionLoading(true);
125
+ try {
126
+ const token = localStorage.getItem("token");
127
+ const headers: any = { "Content-Type": "application/json" };
128
+ if (token) headers["Authorization"] = `Bearer ${token}`;
129
+
130
+ const res = await fetch(`${API_URL}/admin/issues/${id}`, {
131
+ method: "PATCH",
132
+ headers,
133
+ body: JSON.stringify(updateData),
134
+ });
135
+
136
+ if (!res.ok) throw new Error("Failed to update");
137
+
138
+ await fetchIssueDetails();
139
+ setEditingAssignment(false);
140
+ setEditingPriority(false);
141
+ } catch (e) {
142
+ console.error(e);
143
+ alert("Update failed");
144
+ } finally {
145
+ setActionLoading(false);
146
+ }
147
+ };
148
+
149
+ const handleReview = async (status: "approved" | "rejected") => {
150
+ if (!confirm(`Are you sure you want to ${status} this issue?`)) return;
151
+ setActionLoading(true);
152
+ try {
153
+ await apiPost(`/admin/issues/${id}/review`, { status });
154
+ fetchIssueDetails();
155
+ } catch (error) {
156
+ console.error("Review failed:", error);
157
+ alert("Failed to update issue status.");
158
+ } finally {
159
+ setActionLoading(false);
160
+ }
161
+ };
162
+
163
+ const handleResolutionReview = async (action: "approve" | "reject") => {
164
+ const note = prompt(
165
+ action === "reject" ? "Reason for rejection:" : "Optional approval note:",
166
+ );
167
+ if (action === "reject" && !note) return;
168
+
169
+ setActionLoading(true);
170
+ try {
171
+ const token = localStorage.getItem("token");
172
+ const headers: any = { "Content-Type": "application/json" };
173
+ if (token) headers["Authorization"] = `Bearer ${token}`;
174
+
175
+ const res = await fetch(
176
+ `${API_URL}/admin/issues/${id}/approve_resolution`,
177
+ {
178
+ method: "POST",
179
+ headers,
180
+ body: JSON.stringify({ action, comment: note }),
181
+ },
182
+ );
183
+
184
+ if (!res.ok) throw new Error("Status update failed");
185
+
186
+ await fetchIssueDetails();
187
+ } catch (error) {
188
+ console.error("Resolution review failed:", error);
189
+ alert("Failed to update resolution status.");
190
+ } finally {
191
+ setActionLoading(false);
192
+ }
193
+ };
194
+
195
+ if (loading)
196
+ return (
197
+ <div className="p-8 text-center text-slate-500">Loading details...</div>
198
+ );
199
+ if (!data)
200
+ return <div className="p-8 text-center text-red-500">Issue not found</div>;
201
+
202
+ const { issue, department, worker, events } = data;
203
+
204
+ return (
205
+ <div className="max-w-7xl mx-auto space-y-6">
206
+ <div className="flex items-center justify-between">
207
+ <div className="flex items-center gap-4">
208
+ <Link
209
+ href="/admin/issues"
210
+ className="p-2 hover:bg-slate-100 rounded-full text-slate-500"
211
+ >
212
+ <ArrowLeft className="w-5 h-5" />
213
+ </Link>
214
+ <div>
215
+ <h1 className="text-2xl font-bold text-slate-900 flex items-center gap-3">
216
+ Issue #{issue.id.slice(0, 8)}
217
+ <span
218
+ className={`text-sm px-2.5 py-0.5 rounded-full border font-medium uppercase tracking-wide
219
+ ${
220
+ issue.state === "reported"
221
+ ? "bg-blue-50 text-blue-700 border-blue-200"
222
+ : issue.state === "resolved"
223
+ ? "bg-emerald-50 text-emerald-700 border-emerald-200"
224
+ : "bg-slate-50 text-slate-700 border-slate-200"
225
+ }`}
226
+ >
227
+ {issue.state.replace("_", " ")}
228
+ </span>
229
+ </h1>
230
+ <p className="text-slate-500 text-sm flex items-center gap-2 mt-1">
231
+ <Calendar className="w-4 h-4" />
232
+ Reported on {new Date(issue.created_at).toLocaleString()}
233
+ </p>
234
+ </div>
235
+ </div>
236
+
237
+ <div className="flex items-center gap-3">
238
+ {issue.state === "pending_confirmation" && (
239
+ <>
240
+ <button
241
+ onClick={() => handleReview("rejected")}
242
+ disabled={actionLoading}
243
+ className="px-4 py-2 text-sm font-medium text-red-600 bg-white border border-red-200 rounded-lg hover:bg-red-50"
244
+ >
245
+ Reject & Close
246
+ </button>
247
+ <button
248
+ onClick={() => handleReview("approved")}
249
+ disabled={actionLoading}
250
+ className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
251
+ >
252
+ Approve & Assign
253
+ </button>
254
+ </>
255
+ )}
256
+
257
+ {issue.state === "pending_verification" && (
258
+ <>
259
+ <button
260
+ onClick={() => handleResolutionReview("reject")}
261
+ disabled={actionLoading}
262
+ className="px-4 py-2 text-sm font-medium text-red-600 bg-white border border-red-200 rounded-lg hover:bg-red-50"
263
+ >
264
+ Reject Incomplete Work
265
+ </button>
266
+ <button
267
+ onClick={() => handleResolutionReview("approve")}
268
+ disabled={actionLoading}
269
+ className="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700"
270
+ >
271
+ Verify & Approve
272
+ </button>
273
+ </>
274
+ )}
275
+
276
+ <button
277
+ className="p-2 text-slate-400 hover:text-slate-600"
278
+ aria-label="More options"
279
+ >
280
+ <MoreHorizontal className="w-5 h-5" />
281
+ </button>
282
+ </div>
283
+ </div>
284
+
285
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
286
+ <div className="lg:col-span-2 space-y-6">
287
+ <div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
288
+ <h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
289
+ <ImageIcon className="w-5 h-5 text-slate-400" />
290
+ Evidence Photos
291
+ </h3>
292
+ <div className="space-y-6">
293
+ <div>
294
+ <h4 className="text-sm font-semibold text-slate-700 mb-3">
295
+ Original Report
296
+ </h4>
297
+ <div className="grid grid-cols-2 gap-4">
298
+ {issue.image_urls.map((url, idx) => (
299
+ <div
300
+ key={idx}
301
+ className="aspect-video bg-slate-100 rounded-lg overflow-hidden border border-slate-200 relative group"
302
+ >
303
+ <img
304
+ src={url}
305
+ alt={`Original ${idx + 1}`}
306
+ className="w-full h-full object-cover"
307
+ />
308
+ <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
309
+ <a
310
+ href={url}
311
+ target="_blank"
312
+ rel="noopener noreferrer"
313
+ className="text-white text-xs font-medium bg-white/20 px-3 py-1 rounded-full backdrop-blur-sm"
314
+ >
315
+ View Full
316
+ </a>
317
+ </div>
318
+ </div>
319
+ ))}
320
+ </div>
321
+ </div>
322
+
323
+ {issue.annotated_urls && issue.annotated_urls.length > 0 && (
324
+ <div>
325
+ <h4 className="text-sm font-semibold text-slate-700 mb-3">
326
+ AI Analysis
327
+ </h4>
328
+ <div className="grid grid-cols-2 gap-4">
329
+ {issue.annotated_urls.map((url, idx) => (
330
+ <div
331
+ key={idx}
332
+ className="aspect-video bg-slate-100 rounded-lg overflow-hidden border border-blue-200 relative group"
333
+ >
334
+ <img
335
+ src={url}
336
+ alt={`Analyzed ${idx + 1}`}
337
+ className="w-full h-full object-cover"
338
+ />
339
+ <div className="absolute top-2 left-2 bg-blue-500 text-white text-xs px-2 py-0.5 rounded-full font-semibold">
340
+ AI Detected
341
+ </div>
342
+ <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
343
+ <a
344
+ href={url}
345
+ target="_blank"
346
+ rel="noopener noreferrer"
347
+ className="text-white text-xs font-medium bg-white/20 px-3 py-1 rounded-full backdrop-blur-sm"
348
+ >
349
+ View Full
350
+ </a>
351
+ </div>
352
+ </div>
353
+ ))}
354
+ </div>
355
+ </div>
356
+ )}
357
+
358
+ {issue.proof_image_url && (
359
+ <div>
360
+ <h4 className="text-sm font-semibold text-slate-700 mb-3">
361
+ Work Completion Proof
362
+ </h4>
363
+ <div className="grid grid-cols-2 gap-4">
364
+ <div className="aspect-video bg-slate-100 rounded-lg overflow-hidden border border-green-200 relative group">
365
+ <img
366
+ src={issue.proof_image_url}
367
+ alt="Work Proof"
368
+ className="w-full h-full object-cover"
369
+ />
370
+ <div className="absolute top-2 left-2 bg-green-500 text-white text-xs px-2 py-0.5 rounded-full font-semibold">
371
+ Worker Proof
372
+ </div>
373
+ <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
374
+ <a
375
+ href={issue.proof_image_url}
376
+ target="_blank"
377
+ rel="noopener noreferrer"
378
+ className="text-white text-xs font-medium bg-white/20 px-3 py-1 rounded-full backdrop-blur-sm"
379
+ >
380
+ View Full
381
+ </a>
382
+ </div>
383
+ </div>
384
+ </div>
385
+ </div>
386
+ )}
387
+
388
+ {issue.image_urls.length === 0 && !issue.proof_image_url && (
389
+ <div className="py-8 text-center text-slate-400 bg-slate-50 rounded-lg border border-dashed border-slate-300">
390
+ No images attached
391
+ </div>
392
+ )}
393
+ </div>
394
+ </div>
395
+
396
+ <div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
397
+ <h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
398
+ <Activity className="w-5 h-5 text-slate-400" />
399
+ Analysis & Details
400
+ </h3>
401
+
402
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
403
+ <div className="space-y-4">
404
+ <div>
405
+ <label className="text-xs font-semibold text-slate-500 uppercase">
406
+ Category
407
+ </label>
408
+ <div className="text-slate-900 font-medium">
409
+ {issue.category || "Uncategorized"}
410
+ </div>
411
+ {issue.confidence > 0 && (
412
+ <div className="text-xs text-slate-500">
413
+ AI Confidence: {(issue.confidence * 100).toFixed(1)}%
414
+ </div>
415
+ )}
416
+ </div>
417
+ <div>
418
+ <label className="text-xs font-semibold text-slate-500 uppercase">
419
+ Description
420
+ </label>
421
+ <p className="text-slate-900 text-sm leading-relaxed">
422
+ {issue.description || "No description provided."}
423
+ </p>
424
+ </div>
425
+ </div>
426
+
427
+ <div className="space-y-4">
428
+ <div>
429
+ <label className="text-xs font-semibold text-slate-500 uppercase">
430
+ Location
431
+ </label>
432
+ <div className="flex items-start gap-2 text-slate-900">
433
+ <MapPin className="w-4 h-4 text-slate-400 mt-0.5 shrink-0" />
434
+ <div className="text-sm">
435
+ <div className="font-medium">
436
+ {issue.locality
437
+ ? `${issue.locality}, ${issue.city}`
438
+ : issue.city}
439
+ </div>
440
+ {issue.full_address && (
441
+ <div className="text-slate-600 text-xs mt-0.5 leading-relaxed border-l-2 border-slate-200 pl-2 my-1">
442
+ {issue.full_address}
443
+ </div>
444
+ )}
445
+ <div className="text-slate-400 text-xs font-mono mt-1">
446
+ {issue.latitude.toFixed(6)},{" "}
447
+ {issue.longitude.toFixed(6)}
448
+ </div>
449
+ </div>
450
+ </div>
451
+ </div>
452
+
453
+ <div className="group">
454
+ <div className="flex items-center justify-between">
455
+ <label className="text-xs font-semibold text-slate-500 uppercase">
456
+ Priority
457
+ </label>
458
+ <button
459
+ onClick={() => setEditingPriority(!editingPriority)}
460
+ className="p-1 opacity-0 group-hover:opacity-100 hover:bg-slate-100 rounded text-slate-400 hover:text-blue-500 transition-all"
461
+ aria-label="Edit Priority"
462
+ >
463
+ <Pencil className="w-3 h-3" />
464
+ </button>
465
+ </div>
466
+
467
+ {editingPriority ? (
468
+ <div className="flex gap-2 mt-1">
469
+ <select
470
+ aria-label="Select priority"
471
+ value={selectedPriority}
472
+ onChange={(e) =>
473
+ setSelectedPriority(Number(e.target.value))
474
+ }
475
+ className="bg-white border rounded px-2 py-1 text-sm flex-1"
476
+ >
477
+ <option value="1">P1 - Critical</option>
478
+ <option value="2">P2 - High</option>
479
+ <option value="3">P3 - Medium</option>
480
+ <option value="4">P4 - Low</option>
481
+ </select>
482
+ <button
483
+ onClick={() =>
484
+ handleUpdate({ priority: selectedPriority })
485
+ }
486
+ className="p-1.5 bg-blue-100 text-blue-600 rounded hover:bg-blue-200"
487
+ aria-label="Save"
488
+ >
489
+ <Save className="w-4 h-4" />
490
+ </button>
491
+ <button
492
+ onClick={() => setEditingPriority(false)}
493
+ className="p-1.5 bg-slate-100 text-slate-600 rounded hover:bg-slate-200"
494
+ aria-label="Cancel"
495
+ >
496
+ <X className="w-4 h-4" />
497
+ </button>
498
+ </div>
499
+ ) : (
500
+ <div
501
+ className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-sm font-bold mt-1
502
+ ${
503
+ issue.priority === 1
504
+ ? "bg-red-100 text-red-700"
505
+ : issue.priority === 2
506
+ ? "bg-orange-100 text-orange-700"
507
+ : issue.priority === 3
508
+ ? "bg-yellow-100 text-yellow-700"
509
+ : "bg-green-100 text-green-700"
510
+ }`}
511
+ >
512
+ P{issue.priority} Level
513
+ </div>
514
+ )}
515
+ </div>
516
+ </div>
517
+ </div>
518
+ </div>
519
+ </div>
520
+
521
+ <div className="space-y-6">
522
+ <div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
523
+ <h3 className="text-sm font-bold text-slate-900 uppercase tracking-wide mb-4">
524
+ Assignment
525
+ </h3>
526
+
527
+ <div className="space-y-4">
528
+ <div className="flex items-start gap-3">
529
+ <div className="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600">
530
+ <Building2 className="w-5 h-5" />
531
+ </div>
532
+ <div>
533
+ <div className="text-xs text-slate-500">Department</div>
534
+ <div className="text-sm font-semibold text-slate-900">
535
+ {department?.name || "Not Assigned"}
536
+ </div>
537
+ </div>
538
+ </div>
539
+
540
+ <div className="flex items-start gap-3 group relative">
541
+ <div className="absolute right-0 top-0 opacity-0 group-hover:opacity-100 transition-opacity">
542
+ <button
543
+ onClick={() => setEditingAssignment(!editingAssignment)}
544
+ className="p-1 hover:bg-slate-100 rounded text-slate-400 hover:text-blue-500"
545
+ aria-label="Edit Assignment"
546
+ >
547
+ <Pencil className="w-3 h-3" />
548
+ </button>
549
+ </div>
550
+ <div className="w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center text-slate-500 shrink-0">
551
+ <User className="w-5 h-5" />
552
+ </div>
553
+ <div className="flex-1">
554
+ <div className="text-xs text-slate-500">Worker</div>
555
+
556
+ {editingAssignment ? (
557
+ <div className="flex flex-col gap-2 mt-1">
558
+ <select
559
+ aria-label="Select worker"
560
+ value={selectedWorker}
561
+ onChange={(e) => setSelectedWorker(e.target.value)}
562
+ className="bg-white border rounded px-2 py-1 text-sm w-full"
563
+ >
564
+ <option value="">Unassigned</option>
565
+ {workers.map((w) => (
566
+ <option key={w.id} value={w.id}>
567
+ {w.name}
568
+ </option>
569
+ ))}
570
+ </select>
571
+ <div className="flex gap-2">
572
+ <button
573
+ onClick={() =>
574
+ handleUpdate({
575
+ assigned_member_id: selectedWorker || null,
576
+ })
577
+ }
578
+ className="flex-1 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
579
+ >
580
+ Save
581
+ </button>
582
+ <button
583
+ onClick={() => setEditingAssignment(false)}
584
+ className="flex-1 py-1 bg-slate-200 text-slate-700 text-xs rounded hover:bg-slate-300"
585
+ >
586
+ Cancel
587
+ </button>
588
+ </div>
589
+ </div>
590
+ ) : (
591
+ <>
592
+ <div className="text-sm font-semibold text-slate-900">
593
+ {worker?.name || "Unassigned"}
594
+ </div>
595
+ {worker && (
596
+ <div className="text-xs text-slate-400">
597
+ {worker.email}
598
+ </div>
599
+ )}
600
+ </>
601
+ )}
602
+ </div>
603
+ </div>
604
+
605
+ {issue.sla_deadline && (
606
+ <div className="pt-4 border-t border-slate-100">
607
+ <div className="flex items-center gap-2 text-amber-600 bg-amber-50 px-3 py-2 rounded-lg">
608
+ <Clock className="w-4 h-4" />
609
+ <div>
610
+ <div className="text-[10px] font-bold uppercase">
611
+ SLA Deadline
612
+ </div>
613
+ <div className="text-xs font-semibold">
614
+ {new Date(issue.sla_deadline).toLocaleString()}
615
+ </div>
616
+ </div>
617
+ </div>
618
+ </div>
619
+ )}
620
+ </div>
621
+ </div>
622
+
623
+ <div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm relative overflow-hidden">
624
+ <h3 className="text-sm font-bold text-slate-900 uppercase tracking-wide mb-6">
625
+ Activity Audit
626
+ </h3>
627
+
628
+ <div className="absolute top-0 right-0 p-4 opacity-5">
629
+ <Layers className="w-24 h-24" />
630
+ </div>
631
+
632
+ <div className="space-y-0 text-sm relative border-l-2 border-slate-100 ml-2">
633
+ {events.map((event, idx) => (
634
+ <div key={idx} className="pl-6 pb-6 relative last:pb-0">
635
+ <div className="absolute -left-2.25 top-0 w-4 h-4 rounded-full bg-white border-2 border-blue-500"></div>
636
+ <div className="flex flex-col">
637
+ <span className="font-semibold text-slate-900">
638
+ {event.agent || "System"}
639
+ </span>
640
+ <span className="text-xs text-slate-500 mb-1">
641
+ {new Date(event.created_at).toLocaleString()}
642
+ </span>
643
+ <span className="text-slate-600 bg-slate-50 p-2 rounded border border-slate-100">
644
+ {event.data}
645
+ </span>
646
+ </div>
647
+ </div>
648
+ ))}
649
+ {events.length === 0 && (
650
+ <div className="pl-6 text-slate-400 italic">
651
+ No activity recorded.
652
+ </div>
653
+ )}
654
+ </div>
655
+ </div>
656
+ </div>
657
+ </div>
658
+ </div>
659
+ );
660
+ }
Frontend/app/admin/issues/page.tsx ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useState, useMemo } from "react";
3
+ import { useRouter, useSearchParams } from "next/navigation";
4
+ import { Search, ChevronRight, AlertCircle, ArrowUpDown } from "lucide-react";
5
+ import Link from "next/link";
6
+ import { useCachedFetch } from "@/hooks/useCachedFetch";
7
+
8
+ interface AdminIssueListItem {
9
+ id: string;
10
+ description: string;
11
+ state: string;
12
+ priority: number;
13
+ city: string;
14
+ created_at: string;
15
+ department: string;
16
+ assigned_to: string;
17
+ category: string;
18
+ thumbnail: string;
19
+ locality?: string;
20
+ }
21
+
22
+ interface Meta {
23
+ total: number;
24
+ page: number;
25
+ limit: number;
26
+ pages: number;
27
+ }
28
+
29
+ interface IssuesResponse {
30
+ items: AdminIssueListItem[];
31
+ total: number;
32
+ page: number;
33
+ limit: number;
34
+ pages: number;
35
+ }
36
+
37
+ export default function IssuesPage() {
38
+ const router = useRouter();
39
+ const searchParams = useSearchParams();
40
+
41
+ const [page, setPage] = useState(1);
42
+ const limit = 10;
43
+
44
+ const [search, setSearch] = useState("");
45
+ const [status, setStatus] = useState<string>("");
46
+ const [priority, setPriority] = useState<string>("");
47
+ const [sort, setSort] = useState("created_at");
48
+ const [order, setOrder] = useState("desc");
49
+
50
+ const [debouncedSearch, setDebouncedSearch] = useState("");
51
+
52
+ useEffect(() => {
53
+ const statusParam = searchParams.get("status");
54
+ if (statusParam) {
55
+ setStatus(statusParam);
56
+ }
57
+ }, [searchParams]);
58
+
59
+ useEffect(() => {
60
+ const handler = setTimeout(() => {
61
+ setDebouncedSearch(search);
62
+ }, 500);
63
+ return () => clearTimeout(handler);
64
+ }, [search]);
65
+
66
+ // Construct Query URL dynamically
67
+ const queryUrl = useMemo(() => {
68
+ const query = new URLSearchParams({
69
+ page: page.toString(),
70
+ limit: limit.toString(),
71
+ sort_by: sort,
72
+ sort_order: order,
73
+ });
74
+
75
+ if (debouncedSearch) query.append("search", debouncedSearch);
76
+ if (status) query.append("status", status);
77
+ if (priority) query.append("priority", priority);
78
+
79
+ return `/admin/issues?${query.toString()}`;
80
+ }, [page, limit, sort, order, debouncedSearch, status, priority]);
81
+
82
+ const { data: issuesData, loading } = useCachedFetch<IssuesResponse>(queryUrl);
83
+
84
+ const issues = issuesData?.items || [];
85
+ const meta: Meta = {
86
+ total: issuesData?.total || 0,
87
+ page: issuesData?.page || 1,
88
+ limit: issuesData?.limit || 10,
89
+ pages: issuesData?.pages || 0,
90
+ };
91
+
92
+ const handlePageChange = (newPage: number) => {
93
+ if (newPage > 0 && newPage <= meta.pages) {
94
+ setPage(newPage);
95
+ }
96
+ };
97
+
98
+ const getStateBadge = (state: string) => {
99
+ const styles: Record<string, string> = {
100
+ reported: "bg-blue-100 text-blue-700 border-blue-200",
101
+ assigned: "bg-purple-100 text-purple-700 border-purple-200",
102
+ in_progress: "bg-amber-100 text-amber-700 border-amber-200",
103
+ pending_verification: "bg-orange-100 text-orange-700 border-orange-200",
104
+ resolved: "bg-emerald-100 text-emerald-700 border-emerald-200",
105
+ closed: "bg-slate-100 text-slate-600 border-slate-200",
106
+ escalated: "bg-red-100 text-red-700 border-red-200 animate-pulse",
107
+ rejected: "bg-gray-100 text-gray-500 border-gray-200 line-through",
108
+ verified: "bg-indigo-100 text-indigo-700 border-indigo-200",
109
+ };
110
+ return (
111
+ <span
112
+ className={`px-2.5 py-0.5 rounded-full text-xs font-semibold border ${
113
+ styles[state] || "bg-gray-100 text-gray-800"
114
+ }`}
115
+ >
116
+ {state.replace("_", " ").toUpperCase()}
117
+ </span>
118
+ );
119
+ };
120
+
121
+ return (
122
+ <div className="space-y-6">
123
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
124
+ <div>
125
+ <h1 className="text-2xl font-bold text-slate-900 tracking-tight">
126
+ Issue Management
127
+ </h1>
128
+ <p className="text-slate-500 text-sm">
129
+ Monitor, assign, and resolve reported city issues.
130
+ </p>
131
+ </div>
132
+ <div className="flex items-center gap-2">
133
+ <button
134
+ onClick={() => setStatus("pending_verification")}
135
+ className="px-4 py-2 bg-orange-100 text-orange-800 text-sm font-medium rounded-lg hover:bg-orange-200 transition flex items-center gap-2 border border-orange-200"
136
+ >
137
+ <AlertCircle className="w-4 h-4" />
138
+ Pending Reviews
139
+ </button>
140
+ <button className="bg-slate-900 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-slate-800 transition">
141
+ Export CSV
142
+ </button>
143
+ </div>
144
+ </div>
145
+
146
+ <div className="bg-white/60 backdrop-blur-md rounded-2xl border border-slate-200/60 shadow-urban-sm p-4 transition-all">
147
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
148
+ <div className="md:col-span-2 relative group">
149
+ <Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400 group-focus-within:text-blue-500 transition-colors" />
150
+ <input
151
+ type="text"
152
+ placeholder="Search by ID, description, or location..."
153
+ value={search}
154
+ onChange={(e) => setSearch(e.target.value)}
155
+ className="w-full pl-10 pr-4 py-2 text-sm border border-slate-200 bg-white/50 rounded-xl focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 transition-all font-sans"
156
+ />
157
+ </div>
158
+
159
+ <select
160
+ aria-label="Filter by Status"
161
+ value={status}
162
+ onChange={(e) => setStatus(e.target.value)}
163
+ className="px-3 py-2 text-sm border border-slate-200 bg-white/50 rounded-xl focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 transition-all font-sans"
164
+ >
165
+ <option value="">All Statuses</option>
166
+ <option value="reported">Reported</option>
167
+ <option value="verified">Verified</option>
168
+ <option value="assigned">Assigned</option>
169
+ <option value="in_progress">In Progress</option>
170
+ <option value="pending_verification">Pending Verification</option>
171
+ <option value="resolved">Resolved</option>
172
+ <option value="closed">Closed</option>
173
+ <option value="escalated">Escalated</option>
174
+ </select>
175
+
176
+ <select
177
+ aria-label="Filter by Priority"
178
+ value={priority}
179
+ onChange={(e) => setPriority(e.target.value)}
180
+ className="px-3 py-2 text-sm border border-slate-200 bg-white/50 rounded-xl focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 transition-all font-sans"
181
+ >
182
+ <option value="">All Priorities</option>
183
+ <option value="1">Critical (P1)</option>
184
+ <option value="2">High (P2)</option>
185
+ <option value="3">Medium (P3)</option>
186
+ <option value="4">Low (P4)</option>
187
+ </select>
188
+ </div>
189
+
190
+ {loading ? (
191
+ <div className="h-64 flex items-center justify-center text-slate-500">
192
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mr-2"></div>
193
+ Loading issues...
194
+ </div>
195
+ ) : (
196
+ <div className="overflow-x-auto rounded-xl border border-slate-200/50">
197
+ <table className="w-full text-left border-collapse">
198
+ <thead>
199
+ <tr className="border-b border-slate-200/60 text-xs uppercase text-slate-500 bg-slate-50/80 font-mono tracking-wider">
200
+ <th className="px-4 py-3 font-semibold">Issue</th>
201
+ <th className="px-4 py-3 font-semibold">Location</th>
202
+ <th className="px-4 py-3 font-semibold">
203
+ <button
204
+ onClick={() => {
205
+ setSort("priority");
206
+ setOrder(order === "asc" ? "desc" : "asc");
207
+ }}
208
+ className="flex items-center gap-1 hover:text-slate-800 transition-colors"
209
+ >
210
+ Priority <ArrowUpDown className="w-3 h-3" />
211
+ </button>
212
+ </th>
213
+ <th className="px-4 py-3 font-semibold">Status</th>
214
+ <th className="px-4 py-3 font-semibold">Assigned To</th>
215
+ <th className="px-4 py-3 font-semibold">
216
+ <button
217
+ onClick={() => {
218
+ setSort("created_at");
219
+ setOrder(order === "asc" ? "desc" : "asc");
220
+ }}
221
+ className="flex items-center gap-1 hover:text-slate-800 transition-colors"
222
+ >
223
+ Date <ArrowUpDown className="w-3 h-3" />
224
+ </button>
225
+ </th>
226
+ <th className="px-4 py-3 font-semibold text-right">Action</th>
227
+ </tr>
228
+ </thead>
229
+ <tbody className="divide-y divide-slate-100">
230
+ {issues.length === 0 ? (
231
+ <tr>
232
+ <td
233
+ colSpan={7}
234
+ className="px-4 py-8 text-center text-slate-500"
235
+ >
236
+ No issues found matching your filters.
237
+ </td>
238
+ </tr>
239
+ ) : (
240
+ issues.map((issue) => (
241
+ <tr
242
+ key={issue.id}
243
+ className="group hover:bg-blue-50/30 transition-colors duration-200"
244
+ >
245
+ <td className="px-4 py-3">
246
+ <div className="flex items-center gap-3">
247
+ <div className="h-10 w-10 rounded-lg bg-slate-100 overflow-hidden shrink-0 relative border border-slate-200">
248
+ {issue.thumbnail ? (
249
+ <img
250
+ src={issue.thumbnail}
251
+ alt=""
252
+ className="h-full w-full object-cover"
253
+ />
254
+ ) : (
255
+ <div className="h-full w-full flex items-center justify-center text-slate-400">
256
+ <AlertCircle className="w-5 h-5" />
257
+ </div>
258
+ )}
259
+ </div>
260
+ <div>
261
+ <div className="text-sm font-semibold text-slate-900 truncate max-w-50 font-display">
262
+ {issue.category || "Uncategorized Issue"}
263
+ </div>
264
+ <div className="text-xs text-slate-500 truncate max-w-50 font-sans">
265
+ {issue.description || "No description provided"}
266
+ </div>
267
+ </div>
268
+ </div>
269
+ </td>
270
+ <td className="px-4 py-3">
271
+ <div className="flex flex-col">
272
+ <span className="text-sm text-slate-700 font-medium">
273
+ {issue.city || "Unknown"}
274
+ </span>
275
+ <span className="text-xs text-slate-500 truncate max-w-37.5">
276
+ {issue.locality || ""}
277
+ </span>
278
+ </div>
279
+ </td>
280
+ <td className="px-4 py-3">
281
+ <span
282
+ className={`inline-flex items-center justify-center h-6 w-6 rounded-full text-xs font-bold border ${
283
+ issue.priority === 1
284
+ ? "bg-red-50 text-red-600 border-red-100"
285
+ : issue.priority === 2
286
+ ? "bg-orange-50 text-orange-600 border-orange-100"
287
+ : issue.priority === 3
288
+ ? "bg-amber-50 text-amber-600 border-amber-100"
289
+ : "bg-green-50 text-green-600 border-green-100"
290
+ }`}
291
+ >
292
+ P{issue.priority}
293
+ </span>
294
+ </td>
295
+ <td className="px-4 py-3">
296
+ {getStateBadge(issue.state)}
297
+ </td>
298
+ <td className="px-4 py-3 text-sm text-slate-600">
299
+ {issue.assigned_to ? (
300
+ <div className="flex items-center gap-2">
301
+ <div className="w-6 h-6 rounded-full bg-blue-100 flex items-center justify-center text-[10px] font-bold text-blue-700 ring-2 ring-white shadow-sm">
302
+ {issue.assigned_to.charAt(0)}
303
+ </div>
304
+ <span className="font-medium text-slate-700">{issue.assigned_to}</span>
305
+ </div>
306
+ ) : (
307
+ <span className="text-slate-400 italic text-xs">
308
+ Unassigned
309
+ </span>
310
+ )}
311
+ {issue.department && (
312
+ <div className="text-[10px] text-slate-400 mt-0.5 font-mono">
313
+ {issue.department}
314
+ </div>
315
+ )}
316
+ </td>
317
+ <td className="px-4 py-3 text-sm text-slate-600">
318
+ <span className="font-medium">{new Date(issue.created_at).toLocaleDateString()}</span>
319
+ <div className="text-xs text-slate-400 font-mono">
320
+ {new Date(issue.created_at).toLocaleTimeString([], {
321
+ hour: "2-digit",
322
+ minute: "2-digit",
323
+ })}
324
+ </div>
325
+ </td>
326
+ <td className="px-4 py-3 text-right">
327
+ <Link
328
+ href={`/admin/issues/${issue.id}`}
329
+ className="inline-flex items-center gap-1.5 text-xs font-medium text-blue-600 hover:text-blue-800 bg-blue-50 px-2.5 py-1.5 rounded-lg border border-blue-100 hover:bg-blue-100 transition-colors"
330
+ >
331
+ View
332
+ <ChevronRight className="w-3.5 h-3.5" />
333
+ </Link>
334
+ </td>
335
+ </tr>
336
+ ))
337
+ )}
338
+ </tbody>
339
+ </table>
340
+ </div>
341
+ )}
342
+
343
+ <div className="flex items-center justify-between border-t border-slate-200/60 pt-4 mt-4">
344
+ <div className="text-sm text-slate-500">
345
+ Showing{" "}
346
+ <span className="font-semibold text-slate-900">
347
+ {(meta.page - 1) * meta.limit + 1}
348
+ </span>{" "}
349
+ to{" "}
350
+ <span className="font-semibold text-slate-900">
351
+ {Math.min(meta.page * meta.limit, meta.total)}
352
+ </span>{" "}
353
+ of <span className="font-semibold text-slate-900">{meta.total}</span> results
354
+ </div>
355
+ <div className="flex gap-2">
356
+ <button
357
+ onClick={() => handlePageChange(meta.page - 1)}
358
+ disabled={meta.page === 1}
359
+ className="px-3.5 py-1.5 text-sm font-medium border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-slate-700"
360
+ >
361
+ Previous
362
+ </button>
363
+ <button
364
+ onClick={() => handlePageChange(meta.page + 1)}
365
+ disabled={meta.page === meta.pages}
366
+ className="px-3.5 py-1.5 text-sm font-medium border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-slate-700"
367
+ >
368
+ Next
369
+ </button>
370
+ </div>
371
+ </div>
372
+ </div>
373
+ </div>
374
+ );
375
+ }
Frontend/app/admin/layout.tsx ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useState, useEffect } from "react";
3
+ import { useRouter } from "next/navigation";
4
+ import { useAuth } from "@/components/AuthProvider";
5
+ import DashboardSidebar from "@/components/DashboardSidebar";
6
+ import DashboardHeader from "@/components/DashboardHeader";
7
+
8
+ export default function AdminLayout({
9
+ children,
10
+ }: {
11
+ children: React.ReactNode;
12
+ }) {
13
+ const { role, loading, signOut } = useAuth();
14
+ const [mobileOpen, setMobileOpen] = useState(false);
15
+ const [isSidebarOpen, setIsSidebarOpen] = useState(false); // Closed by default
16
+ const router = useRouter();
17
+
18
+ useEffect(() => {
19
+ if (!loading && role !== "admin") {
20
+ router.push("/signin");
21
+ }
22
+ }, [loading, role, router]);
23
+
24
+ if (loading) return null;
25
+
26
+ return (
27
+ <div className="flex min-h-screen bg-urban-bg font-sans overflow-hidden relative">
28
+ <DashboardSidebar
29
+ role="admin"
30
+ mobileOpen={mobileOpen}
31
+ setMobileOpen={setMobileOpen}
32
+ desktopOpen={isSidebarOpen}
33
+ onLogout={signOut}
34
+ />
35
+
36
+ {/* Ambient Background - Global for Admin */}
37
+ <div className="absolute top-0 left-0 w-full h-full pointer-events-none z-0 overflow-hidden">
38
+ <div className="absolute top-[-10%] right-[-5%] w-[40%] h-[40%] bg-urban-primary/5 rounded-full blur-[100px]"></div>
39
+ <div className="absolute bottom-[-10%] left-[-5%] w-[30%] h-[30%] bg-purple-500/5 rounded-full blur-[80px]"></div>
40
+ </div>
41
+
42
+ <div className="flex-1 flex flex-col min-w-0 transition-all duration-300 relative z-10 h-screen overflow-hidden">
43
+ <DashboardHeader
44
+ setMobileOpen={setMobileOpen}
45
+ toggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)}
46
+ title="Admin Console"
47
+ />
48
+ <main className="flex-1 overflow-x-hidden overflow-y-auto">{children}</main>
49
+ </div>
50
+ </div>
51
+ );
52
+ }
Frontend/app/admin/page.tsx ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+ import Link from "next/link";
3
+ import { useCachedFetch } from "@/hooks/useCachedFetch";
4
+ import {
5
+ Building2,
6
+ Users,
7
+ ClipboardList,
8
+ Clock,
9
+ CheckCircle2,
10
+ ClipboardCheck,
11
+ } from "lucide-react";
12
+ import { Skeleton } from "@/components/ui/Skeleton";
13
+ import {
14
+ BarChart,
15
+ Bar,
16
+ XAxis,
17
+ YAxis,
18
+ CartesianGrid,
19
+ Tooltip,
20
+ ResponsiveContainer,
21
+ PieChart,
22
+ Pie,
23
+ Cell,
24
+ Legend,
25
+ } from "recharts";
26
+
27
+ interface Stats {
28
+ departments: number;
29
+ members: number;
30
+ total_issues: number;
31
+ pending_issues: number;
32
+ resolved_issues: number;
33
+ verification_needed: number;
34
+ issues_by_category: { name: string; value: number }[];
35
+ issues_activity: { name: string; reported: number; resolved: number }[];
36
+ }
37
+
38
+ export default function AdminDashboard() {
39
+ const { data: stats, loading } = useCachedFetch<Stats>("/admin/stats");
40
+
41
+ const COLORS = [
42
+ "#3B82F6",
43
+ "#10B981",
44
+ "#F59E0B",
45
+ "#EF4444",
46
+ "#8B5CF6",
47
+ "#EC4899",
48
+ ];
49
+
50
+ if (loading) {
51
+ return (
52
+ <div className="space-y-6">
53
+ <Skeleton className="h-8 w-48 mb-6" />
54
+ <div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-6 gap-6">
55
+ {Array.from({ length: 6 }).map((_, i) => (
56
+ <Skeleton key={i} className="h-32 rounded-2xl" />
57
+ ))}
58
+ </div>
59
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
60
+ <Skeleton className="h-80 lg:col-span-2 rounded-2xl" />
61
+ <Skeleton className="h-80 rounded-2xl" />
62
+ </div>
63
+ </div>
64
+ );
65
+ }
66
+
67
+ const hasChartData =
68
+ stats?.issues_by_category && stats.issues_by_category.length > 0;
69
+ const hasActivityData =
70
+ stats?.issues_activity && stats.issues_activity.length > 0;
71
+
72
+ return (
73
+ <div className="space-y-6">
74
+ <div className="flex items-center justify-between">
75
+ <h2 className="text-2xl font-bold text-slate-900 tracking-tight">
76
+ Overview
77
+ </h2>
78
+ </div>
79
+
80
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-6">
81
+ <StatCard
82
+ title="Departments"
83
+ value={stats?.departments || 0}
84
+ icon={<Building2 className="w-5 h-5 text-blue-600" />}
85
+ />
86
+ <StatCard
87
+ title="Total Staff"
88
+ value={stats?.members || 0}
89
+ icon={<Users className="w-5 h-5 text-purple-600" />}
90
+ />
91
+ <StatCard
92
+ title="Total Issues"
93
+ value={stats?.total_issues || 0}
94
+ icon={<ClipboardList className="w-5 h-5 text-slate-600" />}
95
+ />
96
+ <StatCard
97
+ title="Pending"
98
+ value={stats?.pending_issues || 0}
99
+ icon={<Clock className="w-5 h-5 text-amber-600" />}
100
+ alert={true}
101
+ />
102
+ <Link
103
+ href="/admin/issues?status=pending_verification"
104
+ className="block transform transition-transform hover:scale-105"
105
+ >
106
+ <StatCard
107
+ title="Needs Review"
108
+ value={stats?.verification_needed || 0}
109
+ icon={<ClipboardCheck className="w-5 h-5 text-indigo-600" />}
110
+ alert={(stats?.verification_needed || 0) > 0}
111
+ />
112
+ </Link>
113
+ <StatCard
114
+ title="Total Resolved"
115
+ value={stats?.resolved_issues || 0}
116
+ icon={<CheckCircle2 className="w-5 h-5 text-emerald-600" />}
117
+ />
118
+ </div>
119
+
120
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
121
+ <div className="lg:col-span-2 bg-white/60 backdrop-blur-md p-6 rounded-2xl border border-slate-200/60 shadow-urban-sm hover:shadow-urban-md transition-all">
122
+ <h3 className="text-lg font-bold text-slate-900 mb-6 flex items-center gap-2">
123
+ <span className="w-1 h-6 bg-blue-500 rounded-full"></span>
124
+ Weekly Activity
125
+ </h3>
126
+ {hasActivityData ? (
127
+ <div className="h-80 w-full">
128
+ <ResponsiveContainer width="100%" height="100%">
129
+ <BarChart
130
+ data={stats?.issues_activity || []}
131
+ margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
132
+ >
133
+ <CartesianGrid
134
+ strokeDasharray="3 3"
135
+ vertical={false}
136
+ stroke="#E2E8F0"
137
+ />
138
+ <XAxis
139
+ dataKey="name"
140
+ axisLine={false}
141
+ tickLine={false}
142
+ tick={{ fill: "#64748B", fontSize: 12, fontFamily: 'var(--font-fira-sans)' }}
143
+ dy={10}
144
+ />
145
+ <YAxis
146
+ axisLine={false}
147
+ tickLine={false}
148
+ tick={{ fill: "#64748B", fontSize: 12, fontFamily: 'var(--font-fira-sans)' }}
149
+ />
150
+ <Tooltip
151
+ cursor={{ fill: "#F1F5F9" }}
152
+ contentStyle={{
153
+ borderRadius: "12px",
154
+ border: "1px solid rgba(226, 232, 240, 0.8)",
155
+ boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1)",
156
+ backgroundColor: "rgba(255, 255, 255, 0.9)",
157
+ backdropFilter: "blur(4px)",
158
+ fontFamily: 'var(--font-fira-sans)',
159
+ }}
160
+ />
161
+ <Legend iconType="circle" wrapperStyle={{ fontFamily: 'var(--font-fira-sans)' }} />
162
+ <Bar
163
+ dataKey="reported"
164
+ name="Reported"
165
+ fill="#3B82F6"
166
+ radius={[4, 4, 0, 0]}
167
+ barSize={20}
168
+ />
169
+ <Bar
170
+ dataKey="resolved"
171
+ name="Resolved"
172
+ fill="#10B981"
173
+ radius={[4, 4, 0, 0]}
174
+ barSize={20}
175
+ />
176
+ </BarChart>
177
+ </ResponsiveContainer>
178
+ </div>
179
+ ) : (
180
+ <div className="h-80 flex items-center justify-center text-slate-400">
181
+ No activity data available yet.
182
+ </div>
183
+ )}
184
+ </div>
185
+
186
+ <div className="bg-white/60 backdrop-blur-md p-6 rounded-2xl border border-slate-200/60 shadow-urban-sm hover:shadow-urban-md transition-all">
187
+ <h3 className="text-lg font-bold text-slate-900 mb-6 flex items-center gap-2">
188
+ <span className="w-1 h-6 bg-purple-500 rounded-full"></span>
189
+ Issues by Category
190
+ </h3>
191
+ {hasChartData ? (
192
+ <div className="h-80 w-full">
193
+ <ResponsiveContainer width="100%" height="100%">
194
+ <PieChart>
195
+ <Pie
196
+ data={stats?.issues_by_category}
197
+ cx="50%"
198
+ cy="50%"
199
+ innerRadius={60}
200
+ outerRadius={80}
201
+ fill="#8884d8"
202
+ paddingAngle={5}
203
+ dataKey="value"
204
+ >
205
+ {stats?.issues_by_category?.map((entry, index) => (
206
+ <Cell
207
+ key={`cell-${index}`}
208
+ fill={COLORS[index % COLORS.length]}
209
+ />
210
+ ))}
211
+ </Pie>
212
+ <Tooltip
213
+ contentStyle={{
214
+ borderRadius: "12px",
215
+ border: "1px solid rgba(226, 232, 240, 0.8)",
216
+ boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1)",
217
+ backgroundColor: "rgba(255, 255, 255, 0.9)",
218
+ backdropFilter: "blur(4px)",
219
+ fontFamily: 'var(--font-fira-sans)',
220
+ }}
221
+ />
222
+ <Legend verticalAlign="bottom" height={36} wrapperStyle={{ fontFamily: 'var(--font-fira-sans)' }} />
223
+ </PieChart>
224
+ </ResponsiveContainer>
225
+ </div>
226
+ ) : (
227
+ <div className="h-80 flex items-center justify-center text-slate-400">
228
+ No category data available yet.
229
+ </div>
230
+ )}
231
+ </div>
232
+ </div>
233
+ </div>
234
+ );
235
+ }
236
+
237
+ function StatCard({
238
+ title,
239
+ value,
240
+ icon,
241
+ alert = false,
242
+ }: {
243
+ title: string;
244
+ value: number;
245
+ icon: React.ReactNode;
246
+ alert?: boolean;
247
+ }) {
248
+ return (
249
+ <div className="bg-white/60 backdrop-blur-md p-6 rounded-2xl border border-slate-200/60 shadow-urban-sm hover:shadow-urban-md transition-all hover:-translate-y-1 group">
250
+ <div className="flex justify-between items-start mb-4">
251
+ <div className="p-3 bg-white rounded-xl border border-slate-100 shadow-sm group-hover:scale-110 transition-transform duration-300">
252
+ {icon}
253
+ </div>
254
+ </div>
255
+ <div>
256
+ <h3 className="text-slate-500 text-xs font-bold uppercase tracking-wider font-mono">
257
+ {title}
258
+ </h3>
259
+ <p
260
+ className={`text-3xl font-extrabold mt-2 tracking-tight ${
261
+ alert ? "text-amber-600" : "text-slate-900"
262
+ }`}
263
+ >
264
+ {value}
265
+ </p>
266
+ </div>
267
+ </div>
268
+ );
269
+ }
Frontend/app/admin/review/page.tsx ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import { apiGet, apiPost } from "@/lib/api";
4
+ import { CheckCircle2, XCircle } from "lucide-react";
5
+
6
+ interface Issue {
7
+ id: string;
8
+ description: string;
9
+ state: string;
10
+ city: string;
11
+ locality: string;
12
+ created_at: string;
13
+ full_address: string;
14
+ images: { file_path: string; annotated_path: string }[];
15
+ priority: number;
16
+ }
17
+
18
+ export default function ManualReviewPage() {
19
+ const [issues, setIssues] = useState<Issue[]>([]);
20
+ const [loading, setLoading] = useState(true);
21
+
22
+ useEffect(() => {
23
+ fetchPendingIssues();
24
+ }, []);
25
+
26
+ const fetchPendingIssues = async () => {
27
+ try {
28
+ const data = await apiGet<{ items: Issue[] }>("/issues?state=reported");
29
+ setIssues(data.items || []);
30
+ } catch (error) {
31
+ console.error("Failed to fetch issues:", error);
32
+ } finally {
33
+ setLoading(false);
34
+ }
35
+ };
36
+
37
+ const handleReview = async (id: string, status: "approved" | "rejected") => {
38
+ try {
39
+ const data = await apiPost<{ message: string }>(`/admin/issues/${id}/review`, { status });
40
+ setIssues(prev => prev.filter(i => i.id !== id));
41
+ alert(data.message);
42
+ } catch (error) {
43
+ console.error("Review failed", error);
44
+ alert("Failed to review issue");
45
+ }
46
+ };
47
+
48
+ if (loading) {
49
+ return <div className="text-slate-600 font-medium">Loading Reviews...</div>;
50
+ }
51
+
52
+ return (
53
+ <div className="space-y-6">
54
+ <div className="flex justify-between items-center">
55
+ <div>
56
+ <h2 className="text-2xl font-bold text-slate-900">Manual Review Queue</h2>
57
+ <p className="text-sm text-slate-500">Validate incoming citizen reports before assignment.</p>
58
+ </div>
59
+ <div className="bg-blue-600 text-white text-xs font-bold px-3 py-1.5 rounded-full shadow-sm">
60
+ {issues.length} Pending
61
+ </div>
62
+ </div>
63
+
64
+ {issues.length === 0 ? (
65
+ <div className="text-center py-20 bg-white rounded-xl border border-slate-200 shadow-sm">
66
+ <CheckCircle2 className="w-12 h-12 mx-auto text-green-400" />
67
+ <p className="text-slate-900 font-medium mt-4 text-lg">All caught up!</p>
68
+ <p className="text-slate-500">No issues pending manual review.</p>
69
+ </div>
70
+ ) : (
71
+ <div className="grid gap-6">
72
+ {issues.map((issue) => (
73
+ <div key={issue.id} className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden flex flex-col md:flex-row">
74
+ <div className="md:w-1/3 h-64 md:h-auto bg-slate-100 relative">
75
+ {issue.images?.[0] ? (
76
+ <img
77
+ src={issue.images[0].annotated_path || issue.images[0].file_path}
78
+ alt="Evidence"
79
+ className="w-full h-full object-cover"
80
+ />
81
+ ) : (
82
+ <div className="flex items-center justify-center h-full text-slate-400">No Image</div>
83
+ )}
84
+ </div>
85
+
86
+ <div className="p-6 md:w-2/3 flex flex-col justify-between">
87
+ <div>
88
+ <div className="flex justify-between items-start mb-2">
89
+ <span className="px-2.5 py-0.5 rounded-full bg-slate-100 text-slate-600 text-xs font-bold uppercase">
90
+ {issue.city}
91
+ </span>
92
+ <span className="text-xs text-slate-400">
93
+ {new Date(issue.created_at).toLocaleDateString()}
94
+ </span>
95
+ </div>
96
+ <h3 className="text-lg font-bold text-slate-900 mb-2">{issue.description || "No description"}</h3>
97
+ <p className="text-slate-600 text-sm mb-4">{issue.full_address || issue.locality}</p>
98
+ </div>
99
+
100
+ <div className="flex gap-4 mt-4 pt-4 border-t border-slate-100">
101
+ <button
102
+ onClick={() => handleReview(issue.id, "approved")}
103
+ className="flex-1 bg-green-600 hover:bg-green-700 text-white font-semibold py-2 px-4 rounded-lg transition shadow-sm flex items-center justify-center gap-2"
104
+ >
105
+ <CheckCircle2 className="w-4 h-4" /> Approve & Assign
106
+ </button>
107
+ <button
108
+ onClick={() => handleReview(issue.id, "rejected")}
109
+ className="flex-1 bg-white border border-red-200 text-red-600 hover:bg-red-50 font-semibold py-2 px-4 rounded-lg transition flex items-center justify-center gap-2"
110
+ >
111
+ <XCircle className="w-4 h-4" /> Reject
112
+ </button>
113
+ </div>
114
+ </div>
115
+ </div>
116
+ ))}
117
+ </div>
118
+ )}
119
+ </div>
120
+ );
121
+ }
Frontend/app/admin/workers/page.tsx ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useMemo, useState } from "react";
3
+ import { apiGet, apiPost } from "@/lib/api";
4
+ import { useCachedFetch } from "@/hooks/useCachedFetch";
5
+ import {
6
+ HardHat,
7
+ Plus,
8
+ Search,
9
+ Filter,
10
+ CheckCircle2,
11
+ AlertTriangle,
12
+ TrendingUp,
13
+ } from "lucide-react";
14
+ import { Skeleton } from "@/components/ui/Skeleton";
15
+
16
+ interface Department {
17
+ id: string;
18
+ name: string;
19
+ code: string;
20
+ }
21
+
22
+ interface Worker {
23
+ id: string;
24
+ name: string;
25
+ email: string;
26
+ role: string;
27
+ department_id: string;
28
+ is_active: boolean;
29
+ current_workload: number;
30
+ max_workload: number;
31
+
32
+ resolved_total?: number;
33
+ efficiency?: number;
34
+ }
35
+
36
+ interface WorkerPerformance {
37
+ id: string;
38
+ resolved_total: number;
39
+ efficiency: number;
40
+ }
41
+
42
+ export default function WorkersPage() {
43
+ const { data: departmentsData, loading: deptLoading, revalidate: revalidateDept } = useCachedFetch<Department[]>("/admin/departments");
44
+ const { data: workersData, loading: workersLoading, revalidate: revalidateWorkers } = useCachedFetch<Worker[]>("/admin/members");
45
+ const { data: perfData, loading: perfLoading, revalidate: revalidatePerf } = useCachedFetch<WorkerPerformance[]>("/admin/workers/performance");
46
+
47
+ const [showForm, setShowForm] = useState(false);
48
+ const [formData, setFormData] = useState({
49
+ name: "",
50
+ email: "",
51
+ password: "",
52
+ department_id: "",
53
+ role: "worker",
54
+ });
55
+
56
+ const [search, setSearch] = useState("");
57
+
58
+ const departments = departmentsData || [];
59
+
60
+ const workers = useMemo(() => {
61
+ if (!workersData) return [];
62
+ const perfMap = new Map((perfData || []).map(p => [p.id, p]));
63
+
64
+ return workersData.map((w) => {
65
+ const perf = perfMap.get(w.id);
66
+ return {
67
+ ...w,
68
+ resolved_total: perf?.resolved_total || 0,
69
+ efficiency: perf?.efficiency || 0,
70
+ };
71
+ });
72
+ }, [workersData, perfData]);
73
+
74
+ const loading = deptLoading || workersLoading || perfLoading;
75
+
76
+ const refreshAll = () => {
77
+ revalidateDept();
78
+ revalidateWorkers();
79
+ revalidatePerf();
80
+ };
81
+
82
+ const handleSubmit = async (e: React.FormEvent) => {
83
+ e.preventDefault();
84
+ try {
85
+ await apiPost("/admin/members", formData);
86
+ setShowForm(false);
87
+ setFormData({
88
+ name: "",
89
+ email: "",
90
+ password: "",
91
+ department_id: "",
92
+ role: "worker",
93
+ });
94
+ refreshAll();
95
+ } catch (error: unknown) {
96
+ const message =
97
+ error instanceof Error ? error.message : "Failed to create worker";
98
+ alert(message);
99
+ }
100
+ };
101
+
102
+ const getDepartmentName = (deptId: string) => {
103
+ const dept = departments.find((d) => d.id === deptId);
104
+ return dept ? dept.name : "Unassigned";
105
+ };
106
+
107
+ if (loading) {
108
+ return (
109
+ <div className="space-y-6">
110
+ <div className="flex justify-between items-center mb-6">
111
+ <Skeleton className="h-10 w-64" />
112
+ <Skeleton className="h-10 w-32" />
113
+ </div>
114
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
115
+ {Array.from({ length: 6 }).map((_, i) => (
116
+ <Skeleton key={i} className="h-48 rounded-xl" />
117
+ ))}
118
+ </div>
119
+ </div>
120
+ );
121
+ }
122
+
123
+ const filteredWorkers = workers
124
+ .filter((w) => w.role !== "admin")
125
+ .filter(
126
+ (w) =>
127
+ search === "" ||
128
+ w.name.toLowerCase().includes(search.toLowerCase()) ||
129
+ w.email.toLowerCase().includes(search.toLowerCase()),
130
+ );
131
+
132
+ return (
133
+ <div className="space-y-6">
134
+ <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
135
+ <div>
136
+ <h2 className="text-2xl font-bold text-slate-900">
137
+ Workforce Management
138
+ </h2>
139
+ <p className="text-sm text-slate-500">
140
+ Manage field workers, assign tasks, and monitor performance.
141
+ </p>
142
+ </div>
143
+ <button
144
+ onClick={() => setShowForm(true)}
145
+ className="px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition shadow-sm flex items-center gap-2"
146
+ >
147
+ <Plus className="w-4 h-4" /> Enroll Worker
148
+ </button>
149
+ </div>
150
+
151
+ {showForm && (
152
+ <div className="bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden animate-in fade-in slide-in-from-top-4">
153
+ <div className="bg-slate-50 px-6 py-4 border-b border-slate-200">
154
+ <h2 className="text-lg font-bold text-slate-800">
155
+ New Worker Enrollment
156
+ </h2>
157
+ </div>
158
+ <form onSubmit={handleSubmit} className="p-6 space-y-6">
159
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
160
+ <div>
161
+ <label className="block text-sm font-medium text-slate-700 mb-1">
162
+ Full Name
163
+ </label>
164
+ <input
165
+ type="text"
166
+ value={formData.name}
167
+ onChange={(e) =>
168
+ setFormData({ ...formData, name: e.target.value })
169
+ }
170
+ className="w-full px-4 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
171
+ placeholder="e.g. John Doe"
172
+ required
173
+ />
174
+ </div>
175
+ <div>
176
+ <label className="block text-sm font-medium text-slate-700 mb-1">
177
+ Email Address
178
+ </label>
179
+ <input
180
+ type="email"
181
+ value={formData.email}
182
+ onChange={(e) =>
183
+ setFormData({ ...formData, email: e.target.value })
184
+ }
185
+ className="w-full px-4 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
186
+ placeholder="worker@city.gov"
187
+ required
188
+ />
189
+ </div>
190
+ </div>
191
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
192
+ <div>
193
+ <label className="block text-sm font-medium text-slate-700 mb-1">
194
+ Set Password
195
+ </label>
196
+ <input
197
+ type="password"
198
+ value={formData.password}
199
+ onChange={(e) =>
200
+ setFormData({ ...formData, password: e.target.value })
201
+ }
202
+ className="w-full px-4 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
203
+ required
204
+ minLength={8}
205
+ placeholder="••••••••"
206
+ />
207
+ </div>
208
+ <div>
209
+ <label className="block text-sm font-medium text-slate-700 mb-1">
210
+ Assign Department
211
+ </label>
212
+ <select
213
+ title="department"
214
+ value={formData.department_id}
215
+ onChange={(e) =>
216
+ setFormData({ ...formData, department_id: e.target.value })
217
+ }
218
+ className="w-full px-4 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
219
+ required
220
+ >
221
+ <option value="">Select Department...</option>
222
+ {departments.map((d) => (
223
+ <option key={d.id} value={d.id}>
224
+ {d.name}
225
+ </option>
226
+ ))}
227
+ </select>
228
+ </div>
229
+ </div>
230
+
231
+ <div className="flex gap-3 pt-2">
232
+ <button
233
+ type="submit"
234
+ className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition shadow-sm"
235
+ >
236
+ Enroll Worker
237
+ </button>
238
+ <button
239
+ type="button"
240
+ onClick={() => setShowForm(false)}
241
+ className="px-6 py-2 bg-white text-slate-700 font-medium rounded-lg border border-slate-300 hover:bg-slate-50 transition"
242
+ >
243
+ Cancel
244
+ </button>
245
+ </div>
246
+ </form>
247
+ </div>
248
+ )}
249
+
250
+ <div className="flex gap-4 mb-4">
251
+ <div className="relative flex-1 max-w-sm">
252
+ <Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
253
+ <input
254
+ type="text"
255
+ placeholder="Search workers by name or email..."
256
+ value={search}
257
+ onChange={(e) => setSearch(e.target.value)}
258
+ className="w-full pl-9 pr-4 py-2 bg-white border border-slate-200 rounded-lg text-sm focus:border-blue-500 outline-none"
259
+ />
260
+ </div>
261
+ <button className="px-3 py-2 bg-white border border-slate-200 rounded-lg text-slate-600 flex items-center gap-2 hover:bg-slate-50">
262
+ <Filter className="w-4 h-4" /> Filter
263
+ </button>
264
+ </div>
265
+
266
+ <div className="space-y-4">
267
+ <div className="flex justify-between items-center px-1">
268
+ <p className="text-sm font-medium text-slate-500">
269
+ Active Workforce ({filteredWorkers.length})
270
+ </p>
271
+ </div>
272
+
273
+ {filteredWorkers.length === 0 ? (
274
+ <div className="text-center py-16 bg-white rounded-xl border border-slate-200">
275
+ <HardHat className="w-12 h-12 mx-auto text-slate-300" />
276
+ <p className="text-slate-500 mt-2">No field workers found.</p>
277
+ </div>
278
+ ) : (
279
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
280
+ {filteredWorkers.map((worker) => (
281
+ <div
282
+ key={worker.id}
283
+ className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-all"
284
+ >
285
+ <div className="flex justify-between items-start mb-4">
286
+ <div className="flex items-center gap-3">
287
+ <div className="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center text-lg font-bold text-slate-700 border border-slate-200">
288
+ {worker.name.charAt(0)}
289
+ </div>
290
+ <div>
291
+ <h3 className="font-bold text-slate-900 leading-tight">
292
+ {worker.name}
293
+ </h3>
294
+ <p className="text-xs text-slate-500">{worker.email}</p>
295
+ </div>
296
+ </div>
297
+ {!worker.is_active && (
298
+ <span className="px-2 py-0.5 bg-red-100 text-red-700 text-xs font-bold rounded">
299
+ INACTIVE
300
+ </span>
301
+ )}
302
+ </div>
303
+
304
+ <div className="py-3 border-t border-slate-100 mb-3 space-y-2">
305
+ <div className="flex justify-between text-sm">
306
+ <span className="text-slate-500">Department</span>
307
+ <span className="font-medium text-slate-900">
308
+ {getDepartmentName(worker.department_id)}
309
+ </span>
310
+ </div>
311
+ <div className="flex justify-between text-sm">
312
+ <span className="text-slate-500">Efficiency</span>
313
+ <span className="font-medium text-slate-900 flex items-center gap-1">
314
+ {worker.efficiency}{" "}
315
+ <span className="text-xs text-slate-400">/week</span>
316
+ {worker.efficiency && worker.efficiency > 5 && (
317
+ <TrendingUp className="w-3 h-3 text-green-500" />
318
+ )}
319
+ </span>
320
+ </div>
321
+ <div className="flex justify-between text-sm">
322
+ <span className="text-slate-500">Total Resolved</span>
323
+ <span className="font-medium text-slate-900 flex items-center gap-1">
324
+ <CheckCircle2 className="w-3 h-3 text-green-500" />
325
+ {worker.resolved_total}
326
+ </span>
327
+ </div>
328
+ </div>
329
+
330
+ <div>
331
+ <div className="flex justify-between text-xs mb-1">
332
+ <span className="text-slate-500 font-medium">
333
+ Current Workload
334
+ </span>
335
+ <span className="text-slate-900 font-bold">
336
+ {worker.current_workload} / {worker.max_workload}
337
+ </span>
338
+ </div>
339
+ <div className="w-full bg-slate-100 rounded-full h-2 overflow-hidden">
340
+ <div
341
+ className={`h-full rounded-full transition-all duration-500 ${
342
+ worker.current_workload >= worker.max_workload
343
+ ? "bg-red-500"
344
+ : worker.current_workload > worker.max_workload * 0.7
345
+ ? "bg-amber-500"
346
+ : "bg-blue-600"
347
+ }`}
348
+ style={{
349
+ width: `${Math.min(
350
+ (worker.current_workload /
351
+ (worker.max_workload || 10)) *
352
+ 100,
353
+ 100,
354
+ )}%`,
355
+ }}
356
+ ></div>
357
+ </div>
358
+ {worker.current_workload >= worker.max_workload && (
359
+ <div className="mt-2 flex items-center gap-1 text-xs text-red-600 font-medium">
360
+ <AlertTriangle className="w-3 h-3" /> Overloaded
361
+ </div>
362
+ )}
363
+ </div>
364
+ </div>
365
+ ))}
366
+ </div>
367
+ )}
368
+ </div>
369
+ </div>
370
+ );
371
+ }
Frontend/app/auth/callback/page.tsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect } from "react";
3
+ import { useRouter } from "next/navigation";
4
+ import { useAuth } from "@/components/AuthProvider";
5
+ import { Loader2 } from "lucide-react";
6
+
7
+ export default function AuthCallbackPage() {
8
+ const router = useRouter();
9
+ const { session, loading } = useAuth();
10
+
11
+ useEffect(() => {
12
+ if (!loading) {
13
+ if (session) {
14
+ const user = session.user;
15
+
16
+ const storedUser = localStorage.getItem("user");
17
+ if (storedUser) {
18
+ const parsed = JSON.parse(storedUser);
19
+ if (parsed.role === "admin") router.push("/admin");
20
+ else if (parsed.role === "worker") router.push("/worker");
21
+ else router.push("/user");
22
+ } else {
23
+ router.push("/user");
24
+ }
25
+ } else {
26
+ const timer = setTimeout(() => {
27
+ router.push("/signin?error=callback_timeout");
28
+ }, 3000);
29
+ return () => clearTimeout(timer);
30
+ }
31
+ }
32
+ }, [session, loading, router]);
33
+
34
+ return (
35
+ <div className="min-h-screen flex items-center justify-center bg-slate-50">
36
+ <div className="text-center">
37
+ <Loader2 className="w-10 h-10 animate-spin text-blue-600 mx-auto mb-4" />
38
+ <h2 className="text-xl font-bold text-slate-800">Authenticating...</h2>
39
+ <p className="text-slate-500">Please wait while we log you in.</p>
40
+ </div>
41
+ </div>
42
+ );
43
+ }
Frontend/app/globals.css CHANGED
@@ -1,26 +1,66 @@
1
  @import "tailwindcss";
2
 
3
- :root {
4
- --background: #ffffff;
5
- --foreground: #171717;
6
- }
 
 
7
 
8
- @theme inline {
9
- --color-background: var(--background);
10
- --color-foreground: var(--foreground);
11
- --font-sans: var(--font-geist-sans);
12
- --font-mono: var(--font-geist-mono);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  }
14
 
15
- @media (prefers-color-scheme: dark) {
16
- :root {
17
- --background: #0a0a0a;
18
- --foreground: #ededed;
19
- }
 
20
  }
21
 
22
  body {
23
  background: var(--background);
24
  color: var(--foreground);
25
- font-family: Arial, Helvetica, sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  }
 
1
  @import "tailwindcss";
2
 
3
+ @theme {
4
+ --color-urban-primary: #3B82F6;
5
+ --color-urban-secondary: #60A5FA;
6
+ --color-urban-cta: #F97316;
7
+ --color-urban-bg: #F8FAFC;
8
+ --color-urban-text: #1E293B;
9
 
10
+ --color-slate-50: #f8fafc;
11
+ --color-slate-100: #f1f5f9;
12
+ --color-slate-200: #e2e8f0;
13
+ --color-slate-300: #cbd5e1;
14
+ --color-slate-400: #94a3b8;
15
+ --color-slate-500: #64748b;
16
+ --color-slate-600: #475569;
17
+ --color-slate-700: #334155;
18
+ --color-slate-800: #1e293b;
19
+ --color-slate-900: #0f172a;
20
+ --color-slate-950: #020617;
21
+
22
+ --font-fira-code: 'Fira Code', monospace;
23
+ --font-fira-sans: 'Fira Sans', sans-serif;
24
+
25
+ --shadow-urban-sm: 0 1px 2px rgba(0,0,0,0.05);
26
+ --shadow-urban-md: 0 4px 6px rgba(0,0,0,0.1);
27
+ --shadow-urban-lg: 0 10px 15px rgba(0,0,0,0.1);
28
+ --shadow-urban-xl: 0 20px 25px rgba(0,0,0,0.15);
29
  }
30
 
31
+ :root {
32
+ --background: var(--color-urban-bg);
33
+ --foreground: var(--color-urban-text);
34
+ --primary: var(--color-urban-primary);
35
+ --secondary: var(--color-urban-secondary);
36
+ --cta: var(--color-urban-cta);
37
  }
38
 
39
  body {
40
  background: var(--background);
41
  color: var(--foreground);
42
+ font-family: var(--font-fira-sans);
43
+ }
44
+
45
+ h1, h2, h3, h4, h5, h6 {
46
+ font-family: var(--font-fira-code);
47
+ }
48
+
49
+ input,
50
+ select,
51
+ textarea {
52
+ border-width: 1px;
53
+ border-color: var(--color-slate-300);
54
+ outline: none;
55
+ font-family: var(--font-fira-sans);
56
+ border-radius: 8px;
57
+ padding: 12px 16px;
58
+ transition: border-color 0.2s;
59
+ }
60
+
61
+ input:focus,
62
+ select:focus,
63
+ textarea:focus {
64
+ border-color: var(--primary);
65
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); /* urban-primary 10% */
66
  }
Frontend/app/layout.tsx CHANGED
@@ -1,22 +1,28 @@
1
  import type { Metadata } from "next";
2
- import { Geist, Geist_Mono } from "next/font/google";
3
  import "./globals.css";
4
 
5
- const geistSans = Geist({
6
- variable: "--font-geist-sans",
7
  subsets: ["latin"],
 
 
 
8
  });
9
 
10
- const geistMono = Geist_Mono({
11
- variable: "--font-geist-mono",
12
  subsets: ["latin"],
 
 
 
13
  });
14
 
15
  export const metadata: Metadata = {
16
- title: "Create Next App",
17
- description: "Generated by create next app",
18
  };
19
 
 
 
20
  export default function RootLayout({
21
  children,
22
  }: Readonly<{
@@ -24,10 +30,8 @@ export default function RootLayout({
24
  }>) {
25
  return (
26
  <html lang="en">
27
- <body
28
- className={`${geistSans.variable} ${geistMono.variable} antialiased`}
29
- >
30
- {children}
31
  </body>
32
  </html>
33
  );
 
1
  import type { Metadata } from "next";
2
+ import { Fira_Sans, Fira_Code } from "next/font/google";
3
  import "./globals.css";
4
 
5
+ const firaSans = Fira_Sans({
 
6
  subsets: ["latin"],
7
+ weight: ["300", "400", "500", "600", "700"],
8
+ variable: "--font-fira-sans",
9
+ display: "swap",
10
  });
11
 
12
+ const firaCode = Fira_Code({
 
13
  subsets: ["latin"],
14
+ weight: ["400", "500", "600", "700"],
15
+ variable: "--font-fira-code",
16
+ display: "swap",
17
  });
18
 
19
  export const metadata: Metadata = {
20
+ title: "UrbanLens - City Issue Reporter",
21
+ description: "Smart city issue tracking and resolution dashboard",
22
  };
23
 
24
+ import { AuthProvider } from "@/components/AuthProvider";
25
+
26
  export default function RootLayout({
27
  children,
28
  }: Readonly<{
 
30
  }>) {
31
  return (
32
  <html lang="en">
33
+ <body className={`${firaSans.variable} ${firaCode.variable} antialiased font-sans`}>
34
+ <AuthProvider>{children}</AuthProvider>
 
 
35
  </body>
36
  </html>
37
  );
Frontend/app/page.tsx CHANGED
@@ -1,65 +1,245 @@
1
- import Image from "next/image";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- export default function Home() {
4
  return (
5
- <div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
6
- <main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
7
- <Image
8
- className="dark:invert"
9
- src="/next.svg"
10
- alt="Next.js logo"
11
- width={100}
12
- height={20}
13
- priority
14
- />
15
- <div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
16
- <h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
17
- To get started, edit the page.tsx file.
18
- </h1>
19
- <p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
20
- Looking for a starting point or more instructions? Head over to{" "}
21
- <a
22
- href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
23
- className="font-medium text-zinc-950 dark:text-zinc-50"
24
- >
25
- Templates
26
- </a>{" "}
27
- or the{" "}
28
- <a
29
- href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
30
- className="font-medium text-zinc-950 dark:text-zinc-50"
31
- >
32
- Learning
33
- </a>{" "}
34
- center.
35
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  </div>
37
- <div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
38
- <a
39
- className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
40
- href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
41
- target="_blank"
42
- rel="noopener noreferrer"
43
- >
44
- <Image
45
- className="dark:invert"
46
- src="/vercel.svg"
47
- alt="Vercel logomark"
48
- width={16}
49
- height={16}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  />
51
- Deploy Now
52
- </a>
53
- <a
54
- className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
55
- href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
56
- target="_blank"
57
- rel="noopener noreferrer"
58
- >
59
- Documentation
60
- </a>
 
 
 
61
  </div>
62
  </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  </div>
64
  );
65
  }
 
1
+ "use client";
2
+ import Link from "next/link";
3
+ import {
4
+ Smartphone,
5
+ Zap,
6
+ Shield,
7
+ ChevronRight,
8
+ Radio,
9
+ Activity,
10
+ MapPin,
11
+ CheckCircle2,
12
+ Building2,
13
+ Users,
14
+ LayoutDashboard,
15
+ LogOut
16
+ } from "lucide-react";
17
+ import { useAuth } from "@/components/AuthProvider";
18
+
19
+ export default function LandingPage() {
20
+ const { user, role, signOut } = useAuth();
21
+
22
+ const getDashboardLink = () => {
23
+ if (role === 'admin') return '/admin';
24
+ if (role === 'worker') return '/worker';
25
+ return '/user';
26
+ };
27
 
 
28
  return (
29
+ <div className="min-h-screen bg-slate-50 text-slate-900 font-sans selection:bg-blue-100 selection:text-blue-900 overflow-x-hidden">
30
+ {/* Background Mesh Gradients */}
31
+ <div className="fixed inset-0 z-0 opacity-60 pointer-events-none">
32
+ <div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-blue-200/50 rounded-full blur-[100px]" />
33
+ <div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-indigo-200/50 rounded-full blur-[100px]" />
34
+ <div className="absolute top-[20%] right-[10%] w-[30%] h-[30%] bg-cyan-200/40 rounded-full blur-[80px]" />
35
+ </div>
36
+
37
+ {/* Navigation */}
38
+ <nav className="fixed top-0 w-full z-50 border-b border-slate-200 bg-white/80 backdrop-blur-xl transition-all">
39
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
40
+ <div className="flex justify-between h-20 items-center">
41
+ <div className="flex items-center gap-3 group cursor-default">
42
+ <div className="relative">
43
+ <div className="absolute inset-0 bg-blue-500/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity" />
44
+ <div className="relative w-10 h-10 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-xl flex items-center justify-center text-white font-bold text-xl shadow-lg shadow-blue-500/30">
45
+ <span className="text-white">U</span>
46
+ </div>
47
+ </div>
48
+ <span className="text-xl font-bold tracking-tight text-slate-900">
49
+ Urban<span className="text-blue-600">Lens</span>
50
+ </span>
51
+ </div>
52
+
53
+ <div className="hidden md:flex items-center gap-8">
54
+ <NavLink href="#features">Features</NavLink>
55
+ <NavLink href="#stats">Live Data</NavLink>
56
+ <NavLink href="#roadmap">Roadmap</NavLink>
57
+ </div>
58
+
59
+ <div className="flex gap-4 items-center">
60
+ {user ? (
61
+ <>
62
+ <button
63
+ onClick={() => signOut()}
64
+ className="hidden sm:flex px-4 py-2 text-slate-500 hover:text-red-600 transition-colors"
65
+ >
66
+ <LogOut className="w-5 h-5" />
67
+ </button>
68
+ <Link
69
+ href={getDashboardLink()}
70
+ className="group relative px-6 py-2.5 bg-slate-900 hover:bg-slate-800 text-white font-semibold rounded-lg transition-all shadow-lg hover:shadow-xl hover:-translate-y-0.5 flex items-center gap-2"
71
+ >
72
+ <LayoutDashboard className="w-4 h-4" />
73
+ <span>Go to Dashboard</span>
74
+ </Link>
75
+ </>
76
+ ) : (
77
+ <>
78
+ <Link
79
+ href="/signin"
80
+ className="px-5 py-2 text-slate-600 hover:text-slate-900 font-medium transition-colors hover:bg-slate-100 rounded-lg"
81
+ >
82
+ Agent Login
83
+ </Link>
84
+ <Link
85
+ href="/signup"
86
+ className="group relative px-6 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-semibold rounded-lg transition-all shadow-lg shadow-blue-500/30 overflow-hidden hover:-translate-y-0.5"
87
+ >
88
+ <div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700" />
89
+ <span className="relative flex items-center gap-2">
90
+ Get Started <ChevronRight className="w-4 h-4" />
91
+ </span>
92
+ </Link>
93
+ </>
94
+ )}
95
+ </div>
96
+ </div>
97
  </div>
98
+ </nav>
99
+
100
+ <main className="relative z-10 pt-20">
101
+ {/* Hero Section */}
102
+ <div className="relative border-b border-slate-200 bg-gradient-to-b from-transparent to-white/50">
103
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-32 text-center">
104
+
105
+ <div className="inline-flex items-center gap-3 px-4 py-1.5 rounded-full bg-white border border-slate-200 shadow-sm mb-8 animate-fade-in-up">
106
+ <span className="relative flex h-2 w-2">
107
+ <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
108
+ <span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
109
+ </span>
110
+ <span className="text-sm font-bold text-slate-600 font-mono tracking-wide">
111
+ SYSTEM ONLINE
112
+ </span>
113
+ </div>
114
+
115
+ <h1 className="text-5xl md:text-7xl font-extrabold text-slate-900 tracking-tight mb-8 leading-tight">
116
+ City Infrastructure, <br />
117
+ <span className="bg-gradient-to-r from-blue-600 via-indigo-600 to-cyan-600 bg-clip-text text-transparent">
118
+ Reimagined by Intelligence.
119
+ </span>
120
+ </h1>
121
+
122
+ <p className="text-xl text-slate-600 mb-12 max-w-2xl mx-auto leading-relaxed">
123
+ The advanced civic reporting platform powered by AI vision analysis,
124
+ geo-spatial deduplication, and automated workforce routing.
125
+ </p>
126
+
127
+ <div className="flex flex-col sm:flex-row gap-6 justify-center items-center mb-20">
128
+ <Link
129
+ href="/signup"
130
+ className="px-8 py-4 bg-slate-900 text-white font-bold rounded-xl shadow-xl shadow-slate-900/20 hover:shadow-slate-900/30 transition-all hover:-translate-y-1 flex items-center gap-2"
131
+ >
132
+ Report an Issue
133
+ <Smartphone className="w-5 h-5" />
134
+ </Link>
135
+ <Link
136
+ href="/signin"
137
+ className="px-8 py-4 bg-white text-slate-700 font-semibold rounded-xl border border-slate-200 hover:bg-slate-50 transition-all shadow-lg shadow-slate-200/50 hover:border-slate-300 flex items-center gap-2"
138
+ >
139
+ Track Status
140
+ <Activity className="w-5 h-5 text-blue-600" />
141
+ </Link>
142
+ </div>
143
+
144
+ {/* Live Stats Ticker */}
145
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-5xl mx-auto">
146
+ <StatCard label="ISSUES RESOLVED" value="12,405" icon={CheckCircle2} color="text-emerald-500 bg-emerald-50" />
147
+ <StatCard label="ACTIVE AGENTS" value="84" icon={Users} color="text-blue-500 bg-blue-50" />
148
+ <StatCard label="AVG. REACTION" value="1.2hrs" icon={Zap} color="text-amber-500 bg-amber-50" />
149
+ <StatCard label="CITIES LIVE" value="3" icon={Building2} color="text-purple-500 bg-purple-50" />
150
+ </div>
151
+ </div>
152
+ </div>
153
+
154
+ {/* Features Grid */}
155
+ <div id="features" className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-32">
156
+ <div className="text-center mb-16">
157
+ <h2 className="text-3xl font-bold text-slate-900 mb-4">Core Capabilities</h2>
158
+ <p className="text-slate-500 max-w-2xl mx-auto text-lg">
159
+ Built on a microservices architecture designed for scale, security, and speed.
160
+ </p>
161
+ </div>
162
+
163
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
164
+ <FeatureCard
165
+ icon={<Radio className="w-8 h-8 text-blue-600" />}
166
+ title="Geo-Spatial AI"
167
+ desc="Automatically detects duplicate reports within a 50m radius using smart cluster analysis and GPS verification."
168
+ colorClass="bg-blue-50 group-hover:bg-blue-100/50"
169
  />
170
+ <FeatureCard
171
+ icon={<Zap className="w-8 h-8 text-amber-500" />}
172
+ title="Vision Agent"
173
+ desc="Computer vision algorithms analyze uploaded photos to identify pothole severity, debris types, and hazards instantly."
174
+ colorClass="bg-amber-50 group-hover:bg-amber-100/50"
175
+ />
176
+ <FeatureCard
177
+ icon={<Shield className="w-8 h-8 text-emerald-600" />}
178
+ title="End-to-End Encryption"
179
+ desc="Citizen data is AES-256 encrypted at rest. Zero-knowledge protocols ensure maximum privacy."
180
+ colorClass="bg-emerald-50 group-hover:bg-emerald-100/50"
181
+ />
182
+ </div>
183
  </div>
184
  </main>
185
+
186
+ <footer className="border-t border-slate-200 bg-white py-12 relative z-10">
187
+ <div className="max-w-7xl mx-auto px-4 text-center">
188
+ <div className="flex justify-center items-center gap-2 mb-8 opacity-75 hover:opacity-100 transition-opacity">
189
+ <div className="w-6 h-6 bg-slate-900 rounded-md flex items-center justify-center text-xs font-bold font-mono text-white">U</div>
190
+ <span className="text-sm font-bold tracking-wide text-slate-900">URBANLENS SYSTEMS</span>
191
+ </div>
192
+ <p className="text-slate-500 text-sm">
193
+ © 2026 Dept. of Public Works. Secure. Efficient. Transparent.
194
+ </p>
195
+ </div>
196
+ </footer>
197
+ </div>
198
+ );
199
+ }
200
+
201
+ function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
202
+ return (
203
+ <Link href={href} className="text-sm font-medium text-slate-500 hover:text-blue-600 transition-colors">
204
+ {children}
205
+ </Link>
206
+ )
207
+ }
208
+
209
+ function StatCard({ label, value, icon: Icon, color }: { label: string, value: string, icon: any, color: string }) {
210
+ return (
211
+ <div className="p-4 rounded-2xl bg-white border border-slate-200 shadow-sm hover:shadow-md hover:border-slate-300 transition-all group">
212
+ <div className="flex items-center gap-3 mb-2">
213
+ <div className={`p-1.5 rounded-lg ${color}`}>
214
+ <Icon className="w-4 h-4" />
215
+ </div>
216
+ <span className="text-xs font-mono text-slate-500 font-bold tracking-wider">{label}</span>
217
+ </div>
218
+ <div className="text-2xl font-bold text-slate-900 group-hover:scale-105 transition-transform origin-left font-mono">
219
+ {value}
220
+ </div>
221
+ </div>
222
+ )
223
+ }
224
+
225
+ function FeatureCard({
226
+ icon,
227
+ title,
228
+ desc,
229
+ colorClass
230
+ }: {
231
+ icon: React.ReactNode;
232
+ title: string;
233
+ desc: string;
234
+ colorClass: string;
235
+ }) {
236
+ return (
237
+ <div className="p-8 bg-white rounded-3xl border border-slate-200 shadow-xl shadow-slate-200/50 hover:shadow-2xl hover:shadow-slate-200/80 hover:-translate-y-1 transition-all group">
238
+ <div className={`w-16 h-16 ${colorClass} rounded-2xl flex items-center justify-center mb-6 border border-slate-100 group-hover:scale-110 transition-transform duration-300`}>
239
+ {icon}
240
+ </div>
241
+ <h3 className="text-xl font-bold text-slate-900 mb-3 tracking-tight">{title}</h3>
242
+ <p className="text-slate-500 leading-relaxed text-sm font-medium">{desc}</p>
243
  </div>
244
  );
245
  }
Frontend/app/signin/page.tsx ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useState, useEffect } from "react";
3
+ import { useRouter } from "next/navigation";
4
+ import Link from "next/link";
5
+ import { createClient } from "@supabase/supabase-js";
6
+ import { useAuth } from "@/components/AuthProvider";
7
+ import { HardHat, ShieldCheck, AlertTriangle } from "lucide-react";
8
+
9
+ const API_URL = process.env.NEXT_PUBLIC_API_URL;
10
+ if (!API_URL) throw new Error("Missing NEXT_PUBLIC_API_URL");
11
+ const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
12
+ const SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "";
13
+
14
+ const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
15
+
16
+ type LoginType = "staff" | "user";
17
+ type StaffRole = "admin" | "worker";
18
+
19
+ export default function SignInPage() {
20
+ const { role } = useAuth();
21
+ const router = useRouter();
22
+
23
+ const [loginType, setLoginType] = useState<LoginType>("user");
24
+ const [staffRole, setStaffRole] = useState<StaffRole>("worker");
25
+ const [email, setEmail] = useState("");
26
+ const [password, setPassword] = useState("");
27
+ const [error, setError] = useState("");
28
+ const [loading, setLoading] = useState(false);
29
+
30
+ useEffect(() => {
31
+ if (role) {
32
+ if (role === "admin") router.replace("/admin");
33
+ else if (role === "worker") router.replace("/worker");
34
+ else router.replace("/user");
35
+ }
36
+ }, [role, router]);
37
+
38
+ const handleStaffLogin = async (e: React.FormEvent) => {
39
+ e.preventDefault();
40
+ setLoading(true);
41
+ setError("");
42
+
43
+ try {
44
+ const res = await fetch(`${API_URL}/admin/login`, {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/json" },
47
+ body: JSON.stringify({ email, password, expected_role: staffRole }),
48
+ });
49
+
50
+ if (!res.ok) {
51
+ const data = await res.json();
52
+ throw new Error(data.detail || "Login failed");
53
+ }
54
+
55
+ const data = await res.json();
56
+ localStorage.setItem("token", data.access_token);
57
+ localStorage.setItem("user", JSON.stringify(data.user));
58
+
59
+ window.location.reload();
60
+ } catch (err: unknown) {
61
+ const message = err instanceof Error ? err.message : "Login failed";
62
+ setError(message);
63
+ setLoading(false);
64
+ }
65
+ };
66
+
67
+ const handleGoogleLogin = async () => {
68
+ setLoading(true);
69
+ try {
70
+ await supabase.auth.signInWithOAuth({
71
+ provider: "google",
72
+ options: {
73
+ redirectTo: `${window.location.origin}/auth/callback?next=/user`,
74
+ },
75
+ });
76
+ } catch (err: unknown) {
77
+ const message = err instanceof Error ? err.message : "Login failed";
78
+ setError(message);
79
+ setLoading(false);
80
+ }
81
+ };
82
+
83
+ return (
84
+ <div className="min-h-screen flex flex-col bg-slate-50">
85
+ <nav className="px-8 py-6 bg-white border-b border-slate-200">
86
+ <div className="max-w-7xl mx-auto flex justify-between items-center">
87
+ <Link href="/" className="text-2xl font-bold text-slate-800">
88
+ CityIssue
89
+ </Link>
90
+ <span className="text-sm text-slate-500 font-medium">
91
+ Secure Login
92
+ </span>
93
+ </div>
94
+ </nav>
95
+
96
+ <div className="flex-1 flex items-center justify-center p-4">
97
+ <div className="w-full max-w-md bg-white rounded-xl shadow-lg border border-slate-100 p-8">
98
+ <div className="text-center mb-8">
99
+ <h2 className="text-2xl font-bold text-slate-800">Welcome Back</h2>
100
+ <p className="text-slate-500 mt-2">Access the municipal portal</p>
101
+ </div>
102
+
103
+ <div className="flex bg-slate-100 p-1 rounded-lg mb-8">
104
+ <button
105
+ onClick={() => setLoginType("user")}
106
+ className={`flex-1 py-2 text-sm font-semibold rounded-md transition-all ${
107
+ loginType === "user"
108
+ ? "bg-white text-slate-900 shadow-sm"
109
+ : "text-slate-500 hover:text-slate-700"
110
+ }`}
111
+ >
112
+ Citizen
113
+ </button>
114
+ <button
115
+ onClick={() => setLoginType("staff")}
116
+ className={`flex-1 py-2 text-sm font-semibold rounded-md transition-all ${
117
+ loginType === "staff"
118
+ ? "bg-white text-slate-900 shadow-sm"
119
+ : "text-slate-500 hover:text-slate-700"
120
+ }`}
121
+ >
122
+ Staff
123
+ </button>
124
+ </div>
125
+
126
+ {error && (
127
+ <div className="mb-6 p-4 bg-red-50 border border-red-100 rounded-lg text-red-600 text-sm font-medium flex items-center gap-2">
128
+ <AlertTriangle className="w-5 h-5" />
129
+ {error}
130
+ </div>
131
+ )}
132
+
133
+ {loginType === "user" ? (
134
+ <div className="space-y-4">
135
+ <button
136
+ onClick={handleGoogleLogin}
137
+ disabled={loading}
138
+ className="w-full py-3 px-4 bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 font-semibold rounded-lg flex items-center justify-center gap-3 transition"
139
+ >
140
+ <img
141
+ src="https://www.google.com/favicon.ico"
142
+ alt="Google"
143
+ className="w-5 h-5"
144
+ />
145
+ {loading ? "Connecting..." : "Continue with Google"}
146
+ </button>
147
+ <p className="text-xs text-slate-500 text-center mt-4">
148
+ Secure authentication via Supabase. We do not store your Google
149
+ password.
150
+ </p>
151
+ </div>
152
+ ) : (
153
+ <form onSubmit={handleStaffLogin} className="space-y-5">
154
+ <div className="grid grid-cols-2 gap-3">
155
+ <button
156
+ type="button"
157
+ onClick={() => setStaffRole("worker")}
158
+ className={`py-2 px-3 text-sm font-medium rounded-lg border transition-all flex items-center justify-center gap-2 ${
159
+ staffRole === "worker"
160
+ ? "bg-blue-50 border-blue-200 text-blue-700"
161
+ : "bg-white border-slate-200 text-slate-500 hover:border-slate-300"
162
+ }`}
163
+ >
164
+ <HardHat className="w-4 h-4" /> Field Worker
165
+ </button>
166
+ <button
167
+ type="button"
168
+ onClick={() => setStaffRole("admin")}
169
+ className={`py-2 px-3 text-sm font-medium rounded-lg border transition-all flex items-center justify-center gap-2 ${
170
+ staffRole === "admin"
171
+ ? "bg-purple-50 border-purple-200 text-purple-700"
172
+ : "bg-white border-slate-200 text-slate-500 hover:border-slate-300"
173
+ }`}
174
+ >
175
+ <ShieldCheck className="w-4 h-4" /> Administrator
176
+ </button>
177
+ </div>
178
+
179
+ <div>
180
+ <label className="block text-sm font-semibold text-slate-700 mb-1">
181
+ Official Email
182
+ </label>
183
+ <input
184
+ type="email"
185
+ value={email}
186
+ onChange={(e) => setEmail(e.target.value)}
187
+ className="w-full px-4 py-3 bg-white border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-800 focus:border-transparent transition text-slate-900"
188
+ placeholder="name@city.gov"
189
+ required
190
+ />
191
+ </div>
192
+
193
+ <div>
194
+ <label className="block text-sm font-semibold text-slate-700 mb-1">
195
+ Password
196
+ </label>
197
+ <input
198
+ type="password"
199
+ value={password}
200
+ onChange={(e) => setPassword(e.target.value)}
201
+ className="w-full px-4 py-3 bg-white border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-800 focus:border-transparent transition text-slate-900"
202
+ placeholder="••••••••"
203
+ required
204
+ />
205
+ </div>
206
+
207
+ <button
208
+ type="submit"
209
+ disabled={loading}
210
+ className="w-full py-3 bg-slate-900 hover:bg-slate-800 text-white font-bold rounded-lg transition shadow-md disabled:opacity-70"
211
+ >
212
+ {loading ? "Verifying..." : "Access Portal"}
213
+ </button>
214
+ </form>
215
+ )}
216
+
217
+ <div className="mt-8 pt-6 border-t border-slate-100 text-center">
218
+ <p className="text-sm text-slate-500">
219
+ New citizen?{" "}
220
+ <Link
221
+ href="/signup"
222
+ className="text-blue-600 hover:text-blue-700 font-semibold"
223
+ >
224
+ Create account
225
+ </Link>
226
+ </p>
227
+ </div>
228
+ </div>
229
+ </div>
230
+ </div>
231
+ );
232
+ }
Frontend/app/signup/page.tsx ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useState } from "react";
3
+ import { useRouter } from "next/navigation";
4
+ import Link from "next/link";
5
+ import { createClient } from "@supabase/supabase-js";
6
+
7
+ const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
8
+ const SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "";
9
+
10
+ const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
11
+
12
+ export default function SignUpPage() {
13
+ const [loading, setLoading] = useState(false);
14
+ const [error, setError] = useState("");
15
+ const router = useRouter();
16
+
17
+ const handleGoogleSignUp = async () => {
18
+ setLoading(true);
19
+ setError("");
20
+
21
+ try {
22
+ const { error } = await supabase.auth.signInWithOAuth({
23
+ provider: "google",
24
+ options: {
25
+ redirectTo: `${window.location.origin}/signin`,
26
+ },
27
+ });
28
+
29
+ if (error) throw error;
30
+ } catch (err: any) {
31
+ setError(err.message);
32
+ setLoading(false);
33
+ }
34
+ };
35
+
36
+ return (
37
+ <div className="min-h-screen flex flex-col bg-slate-50">
38
+ <nav className="px-8 py-6 bg-white border-b border-slate-200">
39
+ <div className="max-w-7xl mx-auto flex justify-between items-center">
40
+ <Link href="/" className="text-2xl font-bold text-slate-800">CityIssue</Link>
41
+ </div>
42
+ </nav>
43
+
44
+ <div className="flex-1 flex items-center justify-center p-4">
45
+ <div className="w-full max-w-md bg-white rounded-xl shadow-lg border border-slate-100 p-8">
46
+ <div className="text-center mb-8">
47
+ <h2 className="text-2xl font-bold text-slate-800">Create Account</h2>
48
+ <p className="text-slate-500 mt-2">Join as a citizen to report issues</p>
49
+ </div>
50
+
51
+ {error && (
52
+ <div className="mb-6 p-4 bg-red-50 border border-red-100 rounded-lg text-red-600 text-sm font-medium">
53
+ {error}
54
+ </div>
55
+ )}
56
+
57
+ <button
58
+ onClick={handleGoogleSignUp}
59
+ disabled={loading}
60
+ className="w-full py-4 px-4 bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 font-bold rounded-lg flex items-center justify-center gap-3 transition shadow-sm"
61
+ >
62
+ <img src="https://www.google.com/favicon.ico" alt="Google" className="w-5 h-5" />
63
+ {loading ? "Creating..." : "Sign up with Google"}
64
+ </button>
65
+
66
+ <div className="mt-8 p-6 bg-blue-50 border border-blue-100 rounded-xl">
67
+ <div className="flex items-start gap-3">
68
+ <span className="text-2xl">📱</span>
69
+ <div>
70
+ <h4 className="text-sm font-bold text-blue-900 mb-1">Mobile App Recommended</h4>
71
+ <p className="text-xs text-blue-700 leading-relaxed">
72
+ For the best experience, download our mobile app. It supports GPS-verified photo uploads which are required for official reports.
73
+ </p>
74
+ </div>
75
+ </div>
76
+ </div>
77
+
78
+ <p className="mt-8 text-center text-sm text-slate-500">
79
+ Already have an account?{" "}
80
+ <Link href="/signin" className="text-blue-600 hover:text-blue-700 font-semibold">
81
+ Sign in
82
+ </Link>
83
+ </p>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ );
88
+ }
Frontend/app/user/issues/[id]/page.tsx ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import { useParams, useRouter } from "next/navigation";
4
+
5
+ export const runtime = "edge";
6
+ import { apiGet } from "@/lib/api";
7
+ import {
8
+ ArrowLeft,
9
+ MapPin,
10
+ Calendar,
11
+ CheckCircle2,
12
+ Clock,
13
+ AlertTriangle,
14
+ ImageIcon,
15
+ Activity,
16
+ Maximize2,
17
+ } from "lucide-react";
18
+ import Link from "next/link";
19
+
20
+ interface IssueEvent {
21
+ id: string;
22
+ event_type: string;
23
+ created_at: string;
24
+ data: any;
25
+ }
26
+
27
+ interface Issue {
28
+ id: string;
29
+ description: string;
30
+ state: string;
31
+ city: string;
32
+ locality: string;
33
+ created_at: string;
34
+ full_address: string;
35
+ priority: number;
36
+ category: string;
37
+ confidence: number;
38
+ image_urls: string[];
39
+ annotated_urls: string[];
40
+ validation_source: string;
41
+ sla_deadline?: string;
42
+ is_duplicate: boolean;
43
+ history?: IssueEvent[];
44
+ }
45
+
46
+ export default function UserIssueDetailPage() {
47
+ const params = useParams();
48
+ const router = useRouter();
49
+ const [issue, setIssue] = useState<Issue | null>(null);
50
+ const [loading, setLoading] = useState(true);
51
+
52
+ useEffect(() => {
53
+ if (params.id) {
54
+ fetchIssueDetails(params.id as string);
55
+ }
56
+ }, [params.id]);
57
+
58
+ const fetchIssueDetails = async (id: string) => {
59
+ try {
60
+ // Using generic endpoint - typically users can access their own issues
61
+ const data = await apiGet<Issue>(`/issues/${id}`);
62
+ setIssue(data);
63
+ } catch (error) {
64
+ console.error("Failed to fetch issue details:", error);
65
+ // alert("Failed to load issue details.");
66
+ } finally {
67
+ setLoading(false);
68
+ }
69
+ };
70
+
71
+ const getStateBadge = (state: string) => {
72
+ const styles: Record<string, string> = {
73
+ reported: "bg-blue-100 text-blue-800 border-blue-200",
74
+ assigned: "bg-amber-100 text-amber-800 border-amber-200",
75
+ in_progress: "bg-orange-100 text-orange-800 border-orange-200",
76
+ resolved: "bg-emerald-100 text-emerald-800 border-emerald-200",
77
+ closed: "bg-slate-100 text-slate-600 border-slate-200",
78
+ rejected: "bg-red-100 text-red-800 border-red-200",
79
+ };
80
+ return (
81
+ <span
82
+ className={`px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider border ${styles[state] || styles.reported}`}
83
+ >
84
+ {state.replace("_", " ")}
85
+ </span>
86
+ );
87
+ };
88
+
89
+ if (loading) {
90
+ return (
91
+ <div className="min-h-screen bg-urban-bg flex items-center justify-center">
92
+ <div className="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-urban-primary"></div>
93
+ </div>
94
+ );
95
+ }
96
+
97
+ if (!issue) {
98
+ return (
99
+ <div className="min-h-screen bg-urban-bg p-8 flex flex-col items-center justify-center">
100
+ <h2 className="text-xl font-bold text-slate-700">Issue Not Found</h2>
101
+ <Link href="/user" className="mt-4 text-urban-primary hover:underline">
102
+ Return to Dashboard
103
+ </Link>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ return (
109
+ <div className="min-h-screen bg-urban-bg font-sans pb-12">
110
+ {/* Ambient Background */}
111
+ <div className="fixed inset-0 pointer-events-none overflow-hidden z-0">
112
+ <div className="absolute top-[-10%] right-[-10%] w-[40%] h-[40%] bg-blue-400/10 rounded-full blur-[100px]"></div>
113
+ <div className="absolute bottom-[-10%] left-[-10%] w-[30%] h-[30%] bg-urban-primary/5 rounded-full blur-[80px]"></div>
114
+ </div>
115
+
116
+ <main className="relative z-10 max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
117
+ {/* Header */}
118
+ <div className="mb-8">
119
+ <Link
120
+ href="/user"
121
+ className="inline-flex items-center gap-2 text-slate-500 hover:text-urban-primary mb-4 transition-colors font-medium"
122
+ >
123
+ <ArrowLeft className="w-4 h-4" /> Back to Dashboard
124
+ </Link>
125
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
126
+ <div>
127
+ <div className="flex items-center gap-3 mb-2">
128
+ <span className="font-mono text-slate-400 bg-white/50 px-2 py-0.5 rounded text-sm border border-slate-200">
129
+ #{issue.id.slice(0, 8)}
130
+ </span>
131
+ {getStateBadge(issue.state)}
132
+ </div>
133
+ <h1 className="text-3xl font-bold text-slate-900 tracking-tight">
134
+ {issue.category || "Reported Issue"}
135
+ </h1>
136
+ </div>
137
+ {issue.priority && (
138
+ <div className="text-right">
139
+ <div className="text-xs text-slate-400 font-bold uppercase tracking-wider mb-1">
140
+ Priority Level
141
+ </div>
142
+ <div
143
+ className={`text-lg font-bold px-4 py-1 rounded-full border border-slate-200 inline-block bg-white shadow-sm ${
144
+ issue.priority === 1
145
+ ? "text-red-600 border-red-100 bg-red-50"
146
+ : issue.priority === 2
147
+ ? "text-orange-600 border-orange-100 bg-orange-50"
148
+ : "text-slate-700"
149
+ }`}
150
+ >
151
+ {issue.priority === 1
152
+ ? "Critical"
153
+ : issue.priority === 2
154
+ ? "High"
155
+ : "Normal"}
156
+ </div>
157
+ </div>
158
+ )}
159
+ </div>
160
+ </div>
161
+
162
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
163
+ {/* Left Column: Details & Evidence */}
164
+ <div className="lg:col-span-2 space-y-8">
165
+ {/* Analysis & Location */}
166
+ <div className="card bg-white/80 backdrop-blur-md">
167
+ <div className="flex items-center gap-3 mb-6 pb-4 border-b border-slate-100">
168
+ <div className="p-2 bg-blue-50 text-blue-600 rounded-lg">
169
+ <Activity className="w-5 h-5" />
170
+ </div>
171
+ <h2 className="text-lg font-bold text-slate-800">
172
+ Issue Details & Analysis
173
+ </h2>
174
+ </div>
175
+
176
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
177
+ <div>
178
+ <label className="text-xs font-bold text-slate-400 uppercase tracking-wider block mb-2">
179
+ Description
180
+ </label>
181
+ <p className="text-slate-800 font-medium leading-relaxed bg-slate-50 p-3 rounded-lg border border-slate-100">
182
+ {issue.description || "No description provided."}
183
+ </p>
184
+ </div>
185
+ <div>
186
+ <label className="text-xs font-bold text-slate-400 uppercase tracking-wider block mb-2">
187
+ AI Confidence
188
+ </label>
189
+ <div className="flex items-center gap-2">
190
+ <div className="h-2 flex-1 bg-slate-100 rounded-full overflow-hidden">
191
+ <div
192
+ className="h-full bg-urban-primary rounded-full"
193
+ style={{ width: `${(issue.confidence || 0) * 100}%` }}
194
+ ></div>
195
+ </div>
196
+ <span className="font-mono text-sm font-bold text-slate-700">
197
+ {issue.confidence
198
+ ? `${(issue.confidence * 100).toFixed(1)}%`
199
+ : "N/A"}
200
+ </span>
201
+ </div>
202
+ </div>
203
+ </div>
204
+
205
+ <div>
206
+ <label className="text-xs font-bold text-slate-400 uppercase tracking-wider block mb-2">
207
+ Location
208
+ </label>
209
+ <div className="flex items-start gap-3 bg-slate-50 p-3 rounded-lg border border-slate-100">
210
+ <MapPin className="w-5 h-5 text-urban-primary shrink-0 mt-0.5" />
211
+ <div>
212
+ <p className="text-slate-900 font-medium">
213
+ {issue.full_address || issue.locality}
214
+ </p>
215
+ <p className="text-xs text-slate-500 mt-1">{issue.city}</p>
216
+ </div>
217
+ </div>
218
+ </div>
219
+ </div>
220
+
221
+ {/* Evidence Gallery */}
222
+ <div className="card bg-white/80 backdrop-blur-md">
223
+ <div className="flex items-center gap-3 mb-6 pb-4 border-b border-slate-100">
224
+ <div className="p-2 bg-purple-50 text-purple-600 rounded-lg">
225
+ <ImageIcon className="w-5 h-5" />
226
+ </div>
227
+ <h2 className="text-lg font-bold text-slate-800">
228
+ Evidence & AI Vision
229
+ </h2>
230
+ </div>
231
+
232
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
233
+ {/* Original Image */}
234
+ <div className="space-y-3">
235
+ <div className="flex justify-between items-center">
236
+ <h3 className="text-sm font-bold text-slate-700">
237
+ Original Photo
238
+ </h3>
239
+ </div>
240
+ <div className="relative aspect-video bg-slate-100 rounded-xl overflow-hidden border border-slate-200 shadow-sm group">
241
+ {issue.image_urls?.[0] ? (
242
+ <>
243
+ <img
244
+ src={issue.image_urls[0]}
245
+ alt="Original Issue"
246
+ className="w-full h-full object-cover"
247
+ />
248
+ <div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100">
249
+ <a
250
+ href={issue.image_urls[0]}
251
+ target="_blank"
252
+ rel="noopener noreferrer"
253
+ className="bg-white/90 backdrop-blur text-slate-900 px-4 py-2 rounded-full text-sm font-bold shadow-lg flex items-center gap-2 transform translate-y-2 group-hover:translate-y-0 transition-all"
254
+ >
255
+ <Maximize2 className="w-4 h-4" /> View Full Size
256
+ </a>
257
+ </div>
258
+ </>
259
+ ) : (
260
+ <div className="flex items-center justify-center h-full text-slate-400">
261
+ <ImageIcon className="w-8 h-8 opacity-50" />
262
+ </div>
263
+ )}
264
+ </div>
265
+ </div>
266
+
267
+ {/* AI Annotated Image */}
268
+ <div className="space-y-3">
269
+ <div className="flex justify-between items-center">
270
+ <h3 className="text-sm font-bold text-urban-primary flex items-center gap-2">
271
+ <span className="w-2 h-2 rounded-full bg-urban-primary animate-pulse"></span>
272
+ Vision Agent Analysis
273
+ </h3>
274
+ </div>
275
+ <div className="relative aspect-video bg-slate-900 rounded-xl overflow-hidden border-2 border-urban-primary/20 shadow-lg shadow-urban-primary/5 group">
276
+ {issue.annotated_urls?.[0] ? (
277
+ <>
278
+ <img
279
+ src={issue.annotated_urls[0]}
280
+ alt="AI Analysis"
281
+ className="w-full h-full object-cover"
282
+ />
283
+ <div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100">
284
+ <a
285
+ href={issue.annotated_urls[0]}
286
+ target="_blank"
287
+ rel="noopener noreferrer"
288
+ className="bg-urban-primary/90 backdrop-blur text-white px-4 py-2 rounded-full text-sm font-bold shadow-lg flex items-center gap-2 transform translate-y-2 group-hover:translate-y-0 transition-all"
289
+ >
290
+ <Maximize2 className="w-4 h-4" /> Inspect Analysis
291
+ </a>
292
+ </div>
293
+ </>
294
+ ) : (
295
+ <div className="flex flex-col items-center justify-center h-full text-slate-500 bg-slate-50/5">
296
+ <Activity className="w-8 h-8 opacity-50 mb-2" />
297
+ <span className="text-xs">
298
+ Processing visualization...
299
+ </span>
300
+ </div>
301
+ )}
302
+
303
+ {/* Badge Overlay */}
304
+ <div className="absolute bottom-3 right-3">
305
+ <span className="bg-black/60 backdrop-blur-md text-white text-[10px] font-mono px-2 py-1 rounded border border-white/10 flex items-center gap-1.5">
306
+ <div className="w-1.5 h-1.5 bg-green-400 rounded-full"></div>
307
+ Object Detection v2
308
+ </span>
309
+ </div>
310
+ </div>
311
+ </div>
312
+ </div>
313
+ </div>
314
+ </div>
315
+
316
+ {/* Right Column: Timeline */}
317
+ <div className="space-y-8">
318
+ <div className="card bg-white/80 backdrop-blur-md h-full">
319
+ <div className="flex items-center gap-3 mb-6 pb-4 border-b border-slate-100">
320
+ <div className="p-2 bg-slate-100 text-slate-600 rounded-lg">
321
+ <Clock className="w-5 h-5" />
322
+ </div>
323
+ <h2 className="text-lg font-bold text-slate-800">
324
+ Status Timeline
325
+ </h2>
326
+ </div>
327
+
328
+ <div className="relative pl-4 border-l-2 border-slate-100 space-y-8 py-2">
329
+ {issue.state === "reported" && (
330
+ <div className="relative">
331
+ <span className="absolute -left-[21px] top-1 w-4 h-4 rounded-full border-2 border-blue-500 bg-white ring-4 ring-blue-50"></span>
332
+ <h4 className="text-sm font-bold text-slate-900">
333
+ Report Submitted
334
+ </h4>
335
+ <p className="text-xs text-slate-500 mt-1">
336
+ Issue has been received and is being processed by our AI
337
+ systems.
338
+ </p>
339
+ <span className="text-[10px] font-mono text-slate-400 mt-2 block">
340
+ {new Date(issue.created_at).toLocaleString()}
341
+ </span>
342
+ </div>
343
+ )}
344
+
345
+ {issue.state === "resolved" && (
346
+ <div className="relative">
347
+ <span className="absolute -left-[21px] top-1 w-4 h-4 rounded-full border-2 border-emerald-500 bg-emerald-500 ring-4 ring-emerald-50"></span>
348
+ <h4 className="text-sm font-bold text-emerald-700">
349
+ Issue Resolved
350
+ </h4>
351
+ <p className="text-xs text-slate-500 mt-1">
352
+ Work has been completed and verified.
353
+ </p>
354
+ </div>
355
+ )}
356
+
357
+ {/* Default Start */}
358
+ <div className="relative opacity-60">
359
+ <span className="absolute -left-[21px] top-1 w-4 h-4 rounded-full border-2 border-slate-300 bg-white"></span>
360
+ <h4 className="text-sm font-bold text-slate-600">
361
+ Issue Created
362
+ </h4>
363
+ <span className="text-[10px] font-mono text-slate-400 mt-1 block">
364
+ {new Date(issue.created_at).toLocaleString()}
365
+ </span>
366
+ </div>
367
+ </div>
368
+ </div>
369
+ </div>
370
+ </div>
371
+ </main>
372
+ </div>
373
+ );
374
+ }
Frontend/app/user/page.tsx ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import { useRouter } from "next/navigation";
4
+ import { useAuth } from "@/components/AuthProvider";
5
+ import { apiGet } from "@/lib/api";
6
+ import { useCachedFetch } from "@/hooks/useCachedFetch";
7
+ import { Smartphone, FileText, MapPin } from "lucide-react";
8
+
9
+ const API_URL = process.env.NEXT_PUBLIC_API_URL;
10
+ if (!API_URL) throw new Error("Missing NEXT_PUBLIC_API_URL");
11
+
12
+ interface Issue {
13
+ id: string;
14
+ description: string;
15
+ priority: number;
16
+ state: string;
17
+ city: string;
18
+ locality: string;
19
+ created_at: string;
20
+ primary_category: string;
21
+ classification?: {
22
+ primary_category: string;
23
+ confidence: number;
24
+ };
25
+ images?: { file_path: string; annotated_path: string }[];
26
+ }
27
+
28
+ interface IssuesResponse {
29
+ items: Issue[];
30
+ }
31
+
32
+ export default function UserDashboard() {
33
+ const { user, role, signOut, loading: authLoading } = useAuth();
34
+ const router = useRouter();
35
+
36
+ useEffect(() => {
37
+ if (!authLoading) {
38
+ if (role !== "user") {
39
+ router.push("/signin");
40
+ }
41
+ }
42
+ }, [authLoading, role, router]);
43
+
44
+ // Only fetch if user ID is available
45
+ const fetchUrl = user?.id ? `/issues?user_id=${user.id}` : "";
46
+ const { data: issuesResponse, loading: issuesLoading } = useCachedFetch<IssuesResponse>(fetchUrl);
47
+
48
+ const issues = issuesResponse?.items || [];
49
+
50
+ // Create a combined loading state, but prioritize showing dashboard shell if auth is done
51
+ const contentLoading = authLoading || (issuesLoading && issues.length === 0);
52
+
53
+ const getStateBadge = (state: string) => {
54
+ const styles: Record<string, string> = {
55
+ reported: "bg-blue-100 text-blue-800",
56
+ assigned: "bg-yellow-100 text-yellow-800",
57
+ in_progress: "bg-orange-100 text-orange-800",
58
+ resolved: "bg-green-100 text-green-800",
59
+ closed: "bg-slate-100 text-slate-600",
60
+ };
61
+ return (
62
+ <span
63
+ className={`px-2.5 py-0.5 rounded-full text-xs font-semibold ${styles[state] || styles.reported}`}
64
+ >
65
+ {state.replace("_", " ").toUpperCase()}
66
+ </span>
67
+ );
68
+ };
69
+
70
+ if (authLoading) {
71
+ return (
72
+ <div className="min-h-screen bg-slate-50 flex items-center justify-center">
73
+ <div className="text-slate-600 font-medium">Loading Dashboard...</div>
74
+ </div>
75
+ );
76
+ }
77
+
78
+ return (
79
+ <div className="min-h-screen bg-slate-50 font-sans">
80
+ <nav className="bg-white border-b border-slate-200 sticky top-0 z-50">
81
+ <div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
82
+ <div className="flex justify-between h-16 items-center">
83
+ <div>
84
+ <h1 className="text-xl font-bold text-slate-900">My Reports</h1>
85
+ <p className="text-xs text-slate-500">Citizen Portal</p>
86
+ </div>
87
+ <div className="flex items-center gap-4">
88
+ <div className="text-right hidden sm:block">
89
+ <p className="text-sm font-medium text-slate-900">
90
+ {user?.user_metadata?.full_name || user?.email || "User"}
91
+ </p>
92
+ <p className="text-xs text-slate-500">{user?.email}</p>
93
+ </div>
94
+ <button
95
+ onClick={signOut}
96
+ className="px-4 py-2 bg-white border border-slate-200 text-red-600 font-medium rounded-lg hover:bg-red-50 hover:border-red-100 transition shadow-sm"
97
+ >
98
+ Sign Out
99
+ </button>
100
+ </div>
101
+ </div>
102
+ </div>
103
+ </nav>
104
+
105
+ <main className="max-w-5xl mx-auto px-4 py-8">
106
+ <div className="mb-8 p-6 bg-blue-50 border border-blue-100 rounded-xl flex items-start gap-4">
107
+ <div className="p-2 bg-blue-100 rounded-lg">
108
+ <Smartphone className="w-6 h-6 text-blue-600" />
109
+ </div>
110
+ <div>
111
+ <h3 className="text-blue-900 font-bold text-lg mb-1">
112
+ Make a New Report
113
+ </h3>
114
+ <p className="text-blue-700 leading-relaxed">
115
+ To ensure data accuracy and GPS verification, new issues must be
116
+ reported through the official{" "}
117
+ <strong>City Issue Mobile App</strong>.
118
+ </p>
119
+ </div>
120
+ </div>
121
+
122
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
123
+ <div className="p-6 bg-white rounded-xl border border-slate-200 shadow-sm">
124
+ <p className="text-slate-500 text-sm font-medium uppercase tracking-wide">
125
+ Total Reports
126
+ </p>
127
+ <p className="text-3xl font-extrabold text-slate-900 mt-2">
128
+ {issues.length}
129
+ </p>
130
+ </div>
131
+ <div className="p-6 bg-white rounded-xl border border-slate-200 shadow-sm">
132
+ <p className="text-slate-500 text-sm font-medium uppercase tracking-wide">
133
+ Resolved
134
+ </p>
135
+ <p className="text-3xl font-extrabold text-emerald-600 mt-2">
136
+ {issues.filter((i) => i.state === "resolved").length}
137
+ </p>
138
+ </div>
139
+ <div className="p-6 bg-white rounded-xl border border-slate-200 shadow-sm">
140
+ <p className="text-slate-500 text-sm font-medium uppercase tracking-wide">
141
+ In Progress
142
+ </p>
143
+ <p className="text-3xl font-extrabold text-amber-500 mt-2">
144
+ {
145
+ issues.filter((i) =>
146
+ ["assigned", "in_progress"].includes(i.state),
147
+ ).length
148
+ }
149
+ </p>
150
+ </div>
151
+ </div>
152
+
153
+ <h2 className="text-xl font-bold text-slate-900 mb-6">
154
+ Recent Activity
155
+ </h2>
156
+
157
+ {issues.length === 0 ? (
158
+ <div className="text-center py-16 bg-white rounded-xl border border-slate-200 shadow-sm">
159
+ <FileText className="w-12 h-12 mx-auto text-slate-300" />
160
+ <p className="text-slate-900 font-medium text-lg mt-4">
161
+ No reports submitted yet
162
+ </p>
163
+ <p className="text-slate-500 mt-2">
164
+ Download the mobile app to start contributing to your city.
165
+ </p>
166
+ </div>
167
+ ) : (
168
+ <div className="space-y-4">
169
+ {issues.map((issue) => (
170
+ <div
171
+ key={issue.id}
172
+ className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow"
173
+ >
174
+ <div className="flex flex-col sm:flex-row justify-between items-start gap-4 mb-4">
175
+ <div className="flex items-center gap-3">
176
+ {getStateBadge(issue.state)}
177
+ <span className="text-xs font-bold text-slate-500 uppercase tracking-wide px-2 border-l border-slate-200">
178
+ {issue.classification?.primary_category ||
179
+ issue.primary_category ||
180
+ "General Issue"}
181
+ </span>
182
+ </div>
183
+ <span className="text-sm text-slate-400 font-medium">
184
+ {new Date(issue.created_at).toLocaleDateString(undefined, {
185
+ month: "long",
186
+ day: "numeric",
187
+ year: "numeric",
188
+ })}
189
+ </span>
190
+ </div>
191
+
192
+ <h3 className="text-lg font-bold text-slate-900 mb-2">
193
+ {issue.description || "No description provided"}
194
+ </h3>
195
+
196
+ <div className="flex items-center gap-2 text-sm text-slate-500">
197
+ <span className="flex items-center gap-1">
198
+ <MapPin className="w-4 h-4 text-slate-400" />
199
+ {issue.locality ? `${issue.locality}, ` : ""}
200
+ {issue.city}
201
+ </span>
202
+ </div>
203
+ </div>
204
+ ))}
205
+ </div>
206
+ )}
207
+ </main>
208
+
209
+ <footer className="mt-12 py-8 bg-white border-t border-slate-200">
210
+ <div className="max-w-5xl mx-auto px-4 text-center">
211
+ <p className="text-sm text-slate-400">
212
+ 2026 City Department of Public Works - Secure Citizen Portal
213
+ </p>
214
+ </div>
215
+ </footer>
216
+ </div>
217
+ );
218
+ }
Frontend/app/worker/layout.tsx ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useState, useEffect } from "react";
3
+ import { useRouter } from "next/navigation";
4
+ import { useAuth } from "@/components/AuthProvider";
5
+ import DashboardSidebar from "@/components/DashboardSidebar";
6
+ import DashboardHeader from "@/components/DashboardHeader";
7
+
8
+ export default function WorkerLayout({ children }: { children: React.ReactNode }) {
9
+ const { role, loading, signOut } = useAuth();
10
+ const [mobileOpen, setMobileOpen] = useState(false);
11
+ const [isSidebarOpen, setIsSidebarOpen] = useState(false);
12
+ const router = useRouter();
13
+
14
+ useEffect(() => {
15
+ if (!loading && role !== "worker") {
16
+ router.push("/signin");
17
+ }
18
+ }, [loading, role, router]);
19
+
20
+ if (loading) return null;
21
+
22
+ return (
23
+ <div className="flex min-h-screen bg-slate-50 font-sans text-slate-900 overflow-hidden">
24
+ <DashboardSidebar
25
+ role="worker"
26
+ mobileOpen={mobileOpen}
27
+ setMobileOpen={setMobileOpen}
28
+ desktopOpen={isSidebarOpen}
29
+ onLogout={signOut}
30
+ />
31
+
32
+ <div className="flex-1 flex flex-col min-h-screen transition-all duration-300 h-screen overflow-hidden">
33
+ <DashboardHeader
34
+ setMobileOpen={setMobileOpen}
35
+ toggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)}
36
+ title="Field Worker Portal"
37
+ />
38
+ <main className="flex-1 overflow-x-hidden overflow-y-auto p-4 sm:p-6 lg:p-8">
39
+ {children}
40
+ </main>
41
+ </div>
42
+ </div>
43
+ );
44
+ }
Frontend/app/worker/page.tsx ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ // Removed duplicate imports
4
+ import { useRouter } from "next/navigation";
5
+ import Link from "next/link";
6
+ import { useAuth } from "@/components/AuthProvider";
7
+ import { apiGet } from "@/lib/api";
8
+ import {
9
+ Coffee,
10
+ MapPin,
11
+ ArrowRight,
12
+ AlertCircle,
13
+ Calendar,
14
+ } from "lucide-react";
15
+ import { Skeleton } from "@/components/ui/Skeleton";
16
+
17
+ interface Task {
18
+ id: string;
19
+ description: string;
20
+ priority: number;
21
+ state: string;
22
+ city: string;
23
+ locality: string;
24
+ full_address: string;
25
+ latitude: number;
26
+ longitude: number;
27
+ image_url: string;
28
+ created_at: string;
29
+ sla_deadline: string;
30
+ }
31
+
32
+ import { useCachedFetch } from "@/hooks/useCachedFetch";
33
+
34
+ // ... existing imports
35
+
36
+ export default function WorkerDashboard() {
37
+ const { user, role, loading: authLoading } = useAuth();
38
+ const router = useRouter();
39
+
40
+ // Use cached fetch for instant load + background update
41
+ const { data: tasksData, loading: tasksLoading } = useCachedFetch<Task[]>(
42
+ role === "worker" ? "/worker/tasks" : ""
43
+ );
44
+
45
+ const tasks = tasksData || [];
46
+
47
+ // Combine loading states
48
+ const isLoading = authLoading || (tasksLoading && tasks.length === 0);
49
+
50
+
51
+ const getPriorityBadge = (priority: number) => {
52
+ const badges: Record<number, { bg: string; text: string; border: string }> =
53
+ {
54
+ 1: { bg: "bg-red-50", text: "text-red-700", border: "border-red-200" },
55
+ 2: {
56
+ bg: "bg-orange-50",
57
+ text: "text-orange-700",
58
+ border: "border-orange-200",
59
+ },
60
+ 3: {
61
+ bg: "bg-amber-50",
62
+ text: "text-amber-700",
63
+ border: "border-amber-200",
64
+ },
65
+ 4: {
66
+ bg: "bg-emerald-50",
67
+ text: "text-emerald-700",
68
+ border: "border-emerald-200",
69
+ },
70
+ };
71
+ const labels: Record<number, string> = {
72
+ 1: "Critical",
73
+ 2: "High",
74
+ 3: "Medium",
75
+ 4: "Low",
76
+ };
77
+ const badge = badges[priority] || badges[3];
78
+ return (
79
+ <span
80
+ className={`px-2.5 py-1 rounded-md text-xs font-bold border ${badge.bg} ${badge.text} ${badge.border} flex items-center gap-1.5`}
81
+ >
82
+ <span className={`w-1.5 h-1.5 rounded-full ${priority === 1 ? 'bg-red-500' : priority === 2 ? 'bg-orange-500' : priority === 3 ? 'bg-amber-500' : 'bg-emerald-500'}`}></span>
83
+ {labels[priority] || "Unknown"}
84
+ </span>
85
+ );
86
+ };
87
+
88
+ if (isLoading) {
89
+ return (
90
+ <div className="space-y-6">
91
+ <div className="flex justify-between items-center">
92
+ <div className="space-y-2">
93
+ <Skeleton className="h-8 w-48" />
94
+ <Skeleton className="h-4 w-64" />
95
+ </div>
96
+ </div>
97
+
98
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
99
+ <Skeleton className="h-32 rounded-2xl" />
100
+ <Skeleton className="h-32 rounded-2xl" />
101
+ <Skeleton className="h-32 rounded-2xl" />
102
+ </div>
103
+
104
+ <Skeleton className="h-6 w-40 mb-4" />
105
+
106
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
107
+ {Array.from({ length: 4 }).map((_, i) => (
108
+ <Skeleton key={i} className="h-64 rounded-2xl" />
109
+ ))}
110
+ </div>
111
+ </div>
112
+ );
113
+ }
114
+
115
+ return (
116
+ <div className="space-y-6">
117
+ <div className="flex justify-between items-center">
118
+ <div>
119
+ <h2 className="text-2xl font-bold text-slate-900 tracking-tight">My Assignments</h2>
120
+ <p className="text-sm text-slate-500">
121
+ Tasks assigned to you for resolution.
122
+ </p>
123
+ </div>
124
+ </div>
125
+
126
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
127
+ <div className="bg-white/60 backdrop-blur-md p-6 rounded-2xl border border-slate-200/60 shadow-urban-sm hover:shadow-urban-md transition-all">
128
+ <div className="flex items-center gap-3 mb-2">
129
+ <div className="p-2.5 bg-blue-50 text-blue-600 rounded-xl shadow-sm">
130
+ <AlertCircle className="w-5 h-5" />
131
+ </div>
132
+ <h3 className="text-slate-500 font-bold text-xs uppercase tracking-wider font-mono">
133
+ Active Tasks
134
+ </h3>
135
+ </div>
136
+ <p className="text-4xl font-extrabold text-slate-900 mt-2 tracking-tighter">
137
+ {
138
+ tasks.filter((t) =>
139
+ ["assigned", "in_progress", "rejected"].includes(t.state)
140
+ ).length
141
+ }
142
+ </p>
143
+ </div>
144
+ <div className="bg-white/60 backdrop-blur-md p-6 rounded-2xl border border-slate-200/60 shadow-urban-sm hover:shadow-urban-md transition-all">
145
+ <div className="flex items-center gap-3 mb-2">
146
+ <div className="p-2.5 bg-amber-50 text-amber-600 rounded-xl shadow-sm">
147
+ <Coffee className="w-5 h-5" />
148
+ </div>
149
+ <h3 className="text-slate-500 font-bold text-xs uppercase tracking-wider font-mono">
150
+ Pending Review
151
+ </h3>
152
+ </div>
153
+ <p className="text-4xl font-extrabold text-slate-900 mt-2 tracking-tighter">
154
+ {
155
+ tasks.filter((t) =>
156
+ ["pending_verification", "resolved"].includes(t.state)
157
+ ).length
158
+ }
159
+ </p>
160
+ </div>
161
+ </div>
162
+
163
+ <h3 className="text-lg font-bold text-slate-900 mb-4 flex items-center gap-2">
164
+ <span className="w-1 h-5 bg-blue-600 rounded-full"></span>
165
+ Current Assignments
166
+ </h3>
167
+
168
+ {tasks.length === 0 ? (
169
+ <div className="text-center py-16 bg-white/40 backdrop-blur-sm rounded-2xl border border-slate-200/60 border-dashed">
170
+ <Coffee className="w-12 h-12 mx-auto text-slate-300 mb-4" />
171
+ <p className="text-slate-900 font-bold text-lg">
172
+ All caught up!
173
+ </p>
174
+ <p className="text-slate-500 text-sm mt-1">
175
+ Enjoy your break, no pending assignments.
176
+ </p>
177
+ </div>
178
+ ) : (
179
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
180
+ {tasks.map((task) => (
181
+ <Link key={task.id} href={`/worker/task/${task.id}`}>
182
+ <div className="h-full p-6 bg-white/70 backdrop-blur-md rounded-2xl border border-slate-200/60 shadow-urban-sm hover:shadow-urban-md hover:-translate-y-1 hover:border-blue-300/50 transition-all cursor-pointer group flex flex-col justify-between relative overflow-hidden">
183
+ <div className="absolute top-0 left-0 w-1 h-full bg-slate-200 group-hover:bg-blue-500 transition-colors"></div>
184
+ <div>
185
+ <div className="flex justify-between items-start mb-4 pl-2">
186
+ <div className="flex items-center gap-2 flex-wrap">
187
+ {getPriorityBadge(task.priority)}
188
+ <span
189
+ className={`text-xs font-bold px-2.5 py-1 rounded-md uppercase tracking-wide border ${
190
+ task.state === "pending_verification"
191
+ ? "bg-orange-50 text-orange-700 border-orange-200"
192
+ : task.state === "resolved"
193
+ ? "bg-emerald-50 text-emerald-700 border-emerald-200"
194
+ : "bg-slate-100 text-slate-600 border-slate-200"
195
+ }`}
196
+ >
197
+ {task.state === "pending_verification"
198
+ ? "Under Review"
199
+ : task.state.replace("_", " ")}
200
+ </span>
201
+ </div>
202
+ </div>
203
+
204
+ <h3 className="text-xl font-bold text-slate-900 mb-2 pl-2 group-hover:text-blue-700 transition-colors line-clamp-2 tracking-tight">
205
+ {task.description || "Issue Report"}
206
+ </h3>
207
+
208
+ <div className="py-3 pl-2 space-y-2.5">
209
+ <div className="flex items-center gap-2.5 text-sm text-slate-600">
210
+ <div className="p-1.5 bg-slate-100 rounded-md text-slate-500">
211
+ <MapPin className="w-3.5 h-3.5" />
212
+ </div>
213
+ <span className="truncate font-medium">
214
+ {task.full_address || `${task.city}, ${task.locality}`}
215
+ </span>
216
+ </div>
217
+ {task.sla_deadline && (
218
+ <div className="flex items-center gap-2.5 text-sm text-red-600 font-bold bg-red-50/50 p-2 rounded-lg border border-red-100/50 w-fit">
219
+ <Calendar className="w-3.5 h-3.5 shrink-0" />
220
+ <span>
221
+ Due:{" "}
222
+ {new Date(task.sla_deadline).toLocaleDateString()}
223
+ </span>
224
+ </div>
225
+ )}
226
+ </div>
227
+ </div>
228
+
229
+ <div className="pt-4 mt-2 border-t border-slate-100 pl-2 flex justify-between items-center">
230
+ <span className="text-xs text-slate-400 font-mono font-medium bg-slate-100 px-2 py-1 rounded">
231
+ ID: {task.id.slice(0, 8)}
232
+ </span>
233
+ <span className="text-blue-600 text-sm font-bold flex items-center gap-1.5 group-hover:gap-2.5 transition-all bg-blue-50/50 px-3 py-1.5 rounded-lg border border-blue-100/50 group-hover:bg-blue-100 group-hover:border-blue-200">
234
+ Resolve <ArrowRight className="w-4 h-4" />
235
+ </span>
236
+ </div>
237
+ </div>
238
+ </Link>
239
+ ))}
240
+ </div>
241
+ )}
242
+ </div>
243
+ );
244
+ }
Frontend/app/worker/task/[id]/page.tsx ADDED
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useState, useRef } from "react";
3
+ import { useRouter, useParams } from "next/navigation";
4
+ import { apiGet } from "@/lib/api";
5
+
6
+ export const runtime = "edge";
7
+ import { ArrowLeft, Camera, Navigation, Loader2 } from "lucide-react";
8
+
9
+ const API_URL = process.env.NEXT_PUBLIC_API_URL;
10
+ if (!API_URL) throw new Error("Missing NEXT_PUBLIC_API_URL");
11
+
12
+ interface Task {
13
+ id: string;
14
+ description: string;
15
+ priority: number;
16
+ state: string;
17
+ city: string;
18
+ locality: string;
19
+ full_address: string;
20
+ latitude: number;
21
+ longitude: number;
22
+ image_url: string;
23
+ annotated_url: string;
24
+ category: string;
25
+ created_at: string;
26
+ sla_deadline: string;
27
+ }
28
+
29
+ export default function TaskDetailPage() {
30
+ const [task, setTask] = useState<Task | null>(null);
31
+ const [loading, setLoading] = useState(true);
32
+ const [submitting, setSubmitting] = useState(false);
33
+ const [notes, setNotes] = useState("");
34
+ const [proofImage, setProofImage] = useState<File | null>(null);
35
+ const [previewUrl, setPreviewUrl] = useState<string | null>(null);
36
+ const fileRef = useRef<HTMLInputElement>(null);
37
+ const router = useRouter();
38
+ const params = useParams();
39
+ const taskId = params.id as string;
40
+
41
+ useEffect(() => {
42
+ fetchTask();
43
+ }, [taskId]);
44
+
45
+ const fetchTask = async () => {
46
+ try {
47
+ const data = await apiGet<Task>(`/worker/tasks/${taskId}`);
48
+ setTask(data);
49
+ } catch (error) {
50
+ console.error("Failed to fetch task:", error);
51
+ } finally {
52
+ setLoading(false);
53
+ }
54
+ };
55
+
56
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
57
+ const file = e.target.files?.[0];
58
+ if (file) {
59
+ setProofImage(file);
60
+ setPreviewUrl(URL.createObjectURL(file));
61
+ }
62
+ };
63
+
64
+ const handleStart = async () => {
65
+ const token = localStorage.getItem("token");
66
+ if (!token) {
67
+ router.push("/signin");
68
+ return;
69
+ }
70
+
71
+ try {
72
+ const res = await fetch(`${API_URL}/worker/tasks/${taskId}/start`, {
73
+ method: "POST",
74
+ headers: { Authorization: `Bearer ${token}` },
75
+ });
76
+
77
+ if (res.ok) {
78
+ fetchTask();
79
+ } else {
80
+ console.error("Failed to start task");
81
+ }
82
+ } catch (error) {
83
+ console.error("Error starting task:", error);
84
+ }
85
+ };
86
+
87
+ const handleComplete = async () => {
88
+ if (!proofImage) {
89
+ alert("Please upload a proof image");
90
+ return;
91
+ }
92
+
93
+ const token = localStorage.getItem("token");
94
+ if (!token) {
95
+ alert("Session expired. Please sign in again.");
96
+ router.push("/signin");
97
+ return;
98
+ }
99
+
100
+ setSubmitting(true);
101
+ try {
102
+ const formData = new FormData();
103
+ formData.append("proof_image", proofImage);
104
+ if (notes) formData.append("notes", notes);
105
+
106
+ const res = await fetch(`${API_URL}/worker/tasks/${taskId}/complete`, {
107
+ method: "POST",
108
+ headers: { Authorization: `Bearer ${token}` },
109
+ body: formData,
110
+ });
111
+
112
+ if (res.ok) {
113
+ router.push("/worker");
114
+ } else {
115
+ const data = await res.json();
116
+ alert(data.detail || "Failed to complete task");
117
+ }
118
+ } catch (error) {
119
+ console.error("Failed to complete task:", error);
120
+ } finally {
121
+ setSubmitting(false);
122
+ }
123
+ };
124
+
125
+ if (loading) {
126
+ return (
127
+ <div className="text-slate-600 font-medium">Loading Task Details...</div>
128
+ );
129
+ }
130
+
131
+ if (!task) {
132
+ return (
133
+ <div className="flex flex-col items-center justify-center py-12">
134
+ <p className="text-slate-500 text-lg">Task not found</p>
135
+ <button
136
+ onClick={() => router.back()}
137
+ className="mt-4 text-blue-600 font-medium hover:underline"
138
+ >
139
+ Go Back
140
+ </button>
141
+ </div>
142
+ );
143
+ }
144
+
145
+ return (
146
+ <div className="space-y-6">
147
+ <div className="flex items-center justify-between">
148
+ <button
149
+ onClick={() => router.back()}
150
+ className="text-slate-500 hover:text-slate-900 transition font-medium flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-white/50"
151
+ >
152
+ <ArrowLeft className="w-5 h-5" /> Back to List
153
+ </button>
154
+ <a
155
+ href={`https://www.google.com/maps?q=${task.latitude},${task.longitude}`}
156
+ target="_blank"
157
+ rel="noopener noreferrer"
158
+ className="px-5 py-2.5 bg-blue-600 text-white text-sm font-bold rounded-xl hover:bg-blue-700 transition shadow-lg hover:shadow-blue-500/30 flex items-center gap-2 transform hover:-translate-y-0.5"
159
+ >
160
+ <Navigation className="w-4 h-4" /> Navigation
161
+ </a>
162
+ </div>
163
+
164
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
165
+ <div className="space-y-6">
166
+ <div className="bg-white/60 backdrop-blur-md rounded-2xl border border-slate-200/60 shadow-urban-sm overflow-hidden">
167
+ {task.annotated_url ? (
168
+ <div className="relative h-72 bg-slate-100 group">
169
+ <img
170
+ src={task.annotated_url}
171
+ alt="Issue"
172
+ className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
173
+ />
174
+ <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent opacity-60"></div>
175
+ <div className="absolute bottom-4 left-4 text-white">
176
+ <p className="font-bold text-lg text-shadow-sm">{task.category}</p>
177
+ </div>
178
+ </div>
179
+ ) : (
180
+ <div className="h-64 bg-slate-100 flex items-center justify-center text-slate-400">
181
+ No Image Available
182
+ </div>
183
+ )}
184
+
185
+ <div className="p-6">
186
+ <div className="flex items-center gap-2 mb-4">
187
+ <span className={`px-2.5 py-1 rounded-md text-xs font-bold uppercase tracking-wide border ${
188
+ task.state === 'pending_verification' ? 'bg-orange-50 text-orange-700 border-orange-200' :
189
+ task.state === 'resolved' ? 'bg-emerald-50 text-emerald-700 border-emerald-200' :
190
+ 'bg-blue-50 text-blue-700 border-blue-200'
191
+ }`}>
192
+ {task.state.replace("_", " ")}
193
+ </span>
194
+ <span className="text-xs font-bold text-slate-400 uppercase tracking-wider ml-auto">
195
+ ID: {task.id.slice(0, 8)}
196
+ </span>
197
+ </div>
198
+
199
+ <h1 className="text-3xl font-extrabold text-slate-900 mb-2 tracking-tight">
200
+ {task.description || "Issue Report"}
201
+ </h1>
202
+ <p className="text-slate-600 mb-8 font-medium text-lg leading-relaxed">
203
+ {task.full_address}
204
+ </p>
205
+
206
+ <div className="grid grid-cols-2 gap-6 py-6 border-t border-slate-200/60">
207
+ <div>
208
+ <p className="text-xs font-bold text-slate-400 uppercase tracking-wide mb-1">
209
+ Reported On
210
+ </p>
211
+ <p className="text-slate-900 font-bold text-lg font-mono">
212
+ {new Date(task.created_at).toLocaleDateString()}
213
+ </p>
214
+ </div>
215
+ {task.sla_deadline && (
216
+ <div>
217
+ <p className="text-xs font-bold text-slate-400 uppercase tracking-wide mb-1">
218
+ Deadline
219
+ </p>
220
+ <p className="text-red-600 font-bold text-lg font-mono">
221
+ {new Date(task.sla_deadline).toLocaleDateString()}
222
+ </p>
223
+ </div>
224
+ )}
225
+ </div>
226
+ </div>
227
+ </div>
228
+ </div>
229
+
230
+ <div className="bg-white/60 backdrop-blur-md rounded-2xl border border-slate-200/60 shadow-urban-sm p-8 h-fit sticky top-6">
231
+ <h2 className="text-xl font-bold text-slate-900 mb-6 border-b border-slate-200/60 pb-4 flex items-center gap-2">
232
+ <span className="w-1.5 h-6 bg-blue-600 rounded-full"></span>
233
+ Task Action
234
+ </h2>
235
+
236
+ {task.state === "assigned" || task.state === "rejected" ? (
237
+ <div className="text-center py-6">
238
+ <p className="text-slate-600 mb-8 font-medium">
239
+ {task.state === "rejected"
240
+ ? "This task was returned. Please review feedback and restart work."
241
+ : "You are assigned to this task. Travel to the location and start the work."}
242
+ </p>
243
+ <button
244
+ onClick={handleStart}
245
+ className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold text-lg rounded-xl shadow-lg hover:shadow-blue-500/40 transition-all flex items-center justify-center gap-2 transform hover:-translate-y-1"
246
+ >
247
+ Start Task
248
+ </button>
249
+ </div>
250
+ ) : task.state === "in_progress" ? (
251
+ <>
252
+ <h3 className="font-bold text-slate-800 mb-4 text-sm uppercase tracking-wide">
253
+ Complete Resolution
254
+ </h3>
255
+ <div className="mb-6">
256
+ <label className="block text-sm font-bold text-slate-700 mb-2">
257
+ Proof of Fix <span className="text-red-500">*</span>
258
+ </label>
259
+ <input
260
+ title="image"
261
+ ref={fileRef}
262
+ type="file"
263
+ accept="image/*"
264
+ onChange={handleFileChange}
265
+ className="hidden"
266
+ />
267
+ <button
268
+ onClick={() => fileRef.current?.click()}
269
+ className={`w-full p-8 border-2 border-dashed rounded-2xl transition-all group ${
270
+ previewUrl
271
+ ? "border-blue-500 bg-blue-50/50"
272
+ : "border-slate-300 hover:border-blue-400 hover:bg-slate-50"
273
+ }`}
274
+ >
275
+ {previewUrl ? (
276
+ <div className="text-center">
277
+ <img
278
+ src={previewUrl}
279
+ alt="Proof"
280
+ className="h-48 mx-auto rounded-xl shadow-md object-cover mb-4"
281
+ />
282
+ <span className="text-sm font-bold text-blue-600 group-hover:text-blue-700">
283
+ Change Photo
284
+ </span>
285
+ </div>
286
+ ) : (
287
+ <div className="flex flex-col items-center text-slate-400 group-hover:text-blue-500 transition-colors">
288
+ <div className="p-4 bg-slate-100 rounded-full mb-3 group-hover:bg-blue-100 transition-colors">
289
+ <Camera className="w-8 h-8" />
290
+ </div>
291
+ <span className="font-bold">Tap to Upload Photo</span>
292
+ </div>
293
+ )}
294
+ </button>
295
+ </div>
296
+
297
+ <div className="mb-6">
298
+ <label className="block text-sm font-bold text-slate-700 mb-2">
299
+ Resolution Notes
300
+ </label>
301
+ <textarea
302
+ value={notes}
303
+ onChange={(e) => setNotes(e.target.value)}
304
+ className="w-full px-4 py-3 bg-white/50 border border-slate-200 rounded-xl text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 transition-all resize-none"
305
+ rows={4}
306
+ placeholder="Describe the repair work completed..."
307
+ />
308
+ </div>
309
+
310
+ <button
311
+ onClick={handleComplete}
312
+ disabled={!proofImage || submitting}
313
+ className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-lg rounded-xl shadow-lg hover:shadow-emerald-500/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 transform hover:-translate-y-1 active:scale-95"
314
+ >
315
+ {submitting ? (
316
+ <>
317
+ <Loader2 className="w-6 h-6 animate-spin" />
318
+ Submitting...
319
+ </>
320
+ ) : (
321
+ "Mark as Resolved"
322
+ )}
323
+ </button>
324
+ </>
325
+ ) : task.state === "pending_verification" ? (
326
+ <div className="text-center py-10 bg-orange-50/50 rounded-2xl border border-orange-100">
327
+ <div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
328
+ <Loader2 className="w-8 h-8 text-orange-500 animate-spin" />
329
+ </div>
330
+ <h3 className="text-xl font-bold text-orange-900 mb-2">
331
+ Under Review
332
+ </h3>
333
+ <p className="text-orange-700 font-medium px-4">
334
+ Your work has been submitted. Waiting for admin verification.
335
+ </p>
336
+ </div>
337
+ ) : (
338
+ <div className="text-center py-10 bg-emerald-50/50 rounded-2xl border border-emerald-100">
339
+ <div className="w-16 h-16 bg-emerald-100 rounded-full flex items-center justify-center mx-auto mb-4">
340
+ <div className="w-8 h-8 text-emerald-600 font-bold text-2xl">✓</div>
341
+ </div>
342
+ <h3 className="text-xl font-bold text-emerald-900 mb-2">
343
+ Task Completed
344
+ </h3>
345
+ <p className="text-emerald-700 font-medium">
346
+ This issue has been successfully resolved and closed.
347
+ </p>
348
+ </div>
349
+ )}
350
+ </div>
351
+ </div>
352
+ </div>
353
+ );
354
+ }
Frontend/components/AuthProvider.tsx ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { createContext, useContext, useEffect, useState } from "react";
3
+ import { createClient, Session, User } from "@supabase/supabase-js";
4
+ import { useRouter, usePathname } from "next/navigation";
5
+
6
+ const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
7
+ const SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "";
8
+
9
+ const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
10
+
11
+ interface AuthContextType {
12
+ user: User | null;
13
+ session: Session | null;
14
+ loading: boolean;
15
+ signOut: () => Promise<void>;
16
+ role: string | null;
17
+ }
18
+
19
+ const AuthContext = createContext<AuthContextType>({
20
+ user: null,
21
+ session: null,
22
+ loading: true,
23
+ signOut: async () => {},
24
+ role: null,
25
+ });
26
+
27
+ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
28
+ const [user, setUser] = useState<User | null>(null);
29
+ const [session, setSession] = useState<Session | null>(null);
30
+ const [loading, setLoading] = useState(true);
31
+ const [role, setRole] = useState<string | null>(null);
32
+ const router = useRouter();
33
+ const pathname = usePathname();
34
+
35
+ useEffect(() => {
36
+ supabase.auth.getSession().then(({ data: { session } }) => {
37
+ handleSession(session);
38
+ });
39
+
40
+ const {
41
+ data: { subscription },
42
+ } = supabase.auth.onAuthStateChange((_event, session) => {
43
+ handleSession(session);
44
+ });
45
+
46
+ return () => subscription.unsubscribe();
47
+ }, []);
48
+
49
+ const handleSession = async (session: Session | null) => {
50
+ setSession(session);
51
+
52
+ if (session?.user) {
53
+ setUser(session.user);
54
+
55
+ const storedUser = localStorage.getItem("user");
56
+ let currentRole = "user";
57
+
58
+ if (storedUser) {
59
+ try {
60
+ const parsed = JSON.parse(storedUser);
61
+ if (parsed.email === session.user.email) {
62
+ currentRole = parsed.role || "user";
63
+ }
64
+ } catch (e) {
65
+ console.error("Error parsing stored user", e);
66
+ }
67
+ }
68
+ setRole(currentRole);
69
+ redirectToDashboard(currentRole);
70
+ } else {
71
+ const storedUser = localStorage.getItem("user");
72
+ const token = localStorage.getItem("token");
73
+
74
+ if (storedUser && token) {
75
+ try {
76
+ const parsed = JSON.parse(storedUser);
77
+
78
+ if (["admin", "worker"].includes(parsed.role)) {
79
+ setUser({
80
+ id: parsed.id,
81
+ email: parsed.email,
82
+ user_metadata: { full_name: parsed.name },
83
+ app_metadata: {},
84
+ aud: "authenticated",
85
+ created_at: new Date().toISOString(),
86
+ } as User);
87
+
88
+ setRole(parsed.role);
89
+ redirectToDashboard(parsed.role);
90
+ setLoading(false);
91
+ return;
92
+ }
93
+ } catch (e) {
94
+ console.error("Error parsing stored staff user", e);
95
+ }
96
+ }
97
+
98
+ setUser(null);
99
+ setRole(null);
100
+
101
+ if (!["/signin", "/signup", "/"].includes(window.location.pathname)) {
102
+ router.push("/signin");
103
+ }
104
+ }
105
+
106
+ setLoading(false);
107
+ };
108
+
109
+ const redirectToDashboard = (role: string) => {
110
+ if (["/signin", "/signup"].includes(window.location.pathname)) {
111
+ if (role === "admin") router.push("/admin");
112
+ else if (role === "worker") router.push("/worker");
113
+ else router.push("/user");
114
+ }
115
+ };
116
+
117
+ const signOut = async () => {
118
+ await supabase.auth.signOut();
119
+ localStorage.removeItem("user");
120
+ localStorage.removeItem("token");
121
+ localStorage.removeItem("supabase_token");
122
+ setRole(null);
123
+ setUser(null);
124
+ setSession(null);
125
+ router.push("/signin");
126
+ };
127
+
128
+ return (
129
+ <AuthContext.Provider value={{ user, session, loading, signOut, role }}>
130
+ {!loading ? (
131
+ children
132
+ ) : (
133
+ <div className="min-h-screen flex items-center justify-center bg-slate-50">
134
+ <div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-slate-900"></div>
135
+ </div>
136
+ )}
137
+ </AuthContext.Provider>
138
+ );
139
+ };
140
+
141
+ export const useAuth = () => useContext(AuthContext);
Frontend/components/DashboardHeader.tsx ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+ "use client";
4
+ import { useState } from "react";
5
+ import { Search, Bell, Menu, User } from "lucide-react";
6
+
7
+ interface HeaderProps {
8
+ setMobileOpen: (open: boolean) => void;
9
+ toggleSidebar?: () => void;
10
+ title?: string;
11
+ }
12
+
13
+ export default function DashboardHeader({ setMobileOpen, toggleSidebar, title = "Dashboard" }: HeaderProps) {
14
+ return (
15
+ <header className="sticky top-0 z-30 flex h-16 w-full items-center justify-between border-b border-slate-200/60 bg-white/60 backdrop-blur-md px-4 shadow-sm sm:px-6 lg:px-8 transition-all duration-300">
16
+ <div className="flex items-center gap-4">
17
+ <button
18
+ onClick={() => {
19
+ setMobileOpen(true); // Always open mobile menu
20
+ toggleSidebar?.(); // Toggle desktop if function exists
21
+ }}
22
+ className="rounded-lg p-2 text-slate-500 hover:bg-slate-100/80 hover:text-slate-700 transition-colors"
23
+ >
24
+ <Menu className="h-6 w-6" />
25
+ </button>
26
+ <h1 className="text-xl font-bold text-slate-900 tracking-tight hidden sm:block">{title}</h1>
27
+ </div>
28
+
29
+ <div className="flex items-center gap-4">
30
+ <div className="hidden md:flex relative group">
31
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 group-focus-within:text-blue-500 transition-colors" />
32
+ <input
33
+ type="text"
34
+ placeholder="Search issues, workers..."
35
+ className="h-10 w-64 rounded-full border border-slate-200 bg-slate-50/50 pl-10 pr-4 text-sm outline-none transition-all placeholder:text-slate-400 focus:border-blue-500/50 focus:bg-white focus:ring-4 focus:ring-blue-500/10"
36
+ />
37
+ </div>
38
+
39
+ <button className="relative rounded-full bg-white p-2.5 text-slate-500 shadow-sm ring-1 ring-slate-200 hover:text-blue-600 hover:ring-blue-200 transition-all">
40
+ <Bell className="h-5 w-5" />
41
+ <span className="absolute top-2 right-2.5 h-2 w-2 rounded-full bg-orange-500 ring-2 ring-white"></span>
42
+ </button>
43
+
44
+ <div className="h-9 w-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-700 font-bold border border-white shadow-sm ring-2 ring-blue-50">
45
+ <User className="h-5 w-5" />
46
+ </div>
47
+ </div>
48
+ </header>
49
+ );
50
+ }
Frontend/components/DashboardSidebar.tsx ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import Link from "next/link";
3
+ import { usePathname } from "next/navigation";
4
+ import {
5
+ LayoutDashboard,
6
+ ClipboardList,
7
+ CheckSquare,
8
+ HardHat,
9
+ Building2,
10
+ Map,
11
+ ListTodo,
12
+ Settings,
13
+ LogOut,
14
+ Menu,
15
+ } from "lucide-react";
16
+ import { clsx, type ClassValue } from "clsx";
17
+ import { twMerge } from "tailwind-merge";
18
+
19
+ function cn(...inputs: ClassValue[]) {
20
+ return twMerge(clsx(inputs));
21
+ }
22
+
23
+ interface SidebarProps {
24
+ role: "admin" | "worker";
25
+ mobileOpen: boolean;
26
+ setMobileOpen: (open: boolean) => void;
27
+ desktopOpen: boolean;
28
+ onLogout: () => void;
29
+ }
30
+
31
+ export default function DashboardSidebar({
32
+ role,
33
+ mobileOpen,
34
+ setMobileOpen,
35
+ desktopOpen,
36
+ onLogout,
37
+ }: SidebarProps) {
38
+ const pathname = usePathname();
39
+
40
+ // ... (links definition skipped for brevity if not changing, but we are inside function)
41
+ const adminLinks = [
42
+ { href: "/admin", label: "Overview", icon: LayoutDashboard },
43
+ { href: "/admin/issues", label: "Issues", icon: ClipboardList },
44
+ { href: "/admin/review", label: "Review Queue", icon: CheckSquare },
45
+ { href: "/admin/workers", label: "Workforce", icon: HardHat },
46
+ { href: "/admin/departments", label: "Departments", icon: Building2 },
47
+ { href: "/admin/heatmap", label: "Heatmap", icon: Map },
48
+ ];
49
+
50
+ const workerLinks = [{ href: "/worker", label: "My Tasks", icon: ListTodo }];
51
+
52
+ const links = role === "admin" ? adminLinks : workerLinks;
53
+
54
+ return (
55
+ <>
56
+ {mobileOpen && (
57
+ <div
58
+ className="fixed inset-0 z-40 bg-slate-900/20 backdrop-blur-sm lg:hidden"
59
+ onClick={() => setMobileOpen(false)}
60
+ />
61
+ )}
62
+
63
+ <aside
64
+ className={cn(
65
+ "fixed inset-y-0 left-0 z-50 bg-white/80 backdrop-blur-xl border-r border-slate-200/60 transition-all duration-300 ease-in-out shadow-urban-lg", // UrbanLens Light Glass
66
+ "lg:relative lg:translate-x-0 lg:shadow-none",
67
+ mobileOpen ? "translate-x-0" : "-translate-x-full",
68
+ desktopOpen ? "lg:w-72" : "lg:w-0 lg:overflow-hidden lg:border-r-0"
69
+ )}
70
+ >
71
+ <div className="flex h-16 items-center px-6 border-b border-slate-100/50">
72
+ <div className="flex items-center gap-2">
73
+ <div className="h-8 w-8 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20">
74
+ <span className="text-white font-bold font-mono">U</span>
75
+ </div>
76
+ <span className="text-xl font-bold tracking-tight text-slate-900">UrbanLens</span>
77
+ </div>
78
+ <span className="ml-auto rounded-full bg-blue-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-blue-600 ring-1 ring-blue-100">
79
+ {role}
80
+ </span>
81
+ </div>
82
+
83
+ <div className="flex flex-col justify-between h-[calc(100vh-4rem)] p-4">
84
+ <nav className="space-y-1">
85
+ {links.map((link) => {
86
+ const Icon = link.icon;
87
+ const isActive = pathname === link.href;
88
+ return (
89
+ <Link
90
+ key={link.href}
91
+ href={link.href}
92
+ onClick={() => setMobileOpen(false)}
93
+ className={cn(
94
+ "flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-200 group relative overflow-hidden",
95
+ isActive
96
+ ? "bg-blue-50 text-blue-700 shadow-sm ring-1 ring-blue-100"
97
+ : "text-slate-500 hover:bg-slate-50 hover:text-slate-900"
98
+ )}
99
+ >
100
+ {isActive && (
101
+ <div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-blue-500 rounded-r-full" />
102
+ )}
103
+ <Icon className={cn("h-5 w-5 transition-transform group-hover:scale-110", isActive ? "text-blue-600" : "text-slate-400 group-hover:text-slate-600")} />
104
+ <span className={isActive ? "ml-1.5" : ""}>{link.label}</span>
105
+ </Link>
106
+ );
107
+ })}
108
+ </nav>
109
+
110
+ <div className="border-t border-slate-100 pt-4 space-y-1">
111
+ <button className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-slate-500 hover:bg-slate-50 hover:text-slate-900 transition-colors">
112
+ <Settings className="h-5 w-5 text-slate-400" />
113
+ Settings
114
+ </button>
115
+ <button
116
+ onClick={onLogout}
117
+ className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-red-600 hover:bg-red-50 hover:text-red-700 transition-colors"
118
+ >
119
+ <LogOut className="h-5 w-5" />
120
+ Sign Out
121
+ </button>
122
+ </div>
123
+ </div>
124
+ </aside>
125
+ </>
126
+ );
127
+ }
Frontend/components/ui/Loader.tsx ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+
3
+ export function Loader({ size = "md", className = "" }: { size?: "sm" | "md" | "lg"; className?: string }) {
4
+ const sizeClasses = {
5
+ sm: "w-4 h-4 border-2",
6
+ md: "w-8 h-8 border-[3px]",
7
+ lg: "w-12 h-12 border-4",
8
+ };
9
+
10
+ return (
11
+ <div className={`flex items-center justify-center ${className}`}>
12
+ <div
13
+ className={`animate-spin rounded-full border-slate-200 border-t-blue-600 ${sizeClasses[size]}`}
14
+ ></div>
15
+ </div>
16
+ );
17
+ }
Frontend/components/ui/Skeleton.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { cn } from "@/lib/utils";
2
+
3
+ export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
4
+ return (
5
+ <div
6
+ className={cn("animate-pulse rounded-md bg-slate-200/50", className)}
7
+ {...props}
8
+ />
9
+ );
10
+ }
Frontend/hooks/useCachedFetch.ts ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+
5
+ const cache = new Map<string, { data: any; timestamp: number }>();
6
+ const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
7
+ const CACHE_KEY_PREFIX = "urbanlens_cache_";
8
+
9
+ // Helper to get full URL (duplicated logic, but safe for synchronous init)
10
+ const getFullUrl = (url: string) => {
11
+ const baseUrl = process.env.NEXT_PUBLIC_API_URL || "";
12
+ return url.startsWith("http") ? url : `${baseUrl}${url}`;
13
+ };
14
+
15
+ export function useCachedFetch<T>(url: string, options?: RequestInit) {
16
+ const fullUrl = url ? getFullUrl(url) : "";
17
+
18
+ // 1. Initialize logic: Try memory cache -> Try localStorage -> Default null
19
+ const [data, setData] = useState<T | null>(() => {
20
+ if (!fullUrl) return null; // Skip if no URL
21
+ // Try memory first (fastest)
22
+ if (cache.has(fullUrl)) {
23
+ return cache.get(fullUrl)!.data;
24
+ }
25
+ // Try localStorage (persistence)
26
+ if (typeof window !== "undefined") {
27
+ try {
28
+ const stored = localStorage.getItem(CACHE_KEY_PREFIX + fullUrl);
29
+ if (stored) {
30
+ const parsed = JSON.parse(stored);
31
+ // Hydrate memory cache while we're at it
32
+ cache.set(fullUrl, parsed);
33
+ return parsed.data;
34
+ }
35
+ } catch (e) {
36
+ console.warn("Cache parse error", e);
37
+ }
38
+ }
39
+ return null;
40
+ });
41
+
42
+ // Calculate generic initial loading state based on whether we found data
43
+ const [loading, setLoading] = useState(() => {
44
+ if (!fullUrl) return true; // Default to loading if waiting for URL? Or false?
45
+ // Actually if URL is empty, we are "idle". Let's say loading=true if we expect a URL eventuall?
46
+ // Consistently, if URL is missing, we are NOT loading data yet because we can't.
47
+ // Ideally loading should be false if URL is empty, but let's stick to simple logic:
48
+ const cached = cache.get(fullUrl);
49
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
50
+ return false;
51
+ }
52
+ return true;
53
+ });
54
+
55
+ const [error, setError] = useState<Error | null>(null);
56
+
57
+ const fetchData = useCallback(async (isRevalidating = false) => {
58
+ if (!fullUrl) return; // Skip fetch if no URL
59
+
60
+ const cached = cache.get(fullUrl);
61
+ const isCacheValid = cached && (Date.now() - cached.timestamp < CACHE_TTL);
62
+
63
+ // If we have valid cache and we are NOT forcing a revalidate, stopping here is an option
64
+ // BUT the user wants "background sync", so we proceeds to fetch unless completely fresh?
65
+ // Actually, "stale-while-revalidate" means we show cached, but fetch anyway.
66
+
67
+ if (!isRevalidating) {
68
+ if (isCacheValid) {
69
+ setLoading(false);
70
+ } else {
71
+ setLoading(true);
72
+ }
73
+ }
74
+
75
+ try {
76
+ const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
77
+ const headers = {
78
+ "Content-Type": "application/json",
79
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
80
+ ...options?.headers,
81
+ };
82
+
83
+ const res = await fetch(fullUrl, { ...options, headers });
84
+
85
+ if (!res.ok) {
86
+ // If 401/403, we might want to handle it (though api.ts usually does)
87
+ if (res.status === 401) localStorage.removeItem("token");
88
+ throw new Error(`Fetch error: ${res.status}`);
89
+ }
90
+
91
+ const freshData = await res.json();
92
+ const cacheEntry = { data: freshData, timestamp: Date.now() };
93
+
94
+ // Update Memory
95
+ cache.set(fullUrl, cacheEntry);
96
+
97
+ // Update LocalStorage
98
+ if (typeof window !== "undefined") {
99
+ try {
100
+ localStorage.setItem(CACHE_KEY_PREFIX + fullUrl, JSON.stringify(cacheEntry));
101
+ } catch (e) {
102
+ console.warn("Quota exceeded likely", e);
103
+ }
104
+ }
105
+
106
+ setData(freshData);
107
+ setError(null);
108
+ } catch (err) {
109
+ console.error("Fetch failed:", err);
110
+ if (!data) setError(err as Error); // Only show error if no cached data
111
+ } finally {
112
+ if (!isRevalidating) setLoading(false);
113
+ }
114
+ }, [fullUrl, JSON.stringify(options)]);
115
+
116
+ useEffect(() => {
117
+ fetchData();
118
+ }, [fetchData]);
119
+
120
+ const revalidate = () => fetchData(true);
121
+
122
+ return { data, loading, error, revalidate };
123
+ }
Frontend/lib/api.ts ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ const API_URL = process.env.NEXT_PUBLIC_API_URL || "";
4
+ const REQUEST_TIMEOUT_MS = 30000;
5
+ const MAX_RETRIES = 2;
6
+
7
+ class ApiError extends Error {
8
+ status: number;
9
+ constructor(message: string, status: number) {
10
+ super(message);
11
+ this.status = status;
12
+ this.name = "ApiError";
13
+ }
14
+ }
15
+
16
+ async function fetchWithTimeout(
17
+ url: string,
18
+ options: RequestInit,
19
+ timeout: number,
20
+ ): Promise<Response> {
21
+ const controller = new AbortController();
22
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
23
+
24
+ try {
25
+ const response = await fetch(url, {
26
+ ...options,
27
+ signal: controller.signal,
28
+ });
29
+ return response;
30
+ } finally {
31
+ clearTimeout(timeoutId);
32
+ }
33
+ }
34
+
35
+ export async function apiRequest<T>(
36
+ endpoint: string,
37
+ options: RequestInit = {},
38
+ retries = 0,
39
+ ): Promise<T> {
40
+ const token =
41
+ typeof window !== "undefined" ? localStorage.getItem("token") : null;
42
+
43
+ if (!API_URL) {
44
+ throw new ApiError("API URL not configured", 500);
45
+ }
46
+
47
+ const headers: HeadersInit = {
48
+ "Content-Type": "application/json",
49
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
50
+ ...options.headers,
51
+ };
52
+
53
+ try {
54
+ const res = await fetchWithTimeout(
55
+ `${API_URL}${endpoint}`,
56
+ { ...options, headers },
57
+ REQUEST_TIMEOUT_MS,
58
+ );
59
+
60
+ if (res.status === 401 || res.status === 403) {
61
+ if (typeof window !== "undefined") {
62
+ localStorage.removeItem("token");
63
+ localStorage.removeItem("user");
64
+ window.location.href = "/signin";
65
+ }
66
+ throw new ApiError("Session expired. Please sign in again.", res.status);
67
+ }
68
+
69
+ if (res.status >= 500 && retries < MAX_RETRIES) {
70
+ await new Promise((r) => setTimeout(r, 1000 * (retries + 1)));
71
+ return apiRequest<T>(endpoint, options, retries + 1);
72
+ }
73
+
74
+ if (!res.ok) {
75
+ const data = await res.json().catch(() => ({}));
76
+ throw new ApiError(data.detail || "Request failed", res.status);
77
+ }
78
+
79
+ if (res.status === 204) {
80
+ return {} as T;
81
+ }
82
+
83
+ return res.json();
84
+ } catch (error) {
85
+ if (error instanceof ApiError) throw error;
86
+
87
+ if (error instanceof Error) {
88
+ if (error.name === "AbortError") {
89
+ throw new ApiError("Request timed out. Please try again.", 408);
90
+ }
91
+ if (retries < MAX_RETRIES) {
92
+ await new Promise((r) => setTimeout(r, 1000 * (retries + 1)));
93
+ return apiRequest<T>(endpoint, options, retries + 1);
94
+ }
95
+ }
96
+
97
+ throw new ApiError("Network error. Please check your connection.", 0);
98
+ }
99
+ }
100
+
101
+ export async function apiGet<T>(endpoint: string): Promise<T> {
102
+ return apiRequest<T>(endpoint, { method: "GET" });
103
+ }
104
+
105
+ export async function apiPost<T>(endpoint: string, body?: unknown): Promise<T> {
106
+ return apiRequest<T>(endpoint, {
107
+ method: "POST",
108
+ body: body ? JSON.stringify(body) : undefined,
109
+ });
110
+ }
111
+
112
+ export async function apiPatch<T>(
113
+ endpoint: string,
114
+ body?: unknown,
115
+ ): Promise<T> {
116
+ return apiRequest<T>(endpoint, {
117
+ method: "PATCH",
118
+ body: body ? JSON.stringify(body) : undefined,
119
+ });
120
+ }
121
+
122
+ export async function apiDelete(endpoint: string): Promise<void> {
123
+ await apiRequest(endpoint, { method: "DELETE" });
124
+ }
125
+
126
+ export { ApiError };
Frontend/lib/utils.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
Frontend/next-env.d.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+ import "./.next/dev/types/routes.d.ts";
4
+
5
+ // NOTE: This file should not be edited
6
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
Frontend/next.config.ts CHANGED
@@ -1,7 +1,10 @@
1
  import type { NextConfig } from "next";
2
 
3
  const nextConfig: NextConfig = {
4
- /* config options here */
 
 
 
5
  };
6
 
7
  export default nextConfig;
 
1
  import type { NextConfig } from "next";
2
 
3
  const nextConfig: NextConfig = {
4
+ reactStrictMode: false,
5
+ experimental: {
6
+ optimizePackageImports: ['lucide-react', '@supabase/supabase-js'],
7
+ },
8
  };
9
 
10
  export default nextConfig;
Frontend/package-lock.json CHANGED
@@ -8,9 +8,14 @@
8
  "name": "frontend",
9
  "version": "0.1.0",
10
  "dependencies": {
11
- "next": "16.1.6",
 
 
 
12
  "react": "19.2.3",
13
- "react-dom": "19.2.3"
 
 
14
  },
15
  "devDependencies": {
16
  "@tailwindcss/postcss": "^4",
@@ -18,7 +23,7 @@
18
  "@types/react": "^19",
19
  "@types/react-dom": "^19",
20
  "eslint": "^9",
21
- "eslint-config-next": "16.1.6",
22
  "tailwindcss": "^4",
23
  "typescript": "^5"
24
  }
@@ -37,9 +42,9 @@
37
  }
38
  },
39
  "node_modules/@babel/code-frame": {
40
- "version": "7.29.0",
41
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
42
- "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
43
  "dev": true,
44
  "license": "MIT",
45
  "dependencies": {
@@ -52,9 +57,9 @@
52
  }
53
  },
54
  "node_modules/@babel/compat-data": {
55
- "version": "7.29.0",
56
- "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
57
- "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
58
  "dev": true,
59
  "license": "MIT",
60
  "engines": {
@@ -62,21 +67,21 @@
62
  }
63
  },
64
  "node_modules/@babel/core": {
65
- "version": "7.29.0",
66
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
67
- "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
68
  "dev": true,
69
  "license": "MIT",
70
  "dependencies": {
71
- "@babel/code-frame": "^7.29.0",
72
- "@babel/generator": "^7.29.0",
73
  "@babel/helper-compilation-targets": "^7.28.6",
74
  "@babel/helper-module-transforms": "^7.28.6",
75
  "@babel/helpers": "^7.28.6",
76
- "@babel/parser": "^7.29.0",
77
  "@babel/template": "^7.28.6",
78
- "@babel/traverse": "^7.29.0",
79
- "@babel/types": "^7.29.0",
80
  "@jridgewell/remapping": "^2.3.5",
81
  "convert-source-map": "^2.0.0",
82
  "debug": "^4.1.0",
@@ -93,14 +98,14 @@
93
  }
94
  },
95
  "node_modules/@babel/generator": {
96
- "version": "7.29.1",
97
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
98
- "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
99
  "dev": true,
100
  "license": "MIT",
101
  "dependencies": {
102
- "@babel/parser": "^7.29.0",
103
- "@babel/types": "^7.29.0",
104
  "@jridgewell/gen-mapping": "^0.3.12",
105
  "@jridgewell/trace-mapping": "^0.3.28",
106
  "jsesc": "^3.0.2"
@@ -213,13 +218,13 @@
213
  }
214
  },
215
  "node_modules/@babel/parser": {
216
- "version": "7.29.0",
217
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
218
- "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
219
  "dev": true,
220
  "license": "MIT",
221
  "dependencies": {
222
- "@babel/types": "^7.29.0"
223
  },
224
  "bin": {
225
  "parser": "bin/babel-parser.js"
@@ -244,18 +249,18 @@
244
  }
245
  },
246
  "node_modules/@babel/traverse": {
247
- "version": "7.29.0",
248
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
249
- "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
250
  "dev": true,
251
  "license": "MIT",
252
  "dependencies": {
253
- "@babel/code-frame": "^7.29.0",
254
- "@babel/generator": "^7.29.0",
255
  "@babel/helper-globals": "^7.28.0",
256
- "@babel/parser": "^7.29.0",
257
  "@babel/template": "^7.28.6",
258
- "@babel/types": "^7.29.0",
259
  "debug": "^4.3.1"
260
  },
261
  "engines": {
@@ -263,9 +268,9 @@
263
  }
264
  },
265
  "node_modules/@babel/types": {
266
- "version": "7.29.0",
267
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
268
- "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
269
  "dev": true,
270
  "license": "MIT",
271
  "dependencies": {
@@ -1035,15 +1040,15 @@
1035
  }
1036
  },
1037
  "node_modules/@next/env": {
1038
- "version": "16.1.6",
1039
- "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
1040
- "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
1041
  "license": "MIT"
1042
  },
1043
  "node_modules/@next/eslint-plugin-next": {
1044
- "version": "16.1.6",
1045
- "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz",
1046
- "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==",
1047
  "dev": true,
1048
  "license": "MIT",
1049
  "dependencies": {
@@ -1051,9 +1056,9 @@
1051
  }
1052
  },
1053
  "node_modules/@next/swc-darwin-arm64": {
1054
- "version": "16.1.6",
1055
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
1056
- "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
1057
  "cpu": [
1058
  "arm64"
1059
  ],
@@ -1067,9 +1072,9 @@
1067
  }
1068
  },
1069
  "node_modules/@next/swc-darwin-x64": {
1070
- "version": "16.1.6",
1071
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
1072
- "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
1073
  "cpu": [
1074
  "x64"
1075
  ],
@@ -1083,9 +1088,9 @@
1083
  }
1084
  },
1085
  "node_modules/@next/swc-linux-arm64-gnu": {
1086
- "version": "16.1.6",
1087
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
1088
- "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
1089
  "cpu": [
1090
  "arm64"
1091
  ],
@@ -1099,9 +1104,9 @@
1099
  }
1100
  },
1101
  "node_modules/@next/swc-linux-arm64-musl": {
1102
- "version": "16.1.6",
1103
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
1104
- "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
1105
  "cpu": [
1106
  "arm64"
1107
  ],
@@ -1115,9 +1120,9 @@
1115
  }
1116
  },
1117
  "node_modules/@next/swc-linux-x64-gnu": {
1118
- "version": "16.1.6",
1119
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
1120
- "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
1121
  "cpu": [
1122
  "x64"
1123
  ],
@@ -1131,9 +1136,9 @@
1131
  }
1132
  },
1133
  "node_modules/@next/swc-linux-x64-musl": {
1134
- "version": "16.1.6",
1135
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
1136
- "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
1137
  "cpu": [
1138
  "x64"
1139
  ],
@@ -1147,9 +1152,9 @@
1147
  }
1148
  },
1149
  "node_modules/@next/swc-win32-arm64-msvc": {
1150
- "version": "16.1.6",
1151
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
1152
- "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
1153
  "cpu": [
1154
  "arm64"
1155
  ],
@@ -1163,9 +1168,9 @@
1163
  }
1164
  },
1165
  "node_modules/@next/swc-win32-x64-msvc": {
1166
- "version": "16.1.6",
1167
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
1168
- "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
1169
  "cpu": [
1170
  "x64"
1171
  ],
@@ -1226,6 +1231,42 @@
1226
  "node": ">=12.4.0"
1227
  }
1228
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1229
  "node_modules/@rtsao/scc": {
1230
  "version": "1.1.0",
1231
  "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1233,6 +1274,98 @@
1233
  "dev": true,
1234
  "license": "MIT"
1235
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1236
  "node_modules/@swc/helpers": {
1237
  "version": "0.5.15",
1238
  "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1524,6 +1657,69 @@
1524
  "tslib": "^2.4.0"
1525
  }
1526
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1527
  "node_modules/@types/estree": {
1528
  "version": "1.0.8",
1529
  "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1546,20 +1742,25 @@
1546
  "license": "MIT"
1547
  },
1548
  "node_modules/@types/node": {
1549
- "version": "20.19.32",
1550
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.32.tgz",
1551
- "integrity": "sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA==",
1552
- "dev": true,
1553
  "license": "MIT",
1554
  "dependencies": {
1555
  "undici-types": "~6.21.0"
1556
  }
1557
  },
 
 
 
 
 
 
1558
  "node_modules/@types/react": {
1559
- "version": "19.2.13",
1560
- "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
1561
- "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==",
1562
- "dev": true,
1563
  "license": "MIT",
1564
  "dependencies": {
1565
  "csstype": "^3.2.2"
@@ -1575,18 +1776,33 @@
1575
  "@types/react": "^19.2.0"
1576
  }
1577
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1578
  "node_modules/@typescript-eslint/eslint-plugin": {
1579
- "version": "8.54.0",
1580
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz",
1581
- "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==",
1582
  "dev": true,
1583
  "license": "MIT",
1584
  "dependencies": {
1585
  "@eslint-community/regexpp": "^4.12.2",
1586
- "@typescript-eslint/scope-manager": "8.54.0",
1587
- "@typescript-eslint/type-utils": "8.54.0",
1588
- "@typescript-eslint/utils": "8.54.0",
1589
- "@typescript-eslint/visitor-keys": "8.54.0",
1590
  "ignore": "^7.0.5",
1591
  "natural-compare": "^1.4.0",
1592
  "ts-api-utils": "^2.4.0"
@@ -1599,7 +1815,7 @@
1599
  "url": "https://opencollective.com/typescript-eslint"
1600
  },
1601
  "peerDependencies": {
1602
- "@typescript-eslint/parser": "^8.54.0",
1603
  "eslint": "^8.57.0 || ^9.0.0",
1604
  "typescript": ">=4.8.4 <6.0.0"
1605
  }
@@ -1615,16 +1831,16 @@
1615
  }
1616
  },
1617
  "node_modules/@typescript-eslint/parser": {
1618
- "version": "8.54.0",
1619
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz",
1620
- "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
1621
  "dev": true,
1622
  "license": "MIT",
1623
  "dependencies": {
1624
- "@typescript-eslint/scope-manager": "8.54.0",
1625
- "@typescript-eslint/types": "8.54.0",
1626
- "@typescript-eslint/typescript-estree": "8.54.0",
1627
- "@typescript-eslint/visitor-keys": "8.54.0",
1628
  "debug": "^4.4.3"
1629
  },
1630
  "engines": {
@@ -1640,14 +1856,14 @@
1640
  }
1641
  },
1642
  "node_modules/@typescript-eslint/project-service": {
1643
- "version": "8.54.0",
1644
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz",
1645
- "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==",
1646
  "dev": true,
1647
  "license": "MIT",
1648
  "dependencies": {
1649
- "@typescript-eslint/tsconfig-utils": "^8.54.0",
1650
- "@typescript-eslint/types": "^8.54.0",
1651
  "debug": "^4.4.3"
1652
  },
1653
  "engines": {
@@ -1662,14 +1878,14 @@
1662
  }
1663
  },
1664
  "node_modules/@typescript-eslint/scope-manager": {
1665
- "version": "8.54.0",
1666
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz",
1667
- "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==",
1668
  "dev": true,
1669
  "license": "MIT",
1670
  "dependencies": {
1671
- "@typescript-eslint/types": "8.54.0",
1672
- "@typescript-eslint/visitor-keys": "8.54.0"
1673
  },
1674
  "engines": {
1675
  "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1680,9 +1896,9 @@
1680
  }
1681
  },
1682
  "node_modules/@typescript-eslint/tsconfig-utils": {
1683
- "version": "8.54.0",
1684
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz",
1685
- "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==",
1686
  "dev": true,
1687
  "license": "MIT",
1688
  "engines": {
@@ -1697,15 +1913,15 @@
1697
  }
1698
  },
1699
  "node_modules/@typescript-eslint/type-utils": {
1700
- "version": "8.54.0",
1701
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz",
1702
- "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==",
1703
  "dev": true,
1704
  "license": "MIT",
1705
  "dependencies": {
1706
- "@typescript-eslint/types": "8.54.0",
1707
- "@typescript-eslint/typescript-estree": "8.54.0",
1708
- "@typescript-eslint/utils": "8.54.0",
1709
  "debug": "^4.4.3",
1710
  "ts-api-utils": "^2.4.0"
1711
  },
@@ -1722,9 +1938,9 @@
1722
  }
1723
  },
1724
  "node_modules/@typescript-eslint/types": {
1725
- "version": "8.54.0",
1726
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz",
1727
- "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==",
1728
  "dev": true,
1729
  "license": "MIT",
1730
  "engines": {
@@ -1736,16 +1952,16 @@
1736
  }
1737
  },
1738
  "node_modules/@typescript-eslint/typescript-estree": {
1739
- "version": "8.54.0",
1740
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz",
1741
- "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==",
1742
  "dev": true,
1743
  "license": "MIT",
1744
  "dependencies": {
1745
- "@typescript-eslint/project-service": "8.54.0",
1746
- "@typescript-eslint/tsconfig-utils": "8.54.0",
1747
- "@typescript-eslint/types": "8.54.0",
1748
- "@typescript-eslint/visitor-keys": "8.54.0",
1749
  "debug": "^4.4.3",
1750
  "minimatch": "^9.0.5",
1751
  "semver": "^7.7.3",
@@ -1790,9 +2006,9 @@
1790
  }
1791
  },
1792
  "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
1793
- "version": "7.7.4",
1794
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
1795
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
1796
  "dev": true,
1797
  "license": "ISC",
1798
  "bin": {
@@ -1803,16 +2019,16 @@
1803
  }
1804
  },
1805
  "node_modules/@typescript-eslint/utils": {
1806
- "version": "8.54.0",
1807
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz",
1808
- "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==",
1809
  "dev": true,
1810
  "license": "MIT",
1811
  "dependencies": {
1812
  "@eslint-community/eslint-utils": "^4.9.1",
1813
- "@typescript-eslint/scope-manager": "8.54.0",
1814
- "@typescript-eslint/types": "8.54.0",
1815
- "@typescript-eslint/typescript-estree": "8.54.0"
1816
  },
1817
  "engines": {
1818
  "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1827,13 +2043,13 @@
1827
  }
1828
  },
1829
  "node_modules/@typescript-eslint/visitor-keys": {
1830
- "version": "8.54.0",
1831
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz",
1832
- "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==",
1833
  "dev": true,
1834
  "license": "MIT",
1835
  "dependencies": {
1836
- "@typescript-eslint/types": "8.54.0",
1837
  "eslint-visitor-keys": "^4.2.1"
1838
  },
1839
  "engines": {
@@ -2407,9 +2623,9 @@
2407
  "license": "MIT"
2408
  },
2409
  "node_modules/baseline-browser-mapping": {
2410
- "version": "2.9.19",
2411
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
2412
- "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
2413
  "license": "Apache-2.0",
2414
  "bin": {
2415
  "baseline-browser-mapping": "dist/cli.js"
@@ -2534,9 +2750,9 @@
2534
  }
2535
  },
2536
  "node_modules/caniuse-lite": {
2537
- "version": "1.0.30001769",
2538
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
2539
- "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==",
2540
  "funding": [
2541
  {
2542
  "type": "opencollective",
@@ -2576,6 +2792,15 @@
2576
  "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
2577
  "license": "MIT"
2578
  },
 
 
 
 
 
 
 
 
 
2579
  "node_modules/color-convert": {
2580
  "version": "2.0.1",
2581
  "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2629,9 +2854,130 @@
2629
  "version": "3.2.3",
2630
  "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
2631
  "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
2632
- "dev": true,
2633
  "license": "MIT"
2634
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2635
  "node_modules/damerau-levenshtein": {
2636
  "version": "1.0.8",
2637
  "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -2711,6 +3057,12 @@
2711
  }
2712
  }
2713
  },
 
 
 
 
 
 
2714
  "node_modules/deep-is": {
2715
  "version": "0.1.4",
2716
  "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2793,9 +3145,9 @@
2793
  }
2794
  },
2795
  "node_modules/electron-to-chromium": {
2796
- "version": "1.5.286",
2797
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
2798
- "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==",
2799
  "dev": true,
2800
  "license": "ISC"
2801
  },
@@ -2807,14 +3159,14 @@
2807
  "license": "MIT"
2808
  },
2809
  "node_modules/enhanced-resolve": {
2810
- "version": "5.19.0",
2811
- "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
2812
- "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
2813
  "dev": true,
2814
  "license": "MIT",
2815
  "dependencies": {
2816
  "graceful-fs": "^4.2.4",
2817
- "tapable": "^2.3.0"
2818
  },
2819
  "engines": {
2820
  "node": ">=10.13.0"
@@ -2997,6 +3349,16 @@
2997
  "url": "https://github.com/sponsors/ljharb"
2998
  }
2999
  },
 
 
 
 
 
 
 
 
 
 
3000
  "node_modules/escalade": {
3001
  "version": "3.2.0",
3002
  "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -3081,13 +3443,13 @@
3081
  }
3082
  },
3083
  "node_modules/eslint-config-next": {
3084
- "version": "16.1.6",
3085
- "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz",
3086
- "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==",
3087
  "dev": true,
3088
  "license": "MIT",
3089
  "dependencies": {
3090
- "@next/eslint-plugin-next": "16.1.6",
3091
  "eslint-import-resolver-node": "^0.3.6",
3092
  "eslint-import-resolver-typescript": "^3.5.2",
3093
  "eslint-plugin-import": "^2.32.0",
@@ -3444,6 +3806,12 @@
3444
  "node": ">=0.10.0"
3445
  }
3446
  },
 
 
 
 
 
 
3447
  "node_modules/fast-deep-equal": {
3448
  "version": "3.1.3",
3449
  "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3704,9 +4072,9 @@
3704
  }
3705
  },
3706
  "node_modules/get-tsconfig": {
3707
- "version": "4.13.6",
3708
- "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
3709
- "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
3710
  "dev": true,
3711
  "license": "MIT",
3712
  "dependencies": {
@@ -3890,6 +4258,15 @@
3890
  "hermes-estree": "0.25.1"
3891
  }
3892
  },
 
 
 
 
 
 
 
 
 
3893
  "node_modules/ignore": {
3894
  "version": "5.3.2",
3895
  "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3900,6 +4277,16 @@
3900
  "node": ">= 4"
3901
  }
3902
  },
 
 
 
 
 
 
 
 
 
 
3903
  "node_modules/import-fresh": {
3904
  "version": "3.3.1",
3905
  "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -3942,6 +4329,15 @@
3942
  "node": ">= 0.4"
3943
  }
3944
  },
 
 
 
 
 
 
 
 
 
3945
  "node_modules/is-array-buffer": {
3946
  "version": "3.0.5",
3947
  "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -4024,9 +4420,9 @@
4024
  }
4025
  },
4026
  "node_modules/is-bun-module/node_modules/semver": {
4027
- "version": "7.7.4",
4028
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
4029
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
4030
  "dev": true,
4031
  "license": "ISC",
4032
  "bin": {
@@ -4833,6 +5229,15 @@
4833
  "yallist": "^3.0.2"
4834
  }
4835
  },
 
 
 
 
 
 
 
 
 
4836
  "node_modules/magic-string": {
4837
  "version": "0.30.21",
4838
  "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4949,12 +5354,12 @@
4949
  "license": "MIT"
4950
  },
4951
  "node_modules/next": {
4952
- "version": "16.1.6",
4953
- "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
4954
- "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
4955
  "license": "MIT",
4956
  "dependencies": {
4957
- "@next/env": "16.1.6",
4958
  "@swc/helpers": "0.5.15",
4959
  "baseline-browser-mapping": "^2.8.3",
4960
  "caniuse-lite": "^1.0.30001579",
@@ -4968,14 +5373,14 @@
4968
  "node": ">=20.9.0"
4969
  },
4970
  "optionalDependencies": {
4971
- "@next/swc-darwin-arm64": "16.1.6",
4972
- "@next/swc-darwin-x64": "16.1.6",
4973
- "@next/swc-linux-arm64-gnu": "16.1.6",
4974
- "@next/swc-linux-arm64-musl": "16.1.6",
4975
- "@next/swc-linux-x64-gnu": "16.1.6",
4976
- "@next/swc-linux-x64-musl": "16.1.6",
4977
- "@next/swc-win32-arm64-msvc": "16.1.6",
4978
- "@next/swc-win32-x64-msvc": "16.1.6",
4979
  "sharp": "^0.34.4"
4980
  },
4981
  "peerDependencies": {
@@ -5403,9 +5808,76 @@
5403
  "version": "16.13.1",
5404
  "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
5405
  "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
5406
- "dev": true,
5407
  "license": "MIT"
5408
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5409
  "node_modules/reflect.getprototypeof": {
5410
  "version": "1.0.10",
5411
  "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -5450,6 +5922,12 @@
5450
  "url": "https://github.com/sponsors/ljharb"
5451
  }
5452
  },
 
 
 
 
 
 
5453
  "node_modules/resolve": {
5454
  "version": "1.22.11",
5455
  "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -5692,9 +6170,9 @@
5692
  }
5693
  },
5694
  "node_modules/sharp/node_modules/semver": {
5695
- "version": "7.7.4",
5696
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
5697
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
5698
  "license": "ISC",
5699
  "optional": true,
5700
  "bin": {
@@ -6018,6 +6496,16 @@
6018
  "url": "https://github.com/sponsors/ljharb"
6019
  }
6020
  },
 
 
 
 
 
 
 
 
 
 
6021
  "node_modules/tailwindcss": {
6022
  "version": "4.1.18",
6023
  "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@@ -6039,6 +6527,12 @@
6039
  "url": "https://opencollective.com/webpack"
6040
  }
6041
  },
 
 
 
 
 
 
6042
  "node_modules/tinyglobby": {
6043
  "version": "0.2.15",
6044
  "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -6251,16 +6745,16 @@
6251
  }
6252
  },
6253
  "node_modules/typescript-eslint": {
6254
- "version": "8.54.0",
6255
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz",
6256
- "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==",
6257
  "dev": true,
6258
  "license": "MIT",
6259
  "dependencies": {
6260
- "@typescript-eslint/eslint-plugin": "8.54.0",
6261
- "@typescript-eslint/parser": "8.54.0",
6262
- "@typescript-eslint/typescript-estree": "8.54.0",
6263
- "@typescript-eslint/utils": "8.54.0"
6264
  },
6265
  "engines": {
6266
  "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -6297,7 +6791,6 @@
6297
  "version": "6.21.0",
6298
  "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
6299
  "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
6300
- "dev": true,
6301
  "license": "MIT"
6302
  },
6303
  "node_modules/unrs-resolver": {
@@ -6376,6 +6869,37 @@
6376
  "punycode": "^2.1.0"
6377
  }
6378
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6379
  "node_modules/which": {
6380
  "version": "2.0.2",
6381
  "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -6460,9 +6984,9 @@
6460
  }
6461
  },
6462
  "node_modules/which-typed-array": {
6463
- "version": "1.1.20",
6464
- "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
6465
- "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
6466
  "dev": true,
6467
  "license": "MIT",
6468
  "dependencies": {
@@ -6491,6 +7015,27 @@
6491
  "node": ">=0.10.0"
6492
  }
6493
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6494
  "node_modules/yallist": {
6495
  "version": "3.1.1",
6496
  "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -6512,9 +7057,9 @@
6512
  }
6513
  },
6514
  "node_modules/zod": {
6515
- "version": "4.3.6",
6516
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
6517
- "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
6518
  "dev": true,
6519
  "license": "MIT",
6520
  "funding": {
 
8
  "name": "frontend",
9
  "version": "0.1.0",
10
  "dependencies": {
11
+ "@supabase/supabase-js": "^2.90.1",
12
+ "clsx": "^2.1.1",
13
+ "lucide-react": "^0.562.0",
14
+ "next": "16.1.1",
15
  "react": "19.2.3",
16
+ "react-dom": "19.2.3",
17
+ "recharts": "^3.6.0",
18
+ "tailwind-merge": "^3.4.0"
19
  },
20
  "devDependencies": {
21
  "@tailwindcss/postcss": "^4",
 
23
  "@types/react": "^19",
24
  "@types/react-dom": "^19",
25
  "eslint": "^9",
26
+ "eslint-config-next": "16.1.1",
27
  "tailwindcss": "^4",
28
  "typescript": "^5"
29
  }
 
42
  }
43
  },
44
  "node_modules/@babel/code-frame": {
45
+ "version": "7.28.6",
46
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
47
+ "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==",
48
  "dev": true,
49
  "license": "MIT",
50
  "dependencies": {
 
57
  }
58
  },
59
  "node_modules/@babel/compat-data": {
60
+ "version": "7.28.6",
61
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz",
62
+ "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==",
63
  "dev": true,
64
  "license": "MIT",
65
  "engines": {
 
67
  }
68
  },
69
  "node_modules/@babel/core": {
70
+ "version": "7.28.6",
71
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
72
+ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
73
  "dev": true,
74
  "license": "MIT",
75
  "dependencies": {
76
+ "@babel/code-frame": "^7.28.6",
77
+ "@babel/generator": "^7.28.6",
78
  "@babel/helper-compilation-targets": "^7.28.6",
79
  "@babel/helper-module-transforms": "^7.28.6",
80
  "@babel/helpers": "^7.28.6",
81
+ "@babel/parser": "^7.28.6",
82
  "@babel/template": "^7.28.6",
83
+ "@babel/traverse": "^7.28.6",
84
+ "@babel/types": "^7.28.6",
85
  "@jridgewell/remapping": "^2.3.5",
86
  "convert-source-map": "^2.0.0",
87
  "debug": "^4.1.0",
 
98
  }
99
  },
100
  "node_modules/@babel/generator": {
101
+ "version": "7.28.6",
102
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz",
103
+ "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==",
104
  "dev": true,
105
  "license": "MIT",
106
  "dependencies": {
107
+ "@babel/parser": "^7.28.6",
108
+ "@babel/types": "^7.28.6",
109
  "@jridgewell/gen-mapping": "^0.3.12",
110
  "@jridgewell/trace-mapping": "^0.3.28",
111
  "jsesc": "^3.0.2"
 
218
  }
219
  },
220
  "node_modules/@babel/parser": {
221
+ "version": "7.28.6",
222
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
223
+ "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
224
  "dev": true,
225
  "license": "MIT",
226
  "dependencies": {
227
+ "@babel/types": "^7.28.6"
228
  },
229
  "bin": {
230
  "parser": "bin/babel-parser.js"
 
249
  }
250
  },
251
  "node_modules/@babel/traverse": {
252
+ "version": "7.28.6",
253
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz",
254
+ "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==",
255
  "dev": true,
256
  "license": "MIT",
257
  "dependencies": {
258
+ "@babel/code-frame": "^7.28.6",
259
+ "@babel/generator": "^7.28.6",
260
  "@babel/helper-globals": "^7.28.0",
261
+ "@babel/parser": "^7.28.6",
262
  "@babel/template": "^7.28.6",
263
+ "@babel/types": "^7.28.6",
264
  "debug": "^4.3.1"
265
  },
266
  "engines": {
 
268
  }
269
  },
270
  "node_modules/@babel/types": {
271
+ "version": "7.28.6",
272
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
273
+ "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
274
  "dev": true,
275
  "license": "MIT",
276
  "dependencies": {
 
1040
  }
1041
  },
1042
  "node_modules/@next/env": {
1043
+ "version": "16.1.1",
1044
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz",
1045
+ "integrity": "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==",
1046
  "license": "MIT"
1047
  },
1048
  "node_modules/@next/eslint-plugin-next": {
1049
+ "version": "16.1.1",
1050
+ "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.1.tgz",
1051
+ "integrity": "sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw==",
1052
  "dev": true,
1053
  "license": "MIT",
1054
  "dependencies": {
 
1056
  }
1057
  },
1058
  "node_modules/@next/swc-darwin-arm64": {
1059
+ "version": "16.1.1",
1060
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.1.tgz",
1061
+ "integrity": "sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==",
1062
  "cpu": [
1063
  "arm64"
1064
  ],
 
1072
  }
1073
  },
1074
  "node_modules/@next/swc-darwin-x64": {
1075
+ "version": "16.1.1",
1076
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.1.tgz",
1077
+ "integrity": "sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==",
1078
  "cpu": [
1079
  "x64"
1080
  ],
 
1088
  }
1089
  },
1090
  "node_modules/@next/swc-linux-arm64-gnu": {
1091
+ "version": "16.1.1",
1092
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.1.tgz",
1093
+ "integrity": "sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==",
1094
  "cpu": [
1095
  "arm64"
1096
  ],
 
1104
  }
1105
  },
1106
  "node_modules/@next/swc-linux-arm64-musl": {
1107
+ "version": "16.1.1",
1108
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.1.tgz",
1109
+ "integrity": "sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==",
1110
  "cpu": [
1111
  "arm64"
1112
  ],
 
1120
  }
1121
  },
1122
  "node_modules/@next/swc-linux-x64-gnu": {
1123
+ "version": "16.1.1",
1124
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.1.tgz",
1125
+ "integrity": "sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==",
1126
  "cpu": [
1127
  "x64"
1128
  ],
 
1136
  }
1137
  },
1138
  "node_modules/@next/swc-linux-x64-musl": {
1139
+ "version": "16.1.1",
1140
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.1.tgz",
1141
+ "integrity": "sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==",
1142
  "cpu": [
1143
  "x64"
1144
  ],
 
1152
  }
1153
  },
1154
  "node_modules/@next/swc-win32-arm64-msvc": {
1155
+ "version": "16.1.1",
1156
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.1.tgz",
1157
+ "integrity": "sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==",
1158
  "cpu": [
1159
  "arm64"
1160
  ],
 
1168
  }
1169
  },
1170
  "node_modules/@next/swc-win32-x64-msvc": {
1171
+ "version": "16.1.1",
1172
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.1.tgz",
1173
+ "integrity": "sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==",
1174
  "cpu": [
1175
  "x64"
1176
  ],
 
1231
  "node": ">=12.4.0"
1232
  }
1233
  },
1234
+ "node_modules/@reduxjs/toolkit": {
1235
+ "version": "2.11.2",
1236
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
1237
+ "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
1238
+ "license": "MIT",
1239
+ "dependencies": {
1240
+ "@standard-schema/spec": "^1.0.0",
1241
+ "@standard-schema/utils": "^0.3.0",
1242
+ "immer": "^11.0.0",
1243
+ "redux": "^5.0.1",
1244
+ "redux-thunk": "^3.1.0",
1245
+ "reselect": "^5.1.0"
1246
+ },
1247
+ "peerDependencies": {
1248
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
1249
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
1250
+ },
1251
+ "peerDependenciesMeta": {
1252
+ "react": {
1253
+ "optional": true
1254
+ },
1255
+ "react-redux": {
1256
+ "optional": true
1257
+ }
1258
+ }
1259
+ },
1260
+ "node_modules/@reduxjs/toolkit/node_modules/immer": {
1261
+ "version": "11.1.3",
1262
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
1263
+ "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
1264
+ "license": "MIT",
1265
+ "funding": {
1266
+ "type": "opencollective",
1267
+ "url": "https://opencollective.com/immer"
1268
+ }
1269
+ },
1270
  "node_modules/@rtsao/scc": {
1271
  "version": "1.1.0",
1272
  "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
 
1274
  "dev": true,
1275
  "license": "MIT"
1276
  },
1277
+ "node_modules/@standard-schema/spec": {
1278
+ "version": "1.1.0",
1279
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
1280
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
1281
+ "license": "MIT"
1282
+ },
1283
+ "node_modules/@standard-schema/utils": {
1284
+ "version": "0.3.0",
1285
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
1286
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
1287
+ "license": "MIT"
1288
+ },
1289
+ "node_modules/@supabase/auth-js": {
1290
+ "version": "2.90.1",
1291
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.90.1.tgz",
1292
+ "integrity": "sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng==",
1293
+ "license": "MIT",
1294
+ "dependencies": {
1295
+ "tslib": "2.8.1"
1296
+ },
1297
+ "engines": {
1298
+ "node": ">=20.0.0"
1299
+ }
1300
+ },
1301
+ "node_modules/@supabase/functions-js": {
1302
+ "version": "2.90.1",
1303
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.90.1.tgz",
1304
+ "integrity": "sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw==",
1305
+ "license": "MIT",
1306
+ "dependencies": {
1307
+ "tslib": "2.8.1"
1308
+ },
1309
+ "engines": {
1310
+ "node": ">=20.0.0"
1311
+ }
1312
+ },
1313
+ "node_modules/@supabase/postgrest-js": {
1314
+ "version": "2.90.1",
1315
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.90.1.tgz",
1316
+ "integrity": "sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ==",
1317
+ "license": "MIT",
1318
+ "dependencies": {
1319
+ "tslib": "2.8.1"
1320
+ },
1321
+ "engines": {
1322
+ "node": ">=20.0.0"
1323
+ }
1324
+ },
1325
+ "node_modules/@supabase/realtime-js": {
1326
+ "version": "2.90.1",
1327
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.90.1.tgz",
1328
+ "integrity": "sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w==",
1329
+ "license": "MIT",
1330
+ "dependencies": {
1331
+ "@types/phoenix": "^1.6.6",
1332
+ "@types/ws": "^8.18.1",
1333
+ "tslib": "2.8.1",
1334
+ "ws": "^8.18.2"
1335
+ },
1336
+ "engines": {
1337
+ "node": ">=20.0.0"
1338
+ }
1339
+ },
1340
+ "node_modules/@supabase/storage-js": {
1341
+ "version": "2.90.1",
1342
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.90.1.tgz",
1343
+ "integrity": "sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg==",
1344
+ "license": "MIT",
1345
+ "dependencies": {
1346
+ "iceberg-js": "^0.8.1",
1347
+ "tslib": "2.8.1"
1348
+ },
1349
+ "engines": {
1350
+ "node": ">=20.0.0"
1351
+ }
1352
+ },
1353
+ "node_modules/@supabase/supabase-js": {
1354
+ "version": "2.90.1",
1355
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.90.1.tgz",
1356
+ "integrity": "sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w==",
1357
+ "license": "MIT",
1358
+ "dependencies": {
1359
+ "@supabase/auth-js": "2.90.1",
1360
+ "@supabase/functions-js": "2.90.1",
1361
+ "@supabase/postgrest-js": "2.90.1",
1362
+ "@supabase/realtime-js": "2.90.1",
1363
+ "@supabase/storage-js": "2.90.1"
1364
+ },
1365
+ "engines": {
1366
+ "node": ">=20.0.0"
1367
+ }
1368
+ },
1369
  "node_modules/@swc/helpers": {
1370
  "version": "0.5.15",
1371
  "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
 
1657
  "tslib": "^2.4.0"
1658
  }
1659
  },
1660
+ "node_modules/@types/d3-array": {
1661
+ "version": "3.2.2",
1662
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
1663
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
1664
+ "license": "MIT"
1665
+ },
1666
+ "node_modules/@types/d3-color": {
1667
+ "version": "3.1.3",
1668
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
1669
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
1670
+ "license": "MIT"
1671
+ },
1672
+ "node_modules/@types/d3-ease": {
1673
+ "version": "3.0.2",
1674
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
1675
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
1676
+ "license": "MIT"
1677
+ },
1678
+ "node_modules/@types/d3-interpolate": {
1679
+ "version": "3.0.4",
1680
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
1681
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
1682
+ "license": "MIT",
1683
+ "dependencies": {
1684
+ "@types/d3-color": "*"
1685
+ }
1686
+ },
1687
+ "node_modules/@types/d3-path": {
1688
+ "version": "3.1.1",
1689
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
1690
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
1691
+ "license": "MIT"
1692
+ },
1693
+ "node_modules/@types/d3-scale": {
1694
+ "version": "4.0.9",
1695
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
1696
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
1697
+ "license": "MIT",
1698
+ "dependencies": {
1699
+ "@types/d3-time": "*"
1700
+ }
1701
+ },
1702
+ "node_modules/@types/d3-shape": {
1703
+ "version": "3.1.8",
1704
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
1705
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
1706
+ "license": "MIT",
1707
+ "dependencies": {
1708
+ "@types/d3-path": "*"
1709
+ }
1710
+ },
1711
+ "node_modules/@types/d3-time": {
1712
+ "version": "3.0.4",
1713
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
1714
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
1715
+ "license": "MIT"
1716
+ },
1717
+ "node_modules/@types/d3-timer": {
1718
+ "version": "3.0.2",
1719
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
1720
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
1721
+ "license": "MIT"
1722
+ },
1723
  "node_modules/@types/estree": {
1724
  "version": "1.0.8",
1725
  "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
 
1742
  "license": "MIT"
1743
  },
1744
  "node_modules/@types/node": {
1745
+ "version": "20.19.29",
1746
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.29.tgz",
1747
+ "integrity": "sha512-YrT9ArrGaHForBaCNwFjoqJWmn8G1Pr7+BH/vwyLHciA9qT/wSiuOhxGCT50JA5xLvFBd6PIiGkE3afxcPE1nw==",
 
1748
  "license": "MIT",
1749
  "dependencies": {
1750
  "undici-types": "~6.21.0"
1751
  }
1752
  },
1753
+ "node_modules/@types/phoenix": {
1754
+ "version": "1.6.7",
1755
+ "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
1756
+ "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
1757
+ "license": "MIT"
1758
+ },
1759
  "node_modules/@types/react": {
1760
+ "version": "19.2.8",
1761
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
1762
+ "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
1763
+ "devOptional": true,
1764
  "license": "MIT",
1765
  "dependencies": {
1766
  "csstype": "^3.2.2"
 
1776
  "@types/react": "^19.2.0"
1777
  }
1778
  },
1779
+ "node_modules/@types/use-sync-external-store": {
1780
+ "version": "0.0.6",
1781
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
1782
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
1783
+ "license": "MIT"
1784
+ },
1785
+ "node_modules/@types/ws": {
1786
+ "version": "8.18.1",
1787
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
1788
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
1789
+ "license": "MIT",
1790
+ "dependencies": {
1791
+ "@types/node": "*"
1792
+ }
1793
+ },
1794
  "node_modules/@typescript-eslint/eslint-plugin": {
1795
+ "version": "8.53.0",
1796
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz",
1797
+ "integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==",
1798
  "dev": true,
1799
  "license": "MIT",
1800
  "dependencies": {
1801
  "@eslint-community/regexpp": "^4.12.2",
1802
+ "@typescript-eslint/scope-manager": "8.53.0",
1803
+ "@typescript-eslint/type-utils": "8.53.0",
1804
+ "@typescript-eslint/utils": "8.53.0",
1805
+ "@typescript-eslint/visitor-keys": "8.53.0",
1806
  "ignore": "^7.0.5",
1807
  "natural-compare": "^1.4.0",
1808
  "ts-api-utils": "^2.4.0"
 
1815
  "url": "https://opencollective.com/typescript-eslint"
1816
  },
1817
  "peerDependencies": {
1818
+ "@typescript-eslint/parser": "^8.53.0",
1819
  "eslint": "^8.57.0 || ^9.0.0",
1820
  "typescript": ">=4.8.4 <6.0.0"
1821
  }
 
1831
  }
1832
  },
1833
  "node_modules/@typescript-eslint/parser": {
1834
+ "version": "8.53.0",
1835
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.0.tgz",
1836
+ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
1837
  "dev": true,
1838
  "license": "MIT",
1839
  "dependencies": {
1840
+ "@typescript-eslint/scope-manager": "8.53.0",
1841
+ "@typescript-eslint/types": "8.53.0",
1842
+ "@typescript-eslint/typescript-estree": "8.53.0",
1843
+ "@typescript-eslint/visitor-keys": "8.53.0",
1844
  "debug": "^4.4.3"
1845
  },
1846
  "engines": {
 
1856
  }
1857
  },
1858
  "node_modules/@typescript-eslint/project-service": {
1859
+ "version": "8.53.0",
1860
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.0.tgz",
1861
+ "integrity": "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==",
1862
  "dev": true,
1863
  "license": "MIT",
1864
  "dependencies": {
1865
+ "@typescript-eslint/tsconfig-utils": "^8.53.0",
1866
+ "@typescript-eslint/types": "^8.53.0",
1867
  "debug": "^4.4.3"
1868
  },
1869
  "engines": {
 
1878
  }
1879
  },
1880
  "node_modules/@typescript-eslint/scope-manager": {
1881
+ "version": "8.53.0",
1882
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.0.tgz",
1883
+ "integrity": "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==",
1884
  "dev": true,
1885
  "license": "MIT",
1886
  "dependencies": {
1887
+ "@typescript-eslint/types": "8.53.0",
1888
+ "@typescript-eslint/visitor-keys": "8.53.0"
1889
  },
1890
  "engines": {
1891
  "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
 
1896
  }
1897
  },
1898
  "node_modules/@typescript-eslint/tsconfig-utils": {
1899
+ "version": "8.53.0",
1900
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.0.tgz",
1901
+ "integrity": "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==",
1902
  "dev": true,
1903
  "license": "MIT",
1904
  "engines": {
 
1913
  }
1914
  },
1915
  "node_modules/@typescript-eslint/type-utils": {
1916
+ "version": "8.53.0",
1917
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.0.tgz",
1918
+ "integrity": "sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==",
1919
  "dev": true,
1920
  "license": "MIT",
1921
  "dependencies": {
1922
+ "@typescript-eslint/types": "8.53.0",
1923
+ "@typescript-eslint/typescript-estree": "8.53.0",
1924
+ "@typescript-eslint/utils": "8.53.0",
1925
  "debug": "^4.4.3",
1926
  "ts-api-utils": "^2.4.0"
1927
  },
 
1938
  }
1939
  },
1940
  "node_modules/@typescript-eslint/types": {
1941
+ "version": "8.53.0",
1942
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz",
1943
+ "integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==",
1944
  "dev": true,
1945
  "license": "MIT",
1946
  "engines": {
 
1952
  }
1953
  },
1954
  "node_modules/@typescript-eslint/typescript-estree": {
1955
+ "version": "8.53.0",
1956
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.0.tgz",
1957
+ "integrity": "sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==",
1958
  "dev": true,
1959
  "license": "MIT",
1960
  "dependencies": {
1961
+ "@typescript-eslint/project-service": "8.53.0",
1962
+ "@typescript-eslint/tsconfig-utils": "8.53.0",
1963
+ "@typescript-eslint/types": "8.53.0",
1964
+ "@typescript-eslint/visitor-keys": "8.53.0",
1965
  "debug": "^4.4.3",
1966
  "minimatch": "^9.0.5",
1967
  "semver": "^7.7.3",
 
2006
  }
2007
  },
2008
  "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
2009
+ "version": "7.7.3",
2010
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
2011
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
2012
  "dev": true,
2013
  "license": "ISC",
2014
  "bin": {
 
2019
  }
2020
  },
2021
  "node_modules/@typescript-eslint/utils": {
2022
+ "version": "8.53.0",
2023
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.0.tgz",
2024
+ "integrity": "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==",
2025
  "dev": true,
2026
  "license": "MIT",
2027
  "dependencies": {
2028
  "@eslint-community/eslint-utils": "^4.9.1",
2029
+ "@typescript-eslint/scope-manager": "8.53.0",
2030
+ "@typescript-eslint/types": "8.53.0",
2031
+ "@typescript-eslint/typescript-estree": "8.53.0"
2032
  },
2033
  "engines": {
2034
  "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
 
2043
  }
2044
  },
2045
  "node_modules/@typescript-eslint/visitor-keys": {
2046
+ "version": "8.53.0",
2047
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.0.tgz",
2048
+ "integrity": "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==",
2049
  "dev": true,
2050
  "license": "MIT",
2051
  "dependencies": {
2052
+ "@typescript-eslint/types": "8.53.0",
2053
  "eslint-visitor-keys": "^4.2.1"
2054
  },
2055
  "engines": {
 
2623
  "license": "MIT"
2624
  },
2625
  "node_modules/baseline-browser-mapping": {
2626
+ "version": "2.9.14",
2627
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz",
2628
+ "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==",
2629
  "license": "Apache-2.0",
2630
  "bin": {
2631
  "baseline-browser-mapping": "dist/cli.js"
 
2750
  }
2751
  },
2752
  "node_modules/caniuse-lite": {
2753
+ "version": "1.0.30001764",
2754
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz",
2755
+ "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==",
2756
  "funding": [
2757
  {
2758
  "type": "opencollective",
 
2792
  "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
2793
  "license": "MIT"
2794
  },
2795
+ "node_modules/clsx": {
2796
+ "version": "2.1.1",
2797
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
2798
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
2799
+ "license": "MIT",
2800
+ "engines": {
2801
+ "node": ">=6"
2802
+ }
2803
+ },
2804
  "node_modules/color-convert": {
2805
  "version": "2.0.1",
2806
  "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
 
2854
  "version": "3.2.3",
2855
  "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
2856
  "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
2857
+ "devOptional": true,
2858
  "license": "MIT"
2859
  },
2860
+ "node_modules/d3-array": {
2861
+ "version": "3.2.4",
2862
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
2863
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
2864
+ "license": "ISC",
2865
+ "dependencies": {
2866
+ "internmap": "1 - 2"
2867
+ },
2868
+ "engines": {
2869
+ "node": ">=12"
2870
+ }
2871
+ },
2872
+ "node_modules/d3-color": {
2873
+ "version": "3.1.0",
2874
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
2875
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
2876
+ "license": "ISC",
2877
+ "engines": {
2878
+ "node": ">=12"
2879
+ }
2880
+ },
2881
+ "node_modules/d3-ease": {
2882
+ "version": "3.0.1",
2883
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
2884
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
2885
+ "license": "BSD-3-Clause",
2886
+ "engines": {
2887
+ "node": ">=12"
2888
+ }
2889
+ },
2890
+ "node_modules/d3-format": {
2891
+ "version": "3.1.1",
2892
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.1.tgz",
2893
+ "integrity": "sha512-ryitBnaRbXQtgZ/gU50GSn6jQRwinSCQclpakXymvLd8ytTgE5bmSfgYcUxD7XYL34qHhFDyVk71qqKsfSyvmA==",
2894
+ "license": "ISC",
2895
+ "engines": {
2896
+ "node": ">=12"
2897
+ }
2898
+ },
2899
+ "node_modules/d3-interpolate": {
2900
+ "version": "3.0.1",
2901
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
2902
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
2903
+ "license": "ISC",
2904
+ "dependencies": {
2905
+ "d3-color": "1 - 3"
2906
+ },
2907
+ "engines": {
2908
+ "node": ">=12"
2909
+ }
2910
+ },
2911
+ "node_modules/d3-path": {
2912
+ "version": "3.1.0",
2913
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
2914
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
2915
+ "license": "ISC",
2916
+ "engines": {
2917
+ "node": ">=12"
2918
+ }
2919
+ },
2920
+ "node_modules/d3-scale": {
2921
+ "version": "4.0.2",
2922
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
2923
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
2924
+ "license": "ISC",
2925
+ "dependencies": {
2926
+ "d3-array": "2.10.0 - 3",
2927
+ "d3-format": "1 - 3",
2928
+ "d3-interpolate": "1.2.0 - 3",
2929
+ "d3-time": "2.1.1 - 3",
2930
+ "d3-time-format": "2 - 4"
2931
+ },
2932
+ "engines": {
2933
+ "node": ">=12"
2934
+ }
2935
+ },
2936
+ "node_modules/d3-shape": {
2937
+ "version": "3.2.0",
2938
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
2939
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
2940
+ "license": "ISC",
2941
+ "dependencies": {
2942
+ "d3-path": "^3.1.0"
2943
+ },
2944
+ "engines": {
2945
+ "node": ">=12"
2946
+ }
2947
+ },
2948
+ "node_modules/d3-time": {
2949
+ "version": "3.1.0",
2950
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
2951
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
2952
+ "license": "ISC",
2953
+ "dependencies": {
2954
+ "d3-array": "2 - 3"
2955
+ },
2956
+ "engines": {
2957
+ "node": ">=12"
2958
+ }
2959
+ },
2960
+ "node_modules/d3-time-format": {
2961
+ "version": "4.1.0",
2962
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
2963
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
2964
+ "license": "ISC",
2965
+ "dependencies": {
2966
+ "d3-time": "1 - 3"
2967
+ },
2968
+ "engines": {
2969
+ "node": ">=12"
2970
+ }
2971
+ },
2972
+ "node_modules/d3-timer": {
2973
+ "version": "3.0.1",
2974
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
2975
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
2976
+ "license": "ISC",
2977
+ "engines": {
2978
+ "node": ">=12"
2979
+ }
2980
+ },
2981
  "node_modules/damerau-levenshtein": {
2982
  "version": "1.0.8",
2983
  "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
 
3057
  }
3058
  }
3059
  },
3060
+ "node_modules/decimal.js-light": {
3061
+ "version": "2.5.1",
3062
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
3063
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
3064
+ "license": "MIT"
3065
+ },
3066
  "node_modules/deep-is": {
3067
  "version": "0.1.4",
3068
  "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
 
3145
  }
3146
  },
3147
  "node_modules/electron-to-chromium": {
3148
+ "version": "1.5.267",
3149
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
3150
+ "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
3151
  "dev": true,
3152
  "license": "ISC"
3153
  },
 
3159
  "license": "MIT"
3160
  },
3161
  "node_modules/enhanced-resolve": {
3162
+ "version": "5.18.4",
3163
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
3164
+ "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
3165
  "dev": true,
3166
  "license": "MIT",
3167
  "dependencies": {
3168
  "graceful-fs": "^4.2.4",
3169
+ "tapable": "^2.2.0"
3170
  },
3171
  "engines": {
3172
  "node": ">=10.13.0"
 
3349
  "url": "https://github.com/sponsors/ljharb"
3350
  }
3351
  },
3352
+ "node_modules/es-toolkit": {
3353
+ "version": "1.43.0",
3354
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
3355
+ "integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
3356
+ "license": "MIT",
3357
+ "workspaces": [
3358
+ "docs",
3359
+ "benchmarks"
3360
+ ]
3361
+ },
3362
  "node_modules/escalade": {
3363
  "version": "3.2.0",
3364
  "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
 
3443
  }
3444
  },
3445
  "node_modules/eslint-config-next": {
3446
+ "version": "16.1.1",
3447
+ "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.1.tgz",
3448
+ "integrity": "sha512-55nTpVWm3qeuxoQKLOjQVciKZJUphKrNM0fCcQHAIOGl6VFXgaqeMfv0aKJhs7QtcnlAPhNVqsqRfRjeKBPIUA==",
3449
  "dev": true,
3450
  "license": "MIT",
3451
  "dependencies": {
3452
+ "@next/eslint-plugin-next": "16.1.1",
3453
  "eslint-import-resolver-node": "^0.3.6",
3454
  "eslint-import-resolver-typescript": "^3.5.2",
3455
  "eslint-plugin-import": "^2.32.0",
 
3806
  "node": ">=0.10.0"
3807
  }
3808
  },
3809
+ "node_modules/eventemitter3": {
3810
+ "version": "5.0.1",
3811
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
3812
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
3813
+ "license": "MIT"
3814
+ },
3815
  "node_modules/fast-deep-equal": {
3816
  "version": "3.1.3",
3817
  "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
 
4072
  }
4073
  },
4074
  "node_modules/get-tsconfig": {
4075
+ "version": "4.13.0",
4076
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
4077
+ "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
4078
  "dev": true,
4079
  "license": "MIT",
4080
  "dependencies": {
 
4258
  "hermes-estree": "0.25.1"
4259
  }
4260
  },
4261
+ "node_modules/iceberg-js": {
4262
+ "version": "0.8.1",
4263
+ "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
4264
+ "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
4265
+ "license": "MIT",
4266
+ "engines": {
4267
+ "node": ">=20.0.0"
4268
+ }
4269
+ },
4270
  "node_modules/ignore": {
4271
  "version": "5.3.2",
4272
  "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
 
4277
  "node": ">= 4"
4278
  }
4279
  },
4280
+ "node_modules/immer": {
4281
+ "version": "10.2.0",
4282
+ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
4283
+ "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
4284
+ "license": "MIT",
4285
+ "funding": {
4286
+ "type": "opencollective",
4287
+ "url": "https://opencollective.com/immer"
4288
+ }
4289
+ },
4290
  "node_modules/import-fresh": {
4291
  "version": "3.3.1",
4292
  "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
 
4329
  "node": ">= 0.4"
4330
  }
4331
  },
4332
+ "node_modules/internmap": {
4333
+ "version": "2.0.3",
4334
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
4335
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
4336
+ "license": "ISC",
4337
+ "engines": {
4338
+ "node": ">=12"
4339
+ }
4340
+ },
4341
  "node_modules/is-array-buffer": {
4342
  "version": "3.0.5",
4343
  "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
 
4420
  }
4421
  },
4422
  "node_modules/is-bun-module/node_modules/semver": {
4423
+ "version": "7.7.3",
4424
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
4425
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
4426
  "dev": true,
4427
  "license": "ISC",
4428
  "bin": {
 
5229
  "yallist": "^3.0.2"
5230
  }
5231
  },
5232
+ "node_modules/lucide-react": {
5233
+ "version": "0.562.0",
5234
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
5235
+ "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
5236
+ "license": "ISC",
5237
+ "peerDependencies": {
5238
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
5239
+ }
5240
+ },
5241
  "node_modules/magic-string": {
5242
  "version": "0.30.21",
5243
  "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
 
5354
  "license": "MIT"
5355
  },
5356
  "node_modules/next": {
5357
+ "version": "16.1.1",
5358
+ "resolved": "https://registry.npmjs.org/next/-/next-16.1.1.tgz",
5359
+ "integrity": "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==",
5360
  "license": "MIT",
5361
  "dependencies": {
5362
+ "@next/env": "16.1.1",
5363
  "@swc/helpers": "0.5.15",
5364
  "baseline-browser-mapping": "^2.8.3",
5365
  "caniuse-lite": "^1.0.30001579",
 
5373
  "node": ">=20.9.0"
5374
  },
5375
  "optionalDependencies": {
5376
+ "@next/swc-darwin-arm64": "16.1.1",
5377
+ "@next/swc-darwin-x64": "16.1.1",
5378
+ "@next/swc-linux-arm64-gnu": "16.1.1",
5379
+ "@next/swc-linux-arm64-musl": "16.1.1",
5380
+ "@next/swc-linux-x64-gnu": "16.1.1",
5381
+ "@next/swc-linux-x64-musl": "16.1.1",
5382
+ "@next/swc-win32-arm64-msvc": "16.1.1",
5383
+ "@next/swc-win32-x64-msvc": "16.1.1",
5384
  "sharp": "^0.34.4"
5385
  },
5386
  "peerDependencies": {
 
5808
  "version": "16.13.1",
5809
  "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
5810
  "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
 
5811
  "license": "MIT"
5812
  },
5813
+ "node_modules/react-redux": {
5814
+ "version": "9.2.0",
5815
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
5816
+ "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
5817
+ "license": "MIT",
5818
+ "dependencies": {
5819
+ "@types/use-sync-external-store": "^0.0.6",
5820
+ "use-sync-external-store": "^1.4.0"
5821
+ },
5822
+ "peerDependencies": {
5823
+ "@types/react": "^18.2.25 || ^19",
5824
+ "react": "^18.0 || ^19",
5825
+ "redux": "^5.0.0"
5826
+ },
5827
+ "peerDependenciesMeta": {
5828
+ "@types/react": {
5829
+ "optional": true
5830
+ },
5831
+ "redux": {
5832
+ "optional": true
5833
+ }
5834
+ }
5835
+ },
5836
+ "node_modules/recharts": {
5837
+ "version": "3.6.0",
5838
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
5839
+ "integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==",
5840
+ "license": "MIT",
5841
+ "workspaces": [
5842
+ "www"
5843
+ ],
5844
+ "dependencies": {
5845
+ "@reduxjs/toolkit": "1.x.x || 2.x.x",
5846
+ "clsx": "^2.1.1",
5847
+ "decimal.js-light": "^2.5.1",
5848
+ "es-toolkit": "^1.39.3",
5849
+ "eventemitter3": "^5.0.1",
5850
+ "immer": "^10.1.1",
5851
+ "react-redux": "8.x.x || 9.x.x",
5852
+ "reselect": "5.1.1",
5853
+ "tiny-invariant": "^1.3.3",
5854
+ "use-sync-external-store": "^1.2.2",
5855
+ "victory-vendor": "^37.0.2"
5856
+ },
5857
+ "engines": {
5858
+ "node": ">=18"
5859
+ },
5860
+ "peerDependencies": {
5861
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
5862
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
5863
+ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
5864
+ }
5865
+ },
5866
+ "node_modules/redux": {
5867
+ "version": "5.0.1",
5868
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
5869
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
5870
+ "license": "MIT"
5871
+ },
5872
+ "node_modules/redux-thunk": {
5873
+ "version": "3.1.0",
5874
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
5875
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
5876
+ "license": "MIT",
5877
+ "peerDependencies": {
5878
+ "redux": "^5.0.0"
5879
+ }
5880
+ },
5881
  "node_modules/reflect.getprototypeof": {
5882
  "version": "1.0.10",
5883
  "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
 
5922
  "url": "https://github.com/sponsors/ljharb"
5923
  }
5924
  },
5925
+ "node_modules/reselect": {
5926
+ "version": "5.1.1",
5927
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
5928
+ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
5929
+ "license": "MIT"
5930
+ },
5931
  "node_modules/resolve": {
5932
  "version": "1.22.11",
5933
  "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
 
6170
  }
6171
  },
6172
  "node_modules/sharp/node_modules/semver": {
6173
+ "version": "7.7.3",
6174
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
6175
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
6176
  "license": "ISC",
6177
  "optional": true,
6178
  "bin": {
 
6496
  "url": "https://github.com/sponsors/ljharb"
6497
  }
6498
  },
6499
+ "node_modules/tailwind-merge": {
6500
+ "version": "3.4.0",
6501
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
6502
+ "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
6503
+ "license": "MIT",
6504
+ "funding": {
6505
+ "type": "github",
6506
+ "url": "https://github.com/sponsors/dcastil"
6507
+ }
6508
+ },
6509
  "node_modules/tailwindcss": {
6510
  "version": "4.1.18",
6511
  "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
 
6527
  "url": "https://opencollective.com/webpack"
6528
  }
6529
  },
6530
+ "node_modules/tiny-invariant": {
6531
+ "version": "1.3.3",
6532
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
6533
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
6534
+ "license": "MIT"
6535
+ },
6536
  "node_modules/tinyglobby": {
6537
  "version": "0.2.15",
6538
  "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
 
6745
  }
6746
  },
6747
  "node_modules/typescript-eslint": {
6748
+ "version": "8.53.0",
6749
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.53.0.tgz",
6750
+ "integrity": "sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==",
6751
  "dev": true,
6752
  "license": "MIT",
6753
  "dependencies": {
6754
+ "@typescript-eslint/eslint-plugin": "8.53.0",
6755
+ "@typescript-eslint/parser": "8.53.0",
6756
+ "@typescript-eslint/typescript-estree": "8.53.0",
6757
+ "@typescript-eslint/utils": "8.53.0"
6758
  },
6759
  "engines": {
6760
  "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
 
6791
  "version": "6.21.0",
6792
  "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
6793
  "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
 
6794
  "license": "MIT"
6795
  },
6796
  "node_modules/unrs-resolver": {
 
6869
  "punycode": "^2.1.0"
6870
  }
6871
  },
6872
+ "node_modules/use-sync-external-store": {
6873
+ "version": "1.6.0",
6874
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
6875
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
6876
+ "license": "MIT",
6877
+ "peerDependencies": {
6878
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
6879
+ }
6880
+ },
6881
+ "node_modules/victory-vendor": {
6882
+ "version": "37.3.6",
6883
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
6884
+ "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
6885
+ "license": "MIT AND ISC",
6886
+ "dependencies": {
6887
+ "@types/d3-array": "^3.0.3",
6888
+ "@types/d3-ease": "^3.0.0",
6889
+ "@types/d3-interpolate": "^3.0.1",
6890
+ "@types/d3-scale": "^4.0.2",
6891
+ "@types/d3-shape": "^3.1.0",
6892
+ "@types/d3-time": "^3.0.0",
6893
+ "@types/d3-timer": "^3.0.0",
6894
+ "d3-array": "^3.1.6",
6895
+ "d3-ease": "^3.0.1",
6896
+ "d3-interpolate": "^3.0.1",
6897
+ "d3-scale": "^4.0.2",
6898
+ "d3-shape": "^3.1.0",
6899
+ "d3-time": "^3.0.0",
6900
+ "d3-timer": "^3.0.1"
6901
+ }
6902
+ },
6903
  "node_modules/which": {
6904
  "version": "2.0.2",
6905
  "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
 
6984
  }
6985
  },
6986
  "node_modules/which-typed-array": {
6987
+ "version": "1.1.19",
6988
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
6989
+ "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==",
6990
  "dev": true,
6991
  "license": "MIT",
6992
  "dependencies": {
 
7015
  "node": ">=0.10.0"
7016
  }
7017
  },
7018
+ "node_modules/ws": {
7019
+ "version": "8.19.0",
7020
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
7021
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
7022
+ "license": "MIT",
7023
+ "engines": {
7024
+ "node": ">=10.0.0"
7025
+ },
7026
+ "peerDependencies": {
7027
+ "bufferutil": "^4.0.1",
7028
+ "utf-8-validate": ">=5.0.2"
7029
+ },
7030
+ "peerDependenciesMeta": {
7031
+ "bufferutil": {
7032
+ "optional": true
7033
+ },
7034
+ "utf-8-validate": {
7035
+ "optional": true
7036
+ }
7037
+ }
7038
+ },
7039
  "node_modules/yallist": {
7040
  "version": "3.1.1",
7041
  "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
 
7057
  }
7058
  },
7059
  "node_modules/zod": {
7060
+ "version": "4.3.5",
7061
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
7062
+ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
7063
  "dev": true,
7064
  "license": "MIT",
7065
  "funding": {
Frontend/package.json CHANGED
@@ -9,9 +9,14 @@
9
  "lint": "eslint"
10
  },
11
  "dependencies": {
12
- "next": "16.1.6",
 
 
 
13
  "react": "19.2.3",
14
- "react-dom": "19.2.3"
 
 
15
  },
16
  "devDependencies": {
17
  "@tailwindcss/postcss": "^4",
@@ -19,7 +24,7 @@
19
  "@types/react": "^19",
20
  "@types/react-dom": "^19",
21
  "eslint": "^9",
22
- "eslint-config-next": "16.1.6",
23
  "tailwindcss": "^4",
24
  "typescript": "^5"
25
  }
 
9
  "lint": "eslint"
10
  },
11
  "dependencies": {
12
+ "@supabase/supabase-js": "^2.90.1",
13
+ "clsx": "^2.1.1",
14
+ "lucide-react": "^0.562.0",
15
+ "next": "16.1.1",
16
  "react": "19.2.3",
17
+ "react-dom": "19.2.3",
18
+ "recharts": "^3.6.0",
19
+ "tailwind-merge": "^3.4.0"
20
  },
21
  "devDependencies": {
22
  "@tailwindcss/postcss": "^4",
 
24
  "@types/react": "^19",
25
  "@types/react-dom": "^19",
26
  "eslint": "^9",
27
+ "eslint-config-next": "16.1.1",
28
  "tailwindcss": "^4",
29
  "typescript": "^5"
30
  }
Frontend/tsconfig.tsbuildinfo ADDED
The diff for this file is too large to render. See raw diff