fix notification and add billing page and contract
This commit is contained in:
@@ -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'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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user