0xarchit's picture
frontend v2 refactor and enhancements
c2bc4c7
"use client";
import { useMemo, useState } from "react";
import { apiGet, apiPost } from "@/lib/api";
import { useCachedFetch } from "@/hooks/useCachedFetch";
import {
HardHat,
Plus,
Search,
Filter,
CheckCircle2,
AlertTriangle,
TrendingUp,
} from "lucide-react";
import { Skeleton } from "@/components/ui/Skeleton";
interface Department {
id: string;
name: string;
code: string;
}
interface Worker {
id: string;
name: string;
email: string;
role: string;
department_id: string;
is_active: boolean;
current_workload: number;
max_workload: number;
resolved_total?: number;
efficiency?: number;
}
interface WorkerPerformance {
id: string;
resolved_total: number;
efficiency: number;
}
export default function WorkersPage() {
const {
data: departmentsData,
loading: deptLoading,
revalidate: revalidateDept,
} = useCachedFetch<Department[]>("/admin/departments");
const {
data: workersData,
loading: workersLoading,
revalidate: revalidateWorkers,
} = useCachedFetch<Worker[]>("/admin/members");
const {
data: perfData,
loading: perfLoading,
revalidate: revalidatePerf,
} = useCachedFetch<WorkerPerformance[]>("/admin/workers/performance");
const [showForm, setShowForm] = useState(false);
const [formData, setFormData] = useState({
name: "",
email: "",
password: "",
department_id: "",
role: "worker",
});
const [search, setSearch] = useState("");
const departments = departmentsData || [];
const workers = useMemo(() => {
if (!workersData) return [];
const perfMap = new Map((perfData || []).map((p) => [p.id, p]));
return workersData.map((w) => {
const perf = perfMap.get(w.id);
return {
...w,
resolved_total: perf?.resolved_total || 0,
efficiency: perf?.efficiency || 0,
};
});
}, [workersData, perfData]);
const loading = deptLoading || workersLoading || perfLoading;
const refreshAll = () => {
revalidateDept();
revalidateWorkers();
revalidatePerf();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await apiPost("/admin/members", formData);
setShowForm(false);
setFormData({
name: "",
email: "",
password: "",
department_id: "",
role: "worker",
});
refreshAll();
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Failed to create worker";
alert(message);
}
};
const getDepartmentName = (deptId: string) => {
const dept = departments.find((d) => d.id === deptId);
return dept ? dept.name : "Unassigned";
};
if (loading) {
return (
<div className="space-y-6 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex justify-between items-center mb-6">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-32" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-48 rounded-2xl" />
))}
</div>
</div>
);
}
const filteredWorkers = workers
.filter((w) => w.role !== "admin")
.filter(
(w) =>
search === "" ||
w.name.toLowerCase().includes(search.toLowerCase()) ||
w.email.toLowerCase().includes(search.toLowerCase()),
);
return (
<div className="space-y-8 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h2 className="text-2xl font-black text-slate-900">
Workforce Management
</h2>
<p className="text-sm text-slate-500 font-medium">
Manage field workers, assign tasks, and monitor performance.
</p>
</div>
<button
onClick={() => setShowForm(true)}
className="px-4 py-2 bg-urban-primary text-white font-semibold rounded-xl hover:bg-emerald-600 transition shadow-sm flex items-center gap-2"
>
<Plus className="w-4 h-4" /> Enroll Worker
</button>
</div>
{showForm && (
<div className="bg-white/80 backdrop-blur-md rounded-2xl shadow-urban-md border border-slate-200/70 overflow-hidden">
<div className="bg-slate-50/80 px-6 py-4 border-b border-slate-200/70">
<h2 className="text-lg font-black text-slate-800">
New Worker Enrollment
</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Full Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
className="w-full px-4 py-2.5 bg-white/70 border border-slate-300 rounded-xl text-slate-900 focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 outline-none"
placeholder="e.g. John Doe"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Email Address
</label>
<input
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
className="w-full px-4 py-2.5 bg-white/70 border border-slate-300 rounded-xl text-slate-900 focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 outline-none"
placeholder="worker@city.gov"
required
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Set Password
</label>
<input
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
className="w-full px-4 py-2.5 bg-white/70 border border-slate-300 rounded-xl text-slate-900 focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 outline-none"
required
minLength={8}
placeholder="••••••••"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Assign Department
</label>
<select
title="department"
value={formData.department_id}
onChange={(e) =>
setFormData({ ...formData, department_id: e.target.value })
}
className="w-full px-4 py-2.5 bg-white/70 border border-slate-300 rounded-xl text-slate-900 focus:ring-4 focus:ring-urban-primary/10 focus:border-urban-primary/40 outline-none"
required
>
<option value="">Select Department...</option>
{departments.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
type="submit"
className="px-6 py-2.5 bg-urban-primary text-white font-semibold rounded-xl hover:bg-emerald-600 transition shadow-sm"
>
Enroll Worker
</button>
<button
type="button"
onClick={() => setShowForm(false)}
className="px-6 py-2.5 bg-white/80 text-slate-700 font-semibold rounded-xl border border-slate-300 hover:bg-slate-50 transition"
>
Cancel
</button>
</div>
</form>
</div>
)}
<div className="flex gap-4 mb-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<input
type="text"
placeholder="Search workers by name or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 bg-white/70 border border-slate-200 rounded-xl text-sm focus:border-urban-primary/40 focus:ring-4 focus:ring-urban-primary/10 outline-none"
/>
</div>
<button className="px-3 py-2.5 bg-white/70 border border-slate-200 rounded-xl text-slate-600 flex items-center gap-2 hover:bg-slate-50">
<Filter className="w-4 h-4" /> Filter
</button>
</div>
<div className="space-y-4">
<div className="flex justify-between items-center px-1">
<p className="text-sm font-medium text-slate-500">
Active Workforce ({filteredWorkers.length})
</p>
</div>
{filteredWorkers.length === 0 ? (
<div className="text-center py-16 bg-white/70 backdrop-blur-md rounded-2xl border border-slate-200/70">
<HardHat className="w-12 h-12 mx-auto text-slate-300" />
<p className="text-slate-500 mt-2">No field workers found.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredWorkers.map((worker) => (
<div
key={worker.id}
className="bg-white/80 backdrop-blur-md p-5 rounded-2xl border border-slate-200/70 shadow-urban-sm hover:shadow-urban-md transition-all"
>
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-urban-primary/10 flex items-center justify-center text-lg font-bold text-urban-primary border border-urban-primary/20">
{worker.name.charAt(0)}
</div>
<div>
<h3 className="font-bold text-slate-900 leading-tight">
{worker.name}
</h3>
<p className="text-xs text-slate-500">{worker.email}</p>
</div>
</div>
{!worker.is_active && (
<span className="px-2 py-0.5 bg-red-100 text-red-700 text-xs font-bold rounded">
INACTIVE
</span>
)}
</div>
<div className="py-3 border-t border-slate-100/70 mb-3 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-slate-500">Department</span>
<span className="font-medium text-slate-900">
{getDepartmentName(worker.department_id)}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-500">Efficiency</span>
<span className="font-medium text-slate-900 flex items-center gap-1">
{worker.efficiency}{" "}
<span className="text-xs text-slate-400">/week</span>
{worker.efficiency && worker.efficiency > 5 && (
<TrendingUp className="w-3 h-3 text-green-500" />
)}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-500">Total Resolved</span>
<span className="font-medium text-slate-900 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3 text-green-500" />
{worker.resolved_total}
</span>
</div>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-slate-500 font-medium">
Current Workload
</span>
<span className="text-slate-900 font-bold">
{worker.current_workload} / {worker.max_workload}
</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-2 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
worker.current_workload >= worker.max_workload
? "bg-red-500"
: worker.current_workload > worker.max_workload * 0.7
? "bg-amber-500"
: "bg-urban-primary"
}`}
style={{
width: `${Math.min(
(worker.current_workload /
(worker.max_workload || 10)) *
100,
100,
)}%`,
}}
></div>
</div>
{worker.current_workload >= worker.max_workload && (
<div className="mt-2 flex items-center gap-1 text-xs text-red-600 font-medium">
<AlertTriangle className="w-3 h-3" /> Overloaded
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}