Files
carmanagement/apps/dashboard/src/app/(dashboard)/team/page.tsx
T
2026-05-24 23:58:54 -04:00

442 lines
19 KiB
TypeScript

'use client'
import { useEffect, useMemo, useState } from 'react'
import InviteModal from '@/components/team/InviteModal'
import EditMemberModal from '@/components/team/EditMemberModal'
import PermissionsMatrix from '@/components/team/PermissionsMatrix'
import { EMPLOYEE_TOKEN_KEY } from '@/lib/api'
import { useTeam, TeamMember, InvitePayload } from '@/hooks/useTeam'
import { useDashboardI18n } from '@/components/I18nProvider'
function initials(m: TeamMember) {
return (m.firstName[0] ?? '') + (m.lastName[0] ?? '')
}
function timeAgo(dateStr: string | null, language: 'en' | 'fr' | 'ar') {
if (!dateStr) {
return language === 'fr' ? 'Jamais' : language === 'ar' ? 'أبداً' : 'Never'
}
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 2) {
return language === 'fr' ? "À l'instant" : language === 'ar' ? 'الآن' : 'Just now'
}
if (mins < 60) {
return language === 'fr' ? `il y a ${mins} min` : language === 'ar' ? `منذ ${mins} دقيقة` : `${mins}m ago`
}
const hrs = Math.floor(mins / 60)
if (hrs < 24) {
return language === 'fr' ? `il y a ${hrs}h` : language === 'ar' ? `منذ ${hrs} ساعة` : `${hrs}h ago`
}
const days = Math.floor(hrs / 24)
if (days < 30) {
return language === 'fr' ? `il y a ${days}j` : language === 'ar' ? `منذ ${days} يوم` : `${days}d ago`
}
const locale = language === 'fr' ? 'fr' : language === 'ar' ? 'ar' : 'en'
return new Date(dateStr).toLocaleDateString(locale, { month: 'short', day: 'numeric' })
}
const AVATAR_BG: string[] = [
'bg-violet-100 dark:bg-violet-900/40 text-violet-700 dark:text-violet-300',
'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300',
'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300',
'bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300',
'bg-pink-100 dark:bg-pink-900/40 text-pink-700 dark:text-pink-300',
]
function avatarColor(id: string) {
const hash = id.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0)
return AVATAR_BG[hash % AVATAR_BG.length]
}
const roleLabels: Record<string, Record<'en' | 'fr' | 'ar', string>> = {
OWNER: { en: 'Owner', fr: 'Propriétaire', ar: 'مالك' },
MANAGER: { en: 'Manager', fr: 'Manager', ar: 'مدير' },
AGENT: { en: 'Agent', fr: 'Agent', ar: 'وكيل' },
}
function RoleBadge({ role, language }: { role: string; language: 'en' | 'fr' | 'ar' }) {
const styles: Record<string, string> = {
OWNER: 'bg-violet-50 dark:bg-violet-950/40 text-violet-700 dark:text-violet-300 border-violet-100 dark:border-violet-900/50',
MANAGER: 'bg-blue-50 dark:bg-blue-950/40 text-blue-700 dark:text-blue-300 border-blue-100 dark:border-blue-900/50',
AGENT: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border-zinc-200 dark:border-zinc-700',
}
const label = roleLabels[role]?.[language] ?? (role.charAt(0) + role.slice(1).toLowerCase())
return (
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium ${styles[role] ?? styles.AGENT}`}>
{label}
</span>
)
}
function StatusBadge({ member, language }: { member: TeamMember; language: 'en' | 'fr' | 'ar' }) {
if (member.invitationStatus === 'pending') {
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-orange-50 dark:bg-orange-950/30 text-orange-700 dark:text-orange-400 border-orange-100 dark:border-orange-900/50">
{language === 'fr' ? 'Invitation en attente' : language === 'ar' ? 'دعوة قيد الانتظار' : 'Pending invite'}
</span>
)
}
if (!member.isActive) {
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-500 border-zinc-200 dark:border-zinc-700">
{language === 'fr' ? 'Désactivé' : language === 'ar' ? 'معطل' : 'Deactivated'}
</span>
)
}
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-100 dark:border-green-900/50">
{language === 'fr' ? 'Actif' : language === 'ar' ? 'نشط' : 'Active'}
</span>
)
}
export default function TeamPage() {
const { language } = useDashboardI18n()
const copy = {
en: {
title: 'Team',
subtitle: 'Manage your employees and their access levels',
inviteMember: 'Invite member',
totalMembers: 'Total members',
active: 'Active',
pendingInvite: 'Pending invite',
deactivated: 'Deactivated',
search: 'Search by name or email…',
allRoles: 'All roles',
owner: 'Owner',
manager: 'Manager',
agent: 'Agent',
allStatuses: 'All statuses',
pending: 'Pending',
loadingTeam: 'Loading team…',
noMembers: 'No members match your filters',
member: 'Member',
role: 'Role',
status: 'Status',
lastActive: 'Last active',
manage: 'Manage',
inviteSent: 'Invite sent to',
roleUpdated: 'Role updated',
memberDeactivated: 'Member deactivated',
memberReactivated: 'Member reactivated',
memberRemoved: 'Member removed',
you: '(you)',
},
fr: {
title: 'Équipe',
subtitle: "Gérez vos employés et leurs niveaux d'accès",
inviteMember: 'Inviter un membre',
totalMembers: 'Total membres',
active: 'Actif',
pendingInvite: 'Invitation en attente',
deactivated: 'Désactivé',
search: 'Rechercher par nom ou email…',
allRoles: 'Tous les rôles',
owner: 'Propriétaire',
manager: 'Manager',
agent: 'Agent',
allStatuses: 'Tous les statuts',
pending: 'En attente',
loadingTeam: "Chargement de l'équipe…",
noMembers: 'Aucun membre ne correspond aux filtres',
member: 'Membre',
role: 'Rôle',
status: 'Statut',
lastActive: 'Dernière activité',
manage: 'Gérer',
inviteSent: 'Invitation envoyée à',
roleUpdated: 'Rôle mis à jour',
memberDeactivated: 'Membre désactivé',
memberReactivated: 'Membre réactivé',
memberRemoved: 'Membre supprimé',
you: '(vous)',
},
ar: {
title: 'الفريق',
subtitle: 'إدارة الموظفين ومستويات الوصول الخاصة بهم',
inviteMember: 'دعوة عضو',
totalMembers: 'إجمالي الأعضاء',
active: 'نشط',
pendingInvite: 'دعوة قيد الانتظار',
deactivated: 'معطل',
search: 'ابحث بالاسم أو البريد الإلكتروني…',
allRoles: 'كل الأدوار',
owner: 'مالك',
manager: 'مدير',
agent: 'وكيل',
allStatuses: 'كل الحالات',
pending: 'قيد الانتظار',
loadingTeam: 'جارٍ تحميل الفريق…',
noMembers: 'لا يوجد أعضاء مطابقون للفلاتر',
member: 'العضو',
role: 'الدور',
status: 'الحالة',
lastActive: 'آخر نشاط',
manage: 'إدارة',
inviteSent: 'تم إرسال الدعوة إلى',
roleUpdated: 'تم تحديث الدور',
memberDeactivated: 'تم تعطيل العضو',
memberReactivated: 'تمت إعادة تفعيل العضو',
memberRemoved: 'تم حذف العضو',
you: '(أنت)',
},
}[language]
const { members, stats, loading, error, invite, updateRole, deactivate, reactivate, remove } = useTeam()
const [currentEmployeeId, setCurrentEmployeeId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState<string>('')
const [statusFilter, setStatusFilter] = useState<string>('')
const [inviteOpen, setInviteOpen] = useState(false)
const [editTarget, setEditTarget] = useState<TeamMember | null>(null)
const [toast, setToast] = useState<string | null>(null)
useEffect(() => {
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
if (!token) return
try {
const encoded = token.split('.')[1] ?? ''
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const payload = JSON.parse(atob(padded))
setCurrentEmployeeId(typeof payload?.sub === 'string' ? payload.sub : null)
} catch {
setCurrentEmployeeId(null)
}
}, [])
const currentEmployee = members.find((member) => member.id === currentEmployeeId)
const isOwner = currentEmployee?.role === 'OWNER'
function showToast(msg: string) {
setToast(msg)
setTimeout(() => setToast(null), 3000)
}
const filtered = useMemo(() => {
return members.filter((m) => {
const q = search.toLowerCase()
const matchQ = !q ||
`${m.firstName} ${m.lastName}`.toLowerCase().includes(q) ||
m.email.toLowerCase().includes(q)
const matchRole = !roleFilter || m.role === roleFilter
const matchStatus =
!statusFilter ||
(statusFilter === 'active' && m.isActive && m.invitationStatus === 'accepted') ||
(statusFilter === 'pending' && m.invitationStatus === 'pending') ||
(statusFilter === 'inactive' && !m.isActive && m.invitationStatus === 'accepted')
return matchQ && matchRole && matchStatus
})
}, [members, search, roleFilter, statusFilter])
const handleInvite = async (payload: InvitePayload) => {
await invite(payload)
showToast(`${copy.inviteSent} ${payload.email}`)
}
const handleUpdateRole = async (id: string, role: 'MANAGER' | 'AGENT') => {
await updateRole(id, role)
showToast(copy.roleUpdated)
}
const handleDeactivate = async (id: string) => {
await deactivate(id)
showToast(copy.memberDeactivated)
}
const handleReactivate = async (id: string) => {
await reactivate(id)
showToast(copy.memberReactivated)
}
const handleRemove = async (id: string) => {
await remove(id)
showToast(copy.memberRemoved)
}
return (
<div className="max-w-5xl mx-auto py-8 px-4 sm:px-6">
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-medium text-zinc-900 dark:text-white">{copy.title}</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
{copy.subtitle}
</p>
</div>
{isOwner && (
<button
onClick={() => setInviteOpen(true)}
className="flex items-center gap-1.5 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 16 16">
<path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
{copy.inviteMember}
</button>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
{[
{ label: copy.totalMembers, value: stats.total },
{ label: copy.active, value: stats.active },
{ label: copy.pendingInvite, value: stats.pending },
{ label: copy.deactivated, value: stats.inactive },
].map((s) => (
<div
key={s.label}
className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-4 flex flex-col gap-1"
>
<span className="text-xs text-zinc-500 dark:text-zinc-400">{s.label}</span>
<span className="text-2xl font-medium text-zinc-900 dark:text-white">{s.value}</span>
</div>
))}
</div>
<div className="flex flex-wrap gap-2 mb-4">
<div className="relative flex-1 min-w-48">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-400" fill="none" viewBox="0 0 14 14">
<circle cx="6" cy="6" r="4.5" stroke="currentColor" strokeWidth="1.2" />
<path d="M10 10L13 13" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
<input
type="text"
placeholder={copy.search}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-8 pr-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
>
<option value="">{copy.allRoles}</option>
<option value="OWNER">{copy.owner}</option>
<option value="MANAGER">{copy.manager}</option>
<option value="AGENT">{copy.agent}</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
>
<option value="">{copy.allStatuses}</option>
<option value="active">{copy.active}</option>
<option value="pending">{copy.pending}</option>
<option value="inactive">{copy.deactivated}</option>
</select>
</div>
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-10">
{loading ? (
<div className="py-16 flex flex-col items-center gap-2 text-zinc-400">
<div className="w-5 h-5 border-2 border-zinc-200 dark:border-zinc-700 border-t-zinc-500 rounded-full animate-spin" />
<span className="text-sm">{copy.loadingTeam}</span>
</div>
) : error ? (
<div className="py-12 text-center text-sm text-red-500">{error}</div>
) : filtered.length === 0 ? (
<div className="py-16 text-center">
<p className="text-sm text-zinc-500 dark:text-zinc-400">{copy.noMembers}</p>
</div>
) : (
<table className="w-full">
<thead>
<tr className="bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.member}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.role}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.status}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide hidden sm:table-cell">{copy.lastActive}</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{filtered.map((member) => (
<tr
key={member.id}
className={[
'border-b border-zinc-100 dark:border-zinc-800 last:border-0',
'hover:bg-zinc-50 dark:hover:bg-zinc-800/30 transition-colors',
].join(' ')}
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${avatarColor(member.id)}`}>
{initials(member)}
</div>
<div>
<div className="text-sm font-medium text-zinc-900 dark:text-white">
{member.firstName} {member.lastName}
{member.id === currentEmployeeId && (
<span className="ml-1.5 text-[10px] text-zinc-400">{copy.you}</span>
)}
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</div>
</div>
</div>
</td>
<td className="px-4 py-3">
<RoleBadge role={member.role} language={language} />
</td>
<td className="px-4 py-3">
<StatusBadge member={member} language={language} />
</td>
<td className="px-4 py-3 hidden sm:table-cell">
<span className="text-xs text-zinc-400 dark:text-zinc-500">
{member.invitationStatus === 'pending' ? '—' : timeAgo(member.lastActiveAt, language)}
</span>
</td>
<td className="px-4 py-3 text-right">
{isOwner && member.role !== 'OWNER' && (
<button
onClick={() => setEditTarget(member)}
className="text-xs px-3 py-1.5 border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
{copy.manage}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<PermissionsMatrix />
<InviteModal
open={inviteOpen}
onClose={() => setInviteOpen(false)}
onInvite={handleInvite}
/>
<EditMemberModal
member={editTarget}
open={!!editTarget}
onClose={() => setEditTarget(null)}
onUpdateRole={handleUpdateRole}
onDeactivate={handleDeactivate}
onReactivate={handleReactivate}
onRemove={handleRemove}
/>
{toast && (
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-2 px-4 py-2.5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-xl shadow-lg text-sm font-medium animate-in slide-in-from-bottom-2 duration-200">
<svg className="w-4 h-4 text-green-400 dark:text-green-600" fill="none" viewBox="0 0 16 16">
<path d="M3 8L6.5 11.5L13 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{toast}
</div>
)}
</div>
)
}