o134's picture
Upload full TeamTasker system with all fixes
8314cf4 verified
Raw
History Blame Contribute Delete
25.2 kB
import { useState } from "react";
import {
useListUsers,
useUpdateUser,
useGetDashboardAnalytics,
useListTaskTypes,
useToggleTaskType,
getListTaskTypesQueryKey,
getListUsersQueryKey,
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { TEAMS, getTeamColor } from "../lib/constants";
import { Users, BarChart3, Settings, ShieldCheck, ShieldAlert, Eye, EyeOff } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "../context/auth-context";
type TabId = "users" | "analytics" | "taskTypes";
const TABS: { id: TabId; label: string; icon: typeof Users }[] = [
{ id: "users", label: "المستخدمون", icon: Users },
{ id: "analytics", label: "التحليلات", icon: BarChart3 },
{ id: "taskTypes", label: "أنواع المهام", icon: Settings },
];
// Updated roles with new structure
const CANONICAL_ROLES = [
{ value: "admin", label: "مدير النظام", cls: "bg-red-100 text-red-700 border-red-200" },
{ value: "head_of_team", label: "رئيس الفرق", cls: "bg-orange-100 text-orange-700 border-orange-200" },
{ value: "team_lead", label: "قائد فريق", cls: "bg-blue-100 text-blue-700 border-blue-200" },
{ value: "member", label: "عضو", cls: "bg-slate-100 text-slate-600 border-slate-200" },
];
const ROLE_MAP: Record<string, { label: string; cls: string }> = {
admin: { label: "مدير النظام", cls: "bg-red-100 text-red-700 border-red-200" },
head_of_team: { label: "رئيس الفرق", cls: "bg-orange-100 text-orange-700 border-orange-200" },
team_lead: { label: "قائد فريق", cls: "bg-blue-100 text-blue-700 border-blue-200" },
member: { label: "عضو", cls: "bg-slate-100 text-slate-600 border-slate-200" },
// Legacy support for display
dept_manager: { label: "قائد فريق", cls: "bg-blue-100 text-blue-700 border-blue-200" },
team_leader: { label: "قائد فريق", cls: "bg-blue-100 text-blue-700 border-blue-200" },
};
function RoleBadge({ role }: { role: string }) {
const r = ROLE_MAP[role] ?? { label: role, cls: "bg-slate-100 text-slate-500 border-slate-200" };
return (
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold border ${r.cls}`}>
{r.label}
</span>
);
}
export default function Admin() {
const [tab, setTab] = useState<TabId>("users");
const queryClient = useQueryClient();
const { toast } = useToast();
const { activeUser, token } = useAuth();
const { data: users, isLoading: usersLoading } = useListUsers();
const { data: analytics, isLoading: analyticsLoading } = useGetDashboardAnalytics();
const { data: taskTypes, isLoading: taskTypesLoading } = useListTaskTypes({});
const toggleTaskType = useToggleTaskType();
const updateUser = useUpdateUser();
const handleToggleTaskType = (id: number) => {
toggleTaskType.mutate({ id }, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getListTaskTypesQueryKey() });
toast({ title: "تم تحديث حالة نوع المهمة" });
},
onError: () => toast({ title: "فشل التحديث", variant: "destructive" }),
});
};
const [editUser, setEditUser] = useState<{ id: number; name: string; team: string; role: string } | null>(null);
const [editTeam, setEditTeam] = useState("");
const [editRole, setEditRole] = useState("");
// Add User Form States
const [isAddUserOpen, setIsAddUserOpen] = useState(false);
const [newUserName, setNewUserName] = useState("");
const [newUserEmail, setNewUserEmail] = useState("");
const [newUserPassword, setNewUserPassword] = useState("");
const [newUserTeam, setNewUserTeam] = useState("");
const [newUserRole, setNewUserRole] = useState("member");
const [showPassword, setShowPassword] = useState(false);
const [isSubmittingUser, setIsSubmittingUser] = useState(false);
const openEditUser = (u: { id: number; name: string; team: string; role: string }) => {
setEditUser(u);
setEditTeam(u.team);
setEditRole(u.role);
};
const handleSaveUser = () => {
if (!editUser) return;
updateUser.mutate(
{ id: editUser.id, data: { team: editTeam, role: editRole } },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
toast({ title: "تم تحديث بيانات المستخدم" });
setEditUser(null);
},
onError: () => toast({ title: "فشل التحديث", variant: "destructive" }),
}
);
};
const handleRoleChange = (userId: number, newRole: string) => {
updateUser.mutate(
{ id: userId, data: { role: newRole } },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
toast({ title: "تم تحديث الدور" });
},
onError: () => toast({ title: "فشل التحديث", variant: "destructive" }),
}
);
};
// Grant/revoke admin — only admin can do this
const handleGrantAdmin = (userId: number, currentRole: string) => {
const newRole = currentRole === "admin" ? "team_lead" : "admin";
const confirmMsg = currentRole === "admin"
? "سيتم إلغاء صلاحيات الإدارة من هذا المستخدم. هل أنت متأكد؟"
: "سيتم منح صلاحيات الإدارة الكاملة لهذا المستخدم. هل أنت متأكد؟";
if (!window.confirm(confirmMsg)) return;
updateUser.mutate(
{ id: userId, data: { role: newRole } },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
toast({ title: currentRole === "admin" ? "تم إلغاء صلاحيات الإدارة" : "تم منح صلاحيات الإدارة" });
},
onError: () => toast({ title: "فشل التحديث", variant: "destructive" }),
}
);
};
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
if (!newUserName.trim() || !newUserEmail.trim() || newUserPassword.length < 8 || !newUserTeam) {
return;
}
setIsSubmittingUser(true);
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: newUserName,
email: newUserEmail,
password: newUserPassword,
team: newUserTeam,
role: newUserRole,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "حدث خطأ أثناء إضافة الموظف");
}
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
toast({ title: "تم إضافة الموظف بنجاح" });
setIsAddUserOpen(false);
// Reset form
setNewUserName("");
setNewUserEmail("");
setNewUserPassword("");
setNewUserTeam("");
setNewUserRole("member");
setShowPassword(false);
} catch (err: any) {
toast({
title: err.message || "فشل إضافة الموظف",
variant: "destructive",
});
} finally {
setIsSubmittingUser(false);
}
};
const isFormInvalid = !newUserName.trim() || !newUserEmail.trim() || newUserPassword.length < 8 || !newUserTeam;
return (
<div className="p-8 max-w-6xl mx-auto space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">لوحة الإدارة</h1>
<p className="text-slate-500 mt-1">إدارة المستخدمين والأدوار والتحليلات</p>
</div>
<div className="flex gap-1 bg-slate-100 rounded-lg p-1 w-fit">
{TABS.map(t => {
const Icon = t.icon;
return (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
tab === t.id ? "bg-white text-slate-900 shadow-sm" : "text-slate-600 hover:text-slate-900"
}`}
>
<Icon className="h-4 w-4" />
{t.label}
</button>
);
})}
</div>
{tab === "users" && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<CardTitle>جميع المستخدمين ({users?.length ?? 0})</CardTitle>
<Button size="sm" variant="default" onClick={() => setIsAddUserOpen(true)}>+ إضافة موظف</Button>
</div>
<div className="flex items-center gap-1.5 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-md px-2.5 py-1.5">
<ShieldCheck className="h-3.5 w-3.5" />
صلاحية الإدارة — فقط لك كمدير النظام
</div>
</div>
</CardHeader>
<CardContent>
{usersLoading ? (
<div className="space-y-3">
{[1,2,3].map(i => <Skeleton key={i} className="h-16 w-full" />)}
</div>
) : (
<div className="space-y-2">
{users?.map(user => (
<div
key={user.id}
className="flex items-center gap-4 p-3 rounded-lg border hover:bg-slate-50 transition-colors"
>
<Avatar className="h-10 w-10">
<AvatarFallback style={{ backgroundColor: user.avatarColor, color: "#fff", fontSize: "12px" }}>
{user.name.substring(0, 2)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">{user.name}</p>
<p className="text-xs text-slate-400">{user.email}</p>
</div>
<div
className="px-2.5 py-1 rounded-full text-xs font-medium text-white flex-shrink-0"
style={{ backgroundColor: getTeamColor(user.team) }}
>
{user.team}
</div>
<RoleBadge role={user.role} />
<div className="flex items-center gap-2 flex-shrink-0">
{/* Grant/revoke admin — only available to the current admin, and not on themselves */}
{activeUser?.role === "admin" && user.id !== activeUser?.id && (
<Button
variant={user.role === "admin" ? "destructive" : "outline"}
size="sm"
className="text-xs gap-1.5"
onClick={() => handleGrantAdmin(user.id, user.role)}
title={user.role === "admin" ? "إلغاء صلاحيات الإدارة" : "منح صلاحيات الإدارة"}
>
{user.role === "admin"
? <><ShieldAlert className="h-3.5 w-3.5" /> إلغاء الإدارة</>
: <><ShieldCheck className="h-3.5 w-3.5" /> منح إدارة</>
}
</Button>
)}
<Button
variant="outline"
size="sm"
className="text-xs flex-shrink-0"
onClick={() => openEditUser(user)}
>
تعديل
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
{tab === "analytics" && (
<div className="space-y-6">
{analyticsLoading ? (
<div className="space-y-4">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-64 w-full" />
</div>
) : analytics ? (
<>
<Card>
<CardHeader><CardTitle>المهام هذا الأسبوع مقارنة بالأسبوع الماضي</CardTitle></CardHeader>
<CardContent>
<div className="flex items-end gap-2 h-40 mt-2">
{analytics.weekly.map(d => {
const maxVal = Math.max(...analytics.weekly.flatMap(w => [w.thisWeek, w.lastWeek]), 1);
const thisH = Math.round((d.thisWeek / maxVal) * 100);
const lastH = Math.round((d.lastWeek / maxVal) * 100);
return (
<div key={d.day} className="flex-1 flex flex-col items-center gap-1">
<div className="flex items-end gap-0.5 w-full justify-center h-32">
<div className="w-4 rounded-t bg-primary/30 transition-all" style={{ height: `${lastH}%` }} title={`الأسبوع الماضي: ${d.lastWeek}`} />
<div className="w-4 rounded-t bg-primary transition-all" style={{ height: `${thisH}%` }} title={`هذا الأسبوع: ${d.thisWeek}`} />
</div>
<span className="text-xs text-slate-400">{d.day}</span>
</div>
);
})}
</div>
<div className="flex items-center gap-4 mt-3 text-xs text-slate-500 justify-center">
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded bg-primary/30" /> الأسبوع الماضي</div>
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded bg-primary" /> هذا الأسبوع</div>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle>المهام حسب الفريق</CardTitle></CardHeader>
<CardContent>
<div className="space-y-3">
{analytics.byTeam.filter(t => t.totalTasks > 0).map(t => {
const max = Math.max(...analytics.byTeam.map(x => x.totalTasks), 1);
const pct = Math.round((t.totalTasks / max) * 100);
return (
<div key={t.team} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-slate-600 truncate max-w-[160px]">{t.team}</span>
<span className="font-semibold text-slate-800">{t.totalTasks}</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-1.5">
<div className="h-1.5 rounded-full transition-all" style={{ width: `${pct}%`, backgroundColor: getTeamColor(t.team) }} />
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>أكثر 5 عملاء نشاطاً</CardTitle></CardHeader>
<CardContent>
{analytics.topClients.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-8">لا توجد مهام مرتبطة بعملاء بعد</p>
) : (
<div className="space-y-3">
{analytics.topClients.map((c, i) => (
<div key={c?.id} className="flex items-center gap-3">
<span className="text-slate-400 text-sm font-medium w-5 text-center">{i + 1}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{c?.name}</p>
<p className="text-xs text-slate-400">{c?.completedTasks} / {c?.totalTasks} مكتملة</p>
</div>
<div className="w-16 bg-slate-100 rounded-full h-1.5">
<div className="h-1.5 rounded-full bg-primary" style={{ width: `${c?.totalTasks ? Math.round(((c?.completedTasks ?? 0) / c.totalTasks) * 100) : 0}%` }} />
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</>
) : null}
</div>
)}
{tab === "taskTypes" && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>أنواع المهام ({taskTypes?.length ?? 0})</CardTitle>
<span className="text-xs text-slate-400">يمكن تفعيل/إيقاف كل نوع</span>
</div>
</CardHeader>
<CardContent>
{taskTypesLoading ? (
<div className="space-y-3">
{[1,2,3,4].map(i => <Skeleton key={i} className="h-14 w-full" />)}
</div>
) : taskTypes && taskTypes.length > 0 ? (
<div className="space-y-2">
{taskTypes.map(tt => (
<div key={tt.id} className="flex items-center gap-4 p-3 rounded-lg border hover:bg-slate-50 transition-colors">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">{tt.name}</p>
<div className="flex items-center gap-2 text-xs text-slate-500 mt-0.5">
<span className="px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 font-medium">{tt.fromDeptSlug}</span>
<span></span>
<span className="px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 font-medium">{tt.toDeptSlug}</span>
</div>
</div>
<Badge
variant="outline"
className={`text-xs cursor-pointer select-none ${
tt.isActive
? "text-green-600 border-green-200 bg-green-50 hover:bg-green-100"
: "text-slate-400 border-slate-200 bg-slate-50 hover:bg-slate-100"
}`}
onClick={() => handleToggleTaskType(tt.id)}
>
{tt.isActive ? "نشط" : "متوقف"}
</Badge>
</div>
))}
</div>
) : (
<div className="text-center py-10 text-slate-400">
<Settings className="h-10 w-10 mx-auto mb-3 opacity-40" />
<p className="text-sm">لا توجد أنواع مهام محددة</p>
</div>
)}
</CardContent>
</Card>
)}
{/* Add new user dialog */}
<Dialog open={isAddUserOpen} onOpenChange={setIsAddUserOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>إضافة موظف جديد</DialogTitle>
</DialogHeader>
<form onSubmit={handleCreateUser} className="space-y-4 mt-2">
<div className="space-y-2">
<Label htmlFor="name">الاسم الكامل*</Label>
<Input
id="name"
value={newUserName}
onChange={(e) => setNewUserName(e.target.value)}
placeholder="الاسم الكامل"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">البريد الإلكتروني*</Label>
<Input
id="email"
type="email"
value={newUserEmail}
onChange={(e) => setNewUserEmail(e.target.value)}
placeholder="example@domain.com"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">كلمة المرور*</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={newUserPassword}
onChange={(e) => setNewUserPassword(e.target.value)}
placeholder="كلمة المرور (8 أحرف على الأقل)"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-800 focus:outline-none"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="team">القسم*</Label>
<Select value={newUserTeam} onValueChange={setNewUserTeam}>
<SelectTrigger id="team">
<SelectValue placeholder="اختر القسم" />
</SelectTrigger>
<SelectContent>
{TEAMS.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="role">الدور</Label>
<Select value={newUserRole} onValueChange={setNewUserRole}>
<SelectTrigger id="role">
<SelectValue placeholder="اختر الدور" />
</SelectTrigger>
<SelectContent>
{CANONICAL_ROLES.map(r => (
<SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit" className="w-full mt-4" disabled={isFormInvalid || isSubmittingUser}>
{isSubmittingUser ? "جاري الإضافة..." : "إضافة موظف"}
</Button>
</form>
</DialogContent>
</Dialog>
{/* Edit user dialog */}
<Dialog open={!!editUser} onOpenChange={(o) => { if (!o) setEditUser(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>تعديل بيانات: {editUser?.name}</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-2">
<div className="space-y-2">
<Label>الفريق</Label>
<Select value={editTeam} onValueChange={setEditTeam}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{TEAMS.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>الدور</Label>
<Select value={editRole} onValueChange={setEditRole}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{CANONICAL_ROLES
// Only admin can assign admin role
.filter(r => activeUser?.role === "admin" || r.value !== "admin")
.map(r => (
<SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>
))
}
</SelectContent>
</Select>
</div>
<Button onClick={handleSaveUser} className="w-full" disabled={updateUser.isPending}>
{updateUser.isPending ? "جاري الحفظ..." : "حفظ التعديلات"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}