DeeptiYadav10648 commited on
Commit ·
bcd59c6
1
Parent(s): 45efbb3
Frontend version 1 complete
Browse files- Frontend/.env.example +3 -0
- Frontend/.gitignore +0 -41
- Frontend/README.md +0 -36
- Frontend/app/admin/departments/[id]/page.tsx +456 -0
- Frontend/app/admin/departments/page.tsx +267 -0
- Frontend/app/admin/heatmap/page.tsx +102 -0
- Frontend/app/admin/issues/[id]/page.tsx +660 -0
- Frontend/app/admin/issues/page.tsx +375 -0
- Frontend/app/admin/layout.tsx +52 -0
- Frontend/app/admin/page.tsx +269 -0
- Frontend/app/admin/review/page.tsx +121 -0
- Frontend/app/admin/workers/page.tsx +371 -0
- Frontend/app/auth/callback/page.tsx +43 -0
- Frontend/app/globals.css +55 -15
- Frontend/app/layout.tsx +15 -11
- Frontend/app/page.tsx +236 -56
- Frontend/app/signin/page.tsx +232 -0
- Frontend/app/signup/page.tsx +88 -0
- Frontend/app/user/issues/[id]/page.tsx +374 -0
- Frontend/app/user/page.tsx +218 -0
- Frontend/app/worker/layout.tsx +44 -0
- Frontend/app/worker/page.tsx +244 -0
- Frontend/app/worker/task/[id]/page.tsx +354 -0
- Frontend/components/AuthProvider.tsx +141 -0
- Frontend/components/DashboardHeader.tsx +50 -0
- Frontend/components/DashboardSidebar.tsx +127 -0
- Frontend/components/ui/Loader.tsx +17 -0
- Frontend/components/ui/Skeleton.tsx +10 -0
- Frontend/hooks/useCachedFetch.ts +123 -0
- Frontend/lib/api.ts +126 -0
- Frontend/lib/utils.ts +6 -0
- Frontend/next-env.d.ts +6 -0
- Frontend/next.config.ts +4 -1
- Frontend/package-lock.json +730 -185
- Frontend/package.json +8 -3
- Frontend/tsconfig.tsbuildinfo +0 -0
Frontend/.env.example
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
NEXT_PUBLIC_API_URL=http://localhost:8000
|
| 2 |
+
NEXT_PUBLIC_SUPABASE_URL=
|
| 3 |
+
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
Frontend/.gitignore
DELETED
|
@@ -1,41 +0,0 @@
|
|
| 1 |
-
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
-
|
| 3 |
-
# dependencies
|
| 4 |
-
/node_modules
|
| 5 |
-
/.pnp
|
| 6 |
-
.pnp.*
|
| 7 |
-
.yarn/*
|
| 8 |
-
!.yarn/patches
|
| 9 |
-
!.yarn/plugins
|
| 10 |
-
!.yarn/releases
|
| 11 |
-
!.yarn/versions
|
| 12 |
-
|
| 13 |
-
# testing
|
| 14 |
-
/coverage
|
| 15 |
-
|
| 16 |
-
# next.js
|
| 17 |
-
/.next/
|
| 18 |
-
/out/
|
| 19 |
-
|
| 20 |
-
# production
|
| 21 |
-
/build
|
| 22 |
-
|
| 23 |
-
# misc
|
| 24 |
-
.DS_Store
|
| 25 |
-
*.pem
|
| 26 |
-
|
| 27 |
-
# debug
|
| 28 |
-
npm-debug.log*
|
| 29 |
-
yarn-debug.log*
|
| 30 |
-
yarn-error.log*
|
| 31 |
-
.pnpm-debug.log*
|
| 32 |
-
|
| 33 |
-
# env files (can opt-in for committing if needed)
|
| 34 |
-
.env*
|
| 35 |
-
|
| 36 |
-
# vercel
|
| 37 |
-
.vercel
|
| 38 |
-
|
| 39 |
-
# typescript
|
| 40 |
-
*.tsbuildinfo
|
| 41 |
-
next-env.d.ts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Frontend/README.md
DELETED
|
@@ -1,36 +0,0 @@
|
|
| 1 |
-
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
| 2 |
-
|
| 3 |
-
## Getting Started
|
| 4 |
-
|
| 5 |
-
First, run the development server:
|
| 6 |
-
|
| 7 |
-
```bash
|
| 8 |
-
npm run dev
|
| 9 |
-
# or
|
| 10 |
-
yarn dev
|
| 11 |
-
# or
|
| 12 |
-
pnpm dev
|
| 13 |
-
# or
|
| 14 |
-
bun dev
|
| 15 |
-
```
|
| 16 |
-
|
| 17 |
-
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
| 18 |
-
|
| 19 |
-
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
| 20 |
-
|
| 21 |
-
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
| 22 |
-
|
| 23 |
-
## Learn More
|
| 24 |
-
|
| 25 |
-
To learn more about Next.js, take a look at the following resources:
|
| 26 |
-
|
| 27 |
-
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
| 28 |
-
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
| 29 |
-
|
| 30 |
-
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
| 31 |
-
|
| 32 |
-
## Deploy on Vercel
|
| 33 |
-
|
| 34 |
-
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
| 35 |
-
|
| 36 |
-
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Frontend/app/admin/departments/[id]/page.tsx
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useEffect, useState } from "react";
|
| 3 |
+
import { useParams, useRouter } from "next/navigation";
|
| 4 |
+
|
| 5 |
+
export const runtime = "edge";
|
| 6 |
+
import { apiGet, apiPost } from "@/lib/api";
|
| 7 |
+
import {
|
| 8 |
+
ArrowLeft,
|
| 9 |
+
Users,
|
| 10 |
+
Plus,
|
| 11 |
+
Mail,
|
| 12 |
+
Phone,
|
| 13 |
+
Briefcase,
|
| 14 |
+
Shield,
|
| 15 |
+
MapPin,
|
| 16 |
+
Trash2,
|
| 17 |
+
MoreHorizontal,
|
| 18 |
+
} from "lucide-react";
|
| 19 |
+
import Link from "next/link";
|
| 20 |
+
|
| 21 |
+
interface Department {
|
| 22 |
+
id: string;
|
| 23 |
+
name: string;
|
| 24 |
+
code: string;
|
| 25 |
+
description: string;
|
| 26 |
+
default_sla_hours: number;
|
| 27 |
+
is_active: boolean;
|
| 28 |
+
member_count: number;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
interface Member {
|
| 32 |
+
id: string;
|
| 33 |
+
name: string;
|
| 34 |
+
email: string;
|
| 35 |
+
role: string;
|
| 36 |
+
current_workload: number;
|
| 37 |
+
max_workload: number;
|
| 38 |
+
is_active: boolean;
|
| 39 |
+
phone?: string;
|
| 40 |
+
city?: string;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export default function DepartmentDetailPage() {
|
| 44 |
+
const params = useParams();
|
| 45 |
+
const router = useRouter();
|
| 46 |
+
const [department, setDepartment] = useState<Department | null>(null);
|
| 47 |
+
const [members, setMembers] = useState<Member[]>([]);
|
| 48 |
+
const [loading, setLoading] = useState(true);
|
| 49 |
+
const [showAddMember, setShowAddMember] = useState(false);
|
| 50 |
+
|
| 51 |
+
// Form State
|
| 52 |
+
const [newMember, setNewMember] = useState({
|
| 53 |
+
name: "",
|
| 54 |
+
email: "",
|
| 55 |
+
role: "worker",
|
| 56 |
+
password: "", // Required by backend
|
| 57 |
+
phone: "",
|
| 58 |
+
city: "",
|
| 59 |
+
max_workload: 10,
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
useEffect(() => {
|
| 63 |
+
if (params.id) {
|
| 64 |
+
loadData(params.id as string);
|
| 65 |
+
}
|
| 66 |
+
}, [params.id]);
|
| 67 |
+
|
| 68 |
+
const loadData = async (deptId: string) => {
|
| 69 |
+
try {
|
| 70 |
+
const [deptData, membersData] = await Promise.all([
|
| 71 |
+
apiGet<Department>(`/admin/departments/${deptId}`),
|
| 72 |
+
apiGet<Member[]>(`/admin/members?department_id=${deptId}`),
|
| 73 |
+
]);
|
| 74 |
+
setDepartment(deptData);
|
| 75 |
+
setMembers(membersData);
|
| 76 |
+
} catch (error) {
|
| 77 |
+
console.error("Failed to load department data:", error);
|
| 78 |
+
} finally {
|
| 79 |
+
setLoading(false);
|
| 80 |
+
}
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
const handleAddMember = async (e: React.FormEvent) => {
|
| 84 |
+
e.preventDefault();
|
| 85 |
+
if (!department) return;
|
| 86 |
+
|
| 87 |
+
try {
|
| 88 |
+
await apiPost("/admin/members", {
|
| 89 |
+
...newMember,
|
| 90 |
+
department_id: department.id,
|
| 91 |
+
locality: "General", // Default for now
|
| 92 |
+
});
|
| 93 |
+
setShowAddMember(false);
|
| 94 |
+
setNewMember({
|
| 95 |
+
name: "",
|
| 96 |
+
email: "",
|
| 97 |
+
role: "worker",
|
| 98 |
+
password: "",
|
| 99 |
+
phone: "",
|
| 100 |
+
city: "",
|
| 101 |
+
max_workload: 10,
|
| 102 |
+
});
|
| 103 |
+
loadData(department.id); // Refresh list
|
| 104 |
+
alert("Member added successfully!");
|
| 105 |
+
} catch (error: any) {
|
| 106 |
+
alert(error.message || "Failed to add member");
|
| 107 |
+
}
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
const handleDeleteMember = async (memberId: string) => {
|
| 111 |
+
if (!confirm("Are you sure you want to remove this member?")) return;
|
| 112 |
+
try {
|
| 113 |
+
// Assuming there is a delete endpoint, though strictly not in the original brief, it's good UX
|
| 114 |
+
// backend/api/routes/admin.py lines 612-624 supports DELETE /members/{member_id}
|
| 115 |
+
const token = localStorage.getItem("supabase_token");
|
| 116 |
+
await fetch(
|
| 117 |
+
`${process.env.NEXT_PUBLIC_API_URL}/admin/members/${memberId}`,
|
| 118 |
+
{
|
| 119 |
+
method: "DELETE",
|
| 120 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 121 |
+
},
|
| 122 |
+
);
|
| 123 |
+
setMembers(members.filter((m) => m.id !== memberId));
|
| 124 |
+
} catch (error) {
|
| 125 |
+
console.error("Delete failed", error);
|
| 126 |
+
}
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
if (loading) {
|
| 130 |
+
return (
|
| 131 |
+
<div className="p-8 text-center text-slate-500">
|
| 132 |
+
Loading Department...
|
| 133 |
+
</div>
|
| 134 |
+
);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
if (!department) {
|
| 138 |
+
return <div className="p-8 text-center">Department not found</div>;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
return (
|
| 142 |
+
<div className="max-w-6xl mx-auto space-y-8 p-6">
|
| 143 |
+
{/* Header */}
|
| 144 |
+
<div>
|
| 145 |
+
<Link
|
| 146 |
+
href="/admin/departments"
|
| 147 |
+
className="inline-flex items-center gap-2 text-slate-500 hover:text-urban-primary mb-4"
|
| 148 |
+
>
|
| 149 |
+
<ArrowLeft className="w-4 h-4" /> Back to Departments
|
| 150 |
+
</Link>
|
| 151 |
+
<div className="flex justify-between items-start">
|
| 152 |
+
<div>
|
| 153 |
+
<div className="flex items-center gap-3 mb-1">
|
| 154 |
+
<h1 className="text-3xl font-bold text-slate-900">
|
| 155 |
+
{department.name}
|
| 156 |
+
</h1>
|
| 157 |
+
<span className="bg-slate-100 text-slate-600 px-2 py-1 rounded text-sm font-mono font-bold border border-slate-200">
|
| 158 |
+
{department.code}
|
| 159 |
+
</span>
|
| 160 |
+
</div>
|
| 161 |
+
<p className="text-slate-500">
|
| 162 |
+
{department.description || "No description provided."}
|
| 163 |
+
</p>
|
| 164 |
+
</div>
|
| 165 |
+
<div className="flex gap-3">
|
| 166 |
+
<div className="text-right">
|
| 167 |
+
<div className="text-xs text-slate-400 font-bold uppercase tracking-wider mb-1">
|
| 168 |
+
SLA Limit
|
| 169 |
+
</div>
|
| 170 |
+
<div className="font-mono font-bold text-slate-700 bg-slate-50 px-3 py-1 rounded border border-slate-200">
|
| 171 |
+
{department.default_sla_hours}h
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
|
| 178 |
+
{/* Stats Cards */}
|
| 179 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 180 |
+
<div className="card border-l-4 border-l-blue-500 p-6">
|
| 181 |
+
<div className="flex justify-between items-center">
|
| 182 |
+
<div>
|
| 183 |
+
<p className="text-sm font-bold text-slate-400 uppercase tracking-wider">
|
| 184 |
+
Total Staff
|
| 185 |
+
</p>
|
| 186 |
+
<h3 className="text-3xl font-bold text-slate-900 mt-1">
|
| 187 |
+
{members.length}
|
| 188 |
+
</h3>
|
| 189 |
+
</div>
|
| 190 |
+
<div className="h-10 w-10 bg-blue-50 rounded-lg flex items-center justify-center text-blue-600">
|
| 191 |
+
<Users className="w-6 h-6" />
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
{/* Add more stats if available */}
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
{/* Members Section */}
|
| 199 |
+
<div className="space-y-6">
|
| 200 |
+
<div className="flex justify-between items-center">
|
| 201 |
+
<h2 className="text-xl font-bold text-slate-900 flex items-center gap-2">
|
| 202 |
+
<Users className="w-5 h-5 text-slate-400" />
|
| 203 |
+
Department Members
|
| 204 |
+
</h2>
|
| 205 |
+
<button
|
| 206 |
+
onClick={() => setShowAddMember(true)}
|
| 207 |
+
className="btn-primary flex items-center gap-2"
|
| 208 |
+
>
|
| 209 |
+
<Plus className="w-4 h-4" /> Add Worker
|
| 210 |
+
</button>
|
| 211 |
+
</div>
|
| 212 |
+
|
| 213 |
+
{/* Add Member Form (Inline/Expandable) */}
|
| 214 |
+
{showAddMember && (
|
| 215 |
+
<div className="card bg-slate-50 border-slate-200 animate-in fade-in slide-in-from-top-4">
|
| 216 |
+
<div className="flex justify-between items-center mb-6 pb-4 border-b border-slate-200">
|
| 217 |
+
<h3 className="text-lg font-bold text-slate-800">
|
| 218 |
+
Add New Member
|
| 219 |
+
</h3>
|
| 220 |
+
<button
|
| 221 |
+
onClick={() => setShowAddMember(false)}
|
| 222 |
+
className="text-slate-400 hover:text-slate-600"
|
| 223 |
+
>
|
| 224 |
+
Cancel
|
| 225 |
+
</button>
|
| 226 |
+
</div>
|
| 227 |
+
<form
|
| 228 |
+
onSubmit={handleAddMember}
|
| 229 |
+
className="grid grid-cols-1 md:grid-cols-2 gap-6"
|
| 230 |
+
>
|
| 231 |
+
<div>
|
| 232 |
+
<label className="text-sm font-bold text-slate-700 mb-1 block">
|
| 233 |
+
Full Name
|
| 234 |
+
</label>
|
| 235 |
+
<input
|
| 236 |
+
required
|
| 237 |
+
className="input w-full bg-white"
|
| 238 |
+
placeholder="John Doe"
|
| 239 |
+
value={newMember.name}
|
| 240 |
+
onChange={(e) =>
|
| 241 |
+
setNewMember({ ...newMember, name: e.target.value })
|
| 242 |
+
}
|
| 243 |
+
/>
|
| 244 |
+
</div>
|
| 245 |
+
<div>
|
| 246 |
+
<label className="text-sm font-bold text-slate-700 mb-1 block">
|
| 247 |
+
Email Address
|
| 248 |
+
</label>
|
| 249 |
+
<input
|
| 250 |
+
required
|
| 251 |
+
type="email"
|
| 252 |
+
className="input w-full bg-white"
|
| 253 |
+
placeholder="john@city.gov"
|
| 254 |
+
value={newMember.email}
|
| 255 |
+
onChange={(e) =>
|
| 256 |
+
setNewMember({ ...newMember, email: e.target.value })
|
| 257 |
+
}
|
| 258 |
+
/>
|
| 259 |
+
</div>
|
| 260 |
+
<div>
|
| 261 |
+
<label className="text-sm font-bold text-slate-700 mb-1 block">
|
| 262 |
+
Role
|
| 263 |
+
</label>
|
| 264 |
+
<select
|
| 265 |
+
className="input w-full bg-white"
|
| 266 |
+
value={newMember.role}
|
| 267 |
+
onChange={(e) =>
|
| 268 |
+
setNewMember({ ...newMember, role: e.target.value })
|
| 269 |
+
}
|
| 270 |
+
>
|
| 271 |
+
<option value="worker">Field Worker</option>
|
| 272 |
+
<option value="officer">Department Officer</option>
|
| 273 |
+
<option value="admin">Admin (Restricted)</option>
|
| 274 |
+
</select>
|
| 275 |
+
</div>
|
| 276 |
+
<div>
|
| 277 |
+
<label className="text-sm font-bold text-slate-700 mb-1 block">
|
| 278 |
+
Initial Password
|
| 279 |
+
</label>
|
| 280 |
+
<input
|
| 281 |
+
required
|
| 282 |
+
type="password"
|
| 283 |
+
className="input w-full bg-white"
|
| 284 |
+
placeholder="••••••••"
|
| 285 |
+
value={newMember.password}
|
| 286 |
+
onChange={(e) =>
|
| 287 |
+
setNewMember({ ...newMember, password: e.target.value })
|
| 288 |
+
}
|
| 289 |
+
/>
|
| 290 |
+
</div>
|
| 291 |
+
<div>
|
| 292 |
+
<label className="text-sm font-bold text-slate-700 mb-1 block">
|
| 293 |
+
Phone (Optional)
|
| 294 |
+
</label>
|
| 295 |
+
<input
|
| 296 |
+
className="input w-full bg-white"
|
| 297 |
+
placeholder="+1 234..."
|
| 298 |
+
value={newMember.phone}
|
| 299 |
+
onChange={(e) =>
|
| 300 |
+
setNewMember({ ...newMember, phone: e.target.value })
|
| 301 |
+
}
|
| 302 |
+
/>
|
| 303 |
+
</div>
|
| 304 |
+
<div>
|
| 305 |
+
<label className="text-sm font-bold text-slate-700 mb-1 block">
|
| 306 |
+
City (Optional)
|
| 307 |
+
</label>
|
| 308 |
+
<input
|
| 309 |
+
className="input w-full bg-white"
|
| 310 |
+
placeholder="East District"
|
| 311 |
+
value={newMember.city}
|
| 312 |
+
onChange={(e) =>
|
| 313 |
+
setNewMember({ ...newMember, city: e.target.value })
|
| 314 |
+
}
|
| 315 |
+
/>
|
| 316 |
+
</div>
|
| 317 |
+
<div className="md:col-span-2 pt-4 flex justify-end gap-3">
|
| 318 |
+
<button
|
| 319 |
+
type="button"
|
| 320 |
+
onClick={() => setShowAddMember(false)}
|
| 321 |
+
className="btn-secondary"
|
| 322 |
+
>
|
| 323 |
+
Cancel
|
| 324 |
+
</button>
|
| 325 |
+
<button type="submit" className="btn-primary">
|
| 326 |
+
Create Member Account
|
| 327 |
+
</button>
|
| 328 |
+
</div>
|
| 329 |
+
</form>
|
| 330 |
+
</div>
|
| 331 |
+
)}
|
| 332 |
+
|
| 333 |
+
{/* Members List */}
|
| 334 |
+
<div className="card p-0 overflow-hidden">
|
| 335 |
+
<table className="w-full text-left">
|
| 336 |
+
<thead className="bg-slate-50 border-b border-slate-200">
|
| 337 |
+
<tr>
|
| 338 |
+
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-wider">
|
| 339 |
+
Name / Email
|
| 340 |
+
</th>
|
| 341 |
+
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-wider">
|
| 342 |
+
Role
|
| 343 |
+
</th>
|
| 344 |
+
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-wider">
|
| 345 |
+
Workload
|
| 346 |
+
</th>
|
| 347 |
+
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-wider">
|
| 348 |
+
Location
|
| 349 |
+
</th>
|
| 350 |
+
<th className="px-6 py-4 text-right text-xs font-bold text-slate-500 uppercase tracking-wider">
|
| 351 |
+
Actions
|
| 352 |
+
</th>
|
| 353 |
+
</tr>
|
| 354 |
+
</thead>
|
| 355 |
+
<tbody className="divide-y divide-slate-100">
|
| 356 |
+
{members.map((member) => (
|
| 357 |
+
<tr
|
| 358 |
+
key={member.id}
|
| 359 |
+
className="hover:bg-slate-50/50 transition-colors"
|
| 360 |
+
>
|
| 361 |
+
<td className="px-6 py-4">
|
| 362 |
+
<div className="flex items-center gap-3">
|
| 363 |
+
<div
|
| 364 |
+
className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm ${
|
| 365 |
+
member.role === "admin"
|
| 366 |
+
? "bg-purple-100 text-purple-600"
|
| 367 |
+
: member.role === "officer"
|
| 368 |
+
? "bg-blue-100 text-blue-600"
|
| 369 |
+
: "bg-emerald-100 text-emerald-600"
|
| 370 |
+
}`}
|
| 371 |
+
>
|
| 372 |
+
{member.name.charAt(0)}
|
| 373 |
+
</div>
|
| 374 |
+
<div>
|
| 375 |
+
<div className="font-bold text-slate-900">
|
| 376 |
+
{member.name}
|
| 377 |
+
</div>
|
| 378 |
+
<div className="text-xs text-slate-500 flex items-center gap-1">
|
| 379 |
+
<Mail className="w-3 h-3" /> {member.email}
|
| 380 |
+
</div>
|
| 381 |
+
</div>
|
| 382 |
+
</div>
|
| 383 |
+
</td>
|
| 384 |
+
<td className="px-6 py-4">
|
| 385 |
+
<span
|
| 386 |
+
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wide border ${
|
| 387 |
+
member.role === "worker"
|
| 388 |
+
? "bg-emerald-50 text-emerald-700 border-emerald-100"
|
| 389 |
+
: member.role === "officer"
|
| 390 |
+
? "bg-blue-50 text-blue-700 border-blue-100"
|
| 391 |
+
: "bg-purple-50 text-purple-700 border-purple-100"
|
| 392 |
+
}`}
|
| 393 |
+
>
|
| 394 |
+
{member.role === "worker" ? (
|
| 395 |
+
<Briefcase className="w-3 h-3" />
|
| 396 |
+
) : (
|
| 397 |
+
<Shield className="w-3 h-3" />
|
| 398 |
+
)}
|
| 399 |
+
{member.role}
|
| 400 |
+
</span>
|
| 401 |
+
</td>
|
| 402 |
+
<td className="px-6 py-4">
|
| 403 |
+
<div className="flex items-center gap-2">
|
| 404 |
+
<div className="w-24 h-2 bg-slate-100 rounded-full overflow-hidden">
|
| 405 |
+
<div
|
| 406 |
+
className={`h-full rounded-full ${member.current_workload > 5 ? "bg-orange-500" : "bg-green-500"}`}
|
| 407 |
+
style={{
|
| 408 |
+
width: `${(member.current_workload / (member.max_workload || 10)) * 100}%`,
|
| 409 |
+
}}
|
| 410 |
+
></div>
|
| 411 |
+
</div>
|
| 412 |
+
<span className="text-xs font-mono text-slate-500">
|
| 413 |
+
{member.current_workload}/{member.max_workload || 10}
|
| 414 |
+
</span>
|
| 415 |
+
</div>
|
| 416 |
+
</td>
|
| 417 |
+
<td className="px-6 py-4">
|
| 418 |
+
{member.city ? (
|
| 419 |
+
<div className="flex items-center gap-1.5 text-sm text-slate-600">
|
| 420 |
+
<MapPin className="w-4 h-4 text-slate-400" />
|
| 421 |
+
{member.city}
|
| 422 |
+
</div>
|
| 423 |
+
) : (
|
| 424 |
+
<span className="text-slate-400 text-xs italic">
|
| 425 |
+
Unassigned
|
| 426 |
+
</span>
|
| 427 |
+
)}
|
| 428 |
+
</td>
|
| 429 |
+
<td className="px-6 py-4 text-right">
|
| 430 |
+
<button
|
| 431 |
+
onClick={() => handleDeleteMember(member.id)}
|
| 432 |
+
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
| 433 |
+
title="Remove Member"
|
| 434 |
+
>
|
| 435 |
+
<Trash2 className="w-4 h-4" />
|
| 436 |
+
</button>
|
| 437 |
+
</td>
|
| 438 |
+
</tr>
|
| 439 |
+
))}
|
| 440 |
+
{members.length === 0 && (
|
| 441 |
+
<tr>
|
| 442 |
+
<td
|
| 443 |
+
colSpan={5}
|
| 444 |
+
className="px-6 py-12 text-center text-slate-500"
|
| 445 |
+
>
|
| 446 |
+
No members found in this department.
|
| 447 |
+
</td>
|
| 448 |
+
</tr>
|
| 449 |
+
)}
|
| 450 |
+
</tbody>
|
| 451 |
+
</table>
|
| 452 |
+
</div>
|
| 453 |
+
</div>
|
| 454 |
+
</div>
|
| 455 |
+
);
|
| 456 |
+
}
|
Frontend/app/admin/departments/page.tsx
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useEffect, useState } from "react";
|
| 3 |
+
import { apiGet, apiPost } from "@/lib/api";
|
| 4 |
+
import { Building2, Plus, Search } from "lucide-react";
|
| 5 |
+
|
| 6 |
+
interface Department {
|
| 7 |
+
id: string;
|
| 8 |
+
name: string;
|
| 9 |
+
code: string;
|
| 10 |
+
description: string;
|
| 11 |
+
default_sla_hours: number;
|
| 12 |
+
is_active: boolean;
|
| 13 |
+
member_count: number;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export default function DepartmentsPage() {
|
| 17 |
+
const [departments, setDepartments] = useState<Department[]>([]);
|
| 18 |
+
const [loading, setLoading] = useState(true);
|
| 19 |
+
const [showForm, setShowForm] = useState(false);
|
| 20 |
+
const [formData, setFormData] = useState({
|
| 21 |
+
name: "",
|
| 22 |
+
code: "",
|
| 23 |
+
description: "",
|
| 24 |
+
default_sla_hours: 48,
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
useEffect(() => {
|
| 28 |
+
fetchDepartments();
|
| 29 |
+
}, []);
|
| 30 |
+
|
| 31 |
+
const fetchDepartments = async () => {
|
| 32 |
+
try {
|
| 33 |
+
const data = await apiGet<Department[]>("/admin/departments");
|
| 34 |
+
setDepartments(data);
|
| 35 |
+
} catch (error) {
|
| 36 |
+
console.error("Failed to fetch departments:", error);
|
| 37 |
+
} finally {
|
| 38 |
+
setLoading(false);
|
| 39 |
+
}
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 43 |
+
e.preventDefault();
|
| 44 |
+
try {
|
| 45 |
+
await apiPost("/admin/departments", formData);
|
| 46 |
+
setShowForm(false);
|
| 47 |
+
setFormData({
|
| 48 |
+
name: "",
|
| 49 |
+
code: "",
|
| 50 |
+
description: "",
|
| 51 |
+
default_sla_hours: 48,
|
| 52 |
+
});
|
| 53 |
+
fetchDepartments();
|
| 54 |
+
} catch (error: unknown) {
|
| 55 |
+
const message =
|
| 56 |
+
error instanceof Error ? error.message : "Failed to create department";
|
| 57 |
+
alert(message);
|
| 58 |
+
}
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
if (loading) {
|
| 62 |
+
return (
|
| 63 |
+
<div className="text-slate-600 font-medium">Loading Departments...</div>
|
| 64 |
+
);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
return (
|
| 68 |
+
<div className="space-y-6">
|
| 69 |
+
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
| 70 |
+
<div>
|
| 71 |
+
<h2 className="text-2xl font-bold text-slate-900">Departments</h2>
|
| 72 |
+
<p className="text-sm text-slate-500">
|
| 73 |
+
Organizational units and SLA configurations.
|
| 74 |
+
</p>
|
| 75 |
+
</div>
|
| 76 |
+
<button
|
| 77 |
+
onClick={() => setShowForm(true)}
|
| 78 |
+
className="px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition shadow-sm flex items-center gap-2"
|
| 79 |
+
>
|
| 80 |
+
<Plus className="w-4 h-4" /> New Department
|
| 81 |
+
</button>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
{showForm && (
|
| 85 |
+
<div className="bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden animate-in fade-in slide-in-from-top-4">
|
| 86 |
+
<div className="bg-slate-50 px-6 py-4 border-b border-slate-200">
|
| 87 |
+
<h2 className="text-lg font-bold text-slate-800">
|
| 88 |
+
Create New Department
|
| 89 |
+
</h2>
|
| 90 |
+
</div>
|
| 91 |
+
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
| 92 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 93 |
+
<div>
|
| 94 |
+
<label className="block text-sm font-medium text-slate-700 mb-1">
|
| 95 |
+
Department Name
|
| 96 |
+
</label>
|
| 97 |
+
<input
|
| 98 |
+
type="text"
|
| 99 |
+
value={formData.name}
|
| 100 |
+
onChange={(e) =>
|
| 101 |
+
setFormData({ ...formData, name: e.target.value })
|
| 102 |
+
}
|
| 103 |
+
className="w-full px-4 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
|
| 104 |
+
placeholder="e.g., Public Works Department"
|
| 105 |
+
required
|
| 106 |
+
/>
|
| 107 |
+
</div>
|
| 108 |
+
<div>
|
| 109 |
+
<label className="block text-sm font-medium text-slate-700 mb-1">
|
| 110 |
+
Code
|
| 111 |
+
</label>
|
| 112 |
+
<input
|
| 113 |
+
type="text"
|
| 114 |
+
value={formData.code}
|
| 115 |
+
onChange={(e) =>
|
| 116 |
+
setFormData({
|
| 117 |
+
...formData,
|
| 118 |
+
code: e.target.value.toUpperCase(),
|
| 119 |
+
})
|
| 120 |
+
}
|
| 121 |
+
className="w-full px-4 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
|
| 122 |
+
placeholder="e.g., PWD"
|
| 123 |
+
required
|
| 124 |
+
/>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
+
<div>
|
| 129 |
+
<label
|
| 130 |
+
className="block text-sm font-medium text-slate-700 mb-1"
|
| 131 |
+
htmlFor="dept-desc"
|
| 132 |
+
>
|
| 133 |
+
Description
|
| 134 |
+
</label>
|
| 135 |
+
<textarea
|
| 136 |
+
id="dept-desc"
|
| 137 |
+
value={formData.description}
|
| 138 |
+
onChange={(e) =>
|
| 139 |
+
setFormData({ ...formData, description: e.target.value })
|
| 140 |
+
}
|
| 141 |
+
className="w-full px-4 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
|
| 142 |
+
rows={2}
|
| 143 |
+
placeholder="Brief description of responsibilities..."
|
| 144 |
+
/>
|
| 145 |
+
</div>
|
| 146 |
+
|
| 147 |
+
<div>
|
| 148 |
+
<label
|
| 149 |
+
className="block text-sm font-medium text-slate-700 mb-1"
|
| 150 |
+
htmlFor="dept-sla"
|
| 151 |
+
>
|
| 152 |
+
Default SLA (Hours)
|
| 153 |
+
</label>
|
| 154 |
+
<input
|
| 155 |
+
id="dept-sla"
|
| 156 |
+
type="number"
|
| 157 |
+
value={formData.default_sla_hours}
|
| 158 |
+
onChange={(e) =>
|
| 159 |
+
setFormData({
|
| 160 |
+
...formData,
|
| 161 |
+
default_sla_hours: parseInt(e.target.value),
|
| 162 |
+
})
|
| 163 |
+
}
|
| 164 |
+
className="w-32 px-4 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
|
| 165 |
+
/>
|
| 166 |
+
</div>
|
| 167 |
+
|
| 168 |
+
<div className="flex gap-3 pt-2">
|
| 169 |
+
<button
|
| 170 |
+
type="submit"
|
| 171 |
+
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition shadow-sm"
|
| 172 |
+
>
|
| 173 |
+
Create Department
|
| 174 |
+
</button>
|
| 175 |
+
<button
|
| 176 |
+
type="button"
|
| 177 |
+
onClick={() => setShowForm(false)}
|
| 178 |
+
className="px-6 py-2 bg-white text-slate-700 font-medium rounded-lg border border-slate-300 hover:bg-slate-50 transition"
|
| 179 |
+
>
|
| 180 |
+
Cancel
|
| 181 |
+
</button>
|
| 182 |
+
</div>
|
| 183 |
+
</form>
|
| 184 |
+
</div>
|
| 185 |
+
)}
|
| 186 |
+
|
| 187 |
+
<div className="space-y-4">
|
| 188 |
+
{departments.length === 0 ? (
|
| 189 |
+
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
|
| 190 |
+
<Building2 className="w-12 h-12 mx-auto text-slate-300" />
|
| 191 |
+
<p className="text-slate-500 mt-4 text-lg">No departments found.</p>
|
| 192 |
+
<p className="text-slate-400 text-sm">
|
| 193 |
+
Create your first organizational unit to get started.
|
| 194 |
+
</p>
|
| 195 |
+
</div>
|
| 196 |
+
) : (
|
| 197 |
+
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
| 198 |
+
<table className="min-w-full divide-y divide-slate-200">
|
| 199 |
+
<thead className="bg-slate-50">
|
| 200 |
+
<tr>
|
| 201 |
+
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
| 202 |
+
Department
|
| 203 |
+
</th>
|
| 204 |
+
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
| 205 |
+
Details
|
| 206 |
+
</th>
|
| 207 |
+
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
| 208 |
+
Status
|
| 209 |
+
</th>
|
| 210 |
+
<th className="px-6 py-4 text-right text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
| 211 |
+
Staff Count
|
| 212 |
+
</th>
|
| 213 |
+
</tr>
|
| 214 |
+
</thead>
|
| 215 |
+
<tbody className="divide-y divide-slate-200">
|
| 216 |
+
{departments.map((dept) => (
|
| 217 |
+
<tr key={dept.id} className="hover:bg-slate-50 transition">
|
| 218 |
+
<td className="px-6 py-4 whitespace-nowrap">
|
| 219 |
+
<div className="flex items-center">
|
| 220 |
+
<div className="shrink-0 h-10 w-10 rounded-lg bg-blue-100 text-blue-600 flex items-center justify-center font-bold text-sm">
|
| 221 |
+
{dept.code}
|
| 222 |
+
</div>
|
| 223 |
+
<div className="ml-4">
|
| 224 |
+
<div className="text-sm font-bold text-slate-900">
|
| 225 |
+
{dept.name}
|
| 226 |
+
</div>
|
| 227 |
+
<div className="text-xs text-slate-500">
|
| 228 |
+
Code: {dept.code}
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
</td>
|
| 233 |
+
<td className="px-6 py-4">
|
| 234 |
+
<p className="text-sm text-slate-600 line-clamp-1">
|
| 235 |
+
{dept.description || "-"}
|
| 236 |
+
</p>
|
| 237 |
+
<p className="text-xs text-slate-400 mt-0.5">
|
| 238 |
+
SLA: {dept.default_sla_hours}h
|
| 239 |
+
</p>
|
| 240 |
+
</td>
|
| 241 |
+
<td className="px-6 py-4 whitespace-nowrap">
|
| 242 |
+
<span
|
| 243 |
+
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
| 244 |
+
dept.is_active
|
| 245 |
+
? "bg-green-100 text-green-800"
|
| 246 |
+
: "bg-red-100 text-red-800"
|
| 247 |
+
}`}
|
| 248 |
+
>
|
| 249 |
+
{dept.is_active ? "Active" : "Inactive"}
|
| 250 |
+
</span>
|
| 251 |
+
</td>
|
| 252 |
+
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
| 253 |
+
<span className="text-slate-900 font-bold">
|
| 254 |
+
{dept.member_count}
|
| 255 |
+
</span>
|
| 256 |
+
<span className="text-slate-500 ml-1">staff</span>
|
| 257 |
+
</td>
|
| 258 |
+
</tr>
|
| 259 |
+
))}
|
| 260 |
+
</tbody>
|
| 261 |
+
</table>
|
| 262 |
+
</div>
|
| 263 |
+
)}
|
| 264 |
+
</div>
|
| 265 |
+
</div>
|
| 266 |
+
);
|
| 267 |
+
}
|
Frontend/app/admin/heatmap/page.tsx
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useEffect, useState } from "react";
|
| 3 |
+
import { apiGet } from "@/lib/api";
|
| 4 |
+
import { Map } from "lucide-react";
|
| 5 |
+
|
| 6 |
+
interface HeatmapData {
|
| 7 |
+
city: string;
|
| 8 |
+
count: number;
|
| 9 |
+
priority_avg: number;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export default function HeatmapPage() {
|
| 13 |
+
const [data, setData] = useState<HeatmapData[]>([]);
|
| 14 |
+
const [loading, setLoading] = useState(true);
|
| 15 |
+
|
| 16 |
+
useEffect(() => {
|
| 17 |
+
fetchHeatmap();
|
| 18 |
+
}, []);
|
| 19 |
+
|
| 20 |
+
const fetchHeatmap = async () => {
|
| 21 |
+
try {
|
| 22 |
+
const heatmapData = await apiGet<HeatmapData[]>("/admin/stats/heatmap");
|
| 23 |
+
setData(heatmapData);
|
| 24 |
+
} catch (error) {
|
| 25 |
+
console.error("Failed to fetch heatmap:", error);
|
| 26 |
+
} finally {
|
| 27 |
+
setLoading(false);
|
| 28 |
+
}
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
const getIntensityColor = (count: number, max: number) => {
|
| 32 |
+
const intensity = count / max;
|
| 33 |
+
if (intensity > 0.75) return "bg-red-600";
|
| 34 |
+
if (intensity > 0.5) return "bg-orange-500";
|
| 35 |
+
if (intensity > 0.25) return "bg-amber-400";
|
| 36 |
+
return "bg-emerald-500";
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
if (loading) {
|
| 40 |
+
return <div className="text-slate-600 font-medium">Loading Analytics...</div>;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const maxCount = Math.max(...data.map((d) => d.count), 1);
|
| 44 |
+
|
| 45 |
+
return (
|
| 46 |
+
<div className="space-y-6">
|
| 47 |
+
<div>
|
| 48 |
+
<h2 className="text-2xl font-bold text-slate-900">Geographic Heatmap</h2>
|
| 49 |
+
<p className="text-sm text-slate-500">Distribution of issues across city districts.</p>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm mb-8">
|
| 53 |
+
<div className="flex items-center justify-between">
|
| 54 |
+
<h2 className="text-lg font-bold text-slate-900">Issue Density by City</h2>
|
| 55 |
+
<div className="flex gap-4 text-xs font-semibold uppercase text-slate-500">
|
| 56 |
+
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-full bg-emerald-500"></div> Low</div>
|
| 57 |
+
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-full bg-amber-400"></div> Medium</div>
|
| 58 |
+
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-full bg-orange-500"></div> High</div>
|
| 59 |
+
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-full bg-red-600"></div> Critical</div>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
{data.length === 0 ? (
|
| 65 |
+
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
|
| 66 |
+
<Map className="w-12 h-12 mx-auto text-slate-300" />
|
| 67 |
+
<p className="text-slate-500 mt-2">No location data available yet.</p>
|
| 68 |
+
</div>
|
| 69 |
+
) : (
|
| 70 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
| 71 |
+
{data.map((item) => (
|
| 72 |
+
<div
|
| 73 |
+
key={item.city}
|
| 74 |
+
className="group relative bg-white overflow-hidden rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-all"
|
| 75 |
+
>
|
| 76 |
+
<div className="absolute top-0 left-0 w-full h-1.5 bg-slate-100">
|
| 77 |
+
<div
|
| 78 |
+
className={`h-full ${getIntensityColor(item.count, maxCount)}`}
|
| 79 |
+
style={{ width: `${(item.count / maxCount) * 100}%` }}
|
| 80 |
+
></div>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<div className="p-6">
|
| 84 |
+
<div className="flex justify-between items-start mb-4">
|
| 85 |
+
<h3 className="text-lg font-bold text-slate-900">{item.city}</h3>
|
| 86 |
+
<span className={`w-8 h-8 flex items-center justify-center rounded-lg text-white font-bold text-sm ${getIntensityColor(item.count, maxCount)}`}>
|
| 87 |
+
{Math.round((item.count / maxCount) * 10)}
|
| 88 |
+
</span>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<div className="flex items-baseline gap-1">
|
| 92 |
+
<span className="text-3xl font-extrabold text-slate-900">{item.count}</span>
|
| 93 |
+
<span className="text-sm font-medium text-slate-500">issues</span>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
))}
|
| 98 |
+
</div>
|
| 99 |
+
)}
|
| 100 |
+
</div>
|
| 101 |
+
);
|
| 102 |
+
}
|
Frontend/app/admin/issues/[id]/page.tsx
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
export const runtime = "edge";
|
| 4 |
+
|
| 5 |
+
import { useEffect, useState } from "react";
|
| 6 |
+
import { apiGet, apiPost } from "@/lib/api";
|
| 7 |
+
import { useParams, useRouter } from "next/navigation";
|
| 8 |
+
import {
|
| 9 |
+
ArrowLeft,
|
| 10 |
+
MapPin,
|
| 11 |
+
Clock,
|
| 12 |
+
AlertTriangle,
|
| 13 |
+
CheckCircle2,
|
| 14 |
+
User,
|
| 15 |
+
Building2,
|
| 16 |
+
Calendar,
|
| 17 |
+
Activity,
|
| 18 |
+
Layers,
|
| 19 |
+
Image as ImageIcon,
|
| 20 |
+
ThumbsUp,
|
| 21 |
+
ThumbsDown,
|
| 22 |
+
MoreHorizontal,
|
| 23 |
+
Pencil,
|
| 24 |
+
Save,
|
| 25 |
+
X,
|
| 26 |
+
} from "lucide-react";
|
| 27 |
+
import Link from "next/link";
|
| 28 |
+
|
| 29 |
+
interface IssueDetail {
|
| 30 |
+
issue: {
|
| 31 |
+
id: string;
|
| 32 |
+
description: string;
|
| 33 |
+
state: string;
|
| 34 |
+
priority: number;
|
| 35 |
+
latitude: number;
|
| 36 |
+
longitude: number;
|
| 37 |
+
city: string;
|
| 38 |
+
locality: string;
|
| 39 |
+
full_address: string;
|
| 40 |
+
image_urls: string[];
|
| 41 |
+
annotated_urls: string[];
|
| 42 |
+
proof_image_url: string | null;
|
| 43 |
+
created_at: string;
|
| 44 |
+
confidence: number;
|
| 45 |
+
category: string;
|
| 46 |
+
validation_source: string;
|
| 47 |
+
validation_reason: string;
|
| 48 |
+
is_duplicate: boolean;
|
| 49 |
+
sla_deadline: string;
|
| 50 |
+
assigned_member_id?: string;
|
| 51 |
+
};
|
| 52 |
+
department: {
|
| 53 |
+
id: string;
|
| 54 |
+
name: string;
|
| 55 |
+
} | null;
|
| 56 |
+
worker: {
|
| 57 |
+
id: string;
|
| 58 |
+
name: string;
|
| 59 |
+
email: string;
|
| 60 |
+
} | null;
|
| 61 |
+
events: {
|
| 62 |
+
id: string;
|
| 63 |
+
type: string;
|
| 64 |
+
agent: string;
|
| 65 |
+
data: string;
|
| 66 |
+
created_at: string;
|
| 67 |
+
}[];
|
| 68 |
+
duplicates: any[];
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
interface Worker {
|
| 72 |
+
id: string;
|
| 73 |
+
name: string;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
export default function IssueDetailPage() {
|
| 77 |
+
const { id } = useParams();
|
| 78 |
+
const router = useRouter();
|
| 79 |
+
const [data, setData] = useState<IssueDetail | null>(null);
|
| 80 |
+
const [loading, setLoading] = useState(true);
|
| 81 |
+
const [actionLoading, setActionLoading] = useState(false);
|
| 82 |
+
|
| 83 |
+
const [workers, setWorkers] = useState<Worker[]>([]);
|
| 84 |
+
const [editingAssignment, setEditingAssignment] = useState(false);
|
| 85 |
+
const [editingPriority, setEditingPriority] = useState(false);
|
| 86 |
+
const [selectedWorker, setSelectedWorker] = useState("");
|
| 87 |
+
const [selectedPriority, setSelectedPriority] = useState(0);
|
| 88 |
+
|
| 89 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
| 90 |
+
if (!API_URL) throw new Error("Missing NEXT_PUBLIC_API_URL");
|
| 91 |
+
|
| 92 |
+
useEffect(() => {
|
| 93 |
+
if (id) {
|
| 94 |
+
fetchIssueDetails();
|
| 95 |
+
fetchWorkers();
|
| 96 |
+
}
|
| 97 |
+
}, [id]);
|
| 98 |
+
|
| 99 |
+
const fetchWorkers = async () => {
|
| 100 |
+
try {
|
| 101 |
+
const res = await apiGet<Worker[]>("/admin/members?role=worker");
|
| 102 |
+
setWorkers(res || []);
|
| 103 |
+
} catch (e) {
|
| 104 |
+
console.error("Failed to fetch workers", e);
|
| 105 |
+
}
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
const fetchIssueDetails = async () => {
|
| 109 |
+
try {
|
| 110 |
+
const result = await apiGet<IssueDetail>(`/admin/issues/${id}/details`);
|
| 111 |
+
setData(result);
|
| 112 |
+
if (result) {
|
| 113 |
+
setSelectedPriority(result.issue.priority || 3);
|
| 114 |
+
setSelectedWorker(result.worker?.id || "");
|
| 115 |
+
}
|
| 116 |
+
} catch (error) {
|
| 117 |
+
console.error("Failed to fetch details:", error);
|
| 118 |
+
} finally {
|
| 119 |
+
setLoading(false);
|
| 120 |
+
}
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
const handleUpdate = async (updateData: any) => {
|
| 124 |
+
setActionLoading(true);
|
| 125 |
+
try {
|
| 126 |
+
const token = localStorage.getItem("token");
|
| 127 |
+
const headers: any = { "Content-Type": "application/json" };
|
| 128 |
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
| 129 |
+
|
| 130 |
+
const res = await fetch(`${API_URL}/admin/issues/${id}`, {
|
| 131 |
+
method: "PATCH",
|
| 132 |
+
headers,
|
| 133 |
+
body: JSON.stringify(updateData),
|
| 134 |
+
});
|
| 135 |
+
|
| 136 |
+
if (!res.ok) throw new Error("Failed to update");
|
| 137 |
+
|
| 138 |
+
await fetchIssueDetails();
|
| 139 |
+
setEditingAssignment(false);
|
| 140 |
+
setEditingPriority(false);
|
| 141 |
+
} catch (e) {
|
| 142 |
+
console.error(e);
|
| 143 |
+
alert("Update failed");
|
| 144 |
+
} finally {
|
| 145 |
+
setActionLoading(false);
|
| 146 |
+
}
|
| 147 |
+
};
|
| 148 |
+
|
| 149 |
+
const handleReview = async (status: "approved" | "rejected") => {
|
| 150 |
+
if (!confirm(`Are you sure you want to ${status} this issue?`)) return;
|
| 151 |
+
setActionLoading(true);
|
| 152 |
+
try {
|
| 153 |
+
await apiPost(`/admin/issues/${id}/review`, { status });
|
| 154 |
+
fetchIssueDetails();
|
| 155 |
+
} catch (error) {
|
| 156 |
+
console.error("Review failed:", error);
|
| 157 |
+
alert("Failed to update issue status.");
|
| 158 |
+
} finally {
|
| 159 |
+
setActionLoading(false);
|
| 160 |
+
}
|
| 161 |
+
};
|
| 162 |
+
|
| 163 |
+
const handleResolutionReview = async (action: "approve" | "reject") => {
|
| 164 |
+
const note = prompt(
|
| 165 |
+
action === "reject" ? "Reason for rejection:" : "Optional approval note:",
|
| 166 |
+
);
|
| 167 |
+
if (action === "reject" && !note) return;
|
| 168 |
+
|
| 169 |
+
setActionLoading(true);
|
| 170 |
+
try {
|
| 171 |
+
const token = localStorage.getItem("token");
|
| 172 |
+
const headers: any = { "Content-Type": "application/json" };
|
| 173 |
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
| 174 |
+
|
| 175 |
+
const res = await fetch(
|
| 176 |
+
`${API_URL}/admin/issues/${id}/approve_resolution`,
|
| 177 |
+
{
|
| 178 |
+
method: "POST",
|
| 179 |
+
headers,
|
| 180 |
+
body: JSON.stringify({ action, comment: note }),
|
| 181 |
+
},
|
| 182 |
+
);
|
| 183 |
+
|
| 184 |
+
if (!res.ok) throw new Error("Status update failed");
|
| 185 |
+
|
| 186 |
+
await fetchIssueDetails();
|
| 187 |
+
} catch (error) {
|
| 188 |
+
console.error("Resolution review failed:", error);
|
| 189 |
+
alert("Failed to update resolution status.");
|
| 190 |
+
} finally {
|
| 191 |
+
setActionLoading(false);
|
| 192 |
+
}
|
| 193 |
+
};
|
| 194 |
+
|
| 195 |
+
if (loading)
|
| 196 |
+
return (
|
| 197 |
+
<div className="p-8 text-center text-slate-500">Loading details...</div>
|
| 198 |
+
);
|
| 199 |
+
if (!data)
|
| 200 |
+
return <div className="p-8 text-center text-red-500">Issue not found</div>;
|
| 201 |
+
|
| 202 |
+
const { issue, department, worker, events } = data;
|
| 203 |
+
|
| 204 |
+
return (
|
| 205 |
+
<div className="max-w-7xl mx-auto space-y-6">
|
| 206 |
+
<div className="flex items-center justify-between">
|
| 207 |
+
<div className="flex items-center gap-4">
|
| 208 |
+
<Link
|
| 209 |
+
href="/admin/issues"
|
| 210 |
+
className="p-2 hover:bg-slate-100 rounded-full text-slate-500"
|
| 211 |
+
>
|
| 212 |
+
<ArrowLeft className="w-5 h-5" />
|
| 213 |
+
</Link>
|
| 214 |
+
<div>
|
| 215 |
+
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-3">
|
| 216 |
+
Issue #{issue.id.slice(0, 8)}
|
| 217 |
+
<span
|
| 218 |
+
className={`text-sm px-2.5 py-0.5 rounded-full border font-medium uppercase tracking-wide
|
| 219 |
+
${
|
| 220 |
+
issue.state === "reported"
|
| 221 |
+
? "bg-blue-50 text-blue-700 border-blue-200"
|
| 222 |
+
: issue.state === "resolved"
|
| 223 |
+
? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
| 224 |
+
: "bg-slate-50 text-slate-700 border-slate-200"
|
| 225 |
+
}`}
|
| 226 |
+
>
|
| 227 |
+
{issue.state.replace("_", " ")}
|
| 228 |
+
</span>
|
| 229 |
+
</h1>
|
| 230 |
+
<p className="text-slate-500 text-sm flex items-center gap-2 mt-1">
|
| 231 |
+
<Calendar className="w-4 h-4" />
|
| 232 |
+
Reported on {new Date(issue.created_at).toLocaleString()}
|
| 233 |
+
</p>
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
<div className="flex items-center gap-3">
|
| 238 |
+
{issue.state === "pending_confirmation" && (
|
| 239 |
+
<>
|
| 240 |
+
<button
|
| 241 |
+
onClick={() => handleReview("rejected")}
|
| 242 |
+
disabled={actionLoading}
|
| 243 |
+
className="px-4 py-2 text-sm font-medium text-red-600 bg-white border border-red-200 rounded-lg hover:bg-red-50"
|
| 244 |
+
>
|
| 245 |
+
Reject & Close
|
| 246 |
+
</button>
|
| 247 |
+
<button
|
| 248 |
+
onClick={() => handleReview("approved")}
|
| 249 |
+
disabled={actionLoading}
|
| 250 |
+
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
|
| 251 |
+
>
|
| 252 |
+
Approve & Assign
|
| 253 |
+
</button>
|
| 254 |
+
</>
|
| 255 |
+
)}
|
| 256 |
+
|
| 257 |
+
{issue.state === "pending_verification" && (
|
| 258 |
+
<>
|
| 259 |
+
<button
|
| 260 |
+
onClick={() => handleResolutionReview("reject")}
|
| 261 |
+
disabled={actionLoading}
|
| 262 |
+
className="px-4 py-2 text-sm font-medium text-red-600 bg-white border border-red-200 rounded-lg hover:bg-red-50"
|
| 263 |
+
>
|
| 264 |
+
Reject Incomplete Work
|
| 265 |
+
</button>
|
| 266 |
+
<button
|
| 267 |
+
onClick={() => handleResolutionReview("approve")}
|
| 268 |
+
disabled={actionLoading}
|
| 269 |
+
className="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700"
|
| 270 |
+
>
|
| 271 |
+
Verify & Approve
|
| 272 |
+
</button>
|
| 273 |
+
</>
|
| 274 |
+
)}
|
| 275 |
+
|
| 276 |
+
<button
|
| 277 |
+
className="p-2 text-slate-400 hover:text-slate-600"
|
| 278 |
+
aria-label="More options"
|
| 279 |
+
>
|
| 280 |
+
<MoreHorizontal className="w-5 h-5" />
|
| 281 |
+
</button>
|
| 282 |
+
</div>
|
| 283 |
+
</div>
|
| 284 |
+
|
| 285 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 286 |
+
<div className="lg:col-span-2 space-y-6">
|
| 287 |
+
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
|
| 288 |
+
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
| 289 |
+
<ImageIcon className="w-5 h-5 text-slate-400" />
|
| 290 |
+
Evidence Photos
|
| 291 |
+
</h3>
|
| 292 |
+
<div className="space-y-6">
|
| 293 |
+
<div>
|
| 294 |
+
<h4 className="text-sm font-semibold text-slate-700 mb-3">
|
| 295 |
+
Original Report
|
| 296 |
+
</h4>
|
| 297 |
+
<div className="grid grid-cols-2 gap-4">
|
| 298 |
+
{issue.image_urls.map((url, idx) => (
|
| 299 |
+
<div
|
| 300 |
+
key={idx}
|
| 301 |
+
className="aspect-video bg-slate-100 rounded-lg overflow-hidden border border-slate-200 relative group"
|
| 302 |
+
>
|
| 303 |
+
<img
|
| 304 |
+
src={url}
|
| 305 |
+
alt={`Original ${idx + 1}`}
|
| 306 |
+
className="w-full h-full object-cover"
|
| 307 |
+
/>
|
| 308 |
+
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
| 309 |
+
<a
|
| 310 |
+
href={url}
|
| 311 |
+
target="_blank"
|
| 312 |
+
rel="noopener noreferrer"
|
| 313 |
+
className="text-white text-xs font-medium bg-white/20 px-3 py-1 rounded-full backdrop-blur-sm"
|
| 314 |
+
>
|
| 315 |
+
View Full
|
| 316 |
+
</a>
|
| 317 |
+
</div>
|
| 318 |
+
</div>
|
| 319 |
+
))}
|
| 320 |
+
</div>
|
| 321 |
+
</div>
|
| 322 |
+
|
| 323 |
+
{issue.annotated_urls && issue.annotated_urls.length > 0 && (
|
| 324 |
+
<div>
|
| 325 |
+
<h4 className="text-sm font-semibold text-slate-700 mb-3">
|
| 326 |
+
AI Analysis
|
| 327 |
+
</h4>
|
| 328 |
+
<div className="grid grid-cols-2 gap-4">
|
| 329 |
+
{issue.annotated_urls.map((url, idx) => (
|
| 330 |
+
<div
|
| 331 |
+
key={idx}
|
| 332 |
+
className="aspect-video bg-slate-100 rounded-lg overflow-hidden border border-blue-200 relative group"
|
| 333 |
+
>
|
| 334 |
+
<img
|
| 335 |
+
src={url}
|
| 336 |
+
alt={`Analyzed ${idx + 1}`}
|
| 337 |
+
className="w-full h-full object-cover"
|
| 338 |
+
/>
|
| 339 |
+
<div className="absolute top-2 left-2 bg-blue-500 text-white text-xs px-2 py-0.5 rounded-full font-semibold">
|
| 340 |
+
AI Detected
|
| 341 |
+
</div>
|
| 342 |
+
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
| 343 |
+
<a
|
| 344 |
+
href={url}
|
| 345 |
+
target="_blank"
|
| 346 |
+
rel="noopener noreferrer"
|
| 347 |
+
className="text-white text-xs font-medium bg-white/20 px-3 py-1 rounded-full backdrop-blur-sm"
|
| 348 |
+
>
|
| 349 |
+
View Full
|
| 350 |
+
</a>
|
| 351 |
+
</div>
|
| 352 |
+
</div>
|
| 353 |
+
))}
|
| 354 |
+
</div>
|
| 355 |
+
</div>
|
| 356 |
+
)}
|
| 357 |
+
|
| 358 |
+
{issue.proof_image_url && (
|
| 359 |
+
<div>
|
| 360 |
+
<h4 className="text-sm font-semibold text-slate-700 mb-3">
|
| 361 |
+
Work Completion Proof
|
| 362 |
+
</h4>
|
| 363 |
+
<div className="grid grid-cols-2 gap-4">
|
| 364 |
+
<div className="aspect-video bg-slate-100 rounded-lg overflow-hidden border border-green-200 relative group">
|
| 365 |
+
<img
|
| 366 |
+
src={issue.proof_image_url}
|
| 367 |
+
alt="Work Proof"
|
| 368 |
+
className="w-full h-full object-cover"
|
| 369 |
+
/>
|
| 370 |
+
<div className="absolute top-2 left-2 bg-green-500 text-white text-xs px-2 py-0.5 rounded-full font-semibold">
|
| 371 |
+
Worker Proof
|
| 372 |
+
</div>
|
| 373 |
+
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
| 374 |
+
<a
|
| 375 |
+
href={issue.proof_image_url}
|
| 376 |
+
target="_blank"
|
| 377 |
+
rel="noopener noreferrer"
|
| 378 |
+
className="text-white text-xs font-medium bg-white/20 px-3 py-1 rounded-full backdrop-blur-sm"
|
| 379 |
+
>
|
| 380 |
+
View Full
|
| 381 |
+
</a>
|
| 382 |
+
</div>
|
| 383 |
+
</div>
|
| 384 |
+
</div>
|
| 385 |
+
</div>
|
| 386 |
+
)}
|
| 387 |
+
|
| 388 |
+
{issue.image_urls.length === 0 && !issue.proof_image_url && (
|
| 389 |
+
<div className="py-8 text-center text-slate-400 bg-slate-50 rounded-lg border border-dashed border-slate-300">
|
| 390 |
+
No images attached
|
| 391 |
+
</div>
|
| 392 |
+
)}
|
| 393 |
+
</div>
|
| 394 |
+
</div>
|
| 395 |
+
|
| 396 |
+
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
|
| 397 |
+
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
| 398 |
+
<Activity className="w-5 h-5 text-slate-400" />
|
| 399 |
+
Analysis & Details
|
| 400 |
+
</h3>
|
| 401 |
+
|
| 402 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 403 |
+
<div className="space-y-4">
|
| 404 |
+
<div>
|
| 405 |
+
<label className="text-xs font-semibold text-slate-500 uppercase">
|
| 406 |
+
Category
|
| 407 |
+
</label>
|
| 408 |
+
<div className="text-slate-900 font-medium">
|
| 409 |
+
{issue.category || "Uncategorized"}
|
| 410 |
+
</div>
|
| 411 |
+
{issue.confidence > 0 && (
|
| 412 |
+
<div className="text-xs text-slate-500">
|
| 413 |
+
AI Confidence: {(issue.confidence * 100).toFixed(1)}%
|
| 414 |
+
</div>
|
| 415 |
+
)}
|
| 416 |
+
</div>
|
| 417 |
+
<div>
|
| 418 |
+
<label className="text-xs font-semibold text-slate-500 uppercase">
|
| 419 |
+
Description
|
| 420 |
+
</label>
|
| 421 |
+
<p className="text-slate-900 text-sm leading-relaxed">
|
| 422 |
+
{issue.description || "No description provided."}
|
| 423 |
+
</p>
|
| 424 |
+
</div>
|
| 425 |
+
</div>
|
| 426 |
+
|
| 427 |
+
<div className="space-y-4">
|
| 428 |
+
<div>
|
| 429 |
+
<label className="text-xs font-semibold text-slate-500 uppercase">
|
| 430 |
+
Location
|
| 431 |
+
</label>
|
| 432 |
+
<div className="flex items-start gap-2 text-slate-900">
|
| 433 |
+
<MapPin className="w-4 h-4 text-slate-400 mt-0.5 shrink-0" />
|
| 434 |
+
<div className="text-sm">
|
| 435 |
+
<div className="font-medium">
|
| 436 |
+
{issue.locality
|
| 437 |
+
? `${issue.locality}, ${issue.city}`
|
| 438 |
+
: issue.city}
|
| 439 |
+
</div>
|
| 440 |
+
{issue.full_address && (
|
| 441 |
+
<div className="text-slate-600 text-xs mt-0.5 leading-relaxed border-l-2 border-slate-200 pl-2 my-1">
|
| 442 |
+
{issue.full_address}
|
| 443 |
+
</div>
|
| 444 |
+
)}
|
| 445 |
+
<div className="text-slate-400 text-xs font-mono mt-1">
|
| 446 |
+
{issue.latitude.toFixed(6)},{" "}
|
| 447 |
+
{issue.longitude.toFixed(6)}
|
| 448 |
+
</div>
|
| 449 |
+
</div>
|
| 450 |
+
</div>
|
| 451 |
+
</div>
|
| 452 |
+
|
| 453 |
+
<div className="group">
|
| 454 |
+
<div className="flex items-center justify-between">
|
| 455 |
+
<label className="text-xs font-semibold text-slate-500 uppercase">
|
| 456 |
+
Priority
|
| 457 |
+
</label>
|
| 458 |
+
<button
|
| 459 |
+
onClick={() => setEditingPriority(!editingPriority)}
|
| 460 |
+
className="p-1 opacity-0 group-hover:opacity-100 hover:bg-slate-100 rounded text-slate-400 hover:text-blue-500 transition-all"
|
| 461 |
+
aria-label="Edit Priority"
|
| 462 |
+
>
|
| 463 |
+
<Pencil className="w-3 h-3" />
|
| 464 |
+
</button>
|
| 465 |
+
</div>
|
| 466 |
+
|
| 467 |
+
{editingPriority ? (
|
| 468 |
+
<div className="flex gap-2 mt-1">
|
| 469 |
+
<select
|
| 470 |
+
aria-label="Select priority"
|
| 471 |
+
value={selectedPriority}
|
| 472 |
+
onChange={(e) =>
|
| 473 |
+
setSelectedPriority(Number(e.target.value))
|
| 474 |
+
}
|
| 475 |
+
className="bg-white border rounded px-2 py-1 text-sm flex-1"
|
| 476 |
+
>
|
| 477 |
+
<option value="1">P1 - Critical</option>
|
| 478 |
+
<option value="2">P2 - High</option>
|
| 479 |
+
<option value="3">P3 - Medium</option>
|
| 480 |
+
<option value="4">P4 - Low</option>
|
| 481 |
+
</select>
|
| 482 |
+
<button
|
| 483 |
+
onClick={() =>
|
| 484 |
+
handleUpdate({ priority: selectedPriority })
|
| 485 |
+
}
|
| 486 |
+
className="p-1.5 bg-blue-100 text-blue-600 rounded hover:bg-blue-200"
|
| 487 |
+
aria-label="Save"
|
| 488 |
+
>
|
| 489 |
+
<Save className="w-4 h-4" />
|
| 490 |
+
</button>
|
| 491 |
+
<button
|
| 492 |
+
onClick={() => setEditingPriority(false)}
|
| 493 |
+
className="p-1.5 bg-slate-100 text-slate-600 rounded hover:bg-slate-200"
|
| 494 |
+
aria-label="Cancel"
|
| 495 |
+
>
|
| 496 |
+
<X className="w-4 h-4" />
|
| 497 |
+
</button>
|
| 498 |
+
</div>
|
| 499 |
+
) : (
|
| 500 |
+
<div
|
| 501 |
+
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-sm font-bold mt-1
|
| 502 |
+
${
|
| 503 |
+
issue.priority === 1
|
| 504 |
+
? "bg-red-100 text-red-700"
|
| 505 |
+
: issue.priority === 2
|
| 506 |
+
? "bg-orange-100 text-orange-700"
|
| 507 |
+
: issue.priority === 3
|
| 508 |
+
? "bg-yellow-100 text-yellow-700"
|
| 509 |
+
: "bg-green-100 text-green-700"
|
| 510 |
+
}`}
|
| 511 |
+
>
|
| 512 |
+
P{issue.priority} Level
|
| 513 |
+
</div>
|
| 514 |
+
)}
|
| 515 |
+
</div>
|
| 516 |
+
</div>
|
| 517 |
+
</div>
|
| 518 |
+
</div>
|
| 519 |
+
</div>
|
| 520 |
+
|
| 521 |
+
<div className="space-y-6">
|
| 522 |
+
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
|
| 523 |
+
<h3 className="text-sm font-bold text-slate-900 uppercase tracking-wide mb-4">
|
| 524 |
+
Assignment
|
| 525 |
+
</h3>
|
| 526 |
+
|
| 527 |
+
<div className="space-y-4">
|
| 528 |
+
<div className="flex items-start gap-3">
|
| 529 |
+
<div className="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600">
|
| 530 |
+
<Building2 className="w-5 h-5" />
|
| 531 |
+
</div>
|
| 532 |
+
<div>
|
| 533 |
+
<div className="text-xs text-slate-500">Department</div>
|
| 534 |
+
<div className="text-sm font-semibold text-slate-900">
|
| 535 |
+
{department?.name || "Not Assigned"}
|
| 536 |
+
</div>
|
| 537 |
+
</div>
|
| 538 |
+
</div>
|
| 539 |
+
|
| 540 |
+
<div className="flex items-start gap-3 group relative">
|
| 541 |
+
<div className="absolute right-0 top-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 542 |
+
<button
|
| 543 |
+
onClick={() => setEditingAssignment(!editingAssignment)}
|
| 544 |
+
className="p-1 hover:bg-slate-100 rounded text-slate-400 hover:text-blue-500"
|
| 545 |
+
aria-label="Edit Assignment"
|
| 546 |
+
>
|
| 547 |
+
<Pencil className="w-3 h-3" />
|
| 548 |
+
</button>
|
| 549 |
+
</div>
|
| 550 |
+
<div className="w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center text-slate-500 shrink-0">
|
| 551 |
+
<User className="w-5 h-5" />
|
| 552 |
+
</div>
|
| 553 |
+
<div className="flex-1">
|
| 554 |
+
<div className="text-xs text-slate-500">Worker</div>
|
| 555 |
+
|
| 556 |
+
{editingAssignment ? (
|
| 557 |
+
<div className="flex flex-col gap-2 mt-1">
|
| 558 |
+
<select
|
| 559 |
+
aria-label="Select worker"
|
| 560 |
+
value={selectedWorker}
|
| 561 |
+
onChange={(e) => setSelectedWorker(e.target.value)}
|
| 562 |
+
className="bg-white border rounded px-2 py-1 text-sm w-full"
|
| 563 |
+
>
|
| 564 |
+
<option value="">Unassigned</option>
|
| 565 |
+
{workers.map((w) => (
|
| 566 |
+
<option key={w.id} value={w.id}>
|
| 567 |
+
{w.name}
|
| 568 |
+
</option>
|
| 569 |
+
))}
|
| 570 |
+
</select>
|
| 571 |
+
<div className="flex gap-2">
|
| 572 |
+
<button
|
| 573 |
+
onClick={() =>
|
| 574 |
+
handleUpdate({
|
| 575 |
+
assigned_member_id: selectedWorker || null,
|
| 576 |
+
})
|
| 577 |
+
}
|
| 578 |
+
className="flex-1 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
|
| 579 |
+
>
|
| 580 |
+
Save
|
| 581 |
+
</button>
|
| 582 |
+
<button
|
| 583 |
+
onClick={() => setEditingAssignment(false)}
|
| 584 |
+
className="flex-1 py-1 bg-slate-200 text-slate-700 text-xs rounded hover:bg-slate-300"
|
| 585 |
+
>
|
| 586 |
+
Cancel
|
| 587 |
+
</button>
|
| 588 |
+
</div>
|
| 589 |
+
</div>
|
| 590 |
+
) : (
|
| 591 |
+
<>
|
| 592 |
+
<div className="text-sm font-semibold text-slate-900">
|
| 593 |
+
{worker?.name || "Unassigned"}
|
| 594 |
+
</div>
|
| 595 |
+
{worker && (
|
| 596 |
+
<div className="text-xs text-slate-400">
|
| 597 |
+
{worker.email}
|
| 598 |
+
</div>
|
| 599 |
+
)}
|
| 600 |
+
</>
|
| 601 |
+
)}
|
| 602 |
+
</div>
|
| 603 |
+
</div>
|
| 604 |
+
|
| 605 |
+
{issue.sla_deadline && (
|
| 606 |
+
<div className="pt-4 border-t border-slate-100">
|
| 607 |
+
<div className="flex items-center gap-2 text-amber-600 bg-amber-50 px-3 py-2 rounded-lg">
|
| 608 |
+
<Clock className="w-4 h-4" />
|
| 609 |
+
<div>
|
| 610 |
+
<div className="text-[10px] font-bold uppercase">
|
| 611 |
+
SLA Deadline
|
| 612 |
+
</div>
|
| 613 |
+
<div className="text-xs font-semibold">
|
| 614 |
+
{new Date(issue.sla_deadline).toLocaleString()}
|
| 615 |
+
</div>
|
| 616 |
+
</div>
|
| 617 |
+
</div>
|
| 618 |
+
</div>
|
| 619 |
+
)}
|
| 620 |
+
</div>
|
| 621 |
+
</div>
|
| 622 |
+
|
| 623 |
+
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm relative overflow-hidden">
|
| 624 |
+
<h3 className="text-sm font-bold text-slate-900 uppercase tracking-wide mb-6">
|
| 625 |
+
Activity Audit
|
| 626 |
+
</h3>
|
| 627 |
+
|
| 628 |
+
<div className="absolute top-0 right-0 p-4 opacity-5">
|
| 629 |
+
<Layers className="w-24 h-24" />
|
| 630 |
+
</div>
|
| 631 |
+
|
| 632 |
+
<div className="space-y-0 text-sm relative border-l-2 border-slate-100 ml-2">
|
| 633 |
+
{events.map((event, idx) => (
|
| 634 |
+
<div key={idx} className="pl-6 pb-6 relative last:pb-0">
|
| 635 |
+
<div className="absolute -left-2.25 top-0 w-4 h-4 rounded-full bg-white border-2 border-blue-500"></div>
|
| 636 |
+
<div className="flex flex-col">
|
| 637 |
+
<span className="font-semibold text-slate-900">
|
| 638 |
+
{event.agent || "System"}
|
| 639 |
+
</span>
|
| 640 |
+
<span className="text-xs text-slate-500 mb-1">
|
| 641 |
+
{new Date(event.created_at).toLocaleString()}
|
| 642 |
+
</span>
|
| 643 |
+
<span className="text-slate-600 bg-slate-50 p-2 rounded border border-slate-100">
|
| 644 |
+
{event.data}
|
| 645 |
+
</span>
|
| 646 |
+
</div>
|
| 647 |
+
</div>
|
| 648 |
+
))}
|
| 649 |
+
{events.length === 0 && (
|
| 650 |
+
<div className="pl-6 text-slate-400 italic">
|
| 651 |
+
No activity recorded.
|
| 652 |
+
</div>
|
| 653 |
+
)}
|
| 654 |
+
</div>
|
| 655 |
+
</div>
|
| 656 |
+
</div>
|
| 657 |
+
</div>
|
| 658 |
+
</div>
|
| 659 |
+
);
|
| 660 |
+
}
|
Frontend/app/admin/issues/page.tsx
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useEffect, useState, useMemo } from "react";
|
| 3 |
+
import { useRouter, useSearchParams } from "next/navigation";
|
| 4 |
+
import { Search, ChevronRight, AlertCircle, ArrowUpDown } from "lucide-react";
|
| 5 |
+
import Link from "next/link";
|
| 6 |
+
import { useCachedFetch } from "@/hooks/useCachedFetch";
|
| 7 |
+
|
| 8 |
+
interface AdminIssueListItem {
|
| 9 |
+
id: string;
|
| 10 |
+
description: string;
|
| 11 |
+
state: string;
|
| 12 |
+
priority: number;
|
| 13 |
+
city: string;
|
| 14 |
+
created_at: string;
|
| 15 |
+
department: string;
|
| 16 |
+
assigned_to: string;
|
| 17 |
+
category: string;
|
| 18 |
+
thumbnail: string;
|
| 19 |
+
locality?: string;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
interface Meta {
|
| 23 |
+
total: number;
|
| 24 |
+
page: number;
|
| 25 |
+
limit: number;
|
| 26 |
+
pages: number;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
interface IssuesResponse {
|
| 30 |
+
items: AdminIssueListItem[];
|
| 31 |
+
total: number;
|
| 32 |
+
page: number;
|
| 33 |
+
limit: number;
|
| 34 |
+
pages: number;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export default function IssuesPage() {
|
| 38 |
+
const router = useRouter();
|
| 39 |
+
const searchParams = useSearchParams();
|
| 40 |
+
|
| 41 |
+
const [page, setPage] = useState(1);
|
| 42 |
+
const limit = 10;
|
| 43 |
+
|
| 44 |
+
const [search, setSearch] = useState("");
|
| 45 |
+
const [status, setStatus] = useState<string>("");
|
| 46 |
+
const [priority, setPriority] = useState<string>("");
|
| 47 |
+
const [sort, setSort] = useState("created_at");
|
| 48 |
+
const [order, setOrder] = useState("desc");
|
| 49 |
+
|
| 50 |
+
const [debouncedSearch, setDebouncedSearch] = useState("");
|
| 51 |
+
|
| 52 |
+
useEffect(() => {
|
| 53 |
+
const statusParam = searchParams.get("status");
|
| 54 |
+
if (statusParam) {
|
| 55 |
+
setStatus(statusParam);
|
| 56 |
+
}
|
| 57 |
+
}, [searchParams]);
|
| 58 |
+
|
| 59 |
+
useEffect(() => {
|
| 60 |
+
const handler = setTimeout(() => {
|
| 61 |
+
setDebouncedSearch(search);
|
| 62 |
+
}, 500);
|
| 63 |
+
return () => clearTimeout(handler);
|
| 64 |
+
}, [search]);
|
| 65 |
+
|
| 66 |
+
// Construct Query URL dynamically
|
| 67 |
+
const queryUrl = useMemo(() => {
|
| 68 |
+
const query = new URLSearchParams({
|
| 69 |
+
page: page.toString(),
|
| 70 |
+
limit: limit.toString(),
|
| 71 |
+
sort_by: sort,
|
| 72 |
+
sort_order: order,
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
if (debouncedSearch) query.append("search", debouncedSearch);
|
| 76 |
+
if (status) query.append("status", status);
|
| 77 |
+
if (priority) query.append("priority", priority);
|
| 78 |
+
|
| 79 |
+
return `/admin/issues?${query.toString()}`;
|
| 80 |
+
}, [page, limit, sort, order, debouncedSearch, status, priority]);
|
| 81 |
+
|
| 82 |
+
const { data: issuesData, loading } = useCachedFetch<IssuesResponse>(queryUrl);
|
| 83 |
+
|
| 84 |
+
const issues = issuesData?.items || [];
|
| 85 |
+
const meta: Meta = {
|
| 86 |
+
total: issuesData?.total || 0,
|
| 87 |
+
page: issuesData?.page || 1,
|
| 88 |
+
limit: issuesData?.limit || 10,
|
| 89 |
+
pages: issuesData?.pages || 0,
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
const handlePageChange = (newPage: number) => {
|
| 93 |
+
if (newPage > 0 && newPage <= meta.pages) {
|
| 94 |
+
setPage(newPage);
|
| 95 |
+
}
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
const getStateBadge = (state: string) => {
|
| 99 |
+
const styles: Record<string, string> = {
|
| 100 |
+
reported: "bg-blue-100 text-blue-700 border-blue-200",
|
| 101 |
+
assigned: "bg-purple-100 text-purple-700 border-purple-200",
|
| 102 |
+
in_progress: "bg-amber-100 text-amber-700 border-amber-200",
|
| 103 |
+
pending_verification: "bg-orange-100 text-orange-700 border-orange-200",
|
| 104 |
+
resolved: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
| 105 |
+
closed: "bg-slate-100 text-slate-600 border-slate-200",
|
| 106 |
+
escalated: "bg-red-100 text-red-700 border-red-200 animate-pulse",
|
| 107 |
+
rejected: "bg-gray-100 text-gray-500 border-gray-200 line-through",
|
| 108 |
+
verified: "bg-indigo-100 text-indigo-700 border-indigo-200",
|
| 109 |
+
};
|
| 110 |
+
return (
|
| 111 |
+
<span
|
| 112 |
+
className={`px-2.5 py-0.5 rounded-full text-xs font-semibold border ${
|
| 113 |
+
styles[state] || "bg-gray-100 text-gray-800"
|
| 114 |
+
}`}
|
| 115 |
+
>
|
| 116 |
+
{state.replace("_", " ").toUpperCase()}
|
| 117 |
+
</span>
|
| 118 |
+
);
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
return (
|
| 122 |
+
<div className="space-y-6">
|
| 123 |
+
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
| 124 |
+
<div>
|
| 125 |
+
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">
|
| 126 |
+
Issue Management
|
| 127 |
+
</h1>
|
| 128 |
+
<p className="text-slate-500 text-sm">
|
| 129 |
+
Monitor, assign, and resolve reported city issues.
|
| 130 |
+
</p>
|
| 131 |
+
</div>
|
| 132 |
+
<div className="flex items-center gap-2">
|
| 133 |
+
<button
|
| 134 |
+
onClick={() => setStatus("pending_verification")}
|
| 135 |
+
className="px-4 py-2 bg-orange-100 text-orange-800 text-sm font-medium rounded-lg hover:bg-orange-200 transition flex items-center gap-2 border border-orange-200"
|
| 136 |
+
>
|
| 137 |
+
<AlertCircle className="w-4 h-4" />
|
| 138 |
+
Pending Reviews
|
| 139 |
+
</button>
|
| 140 |
+
<button className="bg-slate-900 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-slate-800 transition">
|
| 141 |
+
Export CSV
|
| 142 |
+
</button>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
<div className="bg-white/60 backdrop-blur-md rounded-2xl border border-slate-200/60 shadow-urban-sm p-4 transition-all">
|
| 147 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
| 148 |
+
<div className="md:col-span-2 relative group">
|
| 149 |
+
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400 group-focus-within:text-blue-500 transition-colors" />
|
| 150 |
+
<input
|
| 151 |
+
type="text"
|
| 152 |
+
placeholder="Search by ID, description, or location..."
|
| 153 |
+
value={search}
|
| 154 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 155 |
+
className="w-full pl-10 pr-4 py-2 text-sm border border-slate-200 bg-white/50 rounded-xl focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 transition-all font-sans"
|
| 156 |
+
/>
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
<select
|
| 160 |
+
aria-label="Filter by Status"
|
| 161 |
+
value={status}
|
| 162 |
+
onChange={(e) => setStatus(e.target.value)}
|
| 163 |
+
className="px-3 py-2 text-sm border border-slate-200 bg-white/50 rounded-xl focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 transition-all font-sans"
|
| 164 |
+
>
|
| 165 |
+
<option value="">All Statuses</option>
|
| 166 |
+
<option value="reported">Reported</option>
|
| 167 |
+
<option value="verified">Verified</option>
|
| 168 |
+
<option value="assigned">Assigned</option>
|
| 169 |
+
<option value="in_progress">In Progress</option>
|
| 170 |
+
<option value="pending_verification">Pending Verification</option>
|
| 171 |
+
<option value="resolved">Resolved</option>
|
| 172 |
+
<option value="closed">Closed</option>
|
| 173 |
+
<option value="escalated">Escalated</option>
|
| 174 |
+
</select>
|
| 175 |
+
|
| 176 |
+
<select
|
| 177 |
+
aria-label="Filter by Priority"
|
| 178 |
+
value={priority}
|
| 179 |
+
onChange={(e) => setPriority(e.target.value)}
|
| 180 |
+
className="px-3 py-2 text-sm border border-slate-200 bg-white/50 rounded-xl focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 transition-all font-sans"
|
| 181 |
+
>
|
| 182 |
+
<option value="">All Priorities</option>
|
| 183 |
+
<option value="1">Critical (P1)</option>
|
| 184 |
+
<option value="2">High (P2)</option>
|
| 185 |
+
<option value="3">Medium (P3)</option>
|
| 186 |
+
<option value="4">Low (P4)</option>
|
| 187 |
+
</select>
|
| 188 |
+
</div>
|
| 189 |
+
|
| 190 |
+
{loading ? (
|
| 191 |
+
<div className="h-64 flex items-center justify-center text-slate-500">
|
| 192 |
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mr-2"></div>
|
| 193 |
+
Loading issues...
|
| 194 |
+
</div>
|
| 195 |
+
) : (
|
| 196 |
+
<div className="overflow-x-auto rounded-xl border border-slate-200/50">
|
| 197 |
+
<table className="w-full text-left border-collapse">
|
| 198 |
+
<thead>
|
| 199 |
+
<tr className="border-b border-slate-200/60 text-xs uppercase text-slate-500 bg-slate-50/80 font-mono tracking-wider">
|
| 200 |
+
<th className="px-4 py-3 font-semibold">Issue</th>
|
| 201 |
+
<th className="px-4 py-3 font-semibold">Location</th>
|
| 202 |
+
<th className="px-4 py-3 font-semibold">
|
| 203 |
+
<button
|
| 204 |
+
onClick={() => {
|
| 205 |
+
setSort("priority");
|
| 206 |
+
setOrder(order === "asc" ? "desc" : "asc");
|
| 207 |
+
}}
|
| 208 |
+
className="flex items-center gap-1 hover:text-slate-800 transition-colors"
|
| 209 |
+
>
|
| 210 |
+
Priority <ArrowUpDown className="w-3 h-3" />
|
| 211 |
+
</button>
|
| 212 |
+
</th>
|
| 213 |
+
<th className="px-4 py-3 font-semibold">Status</th>
|
| 214 |
+
<th className="px-4 py-3 font-semibold">Assigned To</th>
|
| 215 |
+
<th className="px-4 py-3 font-semibold">
|
| 216 |
+
<button
|
| 217 |
+
onClick={() => {
|
| 218 |
+
setSort("created_at");
|
| 219 |
+
setOrder(order === "asc" ? "desc" : "asc");
|
| 220 |
+
}}
|
| 221 |
+
className="flex items-center gap-1 hover:text-slate-800 transition-colors"
|
| 222 |
+
>
|
| 223 |
+
Date <ArrowUpDown className="w-3 h-3" />
|
| 224 |
+
</button>
|
| 225 |
+
</th>
|
| 226 |
+
<th className="px-4 py-3 font-semibold text-right">Action</th>
|
| 227 |
+
</tr>
|
| 228 |
+
</thead>
|
| 229 |
+
<tbody className="divide-y divide-slate-100">
|
| 230 |
+
{issues.length === 0 ? (
|
| 231 |
+
<tr>
|
| 232 |
+
<td
|
| 233 |
+
colSpan={7}
|
| 234 |
+
className="px-4 py-8 text-center text-slate-500"
|
| 235 |
+
>
|
| 236 |
+
No issues found matching your filters.
|
| 237 |
+
</td>
|
| 238 |
+
</tr>
|
| 239 |
+
) : (
|
| 240 |
+
issues.map((issue) => (
|
| 241 |
+
<tr
|
| 242 |
+
key={issue.id}
|
| 243 |
+
className="group hover:bg-blue-50/30 transition-colors duration-200"
|
| 244 |
+
>
|
| 245 |
+
<td className="px-4 py-3">
|
| 246 |
+
<div className="flex items-center gap-3">
|
| 247 |
+
<div className="h-10 w-10 rounded-lg bg-slate-100 overflow-hidden shrink-0 relative border border-slate-200">
|
| 248 |
+
{issue.thumbnail ? (
|
| 249 |
+
<img
|
| 250 |
+
src={issue.thumbnail}
|
| 251 |
+
alt=""
|
| 252 |
+
className="h-full w-full object-cover"
|
| 253 |
+
/>
|
| 254 |
+
) : (
|
| 255 |
+
<div className="h-full w-full flex items-center justify-center text-slate-400">
|
| 256 |
+
<AlertCircle className="w-5 h-5" />
|
| 257 |
+
</div>
|
| 258 |
+
)}
|
| 259 |
+
</div>
|
| 260 |
+
<div>
|
| 261 |
+
<div className="text-sm font-semibold text-slate-900 truncate max-w-50 font-display">
|
| 262 |
+
{issue.category || "Uncategorized Issue"}
|
| 263 |
+
</div>
|
| 264 |
+
<div className="text-xs text-slate-500 truncate max-w-50 font-sans">
|
| 265 |
+
{issue.description || "No description provided"}
|
| 266 |
+
</div>
|
| 267 |
+
</div>
|
| 268 |
+
</div>
|
| 269 |
+
</td>
|
| 270 |
+
<td className="px-4 py-3">
|
| 271 |
+
<div className="flex flex-col">
|
| 272 |
+
<span className="text-sm text-slate-700 font-medium">
|
| 273 |
+
{issue.city || "Unknown"}
|
| 274 |
+
</span>
|
| 275 |
+
<span className="text-xs text-slate-500 truncate max-w-37.5">
|
| 276 |
+
{issue.locality || ""}
|
| 277 |
+
</span>
|
| 278 |
+
</div>
|
| 279 |
+
</td>
|
| 280 |
+
<td className="px-4 py-3">
|
| 281 |
+
<span
|
| 282 |
+
className={`inline-flex items-center justify-center h-6 w-6 rounded-full text-xs font-bold border ${
|
| 283 |
+
issue.priority === 1
|
| 284 |
+
? "bg-red-50 text-red-600 border-red-100"
|
| 285 |
+
: issue.priority === 2
|
| 286 |
+
? "bg-orange-50 text-orange-600 border-orange-100"
|
| 287 |
+
: issue.priority === 3
|
| 288 |
+
? "bg-amber-50 text-amber-600 border-amber-100"
|
| 289 |
+
: "bg-green-50 text-green-600 border-green-100"
|
| 290 |
+
}`}
|
| 291 |
+
>
|
| 292 |
+
P{issue.priority}
|
| 293 |
+
</span>
|
| 294 |
+
</td>
|
| 295 |
+
<td className="px-4 py-3">
|
| 296 |
+
{getStateBadge(issue.state)}
|
| 297 |
+
</td>
|
| 298 |
+
<td className="px-4 py-3 text-sm text-slate-600">
|
| 299 |
+
{issue.assigned_to ? (
|
| 300 |
+
<div className="flex items-center gap-2">
|
| 301 |
+
<div className="w-6 h-6 rounded-full bg-blue-100 flex items-center justify-center text-[10px] font-bold text-blue-700 ring-2 ring-white shadow-sm">
|
| 302 |
+
{issue.assigned_to.charAt(0)}
|
| 303 |
+
</div>
|
| 304 |
+
<span className="font-medium text-slate-700">{issue.assigned_to}</span>
|
| 305 |
+
</div>
|
| 306 |
+
) : (
|
| 307 |
+
<span className="text-slate-400 italic text-xs">
|
| 308 |
+
Unassigned
|
| 309 |
+
</span>
|
| 310 |
+
)}
|
| 311 |
+
{issue.department && (
|
| 312 |
+
<div className="text-[10px] text-slate-400 mt-0.5 font-mono">
|
| 313 |
+
{issue.department}
|
| 314 |
+
</div>
|
| 315 |
+
)}
|
| 316 |
+
</td>
|
| 317 |
+
<td className="px-4 py-3 text-sm text-slate-600">
|
| 318 |
+
<span className="font-medium">{new Date(issue.created_at).toLocaleDateString()}</span>
|
| 319 |
+
<div className="text-xs text-slate-400 font-mono">
|
| 320 |
+
{new Date(issue.created_at).toLocaleTimeString([], {
|
| 321 |
+
hour: "2-digit",
|
| 322 |
+
minute: "2-digit",
|
| 323 |
+
})}
|
| 324 |
+
</div>
|
| 325 |
+
</td>
|
| 326 |
+
<td className="px-4 py-3 text-right">
|
| 327 |
+
<Link
|
| 328 |
+
href={`/admin/issues/${issue.id}`}
|
| 329 |
+
className="inline-flex items-center gap-1.5 text-xs font-medium text-blue-600 hover:text-blue-800 bg-blue-50 px-2.5 py-1.5 rounded-lg border border-blue-100 hover:bg-blue-100 transition-colors"
|
| 330 |
+
>
|
| 331 |
+
View
|
| 332 |
+
<ChevronRight className="w-3.5 h-3.5" />
|
| 333 |
+
</Link>
|
| 334 |
+
</td>
|
| 335 |
+
</tr>
|
| 336 |
+
))
|
| 337 |
+
)}
|
| 338 |
+
</tbody>
|
| 339 |
+
</table>
|
| 340 |
+
</div>
|
| 341 |
+
)}
|
| 342 |
+
|
| 343 |
+
<div className="flex items-center justify-between border-t border-slate-200/60 pt-4 mt-4">
|
| 344 |
+
<div className="text-sm text-slate-500">
|
| 345 |
+
Showing{" "}
|
| 346 |
+
<span className="font-semibold text-slate-900">
|
| 347 |
+
{(meta.page - 1) * meta.limit + 1}
|
| 348 |
+
</span>{" "}
|
| 349 |
+
to{" "}
|
| 350 |
+
<span className="font-semibold text-slate-900">
|
| 351 |
+
{Math.min(meta.page * meta.limit, meta.total)}
|
| 352 |
+
</span>{" "}
|
| 353 |
+
of <span className="font-semibold text-slate-900">{meta.total}</span> results
|
| 354 |
+
</div>
|
| 355 |
+
<div className="flex gap-2">
|
| 356 |
+
<button
|
| 357 |
+
onClick={() => handlePageChange(meta.page - 1)}
|
| 358 |
+
disabled={meta.page === 1}
|
| 359 |
+
className="px-3.5 py-1.5 text-sm font-medium border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-slate-700"
|
| 360 |
+
>
|
| 361 |
+
Previous
|
| 362 |
+
</button>
|
| 363 |
+
<button
|
| 364 |
+
onClick={() => handlePageChange(meta.page + 1)}
|
| 365 |
+
disabled={meta.page === meta.pages}
|
| 366 |
+
className="px-3.5 py-1.5 text-sm font-medium border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-slate-700"
|
| 367 |
+
>
|
| 368 |
+
Next
|
| 369 |
+
</button>
|
| 370 |
+
</div>
|
| 371 |
+
</div>
|
| 372 |
+
</div>
|
| 373 |
+
</div>
|
| 374 |
+
);
|
| 375 |
+
}
|
Frontend/app/admin/layout.tsx
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useState, useEffect } from "react";
|
| 3 |
+
import { useRouter } from "next/navigation";
|
| 4 |
+
import { useAuth } from "@/components/AuthProvider";
|
| 5 |
+
import DashboardSidebar from "@/components/DashboardSidebar";
|
| 6 |
+
import DashboardHeader from "@/components/DashboardHeader";
|
| 7 |
+
|
| 8 |
+
export default function AdminLayout({
|
| 9 |
+
children,
|
| 10 |
+
}: {
|
| 11 |
+
children: React.ReactNode;
|
| 12 |
+
}) {
|
| 13 |
+
const { role, loading, signOut } = useAuth();
|
| 14 |
+
const [mobileOpen, setMobileOpen] = useState(false);
|
| 15 |
+
const [isSidebarOpen, setIsSidebarOpen] = useState(false); // Closed by default
|
| 16 |
+
const router = useRouter();
|
| 17 |
+
|
| 18 |
+
useEffect(() => {
|
| 19 |
+
if (!loading && role !== "admin") {
|
| 20 |
+
router.push("/signin");
|
| 21 |
+
}
|
| 22 |
+
}, [loading, role, router]);
|
| 23 |
+
|
| 24 |
+
if (loading) return null;
|
| 25 |
+
|
| 26 |
+
return (
|
| 27 |
+
<div className="flex min-h-screen bg-urban-bg font-sans overflow-hidden relative">
|
| 28 |
+
<DashboardSidebar
|
| 29 |
+
role="admin"
|
| 30 |
+
mobileOpen={mobileOpen}
|
| 31 |
+
setMobileOpen={setMobileOpen}
|
| 32 |
+
desktopOpen={isSidebarOpen}
|
| 33 |
+
onLogout={signOut}
|
| 34 |
+
/>
|
| 35 |
+
|
| 36 |
+
{/* Ambient Background - Global for Admin */}
|
| 37 |
+
<div className="absolute top-0 left-0 w-full h-full pointer-events-none z-0 overflow-hidden">
|
| 38 |
+
<div className="absolute top-[-10%] right-[-5%] w-[40%] h-[40%] bg-urban-primary/5 rounded-full blur-[100px]"></div>
|
| 39 |
+
<div className="absolute bottom-[-10%] left-[-5%] w-[30%] h-[30%] bg-purple-500/5 rounded-full blur-[80px]"></div>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<div className="flex-1 flex flex-col min-w-0 transition-all duration-300 relative z-10 h-screen overflow-hidden">
|
| 43 |
+
<DashboardHeader
|
| 44 |
+
setMobileOpen={setMobileOpen}
|
| 45 |
+
toggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)}
|
| 46 |
+
title="Admin Console"
|
| 47 |
+
/>
|
| 48 |
+
<main className="flex-1 overflow-x-hidden overflow-y-auto">{children}</main>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
);
|
| 52 |
+
}
|
Frontend/app/admin/page.tsx
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
import Link from "next/link";
|
| 3 |
+
import { useCachedFetch } from "@/hooks/useCachedFetch";
|
| 4 |
+
import {
|
| 5 |
+
Building2,
|
| 6 |
+
Users,
|
| 7 |
+
ClipboardList,
|
| 8 |
+
Clock,
|
| 9 |
+
CheckCircle2,
|
| 10 |
+
ClipboardCheck,
|
| 11 |
+
} from "lucide-react";
|
| 12 |
+
import { Skeleton } from "@/components/ui/Skeleton";
|
| 13 |
+
import {
|
| 14 |
+
BarChart,
|
| 15 |
+
Bar,
|
| 16 |
+
XAxis,
|
| 17 |
+
YAxis,
|
| 18 |
+
CartesianGrid,
|
| 19 |
+
Tooltip,
|
| 20 |
+
ResponsiveContainer,
|
| 21 |
+
PieChart,
|
| 22 |
+
Pie,
|
| 23 |
+
Cell,
|
| 24 |
+
Legend,
|
| 25 |
+
} from "recharts";
|
| 26 |
+
|
| 27 |
+
interface Stats {
|
| 28 |
+
departments: number;
|
| 29 |
+
members: number;
|
| 30 |
+
total_issues: number;
|
| 31 |
+
pending_issues: number;
|
| 32 |
+
resolved_issues: number;
|
| 33 |
+
verification_needed: number;
|
| 34 |
+
issues_by_category: { name: string; value: number }[];
|
| 35 |
+
issues_activity: { name: string; reported: number; resolved: number }[];
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export default function AdminDashboard() {
|
| 39 |
+
const { data: stats, loading } = useCachedFetch<Stats>("/admin/stats");
|
| 40 |
+
|
| 41 |
+
const COLORS = [
|
| 42 |
+
"#3B82F6",
|
| 43 |
+
"#10B981",
|
| 44 |
+
"#F59E0B",
|
| 45 |
+
"#EF4444",
|
| 46 |
+
"#8B5CF6",
|
| 47 |
+
"#EC4899",
|
| 48 |
+
];
|
| 49 |
+
|
| 50 |
+
if (loading) {
|
| 51 |
+
return (
|
| 52 |
+
<div className="space-y-6">
|
| 53 |
+
<Skeleton className="h-8 w-48 mb-6" />
|
| 54 |
+
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-6 gap-6">
|
| 55 |
+
{Array.from({ length: 6 }).map((_, i) => (
|
| 56 |
+
<Skeleton key={i} className="h-32 rounded-2xl" />
|
| 57 |
+
))}
|
| 58 |
+
</div>
|
| 59 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 60 |
+
<Skeleton className="h-80 lg:col-span-2 rounded-2xl" />
|
| 61 |
+
<Skeleton className="h-80 rounded-2xl" />
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
const hasChartData =
|
| 68 |
+
stats?.issues_by_category && stats.issues_by_category.length > 0;
|
| 69 |
+
const hasActivityData =
|
| 70 |
+
stats?.issues_activity && stats.issues_activity.length > 0;
|
| 71 |
+
|
| 72 |
+
return (
|
| 73 |
+
<div className="space-y-6">
|
| 74 |
+
<div className="flex items-center justify-between">
|
| 75 |
+
<h2 className="text-2xl font-bold text-slate-900 tracking-tight">
|
| 76 |
+
Overview
|
| 77 |
+
</h2>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-6">
|
| 81 |
+
<StatCard
|
| 82 |
+
title="Departments"
|
| 83 |
+
value={stats?.departments || 0}
|
| 84 |
+
icon={<Building2 className="w-5 h-5 text-blue-600" />}
|
| 85 |
+
/>
|
| 86 |
+
<StatCard
|
| 87 |
+
title="Total Staff"
|
| 88 |
+
value={stats?.members || 0}
|
| 89 |
+
icon={<Users className="w-5 h-5 text-purple-600" />}
|
| 90 |
+
/>
|
| 91 |
+
<StatCard
|
| 92 |
+
title="Total Issues"
|
| 93 |
+
value={stats?.total_issues || 0}
|
| 94 |
+
icon={<ClipboardList className="w-5 h-5 text-slate-600" />}
|
| 95 |
+
/>
|
| 96 |
+
<StatCard
|
| 97 |
+
title="Pending"
|
| 98 |
+
value={stats?.pending_issues || 0}
|
| 99 |
+
icon={<Clock className="w-5 h-5 text-amber-600" />}
|
| 100 |
+
alert={true}
|
| 101 |
+
/>
|
| 102 |
+
<Link
|
| 103 |
+
href="/admin/issues?status=pending_verification"
|
| 104 |
+
className="block transform transition-transform hover:scale-105"
|
| 105 |
+
>
|
| 106 |
+
<StatCard
|
| 107 |
+
title="Needs Review"
|
| 108 |
+
value={stats?.verification_needed || 0}
|
| 109 |
+
icon={<ClipboardCheck className="w-5 h-5 text-indigo-600" />}
|
| 110 |
+
alert={(stats?.verification_needed || 0) > 0}
|
| 111 |
+
/>
|
| 112 |
+
</Link>
|
| 113 |
+
<StatCard
|
| 114 |
+
title="Total Resolved"
|
| 115 |
+
value={stats?.resolved_issues || 0}
|
| 116 |
+
icon={<CheckCircle2 className="w-5 h-5 text-emerald-600" />}
|
| 117 |
+
/>
|
| 118 |
+
</div>
|
| 119 |
+
|
| 120 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 121 |
+
<div className="lg:col-span-2 bg-white/60 backdrop-blur-md p-6 rounded-2xl border border-slate-200/60 shadow-urban-sm hover:shadow-urban-md transition-all">
|
| 122 |
+
<h3 className="text-lg font-bold text-slate-900 mb-6 flex items-center gap-2">
|
| 123 |
+
<span className="w-1 h-6 bg-blue-500 rounded-full"></span>
|
| 124 |
+
Weekly Activity
|
| 125 |
+
</h3>
|
| 126 |
+
{hasActivityData ? (
|
| 127 |
+
<div className="h-80 w-full">
|
| 128 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 129 |
+
<BarChart
|
| 130 |
+
data={stats?.issues_activity || []}
|
| 131 |
+
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
| 132 |
+
>
|
| 133 |
+
<CartesianGrid
|
| 134 |
+
strokeDasharray="3 3"
|
| 135 |
+
vertical={false}
|
| 136 |
+
stroke="#E2E8F0"
|
| 137 |
+
/>
|
| 138 |
+
<XAxis
|
| 139 |
+
dataKey="name"
|
| 140 |
+
axisLine={false}
|
| 141 |
+
tickLine={false}
|
| 142 |
+
tick={{ fill: "#64748B", fontSize: 12, fontFamily: 'var(--font-fira-sans)' }}
|
| 143 |
+
dy={10}
|
| 144 |
+
/>
|
| 145 |
+
<YAxis
|
| 146 |
+
axisLine={false}
|
| 147 |
+
tickLine={false}
|
| 148 |
+
tick={{ fill: "#64748B", fontSize: 12, fontFamily: 'var(--font-fira-sans)' }}
|
| 149 |
+
/>
|
| 150 |
+
<Tooltip
|
| 151 |
+
cursor={{ fill: "#F1F5F9" }}
|
| 152 |
+
contentStyle={{
|
| 153 |
+
borderRadius: "12px",
|
| 154 |
+
border: "1px solid rgba(226, 232, 240, 0.8)",
|
| 155 |
+
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1)",
|
| 156 |
+
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
| 157 |
+
backdropFilter: "blur(4px)",
|
| 158 |
+
fontFamily: 'var(--font-fira-sans)',
|
| 159 |
+
}}
|
| 160 |
+
/>
|
| 161 |
+
<Legend iconType="circle" wrapperStyle={{ fontFamily: 'var(--font-fira-sans)' }} />
|
| 162 |
+
<Bar
|
| 163 |
+
dataKey="reported"
|
| 164 |
+
name="Reported"
|
| 165 |
+
fill="#3B82F6"
|
| 166 |
+
radius={[4, 4, 0, 0]}
|
| 167 |
+
barSize={20}
|
| 168 |
+
/>
|
| 169 |
+
<Bar
|
| 170 |
+
dataKey="resolved"
|
| 171 |
+
name="Resolved"
|
| 172 |
+
fill="#10B981"
|
| 173 |
+
radius={[4, 4, 0, 0]}
|
| 174 |
+
barSize={20}
|
| 175 |
+
/>
|
| 176 |
+
</BarChart>
|
| 177 |
+
</ResponsiveContainer>
|
| 178 |
+
</div>
|
| 179 |
+
) : (
|
| 180 |
+
<div className="h-80 flex items-center justify-center text-slate-400">
|
| 181 |
+
No activity data available yet.
|
| 182 |
+
</div>
|
| 183 |
+
)}
|
| 184 |
+
</div>
|
| 185 |
+
|
| 186 |
+
<div className="bg-white/60 backdrop-blur-md p-6 rounded-2xl border border-slate-200/60 shadow-urban-sm hover:shadow-urban-md transition-all">
|
| 187 |
+
<h3 className="text-lg font-bold text-slate-900 mb-6 flex items-center gap-2">
|
| 188 |
+
<span className="w-1 h-6 bg-purple-500 rounded-full"></span>
|
| 189 |
+
Issues by Category
|
| 190 |
+
</h3>
|
| 191 |
+
{hasChartData ? (
|
| 192 |
+
<div className="h-80 w-full">
|
| 193 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 194 |
+
<PieChart>
|
| 195 |
+
<Pie
|
| 196 |
+
data={stats?.issues_by_category}
|
| 197 |
+
cx="50%"
|
| 198 |
+
cy="50%"
|
| 199 |
+
innerRadius={60}
|
| 200 |
+
outerRadius={80}
|
| 201 |
+
fill="#8884d8"
|
| 202 |
+
paddingAngle={5}
|
| 203 |
+
dataKey="value"
|
| 204 |
+
>
|
| 205 |
+
{stats?.issues_by_category?.map((entry, index) => (
|
| 206 |
+
<Cell
|
| 207 |
+
key={`cell-${index}`}
|
| 208 |
+
fill={COLORS[index % COLORS.length]}
|
| 209 |
+
/>
|
| 210 |
+
))}
|
| 211 |
+
</Pie>
|
| 212 |
+
<Tooltip
|
| 213 |
+
contentStyle={{
|
| 214 |
+
borderRadius: "12px",
|
| 215 |
+
border: "1px solid rgba(226, 232, 240, 0.8)",
|
| 216 |
+
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1)",
|
| 217 |
+
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
| 218 |
+
backdropFilter: "blur(4px)",
|
| 219 |
+
fontFamily: 'var(--font-fira-sans)',
|
| 220 |
+
}}
|
| 221 |
+
/>
|
| 222 |
+
<Legend verticalAlign="bottom" height={36} wrapperStyle={{ fontFamily: 'var(--font-fira-sans)' }} />
|
| 223 |
+
</PieChart>
|
| 224 |
+
</ResponsiveContainer>
|
| 225 |
+
</div>
|
| 226 |
+
) : (
|
| 227 |
+
<div className="h-80 flex items-center justify-center text-slate-400">
|
| 228 |
+
No category data available yet.
|
| 229 |
+
</div>
|
| 230 |
+
)}
|
| 231 |
+
</div>
|
| 232 |
+
</div>
|
| 233 |
+
</div>
|
| 234 |
+
);
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
function StatCard({
|
| 238 |
+
title,
|
| 239 |
+
value,
|
| 240 |
+
icon,
|
| 241 |
+
alert = false,
|
| 242 |
+
}: {
|
| 243 |
+
title: string;
|
| 244 |
+
value: number;
|
| 245 |
+
icon: React.ReactNode;
|
| 246 |
+
alert?: boolean;
|
| 247 |
+
}) {
|
| 248 |
+
return (
|
| 249 |
+
<div className="bg-white/60 backdrop-blur-md p-6 rounded-2xl border border-slate-200/60 shadow-urban-sm hover:shadow-urban-md transition-all hover:-translate-y-1 group">
|
| 250 |
+
<div className="flex justify-between items-start mb-4">
|
| 251 |
+
<div className="p-3 bg-white rounded-xl border border-slate-100 shadow-sm group-hover:scale-110 transition-transform duration-300">
|
| 252 |
+
{icon}
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
<div>
|
| 256 |
+
<h3 className="text-slate-500 text-xs font-bold uppercase tracking-wider font-mono">
|
| 257 |
+
{title}
|
| 258 |
+
</h3>
|
| 259 |
+
<p
|
| 260 |
+
className={`text-3xl font-extrabold mt-2 tracking-tight ${
|
| 261 |
+
alert ? "text-amber-600" : "text-slate-900"
|
| 262 |
+
}`}
|
| 263 |
+
>
|
| 264 |
+
{value}
|
| 265 |
+
</p>
|
| 266 |
+
</div>
|
| 267 |
+
</div>
|
| 268 |
+
);
|
| 269 |
+
}
|
Frontend/app/admin/review/page.tsx
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useEffect, useState } from "react";
|
| 3 |
+
import { apiGet, apiPost } from "@/lib/api";
|
| 4 |
+
import { CheckCircle2, XCircle } from "lucide-react";
|
| 5 |
+
|
| 6 |
+
interface Issue {
|
| 7 |
+
id: string;
|
| 8 |
+
description: string;
|
| 9 |
+
state: string;
|
| 10 |
+
city: string;
|
| 11 |
+
locality: string;
|
| 12 |
+
created_at: string;
|
| 13 |
+
full_address: string;
|
| 14 |
+
images: { file_path: string; annotated_path: string }[];
|
| 15 |
+
priority: number;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export default function ManualReviewPage() {
|
| 19 |
+
const [issues, setIssues] = useState<Issue[]>([]);
|
| 20 |
+
const [loading, setLoading] = useState(true);
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
fetchPendingIssues();
|
| 24 |
+
}, []);
|
| 25 |
+
|
| 26 |
+
const fetchPendingIssues = async () => {
|
| 27 |
+
try {
|
| 28 |
+
const data = await apiGet<{ items: Issue[] }>("/issues?state=reported");
|
| 29 |
+
setIssues(data.items || []);
|
| 30 |
+
} catch (error) {
|
| 31 |
+
console.error("Failed to fetch issues:", error);
|
| 32 |
+
} finally {
|
| 33 |
+
setLoading(false);
|
| 34 |
+
}
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
const handleReview = async (id: string, status: "approved" | "rejected") => {
|
| 38 |
+
try {
|
| 39 |
+
const data = await apiPost<{ message: string }>(`/admin/issues/${id}/review`, { status });
|
| 40 |
+
setIssues(prev => prev.filter(i => i.id !== id));
|
| 41 |
+
alert(data.message);
|
| 42 |
+
} catch (error) {
|
| 43 |
+
console.error("Review failed", error);
|
| 44 |
+
alert("Failed to review issue");
|
| 45 |
+
}
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
if (loading) {
|
| 49 |
+
return <div className="text-slate-600 font-medium">Loading Reviews...</div>;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<div className="space-y-6">
|
| 54 |
+
<div className="flex justify-between items-center">
|
| 55 |
+
<div>
|
| 56 |
+
<h2 className="text-2xl font-bold text-slate-900">Manual Review Queue</h2>
|
| 57 |
+
<p className="text-sm text-slate-500">Validate incoming citizen reports before assignment.</p>
|
| 58 |
+
</div>
|
| 59 |
+
<div className="bg-blue-600 text-white text-xs font-bold px-3 py-1.5 rounded-full shadow-sm">
|
| 60 |
+
{issues.length} Pending
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
{issues.length === 0 ? (
|
| 65 |
+
<div className="text-center py-20 bg-white rounded-xl border border-slate-200 shadow-sm">
|
| 66 |
+
<CheckCircle2 className="w-12 h-12 mx-auto text-green-400" />
|
| 67 |
+
<p className="text-slate-900 font-medium mt-4 text-lg">All caught up!</p>
|
| 68 |
+
<p className="text-slate-500">No issues pending manual review.</p>
|
| 69 |
+
</div>
|
| 70 |
+
) : (
|
| 71 |
+
<div className="grid gap-6">
|
| 72 |
+
{issues.map((issue) => (
|
| 73 |
+
<div key={issue.id} className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden flex flex-col md:flex-row">
|
| 74 |
+
<div className="md:w-1/3 h-64 md:h-auto bg-slate-100 relative">
|
| 75 |
+
{issue.images?.[0] ? (
|
| 76 |
+
<img
|
| 77 |
+
src={issue.images[0].annotated_path || issue.images[0].file_path}
|
| 78 |
+
alt="Evidence"
|
| 79 |
+
className="w-full h-full object-cover"
|
| 80 |
+
/>
|
| 81 |
+
) : (
|
| 82 |
+
<div className="flex items-center justify-center h-full text-slate-400">No Image</div>
|
| 83 |
+
)}
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<div className="p-6 md:w-2/3 flex flex-col justify-between">
|
| 87 |
+
<div>
|
| 88 |
+
<div className="flex justify-between items-start mb-2">
|
| 89 |
+
<span className="px-2.5 py-0.5 rounded-full bg-slate-100 text-slate-600 text-xs font-bold uppercase">
|
| 90 |
+
{issue.city}
|
| 91 |
+
</span>
|
| 92 |
+
<span className="text-xs text-slate-400">
|
| 93 |
+
{new Date(issue.created_at).toLocaleDateString()}
|
| 94 |
+
</span>
|
| 95 |
+
</div>
|
| 96 |
+
<h3 className="text-lg font-bold text-slate-900 mb-2">{issue.description || "No description"}</h3>
|
| 97 |
+
<p className="text-slate-600 text-sm mb-4">{issue.full_address || issue.locality}</p>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<div className="flex gap-4 mt-4 pt-4 border-t border-slate-100">
|
| 101 |
+
<button
|
| 102 |
+
onClick={() => handleReview(issue.id, "approved")}
|
| 103 |
+
className="flex-1 bg-green-600 hover:bg-green-700 text-white font-semibold py-2 px-4 rounded-lg transition shadow-sm flex items-center justify-center gap-2"
|
| 104 |
+
>
|
| 105 |
+
<CheckCircle2 className="w-4 h-4" /> Approve & Assign
|
| 106 |
+
</button>
|
| 107 |
+
<button
|
| 108 |
+
onClick={() => handleReview(issue.id, "rejected")}
|
| 109 |
+
className="flex-1 bg-white border border-red-200 text-red-600 hover:bg-red-50 font-semibold py-2 px-4 rounded-lg transition flex items-center justify-center gap-2"
|
| 110 |
+
>
|
| 111 |
+
<XCircle className="w-4 h-4" /> Reject
|
| 112 |
+
</button>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
))}
|
| 117 |
+
</div>
|
| 118 |
+
)}
|
| 119 |
+
</div>
|
| 120 |
+
);
|
| 121 |
+
}
|
Frontend/app/admin/workers/page.tsx
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useMemo, useState } from "react";
|
| 3 |
+
import { apiGet, apiPost } from "@/lib/api";
|
| 4 |
+
import { useCachedFetch } from "@/hooks/useCachedFetch";
|
| 5 |
+
import {
|
| 6 |
+
HardHat,
|
| 7 |
+
Plus,
|
| 8 |
+
Search,
|
| 9 |
+
Filter,
|
| 10 |
+
CheckCircle2,
|
| 11 |
+
AlertTriangle,
|
| 12 |
+
TrendingUp,
|
| 13 |
+
} from "lucide-react";
|
| 14 |
+
import { Skeleton } from "@/components/ui/Skeleton";
|
| 15 |
+
|
| 16 |
+
interface Department {
|
| 17 |
+
id: string;
|
| 18 |
+
name: string;
|
| 19 |
+
code: string;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
interface Worker {
|
| 23 |
+
id: string;
|
| 24 |
+
name: string;
|
| 25 |
+
email: string;
|
| 26 |
+
role: string;
|
| 27 |
+
department_id: string;
|
| 28 |
+
is_active: boolean;
|
| 29 |
+
current_workload: number;
|
| 30 |
+
max_workload: number;
|
| 31 |
+
|
| 32 |
+
resolved_total?: number;
|
| 33 |
+
efficiency?: number;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
interface WorkerPerformance {
|
| 37 |
+
id: string;
|
| 38 |
+
resolved_total: number;
|
| 39 |
+
efficiency: number;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export default function WorkersPage() {
|
| 43 |
+
const { data: departmentsData, loading: deptLoading, revalidate: revalidateDept } = useCachedFetch<Department[]>("/admin/departments");
|
| 44 |
+
const { data: workersData, loading: workersLoading, revalidate: revalidateWorkers } = useCachedFetch<Worker[]>("/admin/members");
|
| 45 |
+
const { data: perfData, loading: perfLoading, revalidate: revalidatePerf } = useCachedFetch<WorkerPerformance[]>("/admin/workers/performance");
|
| 46 |
+
|
| 47 |
+
const [showForm, setShowForm] = useState(false);
|
| 48 |
+
const [formData, setFormData] = useState({
|
| 49 |
+
name: "",
|
| 50 |
+
email: "",
|
| 51 |
+
password: "",
|
| 52 |
+
department_id: "",
|
| 53 |
+
role: "worker",
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
const [search, setSearch] = useState("");
|
| 57 |
+
|
| 58 |
+
const departments = departmentsData || [];
|
| 59 |
+
|
| 60 |
+
const workers = useMemo(() => {
|
| 61 |
+
if (!workersData) return [];
|
| 62 |
+
const perfMap = new Map((perfData || []).map(p => [p.id, p]));
|
| 63 |
+
|
| 64 |
+
return workersData.map((w) => {
|
| 65 |
+
const perf = perfMap.get(w.id);
|
| 66 |
+
return {
|
| 67 |
+
...w,
|
| 68 |
+
resolved_total: perf?.resolved_total || 0,
|
| 69 |
+
efficiency: perf?.efficiency || 0,
|
| 70 |
+
};
|
| 71 |
+
});
|
| 72 |
+
}, [workersData, perfData]);
|
| 73 |
+
|
| 74 |
+
const loading = deptLoading || workersLoading || perfLoading;
|
| 75 |
+
|
| 76 |
+
const refreshAll = () => {
|
| 77 |
+
revalidateDept();
|
| 78 |
+
revalidateWorkers();
|
| 79 |
+
revalidatePerf();
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 83 |
+
e.preventDefault();
|
| 84 |
+
try {
|
| 85 |
+
await apiPost("/admin/members", formData);
|
| 86 |
+
setShowForm(false);
|
| 87 |
+
setFormData({
|
| 88 |
+
name: "",
|
| 89 |
+
email: "",
|
| 90 |
+
password: "",
|
| 91 |
+
department_id: "",
|
| 92 |
+
role: "worker",
|
| 93 |
+
});
|
| 94 |
+
refreshAll();
|
| 95 |
+
} catch (error: unknown) {
|
| 96 |
+
const message =
|
| 97 |
+
error instanceof Error ? error.message : "Failed to create worker";
|
| 98 |
+
alert(message);
|
| 99 |
+
}
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
const getDepartmentName = (deptId: string) => {
|
| 103 |
+
const dept = departments.find((d) => d.id === deptId);
|
| 104 |
+
return dept ? dept.name : "Unassigned";
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
if (loading) {
|
| 108 |
+
return (
|
| 109 |
+
<div className="space-y-6">
|
| 110 |
+
<div className="flex justify-between items-center mb-6">
|
| 111 |
+
<Skeleton className="h-10 w-64" />
|
| 112 |
+
<Skeleton className="h-10 w-32" />
|
| 113 |
+
</div>
|
| 114 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 115 |
+
{Array.from({ length: 6 }).map((_, i) => (
|
| 116 |
+
<Skeleton key={i} className="h-48 rounded-xl" />
|
| 117 |
+
))}
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
const filteredWorkers = workers
|
| 124 |
+
.filter((w) => w.role !== "admin")
|
| 125 |
+
.filter(
|
| 126 |
+
(w) =>
|
| 127 |
+
search === "" ||
|
| 128 |
+
w.name.toLowerCase().includes(search.toLowerCase()) ||
|
| 129 |
+
w.email.toLowerCase().includes(search.toLowerCase()),
|
| 130 |
+
);
|
| 131 |
+
|
| 132 |
+
return (
|
| 133 |
+
<div className="space-y-6">
|
| 134 |
+
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
| 135 |
+
<div>
|
| 136 |
+
<h2 className="text-2xl font-bold text-slate-900">
|
| 137 |
+
Workforce Management
|
| 138 |
+
</h2>
|
| 139 |
+
<p className="text-sm text-slate-500">
|
| 140 |
+
Manage field workers, assign tasks, and monitor performance.
|
| 141 |
+
</p>
|
| 142 |
+
</div>
|
| 143 |
+
<button
|
| 144 |
+
onClick={() => setShowForm(true)}
|
| 145 |
+
className="px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition shadow-sm flex items-center gap-2"
|
| 146 |
+
>
|
| 147 |
+
<Plus className="w-4 h-4" /> Enroll Worker
|
| 148 |
+
</button>
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
{showForm && (
|
| 152 |
+
<div className="bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden animate-in fade-in slide-in-from-top-4">
|
| 153 |
+
<div className="bg-slate-50 px-6 py-4 border-b border-slate-200">
|
| 154 |
+
<h2 className="text-lg font-bold text-slate-800">
|
| 155 |
+
New Worker Enrollment
|
| 156 |
+
</h2>
|
| 157 |
+
</div>
|
| 158 |
+
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
| 159 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 160 |
+
<div>
|
| 161 |
+
<label className="block text-sm font-medium text-slate-700 mb-1">
|
| 162 |
+
Full Name
|
| 163 |
+
</label>
|
| 164 |
+
<input
|
| 165 |
+
type="text"
|
| 166 |
+
value={formData.name}
|
| 167 |
+
onChange={(e) =>
|
| 168 |
+
setFormData({ ...formData, name: e.target.value })
|
| 169 |
+
}
|
| 170 |
+
className="w-full px-4 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
|
| 171 |
+
placeholder="e.g. John Doe"
|
| 172 |
+
required
|
| 173 |
+
/>
|
| 174 |
+
</div>
|
| 175 |
+
<div>
|
| 176 |
+
<label className="block text-sm font-medium text-slate-700 mb-1">
|
| 177 |
+
Email Address
|
| 178 |
+
</label>
|
| 179 |
+
<input
|
| 180 |
+
type="email"
|
| 181 |
+
value={formData.email}
|
| 182 |
+
onChange={(e) =>
|
| 183 |
+
setFormData({ ...formData, email: e.target.value })
|
| 184 |
+
}
|
| 185 |
+
className="w-full px-4 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
|
| 186 |
+
placeholder="worker@city.gov"
|
| 187 |
+
required
|
| 188 |
+
/>
|
| 189 |
+
</div>
|
| 190 |
+
</div>
|
| 191 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 192 |
+
<div>
|
| 193 |
+
<label className="block text-sm font-medium text-slate-700 mb-1">
|
| 194 |
+
Set Password
|
| 195 |
+
</label>
|
| 196 |
+
<input
|
| 197 |
+
type="password"
|
| 198 |
+
value={formData.password}
|
| 199 |
+
onChange={(e) =>
|
| 200 |
+
setFormData({ ...formData, password: e.target.value })
|
| 201 |
+
}
|
| 202 |
+
className="w-full px-4 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
|
| 203 |
+
required
|
| 204 |
+
minLength={8}
|
| 205 |
+
placeholder="••••••••"
|
| 206 |
+
/>
|
| 207 |
+
</div>
|
| 208 |
+
<div>
|
| 209 |
+
<label className="block text-sm font-medium text-slate-700 mb-1">
|
| 210 |
+
Assign Department
|
| 211 |
+
</label>
|
| 212 |
+
<select
|
| 213 |
+
title="department"
|
| 214 |
+
value={formData.department_id}
|
| 215 |
+
onChange={(e) =>
|
| 216 |
+
setFormData({ ...formData, department_id: e.target.value })
|
| 217 |
+
}
|
| 218 |
+
className="w-full px-4 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
|
| 219 |
+
required
|
| 220 |
+
>
|
| 221 |
+
<option value="">Select Department...</option>
|
| 222 |
+
{departments.map((d) => (
|
| 223 |
+
<option key={d.id} value={d.id}>
|
| 224 |
+
{d.name}
|
| 225 |
+
</option>
|
| 226 |
+
))}
|
| 227 |
+
</select>
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
|
| 231 |
+
<div className="flex gap-3 pt-2">
|
| 232 |
+
<button
|
| 233 |
+
type="submit"
|
| 234 |
+
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition shadow-sm"
|
| 235 |
+
>
|
| 236 |
+
Enroll Worker
|
| 237 |
+
</button>
|
| 238 |
+
<button
|
| 239 |
+
type="button"
|
| 240 |
+
onClick={() => setShowForm(false)}
|
| 241 |
+
className="px-6 py-2 bg-white text-slate-700 font-medium rounded-lg border border-slate-300 hover:bg-slate-50 transition"
|
| 242 |
+
>
|
| 243 |
+
Cancel
|
| 244 |
+
</button>
|
| 245 |
+
</div>
|
| 246 |
+
</form>
|
| 247 |
+
</div>
|
| 248 |
+
)}
|
| 249 |
+
|
| 250 |
+
<div className="flex gap-4 mb-4">
|
| 251 |
+
<div className="relative flex-1 max-w-sm">
|
| 252 |
+
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
|
| 253 |
+
<input
|
| 254 |
+
type="text"
|
| 255 |
+
placeholder="Search workers by name or email..."
|
| 256 |
+
value={search}
|
| 257 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 258 |
+
className="w-full pl-9 pr-4 py-2 bg-white border border-slate-200 rounded-lg text-sm focus:border-blue-500 outline-none"
|
| 259 |
+
/>
|
| 260 |
+
</div>
|
| 261 |
+
<button className="px-3 py-2 bg-white border border-slate-200 rounded-lg text-slate-600 flex items-center gap-2 hover:bg-slate-50">
|
| 262 |
+
<Filter className="w-4 h-4" /> Filter
|
| 263 |
+
</button>
|
| 264 |
+
</div>
|
| 265 |
+
|
| 266 |
+
<div className="space-y-4">
|
| 267 |
+
<div className="flex justify-between items-center px-1">
|
| 268 |
+
<p className="text-sm font-medium text-slate-500">
|
| 269 |
+
Active Workforce ({filteredWorkers.length})
|
| 270 |
+
</p>
|
| 271 |
+
</div>
|
| 272 |
+
|
| 273 |
+
{filteredWorkers.length === 0 ? (
|
| 274 |
+
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
|
| 275 |
+
<HardHat className="w-12 h-12 mx-auto text-slate-300" />
|
| 276 |
+
<p className="text-slate-500 mt-2">No field workers found.</p>
|
| 277 |
+
</div>
|
| 278 |
+
) : (
|
| 279 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 280 |
+
{filteredWorkers.map((worker) => (
|
| 281 |
+
<div
|
| 282 |
+
key={worker.id}
|
| 283 |
+
className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-all"
|
| 284 |
+
>
|
| 285 |
+
<div className="flex justify-between items-start mb-4">
|
| 286 |
+
<div className="flex items-center gap-3">
|
| 287 |
+
<div className="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center text-lg font-bold text-slate-700 border border-slate-200">
|
| 288 |
+
{worker.name.charAt(0)}
|
| 289 |
+
</div>
|
| 290 |
+
<div>
|
| 291 |
+
<h3 className="font-bold text-slate-900 leading-tight">
|
| 292 |
+
{worker.name}
|
| 293 |
+
</h3>
|
| 294 |
+
<p className="text-xs text-slate-500">{worker.email}</p>
|
| 295 |
+
</div>
|
| 296 |
+
</div>
|
| 297 |
+
{!worker.is_active && (
|
| 298 |
+
<span className="px-2 py-0.5 bg-red-100 text-red-700 text-xs font-bold rounded">
|
| 299 |
+
INACTIVE
|
| 300 |
+
</span>
|
| 301 |
+
)}
|
| 302 |
+
</div>
|
| 303 |
+
|
| 304 |
+
<div className="py-3 border-t border-slate-100 mb-3 space-y-2">
|
| 305 |
+
<div className="flex justify-between text-sm">
|
| 306 |
+
<span className="text-slate-500">Department</span>
|
| 307 |
+
<span className="font-medium text-slate-900">
|
| 308 |
+
{getDepartmentName(worker.department_id)}
|
| 309 |
+
</span>
|
| 310 |
+
</div>
|
| 311 |
+
<div className="flex justify-between text-sm">
|
| 312 |
+
<span className="text-slate-500">Efficiency</span>
|
| 313 |
+
<span className="font-medium text-slate-900 flex items-center gap-1">
|
| 314 |
+
{worker.efficiency}{" "}
|
| 315 |
+
<span className="text-xs text-slate-400">/week</span>
|
| 316 |
+
{worker.efficiency && worker.efficiency > 5 && (
|
| 317 |
+
<TrendingUp className="w-3 h-3 text-green-500" />
|
| 318 |
+
)}
|
| 319 |
+
</span>
|
| 320 |
+
</div>
|
| 321 |
+
<div className="flex justify-between text-sm">
|
| 322 |
+
<span className="text-slate-500">Total Resolved</span>
|
| 323 |
+
<span className="font-medium text-slate-900 flex items-center gap-1">
|
| 324 |
+
<CheckCircle2 className="w-3 h-3 text-green-500" />
|
| 325 |
+
{worker.resolved_total}
|
| 326 |
+
</span>
|
| 327 |
+
</div>
|
| 328 |
+
</div>
|
| 329 |
+
|
| 330 |
+
<div>
|
| 331 |
+
<div className="flex justify-between text-xs mb-1">
|
| 332 |
+
<span className="text-slate-500 font-medium">
|
| 333 |
+
Current Workload
|
| 334 |
+
</span>
|
| 335 |
+
<span className="text-slate-900 font-bold">
|
| 336 |
+
{worker.current_workload} / {worker.max_workload}
|
| 337 |
+
</span>
|
| 338 |
+
</div>
|
| 339 |
+
<div className="w-full bg-slate-100 rounded-full h-2 overflow-hidden">
|
| 340 |
+
<div
|
| 341 |
+
className={`h-full rounded-full transition-all duration-500 ${
|
| 342 |
+
worker.current_workload >= worker.max_workload
|
| 343 |
+
? "bg-red-500"
|
| 344 |
+
: worker.current_workload > worker.max_workload * 0.7
|
| 345 |
+
? "bg-amber-500"
|
| 346 |
+
: "bg-blue-600"
|
| 347 |
+
}`}
|
| 348 |
+
style={{
|
| 349 |
+
width: `${Math.min(
|
| 350 |
+
(worker.current_workload /
|
| 351 |
+
(worker.max_workload || 10)) *
|
| 352 |
+
100,
|
| 353 |
+
100,
|
| 354 |
+
)}%`,
|
| 355 |
+
}}
|
| 356 |
+
></div>
|
| 357 |
+
</div>
|
| 358 |
+
{worker.current_workload >= worker.max_workload && (
|
| 359 |
+
<div className="mt-2 flex items-center gap-1 text-xs text-red-600 font-medium">
|
| 360 |
+
<AlertTriangle className="w-3 h-3" /> Overloaded
|
| 361 |
+
</div>
|
| 362 |
+
)}
|
| 363 |
+
</div>
|
| 364 |
+
</div>
|
| 365 |
+
))}
|
| 366 |
+
</div>
|
| 367 |
+
)}
|
| 368 |
+
</div>
|
| 369 |
+
</div>
|
| 370 |
+
);
|
| 371 |
+
}
|
Frontend/app/auth/callback/page.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useEffect } from "react";
|
| 3 |
+
import { useRouter } from "next/navigation";
|
| 4 |
+
import { useAuth } from "@/components/AuthProvider";
|
| 5 |
+
import { Loader2 } from "lucide-react";
|
| 6 |
+
|
| 7 |
+
export default function AuthCallbackPage() {
|
| 8 |
+
const router = useRouter();
|
| 9 |
+
const { session, loading } = useAuth();
|
| 10 |
+
|
| 11 |
+
useEffect(() => {
|
| 12 |
+
if (!loading) {
|
| 13 |
+
if (session) {
|
| 14 |
+
const user = session.user;
|
| 15 |
+
|
| 16 |
+
const storedUser = localStorage.getItem("user");
|
| 17 |
+
if (storedUser) {
|
| 18 |
+
const parsed = JSON.parse(storedUser);
|
| 19 |
+
if (parsed.role === "admin") router.push("/admin");
|
| 20 |
+
else if (parsed.role === "worker") router.push("/worker");
|
| 21 |
+
else router.push("/user");
|
| 22 |
+
} else {
|
| 23 |
+
router.push("/user");
|
| 24 |
+
}
|
| 25 |
+
} else {
|
| 26 |
+
const timer = setTimeout(() => {
|
| 27 |
+
router.push("/signin?error=callback_timeout");
|
| 28 |
+
}, 3000);
|
| 29 |
+
return () => clearTimeout(timer);
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
}, [session, loading, router]);
|
| 33 |
+
|
| 34 |
+
return (
|
| 35 |
+
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
| 36 |
+
<div className="text-center">
|
| 37 |
+
<Loader2 className="w-10 h-10 animate-spin text-blue-600 mx-auto mb-4" />
|
| 38 |
+
<h2 className="text-xl font-bold text-slate-800">Authenticating...</h2>
|
| 39 |
+
<p className="text-slate-500">Please wait while we log you in.</p>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
);
|
| 43 |
+
}
|
Frontend/app/globals.css
CHANGED
|
@@ -1,26 +1,66 @@
|
|
| 1 |
@import "tailwindcss";
|
| 2 |
|
| 3 |
-
|
| 4 |
-
--
|
| 5 |
-
--
|
| 6 |
-
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
--color-
|
| 10 |
-
--color-
|
| 11 |
-
--
|
| 12 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
-
|
| 16 |
-
:
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
body {
|
| 23 |
background: var(--background);
|
| 24 |
color: var(--foreground);
|
| 25 |
-
font-family:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
}
|
|
|
|
| 1 |
@import "tailwindcss";
|
| 2 |
|
| 3 |
+
@theme {
|
| 4 |
+
--color-urban-primary: #3B82F6;
|
| 5 |
+
--color-urban-secondary: #60A5FA;
|
| 6 |
+
--color-urban-cta: #F97316;
|
| 7 |
+
--color-urban-bg: #F8FAFC;
|
| 8 |
+
--color-urban-text: #1E293B;
|
| 9 |
|
| 10 |
+
--color-slate-50: #f8fafc;
|
| 11 |
+
--color-slate-100: #f1f5f9;
|
| 12 |
+
--color-slate-200: #e2e8f0;
|
| 13 |
+
--color-slate-300: #cbd5e1;
|
| 14 |
+
--color-slate-400: #94a3b8;
|
| 15 |
+
--color-slate-500: #64748b;
|
| 16 |
+
--color-slate-600: #475569;
|
| 17 |
+
--color-slate-700: #334155;
|
| 18 |
+
--color-slate-800: #1e293b;
|
| 19 |
+
--color-slate-900: #0f172a;
|
| 20 |
+
--color-slate-950: #020617;
|
| 21 |
+
|
| 22 |
+
--font-fira-code: 'Fira Code', monospace;
|
| 23 |
+
--font-fira-sans: 'Fira Sans', sans-serif;
|
| 24 |
+
|
| 25 |
+
--shadow-urban-sm: 0 1px 2px rgba(0,0,0,0.05);
|
| 26 |
+
--shadow-urban-md: 0 4px 6px rgba(0,0,0,0.1);
|
| 27 |
+
--shadow-urban-lg: 0 10px 15px rgba(0,0,0,0.1);
|
| 28 |
+
--shadow-urban-xl: 0 20px 25px rgba(0,0,0,0.15);
|
| 29 |
}
|
| 30 |
|
| 31 |
+
:root {
|
| 32 |
+
--background: var(--color-urban-bg);
|
| 33 |
+
--foreground: var(--color-urban-text);
|
| 34 |
+
--primary: var(--color-urban-primary);
|
| 35 |
+
--secondary: var(--color-urban-secondary);
|
| 36 |
+
--cta: var(--color-urban-cta);
|
| 37 |
}
|
| 38 |
|
| 39 |
body {
|
| 40 |
background: var(--background);
|
| 41 |
color: var(--foreground);
|
| 42 |
+
font-family: var(--font-fira-sans);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
h1, h2, h3, h4, h5, h6 {
|
| 46 |
+
font-family: var(--font-fira-code);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
input,
|
| 50 |
+
select,
|
| 51 |
+
textarea {
|
| 52 |
+
border-width: 1px;
|
| 53 |
+
border-color: var(--color-slate-300);
|
| 54 |
+
outline: none;
|
| 55 |
+
font-family: var(--font-fira-sans);
|
| 56 |
+
border-radius: 8px;
|
| 57 |
+
padding: 12px 16px;
|
| 58 |
+
transition: border-color 0.2s;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
input:focus,
|
| 62 |
+
select:focus,
|
| 63 |
+
textarea:focus {
|
| 64 |
+
border-color: var(--primary);
|
| 65 |
+
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); /* urban-primary 10% */
|
| 66 |
}
|
Frontend/app/layout.tsx
CHANGED
|
@@ -1,22 +1,28 @@
|
|
| 1 |
import type { Metadata } from "next";
|
| 2 |
-
import {
|
| 3 |
import "./globals.css";
|
| 4 |
|
| 5 |
-
const
|
| 6 |
-
variable: "--font-geist-sans",
|
| 7 |
subsets: ["latin"],
|
|
|
|
|
|
|
|
|
|
| 8 |
});
|
| 9 |
|
| 10 |
-
const
|
| 11 |
-
variable: "--font-geist-mono",
|
| 12 |
subsets: ["latin"],
|
|
|
|
|
|
|
|
|
|
| 13 |
});
|
| 14 |
|
| 15 |
export const metadata: Metadata = {
|
| 16 |
-
title: "
|
| 17 |
-
description: "
|
| 18 |
};
|
| 19 |
|
|
|
|
|
|
|
| 20 |
export default function RootLayout({
|
| 21 |
children,
|
| 22 |
}: Readonly<{
|
|
@@ -24,10 +30,8 @@ export default function RootLayout({
|
|
| 24 |
}>) {
|
| 25 |
return (
|
| 26 |
<html lang="en">
|
| 27 |
-
<body
|
| 28 |
-
|
| 29 |
-
>
|
| 30 |
-
{children}
|
| 31 |
</body>
|
| 32 |
</html>
|
| 33 |
);
|
|
|
|
| 1 |
import type { Metadata } from "next";
|
| 2 |
+
import { Fira_Sans, Fira_Code } from "next/font/google";
|
| 3 |
import "./globals.css";
|
| 4 |
|
| 5 |
+
const firaSans = Fira_Sans({
|
|
|
|
| 6 |
subsets: ["latin"],
|
| 7 |
+
weight: ["300", "400", "500", "600", "700"],
|
| 8 |
+
variable: "--font-fira-sans",
|
| 9 |
+
display: "swap",
|
| 10 |
});
|
| 11 |
|
| 12 |
+
const firaCode = Fira_Code({
|
|
|
|
| 13 |
subsets: ["latin"],
|
| 14 |
+
weight: ["400", "500", "600", "700"],
|
| 15 |
+
variable: "--font-fira-code",
|
| 16 |
+
display: "swap",
|
| 17 |
});
|
| 18 |
|
| 19 |
export const metadata: Metadata = {
|
| 20 |
+
title: "UrbanLens - City Issue Reporter",
|
| 21 |
+
description: "Smart city issue tracking and resolution dashboard",
|
| 22 |
};
|
| 23 |
|
| 24 |
+
import { AuthProvider } from "@/components/AuthProvider";
|
| 25 |
+
|
| 26 |
export default function RootLayout({
|
| 27 |
children,
|
| 28 |
}: Readonly<{
|
|
|
|
| 30 |
}>) {
|
| 31 |
return (
|
| 32 |
<html lang="en">
|
| 33 |
+
<body className={`${firaSans.variable} ${firaCode.variable} antialiased font-sans`}>
|
| 34 |
+
<AuthProvider>{children}</AuthProvider>
|
|
|
|
|
|
|
| 35 |
</body>
|
| 36 |
</html>
|
| 37 |
);
|
Frontend/app/page.tsx
CHANGED
|
@@ -1,65 +1,245 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
export default function Home() {
|
| 4 |
return (
|
| 5 |
-
<div className="
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
<div className="
|
| 16 |
-
<
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
</
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
</div>
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
<
|
| 45 |
-
className="
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
/>
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
| 61 |
</div>
|
| 62 |
</main>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
</div>
|
| 64 |
);
|
| 65 |
}
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import Link from "next/link";
|
| 3 |
+
import {
|
| 4 |
+
Smartphone,
|
| 5 |
+
Zap,
|
| 6 |
+
Shield,
|
| 7 |
+
ChevronRight,
|
| 8 |
+
Radio,
|
| 9 |
+
Activity,
|
| 10 |
+
MapPin,
|
| 11 |
+
CheckCircle2,
|
| 12 |
+
Building2,
|
| 13 |
+
Users,
|
| 14 |
+
LayoutDashboard,
|
| 15 |
+
LogOut
|
| 16 |
+
} from "lucide-react";
|
| 17 |
+
import { useAuth } from "@/components/AuthProvider";
|
| 18 |
+
|
| 19 |
+
export default function LandingPage() {
|
| 20 |
+
const { user, role, signOut } = useAuth();
|
| 21 |
+
|
| 22 |
+
const getDashboardLink = () => {
|
| 23 |
+
if (role === 'admin') return '/admin';
|
| 24 |
+
if (role === 'worker') return '/worker';
|
| 25 |
+
return '/user';
|
| 26 |
+
};
|
| 27 |
|
|
|
|
| 28 |
return (
|
| 29 |
+
<div className="min-h-screen bg-slate-50 text-slate-900 font-sans selection:bg-blue-100 selection:text-blue-900 overflow-x-hidden">
|
| 30 |
+
{/* Background Mesh Gradients */}
|
| 31 |
+
<div className="fixed inset-0 z-0 opacity-60 pointer-events-none">
|
| 32 |
+
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-blue-200/50 rounded-full blur-[100px]" />
|
| 33 |
+
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-indigo-200/50 rounded-full blur-[100px]" />
|
| 34 |
+
<div className="absolute top-[20%] right-[10%] w-[30%] h-[30%] bg-cyan-200/40 rounded-full blur-[80px]" />
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
{/* Navigation */}
|
| 38 |
+
<nav className="fixed top-0 w-full z-50 border-b border-slate-200 bg-white/80 backdrop-blur-xl transition-all">
|
| 39 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 40 |
+
<div className="flex justify-between h-20 items-center">
|
| 41 |
+
<div className="flex items-center gap-3 group cursor-default">
|
| 42 |
+
<div className="relative">
|
| 43 |
+
<div className="absolute inset-0 bg-blue-500/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity" />
|
| 44 |
+
<div className="relative w-10 h-10 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-xl flex items-center justify-center text-white font-bold text-xl shadow-lg shadow-blue-500/30">
|
| 45 |
+
<span className="text-white">U</span>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
<span className="text-xl font-bold tracking-tight text-slate-900">
|
| 49 |
+
Urban<span className="text-blue-600">Lens</span>
|
| 50 |
+
</span>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<div className="hidden md:flex items-center gap-8">
|
| 54 |
+
<NavLink href="#features">Features</NavLink>
|
| 55 |
+
<NavLink href="#stats">Live Data</NavLink>
|
| 56 |
+
<NavLink href="#roadmap">Roadmap</NavLink>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<div className="flex gap-4 items-center">
|
| 60 |
+
{user ? (
|
| 61 |
+
<>
|
| 62 |
+
<button
|
| 63 |
+
onClick={() => signOut()}
|
| 64 |
+
className="hidden sm:flex px-4 py-2 text-slate-500 hover:text-red-600 transition-colors"
|
| 65 |
+
>
|
| 66 |
+
<LogOut className="w-5 h-5" />
|
| 67 |
+
</button>
|
| 68 |
+
<Link
|
| 69 |
+
href={getDashboardLink()}
|
| 70 |
+
className="group relative px-6 py-2.5 bg-slate-900 hover:bg-slate-800 text-white font-semibold rounded-lg transition-all shadow-lg hover:shadow-xl hover:-translate-y-0.5 flex items-center gap-2"
|
| 71 |
+
>
|
| 72 |
+
<LayoutDashboard className="w-4 h-4" />
|
| 73 |
+
<span>Go to Dashboard</span>
|
| 74 |
+
</Link>
|
| 75 |
+
</>
|
| 76 |
+
) : (
|
| 77 |
+
<>
|
| 78 |
+
<Link
|
| 79 |
+
href="/signin"
|
| 80 |
+
className="px-5 py-2 text-slate-600 hover:text-slate-900 font-medium transition-colors hover:bg-slate-100 rounded-lg"
|
| 81 |
+
>
|
| 82 |
+
Agent Login
|
| 83 |
+
</Link>
|
| 84 |
+
<Link
|
| 85 |
+
href="/signup"
|
| 86 |
+
className="group relative px-6 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-semibold rounded-lg transition-all shadow-lg shadow-blue-500/30 overflow-hidden hover:-translate-y-0.5"
|
| 87 |
+
>
|
| 88 |
+
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700" />
|
| 89 |
+
<span className="relative flex items-center gap-2">
|
| 90 |
+
Get Started <ChevronRight className="w-4 h-4" />
|
| 91 |
+
</span>
|
| 92 |
+
</Link>
|
| 93 |
+
</>
|
| 94 |
+
)}
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
</div>
|
| 98 |
+
</nav>
|
| 99 |
+
|
| 100 |
+
<main className="relative z-10 pt-20">
|
| 101 |
+
{/* Hero Section */}
|
| 102 |
+
<div className="relative border-b border-slate-200 bg-gradient-to-b from-transparent to-white/50">
|
| 103 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-32 text-center">
|
| 104 |
+
|
| 105 |
+
<div className="inline-flex items-center gap-3 px-4 py-1.5 rounded-full bg-white border border-slate-200 shadow-sm mb-8 animate-fade-in-up">
|
| 106 |
+
<span className="relative flex h-2 w-2">
|
| 107 |
+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
| 108 |
+
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
| 109 |
+
</span>
|
| 110 |
+
<span className="text-sm font-bold text-slate-600 font-mono tracking-wide">
|
| 111 |
+
SYSTEM ONLINE
|
| 112 |
+
</span>
|
| 113 |
+
</div>
|
| 114 |
+
|
| 115 |
+
<h1 className="text-5xl md:text-7xl font-extrabold text-slate-900 tracking-tight mb-8 leading-tight">
|
| 116 |
+
City Infrastructure, <br />
|
| 117 |
+
<span className="bg-gradient-to-r from-blue-600 via-indigo-600 to-cyan-600 bg-clip-text text-transparent">
|
| 118 |
+
Reimagined by Intelligence.
|
| 119 |
+
</span>
|
| 120 |
+
</h1>
|
| 121 |
+
|
| 122 |
+
<p className="text-xl text-slate-600 mb-12 max-w-2xl mx-auto leading-relaxed">
|
| 123 |
+
The advanced civic reporting platform powered by AI vision analysis,
|
| 124 |
+
geo-spatial deduplication, and automated workforce routing.
|
| 125 |
+
</p>
|
| 126 |
+
|
| 127 |
+
<div className="flex flex-col sm:flex-row gap-6 justify-center items-center mb-20">
|
| 128 |
+
<Link
|
| 129 |
+
href="/signup"
|
| 130 |
+
className="px-8 py-4 bg-slate-900 text-white font-bold rounded-xl shadow-xl shadow-slate-900/20 hover:shadow-slate-900/30 transition-all hover:-translate-y-1 flex items-center gap-2"
|
| 131 |
+
>
|
| 132 |
+
Report an Issue
|
| 133 |
+
<Smartphone className="w-5 h-5" />
|
| 134 |
+
</Link>
|
| 135 |
+
<Link
|
| 136 |
+
href="/signin"
|
| 137 |
+
className="px-8 py-4 bg-white text-slate-700 font-semibold rounded-xl border border-slate-200 hover:bg-slate-50 transition-all shadow-lg shadow-slate-200/50 hover:border-slate-300 flex items-center gap-2"
|
| 138 |
+
>
|
| 139 |
+
Track Status
|
| 140 |
+
<Activity className="w-5 h-5 text-blue-600" />
|
| 141 |
+
</Link>
|
| 142 |
+
</div>
|
| 143 |
+
|
| 144 |
+
{/* Live Stats Ticker */}
|
| 145 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-5xl mx-auto">
|
| 146 |
+
<StatCard label="ISSUES RESOLVED" value="12,405" icon={CheckCircle2} color="text-emerald-500 bg-emerald-50" />
|
| 147 |
+
<StatCard label="ACTIVE AGENTS" value="84" icon={Users} color="text-blue-500 bg-blue-50" />
|
| 148 |
+
<StatCard label="AVG. REACTION" value="1.2hrs" icon={Zap} color="text-amber-500 bg-amber-50" />
|
| 149 |
+
<StatCard label="CITIES LIVE" value="3" icon={Building2} color="text-purple-500 bg-purple-50" />
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
{/* Features Grid */}
|
| 155 |
+
<div id="features" className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-32">
|
| 156 |
+
<div className="text-center mb-16">
|
| 157 |
+
<h2 className="text-3xl font-bold text-slate-900 mb-4">Core Capabilities</h2>
|
| 158 |
+
<p className="text-slate-500 max-w-2xl mx-auto text-lg">
|
| 159 |
+
Built on a microservices architecture designed for scale, security, and speed.
|
| 160 |
+
</p>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
| 164 |
+
<FeatureCard
|
| 165 |
+
icon={<Radio className="w-8 h-8 text-blue-600" />}
|
| 166 |
+
title="Geo-Spatial AI"
|
| 167 |
+
desc="Automatically detects duplicate reports within a 50m radius using smart cluster analysis and GPS verification."
|
| 168 |
+
colorClass="bg-blue-50 group-hover:bg-blue-100/50"
|
| 169 |
/>
|
| 170 |
+
<FeatureCard
|
| 171 |
+
icon={<Zap className="w-8 h-8 text-amber-500" />}
|
| 172 |
+
title="Vision Agent"
|
| 173 |
+
desc="Computer vision algorithms analyze uploaded photos to identify pothole severity, debris types, and hazards instantly."
|
| 174 |
+
colorClass="bg-amber-50 group-hover:bg-amber-100/50"
|
| 175 |
+
/>
|
| 176 |
+
<FeatureCard
|
| 177 |
+
icon={<Shield className="w-8 h-8 text-emerald-600" />}
|
| 178 |
+
title="End-to-End Encryption"
|
| 179 |
+
desc="Citizen data is AES-256 encrypted at rest. Zero-knowledge protocols ensure maximum privacy."
|
| 180 |
+
colorClass="bg-emerald-50 group-hover:bg-emerald-100/50"
|
| 181 |
+
/>
|
| 182 |
+
</div>
|
| 183 |
</div>
|
| 184 |
</main>
|
| 185 |
+
|
| 186 |
+
<footer className="border-t border-slate-200 bg-white py-12 relative z-10">
|
| 187 |
+
<div className="max-w-7xl mx-auto px-4 text-center">
|
| 188 |
+
<div className="flex justify-center items-center gap-2 mb-8 opacity-75 hover:opacity-100 transition-opacity">
|
| 189 |
+
<div className="w-6 h-6 bg-slate-900 rounded-md flex items-center justify-center text-xs font-bold font-mono text-white">U</div>
|
| 190 |
+
<span className="text-sm font-bold tracking-wide text-slate-900">URBANLENS SYSTEMS</span>
|
| 191 |
+
</div>
|
| 192 |
+
<p className="text-slate-500 text-sm">
|
| 193 |
+
© 2026 Dept. of Public Works. Secure. Efficient. Transparent.
|
| 194 |
+
</p>
|
| 195 |
+
</div>
|
| 196 |
+
</footer>
|
| 197 |
+
</div>
|
| 198 |
+
);
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
|
| 202 |
+
return (
|
| 203 |
+
<Link href={href} className="text-sm font-medium text-slate-500 hover:text-blue-600 transition-colors">
|
| 204 |
+
{children}
|
| 205 |
+
</Link>
|
| 206 |
+
)
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
function StatCard({ label, value, icon: Icon, color }: { label: string, value: string, icon: any, color: string }) {
|
| 210 |
+
return (
|
| 211 |
+
<div className="p-4 rounded-2xl bg-white border border-slate-200 shadow-sm hover:shadow-md hover:border-slate-300 transition-all group">
|
| 212 |
+
<div className="flex items-center gap-3 mb-2">
|
| 213 |
+
<div className={`p-1.5 rounded-lg ${color}`}>
|
| 214 |
+
<Icon className="w-4 h-4" />
|
| 215 |
+
</div>
|
| 216 |
+
<span className="text-xs font-mono text-slate-500 font-bold tracking-wider">{label}</span>
|
| 217 |
+
</div>
|
| 218 |
+
<div className="text-2xl font-bold text-slate-900 group-hover:scale-105 transition-transform origin-left font-mono">
|
| 219 |
+
{value}
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
)
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
function FeatureCard({
|
| 226 |
+
icon,
|
| 227 |
+
title,
|
| 228 |
+
desc,
|
| 229 |
+
colorClass
|
| 230 |
+
}: {
|
| 231 |
+
icon: React.ReactNode;
|
| 232 |
+
title: string;
|
| 233 |
+
desc: string;
|
| 234 |
+
colorClass: string;
|
| 235 |
+
}) {
|
| 236 |
+
return (
|
| 237 |
+
<div className="p-8 bg-white rounded-3xl border border-slate-200 shadow-xl shadow-slate-200/50 hover:shadow-2xl hover:shadow-slate-200/80 hover:-translate-y-1 transition-all group">
|
| 238 |
+
<div className={`w-16 h-16 ${colorClass} rounded-2xl flex items-center justify-center mb-6 border border-slate-100 group-hover:scale-110 transition-transform duration-300`}>
|
| 239 |
+
{icon}
|
| 240 |
+
</div>
|
| 241 |
+
<h3 className="text-xl font-bold text-slate-900 mb-3 tracking-tight">{title}</h3>
|
| 242 |
+
<p className="text-slate-500 leading-relaxed text-sm font-medium">{desc}</p>
|
| 243 |
</div>
|
| 244 |
);
|
| 245 |
}
|
Frontend/app/signin/page.tsx
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useState, useEffect } from "react";
|
| 3 |
+
import { useRouter } from "next/navigation";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
import { createClient } from "@supabase/supabase-js";
|
| 6 |
+
import { useAuth } from "@/components/AuthProvider";
|
| 7 |
+
import { HardHat, ShieldCheck, AlertTriangle } from "lucide-react";
|
| 8 |
+
|
| 9 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
| 10 |
+
if (!API_URL) throw new Error("Missing NEXT_PUBLIC_API_URL");
|
| 11 |
+
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
|
| 12 |
+
const SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "";
|
| 13 |
+
|
| 14 |
+
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
| 15 |
+
|
| 16 |
+
type LoginType = "staff" | "user";
|
| 17 |
+
type StaffRole = "admin" | "worker";
|
| 18 |
+
|
| 19 |
+
export default function SignInPage() {
|
| 20 |
+
const { role } = useAuth();
|
| 21 |
+
const router = useRouter();
|
| 22 |
+
|
| 23 |
+
const [loginType, setLoginType] = useState<LoginType>("user");
|
| 24 |
+
const [staffRole, setStaffRole] = useState<StaffRole>("worker");
|
| 25 |
+
const [email, setEmail] = useState("");
|
| 26 |
+
const [password, setPassword] = useState("");
|
| 27 |
+
const [error, setError] = useState("");
|
| 28 |
+
const [loading, setLoading] = useState(false);
|
| 29 |
+
|
| 30 |
+
useEffect(() => {
|
| 31 |
+
if (role) {
|
| 32 |
+
if (role === "admin") router.replace("/admin");
|
| 33 |
+
else if (role === "worker") router.replace("/worker");
|
| 34 |
+
else router.replace("/user");
|
| 35 |
+
}
|
| 36 |
+
}, [role, router]);
|
| 37 |
+
|
| 38 |
+
const handleStaffLogin = async (e: React.FormEvent) => {
|
| 39 |
+
e.preventDefault();
|
| 40 |
+
setLoading(true);
|
| 41 |
+
setError("");
|
| 42 |
+
|
| 43 |
+
try {
|
| 44 |
+
const res = await fetch(`${API_URL}/admin/login`, {
|
| 45 |
+
method: "POST",
|
| 46 |
+
headers: { "Content-Type": "application/json" },
|
| 47 |
+
body: JSON.stringify({ email, password, expected_role: staffRole }),
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
if (!res.ok) {
|
| 51 |
+
const data = await res.json();
|
| 52 |
+
throw new Error(data.detail || "Login failed");
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const data = await res.json();
|
| 56 |
+
localStorage.setItem("token", data.access_token);
|
| 57 |
+
localStorage.setItem("user", JSON.stringify(data.user));
|
| 58 |
+
|
| 59 |
+
window.location.reload();
|
| 60 |
+
} catch (err: unknown) {
|
| 61 |
+
const message = err instanceof Error ? err.message : "Login failed";
|
| 62 |
+
setError(message);
|
| 63 |
+
setLoading(false);
|
| 64 |
+
}
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
const handleGoogleLogin = async () => {
|
| 68 |
+
setLoading(true);
|
| 69 |
+
try {
|
| 70 |
+
await supabase.auth.signInWithOAuth({
|
| 71 |
+
provider: "google",
|
| 72 |
+
options: {
|
| 73 |
+
redirectTo: `${window.location.origin}/auth/callback?next=/user`,
|
| 74 |
+
},
|
| 75 |
+
});
|
| 76 |
+
} catch (err: unknown) {
|
| 77 |
+
const message = err instanceof Error ? err.message : "Login failed";
|
| 78 |
+
setError(message);
|
| 79 |
+
setLoading(false);
|
| 80 |
+
}
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
return (
|
| 84 |
+
<div className="min-h-screen flex flex-col bg-slate-50">
|
| 85 |
+
<nav className="px-8 py-6 bg-white border-b border-slate-200">
|
| 86 |
+
<div className="max-w-7xl mx-auto flex justify-between items-center">
|
| 87 |
+
<Link href="/" className="text-2xl font-bold text-slate-800">
|
| 88 |
+
CityIssue
|
| 89 |
+
</Link>
|
| 90 |
+
<span className="text-sm text-slate-500 font-medium">
|
| 91 |
+
Secure Login
|
| 92 |
+
</span>
|
| 93 |
+
</div>
|
| 94 |
+
</nav>
|
| 95 |
+
|
| 96 |
+
<div className="flex-1 flex items-center justify-center p-4">
|
| 97 |
+
<div className="w-full max-w-md bg-white rounded-xl shadow-lg border border-slate-100 p-8">
|
| 98 |
+
<div className="text-center mb-8">
|
| 99 |
+
<h2 className="text-2xl font-bold text-slate-800">Welcome Back</h2>
|
| 100 |
+
<p className="text-slate-500 mt-2">Access the municipal portal</p>
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
<div className="flex bg-slate-100 p-1 rounded-lg mb-8">
|
| 104 |
+
<button
|
| 105 |
+
onClick={() => setLoginType("user")}
|
| 106 |
+
className={`flex-1 py-2 text-sm font-semibold rounded-md transition-all ${
|
| 107 |
+
loginType === "user"
|
| 108 |
+
? "bg-white text-slate-900 shadow-sm"
|
| 109 |
+
: "text-slate-500 hover:text-slate-700"
|
| 110 |
+
}`}
|
| 111 |
+
>
|
| 112 |
+
Citizen
|
| 113 |
+
</button>
|
| 114 |
+
<button
|
| 115 |
+
onClick={() => setLoginType("staff")}
|
| 116 |
+
className={`flex-1 py-2 text-sm font-semibold rounded-md transition-all ${
|
| 117 |
+
loginType === "staff"
|
| 118 |
+
? "bg-white text-slate-900 shadow-sm"
|
| 119 |
+
: "text-slate-500 hover:text-slate-700"
|
| 120 |
+
}`}
|
| 121 |
+
>
|
| 122 |
+
Staff
|
| 123 |
+
</button>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
{error && (
|
| 127 |
+
<div className="mb-6 p-4 bg-red-50 border border-red-100 rounded-lg text-red-600 text-sm font-medium flex items-center gap-2">
|
| 128 |
+
<AlertTriangle className="w-5 h-5" />
|
| 129 |
+
{error}
|
| 130 |
+
</div>
|
| 131 |
+
)}
|
| 132 |
+
|
| 133 |
+
{loginType === "user" ? (
|
| 134 |
+
<div className="space-y-4">
|
| 135 |
+
<button
|
| 136 |
+
onClick={handleGoogleLogin}
|
| 137 |
+
disabled={loading}
|
| 138 |
+
className="w-full py-3 px-4 bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 font-semibold rounded-lg flex items-center justify-center gap-3 transition"
|
| 139 |
+
>
|
| 140 |
+
<img
|
| 141 |
+
src="https://www.google.com/favicon.ico"
|
| 142 |
+
alt="Google"
|
| 143 |
+
className="w-5 h-5"
|
| 144 |
+
/>
|
| 145 |
+
{loading ? "Connecting..." : "Continue with Google"}
|
| 146 |
+
</button>
|
| 147 |
+
<p className="text-xs text-slate-500 text-center mt-4">
|
| 148 |
+
Secure authentication via Supabase. We do not store your Google
|
| 149 |
+
password.
|
| 150 |
+
</p>
|
| 151 |
+
</div>
|
| 152 |
+
) : (
|
| 153 |
+
<form onSubmit={handleStaffLogin} className="space-y-5">
|
| 154 |
+
<div className="grid grid-cols-2 gap-3">
|
| 155 |
+
<button
|
| 156 |
+
type="button"
|
| 157 |
+
onClick={() => setStaffRole("worker")}
|
| 158 |
+
className={`py-2 px-3 text-sm font-medium rounded-lg border transition-all flex items-center justify-center gap-2 ${
|
| 159 |
+
staffRole === "worker"
|
| 160 |
+
? "bg-blue-50 border-blue-200 text-blue-700"
|
| 161 |
+
: "bg-white border-slate-200 text-slate-500 hover:border-slate-300"
|
| 162 |
+
}`}
|
| 163 |
+
>
|
| 164 |
+
<HardHat className="w-4 h-4" /> Field Worker
|
| 165 |
+
</button>
|
| 166 |
+
<button
|
| 167 |
+
type="button"
|
| 168 |
+
onClick={() => setStaffRole("admin")}
|
| 169 |
+
className={`py-2 px-3 text-sm font-medium rounded-lg border transition-all flex items-center justify-center gap-2 ${
|
| 170 |
+
staffRole === "admin"
|
| 171 |
+
? "bg-purple-50 border-purple-200 text-purple-700"
|
| 172 |
+
: "bg-white border-slate-200 text-slate-500 hover:border-slate-300"
|
| 173 |
+
}`}
|
| 174 |
+
>
|
| 175 |
+
<ShieldCheck className="w-4 h-4" /> Administrator
|
| 176 |
+
</button>
|
| 177 |
+
</div>
|
| 178 |
+
|
| 179 |
+
<div>
|
| 180 |
+
<label className="block text-sm font-semibold text-slate-700 mb-1">
|
| 181 |
+
Official Email
|
| 182 |
+
</label>
|
| 183 |
+
<input
|
| 184 |
+
type="email"
|
| 185 |
+
value={email}
|
| 186 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 187 |
+
className="w-full px-4 py-3 bg-white border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-800 focus:border-transparent transition text-slate-900"
|
| 188 |
+
placeholder="name@city.gov"
|
| 189 |
+
required
|
| 190 |
+
/>
|
| 191 |
+
</div>
|
| 192 |
+
|
| 193 |
+
<div>
|
| 194 |
+
<label className="block text-sm font-semibold text-slate-700 mb-1">
|
| 195 |
+
Password
|
| 196 |
+
</label>
|
| 197 |
+
<input
|
| 198 |
+
type="password"
|
| 199 |
+
value={password}
|
| 200 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 201 |
+
className="w-full px-4 py-3 bg-white border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-800 focus:border-transparent transition text-slate-900"
|
| 202 |
+
placeholder="••••••••"
|
| 203 |
+
required
|
| 204 |
+
/>
|
| 205 |
+
</div>
|
| 206 |
+
|
| 207 |
+
<button
|
| 208 |
+
type="submit"
|
| 209 |
+
disabled={loading}
|
| 210 |
+
className="w-full py-3 bg-slate-900 hover:bg-slate-800 text-white font-bold rounded-lg transition shadow-md disabled:opacity-70"
|
| 211 |
+
>
|
| 212 |
+
{loading ? "Verifying..." : "Access Portal"}
|
| 213 |
+
</button>
|
| 214 |
+
</form>
|
| 215 |
+
)}
|
| 216 |
+
|
| 217 |
+
<div className="mt-8 pt-6 border-t border-slate-100 text-center">
|
| 218 |
+
<p className="text-sm text-slate-500">
|
| 219 |
+
New citizen?{" "}
|
| 220 |
+
<Link
|
| 221 |
+
href="/signup"
|
| 222 |
+
className="text-blue-600 hover:text-blue-700 font-semibold"
|
| 223 |
+
>
|
| 224 |
+
Create account
|
| 225 |
+
</Link>
|
| 226 |
+
</p>
|
| 227 |
+
</div>
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
);
|
| 232 |
+
}
|
Frontend/app/signup/page.tsx
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useState } from "react";
|
| 3 |
+
import { useRouter } from "next/navigation";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
import { createClient } from "@supabase/supabase-js";
|
| 6 |
+
|
| 7 |
+
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
|
| 8 |
+
const SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "";
|
| 9 |
+
|
| 10 |
+
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
| 11 |
+
|
| 12 |
+
export default function SignUpPage() {
|
| 13 |
+
const [loading, setLoading] = useState(false);
|
| 14 |
+
const [error, setError] = useState("");
|
| 15 |
+
const router = useRouter();
|
| 16 |
+
|
| 17 |
+
const handleGoogleSignUp = async () => {
|
| 18 |
+
setLoading(true);
|
| 19 |
+
setError("");
|
| 20 |
+
|
| 21 |
+
try {
|
| 22 |
+
const { error } = await supabase.auth.signInWithOAuth({
|
| 23 |
+
provider: "google",
|
| 24 |
+
options: {
|
| 25 |
+
redirectTo: `${window.location.origin}/signin`,
|
| 26 |
+
},
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
if (error) throw error;
|
| 30 |
+
} catch (err: any) {
|
| 31 |
+
setError(err.message);
|
| 32 |
+
setLoading(false);
|
| 33 |
+
}
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
return (
|
| 37 |
+
<div className="min-h-screen flex flex-col bg-slate-50">
|
| 38 |
+
<nav className="px-8 py-6 bg-white border-b border-slate-200">
|
| 39 |
+
<div className="max-w-7xl mx-auto flex justify-between items-center">
|
| 40 |
+
<Link href="/" className="text-2xl font-bold text-slate-800">CityIssue</Link>
|
| 41 |
+
</div>
|
| 42 |
+
</nav>
|
| 43 |
+
|
| 44 |
+
<div className="flex-1 flex items-center justify-center p-4">
|
| 45 |
+
<div className="w-full max-w-md bg-white rounded-xl shadow-lg border border-slate-100 p-8">
|
| 46 |
+
<div className="text-center mb-8">
|
| 47 |
+
<h2 className="text-2xl font-bold text-slate-800">Create Account</h2>
|
| 48 |
+
<p className="text-slate-500 mt-2">Join as a citizen to report issues</p>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
{error && (
|
| 52 |
+
<div className="mb-6 p-4 bg-red-50 border border-red-100 rounded-lg text-red-600 text-sm font-medium">
|
| 53 |
+
{error}
|
| 54 |
+
</div>
|
| 55 |
+
)}
|
| 56 |
+
|
| 57 |
+
<button
|
| 58 |
+
onClick={handleGoogleSignUp}
|
| 59 |
+
disabled={loading}
|
| 60 |
+
className="w-full py-4 px-4 bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 font-bold rounded-lg flex items-center justify-center gap-3 transition shadow-sm"
|
| 61 |
+
>
|
| 62 |
+
<img src="https://www.google.com/favicon.ico" alt="Google" className="w-5 h-5" />
|
| 63 |
+
{loading ? "Creating..." : "Sign up with Google"}
|
| 64 |
+
</button>
|
| 65 |
+
|
| 66 |
+
<div className="mt-8 p-6 bg-blue-50 border border-blue-100 rounded-xl">
|
| 67 |
+
<div className="flex items-start gap-3">
|
| 68 |
+
<span className="text-2xl">📱</span>
|
| 69 |
+
<div>
|
| 70 |
+
<h4 className="text-sm font-bold text-blue-900 mb-1">Mobile App Recommended</h4>
|
| 71 |
+
<p className="text-xs text-blue-700 leading-relaxed">
|
| 72 |
+
For the best experience, download our mobile app. It supports GPS-verified photo uploads which are required for official reports.
|
| 73 |
+
</p>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<p className="mt-8 text-center text-sm text-slate-500">
|
| 79 |
+
Already have an account?{" "}
|
| 80 |
+
<Link href="/signin" className="text-blue-600 hover:text-blue-700 font-semibold">
|
| 81 |
+
Sign in
|
| 82 |
+
</Link>
|
| 83 |
+
</p>
|
| 84 |
+
</div>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
);
|
| 88 |
+
}
|
Frontend/app/user/issues/[id]/page.tsx
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useEffect, useState } from "react";
|
| 3 |
+
import { useParams, useRouter } from "next/navigation";
|
| 4 |
+
|
| 5 |
+
export const runtime = "edge";
|
| 6 |
+
import { apiGet } from "@/lib/api";
|
| 7 |
+
import {
|
| 8 |
+
ArrowLeft,
|
| 9 |
+
MapPin,
|
| 10 |
+
Calendar,
|
| 11 |
+
CheckCircle2,
|
| 12 |
+
Clock,
|
| 13 |
+
AlertTriangle,
|
| 14 |
+
ImageIcon,
|
| 15 |
+
Activity,
|
| 16 |
+
Maximize2,
|
| 17 |
+
} from "lucide-react";
|
| 18 |
+
import Link from "next/link";
|
| 19 |
+
|
| 20 |
+
interface IssueEvent {
|
| 21 |
+
id: string;
|
| 22 |
+
event_type: string;
|
| 23 |
+
created_at: string;
|
| 24 |
+
data: any;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
interface Issue {
|
| 28 |
+
id: string;
|
| 29 |
+
description: string;
|
| 30 |
+
state: string;
|
| 31 |
+
city: string;
|
| 32 |
+
locality: string;
|
| 33 |
+
created_at: string;
|
| 34 |
+
full_address: string;
|
| 35 |
+
priority: number;
|
| 36 |
+
category: string;
|
| 37 |
+
confidence: number;
|
| 38 |
+
image_urls: string[];
|
| 39 |
+
annotated_urls: string[];
|
| 40 |
+
validation_source: string;
|
| 41 |
+
sla_deadline?: string;
|
| 42 |
+
is_duplicate: boolean;
|
| 43 |
+
history?: IssueEvent[];
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export default function UserIssueDetailPage() {
|
| 47 |
+
const params = useParams();
|
| 48 |
+
const router = useRouter();
|
| 49 |
+
const [issue, setIssue] = useState<Issue | null>(null);
|
| 50 |
+
const [loading, setLoading] = useState(true);
|
| 51 |
+
|
| 52 |
+
useEffect(() => {
|
| 53 |
+
if (params.id) {
|
| 54 |
+
fetchIssueDetails(params.id as string);
|
| 55 |
+
}
|
| 56 |
+
}, [params.id]);
|
| 57 |
+
|
| 58 |
+
const fetchIssueDetails = async (id: string) => {
|
| 59 |
+
try {
|
| 60 |
+
// Using generic endpoint - typically users can access their own issues
|
| 61 |
+
const data = await apiGet<Issue>(`/issues/${id}`);
|
| 62 |
+
setIssue(data);
|
| 63 |
+
} catch (error) {
|
| 64 |
+
console.error("Failed to fetch issue details:", error);
|
| 65 |
+
// alert("Failed to load issue details.");
|
| 66 |
+
} finally {
|
| 67 |
+
setLoading(false);
|
| 68 |
+
}
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
const getStateBadge = (state: string) => {
|
| 72 |
+
const styles: Record<string, string> = {
|
| 73 |
+
reported: "bg-blue-100 text-blue-800 border-blue-200",
|
| 74 |
+
assigned: "bg-amber-100 text-amber-800 border-amber-200",
|
| 75 |
+
in_progress: "bg-orange-100 text-orange-800 border-orange-200",
|
| 76 |
+
resolved: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
| 77 |
+
closed: "bg-slate-100 text-slate-600 border-slate-200",
|
| 78 |
+
rejected: "bg-red-100 text-red-800 border-red-200",
|
| 79 |
+
};
|
| 80 |
+
return (
|
| 81 |
+
<span
|
| 82 |
+
className={`px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider border ${styles[state] || styles.reported}`}
|
| 83 |
+
>
|
| 84 |
+
{state.replace("_", " ")}
|
| 85 |
+
</span>
|
| 86 |
+
);
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
if (loading) {
|
| 90 |
+
return (
|
| 91 |
+
<div className="min-h-screen bg-urban-bg flex items-center justify-center">
|
| 92 |
+
<div className="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-urban-primary"></div>
|
| 93 |
+
</div>
|
| 94 |
+
);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
if (!issue) {
|
| 98 |
+
return (
|
| 99 |
+
<div className="min-h-screen bg-urban-bg p-8 flex flex-col items-center justify-center">
|
| 100 |
+
<h2 className="text-xl font-bold text-slate-700">Issue Not Found</h2>
|
| 101 |
+
<Link href="/user" className="mt-4 text-urban-primary hover:underline">
|
| 102 |
+
Return to Dashboard
|
| 103 |
+
</Link>
|
| 104 |
+
</div>
|
| 105 |
+
);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
return (
|
| 109 |
+
<div className="min-h-screen bg-urban-bg font-sans pb-12">
|
| 110 |
+
{/* Ambient Background */}
|
| 111 |
+
<div className="fixed inset-0 pointer-events-none overflow-hidden z-0">
|
| 112 |
+
<div className="absolute top-[-10%] right-[-10%] w-[40%] h-[40%] bg-blue-400/10 rounded-full blur-[100px]"></div>
|
| 113 |
+
<div className="absolute bottom-[-10%] left-[-10%] w-[30%] h-[30%] bg-urban-primary/5 rounded-full blur-[80px]"></div>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<main className="relative z-10 max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 117 |
+
{/* Header */}
|
| 118 |
+
<div className="mb-8">
|
| 119 |
+
<Link
|
| 120 |
+
href="/user"
|
| 121 |
+
className="inline-flex items-center gap-2 text-slate-500 hover:text-urban-primary mb-4 transition-colors font-medium"
|
| 122 |
+
>
|
| 123 |
+
<ArrowLeft className="w-4 h-4" /> Back to Dashboard
|
| 124 |
+
</Link>
|
| 125 |
+
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
| 126 |
+
<div>
|
| 127 |
+
<div className="flex items-center gap-3 mb-2">
|
| 128 |
+
<span className="font-mono text-slate-400 bg-white/50 px-2 py-0.5 rounded text-sm border border-slate-200">
|
| 129 |
+
#{issue.id.slice(0, 8)}
|
| 130 |
+
</span>
|
| 131 |
+
{getStateBadge(issue.state)}
|
| 132 |
+
</div>
|
| 133 |
+
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">
|
| 134 |
+
{issue.category || "Reported Issue"}
|
| 135 |
+
</h1>
|
| 136 |
+
</div>
|
| 137 |
+
{issue.priority && (
|
| 138 |
+
<div className="text-right">
|
| 139 |
+
<div className="text-xs text-slate-400 font-bold uppercase tracking-wider mb-1">
|
| 140 |
+
Priority Level
|
| 141 |
+
</div>
|
| 142 |
+
<div
|
| 143 |
+
className={`text-lg font-bold px-4 py-1 rounded-full border border-slate-200 inline-block bg-white shadow-sm ${
|
| 144 |
+
issue.priority === 1
|
| 145 |
+
? "text-red-600 border-red-100 bg-red-50"
|
| 146 |
+
: issue.priority === 2
|
| 147 |
+
? "text-orange-600 border-orange-100 bg-orange-50"
|
| 148 |
+
: "text-slate-700"
|
| 149 |
+
}`}
|
| 150 |
+
>
|
| 151 |
+
{issue.priority === 1
|
| 152 |
+
? "Critical"
|
| 153 |
+
: issue.priority === 2
|
| 154 |
+
? "High"
|
| 155 |
+
: "Normal"}
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
)}
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
| 163 |
+
{/* Left Column: Details & Evidence */}
|
| 164 |
+
<div className="lg:col-span-2 space-y-8">
|
| 165 |
+
{/* Analysis & Location */}
|
| 166 |
+
<div className="card bg-white/80 backdrop-blur-md">
|
| 167 |
+
<div className="flex items-center gap-3 mb-6 pb-4 border-b border-slate-100">
|
| 168 |
+
<div className="p-2 bg-blue-50 text-blue-600 rounded-lg">
|
| 169 |
+
<Activity className="w-5 h-5" />
|
| 170 |
+
</div>
|
| 171 |
+
<h2 className="text-lg font-bold text-slate-800">
|
| 172 |
+
Issue Details & Analysis
|
| 173 |
+
</h2>
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
| 177 |
+
<div>
|
| 178 |
+
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider block mb-2">
|
| 179 |
+
Description
|
| 180 |
+
</label>
|
| 181 |
+
<p className="text-slate-800 font-medium leading-relaxed bg-slate-50 p-3 rounded-lg border border-slate-100">
|
| 182 |
+
{issue.description || "No description provided."}
|
| 183 |
+
</p>
|
| 184 |
+
</div>
|
| 185 |
+
<div>
|
| 186 |
+
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider block mb-2">
|
| 187 |
+
AI Confidence
|
| 188 |
+
</label>
|
| 189 |
+
<div className="flex items-center gap-2">
|
| 190 |
+
<div className="h-2 flex-1 bg-slate-100 rounded-full overflow-hidden">
|
| 191 |
+
<div
|
| 192 |
+
className="h-full bg-urban-primary rounded-full"
|
| 193 |
+
style={{ width: `${(issue.confidence || 0) * 100}%` }}
|
| 194 |
+
></div>
|
| 195 |
+
</div>
|
| 196 |
+
<span className="font-mono text-sm font-bold text-slate-700">
|
| 197 |
+
{issue.confidence
|
| 198 |
+
? `${(issue.confidence * 100).toFixed(1)}%`
|
| 199 |
+
: "N/A"}
|
| 200 |
+
</span>
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
</div>
|
| 204 |
+
|
| 205 |
+
<div>
|
| 206 |
+
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider block mb-2">
|
| 207 |
+
Location
|
| 208 |
+
</label>
|
| 209 |
+
<div className="flex items-start gap-3 bg-slate-50 p-3 rounded-lg border border-slate-100">
|
| 210 |
+
<MapPin className="w-5 h-5 text-urban-primary shrink-0 mt-0.5" />
|
| 211 |
+
<div>
|
| 212 |
+
<p className="text-slate-900 font-medium">
|
| 213 |
+
{issue.full_address || issue.locality}
|
| 214 |
+
</p>
|
| 215 |
+
<p className="text-xs text-slate-500 mt-1">{issue.city}</p>
|
| 216 |
+
</div>
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
+
{/* Evidence Gallery */}
|
| 222 |
+
<div className="card bg-white/80 backdrop-blur-md">
|
| 223 |
+
<div className="flex items-center gap-3 mb-6 pb-4 border-b border-slate-100">
|
| 224 |
+
<div className="p-2 bg-purple-50 text-purple-600 rounded-lg">
|
| 225 |
+
<ImageIcon className="w-5 h-5" />
|
| 226 |
+
</div>
|
| 227 |
+
<h2 className="text-lg font-bold text-slate-800">
|
| 228 |
+
Evidence & AI Vision
|
| 229 |
+
</h2>
|
| 230 |
+
</div>
|
| 231 |
+
|
| 232 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 233 |
+
{/* Original Image */}
|
| 234 |
+
<div className="space-y-3">
|
| 235 |
+
<div className="flex justify-between items-center">
|
| 236 |
+
<h3 className="text-sm font-bold text-slate-700">
|
| 237 |
+
Original Photo
|
| 238 |
+
</h3>
|
| 239 |
+
</div>
|
| 240 |
+
<div className="relative aspect-video bg-slate-100 rounded-xl overflow-hidden border border-slate-200 shadow-sm group">
|
| 241 |
+
{issue.image_urls?.[0] ? (
|
| 242 |
+
<>
|
| 243 |
+
<img
|
| 244 |
+
src={issue.image_urls[0]}
|
| 245 |
+
alt="Original Issue"
|
| 246 |
+
className="w-full h-full object-cover"
|
| 247 |
+
/>
|
| 248 |
+
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100">
|
| 249 |
+
<a
|
| 250 |
+
href={issue.image_urls[0]}
|
| 251 |
+
target="_blank"
|
| 252 |
+
rel="noopener noreferrer"
|
| 253 |
+
className="bg-white/90 backdrop-blur text-slate-900 px-4 py-2 rounded-full text-sm font-bold shadow-lg flex items-center gap-2 transform translate-y-2 group-hover:translate-y-0 transition-all"
|
| 254 |
+
>
|
| 255 |
+
<Maximize2 className="w-4 h-4" /> View Full Size
|
| 256 |
+
</a>
|
| 257 |
+
</div>
|
| 258 |
+
</>
|
| 259 |
+
) : (
|
| 260 |
+
<div className="flex items-center justify-center h-full text-slate-400">
|
| 261 |
+
<ImageIcon className="w-8 h-8 opacity-50" />
|
| 262 |
+
</div>
|
| 263 |
+
)}
|
| 264 |
+
</div>
|
| 265 |
+
</div>
|
| 266 |
+
|
| 267 |
+
{/* AI Annotated Image */}
|
| 268 |
+
<div className="space-y-3">
|
| 269 |
+
<div className="flex justify-between items-center">
|
| 270 |
+
<h3 className="text-sm font-bold text-urban-primary flex items-center gap-2">
|
| 271 |
+
<span className="w-2 h-2 rounded-full bg-urban-primary animate-pulse"></span>
|
| 272 |
+
Vision Agent Analysis
|
| 273 |
+
</h3>
|
| 274 |
+
</div>
|
| 275 |
+
<div className="relative aspect-video bg-slate-900 rounded-xl overflow-hidden border-2 border-urban-primary/20 shadow-lg shadow-urban-primary/5 group">
|
| 276 |
+
{issue.annotated_urls?.[0] ? (
|
| 277 |
+
<>
|
| 278 |
+
<img
|
| 279 |
+
src={issue.annotated_urls[0]}
|
| 280 |
+
alt="AI Analysis"
|
| 281 |
+
className="w-full h-full object-cover"
|
| 282 |
+
/>
|
| 283 |
+
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100">
|
| 284 |
+
<a
|
| 285 |
+
href={issue.annotated_urls[0]}
|
| 286 |
+
target="_blank"
|
| 287 |
+
rel="noopener noreferrer"
|
| 288 |
+
className="bg-urban-primary/90 backdrop-blur text-white px-4 py-2 rounded-full text-sm font-bold shadow-lg flex items-center gap-2 transform translate-y-2 group-hover:translate-y-0 transition-all"
|
| 289 |
+
>
|
| 290 |
+
<Maximize2 className="w-4 h-4" /> Inspect Analysis
|
| 291 |
+
</a>
|
| 292 |
+
</div>
|
| 293 |
+
</>
|
| 294 |
+
) : (
|
| 295 |
+
<div className="flex flex-col items-center justify-center h-full text-slate-500 bg-slate-50/5">
|
| 296 |
+
<Activity className="w-8 h-8 opacity-50 mb-2" />
|
| 297 |
+
<span className="text-xs">
|
| 298 |
+
Processing visualization...
|
| 299 |
+
</span>
|
| 300 |
+
</div>
|
| 301 |
+
)}
|
| 302 |
+
|
| 303 |
+
{/* Badge Overlay */}
|
| 304 |
+
<div className="absolute bottom-3 right-3">
|
| 305 |
+
<span className="bg-black/60 backdrop-blur-md text-white text-[10px] font-mono px-2 py-1 rounded border border-white/10 flex items-center gap-1.5">
|
| 306 |
+
<div className="w-1.5 h-1.5 bg-green-400 rounded-full"></div>
|
| 307 |
+
Object Detection v2
|
| 308 |
+
</span>
|
| 309 |
+
</div>
|
| 310 |
+
</div>
|
| 311 |
+
</div>
|
| 312 |
+
</div>
|
| 313 |
+
</div>
|
| 314 |
+
</div>
|
| 315 |
+
|
| 316 |
+
{/* Right Column: Timeline */}
|
| 317 |
+
<div className="space-y-8">
|
| 318 |
+
<div className="card bg-white/80 backdrop-blur-md h-full">
|
| 319 |
+
<div className="flex items-center gap-3 mb-6 pb-4 border-b border-slate-100">
|
| 320 |
+
<div className="p-2 bg-slate-100 text-slate-600 rounded-lg">
|
| 321 |
+
<Clock className="w-5 h-5" />
|
| 322 |
+
</div>
|
| 323 |
+
<h2 className="text-lg font-bold text-slate-800">
|
| 324 |
+
Status Timeline
|
| 325 |
+
</h2>
|
| 326 |
+
</div>
|
| 327 |
+
|
| 328 |
+
<div className="relative pl-4 border-l-2 border-slate-100 space-y-8 py-2">
|
| 329 |
+
{issue.state === "reported" && (
|
| 330 |
+
<div className="relative">
|
| 331 |
+
<span className="absolute -left-[21px] top-1 w-4 h-4 rounded-full border-2 border-blue-500 bg-white ring-4 ring-blue-50"></span>
|
| 332 |
+
<h4 className="text-sm font-bold text-slate-900">
|
| 333 |
+
Report Submitted
|
| 334 |
+
</h4>
|
| 335 |
+
<p className="text-xs text-slate-500 mt-1">
|
| 336 |
+
Issue has been received and is being processed by our AI
|
| 337 |
+
systems.
|
| 338 |
+
</p>
|
| 339 |
+
<span className="text-[10px] font-mono text-slate-400 mt-2 block">
|
| 340 |
+
{new Date(issue.created_at).toLocaleString()}
|
| 341 |
+
</span>
|
| 342 |
+
</div>
|
| 343 |
+
)}
|
| 344 |
+
|
| 345 |
+
{issue.state === "resolved" && (
|
| 346 |
+
<div className="relative">
|
| 347 |
+
<span className="absolute -left-[21px] top-1 w-4 h-4 rounded-full border-2 border-emerald-500 bg-emerald-500 ring-4 ring-emerald-50"></span>
|
| 348 |
+
<h4 className="text-sm font-bold text-emerald-700">
|
| 349 |
+
Issue Resolved
|
| 350 |
+
</h4>
|
| 351 |
+
<p className="text-xs text-slate-500 mt-1">
|
| 352 |
+
Work has been completed and verified.
|
| 353 |
+
</p>
|
| 354 |
+
</div>
|
| 355 |
+
)}
|
| 356 |
+
|
| 357 |
+
{/* Default Start */}
|
| 358 |
+
<div className="relative opacity-60">
|
| 359 |
+
<span className="absolute -left-[21px] top-1 w-4 h-4 rounded-full border-2 border-slate-300 bg-white"></span>
|
| 360 |
+
<h4 className="text-sm font-bold text-slate-600">
|
| 361 |
+
Issue Created
|
| 362 |
+
</h4>
|
| 363 |
+
<span className="text-[10px] font-mono text-slate-400 mt-1 block">
|
| 364 |
+
{new Date(issue.created_at).toLocaleString()}
|
| 365 |
+
</span>
|
| 366 |
+
</div>
|
| 367 |
+
</div>
|
| 368 |
+
</div>
|
| 369 |
+
</div>
|
| 370 |
+
</div>
|
| 371 |
+
</main>
|
| 372 |
+
</div>
|
| 373 |
+
);
|
| 374 |
+
}
|
Frontend/app/user/page.tsx
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useEffect, useState } from "react";
|
| 3 |
+
import { useRouter } from "next/navigation";
|
| 4 |
+
import { useAuth } from "@/components/AuthProvider";
|
| 5 |
+
import { apiGet } from "@/lib/api";
|
| 6 |
+
import { useCachedFetch } from "@/hooks/useCachedFetch";
|
| 7 |
+
import { Smartphone, FileText, MapPin } from "lucide-react";
|
| 8 |
+
|
| 9 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
| 10 |
+
if (!API_URL) throw new Error("Missing NEXT_PUBLIC_API_URL");
|
| 11 |
+
|
| 12 |
+
interface Issue {
|
| 13 |
+
id: string;
|
| 14 |
+
description: string;
|
| 15 |
+
priority: number;
|
| 16 |
+
state: string;
|
| 17 |
+
city: string;
|
| 18 |
+
locality: string;
|
| 19 |
+
created_at: string;
|
| 20 |
+
primary_category: string;
|
| 21 |
+
classification?: {
|
| 22 |
+
primary_category: string;
|
| 23 |
+
confidence: number;
|
| 24 |
+
};
|
| 25 |
+
images?: { file_path: string; annotated_path: string }[];
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
interface IssuesResponse {
|
| 29 |
+
items: Issue[];
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export default function UserDashboard() {
|
| 33 |
+
const { user, role, signOut, loading: authLoading } = useAuth();
|
| 34 |
+
const router = useRouter();
|
| 35 |
+
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
if (!authLoading) {
|
| 38 |
+
if (role !== "user") {
|
| 39 |
+
router.push("/signin");
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
}, [authLoading, role, router]);
|
| 43 |
+
|
| 44 |
+
// Only fetch if user ID is available
|
| 45 |
+
const fetchUrl = user?.id ? `/issues?user_id=${user.id}` : "";
|
| 46 |
+
const { data: issuesResponse, loading: issuesLoading } = useCachedFetch<IssuesResponse>(fetchUrl);
|
| 47 |
+
|
| 48 |
+
const issues = issuesResponse?.items || [];
|
| 49 |
+
|
| 50 |
+
// Create a combined loading state, but prioritize showing dashboard shell if auth is done
|
| 51 |
+
const contentLoading = authLoading || (issuesLoading && issues.length === 0);
|
| 52 |
+
|
| 53 |
+
const getStateBadge = (state: string) => {
|
| 54 |
+
const styles: Record<string, string> = {
|
| 55 |
+
reported: "bg-blue-100 text-blue-800",
|
| 56 |
+
assigned: "bg-yellow-100 text-yellow-800",
|
| 57 |
+
in_progress: "bg-orange-100 text-orange-800",
|
| 58 |
+
resolved: "bg-green-100 text-green-800",
|
| 59 |
+
closed: "bg-slate-100 text-slate-600",
|
| 60 |
+
};
|
| 61 |
+
return (
|
| 62 |
+
<span
|
| 63 |
+
className={`px-2.5 py-0.5 rounded-full text-xs font-semibold ${styles[state] || styles.reported}`}
|
| 64 |
+
>
|
| 65 |
+
{state.replace("_", " ").toUpperCase()}
|
| 66 |
+
</span>
|
| 67 |
+
);
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
if (authLoading) {
|
| 71 |
+
return (
|
| 72 |
+
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
|
| 73 |
+
<div className="text-slate-600 font-medium">Loading Dashboard...</div>
|
| 74 |
+
</div>
|
| 75 |
+
);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
return (
|
| 79 |
+
<div className="min-h-screen bg-slate-50 font-sans">
|
| 80 |
+
<nav className="bg-white border-b border-slate-200 sticky top-0 z-50">
|
| 81 |
+
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 82 |
+
<div className="flex justify-between h-16 items-center">
|
| 83 |
+
<div>
|
| 84 |
+
<h1 className="text-xl font-bold text-slate-900">My Reports</h1>
|
| 85 |
+
<p className="text-xs text-slate-500">Citizen Portal</p>
|
| 86 |
+
</div>
|
| 87 |
+
<div className="flex items-center gap-4">
|
| 88 |
+
<div className="text-right hidden sm:block">
|
| 89 |
+
<p className="text-sm font-medium text-slate-900">
|
| 90 |
+
{user?.user_metadata?.full_name || user?.email || "User"}
|
| 91 |
+
</p>
|
| 92 |
+
<p className="text-xs text-slate-500">{user?.email}</p>
|
| 93 |
+
</div>
|
| 94 |
+
<button
|
| 95 |
+
onClick={signOut}
|
| 96 |
+
className="px-4 py-2 bg-white border border-slate-200 text-red-600 font-medium rounded-lg hover:bg-red-50 hover:border-red-100 transition shadow-sm"
|
| 97 |
+
>
|
| 98 |
+
Sign Out
|
| 99 |
+
</button>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
</nav>
|
| 104 |
+
|
| 105 |
+
<main className="max-w-5xl mx-auto px-4 py-8">
|
| 106 |
+
<div className="mb-8 p-6 bg-blue-50 border border-blue-100 rounded-xl flex items-start gap-4">
|
| 107 |
+
<div className="p-2 bg-blue-100 rounded-lg">
|
| 108 |
+
<Smartphone className="w-6 h-6 text-blue-600" />
|
| 109 |
+
</div>
|
| 110 |
+
<div>
|
| 111 |
+
<h3 className="text-blue-900 font-bold text-lg mb-1">
|
| 112 |
+
Make a New Report
|
| 113 |
+
</h3>
|
| 114 |
+
<p className="text-blue-700 leading-relaxed">
|
| 115 |
+
To ensure data accuracy and GPS verification, new issues must be
|
| 116 |
+
reported through the official{" "}
|
| 117 |
+
<strong>City Issue Mobile App</strong>.
|
| 118 |
+
</p>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
|
| 123 |
+
<div className="p-6 bg-white rounded-xl border border-slate-200 shadow-sm">
|
| 124 |
+
<p className="text-slate-500 text-sm font-medium uppercase tracking-wide">
|
| 125 |
+
Total Reports
|
| 126 |
+
</p>
|
| 127 |
+
<p className="text-3xl font-extrabold text-slate-900 mt-2">
|
| 128 |
+
{issues.length}
|
| 129 |
+
</p>
|
| 130 |
+
</div>
|
| 131 |
+
<div className="p-6 bg-white rounded-xl border border-slate-200 shadow-sm">
|
| 132 |
+
<p className="text-slate-500 text-sm font-medium uppercase tracking-wide">
|
| 133 |
+
Resolved
|
| 134 |
+
</p>
|
| 135 |
+
<p className="text-3xl font-extrabold text-emerald-600 mt-2">
|
| 136 |
+
{issues.filter((i) => i.state === "resolved").length}
|
| 137 |
+
</p>
|
| 138 |
+
</div>
|
| 139 |
+
<div className="p-6 bg-white rounded-xl border border-slate-200 shadow-sm">
|
| 140 |
+
<p className="text-slate-500 text-sm font-medium uppercase tracking-wide">
|
| 141 |
+
In Progress
|
| 142 |
+
</p>
|
| 143 |
+
<p className="text-3xl font-extrabold text-amber-500 mt-2">
|
| 144 |
+
{
|
| 145 |
+
issues.filter((i) =>
|
| 146 |
+
["assigned", "in_progress"].includes(i.state),
|
| 147 |
+
).length
|
| 148 |
+
}
|
| 149 |
+
</p>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
|
| 153 |
+
<h2 className="text-xl font-bold text-slate-900 mb-6">
|
| 154 |
+
Recent Activity
|
| 155 |
+
</h2>
|
| 156 |
+
|
| 157 |
+
{issues.length === 0 ? (
|
| 158 |
+
<div className="text-center py-16 bg-white rounded-xl border border-slate-200 shadow-sm">
|
| 159 |
+
<FileText className="w-12 h-12 mx-auto text-slate-300" />
|
| 160 |
+
<p className="text-slate-900 font-medium text-lg mt-4">
|
| 161 |
+
No reports submitted yet
|
| 162 |
+
</p>
|
| 163 |
+
<p className="text-slate-500 mt-2">
|
| 164 |
+
Download the mobile app to start contributing to your city.
|
| 165 |
+
</p>
|
| 166 |
+
</div>
|
| 167 |
+
) : (
|
| 168 |
+
<div className="space-y-4">
|
| 169 |
+
{issues.map((issue) => (
|
| 170 |
+
<div
|
| 171 |
+
key={issue.id}
|
| 172 |
+
className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow"
|
| 173 |
+
>
|
| 174 |
+
<div className="flex flex-col sm:flex-row justify-between items-start gap-4 mb-4">
|
| 175 |
+
<div className="flex items-center gap-3">
|
| 176 |
+
{getStateBadge(issue.state)}
|
| 177 |
+
<span className="text-xs font-bold text-slate-500 uppercase tracking-wide px-2 border-l border-slate-200">
|
| 178 |
+
{issue.classification?.primary_category ||
|
| 179 |
+
issue.primary_category ||
|
| 180 |
+
"General Issue"}
|
| 181 |
+
</span>
|
| 182 |
+
</div>
|
| 183 |
+
<span className="text-sm text-slate-400 font-medium">
|
| 184 |
+
{new Date(issue.created_at).toLocaleDateString(undefined, {
|
| 185 |
+
month: "long",
|
| 186 |
+
day: "numeric",
|
| 187 |
+
year: "numeric",
|
| 188 |
+
})}
|
| 189 |
+
</span>
|
| 190 |
+
</div>
|
| 191 |
+
|
| 192 |
+
<h3 className="text-lg font-bold text-slate-900 mb-2">
|
| 193 |
+
{issue.description || "No description provided"}
|
| 194 |
+
</h3>
|
| 195 |
+
|
| 196 |
+
<div className="flex items-center gap-2 text-sm text-slate-500">
|
| 197 |
+
<span className="flex items-center gap-1">
|
| 198 |
+
<MapPin className="w-4 h-4 text-slate-400" />
|
| 199 |
+
{issue.locality ? `${issue.locality}, ` : ""}
|
| 200 |
+
{issue.city}
|
| 201 |
+
</span>
|
| 202 |
+
</div>
|
| 203 |
+
</div>
|
| 204 |
+
))}
|
| 205 |
+
</div>
|
| 206 |
+
)}
|
| 207 |
+
</main>
|
| 208 |
+
|
| 209 |
+
<footer className="mt-12 py-8 bg-white border-t border-slate-200">
|
| 210 |
+
<div className="max-w-5xl mx-auto px-4 text-center">
|
| 211 |
+
<p className="text-sm text-slate-400">
|
| 212 |
+
2026 City Department of Public Works - Secure Citizen Portal
|
| 213 |
+
</p>
|
| 214 |
+
</div>
|
| 215 |
+
</footer>
|
| 216 |
+
</div>
|
| 217 |
+
);
|
| 218 |
+
}
|
Frontend/app/worker/layout.tsx
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useState, useEffect } from "react";
|
| 3 |
+
import { useRouter } from "next/navigation";
|
| 4 |
+
import { useAuth } from "@/components/AuthProvider";
|
| 5 |
+
import DashboardSidebar from "@/components/DashboardSidebar";
|
| 6 |
+
import DashboardHeader from "@/components/DashboardHeader";
|
| 7 |
+
|
| 8 |
+
export default function WorkerLayout({ children }: { children: React.ReactNode }) {
|
| 9 |
+
const { role, loading, signOut } = useAuth();
|
| 10 |
+
const [mobileOpen, setMobileOpen] = useState(false);
|
| 11 |
+
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
| 12 |
+
const router = useRouter();
|
| 13 |
+
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
if (!loading && role !== "worker") {
|
| 16 |
+
router.push("/signin");
|
| 17 |
+
}
|
| 18 |
+
}, [loading, role, router]);
|
| 19 |
+
|
| 20 |
+
if (loading) return null;
|
| 21 |
+
|
| 22 |
+
return (
|
| 23 |
+
<div className="flex min-h-screen bg-slate-50 font-sans text-slate-900 overflow-hidden">
|
| 24 |
+
<DashboardSidebar
|
| 25 |
+
role="worker"
|
| 26 |
+
mobileOpen={mobileOpen}
|
| 27 |
+
setMobileOpen={setMobileOpen}
|
| 28 |
+
desktopOpen={isSidebarOpen}
|
| 29 |
+
onLogout={signOut}
|
| 30 |
+
/>
|
| 31 |
+
|
| 32 |
+
<div className="flex-1 flex flex-col min-h-screen transition-all duration-300 h-screen overflow-hidden">
|
| 33 |
+
<DashboardHeader
|
| 34 |
+
setMobileOpen={setMobileOpen}
|
| 35 |
+
toggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)}
|
| 36 |
+
title="Field Worker Portal"
|
| 37 |
+
/>
|
| 38 |
+
<main className="flex-1 overflow-x-hidden overflow-y-auto p-4 sm:p-6 lg:p-8">
|
| 39 |
+
{children}
|
| 40 |
+
</main>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
);
|
| 44 |
+
}
|
Frontend/app/worker/page.tsx
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useEffect, useState } from "react";
|
| 3 |
+
// Removed duplicate imports
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import Link from "next/link";
|
| 6 |
+
import { useAuth } from "@/components/AuthProvider";
|
| 7 |
+
import { apiGet } from "@/lib/api";
|
| 8 |
+
import {
|
| 9 |
+
Coffee,
|
| 10 |
+
MapPin,
|
| 11 |
+
ArrowRight,
|
| 12 |
+
AlertCircle,
|
| 13 |
+
Calendar,
|
| 14 |
+
} from "lucide-react";
|
| 15 |
+
import { Skeleton } from "@/components/ui/Skeleton";
|
| 16 |
+
|
| 17 |
+
interface Task {
|
| 18 |
+
id: string;
|
| 19 |
+
description: string;
|
| 20 |
+
priority: number;
|
| 21 |
+
state: string;
|
| 22 |
+
city: string;
|
| 23 |
+
locality: string;
|
| 24 |
+
full_address: string;
|
| 25 |
+
latitude: number;
|
| 26 |
+
longitude: number;
|
| 27 |
+
image_url: string;
|
| 28 |
+
created_at: string;
|
| 29 |
+
sla_deadline: string;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
import { useCachedFetch } from "@/hooks/useCachedFetch";
|
| 33 |
+
|
| 34 |
+
// ... existing imports
|
| 35 |
+
|
| 36 |
+
export default function WorkerDashboard() {
|
| 37 |
+
const { user, role, loading: authLoading } = useAuth();
|
| 38 |
+
const router = useRouter();
|
| 39 |
+
|
| 40 |
+
// Use cached fetch for instant load + background update
|
| 41 |
+
const { data: tasksData, loading: tasksLoading } = useCachedFetch<Task[]>(
|
| 42 |
+
role === "worker" ? "/worker/tasks" : ""
|
| 43 |
+
);
|
| 44 |
+
|
| 45 |
+
const tasks = tasksData || [];
|
| 46 |
+
|
| 47 |
+
// Combine loading states
|
| 48 |
+
const isLoading = authLoading || (tasksLoading && tasks.length === 0);
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
const getPriorityBadge = (priority: number) => {
|
| 52 |
+
const badges: Record<number, { bg: string; text: string; border: string }> =
|
| 53 |
+
{
|
| 54 |
+
1: { bg: "bg-red-50", text: "text-red-700", border: "border-red-200" },
|
| 55 |
+
2: {
|
| 56 |
+
bg: "bg-orange-50",
|
| 57 |
+
text: "text-orange-700",
|
| 58 |
+
border: "border-orange-200",
|
| 59 |
+
},
|
| 60 |
+
3: {
|
| 61 |
+
bg: "bg-amber-50",
|
| 62 |
+
text: "text-amber-700",
|
| 63 |
+
border: "border-amber-200",
|
| 64 |
+
},
|
| 65 |
+
4: {
|
| 66 |
+
bg: "bg-emerald-50",
|
| 67 |
+
text: "text-emerald-700",
|
| 68 |
+
border: "border-emerald-200",
|
| 69 |
+
},
|
| 70 |
+
};
|
| 71 |
+
const labels: Record<number, string> = {
|
| 72 |
+
1: "Critical",
|
| 73 |
+
2: "High",
|
| 74 |
+
3: "Medium",
|
| 75 |
+
4: "Low",
|
| 76 |
+
};
|
| 77 |
+
const badge = badges[priority] || badges[3];
|
| 78 |
+
return (
|
| 79 |
+
<span
|
| 80 |
+
className={`px-2.5 py-1 rounded-md text-xs font-bold border ${badge.bg} ${badge.text} ${badge.border} flex items-center gap-1.5`}
|
| 81 |
+
>
|
| 82 |
+
<span className={`w-1.5 h-1.5 rounded-full ${priority === 1 ? 'bg-red-500' : priority === 2 ? 'bg-orange-500' : priority === 3 ? 'bg-amber-500' : 'bg-emerald-500'}`}></span>
|
| 83 |
+
{labels[priority] || "Unknown"}
|
| 84 |
+
</span>
|
| 85 |
+
);
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
if (isLoading) {
|
| 89 |
+
return (
|
| 90 |
+
<div className="space-y-6">
|
| 91 |
+
<div className="flex justify-between items-center">
|
| 92 |
+
<div className="space-y-2">
|
| 93 |
+
<Skeleton className="h-8 w-48" />
|
| 94 |
+
<Skeleton className="h-4 w-64" />
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
| 99 |
+
<Skeleton className="h-32 rounded-2xl" />
|
| 100 |
+
<Skeleton className="h-32 rounded-2xl" />
|
| 101 |
+
<Skeleton className="h-32 rounded-2xl" />
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
<Skeleton className="h-6 w-40 mb-4" />
|
| 105 |
+
|
| 106 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 107 |
+
{Array.from({ length: 4 }).map((_, i) => (
|
| 108 |
+
<Skeleton key={i} className="h-64 rounded-2xl" />
|
| 109 |
+
))}
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
return (
|
| 116 |
+
<div className="space-y-6">
|
| 117 |
+
<div className="flex justify-between items-center">
|
| 118 |
+
<div>
|
| 119 |
+
<h2 className="text-2xl font-bold text-slate-900 tracking-tight">My Assignments</h2>
|
| 120 |
+
<p className="text-sm text-slate-500">
|
| 121 |
+
Tasks assigned to you for resolution.
|
| 122 |
+
</p>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
| 127 |
+
<div className="bg-white/60 backdrop-blur-md p-6 rounded-2xl border border-slate-200/60 shadow-urban-sm hover:shadow-urban-md transition-all">
|
| 128 |
+
<div className="flex items-center gap-3 mb-2">
|
| 129 |
+
<div className="p-2.5 bg-blue-50 text-blue-600 rounded-xl shadow-sm">
|
| 130 |
+
<AlertCircle className="w-5 h-5" />
|
| 131 |
+
</div>
|
| 132 |
+
<h3 className="text-slate-500 font-bold text-xs uppercase tracking-wider font-mono">
|
| 133 |
+
Active Tasks
|
| 134 |
+
</h3>
|
| 135 |
+
</div>
|
| 136 |
+
<p className="text-4xl font-extrabold text-slate-900 mt-2 tracking-tighter">
|
| 137 |
+
{
|
| 138 |
+
tasks.filter((t) =>
|
| 139 |
+
["assigned", "in_progress", "rejected"].includes(t.state)
|
| 140 |
+
).length
|
| 141 |
+
}
|
| 142 |
+
</p>
|
| 143 |
+
</div>
|
| 144 |
+
<div className="bg-white/60 backdrop-blur-md p-6 rounded-2xl border border-slate-200/60 shadow-urban-sm hover:shadow-urban-md transition-all">
|
| 145 |
+
<div className="flex items-center gap-3 mb-2">
|
| 146 |
+
<div className="p-2.5 bg-amber-50 text-amber-600 rounded-xl shadow-sm">
|
| 147 |
+
<Coffee className="w-5 h-5" />
|
| 148 |
+
</div>
|
| 149 |
+
<h3 className="text-slate-500 font-bold text-xs uppercase tracking-wider font-mono">
|
| 150 |
+
Pending Review
|
| 151 |
+
</h3>
|
| 152 |
+
</div>
|
| 153 |
+
<p className="text-4xl font-extrabold text-slate-900 mt-2 tracking-tighter">
|
| 154 |
+
{
|
| 155 |
+
tasks.filter((t) =>
|
| 156 |
+
["pending_verification", "resolved"].includes(t.state)
|
| 157 |
+
).length
|
| 158 |
+
}
|
| 159 |
+
</p>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<h3 className="text-lg font-bold text-slate-900 mb-4 flex items-center gap-2">
|
| 164 |
+
<span className="w-1 h-5 bg-blue-600 rounded-full"></span>
|
| 165 |
+
Current Assignments
|
| 166 |
+
</h3>
|
| 167 |
+
|
| 168 |
+
{tasks.length === 0 ? (
|
| 169 |
+
<div className="text-center py-16 bg-white/40 backdrop-blur-sm rounded-2xl border border-slate-200/60 border-dashed">
|
| 170 |
+
<Coffee className="w-12 h-12 mx-auto text-slate-300 mb-4" />
|
| 171 |
+
<p className="text-slate-900 font-bold text-lg">
|
| 172 |
+
All caught up!
|
| 173 |
+
</p>
|
| 174 |
+
<p className="text-slate-500 text-sm mt-1">
|
| 175 |
+
Enjoy your break, no pending assignments.
|
| 176 |
+
</p>
|
| 177 |
+
</div>
|
| 178 |
+
) : (
|
| 179 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 180 |
+
{tasks.map((task) => (
|
| 181 |
+
<Link key={task.id} href={`/worker/task/${task.id}`}>
|
| 182 |
+
<div className="h-full p-6 bg-white/70 backdrop-blur-md rounded-2xl border border-slate-200/60 shadow-urban-sm hover:shadow-urban-md hover:-translate-y-1 hover:border-blue-300/50 transition-all cursor-pointer group flex flex-col justify-between relative overflow-hidden">
|
| 183 |
+
<div className="absolute top-0 left-0 w-1 h-full bg-slate-200 group-hover:bg-blue-500 transition-colors"></div>
|
| 184 |
+
<div>
|
| 185 |
+
<div className="flex justify-between items-start mb-4 pl-2">
|
| 186 |
+
<div className="flex items-center gap-2 flex-wrap">
|
| 187 |
+
{getPriorityBadge(task.priority)}
|
| 188 |
+
<span
|
| 189 |
+
className={`text-xs font-bold px-2.5 py-1 rounded-md uppercase tracking-wide border ${
|
| 190 |
+
task.state === "pending_verification"
|
| 191 |
+
? "bg-orange-50 text-orange-700 border-orange-200"
|
| 192 |
+
: task.state === "resolved"
|
| 193 |
+
? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
| 194 |
+
: "bg-slate-100 text-slate-600 border-slate-200"
|
| 195 |
+
}`}
|
| 196 |
+
>
|
| 197 |
+
{task.state === "pending_verification"
|
| 198 |
+
? "Under Review"
|
| 199 |
+
: task.state.replace("_", " ")}
|
| 200 |
+
</span>
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
<h3 className="text-xl font-bold text-slate-900 mb-2 pl-2 group-hover:text-blue-700 transition-colors line-clamp-2 tracking-tight">
|
| 205 |
+
{task.description || "Issue Report"}
|
| 206 |
+
</h3>
|
| 207 |
+
|
| 208 |
+
<div className="py-3 pl-2 space-y-2.5">
|
| 209 |
+
<div className="flex items-center gap-2.5 text-sm text-slate-600">
|
| 210 |
+
<div className="p-1.5 bg-slate-100 rounded-md text-slate-500">
|
| 211 |
+
<MapPin className="w-3.5 h-3.5" />
|
| 212 |
+
</div>
|
| 213 |
+
<span className="truncate font-medium">
|
| 214 |
+
{task.full_address || `${task.city}, ${task.locality}`}
|
| 215 |
+
</span>
|
| 216 |
+
</div>
|
| 217 |
+
{task.sla_deadline && (
|
| 218 |
+
<div className="flex items-center gap-2.5 text-sm text-red-600 font-bold bg-red-50/50 p-2 rounded-lg border border-red-100/50 w-fit">
|
| 219 |
+
<Calendar className="w-3.5 h-3.5 shrink-0" />
|
| 220 |
+
<span>
|
| 221 |
+
Due:{" "}
|
| 222 |
+
{new Date(task.sla_deadline).toLocaleDateString()}
|
| 223 |
+
</span>
|
| 224 |
+
</div>
|
| 225 |
+
)}
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
|
| 229 |
+
<div className="pt-4 mt-2 border-t border-slate-100 pl-2 flex justify-between items-center">
|
| 230 |
+
<span className="text-xs text-slate-400 font-mono font-medium bg-slate-100 px-2 py-1 rounded">
|
| 231 |
+
ID: {task.id.slice(0, 8)}
|
| 232 |
+
</span>
|
| 233 |
+
<span className="text-blue-600 text-sm font-bold flex items-center gap-1.5 group-hover:gap-2.5 transition-all bg-blue-50/50 px-3 py-1.5 rounded-lg border border-blue-100/50 group-hover:bg-blue-100 group-hover:border-blue-200">
|
| 234 |
+
Resolve <ArrowRight className="w-4 h-4" />
|
| 235 |
+
</span>
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
</Link>
|
| 239 |
+
))}
|
| 240 |
+
</div>
|
| 241 |
+
)}
|
| 242 |
+
</div>
|
| 243 |
+
);
|
| 244 |
+
}
|
Frontend/app/worker/task/[id]/page.tsx
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useEffect, useState, useRef } from "react";
|
| 3 |
+
import { useRouter, useParams } from "next/navigation";
|
| 4 |
+
import { apiGet } from "@/lib/api";
|
| 5 |
+
|
| 6 |
+
export const runtime = "edge";
|
| 7 |
+
import { ArrowLeft, Camera, Navigation, Loader2 } from "lucide-react";
|
| 8 |
+
|
| 9 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
| 10 |
+
if (!API_URL) throw new Error("Missing NEXT_PUBLIC_API_URL");
|
| 11 |
+
|
| 12 |
+
interface Task {
|
| 13 |
+
id: string;
|
| 14 |
+
description: string;
|
| 15 |
+
priority: number;
|
| 16 |
+
state: string;
|
| 17 |
+
city: string;
|
| 18 |
+
locality: string;
|
| 19 |
+
full_address: string;
|
| 20 |
+
latitude: number;
|
| 21 |
+
longitude: number;
|
| 22 |
+
image_url: string;
|
| 23 |
+
annotated_url: string;
|
| 24 |
+
category: string;
|
| 25 |
+
created_at: string;
|
| 26 |
+
sla_deadline: string;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export default function TaskDetailPage() {
|
| 30 |
+
const [task, setTask] = useState<Task | null>(null);
|
| 31 |
+
const [loading, setLoading] = useState(true);
|
| 32 |
+
const [submitting, setSubmitting] = useState(false);
|
| 33 |
+
const [notes, setNotes] = useState("");
|
| 34 |
+
const [proofImage, setProofImage] = useState<File | null>(null);
|
| 35 |
+
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
| 36 |
+
const fileRef = useRef<HTMLInputElement>(null);
|
| 37 |
+
const router = useRouter();
|
| 38 |
+
const params = useParams();
|
| 39 |
+
const taskId = params.id as string;
|
| 40 |
+
|
| 41 |
+
useEffect(() => {
|
| 42 |
+
fetchTask();
|
| 43 |
+
}, [taskId]);
|
| 44 |
+
|
| 45 |
+
const fetchTask = async () => {
|
| 46 |
+
try {
|
| 47 |
+
const data = await apiGet<Task>(`/worker/tasks/${taskId}`);
|
| 48 |
+
setTask(data);
|
| 49 |
+
} catch (error) {
|
| 50 |
+
console.error("Failed to fetch task:", error);
|
| 51 |
+
} finally {
|
| 52 |
+
setLoading(false);
|
| 53 |
+
}
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 57 |
+
const file = e.target.files?.[0];
|
| 58 |
+
if (file) {
|
| 59 |
+
setProofImage(file);
|
| 60 |
+
setPreviewUrl(URL.createObjectURL(file));
|
| 61 |
+
}
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
const handleStart = async () => {
|
| 65 |
+
const token = localStorage.getItem("token");
|
| 66 |
+
if (!token) {
|
| 67 |
+
router.push("/signin");
|
| 68 |
+
return;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
try {
|
| 72 |
+
const res = await fetch(`${API_URL}/worker/tasks/${taskId}/start`, {
|
| 73 |
+
method: "POST",
|
| 74 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
if (res.ok) {
|
| 78 |
+
fetchTask();
|
| 79 |
+
} else {
|
| 80 |
+
console.error("Failed to start task");
|
| 81 |
+
}
|
| 82 |
+
} catch (error) {
|
| 83 |
+
console.error("Error starting task:", error);
|
| 84 |
+
}
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
const handleComplete = async () => {
|
| 88 |
+
if (!proofImage) {
|
| 89 |
+
alert("Please upload a proof image");
|
| 90 |
+
return;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
const token = localStorage.getItem("token");
|
| 94 |
+
if (!token) {
|
| 95 |
+
alert("Session expired. Please sign in again.");
|
| 96 |
+
router.push("/signin");
|
| 97 |
+
return;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
setSubmitting(true);
|
| 101 |
+
try {
|
| 102 |
+
const formData = new FormData();
|
| 103 |
+
formData.append("proof_image", proofImage);
|
| 104 |
+
if (notes) formData.append("notes", notes);
|
| 105 |
+
|
| 106 |
+
const res = await fetch(`${API_URL}/worker/tasks/${taskId}/complete`, {
|
| 107 |
+
method: "POST",
|
| 108 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 109 |
+
body: formData,
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
if (res.ok) {
|
| 113 |
+
router.push("/worker");
|
| 114 |
+
} else {
|
| 115 |
+
const data = await res.json();
|
| 116 |
+
alert(data.detail || "Failed to complete task");
|
| 117 |
+
}
|
| 118 |
+
} catch (error) {
|
| 119 |
+
console.error("Failed to complete task:", error);
|
| 120 |
+
} finally {
|
| 121 |
+
setSubmitting(false);
|
| 122 |
+
}
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
if (loading) {
|
| 126 |
+
return (
|
| 127 |
+
<div className="text-slate-600 font-medium">Loading Task Details...</div>
|
| 128 |
+
);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
if (!task) {
|
| 132 |
+
return (
|
| 133 |
+
<div className="flex flex-col items-center justify-center py-12">
|
| 134 |
+
<p className="text-slate-500 text-lg">Task not found</p>
|
| 135 |
+
<button
|
| 136 |
+
onClick={() => router.back()}
|
| 137 |
+
className="mt-4 text-blue-600 font-medium hover:underline"
|
| 138 |
+
>
|
| 139 |
+
Go Back
|
| 140 |
+
</button>
|
| 141 |
+
</div>
|
| 142 |
+
);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
return (
|
| 146 |
+
<div className="space-y-6">
|
| 147 |
+
<div className="flex items-center justify-between">
|
| 148 |
+
<button
|
| 149 |
+
onClick={() => router.back()}
|
| 150 |
+
className="text-slate-500 hover:text-slate-900 transition font-medium flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-white/50"
|
| 151 |
+
>
|
| 152 |
+
<ArrowLeft className="w-5 h-5" /> Back to List
|
| 153 |
+
</button>
|
| 154 |
+
<a
|
| 155 |
+
href={`https://www.google.com/maps?q=${task.latitude},${task.longitude}`}
|
| 156 |
+
target="_blank"
|
| 157 |
+
rel="noopener noreferrer"
|
| 158 |
+
className="px-5 py-2.5 bg-blue-600 text-white text-sm font-bold rounded-xl hover:bg-blue-700 transition shadow-lg hover:shadow-blue-500/30 flex items-center gap-2 transform hover:-translate-y-0.5"
|
| 159 |
+
>
|
| 160 |
+
<Navigation className="w-4 h-4" /> Navigation
|
| 161 |
+
</a>
|
| 162 |
+
</div>
|
| 163 |
+
|
| 164 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
| 165 |
+
<div className="space-y-6">
|
| 166 |
+
<div className="bg-white/60 backdrop-blur-md rounded-2xl border border-slate-200/60 shadow-urban-sm overflow-hidden">
|
| 167 |
+
{task.annotated_url ? (
|
| 168 |
+
<div className="relative h-72 bg-slate-100 group">
|
| 169 |
+
<img
|
| 170 |
+
src={task.annotated_url}
|
| 171 |
+
alt="Issue"
|
| 172 |
+
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
|
| 173 |
+
/>
|
| 174 |
+
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent opacity-60"></div>
|
| 175 |
+
<div className="absolute bottom-4 left-4 text-white">
|
| 176 |
+
<p className="font-bold text-lg text-shadow-sm">{task.category}</p>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
) : (
|
| 180 |
+
<div className="h-64 bg-slate-100 flex items-center justify-center text-slate-400">
|
| 181 |
+
No Image Available
|
| 182 |
+
</div>
|
| 183 |
+
)}
|
| 184 |
+
|
| 185 |
+
<div className="p-6">
|
| 186 |
+
<div className="flex items-center gap-2 mb-4">
|
| 187 |
+
<span className={`px-2.5 py-1 rounded-md text-xs font-bold uppercase tracking-wide border ${
|
| 188 |
+
task.state === 'pending_verification' ? 'bg-orange-50 text-orange-700 border-orange-200' :
|
| 189 |
+
task.state === 'resolved' ? 'bg-emerald-50 text-emerald-700 border-emerald-200' :
|
| 190 |
+
'bg-blue-50 text-blue-700 border-blue-200'
|
| 191 |
+
}`}>
|
| 192 |
+
{task.state.replace("_", " ")}
|
| 193 |
+
</span>
|
| 194 |
+
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider ml-auto">
|
| 195 |
+
ID: {task.id.slice(0, 8)}
|
| 196 |
+
</span>
|
| 197 |
+
</div>
|
| 198 |
+
|
| 199 |
+
<h1 className="text-3xl font-extrabold text-slate-900 mb-2 tracking-tight">
|
| 200 |
+
{task.description || "Issue Report"}
|
| 201 |
+
</h1>
|
| 202 |
+
<p className="text-slate-600 mb-8 font-medium text-lg leading-relaxed">
|
| 203 |
+
{task.full_address}
|
| 204 |
+
</p>
|
| 205 |
+
|
| 206 |
+
<div className="grid grid-cols-2 gap-6 py-6 border-t border-slate-200/60">
|
| 207 |
+
<div>
|
| 208 |
+
<p className="text-xs font-bold text-slate-400 uppercase tracking-wide mb-1">
|
| 209 |
+
Reported On
|
| 210 |
+
</p>
|
| 211 |
+
<p className="text-slate-900 font-bold text-lg font-mono">
|
| 212 |
+
{new Date(task.created_at).toLocaleDateString()}
|
| 213 |
+
</p>
|
| 214 |
+
</div>
|
| 215 |
+
{task.sla_deadline && (
|
| 216 |
+
<div>
|
| 217 |
+
<p className="text-xs font-bold text-slate-400 uppercase tracking-wide mb-1">
|
| 218 |
+
Deadline
|
| 219 |
+
</p>
|
| 220 |
+
<p className="text-red-600 font-bold text-lg font-mono">
|
| 221 |
+
{new Date(task.sla_deadline).toLocaleDateString()}
|
| 222 |
+
</p>
|
| 223 |
+
</div>
|
| 224 |
+
)}
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
</div>
|
| 229 |
+
|
| 230 |
+
<div className="bg-white/60 backdrop-blur-md rounded-2xl border border-slate-200/60 shadow-urban-sm p-8 h-fit sticky top-6">
|
| 231 |
+
<h2 className="text-xl font-bold text-slate-900 mb-6 border-b border-slate-200/60 pb-4 flex items-center gap-2">
|
| 232 |
+
<span className="w-1.5 h-6 bg-blue-600 rounded-full"></span>
|
| 233 |
+
Task Action
|
| 234 |
+
</h2>
|
| 235 |
+
|
| 236 |
+
{task.state === "assigned" || task.state === "rejected" ? (
|
| 237 |
+
<div className="text-center py-6">
|
| 238 |
+
<p className="text-slate-600 mb-8 font-medium">
|
| 239 |
+
{task.state === "rejected"
|
| 240 |
+
? "This task was returned. Please review feedback and restart work."
|
| 241 |
+
: "You are assigned to this task. Travel to the location and start the work."}
|
| 242 |
+
</p>
|
| 243 |
+
<button
|
| 244 |
+
onClick={handleStart}
|
| 245 |
+
className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold text-lg rounded-xl shadow-lg hover:shadow-blue-500/40 transition-all flex items-center justify-center gap-2 transform hover:-translate-y-1"
|
| 246 |
+
>
|
| 247 |
+
Start Task
|
| 248 |
+
</button>
|
| 249 |
+
</div>
|
| 250 |
+
) : task.state === "in_progress" ? (
|
| 251 |
+
<>
|
| 252 |
+
<h3 className="font-bold text-slate-800 mb-4 text-sm uppercase tracking-wide">
|
| 253 |
+
Complete Resolution
|
| 254 |
+
</h3>
|
| 255 |
+
<div className="mb-6">
|
| 256 |
+
<label className="block text-sm font-bold text-slate-700 mb-2">
|
| 257 |
+
Proof of Fix <span className="text-red-500">*</span>
|
| 258 |
+
</label>
|
| 259 |
+
<input
|
| 260 |
+
title="image"
|
| 261 |
+
ref={fileRef}
|
| 262 |
+
type="file"
|
| 263 |
+
accept="image/*"
|
| 264 |
+
onChange={handleFileChange}
|
| 265 |
+
className="hidden"
|
| 266 |
+
/>
|
| 267 |
+
<button
|
| 268 |
+
onClick={() => fileRef.current?.click()}
|
| 269 |
+
className={`w-full p-8 border-2 border-dashed rounded-2xl transition-all group ${
|
| 270 |
+
previewUrl
|
| 271 |
+
? "border-blue-500 bg-blue-50/50"
|
| 272 |
+
: "border-slate-300 hover:border-blue-400 hover:bg-slate-50"
|
| 273 |
+
}`}
|
| 274 |
+
>
|
| 275 |
+
{previewUrl ? (
|
| 276 |
+
<div className="text-center">
|
| 277 |
+
<img
|
| 278 |
+
src={previewUrl}
|
| 279 |
+
alt="Proof"
|
| 280 |
+
className="h-48 mx-auto rounded-xl shadow-md object-cover mb-4"
|
| 281 |
+
/>
|
| 282 |
+
<span className="text-sm font-bold text-blue-600 group-hover:text-blue-700">
|
| 283 |
+
Change Photo
|
| 284 |
+
</span>
|
| 285 |
+
</div>
|
| 286 |
+
) : (
|
| 287 |
+
<div className="flex flex-col items-center text-slate-400 group-hover:text-blue-500 transition-colors">
|
| 288 |
+
<div className="p-4 bg-slate-100 rounded-full mb-3 group-hover:bg-blue-100 transition-colors">
|
| 289 |
+
<Camera className="w-8 h-8" />
|
| 290 |
+
</div>
|
| 291 |
+
<span className="font-bold">Tap to Upload Photo</span>
|
| 292 |
+
</div>
|
| 293 |
+
)}
|
| 294 |
+
</button>
|
| 295 |
+
</div>
|
| 296 |
+
|
| 297 |
+
<div className="mb-6">
|
| 298 |
+
<label className="block text-sm font-bold text-slate-700 mb-2">
|
| 299 |
+
Resolution Notes
|
| 300 |
+
</label>
|
| 301 |
+
<textarea
|
| 302 |
+
value={notes}
|
| 303 |
+
onChange={(e) => setNotes(e.target.value)}
|
| 304 |
+
className="w-full px-4 py-3 bg-white/50 border border-slate-200 rounded-xl text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 transition-all resize-none"
|
| 305 |
+
rows={4}
|
| 306 |
+
placeholder="Describe the repair work completed..."
|
| 307 |
+
/>
|
| 308 |
+
</div>
|
| 309 |
+
|
| 310 |
+
<button
|
| 311 |
+
onClick={handleComplete}
|
| 312 |
+
disabled={!proofImage || submitting}
|
| 313 |
+
className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-lg rounded-xl shadow-lg hover:shadow-emerald-500/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 transform hover:-translate-y-1 active:scale-95"
|
| 314 |
+
>
|
| 315 |
+
{submitting ? (
|
| 316 |
+
<>
|
| 317 |
+
<Loader2 className="w-6 h-6 animate-spin" />
|
| 318 |
+
Submitting...
|
| 319 |
+
</>
|
| 320 |
+
) : (
|
| 321 |
+
"Mark as Resolved"
|
| 322 |
+
)}
|
| 323 |
+
</button>
|
| 324 |
+
</>
|
| 325 |
+
) : task.state === "pending_verification" ? (
|
| 326 |
+
<div className="text-center py-10 bg-orange-50/50 rounded-2xl border border-orange-100">
|
| 327 |
+
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
| 328 |
+
<Loader2 className="w-8 h-8 text-orange-500 animate-spin" />
|
| 329 |
+
</div>
|
| 330 |
+
<h3 className="text-xl font-bold text-orange-900 mb-2">
|
| 331 |
+
Under Review
|
| 332 |
+
</h3>
|
| 333 |
+
<p className="text-orange-700 font-medium px-4">
|
| 334 |
+
Your work has been submitted. Waiting for admin verification.
|
| 335 |
+
</p>
|
| 336 |
+
</div>
|
| 337 |
+
) : (
|
| 338 |
+
<div className="text-center py-10 bg-emerald-50/50 rounded-2xl border border-emerald-100">
|
| 339 |
+
<div className="w-16 h-16 bg-emerald-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
| 340 |
+
<div className="w-8 h-8 text-emerald-600 font-bold text-2xl">✓</div>
|
| 341 |
+
</div>
|
| 342 |
+
<h3 className="text-xl font-bold text-emerald-900 mb-2">
|
| 343 |
+
Task Completed
|
| 344 |
+
</h3>
|
| 345 |
+
<p className="text-emerald-700 font-medium">
|
| 346 |
+
This issue has been successfully resolved and closed.
|
| 347 |
+
</p>
|
| 348 |
+
</div>
|
| 349 |
+
)}
|
| 350 |
+
</div>
|
| 351 |
+
</div>
|
| 352 |
+
</div>
|
| 353 |
+
);
|
| 354 |
+
}
|
Frontend/components/AuthProvider.tsx
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { createContext, useContext, useEffect, useState } from "react";
|
| 3 |
+
import { createClient, Session, User } from "@supabase/supabase-js";
|
| 4 |
+
import { useRouter, usePathname } from "next/navigation";
|
| 5 |
+
|
| 6 |
+
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
|
| 7 |
+
const SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "";
|
| 8 |
+
|
| 9 |
+
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
| 10 |
+
|
| 11 |
+
interface AuthContextType {
|
| 12 |
+
user: User | null;
|
| 13 |
+
session: Session | null;
|
| 14 |
+
loading: boolean;
|
| 15 |
+
signOut: () => Promise<void>;
|
| 16 |
+
role: string | null;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const AuthContext = createContext<AuthContextType>({
|
| 20 |
+
user: null,
|
| 21 |
+
session: null,
|
| 22 |
+
loading: true,
|
| 23 |
+
signOut: async () => {},
|
| 24 |
+
role: null,
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
| 28 |
+
const [user, setUser] = useState<User | null>(null);
|
| 29 |
+
const [session, setSession] = useState<Session | null>(null);
|
| 30 |
+
const [loading, setLoading] = useState(true);
|
| 31 |
+
const [role, setRole] = useState<string | null>(null);
|
| 32 |
+
const router = useRouter();
|
| 33 |
+
const pathname = usePathname();
|
| 34 |
+
|
| 35 |
+
useEffect(() => {
|
| 36 |
+
supabase.auth.getSession().then(({ data: { session } }) => {
|
| 37 |
+
handleSession(session);
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
const {
|
| 41 |
+
data: { subscription },
|
| 42 |
+
} = supabase.auth.onAuthStateChange((_event, session) => {
|
| 43 |
+
handleSession(session);
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
return () => subscription.unsubscribe();
|
| 47 |
+
}, []);
|
| 48 |
+
|
| 49 |
+
const handleSession = async (session: Session | null) => {
|
| 50 |
+
setSession(session);
|
| 51 |
+
|
| 52 |
+
if (session?.user) {
|
| 53 |
+
setUser(session.user);
|
| 54 |
+
|
| 55 |
+
const storedUser = localStorage.getItem("user");
|
| 56 |
+
let currentRole = "user";
|
| 57 |
+
|
| 58 |
+
if (storedUser) {
|
| 59 |
+
try {
|
| 60 |
+
const parsed = JSON.parse(storedUser);
|
| 61 |
+
if (parsed.email === session.user.email) {
|
| 62 |
+
currentRole = parsed.role || "user";
|
| 63 |
+
}
|
| 64 |
+
} catch (e) {
|
| 65 |
+
console.error("Error parsing stored user", e);
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
setRole(currentRole);
|
| 69 |
+
redirectToDashboard(currentRole);
|
| 70 |
+
} else {
|
| 71 |
+
const storedUser = localStorage.getItem("user");
|
| 72 |
+
const token = localStorage.getItem("token");
|
| 73 |
+
|
| 74 |
+
if (storedUser && token) {
|
| 75 |
+
try {
|
| 76 |
+
const parsed = JSON.parse(storedUser);
|
| 77 |
+
|
| 78 |
+
if (["admin", "worker"].includes(parsed.role)) {
|
| 79 |
+
setUser({
|
| 80 |
+
id: parsed.id,
|
| 81 |
+
email: parsed.email,
|
| 82 |
+
user_metadata: { full_name: parsed.name },
|
| 83 |
+
app_metadata: {},
|
| 84 |
+
aud: "authenticated",
|
| 85 |
+
created_at: new Date().toISOString(),
|
| 86 |
+
} as User);
|
| 87 |
+
|
| 88 |
+
setRole(parsed.role);
|
| 89 |
+
redirectToDashboard(parsed.role);
|
| 90 |
+
setLoading(false);
|
| 91 |
+
return;
|
| 92 |
+
}
|
| 93 |
+
} catch (e) {
|
| 94 |
+
console.error("Error parsing stored staff user", e);
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
setUser(null);
|
| 99 |
+
setRole(null);
|
| 100 |
+
|
| 101 |
+
if (!["/signin", "/signup", "/"].includes(window.location.pathname)) {
|
| 102 |
+
router.push("/signin");
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
setLoading(false);
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
const redirectToDashboard = (role: string) => {
|
| 110 |
+
if (["/signin", "/signup"].includes(window.location.pathname)) {
|
| 111 |
+
if (role === "admin") router.push("/admin");
|
| 112 |
+
else if (role === "worker") router.push("/worker");
|
| 113 |
+
else router.push("/user");
|
| 114 |
+
}
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
const signOut = async () => {
|
| 118 |
+
await supabase.auth.signOut();
|
| 119 |
+
localStorage.removeItem("user");
|
| 120 |
+
localStorage.removeItem("token");
|
| 121 |
+
localStorage.removeItem("supabase_token");
|
| 122 |
+
setRole(null);
|
| 123 |
+
setUser(null);
|
| 124 |
+
setSession(null);
|
| 125 |
+
router.push("/signin");
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
return (
|
| 129 |
+
<AuthContext.Provider value={{ user, session, loading, signOut, role }}>
|
| 130 |
+
{!loading ? (
|
| 131 |
+
children
|
| 132 |
+
) : (
|
| 133 |
+
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
| 134 |
+
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-slate-900"></div>
|
| 135 |
+
</div>
|
| 136 |
+
)}
|
| 137 |
+
</AuthContext.Provider>
|
| 138 |
+
);
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
export const useAuth = () => useContext(AuthContext);
|
Frontend/components/DashboardHeader.tsx
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
|
| 3 |
+
"use client";
|
| 4 |
+
import { useState } from "react";
|
| 5 |
+
import { Search, Bell, Menu, User } from "lucide-react";
|
| 6 |
+
|
| 7 |
+
interface HeaderProps {
|
| 8 |
+
setMobileOpen: (open: boolean) => void;
|
| 9 |
+
toggleSidebar?: () => void;
|
| 10 |
+
title?: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export default function DashboardHeader({ setMobileOpen, toggleSidebar, title = "Dashboard" }: HeaderProps) {
|
| 14 |
+
return (
|
| 15 |
+
<header className="sticky top-0 z-30 flex h-16 w-full items-center justify-between border-b border-slate-200/60 bg-white/60 backdrop-blur-md px-4 shadow-sm sm:px-6 lg:px-8 transition-all duration-300">
|
| 16 |
+
<div className="flex items-center gap-4">
|
| 17 |
+
<button
|
| 18 |
+
onClick={() => {
|
| 19 |
+
setMobileOpen(true); // Always open mobile menu
|
| 20 |
+
toggleSidebar?.(); // Toggle desktop if function exists
|
| 21 |
+
}}
|
| 22 |
+
className="rounded-lg p-2 text-slate-500 hover:bg-slate-100/80 hover:text-slate-700 transition-colors"
|
| 23 |
+
>
|
| 24 |
+
<Menu className="h-6 w-6" />
|
| 25 |
+
</button>
|
| 26 |
+
<h1 className="text-xl font-bold text-slate-900 tracking-tight hidden sm:block">{title}</h1>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<div className="flex items-center gap-4">
|
| 30 |
+
<div className="hidden md:flex relative group">
|
| 31 |
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 group-focus-within:text-blue-500 transition-colors" />
|
| 32 |
+
<input
|
| 33 |
+
type="text"
|
| 34 |
+
placeholder="Search issues, workers..."
|
| 35 |
+
className="h-10 w-64 rounded-full border border-slate-200 bg-slate-50/50 pl-10 pr-4 text-sm outline-none transition-all placeholder:text-slate-400 focus:border-blue-500/50 focus:bg-white focus:ring-4 focus:ring-blue-500/10"
|
| 36 |
+
/>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
<button className="relative rounded-full bg-white p-2.5 text-slate-500 shadow-sm ring-1 ring-slate-200 hover:text-blue-600 hover:ring-blue-200 transition-all">
|
| 40 |
+
<Bell className="h-5 w-5" />
|
| 41 |
+
<span className="absolute top-2 right-2.5 h-2 w-2 rounded-full bg-orange-500 ring-2 ring-white"></span>
|
| 42 |
+
</button>
|
| 43 |
+
|
| 44 |
+
<div className="h-9 w-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-700 font-bold border border-white shadow-sm ring-2 ring-blue-50">
|
| 45 |
+
<User className="h-5 w-5" />
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
</header>
|
| 49 |
+
);
|
| 50 |
+
}
|
Frontend/components/DashboardSidebar.tsx
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import Link from "next/link";
|
| 3 |
+
import { usePathname } from "next/navigation";
|
| 4 |
+
import {
|
| 5 |
+
LayoutDashboard,
|
| 6 |
+
ClipboardList,
|
| 7 |
+
CheckSquare,
|
| 8 |
+
HardHat,
|
| 9 |
+
Building2,
|
| 10 |
+
Map,
|
| 11 |
+
ListTodo,
|
| 12 |
+
Settings,
|
| 13 |
+
LogOut,
|
| 14 |
+
Menu,
|
| 15 |
+
} from "lucide-react";
|
| 16 |
+
import { clsx, type ClassValue } from "clsx";
|
| 17 |
+
import { twMerge } from "tailwind-merge";
|
| 18 |
+
|
| 19 |
+
function cn(...inputs: ClassValue[]) {
|
| 20 |
+
return twMerge(clsx(inputs));
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
interface SidebarProps {
|
| 24 |
+
role: "admin" | "worker";
|
| 25 |
+
mobileOpen: boolean;
|
| 26 |
+
setMobileOpen: (open: boolean) => void;
|
| 27 |
+
desktopOpen: boolean;
|
| 28 |
+
onLogout: () => void;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export default function DashboardSidebar({
|
| 32 |
+
role,
|
| 33 |
+
mobileOpen,
|
| 34 |
+
setMobileOpen,
|
| 35 |
+
desktopOpen,
|
| 36 |
+
onLogout,
|
| 37 |
+
}: SidebarProps) {
|
| 38 |
+
const pathname = usePathname();
|
| 39 |
+
|
| 40 |
+
// ... (links definition skipped for brevity if not changing, but we are inside function)
|
| 41 |
+
const adminLinks = [
|
| 42 |
+
{ href: "/admin", label: "Overview", icon: LayoutDashboard },
|
| 43 |
+
{ href: "/admin/issues", label: "Issues", icon: ClipboardList },
|
| 44 |
+
{ href: "/admin/review", label: "Review Queue", icon: CheckSquare },
|
| 45 |
+
{ href: "/admin/workers", label: "Workforce", icon: HardHat },
|
| 46 |
+
{ href: "/admin/departments", label: "Departments", icon: Building2 },
|
| 47 |
+
{ href: "/admin/heatmap", label: "Heatmap", icon: Map },
|
| 48 |
+
];
|
| 49 |
+
|
| 50 |
+
const workerLinks = [{ href: "/worker", label: "My Tasks", icon: ListTodo }];
|
| 51 |
+
|
| 52 |
+
const links = role === "admin" ? adminLinks : workerLinks;
|
| 53 |
+
|
| 54 |
+
return (
|
| 55 |
+
<>
|
| 56 |
+
{mobileOpen && (
|
| 57 |
+
<div
|
| 58 |
+
className="fixed inset-0 z-40 bg-slate-900/20 backdrop-blur-sm lg:hidden"
|
| 59 |
+
onClick={() => setMobileOpen(false)}
|
| 60 |
+
/>
|
| 61 |
+
)}
|
| 62 |
+
|
| 63 |
+
<aside
|
| 64 |
+
className={cn(
|
| 65 |
+
"fixed inset-y-0 left-0 z-50 bg-white/80 backdrop-blur-xl border-r border-slate-200/60 transition-all duration-300 ease-in-out shadow-urban-lg", // UrbanLens Light Glass
|
| 66 |
+
"lg:relative lg:translate-x-0 lg:shadow-none",
|
| 67 |
+
mobileOpen ? "translate-x-0" : "-translate-x-full",
|
| 68 |
+
desktopOpen ? "lg:w-72" : "lg:w-0 lg:overflow-hidden lg:border-r-0"
|
| 69 |
+
)}
|
| 70 |
+
>
|
| 71 |
+
<div className="flex h-16 items-center px-6 border-b border-slate-100/50">
|
| 72 |
+
<div className="flex items-center gap-2">
|
| 73 |
+
<div className="h-8 w-8 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20">
|
| 74 |
+
<span className="text-white font-bold font-mono">U</span>
|
| 75 |
+
</div>
|
| 76 |
+
<span className="text-xl font-bold tracking-tight text-slate-900">UrbanLens</span>
|
| 77 |
+
</div>
|
| 78 |
+
<span className="ml-auto rounded-full bg-blue-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-blue-600 ring-1 ring-blue-100">
|
| 79 |
+
{role}
|
| 80 |
+
</span>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<div className="flex flex-col justify-between h-[calc(100vh-4rem)] p-4">
|
| 84 |
+
<nav className="space-y-1">
|
| 85 |
+
{links.map((link) => {
|
| 86 |
+
const Icon = link.icon;
|
| 87 |
+
const isActive = pathname === link.href;
|
| 88 |
+
return (
|
| 89 |
+
<Link
|
| 90 |
+
key={link.href}
|
| 91 |
+
href={link.href}
|
| 92 |
+
onClick={() => setMobileOpen(false)}
|
| 93 |
+
className={cn(
|
| 94 |
+
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-200 group relative overflow-hidden",
|
| 95 |
+
isActive
|
| 96 |
+
? "bg-blue-50 text-blue-700 shadow-sm ring-1 ring-blue-100"
|
| 97 |
+
: "text-slate-500 hover:bg-slate-50 hover:text-slate-900"
|
| 98 |
+
)}
|
| 99 |
+
>
|
| 100 |
+
{isActive && (
|
| 101 |
+
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-blue-500 rounded-r-full" />
|
| 102 |
+
)}
|
| 103 |
+
<Icon className={cn("h-5 w-5 transition-transform group-hover:scale-110", isActive ? "text-blue-600" : "text-slate-400 group-hover:text-slate-600")} />
|
| 104 |
+
<span className={isActive ? "ml-1.5" : ""}>{link.label}</span>
|
| 105 |
+
</Link>
|
| 106 |
+
);
|
| 107 |
+
})}
|
| 108 |
+
</nav>
|
| 109 |
+
|
| 110 |
+
<div className="border-t border-slate-100 pt-4 space-y-1">
|
| 111 |
+
<button className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-slate-500 hover:bg-slate-50 hover:text-slate-900 transition-colors">
|
| 112 |
+
<Settings className="h-5 w-5 text-slate-400" />
|
| 113 |
+
Settings
|
| 114 |
+
</button>
|
| 115 |
+
<button
|
| 116 |
+
onClick={onLogout}
|
| 117 |
+
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-red-600 hover:bg-red-50 hover:text-red-700 transition-colors"
|
| 118 |
+
>
|
| 119 |
+
<LogOut className="h-5 w-5" />
|
| 120 |
+
Sign Out
|
| 121 |
+
</button>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
</aside>
|
| 125 |
+
</>
|
| 126 |
+
);
|
| 127 |
+
}
|
Frontend/components/ui/Loader.tsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
|
| 3 |
+
export function Loader({ size = "md", className = "" }: { size?: "sm" | "md" | "lg"; className?: string }) {
|
| 4 |
+
const sizeClasses = {
|
| 5 |
+
sm: "w-4 h-4 border-2",
|
| 6 |
+
md: "w-8 h-8 border-[3px]",
|
| 7 |
+
lg: "w-12 h-12 border-4",
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
return (
|
| 11 |
+
<div className={`flex items-center justify-center ${className}`}>
|
| 12 |
+
<div
|
| 13 |
+
className={`animate-spin rounded-full border-slate-200 border-t-blue-600 ${sizeClasses[size]}`}
|
| 14 |
+
></div>
|
| 15 |
+
</div>
|
| 16 |
+
);
|
| 17 |
+
}
|
Frontend/components/ui/Skeleton.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { cn } from "@/lib/utils";
|
| 2 |
+
|
| 3 |
+
export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
| 4 |
+
return (
|
| 5 |
+
<div
|
| 6 |
+
className={cn("animate-pulse rounded-md bg-slate-200/50", className)}
|
| 7 |
+
{...props}
|
| 8 |
+
/>
|
| 9 |
+
);
|
| 10 |
+
}
|
Frontend/hooks/useCachedFetch.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useCallback } from "react";
|
| 4 |
+
|
| 5 |
+
const cache = new Map<string, { data: any; timestamp: number }>();
|
| 6 |
+
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
| 7 |
+
const CACHE_KEY_PREFIX = "urbanlens_cache_";
|
| 8 |
+
|
| 9 |
+
// Helper to get full URL (duplicated logic, but safe for synchronous init)
|
| 10 |
+
const getFullUrl = (url: string) => {
|
| 11 |
+
const baseUrl = process.env.NEXT_PUBLIC_API_URL || "";
|
| 12 |
+
return url.startsWith("http") ? url : `${baseUrl}${url}`;
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
export function useCachedFetch<T>(url: string, options?: RequestInit) {
|
| 16 |
+
const fullUrl = url ? getFullUrl(url) : "";
|
| 17 |
+
|
| 18 |
+
// 1. Initialize logic: Try memory cache -> Try localStorage -> Default null
|
| 19 |
+
const [data, setData] = useState<T | null>(() => {
|
| 20 |
+
if (!fullUrl) return null; // Skip if no URL
|
| 21 |
+
// Try memory first (fastest)
|
| 22 |
+
if (cache.has(fullUrl)) {
|
| 23 |
+
return cache.get(fullUrl)!.data;
|
| 24 |
+
}
|
| 25 |
+
// Try localStorage (persistence)
|
| 26 |
+
if (typeof window !== "undefined") {
|
| 27 |
+
try {
|
| 28 |
+
const stored = localStorage.getItem(CACHE_KEY_PREFIX + fullUrl);
|
| 29 |
+
if (stored) {
|
| 30 |
+
const parsed = JSON.parse(stored);
|
| 31 |
+
// Hydrate memory cache while we're at it
|
| 32 |
+
cache.set(fullUrl, parsed);
|
| 33 |
+
return parsed.data;
|
| 34 |
+
}
|
| 35 |
+
} catch (e) {
|
| 36 |
+
console.warn("Cache parse error", e);
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
return null;
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
// Calculate generic initial loading state based on whether we found data
|
| 43 |
+
const [loading, setLoading] = useState(() => {
|
| 44 |
+
if (!fullUrl) return true; // Default to loading if waiting for URL? Or false?
|
| 45 |
+
// Actually if URL is empty, we are "idle". Let's say loading=true if we expect a URL eventuall?
|
| 46 |
+
// Consistently, if URL is missing, we are NOT loading data yet because we can't.
|
| 47 |
+
// Ideally loading should be false if URL is empty, but let's stick to simple logic:
|
| 48 |
+
const cached = cache.get(fullUrl);
|
| 49 |
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
| 50 |
+
return false;
|
| 51 |
+
}
|
| 52 |
+
return true;
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
const [error, setError] = useState<Error | null>(null);
|
| 56 |
+
|
| 57 |
+
const fetchData = useCallback(async (isRevalidating = false) => {
|
| 58 |
+
if (!fullUrl) return; // Skip fetch if no URL
|
| 59 |
+
|
| 60 |
+
const cached = cache.get(fullUrl);
|
| 61 |
+
const isCacheValid = cached && (Date.now() - cached.timestamp < CACHE_TTL);
|
| 62 |
+
|
| 63 |
+
// If we have valid cache and we are NOT forcing a revalidate, stopping here is an option
|
| 64 |
+
// BUT the user wants "background sync", so we proceeds to fetch unless completely fresh?
|
| 65 |
+
// Actually, "stale-while-revalidate" means we show cached, but fetch anyway.
|
| 66 |
+
|
| 67 |
+
if (!isRevalidating) {
|
| 68 |
+
if (isCacheValid) {
|
| 69 |
+
setLoading(false);
|
| 70 |
+
} else {
|
| 71 |
+
setLoading(true);
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
try {
|
| 76 |
+
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
|
| 77 |
+
const headers = {
|
| 78 |
+
"Content-Type": "application/json",
|
| 79 |
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
| 80 |
+
...options?.headers,
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
const res = await fetch(fullUrl, { ...options, headers });
|
| 84 |
+
|
| 85 |
+
if (!res.ok) {
|
| 86 |
+
// If 401/403, we might want to handle it (though api.ts usually does)
|
| 87 |
+
if (res.status === 401) localStorage.removeItem("token");
|
| 88 |
+
throw new Error(`Fetch error: ${res.status}`);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
const freshData = await res.json();
|
| 92 |
+
const cacheEntry = { data: freshData, timestamp: Date.now() };
|
| 93 |
+
|
| 94 |
+
// Update Memory
|
| 95 |
+
cache.set(fullUrl, cacheEntry);
|
| 96 |
+
|
| 97 |
+
// Update LocalStorage
|
| 98 |
+
if (typeof window !== "undefined") {
|
| 99 |
+
try {
|
| 100 |
+
localStorage.setItem(CACHE_KEY_PREFIX + fullUrl, JSON.stringify(cacheEntry));
|
| 101 |
+
} catch (e) {
|
| 102 |
+
console.warn("Quota exceeded likely", e);
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
setData(freshData);
|
| 107 |
+
setError(null);
|
| 108 |
+
} catch (err) {
|
| 109 |
+
console.error("Fetch failed:", err);
|
| 110 |
+
if (!data) setError(err as Error); // Only show error if no cached data
|
| 111 |
+
} finally {
|
| 112 |
+
if (!isRevalidating) setLoading(false);
|
| 113 |
+
}
|
| 114 |
+
}, [fullUrl, JSON.stringify(options)]);
|
| 115 |
+
|
| 116 |
+
useEffect(() => {
|
| 117 |
+
fetchData();
|
| 118 |
+
}, [fetchData]);
|
| 119 |
+
|
| 120 |
+
const revalidate = () => fetchData(true);
|
| 121 |
+
|
| 122 |
+
return { data, loading, error, revalidate };
|
| 123 |
+
}
|
Frontend/lib/api.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || "";
|
| 4 |
+
const REQUEST_TIMEOUT_MS = 30000;
|
| 5 |
+
const MAX_RETRIES = 2;
|
| 6 |
+
|
| 7 |
+
class ApiError extends Error {
|
| 8 |
+
status: number;
|
| 9 |
+
constructor(message: string, status: number) {
|
| 10 |
+
super(message);
|
| 11 |
+
this.status = status;
|
| 12 |
+
this.name = "ApiError";
|
| 13 |
+
}
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
async function fetchWithTimeout(
|
| 17 |
+
url: string,
|
| 18 |
+
options: RequestInit,
|
| 19 |
+
timeout: number,
|
| 20 |
+
): Promise<Response> {
|
| 21 |
+
const controller = new AbortController();
|
| 22 |
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
| 23 |
+
|
| 24 |
+
try {
|
| 25 |
+
const response = await fetch(url, {
|
| 26 |
+
...options,
|
| 27 |
+
signal: controller.signal,
|
| 28 |
+
});
|
| 29 |
+
return response;
|
| 30 |
+
} finally {
|
| 31 |
+
clearTimeout(timeoutId);
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export async function apiRequest<T>(
|
| 36 |
+
endpoint: string,
|
| 37 |
+
options: RequestInit = {},
|
| 38 |
+
retries = 0,
|
| 39 |
+
): Promise<T> {
|
| 40 |
+
const token =
|
| 41 |
+
typeof window !== "undefined" ? localStorage.getItem("token") : null;
|
| 42 |
+
|
| 43 |
+
if (!API_URL) {
|
| 44 |
+
throw new ApiError("API URL not configured", 500);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const headers: HeadersInit = {
|
| 48 |
+
"Content-Type": "application/json",
|
| 49 |
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
| 50 |
+
...options.headers,
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
try {
|
| 54 |
+
const res = await fetchWithTimeout(
|
| 55 |
+
`${API_URL}${endpoint}`,
|
| 56 |
+
{ ...options, headers },
|
| 57 |
+
REQUEST_TIMEOUT_MS,
|
| 58 |
+
);
|
| 59 |
+
|
| 60 |
+
if (res.status === 401 || res.status === 403) {
|
| 61 |
+
if (typeof window !== "undefined") {
|
| 62 |
+
localStorage.removeItem("token");
|
| 63 |
+
localStorage.removeItem("user");
|
| 64 |
+
window.location.href = "/signin";
|
| 65 |
+
}
|
| 66 |
+
throw new ApiError("Session expired. Please sign in again.", res.status);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
if (res.status >= 500 && retries < MAX_RETRIES) {
|
| 70 |
+
await new Promise((r) => setTimeout(r, 1000 * (retries + 1)));
|
| 71 |
+
return apiRequest<T>(endpoint, options, retries + 1);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
if (!res.ok) {
|
| 75 |
+
const data = await res.json().catch(() => ({}));
|
| 76 |
+
throw new ApiError(data.detail || "Request failed", res.status);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
if (res.status === 204) {
|
| 80 |
+
return {} as T;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
return res.json();
|
| 84 |
+
} catch (error) {
|
| 85 |
+
if (error instanceof ApiError) throw error;
|
| 86 |
+
|
| 87 |
+
if (error instanceof Error) {
|
| 88 |
+
if (error.name === "AbortError") {
|
| 89 |
+
throw new ApiError("Request timed out. Please try again.", 408);
|
| 90 |
+
}
|
| 91 |
+
if (retries < MAX_RETRIES) {
|
| 92 |
+
await new Promise((r) => setTimeout(r, 1000 * (retries + 1)));
|
| 93 |
+
return apiRequest<T>(endpoint, options, retries + 1);
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
throw new ApiError("Network error. Please check your connection.", 0);
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
export async function apiGet<T>(endpoint: string): Promise<T> {
|
| 102 |
+
return apiRequest<T>(endpoint, { method: "GET" });
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
export async function apiPost<T>(endpoint: string, body?: unknown): Promise<T> {
|
| 106 |
+
return apiRequest<T>(endpoint, {
|
| 107 |
+
method: "POST",
|
| 108 |
+
body: body ? JSON.stringify(body) : undefined,
|
| 109 |
+
});
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
export async function apiPatch<T>(
|
| 113 |
+
endpoint: string,
|
| 114 |
+
body?: unknown,
|
| 115 |
+
): Promise<T> {
|
| 116 |
+
return apiRequest<T>(endpoint, {
|
| 117 |
+
method: "PATCH",
|
| 118 |
+
body: body ? JSON.stringify(body) : undefined,
|
| 119 |
+
});
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
export async function apiDelete(endpoint: string): Promise<void> {
|
| 123 |
+
await apiRequest(endpoint, { method: "DELETE" });
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
export { ApiError };
|
Frontend/lib/utils.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type ClassValue, clsx } from "clsx";
|
| 2 |
+
import { twMerge } from "tailwind-merge";
|
| 3 |
+
|
| 4 |
+
export function cn(...inputs: ClassValue[]) {
|
| 5 |
+
return twMerge(clsx(inputs));
|
| 6 |
+
}
|
Frontend/next-env.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="next" />
|
| 2 |
+
/// <reference types="next/image-types/global" />
|
| 3 |
+
import "./.next/dev/types/routes.d.ts";
|
| 4 |
+
|
| 5 |
+
// NOTE: This file should not be edited
|
| 6 |
+
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
Frontend/next.config.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
| 1 |
import type { NextConfig } from "next";
|
| 2 |
|
| 3 |
const nextConfig: NextConfig = {
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
| 5 |
};
|
| 6 |
|
| 7 |
export default nextConfig;
|
|
|
|
| 1 |
import type { NextConfig } from "next";
|
| 2 |
|
| 3 |
const nextConfig: NextConfig = {
|
| 4 |
+
reactStrictMode: false,
|
| 5 |
+
experimental: {
|
| 6 |
+
optimizePackageImports: ['lucide-react', '@supabase/supabase-js'],
|
| 7 |
+
},
|
| 8 |
};
|
| 9 |
|
| 10 |
export default nextConfig;
|
Frontend/package-lock.json
CHANGED
|
@@ -8,9 +8,14 @@
|
|
| 8 |
"name": "frontend",
|
| 9 |
"version": "0.1.0",
|
| 10 |
"dependencies": {
|
| 11 |
-
"
|
|
|
|
|
|
|
|
|
|
| 12 |
"react": "19.2.3",
|
| 13 |
-
"react-dom": "19.2.3"
|
|
|
|
|
|
|
| 14 |
},
|
| 15 |
"devDependencies": {
|
| 16 |
"@tailwindcss/postcss": "^4",
|
|
@@ -18,7 +23,7 @@
|
|
| 18 |
"@types/react": "^19",
|
| 19 |
"@types/react-dom": "^19",
|
| 20 |
"eslint": "^9",
|
| 21 |
-
"eslint-config-next": "16.1.
|
| 22 |
"tailwindcss": "^4",
|
| 23 |
"typescript": "^5"
|
| 24 |
}
|
|
@@ -37,9 +42,9 @@
|
|
| 37 |
}
|
| 38 |
},
|
| 39 |
"node_modules/@babel/code-frame": {
|
| 40 |
-
"version": "7.
|
| 41 |
-
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.
|
| 42 |
-
"integrity": "sha512-
|
| 43 |
"dev": true,
|
| 44 |
"license": "MIT",
|
| 45 |
"dependencies": {
|
|
@@ -52,9 +57,9 @@
|
|
| 52 |
}
|
| 53 |
},
|
| 54 |
"node_modules/@babel/compat-data": {
|
| 55 |
-
"version": "7.
|
| 56 |
-
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.
|
| 57 |
-
"integrity": "sha512-
|
| 58 |
"dev": true,
|
| 59 |
"license": "MIT",
|
| 60 |
"engines": {
|
|
@@ -62,21 +67,21 @@
|
|
| 62 |
}
|
| 63 |
},
|
| 64 |
"node_modules/@babel/core": {
|
| 65 |
-
"version": "7.
|
| 66 |
-
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.
|
| 67 |
-
"integrity": "sha512-
|
| 68 |
"dev": true,
|
| 69 |
"license": "MIT",
|
| 70 |
"dependencies": {
|
| 71 |
-
"@babel/code-frame": "^7.
|
| 72 |
-
"@babel/generator": "^7.
|
| 73 |
"@babel/helper-compilation-targets": "^7.28.6",
|
| 74 |
"@babel/helper-module-transforms": "^7.28.6",
|
| 75 |
"@babel/helpers": "^7.28.6",
|
| 76 |
-
"@babel/parser": "^7.
|
| 77 |
"@babel/template": "^7.28.6",
|
| 78 |
-
"@babel/traverse": "^7.
|
| 79 |
-
"@babel/types": "^7.
|
| 80 |
"@jridgewell/remapping": "^2.3.5",
|
| 81 |
"convert-source-map": "^2.0.0",
|
| 82 |
"debug": "^4.1.0",
|
|
@@ -93,14 +98,14 @@
|
|
| 93 |
}
|
| 94 |
},
|
| 95 |
"node_modules/@babel/generator": {
|
| 96 |
-
"version": "7.
|
| 97 |
-
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.
|
| 98 |
-
"integrity": "sha512-
|
| 99 |
"dev": true,
|
| 100 |
"license": "MIT",
|
| 101 |
"dependencies": {
|
| 102 |
-
"@babel/parser": "^7.
|
| 103 |
-
"@babel/types": "^7.
|
| 104 |
"@jridgewell/gen-mapping": "^0.3.12",
|
| 105 |
"@jridgewell/trace-mapping": "^0.3.28",
|
| 106 |
"jsesc": "^3.0.2"
|
|
@@ -213,13 +218,13 @@
|
|
| 213 |
}
|
| 214 |
},
|
| 215 |
"node_modules/@babel/parser": {
|
| 216 |
-
"version": "7.
|
| 217 |
-
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.
|
| 218 |
-
"integrity": "sha512-
|
| 219 |
"dev": true,
|
| 220 |
"license": "MIT",
|
| 221 |
"dependencies": {
|
| 222 |
-
"@babel/types": "^7.
|
| 223 |
},
|
| 224 |
"bin": {
|
| 225 |
"parser": "bin/babel-parser.js"
|
|
@@ -244,18 +249,18 @@
|
|
| 244 |
}
|
| 245 |
},
|
| 246 |
"node_modules/@babel/traverse": {
|
| 247 |
-
"version": "7.
|
| 248 |
-
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.
|
| 249 |
-
"integrity": "sha512-
|
| 250 |
"dev": true,
|
| 251 |
"license": "MIT",
|
| 252 |
"dependencies": {
|
| 253 |
-
"@babel/code-frame": "^7.
|
| 254 |
-
"@babel/generator": "^7.
|
| 255 |
"@babel/helper-globals": "^7.28.0",
|
| 256 |
-
"@babel/parser": "^7.
|
| 257 |
"@babel/template": "^7.28.6",
|
| 258 |
-
"@babel/types": "^7.
|
| 259 |
"debug": "^4.3.1"
|
| 260 |
},
|
| 261 |
"engines": {
|
|
@@ -263,9 +268,9 @@
|
|
| 263 |
}
|
| 264 |
},
|
| 265 |
"node_modules/@babel/types": {
|
| 266 |
-
"version": "7.
|
| 267 |
-
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.
|
| 268 |
-
"integrity": "sha512-
|
| 269 |
"dev": true,
|
| 270 |
"license": "MIT",
|
| 271 |
"dependencies": {
|
|
@@ -1035,15 +1040,15 @@
|
|
| 1035 |
}
|
| 1036 |
},
|
| 1037 |
"node_modules/@next/env": {
|
| 1038 |
-
"version": "16.1.
|
| 1039 |
-
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.
|
| 1040 |
-
"integrity": "sha512-
|
| 1041 |
"license": "MIT"
|
| 1042 |
},
|
| 1043 |
"node_modules/@next/eslint-plugin-next": {
|
| 1044 |
-
"version": "16.1.
|
| 1045 |
-
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.
|
| 1046 |
-
"integrity": "sha512-/
|
| 1047 |
"dev": true,
|
| 1048 |
"license": "MIT",
|
| 1049 |
"dependencies": {
|
|
@@ -1051,9 +1056,9 @@
|
|
| 1051 |
}
|
| 1052 |
},
|
| 1053 |
"node_modules/@next/swc-darwin-arm64": {
|
| 1054 |
-
"version": "16.1.
|
| 1055 |
-
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.
|
| 1056 |
-
"integrity": "sha512-
|
| 1057 |
"cpu": [
|
| 1058 |
"arm64"
|
| 1059 |
],
|
|
@@ -1067,9 +1072,9 @@
|
|
| 1067 |
}
|
| 1068 |
},
|
| 1069 |
"node_modules/@next/swc-darwin-x64": {
|
| 1070 |
-
"version": "16.1.
|
| 1071 |
-
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.
|
| 1072 |
-
"integrity": "sha512-
|
| 1073 |
"cpu": [
|
| 1074 |
"x64"
|
| 1075 |
],
|
|
@@ -1083,9 +1088,9 @@
|
|
| 1083 |
}
|
| 1084 |
},
|
| 1085 |
"node_modules/@next/swc-linux-arm64-gnu": {
|
| 1086 |
-
"version": "16.1.
|
| 1087 |
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.
|
| 1088 |
-
"integrity": "sha512-
|
| 1089 |
"cpu": [
|
| 1090 |
"arm64"
|
| 1091 |
],
|
|
@@ -1099,9 +1104,9 @@
|
|
| 1099 |
}
|
| 1100 |
},
|
| 1101 |
"node_modules/@next/swc-linux-arm64-musl": {
|
| 1102 |
-
"version": "16.1.
|
| 1103 |
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.
|
| 1104 |
-
"integrity": "sha512-
|
| 1105 |
"cpu": [
|
| 1106 |
"arm64"
|
| 1107 |
],
|
|
@@ -1115,9 +1120,9 @@
|
|
| 1115 |
}
|
| 1116 |
},
|
| 1117 |
"node_modules/@next/swc-linux-x64-gnu": {
|
| 1118 |
-
"version": "16.1.
|
| 1119 |
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.
|
| 1120 |
-
"integrity": "sha512-
|
| 1121 |
"cpu": [
|
| 1122 |
"x64"
|
| 1123 |
],
|
|
@@ -1131,9 +1136,9 @@
|
|
| 1131 |
}
|
| 1132 |
},
|
| 1133 |
"node_modules/@next/swc-linux-x64-musl": {
|
| 1134 |
-
"version": "16.1.
|
| 1135 |
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.
|
| 1136 |
-
"integrity": "sha512-
|
| 1137 |
"cpu": [
|
| 1138 |
"x64"
|
| 1139 |
],
|
|
@@ -1147,9 +1152,9 @@
|
|
| 1147 |
}
|
| 1148 |
},
|
| 1149 |
"node_modules/@next/swc-win32-arm64-msvc": {
|
| 1150 |
-
"version": "16.1.
|
| 1151 |
-
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.
|
| 1152 |
-
"integrity": "sha512-
|
| 1153 |
"cpu": [
|
| 1154 |
"arm64"
|
| 1155 |
],
|
|
@@ -1163,9 +1168,9 @@
|
|
| 1163 |
}
|
| 1164 |
},
|
| 1165 |
"node_modules/@next/swc-win32-x64-msvc": {
|
| 1166 |
-
"version": "16.1.
|
| 1167 |
-
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.
|
| 1168 |
-
"integrity": "sha512-
|
| 1169 |
"cpu": [
|
| 1170 |
"x64"
|
| 1171 |
],
|
|
@@ -1226,6 +1231,42 @@
|
|
| 1226 |
"node": ">=12.4.0"
|
| 1227 |
}
|
| 1228 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1229 |
"node_modules/@rtsao/scc": {
|
| 1230 |
"version": "1.1.0",
|
| 1231 |
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
|
@@ -1233,6 +1274,98 @@
|
|
| 1233 |
"dev": true,
|
| 1234 |
"license": "MIT"
|
| 1235 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1236 |
"node_modules/@swc/helpers": {
|
| 1237 |
"version": "0.5.15",
|
| 1238 |
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
|
@@ -1524,6 +1657,69 @@
|
|
| 1524 |
"tslib": "^2.4.0"
|
| 1525 |
}
|
| 1526 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1527 |
"node_modules/@types/estree": {
|
| 1528 |
"version": "1.0.8",
|
| 1529 |
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
|
@@ -1546,20 +1742,25 @@
|
|
| 1546 |
"license": "MIT"
|
| 1547 |
},
|
| 1548 |
"node_modules/@types/node": {
|
| 1549 |
-
"version": "20.19.
|
| 1550 |
-
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.
|
| 1551 |
-
"integrity": "sha512-
|
| 1552 |
-
"dev": true,
|
| 1553 |
"license": "MIT",
|
| 1554 |
"dependencies": {
|
| 1555 |
"undici-types": "~6.21.0"
|
| 1556 |
}
|
| 1557 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1558 |
"node_modules/@types/react": {
|
| 1559 |
-
"version": "19.2.
|
| 1560 |
-
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.
|
| 1561 |
-
"integrity": "sha512-
|
| 1562 |
-
"
|
| 1563 |
"license": "MIT",
|
| 1564 |
"dependencies": {
|
| 1565 |
"csstype": "^3.2.2"
|
|
@@ -1575,18 +1776,33 @@
|
|
| 1575 |
"@types/react": "^19.2.0"
|
| 1576 |
}
|
| 1577 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1578 |
"node_modules/@typescript-eslint/eslint-plugin": {
|
| 1579 |
-
"version": "8.
|
| 1580 |
-
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.
|
| 1581 |
-
"integrity": "sha512-
|
| 1582 |
"dev": true,
|
| 1583 |
"license": "MIT",
|
| 1584 |
"dependencies": {
|
| 1585 |
"@eslint-community/regexpp": "^4.12.2",
|
| 1586 |
-
"@typescript-eslint/scope-manager": "8.
|
| 1587 |
-
"@typescript-eslint/type-utils": "8.
|
| 1588 |
-
"@typescript-eslint/utils": "8.
|
| 1589 |
-
"@typescript-eslint/visitor-keys": "8.
|
| 1590 |
"ignore": "^7.0.5",
|
| 1591 |
"natural-compare": "^1.4.0",
|
| 1592 |
"ts-api-utils": "^2.4.0"
|
|
@@ -1599,7 +1815,7 @@
|
|
| 1599 |
"url": "https://opencollective.com/typescript-eslint"
|
| 1600 |
},
|
| 1601 |
"peerDependencies": {
|
| 1602 |
-
"@typescript-eslint/parser": "^8.
|
| 1603 |
"eslint": "^8.57.0 || ^9.0.0",
|
| 1604 |
"typescript": ">=4.8.4 <6.0.0"
|
| 1605 |
}
|
|
@@ -1615,16 +1831,16 @@
|
|
| 1615 |
}
|
| 1616 |
},
|
| 1617 |
"node_modules/@typescript-eslint/parser": {
|
| 1618 |
-
"version": "8.
|
| 1619 |
-
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.
|
| 1620 |
-
"integrity": "sha512-
|
| 1621 |
"dev": true,
|
| 1622 |
"license": "MIT",
|
| 1623 |
"dependencies": {
|
| 1624 |
-
"@typescript-eslint/scope-manager": "8.
|
| 1625 |
-
"@typescript-eslint/types": "8.
|
| 1626 |
-
"@typescript-eslint/typescript-estree": "8.
|
| 1627 |
-
"@typescript-eslint/visitor-keys": "8.
|
| 1628 |
"debug": "^4.4.3"
|
| 1629 |
},
|
| 1630 |
"engines": {
|
|
@@ -1640,14 +1856,14 @@
|
|
| 1640 |
}
|
| 1641 |
},
|
| 1642 |
"node_modules/@typescript-eslint/project-service": {
|
| 1643 |
-
"version": "8.
|
| 1644 |
-
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.
|
| 1645 |
-
"integrity": "sha512-
|
| 1646 |
"dev": true,
|
| 1647 |
"license": "MIT",
|
| 1648 |
"dependencies": {
|
| 1649 |
-
"@typescript-eslint/tsconfig-utils": "^8.
|
| 1650 |
-
"@typescript-eslint/types": "^8.
|
| 1651 |
"debug": "^4.4.3"
|
| 1652 |
},
|
| 1653 |
"engines": {
|
|
@@ -1662,14 +1878,14 @@
|
|
| 1662 |
}
|
| 1663 |
},
|
| 1664 |
"node_modules/@typescript-eslint/scope-manager": {
|
| 1665 |
-
"version": "8.
|
| 1666 |
-
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.
|
| 1667 |
-
"integrity": "sha512-
|
| 1668 |
"dev": true,
|
| 1669 |
"license": "MIT",
|
| 1670 |
"dependencies": {
|
| 1671 |
-
"@typescript-eslint/types": "8.
|
| 1672 |
-
"@typescript-eslint/visitor-keys": "8.
|
| 1673 |
},
|
| 1674 |
"engines": {
|
| 1675 |
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
|
@@ -1680,9 +1896,9 @@
|
|
| 1680 |
}
|
| 1681 |
},
|
| 1682 |
"node_modules/@typescript-eslint/tsconfig-utils": {
|
| 1683 |
-
"version": "8.
|
| 1684 |
-
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.
|
| 1685 |
-
"integrity": "sha512-
|
| 1686 |
"dev": true,
|
| 1687 |
"license": "MIT",
|
| 1688 |
"engines": {
|
|
@@ -1697,15 +1913,15 @@
|
|
| 1697 |
}
|
| 1698 |
},
|
| 1699 |
"node_modules/@typescript-eslint/type-utils": {
|
| 1700 |
-
"version": "8.
|
| 1701 |
-
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.
|
| 1702 |
-
"integrity": "sha512-
|
| 1703 |
"dev": true,
|
| 1704 |
"license": "MIT",
|
| 1705 |
"dependencies": {
|
| 1706 |
-
"@typescript-eslint/types": "8.
|
| 1707 |
-
"@typescript-eslint/typescript-estree": "8.
|
| 1708 |
-
"@typescript-eslint/utils": "8.
|
| 1709 |
"debug": "^4.4.3",
|
| 1710 |
"ts-api-utils": "^2.4.0"
|
| 1711 |
},
|
|
@@ -1722,9 +1938,9 @@
|
|
| 1722 |
}
|
| 1723 |
},
|
| 1724 |
"node_modules/@typescript-eslint/types": {
|
| 1725 |
-
"version": "8.
|
| 1726 |
-
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.
|
| 1727 |
-
"integrity": "sha512-
|
| 1728 |
"dev": true,
|
| 1729 |
"license": "MIT",
|
| 1730 |
"engines": {
|
|
@@ -1736,16 +1952,16 @@
|
|
| 1736 |
}
|
| 1737 |
},
|
| 1738 |
"node_modules/@typescript-eslint/typescript-estree": {
|
| 1739 |
-
"version": "8.
|
| 1740 |
-
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.
|
| 1741 |
-
"integrity": "sha512-
|
| 1742 |
"dev": true,
|
| 1743 |
"license": "MIT",
|
| 1744 |
"dependencies": {
|
| 1745 |
-
"@typescript-eslint/project-service": "8.
|
| 1746 |
-
"@typescript-eslint/tsconfig-utils": "8.
|
| 1747 |
-
"@typescript-eslint/types": "8.
|
| 1748 |
-
"@typescript-eslint/visitor-keys": "8.
|
| 1749 |
"debug": "^4.4.3",
|
| 1750 |
"minimatch": "^9.0.5",
|
| 1751 |
"semver": "^7.7.3",
|
|
@@ -1790,9 +2006,9 @@
|
|
| 1790 |
}
|
| 1791 |
},
|
| 1792 |
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
|
| 1793 |
-
"version": "7.7.
|
| 1794 |
-
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.
|
| 1795 |
-
"integrity": "sha512-
|
| 1796 |
"dev": true,
|
| 1797 |
"license": "ISC",
|
| 1798 |
"bin": {
|
|
@@ -1803,16 +2019,16 @@
|
|
| 1803 |
}
|
| 1804 |
},
|
| 1805 |
"node_modules/@typescript-eslint/utils": {
|
| 1806 |
-
"version": "8.
|
| 1807 |
-
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.
|
| 1808 |
-
"integrity": "sha512-
|
| 1809 |
"dev": true,
|
| 1810 |
"license": "MIT",
|
| 1811 |
"dependencies": {
|
| 1812 |
"@eslint-community/eslint-utils": "^4.9.1",
|
| 1813 |
-
"@typescript-eslint/scope-manager": "8.
|
| 1814 |
-
"@typescript-eslint/types": "8.
|
| 1815 |
-
"@typescript-eslint/typescript-estree": "8.
|
| 1816 |
},
|
| 1817 |
"engines": {
|
| 1818 |
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
|
@@ -1827,13 +2043,13 @@
|
|
| 1827 |
}
|
| 1828 |
},
|
| 1829 |
"node_modules/@typescript-eslint/visitor-keys": {
|
| 1830 |
-
"version": "8.
|
| 1831 |
-
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.
|
| 1832 |
-
"integrity": "sha512-
|
| 1833 |
"dev": true,
|
| 1834 |
"license": "MIT",
|
| 1835 |
"dependencies": {
|
| 1836 |
-
"@typescript-eslint/types": "8.
|
| 1837 |
"eslint-visitor-keys": "^4.2.1"
|
| 1838 |
},
|
| 1839 |
"engines": {
|
|
@@ -2407,9 +2623,9 @@
|
|
| 2407 |
"license": "MIT"
|
| 2408 |
},
|
| 2409 |
"node_modules/baseline-browser-mapping": {
|
| 2410 |
-
"version": "2.9.
|
| 2411 |
-
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.
|
| 2412 |
-
"integrity": "sha512-
|
| 2413 |
"license": "Apache-2.0",
|
| 2414 |
"bin": {
|
| 2415 |
"baseline-browser-mapping": "dist/cli.js"
|
|
@@ -2534,9 +2750,9 @@
|
|
| 2534 |
}
|
| 2535 |
},
|
| 2536 |
"node_modules/caniuse-lite": {
|
| 2537 |
-
"version": "1.0.
|
| 2538 |
-
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.
|
| 2539 |
-
"integrity": "sha512-
|
| 2540 |
"funding": [
|
| 2541 |
{
|
| 2542 |
"type": "opencollective",
|
|
@@ -2576,6 +2792,15 @@
|
|
| 2576 |
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
| 2577 |
"license": "MIT"
|
| 2578 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2579 |
"node_modules/color-convert": {
|
| 2580 |
"version": "2.0.1",
|
| 2581 |
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
|
@@ -2629,9 +2854,130 @@
|
|
| 2629 |
"version": "3.2.3",
|
| 2630 |
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
| 2631 |
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
| 2632 |
-
"
|
| 2633 |
"license": "MIT"
|
| 2634 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2635 |
"node_modules/damerau-levenshtein": {
|
| 2636 |
"version": "1.0.8",
|
| 2637 |
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
|
@@ -2711,6 +3057,12 @@
|
|
| 2711 |
}
|
| 2712 |
}
|
| 2713 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2714 |
"node_modules/deep-is": {
|
| 2715 |
"version": "0.1.4",
|
| 2716 |
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
|
@@ -2793,9 +3145,9 @@
|
|
| 2793 |
}
|
| 2794 |
},
|
| 2795 |
"node_modules/electron-to-chromium": {
|
| 2796 |
-
"version": "1.5.
|
| 2797 |
-
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.
|
| 2798 |
-
"integrity": "sha512-
|
| 2799 |
"dev": true,
|
| 2800 |
"license": "ISC"
|
| 2801 |
},
|
|
@@ -2807,14 +3159,14 @@
|
|
| 2807 |
"license": "MIT"
|
| 2808 |
},
|
| 2809 |
"node_modules/enhanced-resolve": {
|
| 2810 |
-
"version": "5.
|
| 2811 |
-
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.
|
| 2812 |
-
"integrity": "sha512-
|
| 2813 |
"dev": true,
|
| 2814 |
"license": "MIT",
|
| 2815 |
"dependencies": {
|
| 2816 |
"graceful-fs": "^4.2.4",
|
| 2817 |
-
"tapable": "^2.
|
| 2818 |
},
|
| 2819 |
"engines": {
|
| 2820 |
"node": ">=10.13.0"
|
|
@@ -2997,6 +3349,16 @@
|
|
| 2997 |
"url": "https://github.com/sponsors/ljharb"
|
| 2998 |
}
|
| 2999 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3000 |
"node_modules/escalade": {
|
| 3001 |
"version": "3.2.0",
|
| 3002 |
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
|
@@ -3081,13 +3443,13 @@
|
|
| 3081 |
}
|
| 3082 |
},
|
| 3083 |
"node_modules/eslint-config-next": {
|
| 3084 |
-
"version": "16.1.
|
| 3085 |
-
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.
|
| 3086 |
-
"integrity": "sha512-
|
| 3087 |
"dev": true,
|
| 3088 |
"license": "MIT",
|
| 3089 |
"dependencies": {
|
| 3090 |
-
"@next/eslint-plugin-next": "16.1.
|
| 3091 |
"eslint-import-resolver-node": "^0.3.6",
|
| 3092 |
"eslint-import-resolver-typescript": "^3.5.2",
|
| 3093 |
"eslint-plugin-import": "^2.32.0",
|
|
@@ -3444,6 +3806,12 @@
|
|
| 3444 |
"node": ">=0.10.0"
|
| 3445 |
}
|
| 3446 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3447 |
"node_modules/fast-deep-equal": {
|
| 3448 |
"version": "3.1.3",
|
| 3449 |
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
|
@@ -3704,9 +4072,9 @@
|
|
| 3704 |
}
|
| 3705 |
},
|
| 3706 |
"node_modules/get-tsconfig": {
|
| 3707 |
-
"version": "4.13.
|
| 3708 |
-
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.
|
| 3709 |
-
"integrity": "sha512-
|
| 3710 |
"dev": true,
|
| 3711 |
"license": "MIT",
|
| 3712 |
"dependencies": {
|
|
@@ -3890,6 +4258,15 @@
|
|
| 3890 |
"hermes-estree": "0.25.1"
|
| 3891 |
}
|
| 3892 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3893 |
"node_modules/ignore": {
|
| 3894 |
"version": "5.3.2",
|
| 3895 |
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
|
@@ -3900,6 +4277,16 @@
|
|
| 3900 |
"node": ">= 4"
|
| 3901 |
}
|
| 3902 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3903 |
"node_modules/import-fresh": {
|
| 3904 |
"version": "3.3.1",
|
| 3905 |
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
|
@@ -3942,6 +4329,15 @@
|
|
| 3942 |
"node": ">= 0.4"
|
| 3943 |
}
|
| 3944 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3945 |
"node_modules/is-array-buffer": {
|
| 3946 |
"version": "3.0.5",
|
| 3947 |
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
|
@@ -4024,9 +4420,9 @@
|
|
| 4024 |
}
|
| 4025 |
},
|
| 4026 |
"node_modules/is-bun-module/node_modules/semver": {
|
| 4027 |
-
"version": "7.7.
|
| 4028 |
-
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.
|
| 4029 |
-
"integrity": "sha512-
|
| 4030 |
"dev": true,
|
| 4031 |
"license": "ISC",
|
| 4032 |
"bin": {
|
|
@@ -4833,6 +5229,15 @@
|
|
| 4833 |
"yallist": "^3.0.2"
|
| 4834 |
}
|
| 4835 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4836 |
"node_modules/magic-string": {
|
| 4837 |
"version": "0.30.21",
|
| 4838 |
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
|
@@ -4949,12 +5354,12 @@
|
|
| 4949 |
"license": "MIT"
|
| 4950 |
},
|
| 4951 |
"node_modules/next": {
|
| 4952 |
-
"version": "16.1.
|
| 4953 |
-
"resolved": "https://registry.npmjs.org/next/-/next-16.1.
|
| 4954 |
-
"integrity": "sha512-
|
| 4955 |
"license": "MIT",
|
| 4956 |
"dependencies": {
|
| 4957 |
-
"@next/env": "16.1.
|
| 4958 |
"@swc/helpers": "0.5.15",
|
| 4959 |
"baseline-browser-mapping": "^2.8.3",
|
| 4960 |
"caniuse-lite": "^1.0.30001579",
|
|
@@ -4968,14 +5373,14 @@
|
|
| 4968 |
"node": ">=20.9.0"
|
| 4969 |
},
|
| 4970 |
"optionalDependencies": {
|
| 4971 |
-
"@next/swc-darwin-arm64": "16.1.
|
| 4972 |
-
"@next/swc-darwin-x64": "16.1.
|
| 4973 |
-
"@next/swc-linux-arm64-gnu": "16.1.
|
| 4974 |
-
"@next/swc-linux-arm64-musl": "16.1.
|
| 4975 |
-
"@next/swc-linux-x64-gnu": "16.1.
|
| 4976 |
-
"@next/swc-linux-x64-musl": "16.1.
|
| 4977 |
-
"@next/swc-win32-arm64-msvc": "16.1.
|
| 4978 |
-
"@next/swc-win32-x64-msvc": "16.1.
|
| 4979 |
"sharp": "^0.34.4"
|
| 4980 |
},
|
| 4981 |
"peerDependencies": {
|
|
@@ -5403,9 +5808,76 @@
|
|
| 5403 |
"version": "16.13.1",
|
| 5404 |
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
| 5405 |
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
| 5406 |
-
"dev": true,
|
| 5407 |
"license": "MIT"
|
| 5408 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5409 |
"node_modules/reflect.getprototypeof": {
|
| 5410 |
"version": "1.0.10",
|
| 5411 |
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
|
@@ -5450,6 +5922,12 @@
|
|
| 5450 |
"url": "https://github.com/sponsors/ljharb"
|
| 5451 |
}
|
| 5452 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5453 |
"node_modules/resolve": {
|
| 5454 |
"version": "1.22.11",
|
| 5455 |
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
|
@@ -5692,9 +6170,9 @@
|
|
| 5692 |
}
|
| 5693 |
},
|
| 5694 |
"node_modules/sharp/node_modules/semver": {
|
| 5695 |
-
"version": "7.7.
|
| 5696 |
-
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.
|
| 5697 |
-
"integrity": "sha512-
|
| 5698 |
"license": "ISC",
|
| 5699 |
"optional": true,
|
| 5700 |
"bin": {
|
|
@@ -6018,6 +6496,16 @@
|
|
| 6018 |
"url": "https://github.com/sponsors/ljharb"
|
| 6019 |
}
|
| 6020 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6021 |
"node_modules/tailwindcss": {
|
| 6022 |
"version": "4.1.18",
|
| 6023 |
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
|
@@ -6039,6 +6527,12 @@
|
|
| 6039 |
"url": "https://opencollective.com/webpack"
|
| 6040 |
}
|
| 6041 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6042 |
"node_modules/tinyglobby": {
|
| 6043 |
"version": "0.2.15",
|
| 6044 |
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
|
@@ -6251,16 +6745,16 @@
|
|
| 6251 |
}
|
| 6252 |
},
|
| 6253 |
"node_modules/typescript-eslint": {
|
| 6254 |
-
"version": "8.
|
| 6255 |
-
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.
|
| 6256 |
-
"integrity": "sha512-
|
| 6257 |
"dev": true,
|
| 6258 |
"license": "MIT",
|
| 6259 |
"dependencies": {
|
| 6260 |
-
"@typescript-eslint/eslint-plugin": "8.
|
| 6261 |
-
"@typescript-eslint/parser": "8.
|
| 6262 |
-
"@typescript-eslint/typescript-estree": "8.
|
| 6263 |
-
"@typescript-eslint/utils": "8.
|
| 6264 |
},
|
| 6265 |
"engines": {
|
| 6266 |
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
|
@@ -6297,7 +6791,6 @@
|
|
| 6297 |
"version": "6.21.0",
|
| 6298 |
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
| 6299 |
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
| 6300 |
-
"dev": true,
|
| 6301 |
"license": "MIT"
|
| 6302 |
},
|
| 6303 |
"node_modules/unrs-resolver": {
|
|
@@ -6376,6 +6869,37 @@
|
|
| 6376 |
"punycode": "^2.1.0"
|
| 6377 |
}
|
| 6378 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6379 |
"node_modules/which": {
|
| 6380 |
"version": "2.0.2",
|
| 6381 |
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
|
@@ -6460,9 +6984,9 @@
|
|
| 6460 |
}
|
| 6461 |
},
|
| 6462 |
"node_modules/which-typed-array": {
|
| 6463 |
-
"version": "1.1.
|
| 6464 |
-
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.
|
| 6465 |
-
"integrity": "sha512-
|
| 6466 |
"dev": true,
|
| 6467 |
"license": "MIT",
|
| 6468 |
"dependencies": {
|
|
@@ -6491,6 +7015,27 @@
|
|
| 6491 |
"node": ">=0.10.0"
|
| 6492 |
}
|
| 6493 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6494 |
"node_modules/yallist": {
|
| 6495 |
"version": "3.1.1",
|
| 6496 |
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
|
@@ -6512,9 +7057,9 @@
|
|
| 6512 |
}
|
| 6513 |
},
|
| 6514 |
"node_modules/zod": {
|
| 6515 |
-
"version": "4.3.
|
| 6516 |
-
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.
|
| 6517 |
-
"integrity": "sha512-
|
| 6518 |
"dev": true,
|
| 6519 |
"license": "MIT",
|
| 6520 |
"funding": {
|
|
|
|
| 8 |
"name": "frontend",
|
| 9 |
"version": "0.1.0",
|
| 10 |
"dependencies": {
|
| 11 |
+
"@supabase/supabase-js": "^2.90.1",
|
| 12 |
+
"clsx": "^2.1.1",
|
| 13 |
+
"lucide-react": "^0.562.0",
|
| 14 |
+
"next": "16.1.1",
|
| 15 |
"react": "19.2.3",
|
| 16 |
+
"react-dom": "19.2.3",
|
| 17 |
+
"recharts": "^3.6.0",
|
| 18 |
+
"tailwind-merge": "^3.4.0"
|
| 19 |
},
|
| 20 |
"devDependencies": {
|
| 21 |
"@tailwindcss/postcss": "^4",
|
|
|
|
| 23 |
"@types/react": "^19",
|
| 24 |
"@types/react-dom": "^19",
|
| 25 |
"eslint": "^9",
|
| 26 |
+
"eslint-config-next": "16.1.1",
|
| 27 |
"tailwindcss": "^4",
|
| 28 |
"typescript": "^5"
|
| 29 |
}
|
|
|
|
| 42 |
}
|
| 43 |
},
|
| 44 |
"node_modules/@babel/code-frame": {
|
| 45 |
+
"version": "7.28.6",
|
| 46 |
+
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
|
| 47 |
+
"integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==",
|
| 48 |
"dev": true,
|
| 49 |
"license": "MIT",
|
| 50 |
"dependencies": {
|
|
|
|
| 57 |
}
|
| 58 |
},
|
| 59 |
"node_modules/@babel/compat-data": {
|
| 60 |
+
"version": "7.28.6",
|
| 61 |
+
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz",
|
| 62 |
+
"integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==",
|
| 63 |
"dev": true,
|
| 64 |
"license": "MIT",
|
| 65 |
"engines": {
|
|
|
|
| 67 |
}
|
| 68 |
},
|
| 69 |
"node_modules/@babel/core": {
|
| 70 |
+
"version": "7.28.6",
|
| 71 |
+
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
|
| 72 |
+
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
| 73 |
"dev": true,
|
| 74 |
"license": "MIT",
|
| 75 |
"dependencies": {
|
| 76 |
+
"@babel/code-frame": "^7.28.6",
|
| 77 |
+
"@babel/generator": "^7.28.6",
|
| 78 |
"@babel/helper-compilation-targets": "^7.28.6",
|
| 79 |
"@babel/helper-module-transforms": "^7.28.6",
|
| 80 |
"@babel/helpers": "^7.28.6",
|
| 81 |
+
"@babel/parser": "^7.28.6",
|
| 82 |
"@babel/template": "^7.28.6",
|
| 83 |
+
"@babel/traverse": "^7.28.6",
|
| 84 |
+
"@babel/types": "^7.28.6",
|
| 85 |
"@jridgewell/remapping": "^2.3.5",
|
| 86 |
"convert-source-map": "^2.0.0",
|
| 87 |
"debug": "^4.1.0",
|
|
|
|
| 98 |
}
|
| 99 |
},
|
| 100 |
"node_modules/@babel/generator": {
|
| 101 |
+
"version": "7.28.6",
|
| 102 |
+
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz",
|
| 103 |
+
"integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==",
|
| 104 |
"dev": true,
|
| 105 |
"license": "MIT",
|
| 106 |
"dependencies": {
|
| 107 |
+
"@babel/parser": "^7.28.6",
|
| 108 |
+
"@babel/types": "^7.28.6",
|
| 109 |
"@jridgewell/gen-mapping": "^0.3.12",
|
| 110 |
"@jridgewell/trace-mapping": "^0.3.28",
|
| 111 |
"jsesc": "^3.0.2"
|
|
|
|
| 218 |
}
|
| 219 |
},
|
| 220 |
"node_modules/@babel/parser": {
|
| 221 |
+
"version": "7.28.6",
|
| 222 |
+
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
|
| 223 |
+
"integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
|
| 224 |
"dev": true,
|
| 225 |
"license": "MIT",
|
| 226 |
"dependencies": {
|
| 227 |
+
"@babel/types": "^7.28.6"
|
| 228 |
},
|
| 229 |
"bin": {
|
| 230 |
"parser": "bin/babel-parser.js"
|
|
|
|
| 249 |
}
|
| 250 |
},
|
| 251 |
"node_modules/@babel/traverse": {
|
| 252 |
+
"version": "7.28.6",
|
| 253 |
+
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz",
|
| 254 |
+
"integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==",
|
| 255 |
"dev": true,
|
| 256 |
"license": "MIT",
|
| 257 |
"dependencies": {
|
| 258 |
+
"@babel/code-frame": "^7.28.6",
|
| 259 |
+
"@babel/generator": "^7.28.6",
|
| 260 |
"@babel/helper-globals": "^7.28.0",
|
| 261 |
+
"@babel/parser": "^7.28.6",
|
| 262 |
"@babel/template": "^7.28.6",
|
| 263 |
+
"@babel/types": "^7.28.6",
|
| 264 |
"debug": "^4.3.1"
|
| 265 |
},
|
| 266 |
"engines": {
|
|
|
|
| 268 |
}
|
| 269 |
},
|
| 270 |
"node_modules/@babel/types": {
|
| 271 |
+
"version": "7.28.6",
|
| 272 |
+
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
|
| 273 |
+
"integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
|
| 274 |
"dev": true,
|
| 275 |
"license": "MIT",
|
| 276 |
"dependencies": {
|
|
|
|
| 1040 |
}
|
| 1041 |
},
|
| 1042 |
"node_modules/@next/env": {
|
| 1043 |
+
"version": "16.1.1",
|
| 1044 |
+
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz",
|
| 1045 |
+
"integrity": "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==",
|
| 1046 |
"license": "MIT"
|
| 1047 |
},
|
| 1048 |
"node_modules/@next/eslint-plugin-next": {
|
| 1049 |
+
"version": "16.1.1",
|
| 1050 |
+
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.1.tgz",
|
| 1051 |
+
"integrity": "sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw==",
|
| 1052 |
"dev": true,
|
| 1053 |
"license": "MIT",
|
| 1054 |
"dependencies": {
|
|
|
|
| 1056 |
}
|
| 1057 |
},
|
| 1058 |
"node_modules/@next/swc-darwin-arm64": {
|
| 1059 |
+
"version": "16.1.1",
|
| 1060 |
+
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.1.tgz",
|
| 1061 |
+
"integrity": "sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==",
|
| 1062 |
"cpu": [
|
| 1063 |
"arm64"
|
| 1064 |
],
|
|
|
|
| 1072 |
}
|
| 1073 |
},
|
| 1074 |
"node_modules/@next/swc-darwin-x64": {
|
| 1075 |
+
"version": "16.1.1",
|
| 1076 |
+
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.1.tgz",
|
| 1077 |
+
"integrity": "sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==",
|
| 1078 |
"cpu": [
|
| 1079 |
"x64"
|
| 1080 |
],
|
|
|
|
| 1088 |
}
|
| 1089 |
},
|
| 1090 |
"node_modules/@next/swc-linux-arm64-gnu": {
|
| 1091 |
+
"version": "16.1.1",
|
| 1092 |
+
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.1.tgz",
|
| 1093 |
+
"integrity": "sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==",
|
| 1094 |
"cpu": [
|
| 1095 |
"arm64"
|
| 1096 |
],
|
|
|
|
| 1104 |
}
|
| 1105 |
},
|
| 1106 |
"node_modules/@next/swc-linux-arm64-musl": {
|
| 1107 |
+
"version": "16.1.1",
|
| 1108 |
+
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.1.tgz",
|
| 1109 |
+
"integrity": "sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==",
|
| 1110 |
"cpu": [
|
| 1111 |
"arm64"
|
| 1112 |
],
|
|
|
|
| 1120 |
}
|
| 1121 |
},
|
| 1122 |
"node_modules/@next/swc-linux-x64-gnu": {
|
| 1123 |
+
"version": "16.1.1",
|
| 1124 |
+
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.1.tgz",
|
| 1125 |
+
"integrity": "sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==",
|
| 1126 |
"cpu": [
|
| 1127 |
"x64"
|
| 1128 |
],
|
|
|
|
| 1136 |
}
|
| 1137 |
},
|
| 1138 |
"node_modules/@next/swc-linux-x64-musl": {
|
| 1139 |
+
"version": "16.1.1",
|
| 1140 |
+
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.1.tgz",
|
| 1141 |
+
"integrity": "sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==",
|
| 1142 |
"cpu": [
|
| 1143 |
"x64"
|
| 1144 |
],
|
|
|
|
| 1152 |
}
|
| 1153 |
},
|
| 1154 |
"node_modules/@next/swc-win32-arm64-msvc": {
|
| 1155 |
+
"version": "16.1.1",
|
| 1156 |
+
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.1.tgz",
|
| 1157 |
+
"integrity": "sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==",
|
| 1158 |
"cpu": [
|
| 1159 |
"arm64"
|
| 1160 |
],
|
|
|
|
| 1168 |
}
|
| 1169 |
},
|
| 1170 |
"node_modules/@next/swc-win32-x64-msvc": {
|
| 1171 |
+
"version": "16.1.1",
|
| 1172 |
+
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.1.tgz",
|
| 1173 |
+
"integrity": "sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==",
|
| 1174 |
"cpu": [
|
| 1175 |
"x64"
|
| 1176 |
],
|
|
|
|
| 1231 |
"node": ">=12.4.0"
|
| 1232 |
}
|
| 1233 |
},
|
| 1234 |
+
"node_modules/@reduxjs/toolkit": {
|
| 1235 |
+
"version": "2.11.2",
|
| 1236 |
+
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
| 1237 |
+
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
| 1238 |
+
"license": "MIT",
|
| 1239 |
+
"dependencies": {
|
| 1240 |
+
"@standard-schema/spec": "^1.0.0",
|
| 1241 |
+
"@standard-schema/utils": "^0.3.0",
|
| 1242 |
+
"immer": "^11.0.0",
|
| 1243 |
+
"redux": "^5.0.1",
|
| 1244 |
+
"redux-thunk": "^3.1.0",
|
| 1245 |
+
"reselect": "^5.1.0"
|
| 1246 |
+
},
|
| 1247 |
+
"peerDependencies": {
|
| 1248 |
+
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
| 1249 |
+
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
| 1250 |
+
},
|
| 1251 |
+
"peerDependenciesMeta": {
|
| 1252 |
+
"react": {
|
| 1253 |
+
"optional": true
|
| 1254 |
+
},
|
| 1255 |
+
"react-redux": {
|
| 1256 |
+
"optional": true
|
| 1257 |
+
}
|
| 1258 |
+
}
|
| 1259 |
+
},
|
| 1260 |
+
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
| 1261 |
+
"version": "11.1.3",
|
| 1262 |
+
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
|
| 1263 |
+
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
|
| 1264 |
+
"license": "MIT",
|
| 1265 |
+
"funding": {
|
| 1266 |
+
"type": "opencollective",
|
| 1267 |
+
"url": "https://opencollective.com/immer"
|
| 1268 |
+
}
|
| 1269 |
+
},
|
| 1270 |
"node_modules/@rtsao/scc": {
|
| 1271 |
"version": "1.1.0",
|
| 1272 |
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
|
|
|
| 1274 |
"dev": true,
|
| 1275 |
"license": "MIT"
|
| 1276 |
},
|
| 1277 |
+
"node_modules/@standard-schema/spec": {
|
| 1278 |
+
"version": "1.1.0",
|
| 1279 |
+
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
| 1280 |
+
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
| 1281 |
+
"license": "MIT"
|
| 1282 |
+
},
|
| 1283 |
+
"node_modules/@standard-schema/utils": {
|
| 1284 |
+
"version": "0.3.0",
|
| 1285 |
+
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
| 1286 |
+
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
| 1287 |
+
"license": "MIT"
|
| 1288 |
+
},
|
| 1289 |
+
"node_modules/@supabase/auth-js": {
|
| 1290 |
+
"version": "2.90.1",
|
| 1291 |
+
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.90.1.tgz",
|
| 1292 |
+
"integrity": "sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng==",
|
| 1293 |
+
"license": "MIT",
|
| 1294 |
+
"dependencies": {
|
| 1295 |
+
"tslib": "2.8.1"
|
| 1296 |
+
},
|
| 1297 |
+
"engines": {
|
| 1298 |
+
"node": ">=20.0.0"
|
| 1299 |
+
}
|
| 1300 |
+
},
|
| 1301 |
+
"node_modules/@supabase/functions-js": {
|
| 1302 |
+
"version": "2.90.1",
|
| 1303 |
+
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.90.1.tgz",
|
| 1304 |
+
"integrity": "sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw==",
|
| 1305 |
+
"license": "MIT",
|
| 1306 |
+
"dependencies": {
|
| 1307 |
+
"tslib": "2.8.1"
|
| 1308 |
+
},
|
| 1309 |
+
"engines": {
|
| 1310 |
+
"node": ">=20.0.0"
|
| 1311 |
+
}
|
| 1312 |
+
},
|
| 1313 |
+
"node_modules/@supabase/postgrest-js": {
|
| 1314 |
+
"version": "2.90.1",
|
| 1315 |
+
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.90.1.tgz",
|
| 1316 |
+
"integrity": "sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ==",
|
| 1317 |
+
"license": "MIT",
|
| 1318 |
+
"dependencies": {
|
| 1319 |
+
"tslib": "2.8.1"
|
| 1320 |
+
},
|
| 1321 |
+
"engines": {
|
| 1322 |
+
"node": ">=20.0.0"
|
| 1323 |
+
}
|
| 1324 |
+
},
|
| 1325 |
+
"node_modules/@supabase/realtime-js": {
|
| 1326 |
+
"version": "2.90.1",
|
| 1327 |
+
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.90.1.tgz",
|
| 1328 |
+
"integrity": "sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w==",
|
| 1329 |
+
"license": "MIT",
|
| 1330 |
+
"dependencies": {
|
| 1331 |
+
"@types/phoenix": "^1.6.6",
|
| 1332 |
+
"@types/ws": "^8.18.1",
|
| 1333 |
+
"tslib": "2.8.1",
|
| 1334 |
+
"ws": "^8.18.2"
|
| 1335 |
+
},
|
| 1336 |
+
"engines": {
|
| 1337 |
+
"node": ">=20.0.0"
|
| 1338 |
+
}
|
| 1339 |
+
},
|
| 1340 |
+
"node_modules/@supabase/storage-js": {
|
| 1341 |
+
"version": "2.90.1",
|
| 1342 |
+
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.90.1.tgz",
|
| 1343 |
+
"integrity": "sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg==",
|
| 1344 |
+
"license": "MIT",
|
| 1345 |
+
"dependencies": {
|
| 1346 |
+
"iceberg-js": "^0.8.1",
|
| 1347 |
+
"tslib": "2.8.1"
|
| 1348 |
+
},
|
| 1349 |
+
"engines": {
|
| 1350 |
+
"node": ">=20.0.0"
|
| 1351 |
+
}
|
| 1352 |
+
},
|
| 1353 |
+
"node_modules/@supabase/supabase-js": {
|
| 1354 |
+
"version": "2.90.1",
|
| 1355 |
+
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.90.1.tgz",
|
| 1356 |
+
"integrity": "sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w==",
|
| 1357 |
+
"license": "MIT",
|
| 1358 |
+
"dependencies": {
|
| 1359 |
+
"@supabase/auth-js": "2.90.1",
|
| 1360 |
+
"@supabase/functions-js": "2.90.1",
|
| 1361 |
+
"@supabase/postgrest-js": "2.90.1",
|
| 1362 |
+
"@supabase/realtime-js": "2.90.1",
|
| 1363 |
+
"@supabase/storage-js": "2.90.1"
|
| 1364 |
+
},
|
| 1365 |
+
"engines": {
|
| 1366 |
+
"node": ">=20.0.0"
|
| 1367 |
+
}
|
| 1368 |
+
},
|
| 1369 |
"node_modules/@swc/helpers": {
|
| 1370 |
"version": "0.5.15",
|
| 1371 |
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
|
|
|
| 1657 |
"tslib": "^2.4.0"
|
| 1658 |
}
|
| 1659 |
},
|
| 1660 |
+
"node_modules/@types/d3-array": {
|
| 1661 |
+
"version": "3.2.2",
|
| 1662 |
+
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
| 1663 |
+
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
| 1664 |
+
"license": "MIT"
|
| 1665 |
+
},
|
| 1666 |
+
"node_modules/@types/d3-color": {
|
| 1667 |
+
"version": "3.1.3",
|
| 1668 |
+
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
| 1669 |
+
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
| 1670 |
+
"license": "MIT"
|
| 1671 |
+
},
|
| 1672 |
+
"node_modules/@types/d3-ease": {
|
| 1673 |
+
"version": "3.0.2",
|
| 1674 |
+
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
| 1675 |
+
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
| 1676 |
+
"license": "MIT"
|
| 1677 |
+
},
|
| 1678 |
+
"node_modules/@types/d3-interpolate": {
|
| 1679 |
+
"version": "3.0.4",
|
| 1680 |
+
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
| 1681 |
+
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
| 1682 |
+
"license": "MIT",
|
| 1683 |
+
"dependencies": {
|
| 1684 |
+
"@types/d3-color": "*"
|
| 1685 |
+
}
|
| 1686 |
+
},
|
| 1687 |
+
"node_modules/@types/d3-path": {
|
| 1688 |
+
"version": "3.1.1",
|
| 1689 |
+
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
| 1690 |
+
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
| 1691 |
+
"license": "MIT"
|
| 1692 |
+
},
|
| 1693 |
+
"node_modules/@types/d3-scale": {
|
| 1694 |
+
"version": "4.0.9",
|
| 1695 |
+
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
| 1696 |
+
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
| 1697 |
+
"license": "MIT",
|
| 1698 |
+
"dependencies": {
|
| 1699 |
+
"@types/d3-time": "*"
|
| 1700 |
+
}
|
| 1701 |
+
},
|
| 1702 |
+
"node_modules/@types/d3-shape": {
|
| 1703 |
+
"version": "3.1.8",
|
| 1704 |
+
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
| 1705 |
+
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
| 1706 |
+
"license": "MIT",
|
| 1707 |
+
"dependencies": {
|
| 1708 |
+
"@types/d3-path": "*"
|
| 1709 |
+
}
|
| 1710 |
+
},
|
| 1711 |
+
"node_modules/@types/d3-time": {
|
| 1712 |
+
"version": "3.0.4",
|
| 1713 |
+
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
| 1714 |
+
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
| 1715 |
+
"license": "MIT"
|
| 1716 |
+
},
|
| 1717 |
+
"node_modules/@types/d3-timer": {
|
| 1718 |
+
"version": "3.0.2",
|
| 1719 |
+
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
| 1720 |
+
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
| 1721 |
+
"license": "MIT"
|
| 1722 |
+
},
|
| 1723 |
"node_modules/@types/estree": {
|
| 1724 |
"version": "1.0.8",
|
| 1725 |
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
|
|
|
| 1742 |
"license": "MIT"
|
| 1743 |
},
|
| 1744 |
"node_modules/@types/node": {
|
| 1745 |
+
"version": "20.19.29",
|
| 1746 |
+
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.29.tgz",
|
| 1747 |
+
"integrity": "sha512-YrT9ArrGaHForBaCNwFjoqJWmn8G1Pr7+BH/vwyLHciA9qT/wSiuOhxGCT50JA5xLvFBd6PIiGkE3afxcPE1nw==",
|
|
|
|
| 1748 |
"license": "MIT",
|
| 1749 |
"dependencies": {
|
| 1750 |
"undici-types": "~6.21.0"
|
| 1751 |
}
|
| 1752 |
},
|
| 1753 |
+
"node_modules/@types/phoenix": {
|
| 1754 |
+
"version": "1.6.7",
|
| 1755 |
+
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
|
| 1756 |
+
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
|
| 1757 |
+
"license": "MIT"
|
| 1758 |
+
},
|
| 1759 |
"node_modules/@types/react": {
|
| 1760 |
+
"version": "19.2.8",
|
| 1761 |
+
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
|
| 1762 |
+
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
|
| 1763 |
+
"devOptional": true,
|
| 1764 |
"license": "MIT",
|
| 1765 |
"dependencies": {
|
| 1766 |
"csstype": "^3.2.2"
|
|
|
|
| 1776 |
"@types/react": "^19.2.0"
|
| 1777 |
}
|
| 1778 |
},
|
| 1779 |
+
"node_modules/@types/use-sync-external-store": {
|
| 1780 |
+
"version": "0.0.6",
|
| 1781 |
+
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
| 1782 |
+
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
| 1783 |
+
"license": "MIT"
|
| 1784 |
+
},
|
| 1785 |
+
"node_modules/@types/ws": {
|
| 1786 |
+
"version": "8.18.1",
|
| 1787 |
+
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
| 1788 |
+
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
| 1789 |
+
"license": "MIT",
|
| 1790 |
+
"dependencies": {
|
| 1791 |
+
"@types/node": "*"
|
| 1792 |
+
}
|
| 1793 |
+
},
|
| 1794 |
"node_modules/@typescript-eslint/eslint-plugin": {
|
| 1795 |
+
"version": "8.53.0",
|
| 1796 |
+
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz",
|
| 1797 |
+
"integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==",
|
| 1798 |
"dev": true,
|
| 1799 |
"license": "MIT",
|
| 1800 |
"dependencies": {
|
| 1801 |
"@eslint-community/regexpp": "^4.12.2",
|
| 1802 |
+
"@typescript-eslint/scope-manager": "8.53.0",
|
| 1803 |
+
"@typescript-eslint/type-utils": "8.53.0",
|
| 1804 |
+
"@typescript-eslint/utils": "8.53.0",
|
| 1805 |
+
"@typescript-eslint/visitor-keys": "8.53.0",
|
| 1806 |
"ignore": "^7.0.5",
|
| 1807 |
"natural-compare": "^1.4.0",
|
| 1808 |
"ts-api-utils": "^2.4.0"
|
|
|
|
| 1815 |
"url": "https://opencollective.com/typescript-eslint"
|
| 1816 |
},
|
| 1817 |
"peerDependencies": {
|
| 1818 |
+
"@typescript-eslint/parser": "^8.53.0",
|
| 1819 |
"eslint": "^8.57.0 || ^9.0.0",
|
| 1820 |
"typescript": ">=4.8.4 <6.0.0"
|
| 1821 |
}
|
|
|
|
| 1831 |
}
|
| 1832 |
},
|
| 1833 |
"node_modules/@typescript-eslint/parser": {
|
| 1834 |
+
"version": "8.53.0",
|
| 1835 |
+
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.0.tgz",
|
| 1836 |
+
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
|
| 1837 |
"dev": true,
|
| 1838 |
"license": "MIT",
|
| 1839 |
"dependencies": {
|
| 1840 |
+
"@typescript-eslint/scope-manager": "8.53.0",
|
| 1841 |
+
"@typescript-eslint/types": "8.53.0",
|
| 1842 |
+
"@typescript-eslint/typescript-estree": "8.53.0",
|
| 1843 |
+
"@typescript-eslint/visitor-keys": "8.53.0",
|
| 1844 |
"debug": "^4.4.3"
|
| 1845 |
},
|
| 1846 |
"engines": {
|
|
|
|
| 1856 |
}
|
| 1857 |
},
|
| 1858 |
"node_modules/@typescript-eslint/project-service": {
|
| 1859 |
+
"version": "8.53.0",
|
| 1860 |
+
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.0.tgz",
|
| 1861 |
+
"integrity": "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==",
|
| 1862 |
"dev": true,
|
| 1863 |
"license": "MIT",
|
| 1864 |
"dependencies": {
|
| 1865 |
+
"@typescript-eslint/tsconfig-utils": "^8.53.0",
|
| 1866 |
+
"@typescript-eslint/types": "^8.53.0",
|
| 1867 |
"debug": "^4.4.3"
|
| 1868 |
},
|
| 1869 |
"engines": {
|
|
|
|
| 1878 |
}
|
| 1879 |
},
|
| 1880 |
"node_modules/@typescript-eslint/scope-manager": {
|
| 1881 |
+
"version": "8.53.0",
|
| 1882 |
+
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.0.tgz",
|
| 1883 |
+
"integrity": "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==",
|
| 1884 |
"dev": true,
|
| 1885 |
"license": "MIT",
|
| 1886 |
"dependencies": {
|
| 1887 |
+
"@typescript-eslint/types": "8.53.0",
|
| 1888 |
+
"@typescript-eslint/visitor-keys": "8.53.0"
|
| 1889 |
},
|
| 1890 |
"engines": {
|
| 1891 |
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
|
|
|
| 1896 |
}
|
| 1897 |
},
|
| 1898 |
"node_modules/@typescript-eslint/tsconfig-utils": {
|
| 1899 |
+
"version": "8.53.0",
|
| 1900 |
+
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.0.tgz",
|
| 1901 |
+
"integrity": "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==",
|
| 1902 |
"dev": true,
|
| 1903 |
"license": "MIT",
|
| 1904 |
"engines": {
|
|
|
|
| 1913 |
}
|
| 1914 |
},
|
| 1915 |
"node_modules/@typescript-eslint/type-utils": {
|
| 1916 |
+
"version": "8.53.0",
|
| 1917 |
+
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.0.tgz",
|
| 1918 |
+
"integrity": "sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==",
|
| 1919 |
"dev": true,
|
| 1920 |
"license": "MIT",
|
| 1921 |
"dependencies": {
|
| 1922 |
+
"@typescript-eslint/types": "8.53.0",
|
| 1923 |
+
"@typescript-eslint/typescript-estree": "8.53.0",
|
| 1924 |
+
"@typescript-eslint/utils": "8.53.0",
|
| 1925 |
"debug": "^4.4.3",
|
| 1926 |
"ts-api-utils": "^2.4.0"
|
| 1927 |
},
|
|
|
|
| 1938 |
}
|
| 1939 |
},
|
| 1940 |
"node_modules/@typescript-eslint/types": {
|
| 1941 |
+
"version": "8.53.0",
|
| 1942 |
+
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz",
|
| 1943 |
+
"integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==",
|
| 1944 |
"dev": true,
|
| 1945 |
"license": "MIT",
|
| 1946 |
"engines": {
|
|
|
|
| 1952 |
}
|
| 1953 |
},
|
| 1954 |
"node_modules/@typescript-eslint/typescript-estree": {
|
| 1955 |
+
"version": "8.53.0",
|
| 1956 |
+
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.0.tgz",
|
| 1957 |
+
"integrity": "sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==",
|
| 1958 |
"dev": true,
|
| 1959 |
"license": "MIT",
|
| 1960 |
"dependencies": {
|
| 1961 |
+
"@typescript-eslint/project-service": "8.53.0",
|
| 1962 |
+
"@typescript-eslint/tsconfig-utils": "8.53.0",
|
| 1963 |
+
"@typescript-eslint/types": "8.53.0",
|
| 1964 |
+
"@typescript-eslint/visitor-keys": "8.53.0",
|
| 1965 |
"debug": "^4.4.3",
|
| 1966 |
"minimatch": "^9.0.5",
|
| 1967 |
"semver": "^7.7.3",
|
|
|
|
| 2006 |
}
|
| 2007 |
},
|
| 2008 |
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
|
| 2009 |
+
"version": "7.7.3",
|
| 2010 |
+
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
| 2011 |
+
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
| 2012 |
"dev": true,
|
| 2013 |
"license": "ISC",
|
| 2014 |
"bin": {
|
|
|
|
| 2019 |
}
|
| 2020 |
},
|
| 2021 |
"node_modules/@typescript-eslint/utils": {
|
| 2022 |
+
"version": "8.53.0",
|
| 2023 |
+
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.0.tgz",
|
| 2024 |
+
"integrity": "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==",
|
| 2025 |
"dev": true,
|
| 2026 |
"license": "MIT",
|
| 2027 |
"dependencies": {
|
| 2028 |
"@eslint-community/eslint-utils": "^4.9.1",
|
| 2029 |
+
"@typescript-eslint/scope-manager": "8.53.0",
|
| 2030 |
+
"@typescript-eslint/types": "8.53.0",
|
| 2031 |
+
"@typescript-eslint/typescript-estree": "8.53.0"
|
| 2032 |
},
|
| 2033 |
"engines": {
|
| 2034 |
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
|
|
|
| 2043 |
}
|
| 2044 |
},
|
| 2045 |
"node_modules/@typescript-eslint/visitor-keys": {
|
| 2046 |
+
"version": "8.53.0",
|
| 2047 |
+
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.0.tgz",
|
| 2048 |
+
"integrity": "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==",
|
| 2049 |
"dev": true,
|
| 2050 |
"license": "MIT",
|
| 2051 |
"dependencies": {
|
| 2052 |
+
"@typescript-eslint/types": "8.53.0",
|
| 2053 |
"eslint-visitor-keys": "^4.2.1"
|
| 2054 |
},
|
| 2055 |
"engines": {
|
|
|
|
| 2623 |
"license": "MIT"
|
| 2624 |
},
|
| 2625 |
"node_modules/baseline-browser-mapping": {
|
| 2626 |
+
"version": "2.9.14",
|
| 2627 |
+
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz",
|
| 2628 |
+
"integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==",
|
| 2629 |
"license": "Apache-2.0",
|
| 2630 |
"bin": {
|
| 2631 |
"baseline-browser-mapping": "dist/cli.js"
|
|
|
|
| 2750 |
}
|
| 2751 |
},
|
| 2752 |
"node_modules/caniuse-lite": {
|
| 2753 |
+
"version": "1.0.30001764",
|
| 2754 |
+
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz",
|
| 2755 |
+
"integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==",
|
| 2756 |
"funding": [
|
| 2757 |
{
|
| 2758 |
"type": "opencollective",
|
|
|
|
| 2792 |
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
| 2793 |
"license": "MIT"
|
| 2794 |
},
|
| 2795 |
+
"node_modules/clsx": {
|
| 2796 |
+
"version": "2.1.1",
|
| 2797 |
+
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
| 2798 |
+
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
| 2799 |
+
"license": "MIT",
|
| 2800 |
+
"engines": {
|
| 2801 |
+
"node": ">=6"
|
| 2802 |
+
}
|
| 2803 |
+
},
|
| 2804 |
"node_modules/color-convert": {
|
| 2805 |
"version": "2.0.1",
|
| 2806 |
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
|
|
|
| 2854 |
"version": "3.2.3",
|
| 2855 |
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
| 2856 |
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
| 2857 |
+
"devOptional": true,
|
| 2858 |
"license": "MIT"
|
| 2859 |
},
|
| 2860 |
+
"node_modules/d3-array": {
|
| 2861 |
+
"version": "3.2.4",
|
| 2862 |
+
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
| 2863 |
+
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
| 2864 |
+
"license": "ISC",
|
| 2865 |
+
"dependencies": {
|
| 2866 |
+
"internmap": "1 - 2"
|
| 2867 |
+
},
|
| 2868 |
+
"engines": {
|
| 2869 |
+
"node": ">=12"
|
| 2870 |
+
}
|
| 2871 |
+
},
|
| 2872 |
+
"node_modules/d3-color": {
|
| 2873 |
+
"version": "3.1.0",
|
| 2874 |
+
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
| 2875 |
+
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
| 2876 |
+
"license": "ISC",
|
| 2877 |
+
"engines": {
|
| 2878 |
+
"node": ">=12"
|
| 2879 |
+
}
|
| 2880 |
+
},
|
| 2881 |
+
"node_modules/d3-ease": {
|
| 2882 |
+
"version": "3.0.1",
|
| 2883 |
+
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
| 2884 |
+
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
| 2885 |
+
"license": "BSD-3-Clause",
|
| 2886 |
+
"engines": {
|
| 2887 |
+
"node": ">=12"
|
| 2888 |
+
}
|
| 2889 |
+
},
|
| 2890 |
+
"node_modules/d3-format": {
|
| 2891 |
+
"version": "3.1.1",
|
| 2892 |
+
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.1.tgz",
|
| 2893 |
+
"integrity": "sha512-ryitBnaRbXQtgZ/gU50GSn6jQRwinSCQclpakXymvLd8ytTgE5bmSfgYcUxD7XYL34qHhFDyVk71qqKsfSyvmA==",
|
| 2894 |
+
"license": "ISC",
|
| 2895 |
+
"engines": {
|
| 2896 |
+
"node": ">=12"
|
| 2897 |
+
}
|
| 2898 |
+
},
|
| 2899 |
+
"node_modules/d3-interpolate": {
|
| 2900 |
+
"version": "3.0.1",
|
| 2901 |
+
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
| 2902 |
+
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
| 2903 |
+
"license": "ISC",
|
| 2904 |
+
"dependencies": {
|
| 2905 |
+
"d3-color": "1 - 3"
|
| 2906 |
+
},
|
| 2907 |
+
"engines": {
|
| 2908 |
+
"node": ">=12"
|
| 2909 |
+
}
|
| 2910 |
+
},
|
| 2911 |
+
"node_modules/d3-path": {
|
| 2912 |
+
"version": "3.1.0",
|
| 2913 |
+
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
| 2914 |
+
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
| 2915 |
+
"license": "ISC",
|
| 2916 |
+
"engines": {
|
| 2917 |
+
"node": ">=12"
|
| 2918 |
+
}
|
| 2919 |
+
},
|
| 2920 |
+
"node_modules/d3-scale": {
|
| 2921 |
+
"version": "4.0.2",
|
| 2922 |
+
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
| 2923 |
+
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
| 2924 |
+
"license": "ISC",
|
| 2925 |
+
"dependencies": {
|
| 2926 |
+
"d3-array": "2.10.0 - 3",
|
| 2927 |
+
"d3-format": "1 - 3",
|
| 2928 |
+
"d3-interpolate": "1.2.0 - 3",
|
| 2929 |
+
"d3-time": "2.1.1 - 3",
|
| 2930 |
+
"d3-time-format": "2 - 4"
|
| 2931 |
+
},
|
| 2932 |
+
"engines": {
|
| 2933 |
+
"node": ">=12"
|
| 2934 |
+
}
|
| 2935 |
+
},
|
| 2936 |
+
"node_modules/d3-shape": {
|
| 2937 |
+
"version": "3.2.0",
|
| 2938 |
+
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
| 2939 |
+
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
| 2940 |
+
"license": "ISC",
|
| 2941 |
+
"dependencies": {
|
| 2942 |
+
"d3-path": "^3.1.0"
|
| 2943 |
+
},
|
| 2944 |
+
"engines": {
|
| 2945 |
+
"node": ">=12"
|
| 2946 |
+
}
|
| 2947 |
+
},
|
| 2948 |
+
"node_modules/d3-time": {
|
| 2949 |
+
"version": "3.1.0",
|
| 2950 |
+
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
| 2951 |
+
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
| 2952 |
+
"license": "ISC",
|
| 2953 |
+
"dependencies": {
|
| 2954 |
+
"d3-array": "2 - 3"
|
| 2955 |
+
},
|
| 2956 |
+
"engines": {
|
| 2957 |
+
"node": ">=12"
|
| 2958 |
+
}
|
| 2959 |
+
},
|
| 2960 |
+
"node_modules/d3-time-format": {
|
| 2961 |
+
"version": "4.1.0",
|
| 2962 |
+
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
| 2963 |
+
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
| 2964 |
+
"license": "ISC",
|
| 2965 |
+
"dependencies": {
|
| 2966 |
+
"d3-time": "1 - 3"
|
| 2967 |
+
},
|
| 2968 |
+
"engines": {
|
| 2969 |
+
"node": ">=12"
|
| 2970 |
+
}
|
| 2971 |
+
},
|
| 2972 |
+
"node_modules/d3-timer": {
|
| 2973 |
+
"version": "3.0.1",
|
| 2974 |
+
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
| 2975 |
+
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
| 2976 |
+
"license": "ISC",
|
| 2977 |
+
"engines": {
|
| 2978 |
+
"node": ">=12"
|
| 2979 |
+
}
|
| 2980 |
+
},
|
| 2981 |
"node_modules/damerau-levenshtein": {
|
| 2982 |
"version": "1.0.8",
|
| 2983 |
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
|
|
|
| 3057 |
}
|
| 3058 |
}
|
| 3059 |
},
|
| 3060 |
+
"node_modules/decimal.js-light": {
|
| 3061 |
+
"version": "2.5.1",
|
| 3062 |
+
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
| 3063 |
+
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
| 3064 |
+
"license": "MIT"
|
| 3065 |
+
},
|
| 3066 |
"node_modules/deep-is": {
|
| 3067 |
"version": "0.1.4",
|
| 3068 |
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
|
|
|
| 3145 |
}
|
| 3146 |
},
|
| 3147 |
"node_modules/electron-to-chromium": {
|
| 3148 |
+
"version": "1.5.267",
|
| 3149 |
+
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
|
| 3150 |
+
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
|
| 3151 |
"dev": true,
|
| 3152 |
"license": "ISC"
|
| 3153 |
},
|
|
|
|
| 3159 |
"license": "MIT"
|
| 3160 |
},
|
| 3161 |
"node_modules/enhanced-resolve": {
|
| 3162 |
+
"version": "5.18.4",
|
| 3163 |
+
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
|
| 3164 |
+
"integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
|
| 3165 |
"dev": true,
|
| 3166 |
"license": "MIT",
|
| 3167 |
"dependencies": {
|
| 3168 |
"graceful-fs": "^4.2.4",
|
| 3169 |
+
"tapable": "^2.2.0"
|
| 3170 |
},
|
| 3171 |
"engines": {
|
| 3172 |
"node": ">=10.13.0"
|
|
|
|
| 3349 |
"url": "https://github.com/sponsors/ljharb"
|
| 3350 |
}
|
| 3351 |
},
|
| 3352 |
+
"node_modules/es-toolkit": {
|
| 3353 |
+
"version": "1.43.0",
|
| 3354 |
+
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
|
| 3355 |
+
"integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
|
| 3356 |
+
"license": "MIT",
|
| 3357 |
+
"workspaces": [
|
| 3358 |
+
"docs",
|
| 3359 |
+
"benchmarks"
|
| 3360 |
+
]
|
| 3361 |
+
},
|
| 3362 |
"node_modules/escalade": {
|
| 3363 |
"version": "3.2.0",
|
| 3364 |
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
|
|
|
| 3443 |
}
|
| 3444 |
},
|
| 3445 |
"node_modules/eslint-config-next": {
|
| 3446 |
+
"version": "16.1.1",
|
| 3447 |
+
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.1.tgz",
|
| 3448 |
+
"integrity": "sha512-55nTpVWm3qeuxoQKLOjQVciKZJUphKrNM0fCcQHAIOGl6VFXgaqeMfv0aKJhs7QtcnlAPhNVqsqRfRjeKBPIUA==",
|
| 3449 |
"dev": true,
|
| 3450 |
"license": "MIT",
|
| 3451 |
"dependencies": {
|
| 3452 |
+
"@next/eslint-plugin-next": "16.1.1",
|
| 3453 |
"eslint-import-resolver-node": "^0.3.6",
|
| 3454 |
"eslint-import-resolver-typescript": "^3.5.2",
|
| 3455 |
"eslint-plugin-import": "^2.32.0",
|
|
|
|
| 3806 |
"node": ">=0.10.0"
|
| 3807 |
}
|
| 3808 |
},
|
| 3809 |
+
"node_modules/eventemitter3": {
|
| 3810 |
+
"version": "5.0.1",
|
| 3811 |
+
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
| 3812 |
+
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
| 3813 |
+
"license": "MIT"
|
| 3814 |
+
},
|
| 3815 |
"node_modules/fast-deep-equal": {
|
| 3816 |
"version": "3.1.3",
|
| 3817 |
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
|
|
|
| 4072 |
}
|
| 4073 |
},
|
| 4074 |
"node_modules/get-tsconfig": {
|
| 4075 |
+
"version": "4.13.0",
|
| 4076 |
+
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
| 4077 |
+
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
|
| 4078 |
"dev": true,
|
| 4079 |
"license": "MIT",
|
| 4080 |
"dependencies": {
|
|
|
|
| 4258 |
"hermes-estree": "0.25.1"
|
| 4259 |
}
|
| 4260 |
},
|
| 4261 |
+
"node_modules/iceberg-js": {
|
| 4262 |
+
"version": "0.8.1",
|
| 4263 |
+
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
| 4264 |
+
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
| 4265 |
+
"license": "MIT",
|
| 4266 |
+
"engines": {
|
| 4267 |
+
"node": ">=20.0.0"
|
| 4268 |
+
}
|
| 4269 |
+
},
|
| 4270 |
"node_modules/ignore": {
|
| 4271 |
"version": "5.3.2",
|
| 4272 |
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
|
|
|
| 4277 |
"node": ">= 4"
|
| 4278 |
}
|
| 4279 |
},
|
| 4280 |
+
"node_modules/immer": {
|
| 4281 |
+
"version": "10.2.0",
|
| 4282 |
+
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
| 4283 |
+
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
| 4284 |
+
"license": "MIT",
|
| 4285 |
+
"funding": {
|
| 4286 |
+
"type": "opencollective",
|
| 4287 |
+
"url": "https://opencollective.com/immer"
|
| 4288 |
+
}
|
| 4289 |
+
},
|
| 4290 |
"node_modules/import-fresh": {
|
| 4291 |
"version": "3.3.1",
|
| 4292 |
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
|
|
|
| 4329 |
"node": ">= 0.4"
|
| 4330 |
}
|
| 4331 |
},
|
| 4332 |
+
"node_modules/internmap": {
|
| 4333 |
+
"version": "2.0.3",
|
| 4334 |
+
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
| 4335 |
+
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
| 4336 |
+
"license": "ISC",
|
| 4337 |
+
"engines": {
|
| 4338 |
+
"node": ">=12"
|
| 4339 |
+
}
|
| 4340 |
+
},
|
| 4341 |
"node_modules/is-array-buffer": {
|
| 4342 |
"version": "3.0.5",
|
| 4343 |
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
|
|
|
| 4420 |
}
|
| 4421 |
},
|
| 4422 |
"node_modules/is-bun-module/node_modules/semver": {
|
| 4423 |
+
"version": "7.7.3",
|
| 4424 |
+
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
| 4425 |
+
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
| 4426 |
"dev": true,
|
| 4427 |
"license": "ISC",
|
| 4428 |
"bin": {
|
|
|
|
| 5229 |
"yallist": "^3.0.2"
|
| 5230 |
}
|
| 5231 |
},
|
| 5232 |
+
"node_modules/lucide-react": {
|
| 5233 |
+
"version": "0.562.0",
|
| 5234 |
+
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
|
| 5235 |
+
"integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
|
| 5236 |
+
"license": "ISC",
|
| 5237 |
+
"peerDependencies": {
|
| 5238 |
+
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 5239 |
+
}
|
| 5240 |
+
},
|
| 5241 |
"node_modules/magic-string": {
|
| 5242 |
"version": "0.30.21",
|
| 5243 |
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
|
|
|
| 5354 |
"license": "MIT"
|
| 5355 |
},
|
| 5356 |
"node_modules/next": {
|
| 5357 |
+
"version": "16.1.1",
|
| 5358 |
+
"resolved": "https://registry.npmjs.org/next/-/next-16.1.1.tgz",
|
| 5359 |
+
"integrity": "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==",
|
| 5360 |
"license": "MIT",
|
| 5361 |
"dependencies": {
|
| 5362 |
+
"@next/env": "16.1.1",
|
| 5363 |
"@swc/helpers": "0.5.15",
|
| 5364 |
"baseline-browser-mapping": "^2.8.3",
|
| 5365 |
"caniuse-lite": "^1.0.30001579",
|
|
|
|
| 5373 |
"node": ">=20.9.0"
|
| 5374 |
},
|
| 5375 |
"optionalDependencies": {
|
| 5376 |
+
"@next/swc-darwin-arm64": "16.1.1",
|
| 5377 |
+
"@next/swc-darwin-x64": "16.1.1",
|
| 5378 |
+
"@next/swc-linux-arm64-gnu": "16.1.1",
|
| 5379 |
+
"@next/swc-linux-arm64-musl": "16.1.1",
|
| 5380 |
+
"@next/swc-linux-x64-gnu": "16.1.1",
|
| 5381 |
+
"@next/swc-linux-x64-musl": "16.1.1",
|
| 5382 |
+
"@next/swc-win32-arm64-msvc": "16.1.1",
|
| 5383 |
+
"@next/swc-win32-x64-msvc": "16.1.1",
|
| 5384 |
"sharp": "^0.34.4"
|
| 5385 |
},
|
| 5386 |
"peerDependencies": {
|
|
|
|
| 5808 |
"version": "16.13.1",
|
| 5809 |
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
| 5810 |
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
|
|
|
| 5811 |
"license": "MIT"
|
| 5812 |
},
|
| 5813 |
+
"node_modules/react-redux": {
|
| 5814 |
+
"version": "9.2.0",
|
| 5815 |
+
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
| 5816 |
+
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
| 5817 |
+
"license": "MIT",
|
| 5818 |
+
"dependencies": {
|
| 5819 |
+
"@types/use-sync-external-store": "^0.0.6",
|
| 5820 |
+
"use-sync-external-store": "^1.4.0"
|
| 5821 |
+
},
|
| 5822 |
+
"peerDependencies": {
|
| 5823 |
+
"@types/react": "^18.2.25 || ^19",
|
| 5824 |
+
"react": "^18.0 || ^19",
|
| 5825 |
+
"redux": "^5.0.0"
|
| 5826 |
+
},
|
| 5827 |
+
"peerDependenciesMeta": {
|
| 5828 |
+
"@types/react": {
|
| 5829 |
+
"optional": true
|
| 5830 |
+
},
|
| 5831 |
+
"redux": {
|
| 5832 |
+
"optional": true
|
| 5833 |
+
}
|
| 5834 |
+
}
|
| 5835 |
+
},
|
| 5836 |
+
"node_modules/recharts": {
|
| 5837 |
+
"version": "3.6.0",
|
| 5838 |
+
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
|
| 5839 |
+
"integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==",
|
| 5840 |
+
"license": "MIT",
|
| 5841 |
+
"workspaces": [
|
| 5842 |
+
"www"
|
| 5843 |
+
],
|
| 5844 |
+
"dependencies": {
|
| 5845 |
+
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
| 5846 |
+
"clsx": "^2.1.1",
|
| 5847 |
+
"decimal.js-light": "^2.5.1",
|
| 5848 |
+
"es-toolkit": "^1.39.3",
|
| 5849 |
+
"eventemitter3": "^5.0.1",
|
| 5850 |
+
"immer": "^10.1.1",
|
| 5851 |
+
"react-redux": "8.x.x || 9.x.x",
|
| 5852 |
+
"reselect": "5.1.1",
|
| 5853 |
+
"tiny-invariant": "^1.3.3",
|
| 5854 |
+
"use-sync-external-store": "^1.2.2",
|
| 5855 |
+
"victory-vendor": "^37.0.2"
|
| 5856 |
+
},
|
| 5857 |
+
"engines": {
|
| 5858 |
+
"node": ">=18"
|
| 5859 |
+
},
|
| 5860 |
+
"peerDependencies": {
|
| 5861 |
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
| 5862 |
+
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
| 5863 |
+
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 5864 |
+
}
|
| 5865 |
+
},
|
| 5866 |
+
"node_modules/redux": {
|
| 5867 |
+
"version": "5.0.1",
|
| 5868 |
+
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
| 5869 |
+
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
| 5870 |
+
"license": "MIT"
|
| 5871 |
+
},
|
| 5872 |
+
"node_modules/redux-thunk": {
|
| 5873 |
+
"version": "3.1.0",
|
| 5874 |
+
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
| 5875 |
+
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
| 5876 |
+
"license": "MIT",
|
| 5877 |
+
"peerDependencies": {
|
| 5878 |
+
"redux": "^5.0.0"
|
| 5879 |
+
}
|
| 5880 |
+
},
|
| 5881 |
"node_modules/reflect.getprototypeof": {
|
| 5882 |
"version": "1.0.10",
|
| 5883 |
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
|
|
|
| 5922 |
"url": "https://github.com/sponsors/ljharb"
|
| 5923 |
}
|
| 5924 |
},
|
| 5925 |
+
"node_modules/reselect": {
|
| 5926 |
+
"version": "5.1.1",
|
| 5927 |
+
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
| 5928 |
+
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
| 5929 |
+
"license": "MIT"
|
| 5930 |
+
},
|
| 5931 |
"node_modules/resolve": {
|
| 5932 |
"version": "1.22.11",
|
| 5933 |
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
|
|
|
| 6170 |
}
|
| 6171 |
},
|
| 6172 |
"node_modules/sharp/node_modules/semver": {
|
| 6173 |
+
"version": "7.7.3",
|
| 6174 |
+
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
| 6175 |
+
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
| 6176 |
"license": "ISC",
|
| 6177 |
"optional": true,
|
| 6178 |
"bin": {
|
|
|
|
| 6496 |
"url": "https://github.com/sponsors/ljharb"
|
| 6497 |
}
|
| 6498 |
},
|
| 6499 |
+
"node_modules/tailwind-merge": {
|
| 6500 |
+
"version": "3.4.0",
|
| 6501 |
+
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
| 6502 |
+
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
|
| 6503 |
+
"license": "MIT",
|
| 6504 |
+
"funding": {
|
| 6505 |
+
"type": "github",
|
| 6506 |
+
"url": "https://github.com/sponsors/dcastil"
|
| 6507 |
+
}
|
| 6508 |
+
},
|
| 6509 |
"node_modules/tailwindcss": {
|
| 6510 |
"version": "4.1.18",
|
| 6511 |
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
|
|
|
| 6527 |
"url": "https://opencollective.com/webpack"
|
| 6528 |
}
|
| 6529 |
},
|
| 6530 |
+
"node_modules/tiny-invariant": {
|
| 6531 |
+
"version": "1.3.3",
|
| 6532 |
+
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
| 6533 |
+
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
| 6534 |
+
"license": "MIT"
|
| 6535 |
+
},
|
| 6536 |
"node_modules/tinyglobby": {
|
| 6537 |
"version": "0.2.15",
|
| 6538 |
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
|
|
|
| 6745 |
}
|
| 6746 |
},
|
| 6747 |
"node_modules/typescript-eslint": {
|
| 6748 |
+
"version": "8.53.0",
|
| 6749 |
+
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.53.0.tgz",
|
| 6750 |
+
"integrity": "sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==",
|
| 6751 |
"dev": true,
|
| 6752 |
"license": "MIT",
|
| 6753 |
"dependencies": {
|
| 6754 |
+
"@typescript-eslint/eslint-plugin": "8.53.0",
|
| 6755 |
+
"@typescript-eslint/parser": "8.53.0",
|
| 6756 |
+
"@typescript-eslint/typescript-estree": "8.53.0",
|
| 6757 |
+
"@typescript-eslint/utils": "8.53.0"
|
| 6758 |
},
|
| 6759 |
"engines": {
|
| 6760 |
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
|
|
|
| 6791 |
"version": "6.21.0",
|
| 6792 |
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
| 6793 |
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
|
|
|
| 6794 |
"license": "MIT"
|
| 6795 |
},
|
| 6796 |
"node_modules/unrs-resolver": {
|
|
|
|
| 6869 |
"punycode": "^2.1.0"
|
| 6870 |
}
|
| 6871 |
},
|
| 6872 |
+
"node_modules/use-sync-external-store": {
|
| 6873 |
+
"version": "1.6.0",
|
| 6874 |
+
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
| 6875 |
+
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
| 6876 |
+
"license": "MIT",
|
| 6877 |
+
"peerDependencies": {
|
| 6878 |
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 6879 |
+
}
|
| 6880 |
+
},
|
| 6881 |
+
"node_modules/victory-vendor": {
|
| 6882 |
+
"version": "37.3.6",
|
| 6883 |
+
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
| 6884 |
+
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
| 6885 |
+
"license": "MIT AND ISC",
|
| 6886 |
+
"dependencies": {
|
| 6887 |
+
"@types/d3-array": "^3.0.3",
|
| 6888 |
+
"@types/d3-ease": "^3.0.0",
|
| 6889 |
+
"@types/d3-interpolate": "^3.0.1",
|
| 6890 |
+
"@types/d3-scale": "^4.0.2",
|
| 6891 |
+
"@types/d3-shape": "^3.1.0",
|
| 6892 |
+
"@types/d3-time": "^3.0.0",
|
| 6893 |
+
"@types/d3-timer": "^3.0.0",
|
| 6894 |
+
"d3-array": "^3.1.6",
|
| 6895 |
+
"d3-ease": "^3.0.1",
|
| 6896 |
+
"d3-interpolate": "^3.0.1",
|
| 6897 |
+
"d3-scale": "^4.0.2",
|
| 6898 |
+
"d3-shape": "^3.1.0",
|
| 6899 |
+
"d3-time": "^3.0.0",
|
| 6900 |
+
"d3-timer": "^3.0.1"
|
| 6901 |
+
}
|
| 6902 |
+
},
|
| 6903 |
"node_modules/which": {
|
| 6904 |
"version": "2.0.2",
|
| 6905 |
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
|
|
|
| 6984 |
}
|
| 6985 |
},
|
| 6986 |
"node_modules/which-typed-array": {
|
| 6987 |
+
"version": "1.1.19",
|
| 6988 |
+
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
| 6989 |
+
"integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==",
|
| 6990 |
"dev": true,
|
| 6991 |
"license": "MIT",
|
| 6992 |
"dependencies": {
|
|
|
|
| 7015 |
"node": ">=0.10.0"
|
| 7016 |
}
|
| 7017 |
},
|
| 7018 |
+
"node_modules/ws": {
|
| 7019 |
+
"version": "8.19.0",
|
| 7020 |
+
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
| 7021 |
+
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
| 7022 |
+
"license": "MIT",
|
| 7023 |
+
"engines": {
|
| 7024 |
+
"node": ">=10.0.0"
|
| 7025 |
+
},
|
| 7026 |
+
"peerDependencies": {
|
| 7027 |
+
"bufferutil": "^4.0.1",
|
| 7028 |
+
"utf-8-validate": ">=5.0.2"
|
| 7029 |
+
},
|
| 7030 |
+
"peerDependenciesMeta": {
|
| 7031 |
+
"bufferutil": {
|
| 7032 |
+
"optional": true
|
| 7033 |
+
},
|
| 7034 |
+
"utf-8-validate": {
|
| 7035 |
+
"optional": true
|
| 7036 |
+
}
|
| 7037 |
+
}
|
| 7038 |
+
},
|
| 7039 |
"node_modules/yallist": {
|
| 7040 |
"version": "3.1.1",
|
| 7041 |
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
|
|
|
| 7057 |
}
|
| 7058 |
},
|
| 7059 |
"node_modules/zod": {
|
| 7060 |
+
"version": "4.3.5",
|
| 7061 |
+
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
|
| 7062 |
+
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
| 7063 |
"dev": true,
|
| 7064 |
"license": "MIT",
|
| 7065 |
"funding": {
|
Frontend/package.json
CHANGED
|
@@ -9,9 +9,14 @@
|
|
| 9 |
"lint": "eslint"
|
| 10 |
},
|
| 11 |
"dependencies": {
|
| 12 |
-
"
|
|
|
|
|
|
|
|
|
|
| 13 |
"react": "19.2.3",
|
| 14 |
-
"react-dom": "19.2.3"
|
|
|
|
|
|
|
| 15 |
},
|
| 16 |
"devDependencies": {
|
| 17 |
"@tailwindcss/postcss": "^4",
|
|
@@ -19,7 +24,7 @@
|
|
| 19 |
"@types/react": "^19",
|
| 20 |
"@types/react-dom": "^19",
|
| 21 |
"eslint": "^9",
|
| 22 |
-
"eslint-config-next": "16.1.
|
| 23 |
"tailwindcss": "^4",
|
| 24 |
"typescript": "^5"
|
| 25 |
}
|
|
|
|
| 9 |
"lint": "eslint"
|
| 10 |
},
|
| 11 |
"dependencies": {
|
| 12 |
+
"@supabase/supabase-js": "^2.90.1",
|
| 13 |
+
"clsx": "^2.1.1",
|
| 14 |
+
"lucide-react": "^0.562.0",
|
| 15 |
+
"next": "16.1.1",
|
| 16 |
"react": "19.2.3",
|
| 17 |
+
"react-dom": "19.2.3",
|
| 18 |
+
"recharts": "^3.6.0",
|
| 19 |
+
"tailwind-merge": "^3.4.0"
|
| 20 |
},
|
| 21 |
"devDependencies": {
|
| 22 |
"@tailwindcss/postcss": "^4",
|
|
|
|
| 24 |
"@types/react": "^19",
|
| 25 |
"@types/react-dom": "^19",
|
| 26 |
"eslint": "^9",
|
| 27 |
+
"eslint-config-next": "16.1.1",
|
| 28 |
"tailwindcss": "^4",
|
| 29 |
"typescript": "^5"
|
| 30 |
}
|
Frontend/tsconfig.tsbuildinfo
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|