fix notification and add billing page and contract
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { SHARED_LANGUAGE_COOKIE, SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
|
||||
|
||||
export type DashboardLanguage = 'en' | 'fr' | 'ar'
|
||||
export type DashboardTheme = 'light' | 'dark'
|
||||
export type DashboardTheme = 'light' | 'medium' | 'dark'
|
||||
|
||||
type FleetDict = {
|
||||
statusLabels: Record<'AVAILABLE' | 'RENTED' | 'MAINTENANCE' | 'OUT_OF_SERVICE', string>
|
||||
@@ -294,6 +295,7 @@ type DashboardDictionary = {
|
||||
language: string
|
||||
theme: string
|
||||
light: string
|
||||
medium: string
|
||||
dark: string
|
||||
fleet: FleetDict
|
||||
vehicleDetail: VehicleDetailDict
|
||||
@@ -307,26 +309,31 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
fleet: 'Fleet',
|
||||
reservations: 'Reservations',
|
||||
reservations: 'Booking',
|
||||
onlineReservations: 'Online Reservations',
|
||||
customers: 'Customers',
|
||||
offers: 'Offers',
|
||||
team: 'Team',
|
||||
reports: 'Reports',
|
||||
billing: 'Billing',
|
||||
subscription: 'Subscription',
|
||||
billing: 'Rent Car Billing',
|
||||
contracts: 'Contracts',
|
||||
notifications: 'Notifications',
|
||||
settings: 'Settings',
|
||||
},
|
||||
titles: {
|
||||
'/dashboard': 'Dashboard',
|
||||
'/dashboard/fleet': 'Fleet Management',
|
||||
'/dashboard/reservations': 'Reservations',
|
||||
'/dashboard/reservations': 'Booking',
|
||||
'/dashboard/reservations/new': 'Book Car',
|
||||
'/dashboard/online-reservations': 'Online Reservations',
|
||||
'/dashboard/customers': 'Customers',
|
||||
'/dashboard/offers': 'Offers',
|
||||
'/dashboard/team': 'Team',
|
||||
'/dashboard/reports': 'Reports',
|
||||
'/dashboard/billing': 'Billing',
|
||||
'/dashboard/subscription': 'Subscription',
|
||||
'/dashboard/billing': 'Rent Car Billing',
|
||||
'/dashboard/contracts': 'Contracts',
|
||||
'/dashboard/notifications': 'Notifications',
|
||||
'/dashboard/settings': 'Settings',
|
||||
},
|
||||
@@ -339,6 +346,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
language: 'Language',
|
||||
theme: 'Theme',
|
||||
light: 'Light',
|
||||
medium: 'Medium',
|
||||
dark: 'Dark',
|
||||
fleet: {
|
||||
statusLabels: { AVAILABLE: 'Available', RENTED: 'Rented', MAINTENANCE: 'Maintenance', OUT_OF_SERVICE: 'Out of Service' },
|
||||
@@ -572,11 +580,11 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
trialActive: "You're on a free trial.",
|
||||
pastDueMsg: 'Your payment is past due. Please update your billing information.',
|
||||
suspendedMsg: 'Your account has been suspended. Please contact support or renew your subscription.',
|
||||
manageBilling: 'Manage billing →',
|
||||
manageBilling: 'Manage subscription →',
|
||||
statusLabels: { CONFIRMED: 'Confirmed', PENDING: 'Pending', ACTIVE: 'Active', COMPLETED: 'Completed', CANCELLED: 'Cancelled' },
|
||||
},
|
||||
reservations: {
|
||||
heading: 'Reservations',
|
||||
heading: 'Booking',
|
||||
subtitle: 'All booking sources, including dashboard, public site, and marketplace.',
|
||||
colCustomer: 'Customer',
|
||||
colVehicle: 'Vehicle',
|
||||
@@ -650,7 +658,9 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
offers: 'Offres',
|
||||
team: 'Équipe',
|
||||
reports: 'Rapports',
|
||||
billing: 'Facturation',
|
||||
subscription: 'Abonnement',
|
||||
billing: 'Facturation location',
|
||||
contracts: 'Contrats',
|
||||
notifications: 'Notifications',
|
||||
settings: 'Paramètres',
|
||||
},
|
||||
@@ -658,12 +668,15 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
'/dashboard': 'Tableau de bord',
|
||||
'/dashboard/fleet': 'Gestion de flotte',
|
||||
'/dashboard/reservations': 'Réservations',
|
||||
'/dashboard/reservations/new': 'Réserver une voiture',
|
||||
'/dashboard/online-reservations': 'Réservations en ligne',
|
||||
'/dashboard/customers': 'Clients',
|
||||
'/dashboard/offers': 'Offres',
|
||||
'/dashboard/team': 'Équipe',
|
||||
'/dashboard/reports': 'Rapports',
|
||||
'/dashboard/billing': 'Facturation',
|
||||
'/dashboard/subscription': 'Abonnement',
|
||||
'/dashboard/billing': 'Facturation location',
|
||||
'/dashboard/contracts': 'Contrats',
|
||||
'/dashboard/notifications': 'Notifications',
|
||||
'/dashboard/settings': 'Paramètres',
|
||||
},
|
||||
@@ -676,6 +689,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
language: 'Langue',
|
||||
theme: 'Mode',
|
||||
light: 'Clair',
|
||||
medium: 'Moyen',
|
||||
dark: 'Sombre',
|
||||
fleet: {
|
||||
statusLabels: { AVAILABLE: 'Disponible', RENTED: 'Loué', MAINTENANCE: 'Maintenance', OUT_OF_SERVICE: 'Hors service' },
|
||||
@@ -909,7 +923,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
trialActive: "Vous êtes en période d'essai.",
|
||||
pastDueMsg: 'Votre paiement est en retard. Veuillez mettre à jour vos informations de facturation.',
|
||||
suspendedMsg: 'Votre compte a été suspendu. Veuillez contacter le support ou renouveler votre abonnement.',
|
||||
manageBilling: 'Gérer la facturation →',
|
||||
manageBilling: 'Gérer l’abonnement →',
|
||||
statusLabels: { CONFIRMED: 'Confirmé', PENDING: 'En attente', ACTIVE: 'Actif', COMPLETED: 'Terminé', CANCELLED: 'Annulé' },
|
||||
},
|
||||
reservations: {
|
||||
@@ -987,7 +1001,9 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
offers: 'العروض',
|
||||
team: 'الفريق',
|
||||
reports: 'التقارير',
|
||||
billing: 'الفوترة',
|
||||
subscription: 'الاشتراك',
|
||||
billing: 'فوترة تأجير السيارات',
|
||||
contracts: 'العقود',
|
||||
notifications: 'الإشعارات',
|
||||
settings: 'الإعدادات',
|
||||
},
|
||||
@@ -995,12 +1011,15 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
'/dashboard': 'لوحة التحكم',
|
||||
'/dashboard/fleet': 'إدارة الأسطول',
|
||||
'/dashboard/reservations': 'الحجوزات',
|
||||
'/dashboard/reservations/new': 'حجز سيارة',
|
||||
'/dashboard/online-reservations': 'الحجوزات الإلكترونية',
|
||||
'/dashboard/customers': 'العملاء',
|
||||
'/dashboard/offers': 'العروض',
|
||||
'/dashboard/team': 'الفريق',
|
||||
'/dashboard/reports': 'التقارير',
|
||||
'/dashboard/billing': 'الفوترة',
|
||||
'/dashboard/subscription': 'الاشتراك',
|
||||
'/dashboard/billing': 'فوترة تأجير السيارات',
|
||||
'/dashboard/contracts': 'العقود',
|
||||
'/dashboard/notifications': 'الإشعارات',
|
||||
'/dashboard/settings': 'الإعدادات',
|
||||
},
|
||||
@@ -1013,6 +1032,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
language: 'اللغة',
|
||||
theme: 'الوضع',
|
||||
light: 'فاتح',
|
||||
medium: 'متوسط',
|
||||
dark: 'داكن',
|
||||
fleet: {
|
||||
statusLabels: { AVAILABLE: 'متاح', RENTED: 'مؤجر', MAINTENANCE: 'صيانة', OUT_OF_SERVICE: 'خارج الخدمة' },
|
||||
@@ -1246,7 +1266,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
trialActive: 'أنت في فترة تجريبية مجانية.',
|
||||
pastDueMsg: 'دفعتك متأخرة. يرجى تحديث معلومات الفوترة.',
|
||||
suspendedMsg: 'تم تعليق حسابك. يرجى التواصل مع الدعم أو تجديد اشتراكك.',
|
||||
manageBilling: 'إدارة الفوترة ←',
|
||||
manageBilling: 'إدارة الاشتراك ←',
|
||||
statusLabels: { CONFIRMED: 'مؤكد', PENDING: 'قيد الانتظار', ACTIVE: 'نشط', COMPLETED: 'مكتمل', CANCELLED: 'ملغى' },
|
||||
},
|
||||
reservations: {
|
||||
@@ -1325,25 +1345,38 @@ type I18nContextValue = {
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null)
|
||||
const DASHBOARD_LANGUAGE_KEY = 'dashboard-language'
|
||||
|
||||
export function DashboardI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [language, setLanguage] = useState<DashboardLanguage>('en')
|
||||
function normalizeLanguage(value: string | null | undefined): DashboardLanguage {
|
||||
return value === 'fr' || value === 'ar' || value === 'en' ? value : 'en'
|
||||
}
|
||||
|
||||
export function DashboardI18nProvider({
|
||||
children,
|
||||
initialLanguage = 'en',
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
initialLanguage?: DashboardLanguage
|
||||
}) {
|
||||
const [language, setLanguage] = useState<DashboardLanguage>(initialLanguage)
|
||||
const [theme, setTheme] = useState<DashboardTheme>('light')
|
||||
const themeInitialized = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem('dashboard-language')
|
||||
const storedTheme = window.localStorage.getItem('dashboard-theme')
|
||||
const stored = readScopedPreference(SHARED_LANGUAGE_KEY, [DASHBOARD_LANGUAGE_KEY])
|
||||
if (stored === null) return
|
||||
const next = normalizeLanguage(stored)
|
||||
if (next !== language) setLanguage(next)
|
||||
}, [])
|
||||
|
||||
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
|
||||
setLanguage(stored)
|
||||
}
|
||||
|
||||
if (storedTheme === 'light' || storedTheme === 'dark') {
|
||||
setTheme(storedTheme)
|
||||
useEffect(() => {
|
||||
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['dashboard-theme'])
|
||||
if (storedTheme === 'light' || storedTheme === 'medium' || storedTheme === 'dark') {
|
||||
if (storedTheme !== theme) setTheme(storedTheme)
|
||||
return
|
||||
}
|
||||
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches && theme !== 'dark') {
|
||||
setTheme('dark')
|
||||
}
|
||||
}, [])
|
||||
@@ -1351,17 +1384,46 @@ export function DashboardI18nProvider({ children }: { children: React.ReactNode
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
|
||||
window.localStorage.setItem('dashboard-language', language)
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, language, [DASHBOARD_LANGUAGE_KEY])
|
||||
document.cookie = `${SHARED_LANGUAGE_COOKIE}=${language}; path=/; max-age=31536000; samesite=lax`
|
||||
document.cookie = `${DASHBOARD_LANGUAGE_KEY}=${language}; path=/; max-age=31536000; samesite=lax`
|
||||
}, [language])
|
||||
|
||||
useEffect(() => {
|
||||
if (!themeInitialized.current) {
|
||||
// Skip first run: inline script already applied the correct class; writing 'light' here would flash and corrupt storage.
|
||||
themeInitialized.current = true
|
||||
return
|
||||
}
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
document.documentElement.classList.add(theme)
|
||||
document.documentElement.style.colorScheme = theme
|
||||
document.documentElement.classList.add(theme === 'light' ? 'light' : 'dark')
|
||||
document.documentElement.style.colorScheme = theme === 'light' ? 'light' : 'dark'
|
||||
document.body.dataset.theme = theme
|
||||
window.localStorage.setItem('dashboard-theme', theme)
|
||||
writeScopedPreference(SHARED_THEME_KEY, theme, ['dashboard-theme'])
|
||||
}, [theme])
|
||||
|
||||
useEffect(() => {
|
||||
function handleAuthChanged() {
|
||||
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
|
||||
const scopedTheme = readCurrentUserScopedPreference(SHARED_THEME_KEY)
|
||||
|
||||
if (scopedLanguage && normalizeLanguage(scopedLanguage) !== language) {
|
||||
setLanguage(normalizeLanguage(scopedLanguage))
|
||||
} else {
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, language, [DASHBOARD_LANGUAGE_KEY])
|
||||
}
|
||||
|
||||
if (scopedTheme === 'light' || scopedTheme === 'medium' || scopedTheme === 'dark') {
|
||||
if (scopedTheme !== theme) setTheme(scopedTheme)
|
||||
} else {
|
||||
writeScopedPreference(SHARED_THEME_KEY, theme, ['dashboard-theme'])
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('rentaldrivego:auth-changed', handleAuthChanged as EventListener)
|
||||
return () => window.removeEventListener('rentaldrivego:auth-changed', handleAuthChanged as EventListener)
|
||||
}, [language, theme])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ language, setLanguage, theme, setTheme, dict: dictionaries[language] }),
|
||||
[language, theme],
|
||||
@@ -1426,7 +1488,7 @@ export function DashboardThemeSwitcher() {
|
||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
|
||||
{dict.theme}
|
||||
</span>
|
||||
{(['light', 'dark'] as DashboardTheme[]).map((value) => {
|
||||
{(['light', 'medium', 'dark'] as DashboardTheme[]).map((value) => {
|
||||
const active = value === theme
|
||||
return (
|
||||
<button
|
||||
@@ -1439,7 +1501,7 @@ export function DashboardThemeSwitcher() {
|
||||
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
{value === 'light' ? dict.light : dict.dark}
|
||||
{value === 'light' ? dict.light : value === 'medium' ? dict.medium : dict.dark}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,66 +1,280 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronDown, Globe } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
DashboardLanguageSwitcher,
|
||||
DashboardThemeSwitcher,
|
||||
type DashboardLanguage,
|
||||
useDashboardI18n,
|
||||
} from '@/components/I18nProvider'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
|
||||
type FooterItem = {
|
||||
label: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
const localeOptions: Array<{ value: DashboardLanguage; label: string }> = [
|
||||
{ value: 'en', label: 'Global (English)' },
|
||||
{ value: 'ar', label: 'NorthAfrica (Arabic)' },
|
||||
{ value: 'fr', label: 'Europ (French)' },
|
||||
]
|
||||
|
||||
function getFooterContent(language: DashboardLanguage): {
|
||||
primary: FooterItem[]
|
||||
secondary: FooterItem[]
|
||||
localeLabel: string
|
||||
rightsLabel: string
|
||||
} {
|
||||
switch (language) {
|
||||
case 'fr':
|
||||
return {
|
||||
primary: [
|
||||
{ label: 'À propos', href: marketplaceUrl },
|
||||
{ label: 'CLUF' },
|
||||
{ label: "Conditions d'utilisation" },
|
||||
{ label: 'Sécurité' },
|
||||
{ label: 'Conformité' },
|
||||
{ label: 'Politique de confidentialité' },
|
||||
{ label: 'Politique de cookies' },
|
||||
{ label: "Programme d'affiliation" },
|
||||
],
|
||||
secondary: [
|
||||
{ label: 'Newsletter' },
|
||||
{ label: 'Contacter les ventes', href: '/sign-up' },
|
||||
{ label: 'Nos bureaux', href: marketplaceUrl },
|
||||
],
|
||||
localeLabel: 'Europ (French)',
|
||||
rightsLabel: 'Tous droits réservés.',
|
||||
}
|
||||
case 'ar':
|
||||
return {
|
||||
primary: [
|
||||
{ label: 'من نحن', href: marketplaceUrl },
|
||||
{ label: 'اتفاقية الترخيص' },
|
||||
{ label: 'شروط الخدمة' },
|
||||
{ label: 'الأمان' },
|
||||
{ label: 'الامتثال' },
|
||||
{ label: 'سياسة الخصوصية' },
|
||||
{ label: 'سياسة ملفات الارتباط' },
|
||||
{ label: 'برنامج الشركاء' },
|
||||
],
|
||||
secondary: [
|
||||
{ label: 'النشرة البريدية' },
|
||||
{ label: 'تواصل مع المبيعات', href: '/sign-up' },
|
||||
{ label: 'مكاتبنا', href: marketplaceUrl },
|
||||
],
|
||||
localeLabel: 'NorthAfrica (Arabic)',
|
||||
rightsLabel: 'جميع الحقوق محفوظة.',
|
||||
}
|
||||
case 'en':
|
||||
default:
|
||||
return {
|
||||
primary: [
|
||||
{ label: 'About us', href: marketplaceUrl },
|
||||
{ label: 'EULA' },
|
||||
{ label: 'Terms of service' },
|
||||
{ label: 'Security' },
|
||||
{ label: 'Compliance' },
|
||||
{ label: 'Privacy policy' },
|
||||
{ label: 'Cookie policy' },
|
||||
{ label: 'Affiliate program' },
|
||||
],
|
||||
secondary: [
|
||||
{ label: 'Newsletter' },
|
||||
{ label: 'Contact sales', href: '/sign-up' },
|
||||
{ label: 'Our offices', href: marketplaceUrl },
|
||||
],
|
||||
localeLabel: 'Global (English)',
|
||||
rightsLabel: 'All rights reserved.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function PublicShell({ children }: { children: React.ReactNode }) {
|
||||
const { language } = useDashboardI18n()
|
||||
const { language, setLanguage, theme, setTheme } = useDashboardI18n()
|
||||
const localeMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
|
||||
const dict = {
|
||||
en: {
|
||||
workspace: 'Company Workspace',
|
||||
home: 'Home',
|
||||
marketplace: 'Marketplace',
|
||||
signIn: 'Sign in',
|
||||
createWorkspace: 'Create workspace',
|
||||
createAccount: 'Create account',
|
||||
preferences: 'Workspace preferences',
|
||||
theme: 'Theme',
|
||||
light: 'Light',
|
||||
medium: 'Medium',
|
||||
dark: 'Dark',
|
||||
},
|
||||
fr: {
|
||||
workspace: 'Espace entreprise',
|
||||
home: 'Accueil',
|
||||
marketplace: 'Marketplace',
|
||||
signIn: 'Connexion',
|
||||
createWorkspace: 'Créer un espace',
|
||||
createAccount: 'Créer un compte',
|
||||
preferences: 'Preferences espace',
|
||||
theme: 'Mode',
|
||||
light: 'Clair',
|
||||
medium: 'Moyen',
|
||||
dark: 'Sombre',
|
||||
},
|
||||
ar: {
|
||||
workspace: 'مساحة الشركة',
|
||||
home: 'الرئيسية',
|
||||
marketplace: 'السوق',
|
||||
signIn: 'تسجيل الدخول',
|
||||
createWorkspace: 'إنشاء مساحة',
|
||||
createAccount: 'إنشاء حساب',
|
||||
preferences: 'تفضيلات المساحة',
|
||||
theme: 'الوضع',
|
||||
light: 'فاتح',
|
||||
medium: 'متوسط',
|
||||
dark: 'داكن',
|
||||
},
|
||||
}[language]
|
||||
const footerContent = getFooterContent(language)
|
||||
const availableLocaleOptions = localeOptions.filter((option) => option.value !== language)
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (!localeMenuRef.current?.contains(event.target as Node)) {
|
||||
setLocaleMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)] transition-colors dark:bg-[radial-gradient(circle_at_top,rgba(37,99,235,0.18),transparent_35%),linear-gradient(180deg,#020617,#0f172a)] dark:text-slate-100">
|
||||
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/85 backdrop-blur-md transition-colors dark:border-slate-800 dark:bg-slate-950/85">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4">
|
||||
<Link href={marketplaceUrl} className="flex items-center gap-3 text-slate-900 dark:text-slate-100">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</span>
|
||||
<span className="hidden text-sm font-semibold text-slate-500 dark:text-slate-400 sm:inline">{dict.workspace}</span>
|
||||
<header className="sticky top-0 z-30 border-b border-stone-200 bg-white/85 backdrop-blur-md transition-colors dark:border-stone-800 dark:bg-stone-950/85">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:px-6 lg:flex-row lg:items-center lg:justify-between lg:px-8 lg:py-2">
|
||||
<Link href={marketplaceUrl} className="flex items-center gap-3 text-sm font-bold uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100">
|
||||
<Image
|
||||
src="/rentalcardrive.png"
|
||||
alt="RentalDriveGo"
|
||||
width={36}
|
||||
height={36}
|
||||
className="h-9 w-9 rounded-md object-contain"
|
||||
/>
|
||||
<span>RentalDriveGo</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-2">
|
||||
<Link href="/sign-in" className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100">
|
||||
{dict.signIn}
|
||||
</Link>
|
||||
<Link href="/sign-up" className="rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-950 dark:hover:bg-slate-200">
|
||||
{dict.createWorkspace}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<nav className="flex items-center gap-1 overflow-x-auto">
|
||||
<Link href={marketplaceUrl} className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100">
|
||||
{dict.home}
|
||||
</Link>
|
||||
<Link href={`${marketplaceUrl}/explore`} className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100">
|
||||
{dict.marketplace}
|
||||
</Link>
|
||||
<Link href={`${marketplaceUrl}/sign-in`} className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100">
|
||||
{dict.signIn}
|
||||
</Link>
|
||||
<Link href="/sign-up" className="ml-2 rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300">
|
||||
{dict.createAccount}
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center gap-2 rounded-full border border-stone-200/70 bg-white/90 px-2 py-1 shadow-sm dark:border-stone-700 dark:bg-stone-900/80">
|
||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-stone-500 dark:text-stone-400">
|
||||
{dict.theme}
|
||||
</span>
|
||||
{(['light', 'medium', 'dark'] as const).map((value) => {
|
||||
const active = value === theme
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTheme(value)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
active
|
||||
? 'bg-stone-900 text-white dark:bg-amber-400 dark:text-stone-950'
|
||||
: 'text-stone-600 hover:bg-stone-100 dark:text-stone-300 dark:hover:bg-stone-800'
|
||||
}`}
|
||||
>
|
||||
{value === 'light' ? dict.light : value === 'medium' ? dict.medium : dict.dark}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div>{children}</div>
|
||||
<footer className="border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors dark:border-slate-800 dark:bg-slate-950/90">
|
||||
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 lg:flex-row">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-slate-500">
|
||||
{dict.preferences}
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||
<DashboardLanguageSwitcher />
|
||||
<DashboardThemeSwitcher />
|
||||
<footer className="border-t border-stone-200 bg-white/90 px-4 py-8 text-stone-600 transition-colors dark:border-white/5 dark:bg-[#111111] dark:text-stone-300">
|
||||
<div className="mx-auto flex max-w-7xl flex-col items-center gap-5 text-center px-0 sm:px-2 lg:px-0">
|
||||
<nav className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
|
||||
{footerContent.primary.map((item, index) => (
|
||||
<div key={item.label} className="flex items-center">
|
||||
<FooterNavItem item={item} />
|
||||
{index < footerContent.primary.length - 1 ? (
|
||||
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
|
||||
{footerContent.secondary.map((item) => (
|
||||
<div key={item.label} className="flex items-center">
|
||||
<FooterNavItem item={item} />
|
||||
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={localeMenuRef} className="relative px-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLocaleMenuOpen((open) => !open)}
|
||||
className="inline-flex items-center gap-2 text-sky-600 transition hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
|
||||
aria-expanded={localeMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
<span>{footerContent.localeLabel}</span>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{localeMenuOpen ? (
|
||||
<div className="absolute left-1/2 top-full z-20 mt-3 w-56 -translate-x-1/2 overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-stone-800 dark:bg-stone-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
|
||||
{availableLocaleOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLanguage(option.value)
|
||||
setLocaleMenuOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-stone-950 dark:text-stone-200 dark:hover:bg-stone-900 dark:hover:text-white"
|
||||
>
|
||||
<Globe className="h-4 w-4 text-sky-600 dark:text-sky-400" />
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">
|
||||
© {new Date().getFullYear()} RentalDriveGo. {footerContent.rightsLabel}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FooterNavItem({ item }: { item: FooterItem }) {
|
||||
const className = 'px-3 text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white'
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
<Link href={item.href} className={className}>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return <span className={className}>{item.label}</span>
|
||||
}
|
||||
|
||||
@@ -16,12 +16,13 @@ import {
|
||||
Bell,
|
||||
Settings,
|
||||
LogOut,
|
||||
FileText,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
import { EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
|
||||
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
|
||||
|
||||
interface BrandSettings {
|
||||
displayName: string
|
||||
@@ -33,6 +34,12 @@ interface SidebarUser {
|
||||
subtitle: string
|
||||
initials: string
|
||||
}
|
||||
interface EmployeeProfile {
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
role: string
|
||||
}
|
||||
|
||||
function computeInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean)
|
||||
@@ -41,6 +48,16 @@ function computeInitials(name: string): string {
|
||||
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
|
||||
}
|
||||
|
||||
function toSidebarUser(profile: Partial<EmployeeProfile>, fallbackName: string, fallbackSubtitle: string): SidebarUser {
|
||||
const fullName = `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim()
|
||||
const email = profile.email ?? ''
|
||||
const role = profile.role ?? ''
|
||||
const displayName = fullName || (email ? email.split('@')[0] : fallbackName)
|
||||
const subtitle = email || role || fallbackSubtitle
|
||||
const initials = computeInitials(fullName || email || fallbackName)
|
||||
return { displayName, subtitle, initials }
|
||||
}
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/dashboard', key: 'dashboard', icon: LayoutDashboard, exact: true },
|
||||
{ href: '/dashboard/fleet', key: 'fleet', icon: Car },
|
||||
@@ -50,7 +67,9 @@ const NAV_ITEMS = [
|
||||
{ href: '/dashboard/offers', key: 'offers', icon: Tag },
|
||||
{ href: '/dashboard/team', key: 'team', icon: UserPlus },
|
||||
{ href: '/dashboard/reports', key: 'reports', icon: BarChart2 },
|
||||
{ href: '/dashboard/subscription', key: 'subscription', icon: CreditCard },
|
||||
{ href: '/dashboard/billing', key: 'billing', icon: CreditCard },
|
||||
{ href: '/dashboard/contracts', key: 'contracts', icon: FileText },
|
||||
{ href: '/dashboard/notifications', key: 'notifications', icon: Bell },
|
||||
{ href: '/dashboard/settings', key: 'settings', icon: Settings },
|
||||
] as const
|
||||
@@ -83,33 +102,25 @@ export default function Sidebar() {
|
||||
return
|
||||
}
|
||||
|
||||
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
|
||||
if (cached) {
|
||||
try {
|
||||
const profile = JSON.parse(cached) as Partial<EmployeeProfile>
|
||||
setUser(toSidebarUser(profile, dict.workspaceUser, dict.localAuth))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
apiFetch<{
|
||||
employee: {
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
role: string
|
||||
}
|
||||
}>('/auth/employee/me')
|
||||
apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me')
|
||||
.then(({ employee }) => {
|
||||
if (cancelled) return
|
||||
const fullName = `${employee.firstName ?? ''} ${employee.lastName ?? ''}`.trim()
|
||||
const email = employee.email ?? ''
|
||||
const role = employee.role ?? ''
|
||||
const displayName = fullName || (email ? email.split('@')[0] : dict.workspaceUser)
|
||||
const subtitle = email || role || dict.localAuth
|
||||
const initials = computeInitials(fullName || email || dict.workspaceUser)
|
||||
setUser({ displayName, subtitle, initials })
|
||||
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
|
||||
setUser(toSidebarUser(employee, dict.workspaceUser, dict.localAuth))
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setUser({
|
||||
displayName: dict.workspaceUser,
|
||||
subtitle: dict.localAuth,
|
||||
initials: 'W',
|
||||
})
|
||||
if (!cached) setUser(toSidebarUser({}, dict.workspaceUser, dict.localAuth))
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
@@ -125,6 +136,7 @@ export default function Sidebar() {
|
||||
|
||||
function signOut() {
|
||||
localStorage.removeItem(EMPLOYEE_TOKEN_KEY)
|
||||
localStorage.removeItem(EMPLOYEE_PROFILE_KEY)
|
||||
document.cookie = 'employee_token=; path=/; max-age=0; samesite=lax'
|
||||
router.push('/sign-in')
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Bell } from 'lucide-react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
|
||||
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
function computeInitials(name: string): string {
|
||||
@@ -29,7 +29,12 @@ export default function TopBar() {
|
||||
}>>([])
|
||||
const [loadingNotifs, setLoadingNotifs] = useState(false)
|
||||
|
||||
const title = dict.titles[pathname] ?? dict.titles['/dashboard']
|
||||
const title = (() => {
|
||||
if (dict.titles[pathname]) return dict.titles[pathname]
|
||||
if (pathname.startsWith('/dashboard/contracts/')) return dict.titles['/dashboard/contracts'] ?? dict.titles['/dashboard']
|
||||
if (pathname.startsWith('/dashboard/reservations/')) return dict.titles['/dashboard/reservations'] ?? dict.titles['/dashboard']
|
||||
return dict.titles['/dashboard']
|
||||
})()
|
||||
async function refreshUnreadCount() {
|
||||
try {
|
||||
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
|
||||
@@ -87,6 +92,16 @@ export default function TopBar() {
|
||||
return
|
||||
}
|
||||
|
||||
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
|
||||
if (cached) {
|
||||
try {
|
||||
const profile = JSON.parse(cached) as { email?: string; firstName?: string; lastName?: string }
|
||||
const fullName = `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim()
|
||||
const email = profile.email ?? ''
|
||||
setUserInitials(computeInitials(fullName || email || dict.workspaceUser))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
apiFetch<{
|
||||
@@ -98,6 +113,7 @@ export default function TopBar() {
|
||||
}>('/auth/employee/me')
|
||||
.then(({ employee }) => {
|
||||
if (cancelled) return
|
||||
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
|
||||
const fullName = `${employee.firstName ?? ''} ${employee.lastName ?? ''}`.trim()
|
||||
const email = employee.email ?? ''
|
||||
setUserInitials(computeInitials(fullName || email || dict.workspaceUser))
|
||||
|
||||
@@ -74,19 +74,27 @@ export default function DamageInspectionCard({
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payload = {
|
||||
...(inspection.mileage !== null ? { mileage: inspection.mileage } : {}),
|
||||
fuelLevel: inspection.fuelLevel,
|
||||
...(inspection.fuelCharge !== null ? { fuelCharge: inspection.fuelCharge } : {}),
|
||||
...(inspection.generalCondition ? { generalCondition: inspection.generalCondition } : {}),
|
||||
...(inspection.employeeNotes ? { employeeNotes: inspection.employeeNotes } : {}),
|
||||
customerAgreed: inspection.customerAgreed,
|
||||
damagePoints: inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
|
||||
viewType,
|
||||
x,
|
||||
y,
|
||||
damageType,
|
||||
severity,
|
||||
...(description ? { description } : {}),
|
||||
isPreExisting,
|
||||
})),
|
||||
}
|
||||
|
||||
const result = await apiFetch<DamageInspection>(`/reservations/${reservationId}/inspections/${type.toLowerCase()}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
mileage: inspection.mileage,
|
||||
fuelLevel: inspection.fuelLevel,
|
||||
fuelCharge: inspection.fuelCharge,
|
||||
generalCondition: inspection.generalCondition,
|
||||
employeeNotes: inspection.employeeNotes,
|
||||
customerAgreed: inspection.customerAgreed,
|
||||
damagePoints: inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
|
||||
viewType, x, y, damageType, severity, description, isPreExisting,
|
||||
})),
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
setInspection(result)
|
||||
onSaved(result)
|
||||
|
||||
@@ -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