fix notification and add billing page and contract

This commit is contained in:
root
2026-05-13 00:09:39 -04:00
committed by Administrator
parent 1a39aa8433
commit 89621a163b
52 changed files with 5631 additions and 1110 deletions
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import type { TeamMember } from '@/hooks/useTeam'
import { useDashboardI18n } from '@/components/I18nProvider'
interface Props {
member: TeamMember | null
@@ -13,14 +14,81 @@ interface Props {
onRemove: (memberId: string) => Promise<void>
}
const ROLE_OPTIONS = [
{ value: 'MANAGER' as const, label: 'Manager', description: 'Full ops — no billing' },
{ value: 'AGENT' as const, label: 'Agent', description: 'Reservations & check-ins' },
]
type ActionState = 'idle' | 'saving' | 'deactivating' | 'reactivating' | 'removing'
type ConfirmAction = 'deactivate' | 'reactivate' | 'remove' | null
const copy = {
en: {
pendingInvite: 'Pending invite',
deactivated: 'Deactivated',
role: 'Role',
cancel: 'Cancel',
saveChanges: 'Save changes',
saving: 'Saving…',
working: 'Working…',
deactivate: 'Deactivate',
reactivate: 'Reactivate',
remove: 'Remove',
removePermanently: 'Remove permanently',
confirmDeactivateTitle: 'Deactivate this member?',
confirmRemoveTitle: 'Permanently remove this member?',
confirmReactivateTitle: 'Reactivate this member?',
confirmDeactivateDesc: "They'll immediately lose dashboard access. You can reactivate anytime.",
confirmRemoveDesc: "This will delete the employee record and revoke their dashboard access. This cannot be undone.",
confirmReactivateDesc: "They'll regain dashboard access with their current role.",
roleOptions: [
{ value: 'MANAGER' as const, label: 'Manager', description: 'Full ops — no billing' },
{ value: 'AGENT' as const, label: 'Agent', description: 'Reservations & check-ins' },
],
},
fr: {
pendingInvite: 'Invitation en attente',
deactivated: 'Désactivé',
role: 'Rôle',
cancel: 'Annuler',
saveChanges: 'Enregistrer',
saving: 'Enregistrement…',
working: 'En cours…',
deactivate: 'Désactiver',
reactivate: 'Réactiver',
remove: 'Supprimer',
removePermanently: 'Supprimer définitivement',
confirmDeactivateTitle: 'Désactiver ce membre ?',
confirmRemoveTitle: 'Supprimer définitivement ce membre ?',
confirmReactivateTitle: 'Réactiver ce membre ?',
confirmDeactivateDesc: "Il perdra immédiatement l'accès au tableau de bord. Vous pouvez le réactiver à tout moment.",
confirmRemoveDesc: "Cela supprimera le compte et révoquera son accès au tableau de bord. Cette action est irréversible.",
confirmReactivateDesc: "Il retrouvera l'accès au tableau de bord avec son rôle actuel.",
roleOptions: [
{ value: 'MANAGER' as const, label: 'Manager', description: 'Opérations complètes — sans facturation' },
{ value: 'AGENT' as const, label: 'Agent', description: 'Réservations et enregistrements' },
],
},
ar: {
pendingInvite: 'دعوة قيد الانتظار',
deactivated: 'معطل',
role: 'الدور',
cancel: 'إلغاء',
saveChanges: 'حفظ التغييرات',
saving: 'جارٍ الحفظ…',
working: 'جارٍ التنفيذ…',
deactivate: 'تعطيل',
reactivate: 'إعادة التفعيل',
remove: 'حذف',
removePermanently: 'حذف نهائياً',
confirmDeactivateTitle: 'تعطيل هذا العضو؟',
confirmRemoveTitle: 'حذف هذا العضو نهائياً؟',
confirmReactivateTitle: 'إعادة تفعيل هذا العضو؟',
confirmDeactivateDesc: 'سيفقد الوصول إلى لوحة التحكم فوراً. يمكنك إعادة التفعيل في أي وقت.',
confirmRemoveDesc: 'سيتم حذف سجل الموظف وإلغاء صلاحياته. هذا الإجراء لا يمكن التراجع عنه.',
confirmReactivateDesc: 'سيستعيد الوصول إلى لوحة التحكم بدوره الحالي.',
roleOptions: [
{ value: 'MANAGER' as const, label: 'مدير', description: 'عمليات كاملة — بدون فوترة' },
{ value: 'AGENT' as const, label: 'وكيل', description: 'الحجوزات والاستلام' },
],
},
}
export default function EditMemberModal({
member,
open,
@@ -30,6 +98,10 @@ export default function EditMemberModal({
onReactivate,
onRemove,
}: Props) {
const { language } = useDashboardI18n()
const t = copy[language]
const isRtl = language === 'ar'
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
const [actionState, setActionState] = useState<ActionState>('idle')
const [confirm, setConfirm] = useState<ConfirmAction>(null)
@@ -48,6 +120,8 @@ export default function EditMemberModal({
const isPending = member.invitationStatus === 'pending'
const isActive = member.isActive
const busy = actionState !== 'idle'
const initials = (member.firstName[0] ?? '') + (member.lastName[0] ?? '')
const handleSave = async () => {
if (role === member.role) { onClose(); return }
@@ -101,15 +175,16 @@ export default function EditMemberModal({
setConfirm(null)
}
const busy = actionState !== 'idle'
const initials = (member.firstName[0] ?? '') + (member.lastName[0] ?? '')
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
>
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
<div
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6"
dir={isRtl ? 'rtl' : 'ltr'}
>
{/* Member header */}
<div className="flex items-center gap-3 mb-5 pb-5 border-b border-zinc-100 dark:border-zinc-800">
<div className="w-10 h-10 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-sm font-medium text-zinc-600 dark:text-zinc-300">
{initials}
@@ -123,35 +198,36 @@ export default function EditMemberModal({
<div className="ml-auto">
{isPending && (
<span className="text-xs px-2 py-1 rounded-full bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border border-amber-100 dark:border-amber-900/50">
Pending invite
{t.pendingInvite}
</span>
)}
{!isPending && !isActive && (
<span className="text-xs px-2 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400">
Deactivated
{t.deactivated}
</span>
)}
</div>
</div>
{/* Confirm panel */}
{confirm && (
<div className="mb-4 px-4 py-3 rounded-xl border border-red-100 dark:border-red-900/50 bg-red-50 dark:bg-red-950/20">
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-1">
{confirm === 'deactivate' && 'Deactivate this member?'}
{confirm === 'remove' && 'Permanently remove this member?'}
{confirm === 'reactivate' && 'Reactivate this member?'}
{confirm === 'deactivate' && t.confirmDeactivateTitle}
{confirm === 'remove' && t.confirmRemoveTitle}
{confirm === 'reactivate' && t.confirmReactivateTitle}
</p>
<p className="text-xs text-red-600 dark:text-red-500 mb-3">
{confirm === 'deactivate' && "They'll immediately lose dashboard access. You can reactivate anytime."}
{confirm === 'remove' && "This will delete the employee record and revoke their dashboard access. This cannot be undone."}
{confirm === 'reactivate' && "They'll regain dashboard access with their current role."}
{confirm === 'deactivate' && t.confirmDeactivateDesc}
{confirm === 'remove' && t.confirmRemoveDesc}
{confirm === 'reactivate' && t.confirmReactivateDesc}
</p>
<div className="flex gap-2">
<div className={`flex gap-2 ${isRtl ? 'flex-row-reverse' : ''}`}>
<button
onClick={() => setConfirm(null)}
className="flex-1 text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
Cancel
{t.cancel}
</button>
<button
onClick={
@@ -167,23 +243,24 @@ export default function EditMemberModal({
: 'bg-red-600 text-white hover:bg-red-700',
].join(' ')}
>
{busy ? 'Working…' : (
confirm === 'deactivate' ? 'Deactivate' :
confirm === 'reactivate' ? 'Reactivate' :
'Remove permanently'
{busy ? t.working : (
confirm === 'deactivate' ? t.deactivate :
confirm === 'reactivate' ? t.reactivate :
t.removePermanently
)}
</button>
</div>
</div>
)}
{/* Role selector — only for active, non-pending members */}
{!isPending && isActive && (
<div className="mb-4">
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">
Role
{t.role}
</label>
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((option) => (
{t.roleOptions.map((option) => (
<button
key={option.value}
type="button"
@@ -209,15 +286,16 @@ export default function EditMemberModal({
</div>
)}
<div className="flex items-center gap-2 pt-4 border-t border-zinc-100 dark:border-zinc-800">
<div className="flex gap-2 mr-auto">
{/* Actions footer */}
<div className={`flex items-center gap-2 pt-4 border-t border-zinc-100 dark:border-zinc-800 ${isRtl ? 'flex-row-reverse' : ''}`}>
<div className={`flex gap-2 ${isRtl ? 'ml-auto' : 'mr-auto'}`}>
{isActive && !isPending && (
<button
onClick={() => setConfirm('deactivate')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg border border-red-200 dark:border-red-900/50 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
>
Deactivate
{t.deactivate}
</button>
)}
{!isActive && (
@@ -226,7 +304,7 @@ export default function EditMemberModal({
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 transition-colors"
>
Reactivate
{t.reactivate}
</button>
)}
<button
@@ -234,7 +312,7 @@ export default function EditMemberModal({
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
>
Remove
{t.remove}
</button>
</div>
@@ -243,7 +321,7 @@ export default function EditMemberModal({
disabled={busy}
className="px-4 py-2 text-sm 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 disabled:opacity-50"
>
Cancel
{t.cancel}
</button>
{isActive && !isPending && (
<button
@@ -251,7 +329,7 @@ export default function EditMemberModal({
disabled={busy}
className="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 disabled:opacity-50"
>
{actionState === 'saving' ? 'Saving…' : 'Save changes'}
{actionState === 'saving' ? t.saving : t.saveChanges}
</button>
)}
</div>
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import type { InvitePayload } from '@/hooks/useTeam'
import { useDashboardI18n } from '@/components/I18nProvider'
interface Props {
open: boolean
@@ -9,22 +10,95 @@ interface Props {
onInvite: (payload: InvitePayload) => Promise<void>
}
const ROLE_OPTIONS = [
{
value: 'MANAGER' as const,
label: 'Manager',
description: 'Full fleet ops — no billing or team settings',
permissions: ['Vehicles', 'Reservations', 'CRM', 'Offers', 'Reports', 'License approval'],
const copy = {
en: {
title: 'Invite a team member',
subtitle: "They'll receive an email invitation to join your dashboard",
firstName: 'First name',
lastName: 'Last name',
email: 'Email address',
role: 'Role',
cancel: 'Cancel',
sending: 'Sending…',
sendInvite: 'Send invite →',
failedInvite: 'Failed to send invitation',
more: (n: number) => `+${n} more`,
roleOptions: [
{
value: 'MANAGER' as const,
label: 'Manager',
description: 'Full fleet ops — no billing or team settings',
permissions: ['Vehicles', 'Reservations', 'CRM', 'Offers', 'Reports', 'License approval'],
},
{
value: 'AGENT' as const,
label: 'Agent',
description: 'Day-to-day operations only',
permissions: ['View fleet', 'Reservations', 'Check-in / Check-out', 'View customers'],
},
],
},
{
value: 'AGENT' as const,
label: 'Agent',
description: 'Day-to-day operations only',
permissions: ['View fleet', 'Reservations', 'Check-in / Check-out', 'View customers'],
fr: {
title: 'Inviter un membre',
subtitle: "Il recevra un email d'invitation pour rejoindre votre tableau de bord",
firstName: 'Prénom',
lastName: 'Nom',
email: 'Adresse email',
role: 'Rôle',
cancel: 'Annuler',
sending: 'Envoi…',
sendInvite: "Envoyer l'invitation →",
failedInvite: "Échec de l'envoi de l'invitation",
more: (n: number) => `+${n} de plus`,
roleOptions: [
{
value: 'MANAGER' as const,
label: 'Manager',
description: 'Opérations complètes — sans facturation ni paramètres équipe',
permissions: ['Véhicules', 'Réservations', 'CRM', 'Offres', 'Rapports', 'Validation permis'],
},
{
value: 'AGENT' as const,
label: 'Agent',
description: 'Opérations quotidiennes uniquement',
permissions: ['Voir la flotte', 'Réservations', 'Départ / Retour', 'Voir les clients'],
},
],
},
]
ar: {
title: 'دعوة عضو في الفريق',
subtitle: 'سيتلقى بريداً إلكترونياً للانضمام إلى لوحة التحكم',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
role: 'الدور',
cancel: 'إلغاء',
sending: 'جارٍ الإرسال…',
sendInvite: '← إرسال الدعوة',
failedInvite: 'فشل إرسال الدعوة',
more: (n: number) => `+${n} أكثر`,
roleOptions: [
{
value: 'MANAGER' as const,
label: 'مدير',
description: 'عمليات أسطول كاملة — بدون الفوترة أو إعدادات الفريق',
permissions: ['المركبات', 'الحجوزات', 'CRM', 'العروض', 'التقارير', 'الموافقة على الرخص'],
},
{
value: 'AGENT' as const,
label: 'وكيل',
description: 'العمليات اليومية فقط',
permissions: ['عرض الأسطول', 'الحجوزات', 'الاستلام / الإرجاع', 'عرض العملاء'],
},
],
},
}
export default function InviteModal({ open, onClose, onInvite }: Props) {
const { language } = useDashboardI18n()
const t = copy[language]
const isRtl = language === 'ar'
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [email, setEmail] = useState('')
@@ -48,12 +122,11 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
e.preventDefault()
setError(null)
setLoading(true)
try {
await onInvite({ firstName: firstName.trim(), lastName: lastName.trim(), email: email.trim(), role })
onClose()
} catch (err: any) {
setError(err.message ?? 'Failed to send invitation')
setError(err.message ?? t.failedInvite)
} finally {
setLoading(false)
}
@@ -66,19 +139,20 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
<div
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6"
dir={isRtl ? 'rtl' : 'ltr'}
>
<div className="mb-5">
<h2 className="text-lg font-medium text-zinc-900 dark:text-white">Invite a team member</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
They&apos;ll receive an email invitation to join your dashboard
</p>
<h2 className="text-lg font-medium text-zinc-900 dark:text-white">{t.title}</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">{t.subtitle}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
First name
{t.firstName}
</label>
<input
type="text"
@@ -91,7 +165,7 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
</div>
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Last name
{t.lastName}
</label>
<input
type="text"
@@ -106,7 +180,7 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Email address
{t.email}
</label>
<input
type="email"
@@ -120,10 +194,10 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Role
{t.role}
</label>
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((option) => (
{t.roleOptions.map((option) => (
<button
key={option.value}
type="button"
@@ -161,7 +235,7 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
))}
{option.permissions.length > 3 && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400">
+{option.permissions.length - 3} more
{t.more(option.permissions.length - 3)}
</span>
)}
</div>
@@ -176,20 +250,20 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
</div>
)}
<div className="flex gap-2 pt-2 border-t border-zinc-100 dark:border-zinc-800">
<div className={`flex gap-2 pt-2 border-t border-zinc-100 dark:border-zinc-800 ${isRtl ? 'flex-row-reverse' : ''}`}>
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 text-sm 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"
>
Cancel
{t.cancel}
</button>
<button
type="submit"
disabled={loading}
className="flex-1 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 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Sending…' : 'Send invite →'}
{loading ? t.sending : t.sendInvite}
</button>
</div>
</form>
@@ -1,9 +1,10 @@
'use client'
import { useDashboardI18n } from '@/components/I18nProvider'
type Access = 'full' | 'partial' | 'none'
interface Feature {
label: string
manager: Access
managerNote?: string
agent: Access
@@ -11,36 +12,138 @@ interface Feature {
}
const FEATURES: Feature[] = [
{ label: 'View fleet & vehicles', manager: 'full', agent: 'full' },
{ label: 'Add / edit vehicles', manager: 'full', agent: 'none' },
{ label: 'Publish / unpublish vehicle', manager: 'full', agent: 'none' },
{ label: 'Upload vehicle photos', manager: 'full', agent: 'none' },
{ label: 'View reservations', manager: 'full', agent: 'full' },
{ label: 'Create reservations', manager: 'full', agent: 'full' },
{ label: 'Cancel reservations', manager: 'full', agent: 'partial', agentNote: 'Own only' },
{ label: 'Check-in / check-out', manager: 'full', agent: 'full' },
{ label: 'Damage inspection', manager: 'full', agent: 'full' },
{ label: 'Customer CRM — view', manager: 'full', agent: 'full' },
{ label: 'Customer CRM — edit', manager: 'full', agent: 'partial', agentNote: 'Notes only' },
{ label: 'Approve driver licenses', manager: 'full', agent: 'none' },
{ label: 'Flag / blacklist customer', manager: 'full', agent: 'none' },
{ label: 'Offers & promotions', manager: 'full', agent: 'none' },
{ label: 'Analytics & reports', manager: 'full', agent: 'none' },
{ label: 'Contract & invoice PDF', manager: 'full', agent: 'full' },
{ label: 'Billing & subscription', manager: 'none', agent: 'none' },
{ label: 'Invite / remove staff', manager: 'none', agent: 'none' },
{ label: 'Brand & site settings', manager: 'none', agent: 'none' },
{ label: 'Payment settings', manager: 'none', agent: 'none' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'partial' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'partial' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'full' },
{ manager: 'none', agent: 'none' },
{ manager: 'none', agent: 'none' },
{ manager: 'none', agent: 'none' },
{ manager: 'none', agent: 'none' },
]
function AccessCell({ access, note }: { access: Access; note?: string }) {
const copy = {
en: {
heading: 'Role permissions',
subtitle: 'What each role can do across the dashboard. Owners have full access to everything.',
colFeature: 'Feature',
colManager: 'Manager',
colAgent: 'Agent',
full: 'Full',
noAccess: 'No access',
limited: 'Limited',
featureLabels: [
'View fleet & vehicles',
'Add / edit vehicles',
'Publish / unpublish vehicle',
'Upload vehicle photos',
'View reservations',
'Create reservations',
'Cancel reservations',
'Check-in / check-out',
'Damage inspection',
'Customer CRM — view',
'Customer CRM — edit',
'Approve driver licenses',
'Flag / blacklist customer',
'Offers & promotions',
'Analytics & reports',
'Contract & invoice PDF',
'Billing & subscription',
'Invite / remove staff',
'Brand & site settings',
'Payment settings',
],
agentNotes: ['', '', '', '', '', '', 'Own only', '', '', '', 'Notes only', '', '', '', '', '', '', '', '', ''],
},
fr: {
heading: 'Permissions par rôle',
subtitle: 'Ce que chaque rôle peut faire dans le tableau de bord. Les propriétaires ont un accès complet.',
colFeature: 'Fonctionnalité',
colManager: 'Manager',
colAgent: 'Agent',
full: 'Complet',
noAccess: 'Aucun accès',
limited: 'Limité',
featureLabels: [
'Voir la flotte et les véhicules',
'Ajouter / modifier des véhicules',
'Publier / dépublier un véhicule',
'Télécharger des photos de véhicule',
'Voir les réservations',
'Créer des réservations',
'Annuler des réservations',
'Départ / retour',
'Inspection des dommages',
'CRM clients — consultation',
'CRM clients — modification',
'Approuver les permis de conduire',
'Signaler / blacklister un client',
'Offres et promotions',
'Analyses et rapports',
'Contrat et facture PDF',
'Facturation et abonnement',
'Inviter / supprimer du personnel',
'Paramètres marque et site',
'Paramètres de paiement',
],
agentNotes: ['', '', '', '', '', '', 'Les siennes', '', '', '', 'Notes uniquement', '', '', '', '', '', '', '', '', ''],
},
ar: {
heading: 'صلاحيات الأدوار',
subtitle: 'ما يمكن لكل دور القيام به في لوحة التحكم. يملك الملاك وصولاً كاملاً لكل شيء.',
colFeature: 'الميزة',
colManager: 'المدير',
colAgent: 'الوكيل',
full: 'كامل',
noAccess: 'لا وصول',
limited: 'محدود',
featureLabels: [
'عرض الأسطول والمركبات',
'إضافة / تعديل المركبات',
'نشر / إلغاء نشر المركبة',
'رفع صور المركبة',
'عرض الحجوزات',
'إنشاء الحجوزات',
'إلغاء الحجوزات',
'الاستلام / الإرجاع',
'فحص الأضرار',
'CRM العملاء — عرض',
'CRM العملاء — تعديل',
'الموافقة على رخص القيادة',
'تعليم / حظر العميل',
'العروض والترويجات',
'التحليلات والتقارير',
'العقد وفاتورة PDF',
'الفوترة والاشتراك',
'دعوة / إزالة الموظفين',
'إعدادات العلامة والموقع',
'إعدادات الدفع',
],
agentNotes: ['', '', '', '', '', '', 'حجوزاته فقط', '', '', '', 'الملاحظات فقط', '', '', '', '', '', '', '', '', ''],
},
}
function AccessCell({ access, label, limitedNote }: { access: Access; label: { full: string; noAccess: string; limited: string }; limitedNote: string }) {
if (access === 'full') {
return (
<div className="flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 14 14">
<path d="M2.5 7L5.5 10L11.5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="text-xs text-zinc-700 dark:text-zinc-300">Full</span>
<span className="text-xs text-zinc-700 dark:text-zinc-300">{label.full}</span>
</div>
)
}
@@ -50,7 +153,7 @@ function AccessCell({ access, note }: { access: Access; note?: string }) {
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
<div className="w-2.5 h-0.5 rounded bg-amber-500" />
</div>
<span className="text-xs text-amber-700 dark:text-amber-400">{note ?? 'Limited'}</span>
<span className="text-xs text-amber-700 dark:text-amber-400">{limitedNote || label.limited}</span>
</div>
)
}
@@ -59,37 +162,38 @@ function AccessCell({ access, note }: { access: Access; note?: string }) {
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
<div className="w-2 h-0.5 rounded bg-zinc-300 dark:bg-zinc-600" />
</div>
<span className="text-xs text-zinc-400 dark:text-zinc-600">No access</span>
<span className="text-xs text-zinc-400 dark:text-zinc-600">{label.noAccess}</span>
</div>
)
}
export default function PermissionsMatrix() {
const { language } = useDashboardI18n()
const t = copy[language]
return (
<div>
<div dir={language === 'ar' ? 'rtl' : 'ltr'}>
<div className="mb-4">
<h2 className="text-base font-medium text-zinc-900 dark:text-white">Role permissions</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">
What each role can do across the dashboard. Owners have full access to everything.
</p>
<h2 className="text-base font-medium text-zinc-900 dark:text-white">{t.heading}</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">{t.subtitle}</p>
</div>
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden">
<div className="grid grid-cols-[1fr_120px_120px] bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">
Feature
{t.colFeature}
</div>
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
Manager
{t.colManager}
</div>
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
Agent
{t.colAgent}
</div>
</div>
{FEATURES.map((feature, i) => (
<div
key={feature.label}
key={i}
className={[
'grid grid-cols-[1fr_120px_120px]',
i < FEATURES.length - 1 ? 'border-b border-zinc-100 dark:border-zinc-800' : '',
@@ -97,13 +201,21 @@ export default function PermissionsMatrix() {
].join(' ')}
>
<div className="px-4 py-2.5 text-xs text-zinc-600 dark:text-zinc-400">
{feature.label}
{t.featureLabels[i]}
</div>
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
<AccessCell access={feature.manager} note={feature.managerNote} />
<AccessCell
access={feature.manager}
label={{ full: t.full, noAccess: t.noAccess, limited: t.limited }}
limitedNote={feature.managerNote ?? ''}
/>
</div>
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
<AccessCell access={feature.agent} note={feature.agentNote} />
<AccessCell
access={feature.agent}
label={{ full: t.full, noAccess: t.noAccess, limited: t.limited }}
limitedNote={t.agentNotes[i] ?? ''}
/>
</div>
</div>
))}