442 lines
19 KiB
TypeScript
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>
|
|
)
|
|
}
|