frontend v2 refactor and enhancements
Browse files- .gitignore +2 -1
- Frontend/app/admin/departments/[id]/page.tsx +29 -35
- Frontend/app/admin/departments/page.tsx +32 -40
- Frontend/app/admin/heatmap/page.tsx +68 -55
- Frontend/app/admin/issues/[id]/page.tsx +38 -32
- Frontend/app/admin/issues/page.tsx +29 -22
- Frontend/app/admin/page.tsx +47 -27
- Frontend/app/admin/review/page.tsx +94 -70
- Frontend/app/admin/workers/page.tsx +42 -30
- Frontend/app/globals.css +103 -16
- Frontend/app/page.tsx +227 -97
- Frontend/app/user/issues/[id]/page.tsx +274 -262
- Frontend/app/user/page.tsx +201 -97
- Frontend/app/worker/layout.tsx +22 -13
- Frontend/app/worker/page.tsx +26 -33
- Frontend/app/worker/task/[id]/page.tsx +35 -27
- Frontend/components/DashboardHeader.tsx +43 -40
- Frontend/components/DashboardSidebar.tsx +6 -7
.gitignore
CHANGED
|
@@ -19,4 +19,5 @@ User/android/app/release/
|
|
| 19 |
User/android/app/debug/
|
| 20 |
|
| 21 |
.vscode
|
| 22 |
-
node_modules/
|
|
|
|
|
|
| 19 |
User/android/app/debug/
|
| 20 |
|
| 21 |
.vscode
|
| 22 |
+
node_modules/
|
| 23 |
+
.next
|
Frontend/app/admin/departments/[id]/page.tsx
CHANGED
|
@@ -48,12 +48,11 @@ export default function DepartmentDetailPage() {
|
|
| 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: "",
|
| 57 |
phone: "",
|
| 58 |
city: "",
|
| 59 |
max_workload: 10,
|
|
@@ -88,7 +87,7 @@ export default function DepartmentDetailPage() {
|
|
| 88 |
await apiPost("/admin/members", {
|
| 89 |
...newMember,
|
| 90 |
department_id: department.id,
|
| 91 |
-
locality: "General",
|
| 92 |
});
|
| 93 |
setShowAddMember(false);
|
| 94 |
setNewMember({
|
|
@@ -100,7 +99,7 @@ export default function DepartmentDetailPage() {
|
|
| 100 |
city: "",
|
| 101 |
max_workload: 10,
|
| 102 |
});
|
| 103 |
-
loadData(department.id);
|
| 104 |
alert("Member added successfully!");
|
| 105 |
} catch (error: any) {
|
| 106 |
alert(error.message || "Failed to add member");
|
|
@@ -110,8 +109,6 @@ export default function DepartmentDetailPage() {
|
|
| 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}`,
|
|
@@ -128,7 +125,7 @@ export default function DepartmentDetailPage() {
|
|
| 128 |
|
| 129 |
if (loading) {
|
| 130 |
return (
|
| 131 |
-
<div className="
|
| 132 |
Loading Department...
|
| 133 |
</div>
|
| 134 |
);
|
|
@@ -139,8 +136,7 @@ export default function DepartmentDetailPage() {
|
|
| 139 |
}
|
| 140 |
|
| 141 |
return (
|
| 142 |
-
<div className="max-w-
|
| 143 |
-
{/* Header */}
|
| 144 |
<div>
|
| 145 |
<Link
|
| 146 |
href="/admin/departments"
|
|
@@ -148,10 +144,10 @@ export default function DepartmentDetailPage() {
|
|
| 148 |
>
|
| 149 |
<ArrowLeft className="w-4 h-4" /> Back to Departments
|
| 150 |
</Link>
|
| 151 |
-
<div className="flex
|
| 152 |
<div>
|
| 153 |
<div className="flex items-center gap-3 mb-1">
|
| 154 |
-
<h1 className="text-3xl font-
|
| 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">
|
|
@@ -175,9 +171,8 @@ export default function DepartmentDetailPage() {
|
|
| 175 |
</div>
|
| 176 |
</div>
|
| 177 |
|
| 178 |
-
{/* Stats Cards */}
|
| 179 |
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 180 |
-
<div className="
|
| 181 |
<div className="flex justify-between items-center">
|
| 182 |
<div>
|
| 183 |
<p className="text-sm font-bold text-slate-400 uppercase tracking-wider">
|
|
@@ -187,34 +182,31 @@ export default function DepartmentDetailPage() {
|
|
| 187 |
{members.length}
|
| 188 |
</h3>
|
| 189 |
</div>
|
| 190 |
-
<div className="h-10 w-10 bg-
|
| 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-
|
| 202 |
<Users className="w-5 h-5 text-slate-400" />
|
| 203 |
Department Members
|
| 204 |
</h2>
|
| 205 |
<button
|
| 206 |
onClick={() => setShowAddMember(true)}
|
| 207 |
-
className="
|
| 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="
|
| 216 |
-
<div className="flex justify-between items-center mb-6 pb-4 border-b border-slate-200">
|
| 217 |
-
<h3 className="text-lg font-
|
| 218 |
Add New Member
|
| 219 |
</h3>
|
| 220 |
<button
|
|
@@ -234,7 +226,7 @@ export default function DepartmentDetailPage() {
|
|
| 234 |
</label>
|
| 235 |
<input
|
| 236 |
required
|
| 237 |
-
className="
|
| 238 |
placeholder="John Doe"
|
| 239 |
value={newMember.name}
|
| 240 |
onChange={(e) =>
|
|
@@ -249,7 +241,7 @@ export default function DepartmentDetailPage() {
|
|
| 249 |
<input
|
| 250 |
required
|
| 251 |
type="email"
|
| 252 |
-
className="
|
| 253 |
placeholder="john@city.gov"
|
| 254 |
value={newMember.email}
|
| 255 |
onChange={(e) =>
|
|
@@ -262,7 +254,7 @@ export default function DepartmentDetailPage() {
|
|
| 262 |
Role
|
| 263 |
</label>
|
| 264 |
<select
|
| 265 |
-
className="
|
| 266 |
value={newMember.role}
|
| 267 |
onChange={(e) =>
|
| 268 |
setNewMember({ ...newMember, role: e.target.value })
|
|
@@ -280,7 +272,7 @@ export default function DepartmentDetailPage() {
|
|
| 280 |
<input
|
| 281 |
required
|
| 282 |
type="password"
|
| 283 |
-
className="
|
| 284 |
placeholder="••••••••"
|
| 285 |
value={newMember.password}
|
| 286 |
onChange={(e) =>
|
|
@@ -293,7 +285,7 @@ export default function DepartmentDetailPage() {
|
|
| 293 |
Phone (Optional)
|
| 294 |
</label>
|
| 295 |
<input
|
| 296 |
-
className="
|
| 297 |
placeholder="+1 234..."
|
| 298 |
value={newMember.phone}
|
| 299 |
onChange={(e) =>
|
|
@@ -306,7 +298,7 @@ export default function DepartmentDetailPage() {
|
|
| 306 |
City (Optional)
|
| 307 |
</label>
|
| 308 |
<input
|
| 309 |
-
className="
|
| 310 |
placeholder="East District"
|
| 311 |
value={newMember.city}
|
| 312 |
onChange={(e) =>
|
|
@@ -318,11 +310,14 @@ export default function DepartmentDetailPage() {
|
|
| 318 |
<button
|
| 319 |
type="button"
|
| 320 |
onClick={() => setShowAddMember(false)}
|
| 321 |
-
className="
|
| 322 |
>
|
| 323 |
Cancel
|
| 324 |
</button>
|
| 325 |
-
<button
|
|
|
|
|
|
|
|
|
|
| 326 |
Create Member Account
|
| 327 |
</button>
|
| 328 |
</div>
|
|
@@ -330,10 +325,9 @@ export default function DepartmentDetailPage() {
|
|
| 330 |
</div>
|
| 331 |
)}
|
| 332 |
|
| 333 |
-
|
| 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
|
|
@@ -356,7 +350,7 @@ export default function DepartmentDetailPage() {
|
|
| 356 |
{members.map((member) => (
|
| 357 |
<tr
|
| 358 |
key={member.id}
|
| 359 |
-
className="hover:bg-
|
| 360 |
>
|
| 361 |
<td className="px-6 py-4">
|
| 362 |
<div className="flex items-center gap-3">
|
|
@@ -403,7 +397,7 @@ export default function DepartmentDetailPage() {
|
|
| 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-
|
| 407 |
style={{
|
| 408 |
width: `${(member.current_workload / (member.max_workload || 10)) * 100}%`,
|
| 409 |
}}
|
|
|
|
| 48 |
const [loading, setLoading] = useState(true);
|
| 49 |
const [showAddMember, setShowAddMember] = useState(false);
|
| 50 |
|
|
|
|
| 51 |
const [newMember, setNewMember] = useState({
|
| 52 |
name: "",
|
| 53 |
email: "",
|
| 54 |
role: "worker",
|
| 55 |
+
password: "",
|
| 56 |
phone: "",
|
| 57 |
city: "",
|
| 58 |
max_workload: 10,
|
|
|
|
| 87 |
await apiPost("/admin/members", {
|
| 88 |
...newMember,
|
| 89 |
department_id: department.id,
|
| 90 |
+
locality: "General",
|
| 91 |
});
|
| 92 |
setShowAddMember(false);
|
| 93 |
setNewMember({
|
|
|
|
| 99 |
city: "",
|
| 100 |
max_workload: 10,
|
| 101 |
});
|
| 102 |
+
loadData(department.id);
|
| 103 |
alert("Member added successfully!");
|
| 104 |
} catch (error: any) {
|
| 105 |
alert(error.message || "Failed to add member");
|
|
|
|
| 109 |
const handleDeleteMember = async (memberId: string) => {
|
| 110 |
if (!confirm("Are you sure you want to remove this member?")) return;
|
| 111 |
try {
|
|
|
|
|
|
|
| 112 |
const token = localStorage.getItem("supabase_token");
|
| 113 |
await fetch(
|
| 114 |
`${process.env.NEXT_PUBLIC_API_URL}/admin/members/${memberId}`,
|
|
|
|
| 125 |
|
| 126 |
if (loading) {
|
| 127 |
return (
|
| 128 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 text-center text-slate-500">
|
| 129 |
Loading Department...
|
| 130 |
</div>
|
| 131 |
);
|
|
|
|
| 136 |
}
|
| 137 |
|
| 138 |
return (
|
| 139 |
+
<div className="max-w-7xl mx-auto space-y-8 px-4 sm:px-6 lg:px-8 py-8">
|
|
|
|
| 140 |
<div>
|
| 141 |
<Link
|
| 142 |
href="/admin/departments"
|
|
|
|
| 144 |
>
|
| 145 |
<ArrowLeft className="w-4 h-4" /> Back to Departments
|
| 146 |
</Link>
|
| 147 |
+
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
|
| 148 |
<div>
|
| 149 |
<div className="flex items-center gap-3 mb-1">
|
| 150 |
+
<h1 className="text-3xl font-black text-slate-900">
|
| 151 |
{department.name}
|
| 152 |
</h1>
|
| 153 |
<span className="bg-slate-100 text-slate-600 px-2 py-1 rounded text-sm font-mono font-bold border border-slate-200">
|
|
|
|
| 171 |
</div>
|
| 172 |
</div>
|
| 173 |
|
|
|
|
| 174 |
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 175 |
+
<div className="bg-white/80 backdrop-blur-md rounded-2xl border border-slate-200/70 shadow-urban-sm p-6">
|
| 176 |
<div className="flex justify-between items-center">
|
| 177 |
<div>
|
| 178 |
<p className="text-sm font-bold text-slate-400 uppercase tracking-wider">
|
|
|
|
| 182 |
{members.length}
|
| 183 |
</h3>
|
| 184 |
</div>
|
| 185 |
+
<div className="h-10 w-10 bg-urban-primary/10 rounded-lg flex items-center justify-center text-urban-primary">
|
| 186 |
<Users className="w-6 h-6" />
|
| 187 |
</div>
|
| 188 |
</div>
|
| 189 |
</div>
|
|
|
|
| 190 |
</div>
|
| 191 |
|
|
|
|
| 192 |
<div className="space-y-6">
|
| 193 |
<div className="flex justify-between items-center">
|
| 194 |
+
<h2 className="text-xl font-black text-slate-900 flex items-center gap-2">
|
| 195 |
<Users className="w-5 h-5 text-slate-400" />
|
| 196 |
Department Members
|
| 197 |
</h2>
|
| 198 |
<button
|
| 199 |
onClick={() => setShowAddMember(true)}
|
| 200 |
+
className="px-4 py-2 bg-urban-primary text-white font-semibold rounded-xl hover:bg-emerald-600 transition flex items-center gap-2"
|
| 201 |
>
|
| 202 |
<Plus className="w-4 h-4" /> Add Worker
|
| 203 |
</button>
|
| 204 |
</div>
|
| 205 |
|
|
|
|
| 206 |
{showAddMember && (
|
| 207 |
+
<div className="bg-white/80 backdrop-blur-md rounded-2xl border border-slate-200/70 shadow-urban-sm p-6">
|
| 208 |
+
<div className="flex justify-between items-center mb-6 pb-4 border-b border-slate-200/70">
|
| 209 |
+
<h3 className="text-lg font-black text-slate-800">
|
| 210 |
Add New Member
|
| 211 |
</h3>
|
| 212 |
<button
|
|
|
|
| 226 |
</label>
|
| 227 |
<input
|
| 228 |
required
|
| 229 |
+
className="w-full px-4 py-2.5 bg-white/70 border border-slate-300 rounded-xl text-slate-900 focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 outline-none"
|
| 230 |
placeholder="John Doe"
|
| 231 |
value={newMember.name}
|
| 232 |
onChange={(e) =>
|
|
|
|
| 241 |
<input
|
| 242 |
required
|
| 243 |
type="email"
|
| 244 |
+
className="w-full px-4 py-2.5 bg-white/70 border border-slate-300 rounded-xl text-slate-900 focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 outline-none"
|
| 245 |
placeholder="john@city.gov"
|
| 246 |
value={newMember.email}
|
| 247 |
onChange={(e) =>
|
|
|
|
| 254 |
Role
|
| 255 |
</label>
|
| 256 |
<select
|
| 257 |
+
className="w-full px-4 py-2.5 bg-white/70 border border-slate-300 rounded-xl text-slate-900 focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 outline-none"
|
| 258 |
value={newMember.role}
|
| 259 |
onChange={(e) =>
|
| 260 |
setNewMember({ ...newMember, role: e.target.value })
|
|
|
|
| 272 |
<input
|
| 273 |
required
|
| 274 |
type="password"
|
| 275 |
+
className="w-full px-4 py-2.5 bg-white/70 border border-slate-300 rounded-xl text-slate-900 focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 outline-none"
|
| 276 |
placeholder="••••••••"
|
| 277 |
value={newMember.password}
|
| 278 |
onChange={(e) =>
|
|
|
|
| 285 |
Phone (Optional)
|
| 286 |
</label>
|
| 287 |
<input
|
| 288 |
+
className="w-full px-4 py-2.5 bg-white/70 border border-slate-300 rounded-xl text-slate-900 focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 outline-none"
|
| 289 |
placeholder="+1 234..."
|
| 290 |
value={newMember.phone}
|
| 291 |
onChange={(e) =>
|
|
|
|
| 298 |
City (Optional)
|
| 299 |
</label>
|
| 300 |
<input
|
| 301 |
+
className="w-full px-4 py-2.5 bg-white/70 border border-slate-300 rounded-xl text-slate-900 focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 outline-none"
|
| 302 |
placeholder="East District"
|
| 303 |
value={newMember.city}
|
| 304 |
onChange={(e) =>
|
|
|
|
| 310 |
<button
|
| 311 |
type="button"
|
| 312 |
onClick={() => setShowAddMember(false)}
|
| 313 |
+
className="px-5 py-2.5 bg-white/80 text-slate-700 font-semibold rounded-xl border border-slate-300 hover:bg-slate-50 transition"
|
| 314 |
>
|
| 315 |
Cancel
|
| 316 |
</button>
|
| 317 |
+
<button
|
| 318 |
+
type="submit"
|
| 319 |
+
className="px-5 py-2.5 bg-urban-primary text-white font-semibold rounded-xl hover:bg-emerald-600 transition"
|
| 320 |
+
>
|
| 321 |
Create Member Account
|
| 322 |
</button>
|
| 323 |
</div>
|
|
|
|
| 325 |
</div>
|
| 326 |
)}
|
| 327 |
|
| 328 |
+
<div className="bg-white/80 backdrop-blur-md rounded-2xl border border-slate-200/70 shadow-urban-sm p-0 overflow-hidden">
|
|
|
|
| 329 |
<table className="w-full text-left">
|
| 330 |
+
<thead className="bg-slate-50/80 border-b border-slate-200/70">
|
| 331 |
<tr>
|
| 332 |
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-wider">
|
| 333 |
Name / Email
|
|
|
|
| 350 |
{members.map((member) => (
|
| 351 |
<tr
|
| 352 |
key={member.id}
|
| 353 |
+
className="hover:bg-urban-primary/5 transition-colors"
|
| 354 |
>
|
| 355 |
<td className="px-6 py-4">
|
| 356 |
<div className="flex items-center gap-3">
|
|
|
|
| 397 |
<div className="flex items-center gap-2">
|
| 398 |
<div className="w-24 h-2 bg-slate-100 rounded-full overflow-hidden">
|
| 399 |
<div
|
| 400 |
+
className={`h-full rounded-full ${member.current_workload > 5 ? "bg-amber-500" : "bg-urban-primary"}`}
|
| 401 |
style={{
|
| 402 |
width: `${(member.current_workload / (member.max_workload || 10)) * 100}%`,
|
| 403 |
}}
|
Frontend/app/admin/departments/page.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"use client";
|
| 2 |
-
import {
|
| 3 |
-
import {
|
|
|
|
| 4 |
import { Building2, Plus, Search } from "lucide-react";
|
| 5 |
|
| 6 |
interface Department {
|
|
@@ -14,8 +15,6 @@ interface Department {
|
|
| 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: "",
|
|
@@ -23,21 +22,9 @@ export default function DepartmentsPage() {
|
|
| 23 |
description: "",
|
| 24 |
default_sla_hours: 48,
|
| 25 |
});
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 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();
|
|
@@ -50,7 +37,7 @@ export default function DepartmentsPage() {
|
|
| 50 |
description: "",
|
| 51 |
default_sla_hours: 48,
|
| 52 |
});
|
| 53 |
-
|
| 54 |
} catch (error: unknown) {
|
| 55 |
const message =
|
| 56 |
error instanceof Error ? error.message : "Failed to create department";
|
|
@@ -60,31 +47,33 @@ export default function DepartmentsPage() {
|
|
| 60 |
|
| 61 |
if (loading) {
|
| 62 |
return (
|
| 63 |
-
<div className="text-slate-600 font-medium">
|
|
|
|
|
|
|
| 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-
|
| 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-
|
| 79 |
>
|
| 80 |
<Plus className="w-4 h-4" /> New Department
|
| 81 |
</button>
|
| 82 |
</div>
|
| 83 |
|
| 84 |
{showForm && (
|
| 85 |
-
<div className="bg-white rounded-
|
| 86 |
-
<div className="bg-slate-50 px-6 py-4 border-b border-slate-200">
|
| 87 |
-
<h2 className="text-lg font-
|
| 88 |
Create New Department
|
| 89 |
</h2>
|
| 90 |
</div>
|
|
@@ -100,7 +89,7 @@ export default function DepartmentsPage() {
|
|
| 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-
|
| 104 |
placeholder="e.g., Public Works Department"
|
| 105 |
required
|
| 106 |
/>
|
|
@@ -118,7 +107,7 @@ export default function DepartmentsPage() {
|
|
| 118 |
code: e.target.value.toUpperCase(),
|
| 119 |
})
|
| 120 |
}
|
| 121 |
-
className="w-full px-4 py-2 bg-white border border-slate-300 rounded-
|
| 122 |
placeholder="e.g., PWD"
|
| 123 |
required
|
| 124 |
/>
|
|
@@ -138,7 +127,7 @@ export default function DepartmentsPage() {
|
|
| 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-
|
| 142 |
rows={2}
|
| 143 |
placeholder="Brief description of responsibilities..."
|
| 144 |
/>
|
|
@@ -161,21 +150,21 @@ export default function DepartmentsPage() {
|
|
| 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-
|
| 165 |
/>
|
| 166 |
</div>
|
| 167 |
|
| 168 |
<div className="flex gap-3 pt-2">
|
| 169 |
<button
|
| 170 |
type="submit"
|
| 171 |
-
className="px-6 py-2 bg-
|
| 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-
|
| 179 |
>
|
| 180 |
Cancel
|
| 181 |
</button>
|
|
@@ -186,7 +175,7 @@ export default function DepartmentsPage() {
|
|
| 186 |
|
| 187 |
<div className="space-y-4">
|
| 188 |
{departments.length === 0 ? (
|
| 189 |
-
<div className="text-center py-16 bg-white rounded-
|
| 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">
|
|
@@ -194,9 +183,9 @@ export default function DepartmentsPage() {
|
|
| 194 |
</p>
|
| 195 |
</div>
|
| 196 |
) : (
|
| 197 |
-
<div className="bg-white rounded-
|
| 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
|
|
@@ -214,10 +203,13 @@ export default function DepartmentsPage() {
|
|
| 214 |
</thead>
|
| 215 |
<tbody className="divide-y divide-slate-200">
|
| 216 |
{departments.map((dept) => (
|
| 217 |
-
<tr
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 221 |
{dept.code}
|
| 222 |
</div>
|
| 223 |
<div className="ml-4">
|
|
@@ -242,7 +234,7 @@ export default function DepartmentsPage() {
|
|
| 242 |
<span
|
| 243 |
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
| 244 |
dept.is_active
|
| 245 |
-
? "bg-
|
| 246 |
: "bg-red-100 text-red-800"
|
| 247 |
}`}
|
| 248 |
>
|
|
|
|
| 1 |
"use client";
|
| 2 |
+
import { useState } from "react";
|
| 3 |
+
import { apiPost } from "@/lib/api";
|
| 4 |
+
import { useCachedFetch } from "@/hooks/useCachedFetch";
|
| 5 |
import { Building2, Plus, Search } from "lucide-react";
|
| 6 |
|
| 7 |
interface Department {
|
|
|
|
| 15 |
}
|
| 16 |
|
| 17 |
export default function DepartmentsPage() {
|
|
|
|
|
|
|
| 18 |
const [showForm, setShowForm] = useState(false);
|
| 19 |
const [formData, setFormData] = useState({
|
| 20 |
name: "",
|
|
|
|
| 22 |
description: "",
|
| 23 |
default_sla_hours: 48,
|
| 24 |
});
|
| 25 |
+
const { data, loading, revalidate } =
|
| 26 |
+
useCachedFetch<Department[]>("/admin/departments");
|
| 27 |
+
const departments = data || [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
const handleSubmit = async (e: React.FormEvent) => {
|
| 30 |
e.preventDefault();
|
|
|
|
| 37 |
description: "",
|
| 38 |
default_sla_hours: 48,
|
| 39 |
});
|
| 40 |
+
revalidate();
|
| 41 |
} catch (error: unknown) {
|
| 42 |
const message =
|
| 43 |
error instanceof Error ? error.message : "Failed to create department";
|
|
|
|
| 47 |
|
| 48 |
if (loading) {
|
| 49 |
return (
|
| 50 |
+
<div className="text-slate-600 font-medium max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 51 |
+
Loading Departments...
|
| 52 |
+
</div>
|
| 53 |
);
|
| 54 |
}
|
| 55 |
|
| 56 |
return (
|
| 57 |
+
<div className="space-y-8 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 58 |
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
| 59 |
<div>
|
| 60 |
+
<h2 className="text-2xl font-black text-slate-900">Departments</h2>
|
| 61 |
+
<p className="text-sm text-slate-500 font-medium">
|
| 62 |
Organizational units and SLA configurations.
|
| 63 |
</p>
|
| 64 |
</div>
|
| 65 |
<button
|
| 66 |
onClick={() => setShowForm(true)}
|
| 67 |
+
className="px-4 py-2 bg-urban-primary text-white font-semibold rounded-xl hover:bg-emerald-600 transition shadow-sm flex items-center gap-2"
|
| 68 |
>
|
| 69 |
<Plus className="w-4 h-4" /> New Department
|
| 70 |
</button>
|
| 71 |
</div>
|
| 72 |
|
| 73 |
{showForm && (
|
| 74 |
+
<div className="bg-white/80 backdrop-blur-md rounded-2xl shadow-urban-md border border-slate-200/70 overflow-hidden">
|
| 75 |
+
<div className="bg-slate-50/80 px-6 py-4 border-b border-slate-200/70">
|
| 76 |
+
<h2 className="text-lg font-black text-slate-800">
|
| 77 |
Create New Department
|
| 78 |
</h2>
|
| 79 |
</div>
|
|
|
|
| 89 |
onChange={(e) =>
|
| 90 |
setFormData({ ...formData, name: e.target.value })
|
| 91 |
}
|
| 92 |
+
className="w-full px-4 py-2.5 bg-white/70 border border-slate-300 rounded-xl text-slate-900 focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 outline-none"
|
| 93 |
placeholder="e.g., Public Works Department"
|
| 94 |
required
|
| 95 |
/>
|
|
|
|
| 107 |
code: e.target.value.toUpperCase(),
|
| 108 |
})
|
| 109 |
}
|
| 110 |
+
className="w-full px-4 py-2.5 bg-white/70 border border-slate-300 rounded-xl text-slate-900 focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 outline-none"
|
| 111 |
placeholder="e.g., PWD"
|
| 112 |
required
|
| 113 |
/>
|
|
|
|
| 127 |
onChange={(e) =>
|
| 128 |
setFormData({ ...formData, description: e.target.value })
|
| 129 |
}
|
| 130 |
+
className="w-full px-4 py-2.5 bg-white/70 border border-slate-300 rounded-xl text-slate-900 focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 outline-none"
|
| 131 |
rows={2}
|
| 132 |
placeholder="Brief description of responsibilities..."
|
| 133 |
/>
|
|
|
|
| 150 |
default_sla_hours: parseInt(e.target.value),
|
| 151 |
})
|
| 152 |
}
|
| 153 |
+
className="w-32 px-4 py-2.5 bg-white/70 border border-slate-300 rounded-xl text-slate-900 focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 outline-none"
|
| 154 |
/>
|
| 155 |
</div>
|
| 156 |
|
| 157 |
<div className="flex gap-3 pt-2">
|
| 158 |
<button
|
| 159 |
type="submit"
|
| 160 |
+
className="px-6 py-2.5 bg-urban-primary text-white font-semibold rounded-xl hover:bg-emerald-600 transition shadow-sm"
|
| 161 |
>
|
| 162 |
Create Department
|
| 163 |
</button>
|
| 164 |
<button
|
| 165 |
type="button"
|
| 166 |
onClick={() => setShowForm(false)}
|
| 167 |
+
className="px-6 py-2.5 bg-white/80 text-slate-700 font-semibold rounded-xl border border-slate-300 hover:bg-slate-50 transition"
|
| 168 |
>
|
| 169 |
Cancel
|
| 170 |
</button>
|
|
|
|
| 175 |
|
| 176 |
<div className="space-y-4">
|
| 177 |
{departments.length === 0 ? (
|
| 178 |
+
<div className="text-center py-16 bg-white/70 backdrop-blur-md rounded-2xl border border-slate-200/70">
|
| 179 |
<Building2 className="w-12 h-12 mx-auto text-slate-300" />
|
| 180 |
<p className="text-slate-500 mt-4 text-lg">No departments found.</p>
|
| 181 |
<p className="text-slate-400 text-sm">
|
|
|
|
| 183 |
</p>
|
| 184 |
</div>
|
| 185 |
) : (
|
| 186 |
+
<div className="bg-white/70 backdrop-blur-md rounded-2xl shadow-urban-sm border border-slate-200/70 overflow-hidden">
|
| 187 |
<table className="min-w-full divide-y divide-slate-200">
|
| 188 |
+
<thead className="bg-slate-50/80">
|
| 189 |
<tr>
|
| 190 |
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
| 191 |
Department
|
|
|
|
| 203 |
</thead>
|
| 204 |
<tbody className="divide-y divide-slate-200">
|
| 205 |
{departments.map((dept) => (
|
| 206 |
+
<tr
|
| 207 |
+
key={dept.id}
|
| 208 |
+
className="hover:bg-urban-primary/5 transition"
|
| 209 |
+
>
|
| 210 |
<td className="px-6 py-4 whitespace-nowrap">
|
| 211 |
<div className="flex items-center">
|
| 212 |
+
<div className="shrink-0 h-10 w-10 rounded-xl bg-urban-primary/10 text-urban-primary flex items-center justify-center font-bold text-sm">
|
| 213 |
{dept.code}
|
| 214 |
</div>
|
| 215 |
<div className="ml-4">
|
|
|
|
| 234 |
<span
|
| 235 |
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
| 236 |
dept.is_active
|
| 237 |
+
? "bg-emerald-100 text-emerald-800"
|
| 238 |
: "bg-red-100 text-red-800"
|
| 239 |
}`}
|
| 240 |
>
|
Frontend/app/admin/heatmap/page.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"use client";
|
| 2 |
-
import {
|
| 3 |
-
import {
|
| 4 |
import { Map } from "lucide-react";
|
| 5 |
|
| 6 |
interface HeatmapData {
|
|
@@ -10,23 +10,10 @@ interface HeatmapData {
|
|
| 10 |
}
|
| 11 |
|
| 12 |
export default function HeatmapPage() {
|
| 13 |
-
const
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 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;
|
|
@@ -37,61 +24,87 @@ export default function HeatmapPage() {
|
|
| 37 |
};
|
| 38 |
|
| 39 |
if (loading) {
|
| 40 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
|
| 43 |
-
const maxCount = Math.max(...
|
| 44 |
|
| 45 |
return (
|
| 46 |
-
<div className="space-y-6">
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
-
<div className="bg-white p-6 rounded-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
<div className="
|
| 60 |
</div>
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
</div>
|
| 63 |
|
| 64 |
-
{
|
| 65 |
-
<div className="text-center py-16 bg-white rounded-
|
| 66 |
-
|
| 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 |
-
{
|
| 72 |
<div
|
| 73 |
key={item.city}
|
| 74 |
-
className="group relative bg-white overflow-hidden rounded-
|
| 75 |
>
|
| 76 |
<div className="absolute top-0 left-0 w-full h-1.5 bg-slate-100">
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
</div>
|
| 82 |
|
| 83 |
<div className="p-6">
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
</div>
|
| 96 |
</div>
|
| 97 |
))}
|
|
|
|
| 1 |
"use client";
|
| 2 |
+
import { useMemo } from "react";
|
| 3 |
+
import { useCachedFetch } from "@/hooks/useCachedFetch";
|
| 4 |
import { Map } from "lucide-react";
|
| 5 |
|
| 6 |
interface HeatmapData {
|
|
|
|
| 10 |
}
|
| 11 |
|
| 12 |
export default function HeatmapPage() {
|
| 13 |
+
const { data, loading } = useCachedFetch<HeatmapData[]>(
|
| 14 |
+
"/admin/stats/heatmap",
|
| 15 |
+
);
|
| 16 |
+
const heatmapData = useMemo(() => data || [], [data]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
const getIntensityColor = (count: number, max: number) => {
|
| 19 |
const intensity = count / max;
|
|
|
|
| 24 |
};
|
| 25 |
|
| 26 |
if (loading) {
|
| 27 |
+
return (
|
| 28 |
+
<div className="text-slate-600 font-medium max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 29 |
+
Loading Analytics...
|
| 30 |
+
</div>
|
| 31 |
+
);
|
| 32 |
}
|
| 33 |
|
| 34 |
+
const maxCount = Math.max(...heatmapData.map((d) => d.count), 1);
|
| 35 |
|
| 36 |
return (
|
| 37 |
+
<div className="space-y-8 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 38 |
+
<div>
|
| 39 |
+
<h2 className="text-2xl font-black text-slate-900">
|
| 40 |
+
Geographic Heatmap
|
| 41 |
+
</h2>
|
| 42 |
+
<p className="text-sm text-slate-500 font-medium">
|
| 43 |
+
Distribution of issues across city districts.
|
| 44 |
+
</p>
|
| 45 |
+
</div>
|
| 46 |
|
| 47 |
+
<div className="bg-white/70 backdrop-blur-md p-6 rounded-2xl border border-slate-200/70 shadow-urban-sm">
|
| 48 |
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
| 49 |
+
<h2 className="text-lg font-black text-slate-900">
|
| 50 |
+
Issue Density by City
|
| 51 |
+
</h2>
|
| 52 |
+
<div className="flex flex-wrap gap-4 text-xs font-semibold uppercase text-slate-500">
|
| 53 |
+
<div className="flex items-center gap-2">
|
| 54 |
+
<div className="w-3 h-3 rounded-full bg-emerald-500"></div> Low
|
| 55 |
</div>
|
| 56 |
+
<div className="flex items-center gap-2">
|
| 57 |
+
<div className="w-3 h-3 rounded-full bg-amber-400"></div> Medium
|
| 58 |
+
</div>
|
| 59 |
+
<div className="flex items-center gap-2">
|
| 60 |
+
<div className="w-3 h-3 rounded-full bg-orange-500"></div> High
|
| 61 |
+
</div>
|
| 62 |
+
<div className="flex items-center gap-2">
|
| 63 |
+
<div className="w-3 h-3 rounded-full bg-red-600"></div> Critical
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
</div>
|
| 68 |
|
| 69 |
+
{heatmapData.length === 0 ? (
|
| 70 |
+
<div className="text-center py-16 bg-white/70 backdrop-blur-md rounded-2xl border border-slate-200/70">
|
| 71 |
+
<Map className="w-12 h-12 mx-auto text-slate-300" />
|
| 72 |
<p className="text-slate-500 mt-2">No location data available yet.</p>
|
| 73 |
</div>
|
| 74 |
) : (
|
| 75 |
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
| 76 |
+
{heatmapData.map((item) => (
|
| 77 |
<div
|
| 78 |
key={item.city}
|
| 79 |
+
className="group relative bg-white/80 backdrop-blur-md overflow-hidden rounded-2xl border border-slate-200/70 shadow-urban-sm hover:shadow-urban-md transition-all"
|
| 80 |
>
|
| 81 |
<div className="absolute top-0 left-0 w-full h-1.5 bg-slate-100">
|
| 82 |
+
<div
|
| 83 |
+
className={`h-full ${getIntensityColor(item.count, maxCount)}`}
|
| 84 |
+
style={{ width: `${(item.count / maxCount) * 100}%` }}
|
| 85 |
+
></div>
|
| 86 |
</div>
|
| 87 |
|
| 88 |
<div className="p-6">
|
| 89 |
+
<div className="flex justify-between items-start mb-4">
|
| 90 |
+
<h3 className="text-lg font-bold text-slate-900">
|
| 91 |
+
{item.city}
|
| 92 |
+
</h3>
|
| 93 |
+
<span
|
| 94 |
+
className={`w-8 h-8 flex items-center justify-center rounded-lg text-white font-bold text-sm ${getIntensityColor(item.count, maxCount)}`}
|
| 95 |
+
>
|
| 96 |
+
{Math.round((item.count / maxCount) * 10)}
|
| 97 |
+
</span>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<div className="flex items-baseline gap-1">
|
| 101 |
+
<span className="text-3xl font-extrabold text-slate-900">
|
| 102 |
+
{item.count}
|
| 103 |
+
</span>
|
| 104 |
+
<span className="text-sm font-medium text-slate-500">
|
| 105 |
+
issues
|
| 106 |
+
</span>
|
| 107 |
+
</div>
|
| 108 |
</div>
|
| 109 |
</div>
|
| 110 |
))}
|
Frontend/app/admin/issues/[id]/page.tsx
CHANGED
|
@@ -194,15 +194,21 @@ export default function IssueDetailPage() {
|
|
| 194 |
|
| 195 |
if (loading)
|
| 196 |
return (
|
| 197 |
-
<div className="
|
|
|
|
|
|
|
| 198 |
);
|
| 199 |
if (!data)
|
| 200 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -212,7 +218,7 @@ export default function IssueDetailPage() {
|
|
| 212 |
<ArrowLeft className="w-5 h-5" />
|
| 213 |
</Link>
|
| 214 |
<div>
|
| 215 |
-
<h1 className="text-2xl font-
|
| 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
|
|
@@ -240,14 +246,14 @@ export default function IssueDetailPage() {
|
|
| 240 |
<button
|
| 241 |
onClick={() => handleReview("rejected")}
|
| 242 |
disabled={actionLoading}
|
| 243 |
-
className="px-4 py-2 text-sm font-
|
| 244 |
>
|
| 245 |
Reject & Close
|
| 246 |
</button>
|
| 247 |
<button
|
| 248 |
onClick={() => handleReview("approved")}
|
| 249 |
disabled={actionLoading}
|
| 250 |
-
className="px-4 py-2 text-sm font-
|
| 251 |
>
|
| 252 |
Approve & Assign
|
| 253 |
</button>
|
|
@@ -259,14 +265,14 @@ export default function IssueDetailPage() {
|
|
| 259 |
<button
|
| 260 |
onClick={() => handleResolutionReview("reject")}
|
| 261 |
disabled={actionLoading}
|
| 262 |
-
className="px-4 py-2 text-sm font-
|
| 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-
|
| 270 |
>
|
| 271 |
Verify & Approve
|
| 272 |
</button>
|
|
@@ -284,8 +290,8 @@ export default function IssueDetailPage() {
|
|
| 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-
|
| 288 |
-
<h3 className="text-lg font-
|
| 289 |
<ImageIcon className="w-5 h-5 text-slate-400" />
|
| 290 |
Evidence Photos
|
| 291 |
</h3>
|
|
@@ -298,7 +304,7 @@ export default function IssueDetailPage() {
|
|
| 298 |
{issue.image_urls.map((url, idx) => (
|
| 299 |
<div
|
| 300 |
key={idx}
|
| 301 |
-
className="aspect-video bg-slate-100 rounded-
|
| 302 |
>
|
| 303 |
<img
|
| 304 |
src={url}
|
|
@@ -329,14 +335,14 @@ export default function IssueDetailPage() {
|
|
| 329 |
{issue.annotated_urls.map((url, idx) => (
|
| 330 |
<div
|
| 331 |
key={idx}
|
| 332 |
-
className="aspect-video bg-slate-100 rounded-
|
| 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-
|
| 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">
|
|
@@ -361,13 +367,13 @@ export default function IssueDetailPage() {
|
|
| 361 |
Work Completion Proof
|
| 362 |
</h4>
|
| 363 |
<div className="grid grid-cols-2 gap-4">
|
| 364 |
-
<div className="aspect-video bg-slate-100 rounded-
|
| 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-
|
| 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">
|
|
@@ -386,15 +392,15 @@ export default function IssueDetailPage() {
|
|
| 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-
|
| 390 |
No images attached
|
| 391 |
</div>
|
| 392 |
)}
|
| 393 |
</div>
|
| 394 |
</div>
|
| 395 |
|
| 396 |
-
<div className="bg-white p-6 rounded-
|
| 397 |
-
<h3 className="text-lg font-
|
| 398 |
<Activity className="w-5 h-5 text-slate-400" />
|
| 399 |
Analysis & Details
|
| 400 |
</h3>
|
|
@@ -457,7 +463,7 @@ export default function IssueDetailPage() {
|
|
| 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-
|
| 461 |
aria-label="Edit Priority"
|
| 462 |
>
|
| 463 |
<Pencil className="w-3 h-3" />
|
|
@@ -472,7 +478,7 @@ export default function IssueDetailPage() {
|
|
| 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>
|
|
@@ -483,7 +489,7 @@ export default function IssueDetailPage() {
|
|
| 483 |
onClick={() =>
|
| 484 |
handleUpdate({ priority: selectedPriority })
|
| 485 |
}
|
| 486 |
-
className="p-1.5 bg-
|
| 487 |
aria-label="Save"
|
| 488 |
>
|
| 489 |
<Save className="w-4 h-4" />
|
|
@@ -519,14 +525,14 @@ export default function IssueDetailPage() {
|
|
| 519 |
</div>
|
| 520 |
|
| 521 |
<div className="space-y-6">
|
| 522 |
-
<div className="bg-white p-6 rounded-
|
| 523 |
-
<h3 className="text-sm font-
|
| 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-
|
| 530 |
<Building2 className="w-5 h-5" />
|
| 531 |
</div>
|
| 532 |
<div>
|
|
@@ -541,7 +547,7 @@ export default function IssueDetailPage() {
|
|
| 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-
|
| 545 |
aria-label="Edit Assignment"
|
| 546 |
>
|
| 547 |
<Pencil className="w-3 h-3" />
|
|
@@ -559,7 +565,7 @@ export default function IssueDetailPage() {
|
|
| 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) => (
|
|
@@ -575,13 +581,13 @@ export default function IssueDetailPage() {
|
|
| 575 |
assigned_member_id: selectedWorker || null,
|
| 576 |
})
|
| 577 |
}
|
| 578 |
-
className="flex-1 py-1 bg-
|
| 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>
|
|
@@ -604,7 +610,7 @@ export default function IssueDetailPage() {
|
|
| 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-
|
| 608 |
<Clock className="w-4 h-4" />
|
| 609 |
<div>
|
| 610 |
<div className="text-[10px] font-bold uppercase">
|
|
@@ -620,8 +626,8 @@ export default function IssueDetailPage() {
|
|
| 620 |
</div>
|
| 621 |
</div>
|
| 622 |
|
| 623 |
-
<div className="bg-white p-6 rounded-
|
| 624 |
-
<h3 className="text-sm font-
|
| 625 |
Activity Audit
|
| 626 |
</h3>
|
| 627 |
|
|
@@ -632,7 +638,7 @@ export default function IssueDetailPage() {
|
|
| 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-
|
| 636 |
<div className="flex flex-col">
|
| 637 |
<span className="font-semibold text-slate-900">
|
| 638 |
{event.agent || "System"}
|
|
|
|
| 194 |
|
| 195 |
if (loading)
|
| 196 |
return (
|
| 197 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 text-center text-slate-500">
|
| 198 |
+
Loading details...
|
| 199 |
+
</div>
|
| 200 |
);
|
| 201 |
if (!data)
|
| 202 |
+
return (
|
| 203 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 text-center text-red-500">
|
| 204 |
+
Issue not found
|
| 205 |
+
</div>
|
| 206 |
+
);
|
| 207 |
|
| 208 |
const { issue, department, worker, events } = data;
|
| 209 |
|
| 210 |
return (
|
| 211 |
+
<div className="max-w-7xl mx-auto space-y-8 px-4 sm:px-6 lg:px-8 py-8">
|
| 212 |
<div className="flex items-center justify-between">
|
| 213 |
<div className="flex items-center gap-4">
|
| 214 |
<Link
|
|
|
|
| 218 |
<ArrowLeft className="w-5 h-5" />
|
| 219 |
</Link>
|
| 220 |
<div>
|
| 221 |
+
<h1 className="text-2xl font-black text-slate-900 flex items-center gap-3">
|
| 222 |
Issue #{issue.id.slice(0, 8)}
|
| 223 |
<span
|
| 224 |
className={`text-sm px-2.5 py-0.5 rounded-full border font-medium uppercase tracking-wide
|
|
|
|
| 246 |
<button
|
| 247 |
onClick={() => handleReview("rejected")}
|
| 248 |
disabled={actionLoading}
|
| 249 |
+
className="px-4 py-2 text-sm font-semibold text-red-600 bg-white border border-red-200 rounded-xl hover:bg-red-50"
|
| 250 |
>
|
| 251 |
Reject & Close
|
| 252 |
</button>
|
| 253 |
<button
|
| 254 |
onClick={() => handleReview("approved")}
|
| 255 |
disabled={actionLoading}
|
| 256 |
+
className="px-4 py-2 text-sm font-semibold text-white bg-urban-primary rounded-xl hover:bg-emerald-600"
|
| 257 |
>
|
| 258 |
Approve & Assign
|
| 259 |
</button>
|
|
|
|
| 265 |
<button
|
| 266 |
onClick={() => handleResolutionReview("reject")}
|
| 267 |
disabled={actionLoading}
|
| 268 |
+
className="px-4 py-2 text-sm font-semibold text-red-600 bg-white border border-red-200 rounded-xl hover:bg-red-50"
|
| 269 |
>
|
| 270 |
Reject Incomplete Work
|
| 271 |
</button>
|
| 272 |
<button
|
| 273 |
onClick={() => handleResolutionReview("approve")}
|
| 274 |
disabled={actionLoading}
|
| 275 |
+
className="px-4 py-2 text-sm font-semibold text-white bg-emerald-600 rounded-xl hover:bg-emerald-700"
|
| 276 |
>
|
| 277 |
Verify & Approve
|
| 278 |
</button>
|
|
|
|
| 290 |
|
| 291 |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 292 |
<div className="lg:col-span-2 space-y-6">
|
| 293 |
+
<div className="bg-white/80 backdrop-blur-md p-6 rounded-3xl border border-slate-200/70 shadow-urban-sm">
|
| 294 |
+
<h3 className="text-lg font-black text-slate-900 mb-4 flex items-center gap-2">
|
| 295 |
<ImageIcon className="w-5 h-5 text-slate-400" />
|
| 296 |
Evidence Photos
|
| 297 |
</h3>
|
|
|
|
| 304 |
{issue.image_urls.map((url, idx) => (
|
| 305 |
<div
|
| 306 |
key={idx}
|
| 307 |
+
className="aspect-video bg-slate-100 rounded-2xl overflow-hidden border border-slate-200 relative group"
|
| 308 |
>
|
| 309 |
<img
|
| 310 |
src={url}
|
|
|
|
| 335 |
{issue.annotated_urls.map((url, idx) => (
|
| 336 |
<div
|
| 337 |
key={idx}
|
| 338 |
+
className="aspect-video bg-slate-100 rounded-2xl overflow-hidden border border-urban-primary/30 relative group"
|
| 339 |
>
|
| 340 |
<img
|
| 341 |
src={url}
|
| 342 |
alt={`Analyzed ${idx + 1}`}
|
| 343 |
className="w-full h-full object-cover"
|
| 344 |
/>
|
| 345 |
+
<div className="absolute top-2 left-2 bg-urban-primary text-white text-xs px-2 py-0.5 rounded-full font-semibold">
|
| 346 |
AI Detected
|
| 347 |
</div>
|
| 348 |
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
|
|
|
| 367 |
Work Completion Proof
|
| 368 |
</h4>
|
| 369 |
<div className="grid grid-cols-2 gap-4">
|
| 370 |
+
<div className="aspect-video bg-slate-100 rounded-2xl overflow-hidden border border-emerald-200 relative group">
|
| 371 |
<img
|
| 372 |
src={issue.proof_image_url}
|
| 373 |
alt="Work Proof"
|
| 374 |
className="w-full h-full object-cover"
|
| 375 |
/>
|
| 376 |
+
<div className="absolute top-2 left-2 bg-emerald-500 text-white text-xs px-2 py-0.5 rounded-full font-semibold">
|
| 377 |
Worker Proof
|
| 378 |
</div>
|
| 379 |
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
|
|
|
| 392 |
)}
|
| 393 |
|
| 394 |
{issue.image_urls.length === 0 && !issue.proof_image_url && (
|
| 395 |
+
<div className="py-8 text-center text-slate-400 bg-slate-50 rounded-2xl border border-dashed border-slate-300">
|
| 396 |
No images attached
|
| 397 |
</div>
|
| 398 |
)}
|
| 399 |
</div>
|
| 400 |
</div>
|
| 401 |
|
| 402 |
+
<div className="bg-white/80 backdrop-blur-md p-6 rounded-3xl border border-slate-200/70 shadow-urban-sm">
|
| 403 |
+
<h3 className="text-lg font-black text-slate-900 mb-4 flex items-center gap-2">
|
| 404 |
<Activity className="w-5 h-5 text-slate-400" />
|
| 405 |
Analysis & Details
|
| 406 |
</h3>
|
|
|
|
| 463 |
</label>
|
| 464 |
<button
|
| 465 |
onClick={() => setEditingPriority(!editingPriority)}
|
| 466 |
+
className="p-1 opacity-0 group-hover:opacity-100 hover:bg-slate-100 rounded text-slate-400 hover:text-urban-primary transition-all"
|
| 467 |
aria-label="Edit Priority"
|
| 468 |
>
|
| 469 |
<Pencil className="w-3 h-3" />
|
|
|
|
| 478 |
onChange={(e) =>
|
| 479 |
setSelectedPriority(Number(e.target.value))
|
| 480 |
}
|
| 481 |
+
className="bg-white border border-slate-200 rounded-lg px-2 py-1 text-sm flex-1 focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40"
|
| 482 |
>
|
| 483 |
<option value="1">P1 - Critical</option>
|
| 484 |
<option value="2">P2 - High</option>
|
|
|
|
| 489 |
onClick={() =>
|
| 490 |
handleUpdate({ priority: selectedPriority })
|
| 491 |
}
|
| 492 |
+
className="p-1.5 bg-urban-primary/10 text-urban-primary rounded hover:bg-urban-primary/20"
|
| 493 |
aria-label="Save"
|
| 494 |
>
|
| 495 |
<Save className="w-4 h-4" />
|
|
|
|
| 525 |
</div>
|
| 526 |
|
| 527 |
<div className="space-y-6">
|
| 528 |
+
<div className="bg-white/80 backdrop-blur-md p-6 rounded-3xl border border-slate-200/70 shadow-urban-sm">
|
| 529 |
+
<h3 className="text-sm font-black text-slate-900 uppercase tracking-wide mb-4">
|
| 530 |
Assignment
|
| 531 |
</h3>
|
| 532 |
|
| 533 |
<div className="space-y-4">
|
| 534 |
<div className="flex items-start gap-3">
|
| 535 |
+
<div className="w-9 h-9 rounded-full bg-urban-primary/10 flex items-center justify-center text-urban-primary">
|
| 536 |
<Building2 className="w-5 h-5" />
|
| 537 |
</div>
|
| 538 |
<div>
|
|
|
|
| 547 |
<div className="absolute right-0 top-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 548 |
<button
|
| 549 |
onClick={() => setEditingAssignment(!editingAssignment)}
|
| 550 |
+
className="p-1 hover:bg-slate-100 rounded text-slate-400 hover:text-urban-primary"
|
| 551 |
aria-label="Edit Assignment"
|
| 552 |
>
|
| 553 |
<Pencil className="w-3 h-3" />
|
|
|
|
| 565 |
aria-label="Select worker"
|
| 566 |
value={selectedWorker}
|
| 567 |
onChange={(e) => setSelectedWorker(e.target.value)}
|
| 568 |
+
className="bg-white border border-slate-200 rounded-lg px-2 py-1 text-sm w-full focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40"
|
| 569 |
>
|
| 570 |
<option value="">Unassigned</option>
|
| 571 |
{workers.map((w) => (
|
|
|
|
| 581 |
assigned_member_id: selectedWorker || null,
|
| 582 |
})
|
| 583 |
}
|
| 584 |
+
className="flex-1 py-1 bg-urban-primary text-white text-xs rounded-lg hover:bg-emerald-600"
|
| 585 |
>
|
| 586 |
Save
|
| 587 |
</button>
|
| 588 |
<button
|
| 589 |
onClick={() => setEditingAssignment(false)}
|
| 590 |
+
className="flex-1 py-1 bg-slate-200 text-slate-700 text-xs rounded-lg hover:bg-slate-300"
|
| 591 |
>
|
| 592 |
Cancel
|
| 593 |
</button>
|
|
|
|
| 610 |
|
| 611 |
{issue.sla_deadline && (
|
| 612 |
<div className="pt-4 border-t border-slate-100">
|
| 613 |
+
<div className="flex items-center gap-2 text-amber-600 bg-amber-50 px-3 py-2 rounded-xl">
|
| 614 |
<Clock className="w-4 h-4" />
|
| 615 |
<div>
|
| 616 |
<div className="text-[10px] font-bold uppercase">
|
|
|
|
| 626 |
</div>
|
| 627 |
</div>
|
| 628 |
|
| 629 |
+
<div className="bg-white/80 backdrop-blur-md p-6 rounded-3xl border border-slate-200/70 shadow-urban-sm relative overflow-hidden">
|
| 630 |
+
<h3 className="text-sm font-black text-slate-900 uppercase tracking-wide mb-6">
|
| 631 |
Activity Audit
|
| 632 |
</h3>
|
| 633 |
|
|
|
|
| 638 |
<div className="space-y-0 text-sm relative border-l-2 border-slate-100 ml-2">
|
| 639 |
{events.map((event, idx) => (
|
| 640 |
<div key={idx} className="pl-6 pb-6 relative last:pb-0">
|
| 641 |
+
<div className="absolute -left-2.25 top-0 w-4 h-4 rounded-full bg-white border-2 border-urban-primary"></div>
|
| 642 |
<div className="flex flex-col">
|
| 643 |
<span className="font-semibold text-slate-900">
|
| 644 |
{event.agent || "System"}
|
Frontend/app/admin/issues/page.tsx
CHANGED
|
@@ -37,10 +37,10 @@ interface IssuesResponse {
|
|
| 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>("");
|
|
@@ -79,7 +79,8 @@ export default function IssuesPage() {
|
|
| 79 |
return `/admin/issues?${query.toString()}`;
|
| 80 |
}, [page, limit, sort, order, debouncedSearch, status, priority]);
|
| 81 |
|
| 82 |
-
const { data: issuesData, loading } =
|
|
|
|
| 83 |
|
| 84 |
const issues = issuesData?.items || [];
|
| 85 |
const meta: Meta = {
|
|
@@ -119,31 +120,31 @@ export default function IssuesPage() {
|
|
| 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-
|
| 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-
|
| 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-
|
| 141 |
Export CSV
|
| 142 |
</button>
|
| 143 |
</div>
|
| 144 |
</div>
|
| 145 |
|
| 146 |
-
<div className="bg-white/
|
| 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" />
|
|
@@ -152,7 +153,7 @@ export default function IssuesPage() {
|
|
| 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/
|
| 156 |
/>
|
| 157 |
</div>
|
| 158 |
|
|
@@ -160,7 +161,7 @@ export default function IssuesPage() {
|
|
| 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/
|
| 164 |
>
|
| 165 |
<option value="">All Statuses</option>
|
| 166 |
<option value="reported">Reported</option>
|
|
@@ -177,7 +178,7 @@ export default function IssuesPage() {
|
|
| 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/
|
| 181 |
>
|
| 182 |
<option value="">All Priorities</option>
|
| 183 |
<option value="1">Critical (P1)</option>
|
|
@@ -193,7 +194,7 @@ export default function IssuesPage() {
|
|
| 193 |
Loading issues...
|
| 194 |
</div>
|
| 195 |
) : (
|
| 196 |
-
<div className="overflow-x-auto rounded-
|
| 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">
|
|
@@ -240,7 +241,7 @@ export default function IssuesPage() {
|
|
| 240 |
issues.map((issue) => (
|
| 241 |
<tr
|
| 242 |
key={issue.id}
|
| 243 |
-
className="group hover:bg-
|
| 244 |
>
|
| 245 |
<td className="px-4 py-3">
|
| 246 |
<div className="flex items-center gap-3">
|
|
@@ -258,10 +259,10 @@ export default function IssuesPage() {
|
|
| 258 |
)}
|
| 259 |
</div>
|
| 260 |
<div>
|
| 261 |
-
<div className="text-sm font-semibold text-slate-900 truncate max-w-50
|
| 262 |
{issue.category || "Uncategorized Issue"}
|
| 263 |
</div>
|
| 264 |
-
<div className="text-xs text-slate-500 truncate max-w-50
|
| 265 |
{issue.description || "No description provided"}
|
| 266 |
</div>
|
| 267 |
</div>
|
|
@@ -301,7 +302,9 @@ export default function IssuesPage() {
|
|
| 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">
|
|
|
|
|
|
|
| 305 |
</div>
|
| 306 |
) : (
|
| 307 |
<span className="text-slate-400 italic text-xs">
|
|
@@ -315,7 +318,9 @@ export default function IssuesPage() {
|
|
| 315 |
)}
|
| 316 |
</td>
|
| 317 |
<td className="px-4 py-3 text-sm text-slate-600">
|
| 318 |
-
<span className="font-medium">
|
|
|
|
|
|
|
| 319 |
<div className="text-xs text-slate-400 font-mono">
|
| 320 |
{new Date(issue.created_at).toLocaleTimeString([], {
|
| 321 |
hour: "2-digit",
|
|
@@ -326,7 +331,7 @@ export default function IssuesPage() {
|
|
| 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-
|
| 330 |
>
|
| 331 |
View
|
| 332 |
<ChevronRight className="w-3.5 h-3.5" />
|
|
@@ -350,20 +355,22 @@ export default function IssuesPage() {
|
|
| 350 |
<span className="font-semibold text-slate-900">
|
| 351 |
{Math.min(meta.page * meta.limit, meta.total)}
|
| 352 |
</span>{" "}
|
| 353 |
-
of
|
|
|
|
|
|
|
| 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-
|
| 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-
|
| 367 |
>
|
| 368 |
Next
|
| 369 |
</button>
|
|
|
|
| 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>("");
|
|
|
|
| 79 |
return `/admin/issues?${query.toString()}`;
|
| 80 |
}, [page, limit, sort, order, debouncedSearch, status, priority]);
|
| 81 |
|
| 82 |
+
const { data: issuesData, loading } =
|
| 83 |
+
useCachedFetch<IssuesResponse>(queryUrl);
|
| 84 |
|
| 85 |
const issues = issuesData?.items || [];
|
| 86 |
const meta: Meta = {
|
|
|
|
| 120 |
};
|
| 121 |
|
| 122 |
return (
|
| 123 |
+
<div className="space-y-8 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 124 |
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
| 125 |
<div>
|
| 126 |
+
<h1 className="text-2xl font-black text-slate-900 tracking-tight">
|
| 127 |
Issue Management
|
| 128 |
</h1>
|
| 129 |
+
<p className="text-slate-500 text-sm font-medium">
|
| 130 |
Monitor, assign, and resolve reported city issues.
|
| 131 |
</p>
|
| 132 |
</div>
|
| 133 |
<div className="flex items-center gap-2">
|
| 134 |
<button
|
| 135 |
onClick={() => setStatus("pending_verification")}
|
| 136 |
+
className="px-4 py-2 bg-amber-100 text-amber-800 text-sm font-semibold rounded-xl hover:bg-amber-200 transition flex items-center gap-2 border border-amber-200"
|
| 137 |
>
|
| 138 |
<AlertCircle className="w-4 h-4" />
|
| 139 |
Pending Reviews
|
| 140 |
</button>
|
| 141 |
+
<button className="bg-slate-900 text-white px-4 py-2 rounded-xl text-sm font-semibold hover:bg-slate-800 transition">
|
| 142 |
Export CSV
|
| 143 |
</button>
|
| 144 |
</div>
|
| 145 |
</div>
|
| 146 |
|
| 147 |
+
<div className="bg-white/70 backdrop-blur-md rounded-3xl border border-slate-200/70 shadow-urban-sm p-4 sm:p-6 transition-all">
|
| 148 |
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
| 149 |
<div className="md:col-span-2 relative group">
|
| 150 |
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400 group-focus-within:text-blue-500 transition-colors" />
|
|
|
|
| 153 |
placeholder="Search by ID, description, or location..."
|
| 154 |
value={search}
|
| 155 |
onChange={(e) => setSearch(e.target.value)}
|
| 156 |
+
className="w-full pl-10 pr-4 py-2.5 text-sm border border-slate-200 bg-white/70 rounded-xl focus:outline-none focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 transition-all font-sans"
|
| 157 |
/>
|
| 158 |
</div>
|
| 159 |
|
|
|
|
| 161 |
aria-label="Filter by Status"
|
| 162 |
value={status}
|
| 163 |
onChange={(e) => setStatus(e.target.value)}
|
| 164 |
+
className="px-3 py-2.5 text-sm border border-slate-200 bg-white/70 rounded-xl focus:outline-none focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 transition-all font-sans"
|
| 165 |
>
|
| 166 |
<option value="">All Statuses</option>
|
| 167 |
<option value="reported">Reported</option>
|
|
|
|
| 178 |
aria-label="Filter by Priority"
|
| 179 |
value={priority}
|
| 180 |
onChange={(e) => setPriority(e.target.value)}
|
| 181 |
+
className="px-3 py-2.5 text-sm border border-slate-200 bg-white/70 rounded-xl focus:outline-none focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 transition-all font-sans"
|
| 182 |
>
|
| 183 |
<option value="">All Priorities</option>
|
| 184 |
<option value="1">Critical (P1)</option>
|
|
|
|
| 194 |
Loading issues...
|
| 195 |
</div>
|
| 196 |
) : (
|
| 197 |
+
<div className="overflow-x-auto rounded-2xl border border-slate-200/60 bg-white/60">
|
| 198 |
<table className="w-full text-left border-collapse">
|
| 199 |
<thead>
|
| 200 |
<tr className="border-b border-slate-200/60 text-xs uppercase text-slate-500 bg-slate-50/80 font-mono tracking-wider">
|
|
|
|
| 241 |
issues.map((issue) => (
|
| 242 |
<tr
|
| 243 |
key={issue.id}
|
| 244 |
+
className="group hover:bg-urban-primary/5 transition-colors duration-200"
|
| 245 |
>
|
| 246 |
<td className="px-4 py-3">
|
| 247 |
<div className="flex items-center gap-3">
|
|
|
|
| 259 |
)}
|
| 260 |
</div>
|
| 261 |
<div>
|
| 262 |
+
<div className="text-sm font-semibold text-slate-900 truncate max-w-50">
|
| 263 |
{issue.category || "Uncategorized Issue"}
|
| 264 |
</div>
|
| 265 |
+
<div className="text-xs text-slate-500 truncate max-w-50">
|
| 266 |
{issue.description || "No description provided"}
|
| 267 |
</div>
|
| 268 |
</div>
|
|
|
|
| 302 |
<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">
|
| 303 |
{issue.assigned_to.charAt(0)}
|
| 304 |
</div>
|
| 305 |
+
<span className="font-medium text-slate-700">
|
| 306 |
+
{issue.assigned_to}
|
| 307 |
+
</span>
|
| 308 |
</div>
|
| 309 |
) : (
|
| 310 |
<span className="text-slate-400 italic text-xs">
|
|
|
|
| 318 |
)}
|
| 319 |
</td>
|
| 320 |
<td className="px-4 py-3 text-sm text-slate-600">
|
| 321 |
+
<span className="font-medium">
|
| 322 |
+
{new Date(issue.created_at).toLocaleDateString()}
|
| 323 |
+
</span>
|
| 324 |
<div className="text-xs text-slate-400 font-mono">
|
| 325 |
{new Date(issue.created_at).toLocaleTimeString([], {
|
| 326 |
hour: "2-digit",
|
|
|
|
| 331 |
<td className="px-4 py-3 text-right">
|
| 332 |
<Link
|
| 333 |
href={`/admin/issues/${issue.id}`}
|
| 334 |
+
className="inline-flex items-center gap-1.5 text-xs font-semibold text-urban-primary hover:text-emerald-700 bg-urban-primary/10 px-2.5 py-1.5 rounded-lg border border-urban-primary/20 hover:bg-urban-primary/20 transition-colors"
|
| 335 |
>
|
| 336 |
View
|
| 337 |
<ChevronRight className="w-3.5 h-3.5" />
|
|
|
|
| 355 |
<span className="font-semibold text-slate-900">
|
| 356 |
{Math.min(meta.page * meta.limit, meta.total)}
|
| 357 |
</span>{" "}
|
| 358 |
+
of{" "}
|
| 359 |
+
<span className="font-semibold text-slate-900">{meta.total}</span>{" "}
|
| 360 |
+
results
|
| 361 |
</div>
|
| 362 |
<div className="flex gap-2">
|
| 363 |
<button
|
| 364 |
onClick={() => handlePageChange(meta.page - 1)}
|
| 365 |
disabled={meta.page === 1}
|
| 366 |
+
className="px-3.5 py-1.5 text-sm font-semibold border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-slate-700"
|
| 367 |
>
|
| 368 |
Previous
|
| 369 |
</button>
|
| 370 |
<button
|
| 371 |
onClick={() => handlePageChange(meta.page + 1)}
|
| 372 |
disabled={meta.page === meta.pages}
|
| 373 |
+
className="px-3.5 py-1.5 text-sm font-semibold border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-slate-700"
|
| 374 |
>
|
| 375 |
Next
|
| 376 |
</button>
|
Frontend/app/admin/page.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"use client"
|
| 2 |
import Link from "next/link";
|
| 3 |
import { useCachedFetch } from "@/hooks/useCachedFetch";
|
| 4 |
import {
|
|
@@ -49,9 +49,9 @@ export default function AdminDashboard() {
|
|
| 49 |
|
| 50 |
if (loading) {
|
| 51 |
return (
|
| 52 |
-
<div className="space-y-6">
|
| 53 |
-
<Skeleton className="h-8 w-48
|
| 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 |
))}
|
|
@@ -70,23 +70,28 @@ export default function AdminDashboard() {
|
|
| 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 |
-
<
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 85 |
/>
|
| 86 |
<StatCard
|
| 87 |
title="Total Staff"
|
| 88 |
value={stats?.members || 0}
|
| 89 |
-
icon={<Users className="w-5 h-5 text-
|
| 90 |
/>
|
| 91 |
<StatCard
|
| 92 |
title="Total Issues"
|
|
@@ -106,7 +111,7 @@ export default function AdminDashboard() {
|
|
| 106 |
<StatCard
|
| 107 |
title="Needs Review"
|
| 108 |
value={stats?.verification_needed || 0}
|
| 109 |
-
icon={<ClipboardCheck className="w-5 h-5 text-
|
| 110 |
alert={(stats?.verification_needed || 0) > 0}
|
| 111 |
/>
|
| 112 |
</Link>
|
|
@@ -118,9 +123,9 @@ export default function AdminDashboard() {
|
|
| 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/
|
| 122 |
-
<h3 className="text-lg font-
|
| 123 |
-
<span className="w-1 h-6 bg-
|
| 124 |
Weekly Activity
|
| 125 |
</h3>
|
| 126 |
{hasActivityData ? (
|
|
@@ -139,13 +144,21 @@ export default function AdminDashboard() {
|
|
| 139 |
dataKey="name"
|
| 140 |
axisLine={false}
|
| 141 |
tickLine={false}
|
| 142 |
-
tick={{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
dy={10}
|
| 144 |
/>
|
| 145 |
<YAxis
|
| 146 |
axisLine={false}
|
| 147 |
tickLine={false}
|
| 148 |
-
tick={{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
/>
|
| 150 |
<Tooltip
|
| 151 |
cursor={{ fill: "#F1F5F9" }}
|
|
@@ -155,21 +168,24 @@ export default function AdminDashboard() {
|
|
| 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:
|
| 159 |
}}
|
| 160 |
/>
|
| 161 |
-
<Legend
|
|
|
|
|
|
|
|
|
|
| 162 |
<Bar
|
| 163 |
dataKey="reported"
|
| 164 |
name="Reported"
|
| 165 |
-
fill="#
|
| 166 |
radius={[4, 4, 0, 0]}
|
| 167 |
barSize={20}
|
| 168 |
/>
|
| 169 |
<Bar
|
| 170 |
dataKey="resolved"
|
| 171 |
name="Resolved"
|
| 172 |
-
fill="#
|
| 173 |
radius={[4, 4, 0, 0]}
|
| 174 |
barSize={20}
|
| 175 |
/>
|
|
@@ -183,9 +199,9 @@ export default function AdminDashboard() {
|
|
| 183 |
)}
|
| 184 |
</div>
|
| 185 |
|
| 186 |
-
<div className="bg-white/
|
| 187 |
-
<h3 className="text-lg font-
|
| 188 |
-
<span className="w-1 h-6 bg-
|
| 189 |
Issues by Category
|
| 190 |
</h3>
|
| 191 |
{hasChartData ? (
|
|
@@ -209,17 +225,21 @@ export default function AdminDashboard() {
|
|
| 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:
|
| 220 |
}}
|
| 221 |
/>
|
| 222 |
-
<Legend
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
</PieChart>
|
| 224 |
</ResponsiveContainer>
|
| 225 |
</div>
|
|
@@ -246,7 +266,7 @@ function StatCard({
|
|
| 246 |
alert?: boolean;
|
| 247 |
}) {
|
| 248 |
return (
|
| 249 |
-
<div className="bg-white/
|
| 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}
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
import Link from "next/link";
|
| 3 |
import { useCachedFetch } from "@/hooks/useCachedFetch";
|
| 4 |
import {
|
|
|
|
| 49 |
|
| 50 |
if (loading) {
|
| 51 |
return (
|
| 52 |
+
<div className="space-y-8 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 53 |
+
<Skeleton className="h-8 w-48" />
|
| 54 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg: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 |
))}
|
|
|
|
| 70 |
stats?.issues_activity && stats.issues_activity.length > 0;
|
| 71 |
|
| 72 |
return (
|
| 73 |
+
<div className="space-y-8 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 74 |
<div className="flex items-center justify-between">
|
| 75 |
+
<div>
|
| 76 |
+
<h2 className="text-2xl font-black text-slate-900 tracking-tight">
|
| 77 |
+
Overview
|
| 78 |
+
</h2>
|
| 79 |
+
<p className="text-sm text-slate-500 font-medium">
|
| 80 |
+
City operations at a glance
|
| 81 |
+
</p>
|
| 82 |
+
</div>
|
| 83 |
</div>
|
| 84 |
|
| 85 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-6">
|
| 86 |
<StatCard
|
| 87 |
title="Departments"
|
| 88 |
value={stats?.departments || 0}
|
| 89 |
+
icon={<Building2 className="w-5 h-5 text-urban-primary" />}
|
| 90 |
/>
|
| 91 |
<StatCard
|
| 92 |
title="Total Staff"
|
| 93 |
value={stats?.members || 0}
|
| 94 |
+
icon={<Users className="w-5 h-5 text-amber-500" />}
|
| 95 |
/>
|
| 96 |
<StatCard
|
| 97 |
title="Total Issues"
|
|
|
|
| 111 |
<StatCard
|
| 112 |
title="Needs Review"
|
| 113 |
value={stats?.verification_needed || 0}
|
| 114 |
+
icon={<ClipboardCheck className="w-5 h-5 text-urban-primary" />}
|
| 115 |
alert={(stats?.verification_needed || 0) > 0}
|
| 116 |
/>
|
| 117 |
</Link>
|
|
|
|
| 123 |
</div>
|
| 124 |
|
| 125 |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 126 |
+
<div className="lg:col-span-2 bg-white/70 backdrop-blur-md p-6 rounded-3xl border border-slate-200/70 shadow-urban-sm hover:shadow-urban-md transition-all">
|
| 127 |
+
<h3 className="text-lg font-black text-slate-900 mb-6 flex items-center gap-3">
|
| 128 |
+
<span className="w-1.5 h-6 bg-urban-primary rounded-full"></span>
|
| 129 |
Weekly Activity
|
| 130 |
</h3>
|
| 131 |
{hasActivityData ? (
|
|
|
|
| 144 |
dataKey="name"
|
| 145 |
axisLine={false}
|
| 146 |
tickLine={false}
|
| 147 |
+
tick={{
|
| 148 |
+
fill: "#64748B",
|
| 149 |
+
fontSize: 12,
|
| 150 |
+
fontFamily: "var(--font-fira-sans)",
|
| 151 |
+
}}
|
| 152 |
dy={10}
|
| 153 |
/>
|
| 154 |
<YAxis
|
| 155 |
axisLine={false}
|
| 156 |
tickLine={false}
|
| 157 |
+
tick={{
|
| 158 |
+
fill: "#64748B",
|
| 159 |
+
fontSize: 12,
|
| 160 |
+
fontFamily: "var(--font-fira-sans)",
|
| 161 |
+
}}
|
| 162 |
/>
|
| 163 |
<Tooltip
|
| 164 |
cursor={{ fill: "#F1F5F9" }}
|
|
|
|
| 168 |
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1)",
|
| 169 |
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
| 170 |
backdropFilter: "blur(4px)",
|
| 171 |
+
fontFamily: "var(--font-fira-sans)",
|
| 172 |
}}
|
| 173 |
/>
|
| 174 |
+
<Legend
|
| 175 |
+
iconType="circle"
|
| 176 |
+
wrapperStyle={{ fontFamily: "var(--font-fira-sans)" }}
|
| 177 |
+
/>
|
| 178 |
<Bar
|
| 179 |
dataKey="reported"
|
| 180 |
name="Reported"
|
| 181 |
+
fill="#0ea5a4"
|
| 182 |
radius={[4, 4, 0, 0]}
|
| 183 |
barSize={20}
|
| 184 |
/>
|
| 185 |
<Bar
|
| 186 |
dataKey="resolved"
|
| 187 |
name="Resolved"
|
| 188 |
+
fill="#22c55e"
|
| 189 |
radius={[4, 4, 0, 0]}
|
| 190 |
barSize={20}
|
| 191 |
/>
|
|
|
|
| 199 |
)}
|
| 200 |
</div>
|
| 201 |
|
| 202 |
+
<div className="bg-white/70 backdrop-blur-md p-6 rounded-3xl border border-slate-200/70 shadow-urban-sm hover:shadow-urban-md transition-all">
|
| 203 |
+
<h3 className="text-lg font-black text-slate-900 mb-6 flex items-center gap-3">
|
| 204 |
+
<span className="w-1.5 h-6 bg-amber-500 rounded-full"></span>
|
| 205 |
Issues by Category
|
| 206 |
</h3>
|
| 207 |
{hasChartData ? (
|
|
|
|
| 225 |
/>
|
| 226 |
))}
|
| 227 |
</Pie>
|
| 228 |
+
<Tooltip
|
| 229 |
contentStyle={{
|
| 230 |
borderRadius: "12px",
|
| 231 |
border: "1px solid rgba(226, 232, 240, 0.8)",
|
| 232 |
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1)",
|
| 233 |
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
| 234 |
backdropFilter: "blur(4px)",
|
| 235 |
+
fontFamily: "var(--font-fira-sans)",
|
| 236 |
}}
|
| 237 |
/>
|
| 238 |
+
<Legend
|
| 239 |
+
verticalAlign="bottom"
|
| 240 |
+
height={36}
|
| 241 |
+
wrapperStyle={{ fontFamily: "var(--font-fira-sans)" }}
|
| 242 |
+
/>
|
| 243 |
</PieChart>
|
| 244 |
</ResponsiveContainer>
|
| 245 |
</div>
|
|
|
|
| 266 |
alert?: boolean;
|
| 267 |
}) {
|
| 268 |
return (
|
| 269 |
+
<div className="bg-white/70 backdrop-blur-md p-6 rounded-2xl border border-slate-200/70 shadow-urban-sm hover:shadow-urban-md transition-all hover:-translate-y-1 group">
|
| 270 |
<div className="flex justify-between items-start mb-4">
|
| 271 |
<div className="p-3 bg-white rounded-xl border border-slate-100 shadow-sm group-hover:scale-110 transition-transform duration-300">
|
| 272 |
{icon}
|
Frontend/app/admin/review/page.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"use client";
|
| 2 |
-
import {
|
| 3 |
-
import {
|
|
|
|
| 4 |
import { CheckCircle2, XCircle } from "lucide-react";
|
| 5 |
|
| 6 |
interface Issue {
|
|
@@ -16,28 +17,29 @@ interface Issue {
|
|
| 16 |
}
|
| 17 |
|
| 18 |
export default function ManualReviewPage() {
|
| 19 |
-
const [
|
| 20 |
-
const
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
fetchPendingIssues();
|
| 24 |
-
}, []);
|
| 25 |
|
| 26 |
-
const
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 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 }>(
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
alert(data.message);
|
| 42 |
} catch (error) {
|
| 43 |
console.error("Review failed", error);
|
|
@@ -46,71 +48,93 @@ export default function ManualReviewPage() {
|
|
| 46 |
};
|
| 47 |
|
| 48 |
if (loading) {
|
| 49 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
}
|
| 51 |
|
| 52 |
return (
|
| 53 |
-
<div className="space-y-6">
|
| 54 |
-
<div className="flex justify-between items-center">
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
</div>
|
| 63 |
|
| 64 |
-
{
|
| 65 |
-
<div className="text-center py-20 bg-white rounded-
|
| 66 |
-
<CheckCircle2 className="w-12 h-12 mx-auto text-
|
| 67 |
-
<p className="text-slate-900 font-medium mt-4 text-lg">
|
|
|
|
|
|
|
| 68 |
<p className="text-slate-500">No issues pending manual review.</p>
|
| 69 |
</div>
|
| 70 |
) : (
|
| 71 |
<div className="grid gap-6">
|
| 72 |
-
{
|
| 73 |
-
<div
|
|
|
|
|
|
|
|
|
|
| 74 |
<div className="md:w-1/3 h-64 md:h-auto bg-slate-100 relative">
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
</div>
|
| 85 |
|
| 86 |
<div className="p-6 md:w-2/3 flex flex-col justify-between">
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 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 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
</div>
|
| 115 |
</div>
|
| 116 |
))}
|
|
|
|
| 1 |
"use client";
|
| 2 |
+
import { useMemo, useState } from "react";
|
| 3 |
+
import { apiPost } from "@/lib/api";
|
| 4 |
+
import { useCachedFetch } from "@/hooks/useCachedFetch";
|
| 5 |
import { CheckCircle2, XCircle } from "lucide-react";
|
| 6 |
|
| 7 |
interface Issue {
|
|
|
|
| 17 |
}
|
| 18 |
|
| 19 |
export default function ManualReviewPage() {
|
| 20 |
+
const [removedIds, setRemovedIds] = useState<Set<string>>(new Set());
|
| 21 |
+
const { data, loading, revalidate } = useCachedFetch<{ items: Issue[] }>(
|
| 22 |
+
"/issues?state=reported",
|
| 23 |
+
);
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
const issues = useMemo(() => data?.items || [], [data]);
|
| 26 |
+
const visibleIssues = useMemo(
|
| 27 |
+
() => issues.filter((issue) => !removedIds.has(issue.id)),
|
| 28 |
+
[issues, removedIds],
|
| 29 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
const handleReview = async (id: string, status: "approved" | "rejected") => {
|
| 32 |
try {
|
| 33 |
+
const data = await apiPost<{ message: string }>(
|
| 34 |
+
`/admin/issues/${id}/review`,
|
| 35 |
+
{ status },
|
| 36 |
+
);
|
| 37 |
+
setRemovedIds((prev) => {
|
| 38 |
+
const next = new Set(prev);
|
| 39 |
+
next.add(id);
|
| 40 |
+
return next;
|
| 41 |
+
});
|
| 42 |
+
revalidate();
|
| 43 |
alert(data.message);
|
| 44 |
} catch (error) {
|
| 45 |
console.error("Review failed", error);
|
|
|
|
| 48 |
};
|
| 49 |
|
| 50 |
if (loading) {
|
| 51 |
+
return (
|
| 52 |
+
<div className="text-slate-600 font-medium max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 53 |
+
Loading Reviews...
|
| 54 |
+
</div>
|
| 55 |
+
);
|
| 56 |
}
|
| 57 |
|
| 58 |
return (
|
| 59 |
+
<div className="space-y-8 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 60 |
+
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
| 61 |
+
<div>
|
| 62 |
+
<h2 className="text-2xl font-black text-slate-900">
|
| 63 |
+
Manual Review Queue
|
| 64 |
+
</h2>
|
| 65 |
+
<p className="text-sm text-slate-500 font-medium">
|
| 66 |
+
Validate incoming citizen reports before assignment.
|
| 67 |
+
</p>
|
| 68 |
+
</div>
|
| 69 |
+
<div className="bg-urban-primary text-white text-xs font-bold px-3 py-1.5 rounded-full shadow-sm">
|
| 70 |
+
{visibleIssues.length} Pending
|
| 71 |
+
</div>
|
| 72 |
</div>
|
| 73 |
|
| 74 |
+
{visibleIssues.length === 0 ? (
|
| 75 |
+
<div className="text-center py-20 bg-white/70 backdrop-blur-md rounded-2xl border border-slate-200/70 shadow-urban-sm">
|
| 76 |
+
<CheckCircle2 className="w-12 h-12 mx-auto text-emerald-500" />
|
| 77 |
+
<p className="text-slate-900 font-medium mt-4 text-lg">
|
| 78 |
+
All caught up!
|
| 79 |
+
</p>
|
| 80 |
<p className="text-slate-500">No issues pending manual review.</p>
|
| 81 |
</div>
|
| 82 |
) : (
|
| 83 |
<div className="grid gap-6">
|
| 84 |
+
{visibleIssues.map((issue) => (
|
| 85 |
+
<div
|
| 86 |
+
key={issue.id}
|
| 87 |
+
className="bg-white/80 backdrop-blur-md rounded-2xl border border-slate-200/70 shadow-urban-sm overflow-hidden flex flex-col md:flex-row"
|
| 88 |
+
>
|
| 89 |
<div className="md:w-1/3 h-64 md:h-auto bg-slate-100 relative">
|
| 90 |
+
{issue.images?.[0] ? (
|
| 91 |
+
<img
|
| 92 |
+
src={
|
| 93 |
+
issue.images[0].annotated_path ||
|
| 94 |
+
issue.images[0].file_path
|
| 95 |
+
}
|
| 96 |
+
alt="Evidence"
|
| 97 |
+
className="w-full h-full object-cover"
|
| 98 |
+
/>
|
| 99 |
+
) : (
|
| 100 |
+
<div className="flex items-center justify-center h-full text-slate-400">
|
| 101 |
+
No Image
|
| 102 |
+
</div>
|
| 103 |
+
)}
|
| 104 |
</div>
|
| 105 |
|
| 106 |
<div className="p-6 md:w-2/3 flex flex-col justify-between">
|
| 107 |
+
<div>
|
| 108 |
+
<div className="flex justify-between items-start mb-2">
|
| 109 |
+
<span className="px-2.5 py-0.5 rounded-full bg-slate-100 text-slate-600 text-xs font-bold uppercase">
|
| 110 |
+
{issue.city}
|
| 111 |
+
</span>
|
| 112 |
+
<span className="text-xs text-slate-400">
|
| 113 |
+
{new Date(issue.created_at).toLocaleDateString()}
|
| 114 |
+
</span>
|
|
|
|
|
|
|
|
|
|
| 115 |
</div>
|
| 116 |
+
<h3 className="text-lg font-bold text-slate-900 mb-2">
|
| 117 |
+
{issue.description || "No description"}
|
| 118 |
+
</h3>
|
| 119 |
+
<p className="text-slate-600 text-sm mb-4">
|
| 120 |
+
{issue.full_address || issue.locality}
|
| 121 |
+
</p>
|
| 122 |
+
</div>
|
| 123 |
|
| 124 |
+
<div className="flex flex-col sm:flex-row gap-3 mt-4 pt-4 border-t border-slate-100/70">
|
| 125 |
+
<button
|
| 126 |
+
onClick={() => handleReview(issue.id, "approved")}
|
| 127 |
+
className="flex-1 bg-emerald-600 hover:bg-emerald-700 text-white font-semibold py-2.5 px-4 rounded-xl transition shadow-sm flex items-center justify-center gap-2"
|
| 128 |
+
>
|
| 129 |
+
<CheckCircle2 className="w-4 h-4" /> Approve & Assign
|
| 130 |
+
</button>
|
| 131 |
+
<button
|
| 132 |
+
onClick={() => handleReview(issue.id, "rejected")}
|
| 133 |
+
className="flex-1 bg-white border border-red-200 text-red-600 hover:bg-red-50 font-semibold py-2.5 px-4 rounded-xl transition flex items-center justify-center gap-2"
|
| 134 |
+
>
|
| 135 |
+
<XCircle className="w-4 h-4" /> Reject
|
| 136 |
+
</button>
|
| 137 |
+
</div>
|
| 138 |
</div>
|
| 139 |
</div>
|
| 140 |
))}
|
Frontend/app/admin/workers/page.tsx
CHANGED
|
@@ -40,10 +40,22 @@ interface WorkerPerformance {
|
|
| 40 |
}
|
| 41 |
|
| 42 |
export default function WorkersPage() {
|
| 43 |
-
const {
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
const [showForm, setShowForm] = useState(false);
|
| 48 |
const [formData, setFormData] = useState({
|
| 49 |
name: "",
|
|
@@ -56,11 +68,11 @@ export default function WorkersPage() {
|
|
| 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 {
|
|
@@ -106,14 +118,14 @@ export default function WorkersPage() {
|
|
| 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-
|
| 117 |
))}
|
| 118 |
</div>
|
| 119 |
</div>
|
|
@@ -130,28 +142,28 @@ export default function WorkersPage() {
|
|
| 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-
|
| 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-
|
| 146 |
>
|
| 147 |
<Plus className="w-4 h-4" /> Enroll Worker
|
| 148 |
</button>
|
| 149 |
</div>
|
| 150 |
|
| 151 |
{showForm && (
|
| 152 |
-
<div className="bg-white rounded-
|
| 153 |
-
<div className="bg-slate-50 px-6 py-4 border-b border-slate-200">
|
| 154 |
-
<h2 className="text-lg font-
|
| 155 |
New Worker Enrollment
|
| 156 |
</h2>
|
| 157 |
</div>
|
|
@@ -167,7 +179,7 @@ export default function WorkersPage() {
|
|
| 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-
|
| 171 |
placeholder="e.g. John Doe"
|
| 172 |
required
|
| 173 |
/>
|
|
@@ -182,7 +194,7 @@ export default function WorkersPage() {
|
|
| 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-
|
| 186 |
placeholder="worker@city.gov"
|
| 187 |
required
|
| 188 |
/>
|
|
@@ -199,7 +211,7 @@ export default function WorkersPage() {
|
|
| 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-
|
| 203 |
required
|
| 204 |
minLength={8}
|
| 205 |
placeholder="••••••••"
|
|
@@ -215,7 +227,7 @@ export default function WorkersPage() {
|
|
| 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-
|
| 219 |
required
|
| 220 |
>
|
| 221 |
<option value="">Select Department...</option>
|
|
@@ -231,14 +243,14 @@ export default function WorkersPage() {
|
|
| 231 |
<div className="flex gap-3 pt-2">
|
| 232 |
<button
|
| 233 |
type="submit"
|
| 234 |
-
className="px-6 py-2 bg-
|
| 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-
|
| 242 |
>
|
| 243 |
Cancel
|
| 244 |
</button>
|
|
@@ -249,16 +261,16 @@ export default function WorkersPage() {
|
|
| 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-
|
| 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-
|
| 259 |
/>
|
| 260 |
</div>
|
| 261 |
-
<button className="px-3 py-2 bg-white border border-slate-200 rounded-
|
| 262 |
<Filter className="w-4 h-4" /> Filter
|
| 263 |
</button>
|
| 264 |
</div>
|
|
@@ -271,7 +283,7 @@ export default function WorkersPage() {
|
|
| 271 |
</div>
|
| 272 |
|
| 273 |
{filteredWorkers.length === 0 ? (
|
| 274 |
-
<div className="text-center py-16 bg-white rounded-
|
| 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>
|
|
@@ -280,11 +292,11 @@ export default function WorkersPage() {
|
|
| 280 |
{filteredWorkers.map((worker) => (
|
| 281 |
<div
|
| 282 |
key={worker.id}
|
| 283 |
-
className="bg-white p-5 rounded-
|
| 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-
|
| 288 |
{worker.name.charAt(0)}
|
| 289 |
</div>
|
| 290 |
<div>
|
|
@@ -301,7 +313,7 @@ export default function WorkersPage() {
|
|
| 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">
|
|
@@ -343,7 +355,7 @@ export default function WorkersPage() {
|
|
| 343 |
? "bg-red-500"
|
| 344 |
: worker.current_workload > worker.max_workload * 0.7
|
| 345 |
? "bg-amber-500"
|
| 346 |
-
: "bg-
|
| 347 |
}`}
|
| 348 |
style={{
|
| 349 |
width: `${Math.min(
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
export default function WorkersPage() {
|
| 43 |
+
const {
|
| 44 |
+
data: departmentsData,
|
| 45 |
+
loading: deptLoading,
|
| 46 |
+
revalidate: revalidateDept,
|
| 47 |
+
} = useCachedFetch<Department[]>("/admin/departments");
|
| 48 |
+
const {
|
| 49 |
+
data: workersData,
|
| 50 |
+
loading: workersLoading,
|
| 51 |
+
revalidate: revalidateWorkers,
|
| 52 |
+
} = useCachedFetch<Worker[]>("/admin/members");
|
| 53 |
+
const {
|
| 54 |
+
data: perfData,
|
| 55 |
+
loading: perfLoading,
|
| 56 |
+
revalidate: revalidatePerf,
|
| 57 |
+
} = useCachedFetch<WorkerPerformance[]>("/admin/workers/performance");
|
| 58 |
+
|
| 59 |
const [showForm, setShowForm] = useState(false);
|
| 60 |
const [formData, setFormData] = useState({
|
| 61 |
name: "",
|
|
|
|
| 68 |
const [search, setSearch] = useState("");
|
| 69 |
|
| 70 |
const departments = departmentsData || [];
|
| 71 |
+
|
| 72 |
const workers = useMemo(() => {
|
| 73 |
if (!workersData) return [];
|
| 74 |
+
const perfMap = new Map((perfData || []).map((p) => [p.id, p]));
|
| 75 |
+
|
| 76 |
return workersData.map((w) => {
|
| 77 |
const perf = perfMap.get(w.id);
|
| 78 |
return {
|
|
|
|
| 118 |
|
| 119 |
if (loading) {
|
| 120 |
return (
|
| 121 |
+
<div className="space-y-6 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 122 |
<div className="flex justify-between items-center mb-6">
|
| 123 |
<Skeleton className="h-10 w-64" />
|
| 124 |
<Skeleton className="h-10 w-32" />
|
| 125 |
</div>
|
| 126 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 127 |
{Array.from({ length: 6 }).map((_, i) => (
|
| 128 |
+
<Skeleton key={i} className="h-48 rounded-2xl" />
|
| 129 |
))}
|
| 130 |
</div>
|
| 131 |
</div>
|
|
|
|
| 142 |
);
|
| 143 |
|
| 144 |
return (
|
| 145 |
+
<div className="space-y-8 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 146 |
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
| 147 |
<div>
|
| 148 |
+
<h2 className="text-2xl font-black text-slate-900">
|
| 149 |
Workforce Management
|
| 150 |
</h2>
|
| 151 |
+
<p className="text-sm text-slate-500 font-medium">
|
| 152 |
Manage field workers, assign tasks, and monitor performance.
|
| 153 |
</p>
|
| 154 |
</div>
|
| 155 |
<button
|
| 156 |
onClick={() => setShowForm(true)}
|
| 157 |
+
className="px-4 py-2 bg-urban-primary text-white font-semibold rounded-xl hover:bg-emerald-600 transition shadow-sm flex items-center gap-2"
|
| 158 |
>
|
| 159 |
<Plus className="w-4 h-4" /> Enroll Worker
|
| 160 |
</button>
|
| 161 |
</div>
|
| 162 |
|
| 163 |
{showForm && (
|
| 164 |
+
<div className="bg-white/80 backdrop-blur-md rounded-2xl shadow-urban-md border border-slate-200/70 overflow-hidden">
|
| 165 |
+
<div className="bg-slate-50/80 px-6 py-4 border-b border-slate-200/70">
|
| 166 |
+
<h2 className="text-lg font-black text-slate-800">
|
| 167 |
New Worker Enrollment
|
| 168 |
</h2>
|
| 169 |
</div>
|
|
|
|
| 179 |
onChange={(e) =>
|
| 180 |
setFormData({ ...formData, name: e.target.value })
|
| 181 |
}
|
| 182 |
+
className="w-full px-4 py-2.5 bg-white/70 border border-slate-300 rounded-xl text-slate-900 focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 outline-none"
|
| 183 |
placeholder="e.g. John Doe"
|
| 184 |
required
|
| 185 |
/>
|
|
|
|
| 194 |
onChange={(e) =>
|
| 195 |
setFormData({ ...formData, email: e.target.value })
|
| 196 |
}
|
| 197 |
+
className="w-full px-4 py-2.5 bg-white/70 border border-slate-300 rounded-xl text-slate-900 focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 outline-none"
|
| 198 |
placeholder="worker@city.gov"
|
| 199 |
required
|
| 200 |
/>
|
|
|
|
| 211 |
onChange={(e) =>
|
| 212 |
setFormData({ ...formData, password: e.target.value })
|
| 213 |
}
|
| 214 |
+
className="w-full px-4 py-2.5 bg-white/70 border border-slate-300 rounded-xl text-slate-900 focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 outline-none"
|
| 215 |
required
|
| 216 |
minLength={8}
|
| 217 |
placeholder="••••••••"
|
|
|
|
| 227 |
onChange={(e) =>
|
| 228 |
setFormData({ ...formData, department_id: e.target.value })
|
| 229 |
}
|
| 230 |
+
className="w-full px-4 py-2.5 bg-white/70 border border-slate-300 rounded-xl text-slate-900 focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 outline-none"
|
| 231 |
required
|
| 232 |
>
|
| 233 |
<option value="">Select Department...</option>
|
|
|
|
| 243 |
<div className="flex gap-3 pt-2">
|
| 244 |
<button
|
| 245 |
type="submit"
|
| 246 |
+
className="px-6 py-2.5 bg-urban-primary text-white font-semibold rounded-xl hover:bg-emerald-600 transition shadow-sm"
|
| 247 |
>
|
| 248 |
Enroll Worker
|
| 249 |
</button>
|
| 250 |
<button
|
| 251 |
type="button"
|
| 252 |
onClick={() => setShowForm(false)}
|
| 253 |
+
className="px-6 py-2.5 bg-white/80 text-slate-700 font-semibold rounded-xl border border-slate-300 hover:bg-slate-50 transition"
|
| 254 |
>
|
| 255 |
Cancel
|
| 256 |
</button>
|
|
|
|
| 261 |
|
| 262 |
<div className="flex gap-4 mb-4">
|
| 263 |
<div className="relative flex-1 max-w-sm">
|
| 264 |
+
<Search className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
| 265 |
<input
|
| 266 |
type="text"
|
| 267 |
placeholder="Search workers by name or email..."
|
| 268 |
value={search}
|
| 269 |
onChange={(e) => setSearch(e.target.value)}
|
| 270 |
+
className="w-full pl-9 pr-4 py-2.5 bg-white/70 border border-slate-200 rounded-xl text-sm focus:border-urban-primary/40 focus:ring-4 focus:ring-urban-primary/10 outline-none"
|
| 271 |
/>
|
| 272 |
</div>
|
| 273 |
+
<button className="px-3 py-2.5 bg-white/70 border border-slate-200 rounded-xl text-slate-600 flex items-center gap-2 hover:bg-slate-50">
|
| 274 |
<Filter className="w-4 h-4" /> Filter
|
| 275 |
</button>
|
| 276 |
</div>
|
|
|
|
| 283 |
</div>
|
| 284 |
|
| 285 |
{filteredWorkers.length === 0 ? (
|
| 286 |
+
<div className="text-center py-16 bg-white/70 backdrop-blur-md rounded-2xl border border-slate-200/70">
|
| 287 |
<HardHat className="w-12 h-12 mx-auto text-slate-300" />
|
| 288 |
<p className="text-slate-500 mt-2">No field workers found.</p>
|
| 289 |
</div>
|
|
|
|
| 292 |
{filteredWorkers.map((worker) => (
|
| 293 |
<div
|
| 294 |
key={worker.id}
|
| 295 |
+
className="bg-white/80 backdrop-blur-md p-5 rounded-2xl border border-slate-200/70 shadow-urban-sm hover:shadow-urban-md transition-all"
|
| 296 |
>
|
| 297 |
<div className="flex justify-between items-start mb-4">
|
| 298 |
<div className="flex items-center gap-3">
|
| 299 |
+
<div className="w-10 h-10 rounded-full bg-urban-primary/10 flex items-center justify-center text-lg font-bold text-urban-primary border border-urban-primary/20">
|
| 300 |
{worker.name.charAt(0)}
|
| 301 |
</div>
|
| 302 |
<div>
|
|
|
|
| 313 |
)}
|
| 314 |
</div>
|
| 315 |
|
| 316 |
+
<div className="py-3 border-t border-slate-100/70 mb-3 space-y-2">
|
| 317 |
<div className="flex justify-between text-sm">
|
| 318 |
<span className="text-slate-500">Department</span>
|
| 319 |
<span className="font-medium text-slate-900">
|
|
|
|
| 355 |
? "bg-red-500"
|
| 356 |
: worker.current_workload > worker.max_workload * 0.7
|
| 357 |
? "bg-amber-500"
|
| 358 |
+
: "bg-urban-primary"
|
| 359 |
}`}
|
| 360 |
style={{
|
| 361 |
width: `${Math.min(
|
Frontend/app/globals.css
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
@import "tailwindcss";
|
| 2 |
|
| 3 |
@theme {
|
| 4 |
-
--color-urban-primary: #
|
| 5 |
-
--color-urban-secondary: #
|
| 6 |
-
--color-urban-cta: #
|
| 7 |
-
--color-urban-bg: #
|
| 8 |
-
--color-urban-text: #
|
| 9 |
|
| 10 |
--color-slate-50: #f8fafc;
|
| 11 |
--color-slate-100: #f1f5f9;
|
|
@@ -18,14 +18,14 @@
|
|
| 18 |
--color-slate-800: #1e293b;
|
| 19 |
--color-slate-900: #0f172a;
|
| 20 |
--color-slate-950: #020617;
|
| 21 |
-
|
| 22 |
-
--font-fira-code:
|
| 23 |
-
--font-fira-sans:
|
| 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 {
|
|
@@ -37,12 +37,33 @@
|
|
| 37 |
}
|
| 38 |
|
| 39 |
body {
|
| 40 |
-
background:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
color: var(--foreground);
|
| 42 |
font-family: var(--font-fira-sans);
|
| 43 |
}
|
| 44 |
|
| 45 |
-
h1,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
font-family: var(--font-fira-code);
|
| 47 |
}
|
| 48 |
|
|
@@ -62,5 +83,71 @@ input:focus,
|
|
| 62 |
select:focus,
|
| 63 |
textarea:focus {
|
| 64 |
border-color: var(--primary);
|
| 65 |
-
box-shadow: 0 0 0 3px rgba(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
}
|
|
|
|
| 1 |
@import "tailwindcss";
|
| 2 |
|
| 3 |
@theme {
|
| 4 |
+
--color-urban-primary: #0ea5a4;
|
| 5 |
+
--color-urban-secondary: #f59e0b;
|
| 6 |
+
--color-urban-cta: #0f172a;
|
| 7 |
+
--color-urban-bg: #f6f7fb;
|
| 8 |
+
--color-urban-text: #0f172a;
|
| 9 |
|
| 10 |
--color-slate-50: #f8fafc;
|
| 11 |
--color-slate-100: #f1f5f9;
|
|
|
|
| 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 {
|
|
|
|
| 37 |
}
|
| 38 |
|
| 39 |
body {
|
| 40 |
+
background:
|
| 41 |
+
radial-gradient(
|
| 42 |
+
1200px 600px at 10% -10%,
|
| 43 |
+
rgba(14, 165, 164, 0.18),
|
| 44 |
+
transparent 60%
|
| 45 |
+
),
|
| 46 |
+
radial-gradient(
|
| 47 |
+
1000px 500px at 90% -20%,
|
| 48 |
+
rgba(245, 158, 11, 0.18),
|
| 49 |
+
transparent 55%
|
| 50 |
+
),
|
| 51 |
+
radial-gradient(
|
| 52 |
+
800px 400px at 50% 100%,
|
| 53 |
+
rgba(59, 130, 246, 0.12),
|
| 54 |
+
transparent 55%
|
| 55 |
+
),
|
| 56 |
+
var(--background);
|
| 57 |
color: var(--foreground);
|
| 58 |
font-family: var(--font-fira-sans);
|
| 59 |
}
|
| 60 |
|
| 61 |
+
h1,
|
| 62 |
+
h2,
|
| 63 |
+
h3,
|
| 64 |
+
h4,
|
| 65 |
+
h5,
|
| 66 |
+
h6 {
|
| 67 |
font-family: var(--font-fira-code);
|
| 68 |
}
|
| 69 |
|
|
|
|
| 83 |
select:focus,
|
| 84 |
textarea:focus {
|
| 85 |
border-color: var(--primary);
|
| 86 |
+
box-shadow: 0 0 0 3px rgba(14, 165, 164, 0.12);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
::selection {
|
| 90 |
+
background: rgba(14, 165, 164, 0.2);
|
| 91 |
+
color: #0f172a;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.bg-mesh {
|
| 95 |
+
background-image:
|
| 96 |
+
radial-gradient(
|
| 97 |
+
circle at 20% 20%,
|
| 98 |
+
rgba(14, 165, 164, 0.2),
|
| 99 |
+
transparent 45%
|
| 100 |
+
),
|
| 101 |
+
radial-gradient(
|
| 102 |
+
circle at 80% 10%,
|
| 103 |
+
rgba(245, 158, 11, 0.18),
|
| 104 |
+
transparent 45%
|
| 105 |
+
),
|
| 106 |
+
radial-gradient(
|
| 107 |
+
circle at 30% 80%,
|
| 108 |
+
rgba(59, 130, 246, 0.15),
|
| 109 |
+
transparent 45%
|
| 110 |
+
);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.bg-grid {
|
| 114 |
+
background-image:
|
| 115 |
+
linear-gradient(rgba(15, 23, 42, 0.04) 1px, transparent 1px),
|
| 116 |
+
linear-gradient(90deg, rgba(15, 23, 42, 0.04) 1px, transparent 1px);
|
| 117 |
+
background-size: 32px 32px;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.glass-panel {
|
| 121 |
+
background: rgba(255, 255, 255, 0.75);
|
| 122 |
+
backdrop-filter: blur(18px);
|
| 123 |
+
border: 1px solid rgba(148, 163, 184, 0.25);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.fade-up {
|
| 127 |
+
animation: fadeUp 0.7s ease-out both;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.float-slow {
|
| 131 |
+
animation: floatSlow 6s ease-in-out infinite;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
@keyframes fadeUp {
|
| 135 |
+
from {
|
| 136 |
+
opacity: 0;
|
| 137 |
+
transform: translateY(18px);
|
| 138 |
+
}
|
| 139 |
+
to {
|
| 140 |
+
opacity: 1;
|
| 141 |
+
transform: translateY(0);
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
@keyframes floatSlow {
|
| 146 |
+
0%,
|
| 147 |
+
100% {
|
| 148 |
+
transform: translateY(0);
|
| 149 |
+
}
|
| 150 |
+
50% {
|
| 151 |
+
transform: translateY(-10px);
|
| 152 |
+
}
|
| 153 |
}
|
Frontend/app/page.tsx
CHANGED
|
@@ -1,18 +1,18 @@
|
|
| 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 |
|
|
@@ -20,50 +20,45 @@ export default function LandingPage() {
|
|
| 20 |
const { user, role, signOut } = useAuth();
|
| 21 |
|
| 22 |
const getDashboardLink = () => {
|
| 23 |
-
if (role ===
|
| 24 |
-
if (role ===
|
| 25 |
-
return
|
| 26 |
};
|
| 27 |
|
| 28 |
return (
|
| 29 |
-
<div className="min-h-screen
|
| 30 |
-
|
| 31 |
-
<div className="fixed inset-0 z-0 opacity-
|
| 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 |
-
|
| 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-
|
| 45 |
-
<
|
| 46 |
</div>
|
| 47 |
</div>
|
| 48 |
<span className="text-xl font-bold tracking-tight text-slate-900">
|
| 49 |
-
|
| 50 |
</span>
|
| 51 |
</div>
|
| 52 |
-
|
| 53 |
<div className="hidden md:flex items-center gap-8">
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 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 |
-
|
| 67 |
</button>
|
| 68 |
<Link
|
| 69 |
href={getDashboardLink()}
|
|
@@ -79,11 +74,11 @@ export default function LandingPage() {
|
|
| 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 |
-
|
| 83 |
</Link>
|
| 84 |
<Link
|
| 85 |
href="/signup"
|
| 86 |
-
className="group relative px-6 py-2.5 bg-
|
| 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">
|
|
@@ -97,70 +92,123 @@ export default function LandingPage() {
|
|
| 97 |
</div>
|
| 98 |
</nav>
|
| 99 |
|
| 100 |
-
<main className="relative z-10 pt-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
|
|
|
| 126 |
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
</div>
|
| 151 |
</div>
|
| 152 |
</div>
|
| 153 |
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
| 156 |
<div className="text-center mb-16">
|
| 157 |
-
<h2 className="text-3xl font-bold text-slate-900 mb-4">
|
|
|
|
|
|
|
| 158 |
<p className="text-slate-500 max-w-2xl mx-auto text-lg">
|
| 159 |
-
Built on a microservices architecture designed for scale,
|
|
|
|
| 160 |
</p>
|
| 161 |
</div>
|
| 162 |
-
|
| 163 |
-
<div className="grid grid-cols-1
|
| 164 |
<FeatureCard
|
| 165 |
icon={<Radio className="w-8 h-8 text-blue-600" />}
|
| 166 |
title="Geo-Spatial AI"
|
|
@@ -181,13 +229,68 @@ export default function LandingPage() {
|
|
| 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-
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
</div>
|
| 192 |
<p className="text-slate-500 text-sm">
|
| 193 |
© 2026 Dept. of Public Works. Secure. Efficient. Transparent.
|
|
@@ -198,35 +301,56 @@ export default function LandingPage() {
|
|
| 198 |
);
|
| 199 |
}
|
| 200 |
|
| 201 |
-
function NavLink({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
return (
|
| 203 |
-
<Link
|
|
|
|
|
|
|
|
|
|
| 204 |
{children}
|
| 205 |
</Link>
|
| 206 |
-
)
|
| 207 |
}
|
| 208 |
|
| 209 |
-
function StatCard({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">
|
|
|
|
|
|
|
| 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;
|
|
@@ -235,11 +359,17 @@ function FeatureCard({
|
|
| 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
|
|
|
|
|
|
|
| 239 |
{icon}
|
| 240 |
</div>
|
| 241 |
-
<h3 className="text-xl font-bold text-slate-900 mb-3 tracking-tight">
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
</div>
|
| 244 |
);
|
| 245 |
}
|
|
|
|
| 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 |
|
|
|
|
| 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 text-slate-900 font-sans selection:bg-emerald-100 selection:text-slate-900 overflow-x-hidden">
|
| 30 |
+
<div className="fixed inset-0 z-0 opacity-70 pointer-events-none bg-mesh" />
|
| 31 |
+
<div className="fixed inset-0 z-0 opacity-50 pointer-events-none bg-grid" />
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
+
<nav className="fixed top-0 w-full z-50 border-b border-slate-200/70 bg-white/70 backdrop-blur-xl transition-all">
|
|
|
|
| 34 |
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 35 |
<div className="flex justify-between h-20 items-center">
|
| 36 |
<div className="flex items-center gap-3 group cursor-default">
|
| 37 |
<div className="relative">
|
| 38 |
<div className="absolute inset-0 bg-blue-500/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity" />
|
| 39 |
+
<div className="relative w-10 h-10 bg-gradient-to-br from-emerald-500 to-cyan-600 rounded-xl flex items-center justify-center text-white shadow-lg shadow-emerald-500/30">
|
| 40 |
+
<MapPin className="w-5 h-5 text-white" />
|
| 41 |
</div>
|
| 42 |
</div>
|
| 43 |
<span className="text-xl font-bold tracking-tight text-slate-900">
|
| 44 |
+
City<span className="text-emerald-600">Track</span>
|
| 45 |
</span>
|
| 46 |
</div>
|
| 47 |
+
|
| 48 |
<div className="hidden md:flex items-center gap-8">
|
| 49 |
+
<NavLink href="#features">Features</NavLink>
|
| 50 |
+
<NavLink href="#stats">Live Data</NavLink>
|
| 51 |
+
<NavLink href="#roadmap">Roadmap</NavLink>
|
| 52 |
</div>
|
| 53 |
|
| 54 |
<div className="flex gap-4 items-center">
|
| 55 |
{user ? (
|
| 56 |
<>
|
| 57 |
+
<button
|
| 58 |
onClick={() => signOut()}
|
| 59 |
className="hidden sm:flex px-4 py-2 text-slate-500 hover:text-red-600 transition-colors"
|
| 60 |
>
|
| 61 |
+
<LogOut className="w-5 h-5" />
|
| 62 |
</button>
|
| 63 |
<Link
|
| 64 |
href={getDashboardLink()}
|
|
|
|
| 74 |
href="/signin"
|
| 75 |
className="px-5 py-2 text-slate-600 hover:text-slate-900 font-medium transition-colors hover:bg-slate-100 rounded-lg"
|
| 76 |
>
|
| 77 |
+
Login
|
| 78 |
</Link>
|
| 79 |
<Link
|
| 80 |
href="/signup"
|
| 81 |
+
className="group relative px-6 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white font-semibold rounded-lg transition-all shadow-lg shadow-emerald-500/30 overflow-hidden hover:-translate-y-0.5"
|
| 82 |
>
|
| 83 |
<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" />
|
| 84 |
<span className="relative flex items-center gap-2">
|
|
|
|
| 92 |
</div>
|
| 93 |
</nav>
|
| 94 |
|
| 95 |
+
<main className="relative z-10 pt-24">
|
| 96 |
+
<div className="relative border-b border-slate-200/70">
|
| 97 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32">
|
| 98 |
+
<div className="grid gap-12 lg:grid-cols-[1.1fr_0.9fr] items-center">
|
| 99 |
+
<div className="text-center lg:text-left">
|
| 100 |
+
<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 fade-up">
|
| 101 |
+
<span className="relative flex h-2 w-2">
|
| 102 |
+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
| 103 |
+
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
| 104 |
+
</span>
|
| 105 |
+
<span className="text-sm font-bold text-slate-600 font-mono tracking-wide">
|
| 106 |
+
SYSTEM ONLINE
|
| 107 |
+
</span>
|
| 108 |
+
</div>
|
| 109 |
|
| 110 |
+
<h1 className="text-5xl md:text-6xl lg:text-7xl font-extrabold text-slate-900 tracking-tight mb-6 leading-[1.05]">
|
| 111 |
+
City infrastructure,
|
| 112 |
+
<span className="block bg-gradient-to-r from-emerald-600 via-cyan-600 to-blue-600 bg-clip-text text-transparent">
|
| 113 |
+
remapped by intelligence.
|
| 114 |
+
</span>
|
| 115 |
+
</h1>
|
| 116 |
|
| 117 |
+
<p className="text-lg md:text-xl text-slate-600 mb-10 max-w-2xl lg:max-w-none leading-relaxed">
|
| 118 |
+
A civic response system that blends vision AI,
|
| 119 |
+
geo-deduplication, and automated routing to turn every report
|
| 120 |
+
into a verified, trackable action.
|
| 121 |
+
</p>
|
| 122 |
|
| 123 |
+
<div className="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start items-center">
|
| 124 |
+
<Link
|
| 125 |
+
href="/signup"
|
| 126 |
+
className="px-7 py-3.5 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"
|
| 127 |
+
>
|
| 128 |
+
Report an Issue
|
| 129 |
+
<Smartphone className="w-5 h-5" />
|
| 130 |
+
</Link>
|
| 131 |
+
<Link
|
| 132 |
+
href="/signin"
|
| 133 |
+
className="px-7 py-3.5 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"
|
| 134 |
+
>
|
| 135 |
+
Track Status
|
| 136 |
+
<Activity className="w-5 h-5 text-emerald-600" />
|
| 137 |
+
</Link>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
|
| 141 |
+
<div className="glass-panel rounded-3xl p-6 md:p-8 shadow-urban-xl float-slow">
|
| 142 |
+
<div className="flex items-center justify-between mb-6">
|
| 143 |
+
<div>
|
| 144 |
+
<p className="text-xs uppercase tracking-[0.2em] text-slate-500 font-semibold">
|
| 145 |
+
Ops Pulse
|
| 146 |
+
</p>
|
| 147 |
+
<p className="text-2xl font-bold text-slate-900">
|
| 148 |
+
Live Command Board
|
| 149 |
+
</p>
|
| 150 |
+
</div>
|
| 151 |
+
<div className="h-10 w-10 rounded-2xl bg-emerald-100 text-emerald-700 flex items-center justify-center">
|
| 152 |
+
<Activity className="w-5 h-5" />
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
|
| 156 |
+
<div id="stats" className="grid grid-cols-2 gap-4">
|
| 157 |
+
<StatCard
|
| 158 |
+
label="ISSUES RESOLVED"
|
| 159 |
+
value="12,405"
|
| 160 |
+
icon={CheckCircle2}
|
| 161 |
+
color="text-emerald-600 bg-emerald-50"
|
| 162 |
+
/>
|
| 163 |
+
<StatCard
|
| 164 |
+
label="ACTIVE AGENTS"
|
| 165 |
+
value="84"
|
| 166 |
+
icon={Users}
|
| 167 |
+
color="text-cyan-600 bg-cyan-50"
|
| 168 |
+
/>
|
| 169 |
+
<StatCard
|
| 170 |
+
label="AVG. REACTION"
|
| 171 |
+
value="1.2hrs"
|
| 172 |
+
icon={Zap}
|
| 173 |
+
color="text-amber-600 bg-amber-50"
|
| 174 |
+
/>
|
| 175 |
+
<StatCard
|
| 176 |
+
label="CITIES LIVE"
|
| 177 |
+
value="3"
|
| 178 |
+
icon={Building2}
|
| 179 |
+
color="text-slate-700 bg-slate-100"
|
| 180 |
+
/>
|
| 181 |
+
</div>
|
| 182 |
+
|
| 183 |
+
<div className="mt-6 rounded-2xl bg-slate-900 text-white p-4">
|
| 184 |
+
<div className="flex items-center justify-between text-xs uppercase tracking-[0.2em] text-slate-300">
|
| 185 |
+
<span>Routing</span>
|
| 186 |
+
<span className="text-emerald-400">Auto-assign</span>
|
| 187 |
+
</div>
|
| 188 |
+
<p className="mt-3 text-sm text-slate-200">
|
| 189 |
+
18 active tasks prioritized and dispatched to nearest teams.
|
| 190 |
+
</p>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
</div>
|
| 194 |
</div>
|
| 195 |
</div>
|
| 196 |
|
| 197 |
+
<div
|
| 198 |
+
id="features"
|
| 199 |
+
className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-28"
|
| 200 |
+
>
|
| 201 |
<div className="text-center mb-16">
|
| 202 |
+
<h2 className="text-3xl md:text-4xl font-bold text-slate-900 mb-4">
|
| 203 |
+
Core Capabilities
|
| 204 |
+
</h2>
|
| 205 |
<p className="text-slate-500 max-w-2xl mx-auto text-lg">
|
| 206 |
+
Built on a microservices architecture designed for scale,
|
| 207 |
+
security, and speed.
|
| 208 |
</p>
|
| 209 |
</div>
|
| 210 |
+
|
| 211 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
| 212 |
<FeatureCard
|
| 213 |
icon={<Radio className="w-8 h-8 text-blue-600" />}
|
| 214 |
title="Geo-Spatial AI"
|
|
|
|
| 229 |
/>
|
| 230 |
</div>
|
| 231 |
</div>
|
| 232 |
+
|
| 233 |
+
<div
|
| 234 |
+
id="roadmap"
|
| 235 |
+
className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-28"
|
| 236 |
+
>
|
| 237 |
+
<div className="glass-panel rounded-3xl p-8 md:p-12">
|
| 238 |
+
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
| 239 |
+
<div>
|
| 240 |
+
<p className="text-xs uppercase tracking-[0.2em] text-slate-500 font-semibold">
|
| 241 |
+
Roadmap
|
| 242 |
+
</p>
|
| 243 |
+
<h3 className="text-3xl font-bold text-slate-900 mt-2">
|
| 244 |
+
Next deployments
|
| 245 |
+
</h3>
|
| 246 |
+
<p className="text-slate-500 mt-3 max-w-xl">
|
| 247 |
+
Expanding to new municipal zones with predictive maintenance,
|
| 248 |
+
citizen feedback loops, and real-time response SLAs.
|
| 249 |
+
</p>
|
| 250 |
+
</div>
|
| 251 |
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 w-full md:w-auto">
|
| 252 |
+
<div className="rounded-2xl bg-white border border-slate-200 p-4">
|
| 253 |
+
<p className="text-xs uppercase tracking-wide text-slate-400 font-semibold">
|
| 254 |
+
Phase 1
|
| 255 |
+
</p>
|
| 256 |
+
<p className="text-lg font-bold text-slate-900 mt-2">
|
| 257 |
+
North Zone
|
| 258 |
+
</p>
|
| 259 |
+
<p className="text-sm text-slate-500 mt-1">Q2 rollout</p>
|
| 260 |
+
</div>
|
| 261 |
+
<div className="rounded-2xl bg-white border border-slate-200 p-4">
|
| 262 |
+
<p className="text-xs uppercase tracking-wide text-slate-400 font-semibold">
|
| 263 |
+
Phase 2
|
| 264 |
+
</p>
|
| 265 |
+
<p className="text-lg font-bold text-slate-900 mt-2">
|
| 266 |
+
Predictive SLA
|
| 267 |
+
</p>
|
| 268 |
+
<p className="text-sm text-slate-500 mt-1">Q3 launch</p>
|
| 269 |
+
</div>
|
| 270 |
+
<div className="rounded-2xl bg-white border border-slate-200 p-4">
|
| 271 |
+
<p className="text-xs uppercase tracking-wide text-slate-400 font-semibold">
|
| 272 |
+
Phase 3
|
| 273 |
+
</p>
|
| 274 |
+
<p className="text-lg font-bold text-slate-900 mt-2">
|
| 275 |
+
Citizen Portal
|
| 276 |
+
</p>
|
| 277 |
+
<p className="text-sm text-slate-500 mt-1">Q4 release</p>
|
| 278 |
+
</div>
|
| 279 |
+
</div>
|
| 280 |
+
</div>
|
| 281 |
+
</div>
|
| 282 |
+
</div>
|
| 283 |
</main>
|
| 284 |
|
| 285 |
+
<footer className="border-t border-slate-200 bg-white/80 py-12 relative z-10">
|
| 286 |
<div className="max-w-7xl mx-auto px-4 text-center">
|
| 287 |
<div className="flex justify-center items-center gap-2 mb-8 opacity-75 hover:opacity-100 transition-opacity">
|
| 288 |
+
<div className="w-6 h-6 bg-slate-900 rounded-md flex items-center justify-center text-white">
|
| 289 |
+
<MapPin className="w-3.5 h-3.5" />
|
| 290 |
+
</div>
|
| 291 |
+
<span className="text-sm font-bold tracking-wide text-slate-900">
|
| 292 |
+
CityTracker SYSTEMS
|
| 293 |
+
</span>
|
| 294 |
</div>
|
| 295 |
<p className="text-slate-500 text-sm">
|
| 296 |
© 2026 Dept. of Public Works. Secure. Efficient. Transparent.
|
|
|
|
| 301 |
);
|
| 302 |
}
|
| 303 |
|
| 304 |
+
function NavLink({
|
| 305 |
+
href,
|
| 306 |
+
children,
|
| 307 |
+
}: {
|
| 308 |
+
href: string;
|
| 309 |
+
children: React.ReactNode;
|
| 310 |
+
}) {
|
| 311 |
return (
|
| 312 |
+
<Link
|
| 313 |
+
href={href}
|
| 314 |
+
className="text-sm font-medium text-slate-500 hover:text-emerald-600 transition-colors"
|
| 315 |
+
>
|
| 316 |
{children}
|
| 317 |
</Link>
|
| 318 |
+
);
|
| 319 |
}
|
| 320 |
|
| 321 |
+
function StatCard({
|
| 322 |
+
label,
|
| 323 |
+
value,
|
| 324 |
+
icon: Icon,
|
| 325 |
+
color,
|
| 326 |
+
}: {
|
| 327 |
+
label: string;
|
| 328 |
+
value: string;
|
| 329 |
+
icon: any;
|
| 330 |
+
color: string;
|
| 331 |
+
}) {
|
| 332 |
return (
|
| 333 |
<div className="p-4 rounded-2xl bg-white border border-slate-200 shadow-sm hover:shadow-md hover:border-slate-300 transition-all group">
|
| 334 |
<div className="flex items-center gap-3 mb-2">
|
| 335 |
<div className={`p-1.5 rounded-lg ${color}`}>
|
| 336 |
<Icon className="w-4 h-4" />
|
| 337 |
</div>
|
| 338 |
+
<span className="text-xs font-mono text-slate-500 font-bold tracking-wider">
|
| 339 |
+
{label}
|
| 340 |
+
</span>
|
| 341 |
</div>
|
| 342 |
<div className="text-2xl font-bold text-slate-900 group-hover:scale-105 transition-transform origin-left font-mono">
|
| 343 |
{value}
|
| 344 |
</div>
|
| 345 |
</div>
|
| 346 |
+
);
|
| 347 |
}
|
| 348 |
|
| 349 |
function FeatureCard({
|
| 350 |
icon,
|
| 351 |
title,
|
| 352 |
desc,
|
| 353 |
+
colorClass,
|
| 354 |
}: {
|
| 355 |
icon: React.ReactNode;
|
| 356 |
title: string;
|
|
|
|
| 359 |
}) {
|
| 360 |
return (
|
| 361 |
<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">
|
| 362 |
+
<div
|
| 363 |
+
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`}
|
| 364 |
+
>
|
| 365 |
{icon}
|
| 366 |
</div>
|
| 367 |
+
<h3 className="text-xl font-bold text-slate-900 mb-3 tracking-tight">
|
| 368 |
+
{title}
|
| 369 |
+
</h3>
|
| 370 |
+
<p className="text-slate-500 leading-relaxed text-sm font-medium">
|
| 371 |
+
{desc}
|
| 372 |
+
</p>
|
| 373 |
</div>
|
| 374 |
);
|
| 375 |
}
|
Frontend/app/user/issues/[id]/page.tsx
CHANGED
|
@@ -1,16 +1,13 @@
|
|
| 1 |
"use client";
|
| 2 |
-
import {
|
| 3 |
-
import { useParams, useRouter } from "next/navigation";
|
| 4 |
|
| 5 |
export const runtime = "edge";
|
| 6 |
-
import {
|
|
|
|
| 7 |
import {
|
| 8 |
ArrowLeft,
|
| 9 |
MapPin,
|
| 10 |
-
Calendar,
|
| 11 |
-
CheckCircle2,
|
| 12 |
Clock,
|
| 13 |
-
AlertTriangle,
|
| 14 |
ImageIcon,
|
| 15 |
Activity,
|
| 16 |
Maximize2,
|
|
@@ -28,45 +25,40 @@ interface Issue {
|
|
| 28 |
id: string;
|
| 29 |
description: string;
|
| 30 |
state: string;
|
| 31 |
-
city: string;
|
| 32 |
-
locality: string;
|
| 33 |
created_at: string;
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
}
|
| 45 |
|
| 46 |
export default function UserIssueDetailPage() {
|
| 47 |
const params = useParams();
|
| 48 |
-
const
|
| 49 |
-
const
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 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> = {
|
|
@@ -76,6 +68,10 @@ export default function UserIssueDetailPage() {
|
|
| 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
|
|
@@ -86,287 +82,303 @@ export default function UserIssueDetailPage() {
|
|
| 86 |
);
|
| 87 |
};
|
| 88 |
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
return (
|
| 91 |
-
<div className="min-h-screen bg-
|
| 92 |
-
<div className="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
</div>
|
| 94 |
);
|
| 95 |
}
|
| 96 |
|
| 97 |
if (!issue) {
|
| 98 |
return (
|
| 99 |
-
<div className="min-h-screen bg-
|
| 100 |
<h2 className="text-xl font-bold text-slate-700">Issue Not Found</h2>
|
| 101 |
-
<Link
|
|
|
|
|
|
|
|
|
|
| 102 |
Return to Dashboard
|
| 103 |
</Link>
|
| 104 |
</div>
|
| 105 |
);
|
| 106 |
}
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
return (
|
| 109 |
-
<div className="min-h-screen bg-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
-
<main className="relative z-10 max-w-
|
| 117 |
{/* Header */}
|
| 118 |
-
<div className="mb-8">
|
| 119 |
-
<
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 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-
|
| 134 |
{issue.category || "Reported Issue"}
|
| 135 |
</h1>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
</div>
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
| 141 |
</div>
|
| 142 |
<div
|
| 143 |
-
className={`text-
|
| 144 |
issue.priority === 1
|
| 145 |
-
? "text-red-
|
| 146 |
: issue.priority === 2
|
| 147 |
-
? "text-orange-
|
| 148 |
-
: "text-
|
| 149 |
}`}
|
| 150 |
>
|
| 151 |
{issue.priority === 1
|
| 152 |
? "Critical"
|
| 153 |
: issue.priority === 2
|
| 154 |
-
? "High"
|
| 155 |
-
: "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
</div>
|
| 157 |
</div>
|
| 158 |
)}
|
| 159 |
</div>
|
| 160 |
</div>
|
| 161 |
|
| 162 |
-
<div className="
|
| 163 |
-
{/*
|
| 164 |
-
<div className="
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 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 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
</p>
|
| 215 |
-
<p className="text-xs text-slate-500 mt-1">{issue.city}</p>
|
| 216 |
</div>
|
| 217 |
-
|
| 218 |
-
</div>
|
| 219 |
</div>
|
|
|
|
| 220 |
|
| 221 |
-
|
| 222 |
-
<div className="
|
| 223 |
-
<div className="
|
| 224 |
-
<
|
| 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 |
-
|
| 233 |
-
|
| 234 |
<div className="space-y-3">
|
| 235 |
-
<
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 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 |
-
|
| 268 |
<div className="space-y-3">
|
| 269 |
-
<
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
<
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 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 |
-
|
| 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 |
-
|
| 329 |
-
|
| 330 |
-
<
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
<
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
<
|
| 340 |
-
{new Date(issue.created_at).toLocaleString()}
|
| 341 |
-
</span>
|
| 342 |
</div>
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
<div className="
|
| 347 |
-
<
|
| 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 |
-
|
| 368 |
</div>
|
| 369 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
</div>
|
| 371 |
</main>
|
| 372 |
</div>
|
|
|
|
| 1 |
"use client";
|
| 2 |
+
import { useParams } from "next/navigation";
|
|
|
|
| 3 |
|
| 4 |
export const runtime = "edge";
|
| 5 |
+
import { useCachedFetch } from "@/hooks/useCachedFetch";
|
| 6 |
+
import { Skeleton } from "@/components/ui/Skeleton";
|
| 7 |
import {
|
| 8 |
ArrowLeft,
|
| 9 |
MapPin,
|
|
|
|
|
|
|
| 10 |
Clock,
|
|
|
|
| 11 |
ImageIcon,
|
| 12 |
Activity,
|
| 13 |
Maximize2,
|
|
|
|
| 25 |
id: string;
|
| 26 |
description: string;
|
| 27 |
state: string;
|
| 28 |
+
city: string | null;
|
| 29 |
+
locality: string | null;
|
| 30 |
created_at: string;
|
| 31 |
+
updated_at: string;
|
| 32 |
+
full_address: string | null;
|
| 33 |
+
priority: number | null;
|
| 34 |
+
priority_reason: string | null;
|
| 35 |
+
category: string | null;
|
| 36 |
+
confidence: number | null;
|
| 37 |
+
detections_count: number | null;
|
| 38 |
+
validation_source: string | null;
|
| 39 |
+
geo_status: string | null;
|
| 40 |
+
is_duplicate: boolean | null;
|
| 41 |
+
parent_issue_id: string | null;
|
| 42 |
+
nearby_count: number | null;
|
| 43 |
+
department: string | null;
|
| 44 |
+
assigned_member: string | null;
|
| 45 |
+
image_urls: string[] | null;
|
| 46 |
+
annotated_urls: string[] | null;
|
| 47 |
+
proof_image_url: string | null;
|
| 48 |
+
sla_hours: number | null;
|
| 49 |
+
sla_deadline: string | null;
|
| 50 |
+
agent_flow: IssueEvent[] | null;
|
| 51 |
+
latitude: number | null;
|
| 52 |
+
longitude: number | null;
|
| 53 |
}
|
| 54 |
|
| 55 |
export default function UserIssueDetailPage() {
|
| 56 |
const params = useParams();
|
| 57 |
+
const issueId = typeof params.id === "string" ? params.id : params.id?.[0];
|
| 58 |
+
const { data: issue, loading } = useCachedFetch<Issue>(
|
| 59 |
+
issueId ? `/issues/${issueId}` : "",
|
| 60 |
+
);
|
| 61 |
+
const contentLoading = loading && !issue;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
const getStateBadge = (state: string) => {
|
| 64 |
const styles: Record<string, string> = {
|
|
|
|
| 68 |
resolved: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
| 69 |
closed: "bg-slate-100 text-slate-600 border-slate-200",
|
| 70 |
rejected: "bg-red-100 text-red-800 border-red-200",
|
| 71 |
+
escalated: "bg-red-100 text-red-800 border-red-200",
|
| 72 |
+
validated: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
| 73 |
+
pending_confirmation: "bg-slate-200 text-slate-700 border-slate-300",
|
| 74 |
+
pending_verification: "bg-indigo-100 text-indigo-800 border-indigo-200",
|
| 75 |
};
|
| 76 |
return (
|
| 77 |
<span
|
|
|
|
| 82 |
);
|
| 83 |
};
|
| 84 |
|
| 85 |
+
const formatText = (value: string | null | undefined) =>
|
| 86 |
+
value && value.trim() ? value : "—";
|
| 87 |
+
|
| 88 |
+
const formatDate = (value: string | null | undefined) =>
|
| 89 |
+
value ? new Date(value).toLocaleString() : "—";
|
| 90 |
+
|
| 91 |
+
const formatBool = (value: boolean | null | undefined) =>
|
| 92 |
+
value === null || value === undefined ? "—" : value ? "Yes" : "No";
|
| 93 |
+
|
| 94 |
+
const formatNumber = (value: number | null | undefined) =>
|
| 95 |
+
value === null || value === undefined ? "—" : value.toString();
|
| 96 |
+
|
| 97 |
+
const formatConfidence = (value: number | null | undefined) =>
|
| 98 |
+
value === null || value === undefined
|
| 99 |
+
? "—"
|
| 100 |
+
: `${(value * 100).toFixed(1)}%`;
|
| 101 |
+
|
| 102 |
+
const formatCoords = (value: number | null | undefined) =>
|
| 103 |
+
value === null || value === undefined ? "—" : value.toFixed(6);
|
| 104 |
+
|
| 105 |
+
if (contentLoading) {
|
| 106 |
return (
|
| 107 |
+
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
|
| 108 |
+
<div className="w-full max-w-7xl px-4 sm:px-6 lg:px-8">
|
| 109 |
+
<Skeleton className="h-20 w-full mb-8 rounded-3xl" />
|
| 110 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-10">
|
| 111 |
+
<div className="lg:col-span-2 space-y-8">
|
| 112 |
+
<Skeleton className="h-100 w-full rounded-3xl" />
|
| 113 |
+
<Skeleton className="h-50 w-full rounded-3xl" />
|
| 114 |
+
</div>
|
| 115 |
+
<Skeleton className="h-150 w-full rounded-3xl" />
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
</div>
|
| 119 |
);
|
| 120 |
}
|
| 121 |
|
| 122 |
if (!issue) {
|
| 123 |
return (
|
| 124 |
+
<div className="min-h-screen bg-transparent p-8 flex flex-col items-center justify-center">
|
| 125 |
<h2 className="text-xl font-bold text-slate-700">Issue Not Found</h2>
|
| 126 |
+
<Link
|
| 127 |
+
href="/user"
|
| 128 |
+
className="mt-4 text-urban-primary hover:underline font-bold"
|
| 129 |
+
>
|
| 130 |
Return to Dashboard
|
| 131 |
</Link>
|
| 132 |
</div>
|
| 133 |
);
|
| 134 |
}
|
| 135 |
|
| 136 |
+
const addressText =
|
| 137 |
+
issue.full_address ||
|
| 138 |
+
[issue.locality, issue.city].filter(Boolean).join(", ") ||
|
| 139 |
+
"—";
|
| 140 |
+
|
| 141 |
+
const dataRows = [
|
| 142 |
+
{ label: "Issue ID", value: issue.id },
|
| 143 |
+
{ label: "State", value: formatText(issue.state) },
|
| 144 |
+
{ label: "Category", value: formatText(issue.category) },
|
| 145 |
+
{ label: "Description", value: formatText(issue.description) },
|
| 146 |
+
{ label: "Full Address", value: addressText },
|
| 147 |
+
{ label: "Locality", value: formatText(issue.locality) },
|
| 148 |
+
{ label: "City", value: formatText(issue.city) },
|
| 149 |
+
{ label: "Priority", value: formatNumber(issue.priority) },
|
| 150 |
+
{ label: "Priority Reason", value: formatText(issue.priority_reason) },
|
| 151 |
+
{ label: "Confidence", value: formatConfidence(issue.confidence) },
|
| 152 |
+
{ label: "Detections", value: formatNumber(issue.detections_count) },
|
| 153 |
+
{ label: "Validation Source", value: formatText(issue.validation_source) },
|
| 154 |
+
{ label: "Geo Status", value: formatText(issue.geo_status) },
|
| 155 |
+
{ label: "Duplicate", value: formatBool(issue.is_duplicate) },
|
| 156 |
+
{ label: "Parent Issue", value: formatText(issue.parent_issue_id) },
|
| 157 |
+
{ label: "Nearby Count", value: formatNumber(issue.nearby_count) },
|
| 158 |
+
{ label: "Department", value: formatText(issue.department) },
|
| 159 |
+
{ label: "Assigned Member", value: formatText(issue.assigned_member) },
|
| 160 |
+
{ label: "SLA Hours", value: formatNumber(issue.sla_hours) },
|
| 161 |
+
{ label: "SLA Deadline", value: formatDate(issue.sla_deadline) },
|
| 162 |
+
{ label: "Created", value: formatDate(issue.created_at) },
|
| 163 |
+
{ label: "Updated", value: formatDate(issue.updated_at) },
|
| 164 |
+
{ label: "Latitude", value: formatCoords(issue.latitude) },
|
| 165 |
+
{ label: "Longitude", value: formatCoords(issue.longitude) },
|
| 166 |
+
];
|
| 167 |
+
|
| 168 |
return (
|
| 169 |
+
<div className="min-h-screen bg-transparent font-sans pb-20">
|
| 170 |
+
<nav className="bg-white/80 backdrop-blur-md border-b border-slate-200 sticky top-0 z-50">
|
| 171 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 172 |
+
<div className="flex justify-between h-20 items-center">
|
| 173 |
+
<Link href="/user" className="flex items-center gap-2 group">
|
| 174 |
+
<div className="w-10 h-10 bg-slate-100 rounded-xl flex items-center justify-center group-hover:bg-urban-primary transition-colors">
|
| 175 |
+
<ArrowLeft className="w-5 h-5 text-slate-600 group-hover:text-white" />
|
| 176 |
+
</div>
|
| 177 |
+
<div>
|
| 178 |
+
<h1 className="text-xl font-bold text-slate-900 leading-tight">
|
| 179 |
+
Issue Details
|
| 180 |
+
</h1>
|
| 181 |
+
<p className="text-[10px] font-bold text-urban-primary uppercase tracking-widest">
|
| 182 |
+
Back to Dashboard
|
| 183 |
+
</p>
|
| 184 |
+
</div>
|
| 185 |
+
</Link>
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
</nav>
|
| 189 |
|
| 190 |
+
<main className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
| 191 |
{/* Header */}
|
| 192 |
+
<div className="mb-10 bg-white/50 backdrop-blur-sm p-8 rounded-3xl border border-white shadow-xl shadow-slate-200/50">
|
| 193 |
+
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-8">
|
| 194 |
+
<div className="flex-1">
|
| 195 |
+
<div className="flex flex-wrap items-center gap-4 mb-4">
|
| 196 |
+
<span className="font-mono text-xs font-bold text-slate-400 bg-slate-100 px-3 py-1.5 rounded-full border border-slate-200 uppercase tracking-tighter">
|
| 197 |
+
ID: {issue.id.slice(0, 12)}...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
</span>
|
| 199 |
{getStateBadge(issue.state)}
|
| 200 |
+
<span className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] px-3 py-1 border-l border-slate-200">
|
| 201 |
+
{issue.category || "General"}
|
| 202 |
+
</span>
|
| 203 |
</div>
|
| 204 |
+
<h1 className="text-3xl md:text-4xl font-black text-slate-900 tracking-tight leading-tight uppercase">
|
| 205 |
{issue.category || "Reported Issue"}
|
| 206 |
</h1>
|
| 207 |
+
<p className="text-slate-600 font-medium text-lg mt-3 max-w-3xl">
|
| 208 |
+
{issue.description || "No description provided."}
|
| 209 |
+
</p>
|
| 210 |
+
<div className="flex flex-wrap items-center gap-6 mt-6">
|
| 211 |
+
<div className="flex items-center gap-2 text-slate-500 font-medium bg-slate-50 px-3 py-1.5 rounded-xl border border-slate-100">
|
| 212 |
+
<MapPin className="w-4 h-4 text-urban-primary" />
|
| 213 |
+
<span className="text-sm">{addressText}</span>
|
| 214 |
+
</div>
|
| 215 |
+
<div className="flex items-center gap-2 text-slate-500 font-medium bg-slate-50 px-3 py-1.5 rounded-xl border border-slate-100">
|
| 216 |
+
<Clock className="w-4 h-4 text-amber-500" />
|
| 217 |
+
<span className="text-sm">
|
| 218 |
+
Reported {new Date(issue.created_at).toLocaleDateString()}
|
| 219 |
+
</span>
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
</div>
|
| 223 |
+
|
| 224 |
+
{issue.priority !== null && (
|
| 225 |
+
<div className="flex flex-col items-center lg:items-end gap-3 p-6 bg-white/70 backdrop-blur-sm rounded-2xl border border-slate-200 shadow-sm min-w-50">
|
| 226 |
+
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
| 227 |
+
Priority Rating
|
| 228 |
</div>
|
| 229 |
<div
|
| 230 |
+
className={`text-2xl font-black ${
|
| 231 |
issue.priority === 1
|
| 232 |
+
? "text-red-500"
|
| 233 |
: issue.priority === 2
|
| 234 |
+
? "text-orange-500"
|
| 235 |
+
: "text-emerald-500"
|
| 236 |
}`}
|
| 237 |
>
|
| 238 |
{issue.priority === 1
|
| 239 |
? "Critical"
|
| 240 |
: issue.priority === 2
|
| 241 |
+
? "High Priority"
|
| 242 |
+
: "Standard"}
|
| 243 |
+
</div>
|
| 244 |
+
<div className="w-full h-1.5 bg-slate-100 rounded-full mt-2 overflow-hidden">
|
| 245 |
+
<div
|
| 246 |
+
className={`h-full transition-all duration-1000 ${
|
| 247 |
+
issue.priority === 1
|
| 248 |
+
? "bg-red-500 w-full"
|
| 249 |
+
: issue.priority === 2
|
| 250 |
+
? "bg-orange-500 w-2/3"
|
| 251 |
+
: "bg-emerald-500 w-1/3"
|
| 252 |
+
}`}
|
| 253 |
+
/>
|
| 254 |
</div>
|
| 255 |
</div>
|
| 256 |
)}
|
| 257 |
</div>
|
| 258 |
</div>
|
| 259 |
|
| 260 |
+
<div className="max-w-5xl mx-auto space-y-10">
|
| 261 |
+
{/* Metadata Grid */}
|
| 262 |
+
<div className="bg-white/70 backdrop-blur-md rounded-3xl border border-slate-200 p-8 shadow-sm">
|
| 263 |
+
<div className="flex items-center gap-3 mb-8">
|
| 264 |
+
<div className="w-10 h-10 bg-urban-primary/10 rounded-xl flex items-center justify-center">
|
| 265 |
+
<Activity className="w-5 h-5 text-urban-primary" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
</div>
|
| 267 |
+
<h2 className="text-xl font-black text-slate-900 uppercase tracking-tight">
|
| 268 |
+
System Analysis
|
| 269 |
+
</h2>
|
| 270 |
+
</div>
|
| 271 |
|
| 272 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8">
|
| 273 |
+
{dataRows
|
| 274 |
+
.filter(
|
| 275 |
+
(row) =>
|
| 276 |
+
row.value !== "—" &&
|
| 277 |
+
row.value !== "" &&
|
| 278 |
+
row.value !== null &&
|
| 279 |
+
row.value !== undefined,
|
| 280 |
+
)
|
| 281 |
+
.map((row, i) => (
|
| 282 |
+
<div key={i} className="group">
|
| 283 |
+
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1 group-hover:text-urban-primary transition-colors">
|
| 284 |
+
{row.label}
|
| 285 |
+
</label>
|
| 286 |
+
<p
|
| 287 |
+
className="text-sm font-bold text-slate-700 truncate"
|
| 288 |
+
title={row.value?.toString()}
|
| 289 |
+
>
|
| 290 |
+
{row.value}
|
| 291 |
</p>
|
|
|
|
| 292 |
</div>
|
| 293 |
+
))}
|
|
|
|
| 294 |
</div>
|
| 295 |
+
</div>
|
| 296 |
|
| 297 |
+
<div className="bg-white/70 backdrop-blur-md rounded-3xl border border-slate-200 p-8 shadow-sm">
|
| 298 |
+
<div className="flex items-center gap-3 mb-8">
|
| 299 |
+
<div className="w-10 h-10 bg-purple-100 rounded-xl flex items-center justify-center">
|
| 300 |
+
<ImageIcon className="w-5 h-5 text-purple-600" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
</div>
|
| 302 |
+
<h2 className="text-xl font-black text-slate-900 uppercase tracking-tight">
|
| 303 |
+
Visual Evidence
|
| 304 |
+
</h2>
|
| 305 |
+
</div>
|
| 306 |
|
| 307 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
| 308 |
+
{issue.image_urls?.[0] ? (
|
| 309 |
<div className="space-y-3">
|
| 310 |
+
<p className="text-xs font-black text-slate-400 uppercase tracking-widest">
|
| 311 |
+
Original Capture
|
| 312 |
+
</p>
|
| 313 |
+
<div className="relative aspect-video rounded-2xl overflow-hidden border border-slate-200 group">
|
| 314 |
+
<img
|
| 315 |
+
src={issue.image_urls[0]}
|
| 316 |
+
alt="Evidence"
|
| 317 |
+
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
| 318 |
+
/>
|
| 319 |
+
<div className="absolute inset-0 bg-linear-to-t from-black/40 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
</div>
|
| 321 |
</div>
|
| 322 |
+
) : (
|
| 323 |
+
<div className="h-40 bg-slate-50 rounded-2xl border-2 border-dashed border-slate-200 flex flex-col items-center justify-center text-slate-400">
|
| 324 |
+
<ImageIcon className="w-8 h-8 mb-2 opacity-20" />
|
| 325 |
+
<p className="text-xs font-bold">No evidence attached</p>
|
| 326 |
+
</div>
|
| 327 |
+
)}
|
| 328 |
|
| 329 |
+
{issue.annotated_urls?.[0] && (
|
| 330 |
<div className="space-y-3">
|
| 331 |
+
<p className="text-xs font-black text-urban-primary uppercase tracking-widest">
|
| 332 |
+
AI Vision Analysis
|
| 333 |
+
</p>
|
| 334 |
+
<div className="relative aspect-video rounded-2xl overflow-hidden border border-urban-primary/30 group">
|
| 335 |
+
<img
|
| 336 |
+
src={issue.annotated_urls[0]}
|
| 337 |
+
alt="AI Analysis"
|
| 338 |
+
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
| 339 |
+
/>
|
| 340 |
+
<div className="absolute inset-0 bg-linear-to-t from-urban-primary/40 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
</div>
|
| 342 |
</div>
|
| 343 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
|
| 345 |
+
{issue.proof_image_url ? (
|
| 346 |
+
<div className="space-y-3">
|
| 347 |
+
<p className="text-xs font-black text-slate-400 uppercase tracking-widest">
|
| 348 |
+
Resolution Proof
|
| 349 |
+
</p>
|
| 350 |
+
<div className="relative aspect-video rounded-2xl overflow-hidden border border-emerald-200 group">
|
| 351 |
+
<img
|
| 352 |
+
src={issue.proof_image_url}
|
| 353 |
+
alt="Proof"
|
| 354 |
+
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
| 355 |
+
/>
|
| 356 |
+
<div className="absolute inset-0 bg-linear-to-t from-emerald-900/40 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
|
|
|
|
|
| 357 |
</div>
|
| 358 |
+
</div>
|
| 359 |
+
) : (
|
| 360 |
+
<div className="h-40 bg-slate-50 rounded-2xl border-2 border-dashed border-slate-200 flex flex-col items-center justify-center text-slate-400">
|
| 361 |
+
<div className="w-8 h-8 bg-slate-200 rounded-full flex items-center justify-center mb-2 animate-pulse">
|
| 362 |
+
<Clock className="w-4 h-4 text-slate-400" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
</div>
|
| 364 |
+
<p className="text-xs font-bold">Awaiting resolution proof</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
</div>
|
| 366 |
+
)}
|
| 367 |
</div>
|
| 368 |
</div>
|
| 369 |
+
|
| 370 |
+
<div className="bg-white rounded-3xl p-8 border border-slate-200 shadow-sm max-w-2xl mx-auto text-center">
|
| 371 |
+
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest mb-4">
|
| 372 |
+
Support
|
| 373 |
+
</h3>
|
| 374 |
+
<p className="text-sm text-slate-600 mb-6 font-medium leading-relaxed">
|
| 375 |
+
If this issue remains unresolved or was flagged incorrectly,
|
| 376 |
+
please contact your local department office.
|
| 377 |
+
</p>
|
| 378 |
+
<button className="w-full md:w-auto px-12 py-4 bg-slate-100 hover:bg-slate-200 text-slate-900 text-xs font-black rounded-2xl uppercase tracking-widest transition-all">
|
| 379 |
+
Contact Support
|
| 380 |
+
</button>
|
| 381 |
+
</div>
|
| 382 |
</div>
|
| 383 |
</main>
|
| 384 |
</div>
|
Frontend/app/user/page.tsx
CHANGED
|
@@ -1,28 +1,26 @@
|
|
| 1 |
"use client";
|
| 2 |
-
import { useEffect
|
|
|
|
| 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 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
images?: { file_path: string; annotated_path: string }[];
|
| 26 |
}
|
| 27 |
|
| 28 |
interface IssuesResponse {
|
|
@@ -43,12 +41,11 @@ export default function UserDashboard() {
|
|
| 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 } =
|
|
|
|
| 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> = {
|
|
@@ -57,6 +54,10 @@ export default function UserDashboard() {
|
|
| 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
|
|
@@ -76,24 +77,38 @@ export default function UserDashboard() {
|
|
| 76 |
}
|
| 77 |
|
| 78 |
return (
|
| 79 |
-
<div className="min-h-screen bg-
|
| 80 |
-
<nav className="bg-white border-b border-slate-200 sticky top-0 z-50">
|
| 81 |
-
<div className="max-w-
|
| 82 |
-
<div className="flex justify-between h-
|
| 83 |
-
<
|
| 84 |
-
<
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
</p>
|
| 92 |
-
<p className="text-xs text-slate-500">{user?.email}</p>
|
| 93 |
</div>
|
| 94 |
<button
|
| 95 |
onClick={signOut}
|
| 96 |
-
className="px-
|
| 97 |
>
|
| 98 |
Sign Out
|
| 99 |
</button>
|
|
@@ -102,114 +117,203 @@ export default function UserDashboard() {
|
|
| 102 |
</div>
|
| 103 |
</nav>
|
| 104 |
|
| 105 |
-
<main className="max-w-
|
| 106 |
-
<div className="mb-
|
| 107 |
-
<div className="
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
<h3 className="text-blue-900 font-bold text-lg mb-1">
|
| 112 |
-
Make a New Report
|
| 113 |
</h3>
|
| 114 |
-
<p className="text-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
<strong>City Issue Mobile App</strong>.
|
| 118 |
</p>
|
| 119 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
</div>
|
| 121 |
|
| 122 |
-
<div className="grid grid-cols-1
|
| 123 |
-
<div className="p-6 bg-white rounded-
|
| 124 |
-
<p className="text-slate-500 text-
|
| 125 |
Total Reports
|
| 126 |
</p>
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
</div>
|
| 131 |
-
<div className="p-6 bg-white rounded-
|
| 132 |
-
<p className="text-
|
| 133 |
Resolved
|
| 134 |
</p>
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
</div>
|
| 139 |
-
<div className="p-6 bg-white rounded-
|
| 140 |
-
<p className="text-
|
| 141 |
-
|
| 142 |
</p>
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
</div>
|
| 151 |
</div>
|
| 152 |
|
| 153 |
-
<
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 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 |
-
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
</p>
|
| 166 |
</div>
|
| 167 |
) : (
|
| 168 |
-
<div className="
|
| 169 |
{issues.map((issue) => (
|
| 170 |
-
<
|
| 171 |
key={issue.id}
|
| 172 |
-
|
|
|
|
| 173 |
>
|
| 174 |
-
<div className="flex
|
| 175 |
<div className="flex items-center gap-3">
|
| 176 |
{getStateBadge(issue.state)}
|
| 177 |
-
<span className="text-
|
| 178 |
-
{issue.
|
| 179 |
-
issue.primary_category ||
|
| 180 |
-
"General Issue"}
|
| 181 |
</span>
|
| 182 |
</div>
|
| 183 |
-
<span className="text-
|
| 184 |
{new Date(issue.created_at).toLocaleDateString(undefined, {
|
| 185 |
-
month: "
|
| 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="
|
| 197 |
-
<
|
| 198 |
-
<
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
</div>
|
| 203 |
-
</
|
| 204 |
))}
|
| 205 |
</div>
|
| 206 |
)}
|
| 207 |
</main>
|
| 208 |
|
| 209 |
-
<footer className="mt-
|
| 210 |
-
<div className="max-w-
|
| 211 |
-
<p className="text-sm text-slate-400">
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
| 213 |
</p>
|
| 214 |
</div>
|
| 215 |
</footer>
|
|
|
|
| 1 |
"use client";
|
| 2 |
+
import { useEffect } from "react";
|
| 3 |
+
import Link from "next/link";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
import { useAuth } from "@/components/AuthProvider";
|
|
|
|
| 6 |
import { useCachedFetch } from "@/hooks/useCachedFetch";
|
| 7 |
import { Smartphone, FileText, MapPin } from "lucide-react";
|
| 8 |
+
import { Skeleton } from "@/components/ui/Skeleton";
|
|
|
|
|
|
|
| 9 |
|
| 10 |
interface Issue {
|
| 11 |
id: string;
|
| 12 |
description: string;
|
| 13 |
+
priority: number | null;
|
| 14 |
state: string;
|
| 15 |
+
city: string | null;
|
| 16 |
+
locality: string | null;
|
| 17 |
+
full_address: string | null;
|
| 18 |
created_at: string;
|
| 19 |
+
category: string | null;
|
| 20 |
+
confidence: number | null;
|
| 21 |
+
image_urls: string[];
|
| 22 |
+
annotated_urls: string[];
|
| 23 |
+
sla_deadline: string | null;
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
interface IssuesResponse {
|
|
|
|
| 41 |
|
| 42 |
// Only fetch if user ID is available
|
| 43 |
const fetchUrl = user?.id ? `/issues?user_id=${user.id}` : "";
|
| 44 |
+
const { data: issuesResponse, loading: issuesLoading } =
|
| 45 |
+
useCachedFetch<IssuesResponse>(fetchUrl);
|
| 46 |
|
| 47 |
const issues = issuesResponse?.items || [];
|
| 48 |
+
const contentLoading = issuesLoading && !issuesResponse;
|
|
|
|
|
|
|
| 49 |
|
| 50 |
const getStateBadge = (state: string) => {
|
| 51 |
const styles: Record<string, string> = {
|
|
|
|
| 54 |
in_progress: "bg-orange-100 text-orange-800",
|
| 55 |
resolved: "bg-green-100 text-green-800",
|
| 56 |
closed: "bg-slate-100 text-slate-600",
|
| 57 |
+
escalated: "bg-red-100 text-red-800",
|
| 58 |
+
validated: "bg-emerald-100 text-emerald-800",
|
| 59 |
+
pending_confirmation: "bg-slate-200 text-slate-700",
|
| 60 |
+
pending_verification: "bg-indigo-100 text-indigo-800",
|
| 61 |
};
|
| 62 |
return (
|
| 63 |
<span
|
|
|
|
| 77 |
}
|
| 78 |
|
| 79 |
return (
|
| 80 |
+
<div className="min-h-screen bg-transparent font-sans">
|
| 81 |
+
<nav className="bg-white/80 backdrop-blur-md border-b border-slate-200 sticky top-0 z-50">
|
| 82 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 83 |
+
<div className="flex justify-between h-20 items-center">
|
| 84 |
+
<Link href="/" className="flex items-center gap-2 group">
|
| 85 |
+
<div className="w-10 h-10 bg-urban-primary rounded-xl flex items-center justify-center shadow-lg shadow-urban-primary/20 group-hover:scale-105 transition-transform">
|
| 86 |
+
<MapPin className="text-white w-6 h-6" />
|
| 87 |
+
</div>
|
| 88 |
+
<div>
|
| 89 |
+
<h1 className="text-xl font-bold text-slate-900 leading-tight">
|
| 90 |
+
CityTrack
|
| 91 |
+
</h1>
|
| 92 |
+
<p className="text-[10px] font-bold text-urban-primary uppercase tracking-widest">
|
| 93 |
+
Citizen Portal
|
| 94 |
+
</p>
|
| 95 |
+
</div>
|
| 96 |
+
</Link>
|
| 97 |
+
|
| 98 |
+
<div className="flex items-center gap-6">
|
| 99 |
+
<div className="text-right hidden md:block border-r border-slate-200 pr-6">
|
| 100 |
+
<p className="text-sm font-bold text-slate-900">
|
| 101 |
+
{user?.user_metadata?.full_name ||
|
| 102 |
+
user?.email?.split("@")[0] ||
|
| 103 |
+
"Citizen"}
|
| 104 |
+
</p>
|
| 105 |
+
<p className="text-xs text-slate-500 font-medium">
|
| 106 |
+
{user?.email}
|
| 107 |
</p>
|
|
|
|
| 108 |
</div>
|
| 109 |
<button
|
| 110 |
onClick={signOut}
|
| 111 |
+
className="px-5 py-2.5 bg-slate-900 text-white text-sm font-bold rounded-xl hover:bg-slate-800 transition-all shadow-lg shadow-slate-900/10 active:scale-95"
|
| 112 |
>
|
| 113 |
Sign Out
|
| 114 |
</button>
|
|
|
|
| 117 |
</div>
|
| 118 |
</nav>
|
| 119 |
|
| 120 |
+
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
| 121 |
+
<div className="mb-12 p-8 bg-gradient-to-br from-urban-primary to-emerald-600 rounded-2xl shadow-xl shadow-urban-primary/10 flex flex-col md:flex-row items-center justify-between gap-8 relative overflow-hidden group">
|
| 122 |
+
<div className="absolute top-0 right-0 w-64 h-64 bg-white/10 rounded-full blur-3xl -mr-32 -mt-32" />
|
| 123 |
+
<div className="relative z-10 text-center md:text-left">
|
| 124 |
+
<h3 className="text-white font-bold text-2xl mb-2">
|
| 125 |
+
Report an Issue
|
|
|
|
|
|
|
| 126 |
</h3>
|
| 127 |
+
<p className="text-emerald-50 max-w-xl leading-relaxed font-medium">
|
| 128 |
+
Notice something wrong in your neighborhood? Use our mobile app to
|
| 129 |
+
report issues with instant GPS tagging and AI verification.
|
|
|
|
| 130 |
</p>
|
| 131 |
</div>
|
| 132 |
+
<div className="relative z-10 flex flex-col sm:flex-row items-center gap-4">
|
| 133 |
+
<div className="flex items-center gap-3 bg-white/10 backdrop-blur-sm border border-white/20 px-4 py-3 rounded-xl">
|
| 134 |
+
<Smartphone className="w-6 h-6 text-white" />
|
| 135 |
+
<div className="text-left">
|
| 136 |
+
<p className="text-[10px] font-bold text-emerald-100 uppercase tracking-wider">
|
| 137 |
+
Available for
|
| 138 |
+
</p>
|
| 139 |
+
<p className="text-sm font-bold text-white">Android & iOS</p>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
</div>
|
| 144 |
|
| 145 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
| 146 |
+
<div className="p-6 bg-white/80 backdrop-blur-sm rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
|
| 147 |
+
<p className="text-slate-500 text-[10px] font-bold uppercase tracking-widest mb-1">
|
| 148 |
Total Reports
|
| 149 |
</p>
|
| 150 |
+
{contentLoading ? (
|
| 151 |
+
<Skeleton className="mt-2 h-10 w-24" />
|
| 152 |
+
) : (
|
| 153 |
+
<p className="text-4xl font-black text-slate-900">
|
| 154 |
+
{issues.length}
|
| 155 |
+
</p>
|
| 156 |
+
)}
|
| 157 |
</div>
|
| 158 |
+
<div className="p-6 bg-white/80 backdrop-blur-sm rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
|
| 159 |
+
<p className="text-emerald-600 text-[10px] font-bold uppercase tracking-widest mb-1">
|
| 160 |
Resolved
|
| 161 |
</p>
|
| 162 |
+
{contentLoading ? (
|
| 163 |
+
<Skeleton className="mt-2 h-10 w-24" />
|
| 164 |
+
) : (
|
| 165 |
+
<p className="text-4xl font-black text-emerald-600">
|
| 166 |
+
{issues.filter((i) => i.state === "resolved").length}
|
| 167 |
+
</p>
|
| 168 |
+
)}
|
| 169 |
</div>
|
| 170 |
+
<div className="p-6 bg-white/80 backdrop-blur-sm rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
|
| 171 |
+
<p className="text-amber-500 text-[10px] font-bold uppercase tracking-widest mb-1">
|
| 172 |
+
Active Cases
|
| 173 |
</p>
|
| 174 |
+
{contentLoading ? (
|
| 175 |
+
<Skeleton className="mt-2 h-10 w-24" />
|
| 176 |
+
) : (
|
| 177 |
+
<p className="text-4xl font-black text-amber-500">
|
| 178 |
+
{
|
| 179 |
+
issues.filter((i) =>
|
| 180 |
+
[
|
| 181 |
+
"reported",
|
| 182 |
+
"assigned",
|
| 183 |
+
"in_progress",
|
| 184 |
+
"pending_verification",
|
| 185 |
+
].includes(i.state),
|
| 186 |
+
).length
|
| 187 |
+
}
|
| 188 |
+
</p>
|
| 189 |
+
)}
|
| 190 |
+
</div>
|
| 191 |
+
<div className="p-6 bg-white/80 backdrop-blur-sm rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
|
| 192 |
+
<p className="text-urban-primary text-[10px] font-bold uppercase tracking-widest mb-1">
|
| 193 |
+
Efficiency
|
| 194 |
</p>
|
| 195 |
+
{contentLoading ? (
|
| 196 |
+
<Skeleton className="mt-2 h-10 w-24" />
|
| 197 |
+
) : (
|
| 198 |
+
<p className="text-4xl font-black text-urban-primary">
|
| 199 |
+
{issues.length > 0
|
| 200 |
+
? Math.round(
|
| 201 |
+
(issues.filter((i) => i.state === "resolved").length /
|
| 202 |
+
issues.length) *
|
| 203 |
+
100,
|
| 204 |
+
)
|
| 205 |
+
: 0}
|
| 206 |
+
%
|
| 207 |
+
</p>
|
| 208 |
+
)}
|
| 209 |
</div>
|
| 210 |
</div>
|
| 211 |
|
| 212 |
+
<div className="flex items-center justify-between mb-8">
|
| 213 |
+
<div>
|
| 214 |
+
<h2 className="text-2xl font-black text-slate-900">My Activity</h2>
|
| 215 |
+
<p className="text-sm text-slate-500 font-medium">
|
| 216 |
+
List of all reports submitted by you
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
</p>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
+
{contentLoading ? (
|
| 222 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 223 |
+
{Array.from({ length: 4 }).map((_, idx) => (
|
| 224 |
+
<div
|
| 225 |
+
key={`skeleton-${idx}`}
|
| 226 |
+
className="bg-white/60 p-6 rounded-2xl border border-slate-200 shadow-sm"
|
| 227 |
+
>
|
| 228 |
+
<div className="flex justify-between mb-4">
|
| 229 |
+
<Skeleton className="h-6 w-24 rounded-full" />
|
| 230 |
+
<Skeleton className="h-4 w-28" />
|
| 231 |
+
</div>
|
| 232 |
+
<Skeleton className="h-7 w-3/4 mb-4" />
|
| 233 |
+
<div className="space-y-2">
|
| 234 |
+
<Skeleton className="h-4 w-full" />
|
| 235 |
+
<Skeleton className="h-4 w-1/2" />
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
))}
|
| 239 |
+
</div>
|
| 240 |
+
) : issues.length === 0 ? (
|
| 241 |
+
<div className="text-center py-20 bg-white/40 backdrop-blur-sm rounded-2xl border-2 border-dashed border-slate-200">
|
| 242 |
+
<div className="w-20 h-20 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
| 243 |
+
<FileText className="w-10 h-10 text-slate-300" />
|
| 244 |
+
</div>
|
| 245 |
+
<p className="text-slate-900 font-bold text-xl">No reports found</p>
|
| 246 |
+
<p className="text-slate-500 mt-2 max-w-sm mx-auto">
|
| 247 |
+
Your report history will appear here once you submit an issue
|
| 248 |
+
through the app.
|
| 249 |
</p>
|
| 250 |
</div>
|
| 251 |
) : (
|
| 252 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 253 |
{issues.map((issue) => (
|
| 254 |
+
<Link
|
| 255 |
key={issue.id}
|
| 256 |
+
href={`/user/issues/${issue.id}`}
|
| 257 |
+
className="group bg-white hover:bg-slate-50 p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 flex flex-col h-full"
|
| 258 |
>
|
| 259 |
+
<div className="flex justify-between items-start mb-4">
|
| 260 |
<div className="flex items-center gap-3">
|
| 261 |
{getStateBadge(issue.state)}
|
| 262 |
+
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
| 263 |
+
{issue.category || "General"}
|
|
|
|
|
|
|
| 264 |
</span>
|
| 265 |
</div>
|
| 266 |
+
<span className="text-xs text-slate-400 font-bold bg-slate-50 px-2 py-1 rounded-md">
|
| 267 |
{new Date(issue.created_at).toLocaleDateString(undefined, {
|
| 268 |
+
month: "short",
|
| 269 |
day: "numeric",
|
| 270 |
year: "numeric",
|
| 271 |
})}
|
| 272 |
</span>
|
| 273 |
</div>
|
| 274 |
|
| 275 |
+
<h3 className="text-lg font-bold text-slate-900 mb-3 line-clamp-2 min-h-[3.5rem] group-hover:text-urban-primary transition-colors">
|
| 276 |
{issue.description || "No description provided"}
|
| 277 |
</h3>
|
| 278 |
|
| 279 |
+
<div className="mt-auto pt-4 border-t border-slate-100 space-y-3">
|
| 280 |
+
<div className="flex items-center gap-2 text-sm text-slate-500">
|
| 281 |
+
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center flex-shrink-0">
|
| 282 |
+
<MapPin className="w-4 h-4 text-urban-primary" />
|
| 283 |
+
</div>
|
| 284 |
+
<span className="line-clamp-1 font-medium">
|
| 285 |
+
{issue.full_address ||
|
| 286 |
+
[issue.locality, issue.city]
|
| 287 |
+
.filter(Boolean)
|
| 288 |
+
.join(", ") ||
|
| 289 |
+
"Location pending"}
|
| 290 |
+
</span>
|
| 291 |
+
</div>
|
| 292 |
+
|
| 293 |
+
{issue.image_urls?.[0] && (
|
| 294 |
+
<div className="relative aspect-video rounded-xl overflow-hidden border border-slate-100 bg-slate-50 group-hover:shadow-inner">
|
| 295 |
+
<img
|
| 296 |
+
src={issue.image_urls[0]}
|
| 297 |
+
alt="Issue"
|
| 298 |
+
className="absolute inset-0 h-full w-full object-cover group-hover:scale-105 transition-transform duration-500"
|
| 299 |
+
/>
|
| 300 |
+
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent" />
|
| 301 |
+
</div>
|
| 302 |
+
)}
|
| 303 |
</div>
|
| 304 |
+
</Link>
|
| 305 |
))}
|
| 306 |
</div>
|
| 307 |
)}
|
| 308 |
</main>
|
| 309 |
|
| 310 |
+
<footer className="mt-20 py-12 bg-white/80 backdrop-blur-md border-t border-slate-200">
|
| 311 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-slate-500">
|
| 312 |
+
<p className="text-sm font-bold uppercase tracking-widest text-slate-400 mb-2">
|
| 313 |
+
Developed for CityTrack
|
| 314 |
+
</p>
|
| 315 |
+
<p className="text-xs font-medium">
|
| 316 |
+
Improving Urban Life with AI Governance
|
| 317 |
</p>
|
| 318 |
</div>
|
| 319 |
</footer>
|
Frontend/app/worker/layout.tsx
CHANGED
|
@@ -5,7 +5,11 @@ import { useAuth } from "@/components/AuthProvider";
|
|
| 5 |
import DashboardSidebar from "@/components/DashboardSidebar";
|
| 6 |
import DashboardHeader from "@/components/DashboardHeader";
|
| 7 |
|
| 8 |
-
export default function WorkerLayout({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
const { role, loading, signOut } = useAuth();
|
| 10 |
const [mobileOpen, setMobileOpen] = useState(false);
|
| 11 |
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
|
@@ -20,20 +24,25 @@ export default function WorkerLayout({ children }: { children: React.ReactNode }
|
|
| 20 |
if (loading) return null;
|
| 21 |
|
| 22 |
return (
|
| 23 |
-
<div className="flex min-h-screen bg-
|
| 24 |
-
<DashboardSidebar
|
| 25 |
-
role="worker"
|
| 26 |
-
mobileOpen={mobileOpen}
|
| 27 |
-
setMobileOpen={setMobileOpen}
|
| 28 |
desktopOpen={isSidebarOpen}
|
| 29 |
-
onLogout={signOut}
|
| 30 |
/>
|
| 31 |
-
|
| 32 |
-
<div className="
|
| 33 |
-
<
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
/>
|
| 38 |
<main className="flex-1 overflow-x-hidden overflow-y-auto p-4 sm:p-6 lg:p-8">
|
| 39 |
{children}
|
|
|
|
| 5 |
import DashboardSidebar from "@/components/DashboardSidebar";
|
| 6 |
import DashboardHeader from "@/components/DashboardHeader";
|
| 7 |
|
| 8 |
+
export default function WorkerLayout({
|
| 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);
|
|
|
|
| 24 |
if (loading) return null;
|
| 25 |
|
| 26 |
return (
|
| 27 |
+
<div className="flex min-h-screen bg-urban-bg font-sans text-slate-900 overflow-hidden relative">
|
| 28 |
+
<DashboardSidebar
|
| 29 |
+
role="worker"
|
| 30 |
+
mobileOpen={mobileOpen}
|
| 31 |
+
setMobileOpen={setMobileOpen}
|
| 32 |
desktopOpen={isSidebarOpen}
|
| 33 |
+
onLogout={signOut}
|
| 34 |
/>
|
| 35 |
+
|
| 36 |
+
<div className="absolute top-0 left-0 w-full h-full pointer-events-none z-0 overflow-hidden">
|
| 37 |
+
<div className="absolute top-[-10%] right-[-5%] w-[40%] h-[40%] bg-urban-primary/5 rounded-full blur-[100px]"></div>
|
| 38 |
+
<div className="absolute bottom-[-10%] left-[-5%] w-[30%] h-[30%] bg-amber-500/5 rounded-full blur-[80px]"></div>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<div className="flex-1 flex flex-col min-h-screen transition-all duration-300 relative z-10 h-screen overflow-hidden">
|
| 42 |
+
<DashboardHeader
|
| 43 |
+
setMobileOpen={setMobileOpen}
|
| 44 |
+
toggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)}
|
| 45 |
+
title="Field Worker Portal"
|
| 46 |
/>
|
| 47 |
<main className="flex-1 overflow-x-hidden overflow-y-auto p-4 sm:p-6 lg:p-8">
|
| 48 |
{children}
|
Frontend/app/worker/page.tsx
CHANGED
|
@@ -1,10 +1,7 @@
|
|
| 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,
|
|
@@ -31,23 +28,17 @@ interface Task {
|
|
| 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 |
{
|
|
@@ -79,7 +70,9 @@ export default function WorkerDashboard() {
|
|
| 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
|
|
|
|
|
|
|
| 83 |
{labels[priority] || "Unknown"}
|
| 84 |
</span>
|
| 85 |
);
|
|
@@ -113,20 +106,22 @@ export default function WorkerDashboard() {
|
|
| 113 |
}
|
| 114 |
|
| 115 |
return (
|
| 116 |
-
<div className="space-y-
|
| 117 |
<div className="flex justify-between items-center">
|
| 118 |
<div>
|
| 119 |
-
<h2 className="text-2xl font-
|
| 120 |
-
|
|
|
|
|
|
|
| 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/
|
| 128 |
<div className="flex items-center gap-3 mb-2">
|
| 129 |
-
<div className="p-2.5 bg-
|
| 130 |
<AlertCircle className="w-5 h-5" />
|
| 131 |
</div>
|
| 132 |
<h3 className="text-slate-500 font-bold text-xs uppercase tracking-wider font-mono">
|
|
@@ -136,12 +131,12 @@ export default function WorkerDashboard() {
|
|
| 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/
|
| 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" />
|
|
@@ -153,24 +148,22 @@ export default function WorkerDashboard() {
|
|
| 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-
|
| 164 |
-
<span className="w-1 h-5 bg-
|
| 165 |
Current Assignments
|
| 166 |
</h3>
|
| 167 |
|
| 168 |
{tasks.length === 0 ? (
|
| 169 |
-
<div className="text-center py-16 bg-white/
|
| 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>
|
|
@@ -179,8 +172,8 @@ export default function WorkerDashboard() {
|
|
| 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/
|
| 183 |
-
<div className="absolute top-0 left-0 w-1 h-full bg-slate-200 group-hover:bg-
|
| 184 |
<div>
|
| 185 |
<div className="flex justify-between items-start mb-4 pl-2">
|
| 186 |
<div className="flex items-center gap-2 flex-wrap">
|
|
@@ -190,8 +183,8 @@ export default function WorkerDashboard() {
|
|
| 190 |
task.state === "pending_verification"
|
| 191 |
? "bg-orange-50 text-orange-700 border-orange-200"
|
| 192 |
: task.state === "resolved"
|
| 193 |
-
|
| 194 |
-
|
| 195 |
}`}
|
| 196 |
>
|
| 197 |
{task.state === "pending_verification"
|
|
@@ -201,14 +194,14 @@ export default function WorkerDashboard() {
|
|
| 201 |
</div>
|
| 202 |
</div>
|
| 203 |
|
| 204 |
-
<h3 className="text-xl font-bold text-slate-900 mb-2 pl-2 group-hover:text-
|
| 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 |
-
|
| 212 |
</div>
|
| 213 |
<span className="truncate font-medium">
|
| 214 |
{task.full_address || `${task.city}, ${task.locality}`}
|
|
@@ -230,7 +223,7 @@ export default function WorkerDashboard() {
|
|
| 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-
|
| 234 |
Resolve <ArrowRight className="w-4 h-4" />
|
| 235 |
</span>
|
| 236 |
</div>
|
|
|
|
| 1 |
"use client";
|
|
|
|
|
|
|
| 2 |
import { useRouter } from "next/navigation";
|
| 3 |
import Link from "next/link";
|
| 4 |
import { useAuth } from "@/components/AuthProvider";
|
|
|
|
| 5 |
import {
|
| 6 |
Coffee,
|
| 7 |
MapPin,
|
|
|
|
| 28 |
|
| 29 |
import { useCachedFetch } from "@/hooks/useCachedFetch";
|
| 30 |
|
|
|
|
|
|
|
| 31 |
export default function WorkerDashboard() {
|
| 32 |
const { user, role, loading: authLoading } = useAuth();
|
| 33 |
const router = useRouter();
|
| 34 |
+
|
|
|
|
| 35 |
const { data: tasksData, loading: tasksLoading } = useCachedFetch<Task[]>(
|
| 36 |
+
role === "worker" ? "/worker/tasks" : "",
|
| 37 |
);
|
| 38 |
|
| 39 |
const tasks = tasksData || [];
|
|
|
|
|
|
|
| 40 |
const isLoading = authLoading || (tasksLoading && tasks.length === 0);
|
| 41 |
|
|
|
|
| 42 |
const getPriorityBadge = (priority: number) => {
|
| 43 |
const badges: Record<number, { bg: string; text: string; border: string }> =
|
| 44 |
{
|
|
|
|
| 70 |
<span
|
| 71 |
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`}
|
| 72 |
>
|
| 73 |
+
<span
|
| 74 |
+
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"}`}
|
| 75 |
+
></span>
|
| 76 |
{labels[priority] || "Unknown"}
|
| 77 |
</span>
|
| 78 |
);
|
|
|
|
| 106 |
}
|
| 107 |
|
| 108 |
return (
|
| 109 |
+
<div className="space-y-8 max-w-7xl mx-auto">
|
| 110 |
<div className="flex justify-between items-center">
|
| 111 |
<div>
|
| 112 |
+
<h2 className="text-2xl font-black text-slate-900 tracking-tight">
|
| 113 |
+
My Assignments
|
| 114 |
+
</h2>
|
| 115 |
+
<p className="text-sm text-slate-500 font-medium">
|
| 116 |
Tasks assigned to you for resolution.
|
| 117 |
</p>
|
| 118 |
</div>
|
| 119 |
</div>
|
| 120 |
|
| 121 |
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
| 122 |
+
<div className="bg-white/70 backdrop-blur-md p-6 rounded-2xl border border-slate-200/70 shadow-urban-sm hover:shadow-urban-md transition-all">
|
| 123 |
<div className="flex items-center gap-3 mb-2">
|
| 124 |
+
<div className="p-2.5 bg-urban-primary/10 text-urban-primary rounded-xl shadow-sm">
|
| 125 |
<AlertCircle className="w-5 h-5" />
|
| 126 |
</div>
|
| 127 |
<h3 className="text-slate-500 font-bold text-xs uppercase tracking-wider font-mono">
|
|
|
|
| 131 |
<p className="text-4xl font-extrabold text-slate-900 mt-2 tracking-tighter">
|
| 132 |
{
|
| 133 |
tasks.filter((t) =>
|
| 134 |
+
["assigned", "in_progress", "rejected"].includes(t.state),
|
| 135 |
).length
|
| 136 |
}
|
| 137 |
</p>
|
| 138 |
</div>
|
| 139 |
+
<div className="bg-white/70 backdrop-blur-md p-6 rounded-2xl border border-slate-200/70 shadow-urban-sm hover:shadow-urban-md transition-all">
|
| 140 |
<div className="flex items-center gap-3 mb-2">
|
| 141 |
<div className="p-2.5 bg-amber-50 text-amber-600 rounded-xl shadow-sm">
|
| 142 |
<Coffee className="w-5 h-5" />
|
|
|
|
| 148 |
<p className="text-4xl font-extrabold text-slate-900 mt-2 tracking-tighter">
|
| 149 |
{
|
| 150 |
tasks.filter((t) =>
|
| 151 |
+
["pending_verification", "resolved"].includes(t.state),
|
| 152 |
).length
|
| 153 |
}
|
| 154 |
</p>
|
| 155 |
</div>
|
| 156 |
</div>
|
| 157 |
|
| 158 |
+
<h3 className="text-lg font-black text-slate-900 mb-4 flex items-center gap-2">
|
| 159 |
+
<span className="w-1.5 h-5 bg-urban-primary rounded-full"></span>
|
| 160 |
Current Assignments
|
| 161 |
</h3>
|
| 162 |
|
| 163 |
{tasks.length === 0 ? (
|
| 164 |
+
<div className="text-center py-16 bg-white/60 backdrop-blur-sm rounded-2xl border border-slate-200/70 border-dashed">
|
| 165 |
<Coffee className="w-12 h-12 mx-auto text-slate-300 mb-4" />
|
| 166 |
+
<p className="text-slate-900 font-bold text-lg">All caught up!</p>
|
|
|
|
|
|
|
| 167 |
<p className="text-slate-500 text-sm mt-1">
|
| 168 |
Enjoy your break, no pending assignments.
|
| 169 |
</p>
|
|
|
|
| 172 |
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 173 |
{tasks.map((task) => (
|
| 174 |
<Link key={task.id} href={`/worker/task/${task.id}`}>
|
| 175 |
+
<div className="h-full p-6 bg-white/70 backdrop-blur-md rounded-2xl border border-slate-200/70 shadow-urban-sm hover:shadow-urban-md hover:-translate-y-1 hover:border-urban-primary/30 transition-all cursor-pointer group flex flex-col justify-between relative overflow-hidden">
|
| 176 |
+
<div className="absolute top-0 left-0 w-1 h-full bg-slate-200 group-hover:bg-urban-primary transition-colors"></div>
|
| 177 |
<div>
|
| 178 |
<div className="flex justify-between items-start mb-4 pl-2">
|
| 179 |
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
| 183 |
task.state === "pending_verification"
|
| 184 |
? "bg-orange-50 text-orange-700 border-orange-200"
|
| 185 |
: task.state === "resolved"
|
| 186 |
+
? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
| 187 |
+
: "bg-slate-100 text-slate-600 border-slate-200"
|
| 188 |
}`}
|
| 189 |
>
|
| 190 |
{task.state === "pending_verification"
|
|
|
|
| 194 |
</div>
|
| 195 |
</div>
|
| 196 |
|
| 197 |
+
<h3 className="text-xl font-bold text-slate-900 mb-2 pl-2 group-hover:text-urban-primary transition-colors line-clamp-2 tracking-tight">
|
| 198 |
{task.description || "Issue Report"}
|
| 199 |
</h3>
|
| 200 |
|
| 201 |
<div className="py-3 pl-2 space-y-2.5">
|
| 202 |
<div className="flex items-center gap-2.5 text-sm text-slate-600">
|
| 203 |
<div className="p-1.5 bg-slate-100 rounded-md text-slate-500">
|
| 204 |
+
<MapPin className="w-3.5 h-3.5" />
|
| 205 |
</div>
|
| 206 |
<span className="truncate font-medium">
|
| 207 |
{task.full_address || `${task.city}, ${task.locality}`}
|
|
|
|
| 223 |
<span className="text-xs text-slate-400 font-mono font-medium bg-slate-100 px-2 py-1 rounded">
|
| 224 |
ID: {task.id.slice(0, 8)}
|
| 225 |
</span>
|
| 226 |
+
<span className="text-urban-primary text-sm font-bold flex items-center gap-1.5 group-hover:gap-2.5 transition-all bg-urban-primary/10 px-3 py-1.5 rounded-lg border border-urban-primary/20 group-hover:bg-urban-primary/20 group-hover:border-urban-primary/30">
|
| 227 |
Resolve <ArrowRight className="w-4 h-4" />
|
| 228 |
</span>
|
| 229 |
</div>
|
Frontend/app/worker/task/[id]/page.tsx
CHANGED
|
@@ -124,7 +124,9 @@ export default function TaskDetailPage() {
|
|
| 124 |
|
| 125 |
if (loading) {
|
| 126 |
return (
|
| 127 |
-
<div className="text-slate-600 font-medium
|
|
|
|
|
|
|
| 128 |
);
|
| 129 |
}
|
| 130 |
|
|
@@ -134,7 +136,7 @@ export default function TaskDetailPage() {
|
|
| 134 |
<p className="text-slate-500 text-lg">Task not found</p>
|
| 135 |
<button
|
| 136 |
onClick={() => router.back()}
|
| 137 |
-
className="mt-4 text-
|
| 138 |
>
|
| 139 |
Go Back
|
| 140 |
</button>
|
|
@@ -143,7 +145,7 @@ export default function TaskDetailPage() {
|
|
| 143 |
}
|
| 144 |
|
| 145 |
return (
|
| 146 |
-
<div className="space-y-6">
|
| 147 |
<div className="flex items-center justify-between">
|
| 148 |
<button
|
| 149 |
onClick={() => router.back()}
|
|
@@ -155,7 +157,7 @@ export default function TaskDetailPage() {
|
|
| 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-
|
| 159 |
>
|
| 160 |
<Navigation className="w-4 h-4" /> Navigation
|
| 161 |
</a>
|
|
@@ -163,7 +165,7 @@ export default function TaskDetailPage() {
|
|
| 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/
|
| 167 |
{task.annotated_url ? (
|
| 168 |
<div className="relative h-72 bg-slate-100 group">
|
| 169 |
<img
|
|
@@ -171,9 +173,9 @@ export default function TaskDetailPage() {
|
|
| 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-
|
| 175 |
<div className="absolute bottom-4 left-4 text-white">
|
| 176 |
-
|
| 177 |
</div>
|
| 178 |
</div>
|
| 179 |
) : (
|
|
@@ -184,15 +186,19 @@ export default function TaskDetailPage() {
|
|
| 184 |
|
| 185 |
<div className="p-6">
|
| 186 |
<div className="flex items-center gap-2 mb-4">
|
| 187 |
-
<span
|
| 188 |
-
|
| 189 |
-
task.state ===
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
{task.state.replace("_", " ")}
|
| 193 |
</span>
|
| 194 |
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider ml-auto">
|
| 195 |
-
|
| 196 |
</span>
|
| 197 |
</div>
|
| 198 |
|
|
@@ -203,7 +209,7 @@ export default function TaskDetailPage() {
|
|
| 203 |
{task.full_address}
|
| 204 |
</p>
|
| 205 |
|
| 206 |
-
<div className="grid grid-cols-2 gap-6 py-6 border-t border-slate-200/
|
| 207 |
<div>
|
| 208 |
<p className="text-xs font-bold text-slate-400 uppercase tracking-wide mb-1">
|
| 209 |
Reported On
|
|
@@ -227,9 +233,9 @@ export default function TaskDetailPage() {
|
|
| 227 |
</div>
|
| 228 |
</div>
|
| 229 |
|
| 230 |
-
<div className="bg-white/
|
| 231 |
-
<h2 className="text-xl font-
|
| 232 |
-
<span className="w-1.5 h-6 bg-
|
| 233 |
Task Action
|
| 234 |
</h2>
|
| 235 |
|
|
@@ -242,7 +248,7 @@ export default function TaskDetailPage() {
|
|
| 242 |
</p>
|
| 243 |
<button
|
| 244 |
onClick={handleStart}
|
| 245 |
-
className="w-full py-4 bg-
|
| 246 |
>
|
| 247 |
Start Task
|
| 248 |
</button>
|
|
@@ -268,8 +274,8 @@ export default function TaskDetailPage() {
|
|
| 268 |
onClick={() => fileRef.current?.click()}
|
| 269 |
className={`w-full p-8 border-2 border-dashed rounded-2xl transition-all group ${
|
| 270 |
previewUrl
|
| 271 |
-
? "border-
|
| 272 |
-
: "border-slate-300 hover:border-
|
| 273 |
}`}
|
| 274 |
>
|
| 275 |
{previewUrl ? (
|
|
@@ -279,14 +285,14 @@ export default function TaskDetailPage() {
|
|
| 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-
|
| 283 |
Change Photo
|
| 284 |
</span>
|
| 285 |
</div>
|
| 286 |
) : (
|
| 287 |
-
<div className="flex flex-col items-center text-slate-400 group-hover:text-
|
| 288 |
-
<div className="p-4 bg-slate-100 rounded-full mb-3 group-hover:bg-
|
| 289 |
-
|
| 290 |
</div>
|
| 291 |
<span className="font-bold">Tap to Upload Photo</span>
|
| 292 |
</div>
|
|
@@ -301,7 +307,7 @@ export default function TaskDetailPage() {
|
|
| 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-
|
| 305 |
rows={4}
|
| 306 |
placeholder="Describe the repair work completed..."
|
| 307 |
/>
|
|
@@ -325,7 +331,7 @@ export default function TaskDetailPage() {
|
|
| 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 |
-
|
| 329 |
</div>
|
| 330 |
<h3 className="text-xl font-bold text-orange-900 mb-2">
|
| 331 |
Under Review
|
|
@@ -337,7 +343,9 @@ export default function TaskDetailPage() {
|
|
| 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 |
-
|
|
|
|
|
|
|
| 341 |
</div>
|
| 342 |
<h3 className="text-xl font-bold text-emerald-900 mb-2">
|
| 343 |
Task Completed
|
|
|
|
| 124 |
|
| 125 |
if (loading) {
|
| 126 |
return (
|
| 127 |
+
<div className="text-slate-600 font-medium max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 128 |
+
Loading Task Details...
|
| 129 |
+
</div>
|
| 130 |
);
|
| 131 |
}
|
| 132 |
|
|
|
|
| 136 |
<p className="text-slate-500 text-lg">Task not found</p>
|
| 137 |
<button
|
| 138 |
onClick={() => router.back()}
|
| 139 |
+
className="mt-4 text-urban-primary font-medium hover:underline"
|
| 140 |
>
|
| 141 |
Go Back
|
| 142 |
</button>
|
|
|
|
| 145 |
}
|
| 146 |
|
| 147 |
return (
|
| 148 |
+
<div className="space-y-6 max-w-7xl mx-auto">
|
| 149 |
<div className="flex items-center justify-between">
|
| 150 |
<button
|
| 151 |
onClick={() => router.back()}
|
|
|
|
| 157 |
href={`https://www.google.com/maps?q=${task.latitude},${task.longitude}`}
|
| 158 |
target="_blank"
|
| 159 |
rel="noopener noreferrer"
|
| 160 |
+
className="px-5 py-2.5 bg-urban-primary text-white text-sm font-bold rounded-xl hover:bg-emerald-600 transition shadow-lg hover:shadow-emerald-500/30 flex items-center gap-2 transform hover:-translate-y-0.5"
|
| 161 |
>
|
| 162 |
<Navigation className="w-4 h-4" /> Navigation
|
| 163 |
</a>
|
|
|
|
| 165 |
|
| 166 |
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
| 167 |
<div className="space-y-6">
|
| 168 |
+
<div className="bg-white/70 backdrop-blur-md rounded-2xl border border-slate-200/70 shadow-urban-sm overflow-hidden">
|
| 169 |
{task.annotated_url ? (
|
| 170 |
<div className="relative h-72 bg-slate-100 group">
|
| 171 |
<img
|
|
|
|
| 173 |
alt="Issue"
|
| 174 |
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
|
| 175 |
/>
|
| 176 |
+
<div className="absolute inset-0 bg-linear-to-t from-black/50 to-transparent opacity-60"></div>
|
| 177 |
<div className="absolute bottom-4 left-4 text-white">
|
| 178 |
+
<p className="font-bold text-lg">{task.category}</p>
|
| 179 |
</div>
|
| 180 |
</div>
|
| 181 |
) : (
|
|
|
|
| 186 |
|
| 187 |
<div className="p-6">
|
| 188 |
<div className="flex items-center gap-2 mb-4">
|
| 189 |
+
<span
|
| 190 |
+
className={`px-2.5 py-1 rounded-md text-xs font-bold uppercase tracking-wide border ${
|
| 191 |
+
task.state === "pending_verification"
|
| 192 |
+
? "bg-orange-50 text-orange-700 border-orange-200"
|
| 193 |
+
: task.state === "resolved"
|
| 194 |
+
? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
| 195 |
+
: "bg-urban-primary/10 text-urban-primary border-urban-primary/20"
|
| 196 |
+
}`}
|
| 197 |
+
>
|
| 198 |
{task.state.replace("_", " ")}
|
| 199 |
</span>
|
| 200 |
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider ml-auto">
|
| 201 |
+
ID: {task.id.slice(0, 8)}
|
| 202 |
</span>
|
| 203 |
</div>
|
| 204 |
|
|
|
|
| 209 |
{task.full_address}
|
| 210 |
</p>
|
| 211 |
|
| 212 |
+
<div className="grid grid-cols-2 gap-6 py-6 border-t border-slate-200/70">
|
| 213 |
<div>
|
| 214 |
<p className="text-xs font-bold text-slate-400 uppercase tracking-wide mb-1">
|
| 215 |
Reported On
|
|
|
|
| 233 |
</div>
|
| 234 |
</div>
|
| 235 |
|
| 236 |
+
<div className="bg-white/70 backdrop-blur-md rounded-2xl border border-slate-200/70 shadow-urban-sm p-8 h-fit sticky top-6">
|
| 237 |
+
<h2 className="text-xl font-black text-slate-900 mb-6 border-b border-slate-200/70 pb-4 flex items-center gap-2">
|
| 238 |
+
<span className="w-1.5 h-6 bg-urban-primary rounded-full"></span>
|
| 239 |
Task Action
|
| 240 |
</h2>
|
| 241 |
|
|
|
|
| 248 |
</p>
|
| 249 |
<button
|
| 250 |
onClick={handleStart}
|
| 251 |
+
className="w-full py-4 bg-urban-primary hover:bg-emerald-600 text-white font-bold text-lg rounded-xl shadow-lg hover:shadow-emerald-500/40 transition-all flex items-center justify-center gap-2 transform hover:-translate-y-1"
|
| 252 |
>
|
| 253 |
Start Task
|
| 254 |
</button>
|
|
|
|
| 274 |
onClick={() => fileRef.current?.click()}
|
| 275 |
className={`w-full p-8 border-2 border-dashed rounded-2xl transition-all group ${
|
| 276 |
previewUrl
|
| 277 |
+
? "border-urban-primary bg-urban-primary/10"
|
| 278 |
+
: "border-slate-300 hover:border-urban-primary/50 hover:bg-slate-50"
|
| 279 |
}`}
|
| 280 |
>
|
| 281 |
{previewUrl ? (
|
|
|
|
| 285 |
alt="Proof"
|
| 286 |
className="h-48 mx-auto rounded-xl shadow-md object-cover mb-4"
|
| 287 |
/>
|
| 288 |
+
<span className="text-sm font-bold text-urban-primary group-hover:text-emerald-700">
|
| 289 |
Change Photo
|
| 290 |
</span>
|
| 291 |
</div>
|
| 292 |
) : (
|
| 293 |
+
<div className="flex flex-col items-center text-slate-400 group-hover:text-urban-primary transition-colors">
|
| 294 |
+
<div className="p-4 bg-slate-100 rounded-full mb-3 group-hover:bg-urban-primary/10 transition-colors">
|
| 295 |
+
<Camera className="w-8 h-8" />
|
| 296 |
</div>
|
| 297 |
<span className="font-bold">Tap to Upload Photo</span>
|
| 298 |
</div>
|
|
|
|
| 307 |
<textarea
|
| 308 |
value={notes}
|
| 309 |
onChange={(e) => setNotes(e.target.value)}
|
| 310 |
+
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-urban-primary/10 focus:border-urban-primary/40 transition-all resize-none"
|
| 311 |
rows={4}
|
| 312 |
placeholder="Describe the repair work completed..."
|
| 313 |
/>
|
|
|
|
| 331 |
) : task.state === "pending_verification" ? (
|
| 332 |
<div className="text-center py-10 bg-orange-50/50 rounded-2xl border border-orange-100">
|
| 333 |
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
| 334 |
+
<Loader2 className="w-8 h-8 text-orange-500 animate-spin" />
|
| 335 |
</div>
|
| 336 |
<h3 className="text-xl font-bold text-orange-900 mb-2">
|
| 337 |
Under Review
|
|
|
|
| 343 |
) : (
|
| 344 |
<div className="text-center py-10 bg-emerald-50/50 rounded-2xl border border-emerald-100">
|
| 345 |
<div className="w-16 h-16 bg-emerald-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
| 346 |
+
<div className="w-8 h-8 text-emerald-600 font-bold text-2xl">
|
| 347 |
+
✓
|
| 348 |
+
</div>
|
| 349 |
</div>
|
| 350 |
<h3 className="text-xl font-bold text-emerald-900 mb-2">
|
| 351 |
Task Completed
|
Frontend/components/DashboardHeader.tsx
CHANGED
|
@@ -1,50 +1,53 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
"use client";
|
| 4 |
-
import { useState } from "react";
|
| 5 |
import { Search, Bell, Menu, User } from "lucide-react";
|
| 6 |
|
| 7 |
interface HeaderProps {
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
}
|
| 12 |
|
| 13 |
-
export default function DashboardHeader({
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
}
|
|
|
|
|
|
|
|
|
|
| 1 |
"use client";
|
|
|
|
| 2 |
import { Search, Bell, Menu, User } from "lucide-react";
|
| 3 |
|
| 4 |
interface HeaderProps {
|
| 5 |
+
setMobileOpen: (open: boolean) => void;
|
| 6 |
+
toggleSidebar?: () => void;
|
| 7 |
+
title?: string;
|
| 8 |
}
|
| 9 |
|
| 10 |
+
export default function DashboardHeader({
|
| 11 |
+
setMobileOpen,
|
| 12 |
+
toggleSidebar,
|
| 13 |
+
title = "Dashboard",
|
| 14 |
+
}: HeaderProps) {
|
| 15 |
+
return (
|
| 16 |
+
<header className="sticky top-0 z-30 flex h-16 w-full items-center justify-between border-b border-slate-200/60 bg-white/70 backdrop-blur-md px-4 shadow-sm sm:px-6 lg:px-8 transition-all duration-300">
|
| 17 |
+
<div className="flex items-center gap-4">
|
| 18 |
+
<button
|
| 19 |
+
onClick={() => {
|
| 20 |
+
setMobileOpen(true);
|
| 21 |
+
toggleSidebar?.();
|
| 22 |
+
}}
|
| 23 |
+
className="rounded-lg p-2 text-slate-500 hover:bg-slate-100/80 hover:text-slate-700 transition-colors"
|
| 24 |
+
>
|
| 25 |
+
<Menu className="h-6 w-6" />
|
| 26 |
+
</button>
|
| 27 |
+
<h1 className="text-xl font-black text-slate-900 tracking-tight hidden sm:block">
|
| 28 |
+
{title}
|
| 29 |
+
</h1>
|
| 30 |
+
</div>
|
| 31 |
|
| 32 |
+
<div className="flex items-center gap-4">
|
| 33 |
+
<div className="hidden md:flex relative group">
|
| 34 |
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 group-focus-within:text-urban-primary transition-colors" />
|
| 35 |
+
<input
|
| 36 |
+
type="text"
|
| 37 |
+
placeholder="Search issues, workers..."
|
| 38 |
+
className="h-10 w-64 rounded-full border border-slate-200 bg-slate-50/60 pl-10 pr-4 text-sm outline-none transition-all placeholder:text-slate-400 focus:border-urban-primary/40 focus:bg-white focus:ring-4 focus:ring-urban-primary/10"
|
| 39 |
+
/>
|
| 40 |
+
</div>
|
| 41 |
|
| 42 |
+
<button className="relative rounded-full bg-white p-2.5 text-slate-500 shadow-sm ring-1 ring-slate-200 hover:text-urban-primary hover:ring-urban-primary/20 transition-all">
|
| 43 |
+
<Bell className="h-5 w-5" />
|
| 44 |
+
<span className="absolute top-2 right-2.5 h-2 w-2 rounded-full bg-amber-500 ring-2 ring-white"></span>
|
| 45 |
+
</button>
|
| 46 |
|
| 47 |
+
<div className="h-9 w-9 rounded-full bg-urban-primary/10 flex items-center justify-center text-urban-primary font-bold border border-white shadow-sm ring-2 ring-urban-primary/20">
|
| 48 |
+
<User className="h-5 w-5" />
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
</header>
|
| 52 |
+
);
|
| 53 |
}
|
Frontend/components/DashboardSidebar.tsx
CHANGED
|
@@ -37,7 +37,6 @@ export default function DashboardSidebar({
|
|
| 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 },
|
|
@@ -70,14 +69,14 @@ export default function DashboardSidebar({
|
|
| 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-
|
| 74 |
-
<span className="text-white font-bold font-mono">
|
| 75 |
</div>
|
| 76 |
<span className="text-xl font-bold tracking-tight text-slate-900">
|
| 77 |
CityTracker
|
| 78 |
</span>
|
| 79 |
</div>
|
| 80 |
-
<span className="ml-auto rounded-full bg-
|
| 81 |
{role}
|
| 82 |
</span>
|
| 83 |
</div>
|
|
@@ -95,18 +94,18 @@ export default function DashboardSidebar({
|
|
| 95 |
className={cn(
|
| 96 |
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-200 group relative overflow-hidden",
|
| 97 |
isActive
|
| 98 |
-
? "bg-
|
| 99 |
: "text-slate-500 hover:bg-slate-50 hover:text-slate-900",
|
| 100 |
)}
|
| 101 |
>
|
| 102 |
{isActive && (
|
| 103 |
-
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-
|
| 104 |
)}
|
| 105 |
<Icon
|
| 106 |
className={cn(
|
| 107 |
"h-5 w-5 transition-transform group-hover:scale-110",
|
| 108 |
isActive
|
| 109 |
-
? "text-
|
| 110 |
: "text-slate-400 group-hover:text-slate-600",
|
| 111 |
)}
|
| 112 |
/>
|
|
|
|
| 37 |
}: SidebarProps) {
|
| 38 |
const pathname = usePathname();
|
| 39 |
|
|
|
|
| 40 |
const adminLinks = [
|
| 41 |
{ href: "/admin", label: "Overview", icon: LayoutDashboard },
|
| 42 |
{ href: "/admin/issues", label: "Issues", icon: ClipboardList },
|
|
|
|
| 69 |
>
|
| 70 |
<div className="flex h-16 items-center px-6 border-b border-slate-100/50">
|
| 71 |
<div className="flex items-center gap-2">
|
| 72 |
+
<div className="h-8 w-8 rounded-lg bg-gradient-to-br from-urban-primary to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
| 73 |
+
<span className="text-white font-bold font-mono">C</span>
|
| 74 |
</div>
|
| 75 |
<span className="text-xl font-bold tracking-tight text-slate-900">
|
| 76 |
CityTracker
|
| 77 |
</span>
|
| 78 |
</div>
|
| 79 |
+
<span className="ml-auto rounded-full bg-urban-primary/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-urban-primary ring-1 ring-urban-primary/20">
|
| 80 |
{role}
|
| 81 |
</span>
|
| 82 |
</div>
|
|
|
|
| 94 |
className={cn(
|
| 95 |
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-200 group relative overflow-hidden",
|
| 96 |
isActive
|
| 97 |
+
? "bg-urban-primary/10 text-urban-primary shadow-sm ring-1 ring-urban-primary/20"
|
| 98 |
: "text-slate-500 hover:bg-slate-50 hover:text-slate-900",
|
| 99 |
)}
|
| 100 |
>
|
| 101 |
{isActive && (
|
| 102 |
+
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-urban-primary rounded-r-full" />
|
| 103 |
)}
|
| 104 |
<Icon
|
| 105 |
className={cn(
|
| 106 |
"h-5 w-5 transition-transform group-hover:scale-110",
|
| 107 |
isActive
|
| 108 |
+
? "text-urban-primary"
|
| 109 |
: "text-slate-400 group-hover:text-slate-600",
|
| 110 |
)}
|
| 111 |
/>
|