fixing platform admin
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
export type DashboardLanguage = 'en' | 'fr' | 'ar'
|
||||
|
||||
type DashboardDictionary = {
|
||||
nav: Record<string, string>
|
||||
titles: Record<string, string>
|
||||
notifications: string
|
||||
noNewNotifications: string
|
||||
unreadNotifications: (count: number) => string
|
||||
signOut: string
|
||||
demoUser: string
|
||||
clerkDisabled: string
|
||||
language: string
|
||||
light: string
|
||||
dark: string
|
||||
}
|
||||
|
||||
const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
en: {
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
fleet: 'Fleet',
|
||||
reservations: 'Reservations',
|
||||
customers: 'Customers',
|
||||
offers: 'Offers',
|
||||
team: 'Team',
|
||||
reports: 'Reports',
|
||||
billing: 'Billing',
|
||||
notifications: 'Notifications',
|
||||
settings: 'Settings',
|
||||
},
|
||||
titles: {
|
||||
'/dashboard': 'Dashboard',
|
||||
'/dashboard/fleet': 'Fleet Management',
|
||||
'/dashboard/reservations': 'Reservations',
|
||||
'/dashboard/customers': 'Customers',
|
||||
'/dashboard/offers': 'Offers',
|
||||
'/dashboard/team': 'Team',
|
||||
'/dashboard/reports': 'Reports',
|
||||
'/dashboard/billing': 'Billing',
|
||||
'/dashboard/settings': 'Settings',
|
||||
},
|
||||
notifications: 'Notifications',
|
||||
noNewNotifications: 'No new notifications',
|
||||
unreadNotifications: (count) => `${count} unread notification${count > 1 ? 's' : ''}`,
|
||||
signOut: 'Sign out',
|
||||
demoUser: 'Demo User',
|
||||
clerkDisabled: 'Clerk disabled in local dev',
|
||||
language: 'Language',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
},
|
||||
fr: {
|
||||
nav: {
|
||||
dashboard: 'Tableau de bord',
|
||||
fleet: 'Flotte',
|
||||
reservations: 'Réservations',
|
||||
customers: 'Clients',
|
||||
offers: 'Offres',
|
||||
team: 'Équipe',
|
||||
reports: 'Rapports',
|
||||
billing: 'Facturation',
|
||||
notifications: 'Notifications',
|
||||
settings: 'Paramètres',
|
||||
},
|
||||
titles: {
|
||||
'/dashboard': 'Tableau de bord',
|
||||
'/dashboard/fleet': 'Gestion de flotte',
|
||||
'/dashboard/reservations': 'Réservations',
|
||||
'/dashboard/customers': 'Clients',
|
||||
'/dashboard/offers': 'Offres',
|
||||
'/dashboard/team': 'Équipe',
|
||||
'/dashboard/reports': 'Rapports',
|
||||
'/dashboard/billing': 'Facturation',
|
||||
'/dashboard/settings': 'Paramètres',
|
||||
},
|
||||
notifications: 'Notifications',
|
||||
noNewNotifications: 'Aucune nouvelle notification',
|
||||
unreadNotifications: (count) => `${count} notification${count > 1 ? 's' : ''} non lue${count > 1 ? 's' : ''}`,
|
||||
signOut: 'Déconnexion',
|
||||
demoUser: 'Utilisateur démo',
|
||||
clerkDisabled: 'Clerk désactivé en local',
|
||||
language: 'Langue',
|
||||
light: 'Clair',
|
||||
dark: 'Sombre',
|
||||
},
|
||||
ar: {
|
||||
nav: {
|
||||
dashboard: 'لوحة التحكم',
|
||||
fleet: 'الأسطول',
|
||||
reservations: 'الحجوزات',
|
||||
customers: 'العملاء',
|
||||
offers: 'العروض',
|
||||
team: 'الفريق',
|
||||
reports: 'التقارير',
|
||||
billing: 'الفوترة',
|
||||
notifications: 'الإشعارات',
|
||||
settings: 'الإعدادات',
|
||||
},
|
||||
titles: {
|
||||
'/dashboard': 'لوحة التحكم',
|
||||
'/dashboard/fleet': 'إدارة الأسطول',
|
||||
'/dashboard/reservations': 'الحجوزات',
|
||||
'/dashboard/customers': 'العملاء',
|
||||
'/dashboard/offers': 'العروض',
|
||||
'/dashboard/team': 'الفريق',
|
||||
'/dashboard/reports': 'التقارير',
|
||||
'/dashboard/billing': 'الفوترة',
|
||||
'/dashboard/settings': 'الإعدادات',
|
||||
},
|
||||
notifications: 'الإشعارات',
|
||||
noNewNotifications: 'لا توجد إشعارات جديدة',
|
||||
unreadNotifications: (count) => `${count} إشعار غير مقروء`,
|
||||
signOut: 'تسجيل الخروج',
|
||||
demoUser: 'مستخدم تجريبي',
|
||||
clerkDisabled: 'Clerk غير مفعّل محلياً',
|
||||
language: 'اللغة',
|
||||
light: 'فاتح',
|
||||
dark: 'داكن',
|
||||
},
|
||||
}
|
||||
|
||||
type I18nContextValue = {
|
||||
language: DashboardLanguage
|
||||
setLanguage: (value: DashboardLanguage) => void
|
||||
dict: DashboardDictionary
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null)
|
||||
|
||||
export function DashboardI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [language, setLanguage] = useState<DashboardLanguage>('en')
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem('dashboard-language')
|
||||
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
|
||||
setLanguage(stored)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
|
||||
window.localStorage.setItem('dashboard-language', language)
|
||||
}, [language])
|
||||
|
||||
const value = useMemo(() => ({ language, setLanguage, dict: dictionaries[language] }), [language])
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
||||
}
|
||||
|
||||
export function useDashboardI18n() {
|
||||
const context = useContext(I18nContext)
|
||||
if (!context) throw new Error('useDashboardI18n must be used within DashboardI18nProvider')
|
||||
return context
|
||||
}
|
||||
|
||||
export function DashboardLanguageSwitcher() {
|
||||
const { language, setLanguage, dict } = useDashboardI18n()
|
||||
const [embedded, setEmbedded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setEmbedded(window.self !== window.top)
|
||||
}, [])
|
||||
|
||||
if (embedded) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-slate-200 bg-white px-2 py-1 shadow-sm">
|
||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||
{dict.language}
|
||||
</span>
|
||||
{(['en', 'fr', 'ar'] as DashboardLanguage[]).map((value) => {
|
||||
const active = value === language
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setLanguage(value)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
active ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{value.toUpperCase()}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { DashboardLanguageSwitcher, useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
|
||||
export default function PublicShell({ children }: { children: React.ReactNode }) {
|
||||
const { language } = useDashboardI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
workspace: 'Company Workspace',
|
||||
signIn: 'Sign in',
|
||||
createWorkspace: 'Create workspace',
|
||||
preferences: 'Workspace preferences',
|
||||
},
|
||||
fr: {
|
||||
workspace: 'Espace entreprise',
|
||||
signIn: 'Connexion',
|
||||
createWorkspace: 'Créer un espace',
|
||||
preferences: 'Preferences espace',
|
||||
},
|
||||
ar: {
|
||||
workspace: 'مساحة الشركة',
|
||||
signIn: 'تسجيل الدخول',
|
||||
createWorkspace: 'إنشاء مساحة',
|
||||
preferences: 'تفضيلات المساحة',
|
||||
},
|
||||
}[language]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)]">
|
||||
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/85 backdrop-blur-md">
|
||||
<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">
|
||||
<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 sm:inline">{dict.workspace}</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">
|
||||
{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">
|
||||
{dict.createWorkspace}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<div>{children}</div>
|
||||
<footer className="border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors">
|
||||
<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">
|
||||
{dict.preferences}
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||
<DashboardLanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,24 +11,30 @@ import {
|
||||
UserPlus,
|
||||
BarChart2,
|
||||
CreditCard,
|
||||
Bell,
|
||||
Settings,
|
||||
LogOut,
|
||||
} from 'lucide-react'
|
||||
import { useClerk, useUser } from '@clerk/nextjs'
|
||||
import { clerkFrontendEnabled } from '@/lib/clerk'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, exact: true },
|
||||
{ href: '/dashboard/fleet', label: 'Fleet', icon: Car },
|
||||
{ href: '/dashboard/reservations', label: 'Reservations', icon: Calendar },
|
||||
{ href: '/dashboard/customers', label: 'Customers', icon: Users },
|
||||
{ href: '/dashboard/offers', label: 'Offers', icon: Tag },
|
||||
{ href: '/dashboard/team', label: 'Team', icon: UserPlus },
|
||||
{ href: '/dashboard/reports', label: 'Reports', icon: BarChart2 },
|
||||
{ href: '/dashboard/billing', label: 'Billing', icon: CreditCard },
|
||||
{ href: '/dashboard/settings', label: 'Settings', icon: Settings },
|
||||
]
|
||||
{ href: '/dashboard', key: 'dashboard', icon: LayoutDashboard, exact: true },
|
||||
{ href: '/dashboard/fleet', key: 'fleet', icon: Car },
|
||||
{ href: '/dashboard/reservations', key: 'reservations', icon: Calendar },
|
||||
{ href: '/dashboard/customers', key: 'customers', icon: Users },
|
||||
{ href: '/dashboard/offers', key: 'offers', icon: Tag },
|
||||
{ href: '/dashboard/team', key: 'team', icon: UserPlus },
|
||||
{ href: '/dashboard/reports', key: 'reports', icon: BarChart2 },
|
||||
{ href: '/dashboard/billing', key: 'billing', icon: CreditCard },
|
||||
{ href: '/dashboard/notifications', key: 'notifications', icon: Bell },
|
||||
{ href: '/dashboard/settings', key: 'settings', icon: Settings },
|
||||
] as const
|
||||
|
||||
export default function Sidebar() {
|
||||
function SidebarWithClerk() {
|
||||
const { dict } = useDashboardI18n()
|
||||
const pathname = usePathname()
|
||||
const { signOut } = useClerk()
|
||||
const { user } = useUser()
|
||||
@@ -41,12 +47,12 @@ export default function Sidebar() {
|
||||
return (
|
||||
<aside className="fixed inset-y-0 left-0 w-64 bg-slate-900 flex flex-col z-40">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 px-6 py-5 border-b border-slate-800">
|
||||
<Link href={marketplaceUrl} className="flex items-center gap-3 px-6 py-5 border-b border-slate-800">
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<Car className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-white text-sm tracking-wide">RentalDriveGo</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
||||
@@ -65,7 +71,7 @@ export default function Sidebar() {
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
{item.label}
|
||||
{dict.nav[item.key]}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
@@ -91,9 +97,68 @@ export default function Sidebar() {
|
||||
className="mt-2 w-full flex items-center gap-3 px-3 py-2 text-sm text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Sign out
|
||||
{dict.signOut}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarWithoutClerk() {
|
||||
const { dict } = useDashboardI18n()
|
||||
const pathname = usePathname()
|
||||
|
||||
const isActive = (item: typeof NAV_ITEMS[0]) => {
|
||||
if (item.exact) return pathname === item.href
|
||||
return pathname.startsWith(item.href)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="fixed inset-y-0 left-0 w-64 bg-slate-900 flex flex-col z-40">
|
||||
<Link href={marketplaceUrl} className="flex items-center gap-3 px-6 py-5 border-b border-slate-800">
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<Car className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-white text-sm tracking-wide">RentalDriveGo</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item)
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={[
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
||||
active
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800',
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
{dict.nav[item.key]}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="px-3 py-4 border-t border-slate-800">
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-xs font-semibold flex-shrink-0">
|
||||
D
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{dict.demoUser}</p>
|
||||
<p className="text-xs text-slate-400 truncate">{dict.clerkDisabled}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
return clerkFrontendEnabled ? <SidebarWithClerk /> : <SidebarWithoutClerk />
|
||||
}
|
||||
|
||||
@@ -5,26 +5,17 @@ import { usePathname } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useUser } from '@clerk/nextjs'
|
||||
import { clerkFrontendEnabled } from '@/lib/clerk'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
const PAGE_TITLES: Record<string, string> = {
|
||||
'/dashboard': 'Dashboard',
|
||||
'/dashboard/fleet': 'Fleet Management',
|
||||
'/dashboard/reservations': 'Reservations',
|
||||
'/dashboard/customers': 'Customers',
|
||||
'/dashboard/offers': 'Offers',
|
||||
'/dashboard/team': 'Team',
|
||||
'/dashboard/reports': 'Reports',
|
||||
'/dashboard/billing': 'Billing',
|
||||
'/dashboard/settings': 'Settings',
|
||||
}
|
||||
|
||||
export default function TopBar() {
|
||||
function TopBarWithClerk() {
|
||||
const { dict } = useDashboardI18n()
|
||||
const pathname = usePathname()
|
||||
const { user } = useUser()
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
const [showNotifs, setShowNotifs] = useState(false)
|
||||
|
||||
const title = PAGE_TITLES[pathname] ?? 'Dashboard'
|
||||
const title = dict.titles[pathname] ?? dict.titles['/dashboard']
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ unread: number }>('/notifications/unread-count')
|
||||
@@ -53,9 +44,9 @@ export default function TopBar() {
|
||||
|
||||
{showNotifs && (
|
||||
<div className="absolute right-0 top-full mt-2 w-80 bg-white border border-slate-200 rounded-xl shadow-lg z-50 p-4">
|
||||
<p className="text-sm font-medium text-slate-900 mb-2">Notifications</p>
|
||||
<p className="text-sm font-medium text-slate-900 mb-2">{dict.notifications}</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{unreadCount === 0 ? 'No new notifications' : `${unreadCount} unread notification${unreadCount > 1 ? 's' : ''}`}
|
||||
{unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -69,3 +60,57 @@ export default function TopBar() {
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function TopBarWithoutClerk() {
|
||||
const { dict } = useDashboardI18n()
|
||||
const pathname = usePathname()
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
const [showNotifs, setShowNotifs] = useState(false)
|
||||
|
||||
const title = dict.titles[pathname] ?? dict.titles['/dashboard']
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ unread: number }>('/notifications/unread-count')
|
||||
.then((data) => setUnreadCount(data.unread))
|
||||
.catch(() => {})
|
||||
}, [pathname])
|
||||
|
||||
return (
|
||||
<header className="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-6">
|
||||
<h1 className="text-lg font-semibold text-slate-900">{title}</h1>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowNotifs(!showNotifs)}
|
||||
className="relative p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1 right-1 min-w-[16px] h-4 bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center px-1">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showNotifs && (
|
||||
<div className="absolute right-0 top-full mt-2 w-80 bg-white border border-slate-200 rounded-xl shadow-lg z-50 p-4">
|
||||
<p className="text-sm font-medium text-slate-900 mb-2">{dict.notifications}</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-xs font-semibold">
|
||||
D
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TopBar() {
|
||||
return clerkFrontendEnabled ? <TopBarWithClerk /> : <TopBarWithoutClerk />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
type InspectionType = 'CHECKIN' | 'CHECKOUT'
|
||||
type FuelLevel = 'FULL' | 'SEVEN_EIGHTHS' | 'THREE_QUARTERS' | 'FIVE_EIGHTHS' | 'HALF' | 'THREE_EIGHTHS' | 'QUARTER' | 'ONE_EIGHTH' | 'EMPTY'
|
||||
type DamageType = 'SCRATCH' | 'DENT' | 'CRACK' | 'CHIP' | 'MISSING' | 'STAIN' | 'OTHER'
|
||||
type DamageSeverity = 'MINOR' | 'MODERATE' | 'MAJOR'
|
||||
|
||||
export interface DamagePoint {
|
||||
id?: string
|
||||
viewType: 'TOP' | 'FRONT' | 'REAR' | 'LEFT' | 'RIGHT'
|
||||
x: number
|
||||
y: number
|
||||
damageType: DamageType
|
||||
severity: DamageSeverity
|
||||
description?: string | null
|
||||
isPreExisting: boolean
|
||||
}
|
||||
|
||||
export interface DamageInspection {
|
||||
id: string
|
||||
type: InspectionType
|
||||
mileage: number | null
|
||||
fuelLevel: FuelLevel
|
||||
fuelCharge: number | null
|
||||
generalCondition: string | null
|
||||
employeeNotes: string | null
|
||||
customerAgreed: boolean
|
||||
damagePoints: DamagePoint[]
|
||||
}
|
||||
|
||||
const fuelLevels: FuelLevel[] = ['FULL', 'SEVEN_EIGHTHS', 'THREE_QUARTERS', 'FIVE_EIGHTHS', 'HALF', 'THREE_EIGHTHS', 'QUARTER', 'ONE_EIGHTH', 'EMPTY']
|
||||
const damageTypes: DamageType[] = ['SCRATCH', 'DENT', 'CRACK', 'CHIP', 'MISSING', 'STAIN', 'OTHER']
|
||||
const severityColors: Record<DamageSeverity, string> = {
|
||||
MINOR: '#f59e0b',
|
||||
MODERATE: '#f97316',
|
||||
MAJOR: '#dc2626',
|
||||
}
|
||||
|
||||
export default function DamageInspectionCard({
|
||||
reservationId,
|
||||
type,
|
||||
initialInspection,
|
||||
onSaved,
|
||||
}: {
|
||||
reservationId: string
|
||||
type: InspectionType
|
||||
initialInspection?: DamageInspection | null
|
||||
onSaved: (inspection: DamageInspection) => void
|
||||
}) {
|
||||
const [inspection, setInspection] = useState<DamageInspection>({
|
||||
id: initialInspection?.id ?? `${type.toLowerCase()}-draft`,
|
||||
type,
|
||||
mileage: initialInspection?.mileage ?? null,
|
||||
fuelLevel: initialInspection?.fuelLevel ?? 'FULL',
|
||||
fuelCharge: initialInspection?.fuelCharge ?? null,
|
||||
generalCondition: initialInspection?.generalCondition ?? '',
|
||||
employeeNotes: initialInspection?.employeeNotes ?? '',
|
||||
customerAgreed: initialInspection?.customerAgreed ?? false,
|
||||
damagePoints: initialInspection?.damagePoints ?? [],
|
||||
})
|
||||
const [damageType, setDamageType] = useState<DamageType>('SCRATCH')
|
||||
const [severity, setSeverity] = useState<DamageSeverity>('MINOR')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function saveInspection() {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
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,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
setInspection(result)
|
||||
onSaved(result)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to save inspection')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function addDamagePoint(event: React.MouseEvent<SVGSVGElement>) {
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const x = ((event.clientX - rect.left) / rect.width) * 100
|
||||
const y = ((event.clientY - rect.top) / rect.height) * 180
|
||||
setInspection((current) => ({
|
||||
...current,
|
||||
damagePoints: [
|
||||
...current.damagePoints,
|
||||
{
|
||||
viewType: 'TOP',
|
||||
x,
|
||||
y,
|
||||
damageType,
|
||||
severity,
|
||||
description: '',
|
||||
isPreExisting: type === 'CHECKIN',
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
function removePoint(index: number) {
|
||||
setInspection((current) => ({
|
||||
...current,
|
||||
damagePoints: current.damagePoints.filter((_, pointIndex) => pointIndex !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">{type === 'CHECKIN' ? 'Check-in inspection' : 'Check-out inspection'}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">Mark existing and newly discovered damage directly on the vehicle diagram.</p>
|
||||
</div>
|
||||
<button onClick={saveInspection} disabled={saving} className="btn-primary">
|
||||
{saving ? 'Saving…' : 'Save inspection'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
|
||||
|
||||
<div className="mt-5 grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
{damageTypes.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setDamageType(option)}
|
||||
className={`rounded-full px-3 py-1.5 font-semibold ${damageType === option ? 'bg-slate-900 text-white' : 'bg-white text-slate-600'}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs">
|
||||
{(Object.keys(severityColors) as DamageSeverity[]).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setSeverity(option)}
|
||||
className="rounded-full px-3 py-1.5 font-semibold text-white"
|
||||
style={{ backgroundColor: severityColors[option], opacity: severity === option ? 1 : 0.55 }}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<svg viewBox="0 0 100 180" className="w-full cursor-crosshair rounded-2xl border border-slate-200 bg-white" onClick={addDamagePoint}>
|
||||
<rect x="30" y="12" width="40" height="156" rx="18" fill="#e2e8f0" stroke="#94a3b8" />
|
||||
<rect x="36" y="22" width="28" height="24" rx="8" fill="#cbd5e1" />
|
||||
<rect x="36" y="54" width="28" height="70" rx="8" fill="#f8fafc" stroke="#cbd5e1" />
|
||||
<rect x="36" y="132" width="28" height="20" rx="8" fill="#cbd5e1" />
|
||||
<rect x="18" y="38" width="12" height="28" rx="5" fill="#1e293b" />
|
||||
<rect x="70" y="38" width="12" height="28" rx="5" fill="#1e293b" />
|
||||
<rect x="18" y="114" width="12" height="28" rx="5" fill="#1e293b" />
|
||||
<rect x="70" y="114" width="12" height="28" rx="5" fill="#1e293b" />
|
||||
{inspection.damagePoints.map((point, index) => (
|
||||
<g key={`${point.x}-${point.y}-${index}`}>
|
||||
<circle cx={point.x} cy={point.y} r="3.8" fill={severityColors[point.severity]} stroke="white" strokeWidth="1.5" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
<p className="mt-2 text-xs text-slate-500">Click anywhere on the diagram to add a damage marker with the selected type and severity.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Mileage</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-field"
|
||||
value={inspection.mileage ?? ''}
|
||||
onChange={(event) => setInspection((current) => ({ ...current, mileage: event.target.value ? Number(event.target.value) : null }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Fuel level</label>
|
||||
<select className="input-field" value={inspection.fuelLevel} onChange={(event) => setInspection((current) => ({ ...current, fuelLevel: event.target.value as FuelLevel }))}>
|
||||
{fuelLevels.map((fuelLevel) => <option key={fuelLevel} value={fuelLevel}>{fuelLevel}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">General condition</label>
|
||||
<textarea className="input-field min-h-24" value={inspection.generalCondition ?? ''} onChange={(event) => setInspection((current) => ({ ...current, generalCondition: event.target.value }))} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Employee notes</label>
|
||||
<textarea className="input-field min-h-24" value={inspection.employeeNotes ?? ''} onChange={(event) => setInspection((current) => ({ ...current, employeeNotes: event.target.value }))} />
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" checked={inspection.customerAgreed} onChange={(event) => setInspection((current) => ({ ...current, customerAgreed: event.target.checked }))} />
|
||||
Customer acknowledged this inspection
|
||||
</label>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200">
|
||||
<div className="border-b border-slate-200 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-slate-900">Damage markers</p>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{inspection.damagePoints.map((point, index) => (
|
||||
<div key={`${point.x}-${point.y}-${index}`} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{point.damageType} · {point.severity}</p>
|
||||
<p className="text-xs text-slate-500">x {point.x.toFixed(1)} · y {point.y.toFixed(1)}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => removePoint(index)} className="rounded-full border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{inspection.damagePoints.length === 0 && (
|
||||
<div className="px-4 py-6 text-sm text-slate-400">No damage markers saved yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { TeamMember } from '@/hooks/useTeam'
|
||||
|
||||
interface Props {
|
||||
member: TeamMember | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onUpdateRole: (memberId: string, role: 'MANAGER' | 'AGENT') => Promise<void>
|
||||
onDeactivate: (memberId: string) => Promise<void>
|
||||
onReactivate: (memberId: string) => Promise<void>
|
||||
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
|
||||
|
||||
export default function EditMemberModal({
|
||||
member,
|
||||
open,
|
||||
onClose,
|
||||
onUpdateRole,
|
||||
onDeactivate,
|
||||
onReactivate,
|
||||
onRemove,
|
||||
}: Props) {
|
||||
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
|
||||
const [actionState, setActionState] = useState<ActionState>('idle')
|
||||
const [confirm, setConfirm] = useState<ConfirmAction>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (member && open) {
|
||||
setRole(member.role === 'OWNER' ? 'MANAGER' : (member.role as 'MANAGER' | 'AGENT'))
|
||||
setActionState('idle')
|
||||
setConfirm(null)
|
||||
setError(null)
|
||||
}
|
||||
}, [member, open])
|
||||
|
||||
if (!open || !member) return null
|
||||
|
||||
const isPending = member.invitationStatus === 'pending'
|
||||
const isActive = member.isActive
|
||||
|
||||
const handleSave = async () => {
|
||||
if (role === member.role) { onClose(); return }
|
||||
setActionState('saving')
|
||||
setError(null)
|
||||
try {
|
||||
await onUpdateRole(member.id, role)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
setActionState('idle')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeactivate = async () => {
|
||||
setActionState('deactivating')
|
||||
setError(null)
|
||||
try {
|
||||
await onDeactivate(member.id)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
setActionState('idle')
|
||||
}
|
||||
setConfirm(null)
|
||||
}
|
||||
|
||||
const handleReactivate = async () => {
|
||||
setActionState('reactivating')
|
||||
setError(null)
|
||||
try {
|
||||
await onReactivate(member.id)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
setActionState('idle')
|
||||
}
|
||||
setConfirm(null)
|
||||
}
|
||||
|
||||
const handleRemove = async () => {
|
||||
setActionState('removing')
|
||||
setError(null)
|
||||
try {
|
||||
await onRemove(member.id)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
setActionState('idle')
|
||||
}
|
||||
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="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}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-zinc-900 dark:text-white text-sm">
|
||||
{member.firstName} {member.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</p>
|
||||
</div>
|
||||
<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
|
||||
</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
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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?'}
|
||||
</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 Clerk access. This cannot be undone."}
|
||||
{confirm === 'reactivate' && "They'll regain dashboard access with their current role."}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<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
|
||||
</button>
|
||||
<button
|
||||
onClick={
|
||||
confirm === 'deactivate' ? handleDeactivate :
|
||||
confirm === 'reactivate' ? handleReactivate :
|
||||
handleRemove
|
||||
}
|
||||
disabled={busy}
|
||||
className={[
|
||||
'flex-1 text-xs px-3 py-1.5 rounded-lg font-medium disabled:opacity-50',
|
||||
confirm === 'reactivate'
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'bg-red-600 text-white hover:bg-red-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{busy ? 'Working…' : (
|
||||
confirm === 'deactivate' ? 'Deactivate' :
|
||||
confirm === 'reactivate' ? 'Reactivate' :
|
||||
'Remove permanently'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPending && isActive && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">
|
||||
Role
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ROLE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setRole(option.value)}
|
||||
className={[
|
||||
'text-left px-3 py-2.5 rounded-xl border transition-all',
|
||||
role === option.value
|
||||
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
|
||||
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="text-sm font-medium text-zinc-900 dark:text-white">{option.label}</div>
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{option.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</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">
|
||||
{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
|
||||
</button>
|
||||
)}
|
||||
{!isActive && (
|
||||
<button
|
||||
onClick={() => setConfirm('reactivate')}
|
||||
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
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setConfirm('remove')}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
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
|
||||
</button>
|
||||
{isActive && !isPending && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
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'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { InvitePayload } from '@/hooks/useTeam'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
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'],
|
||||
},
|
||||
{
|
||||
value: 'AGENT' as const,
|
||||
label: 'Agent',
|
||||
description: 'Day-to-day operations only',
|
||||
permissions: ['View fleet', 'Reservations', 'Check-in / Check-out', 'View customers'],
|
||||
},
|
||||
]
|
||||
|
||||
export default function InviteModal({ open, onClose, onInvite }: Props) {
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setTimeout(() => {
|
||||
setFirstName('')
|
||||
setLastName('')
|
||||
setEmail('')
|
||||
setRole('AGENT')
|
||||
setError(null)
|
||||
}, 200)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
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')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
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) 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="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>
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
placeholder="Youssef"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:ring-offset-0 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
|
||||
Last name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
placeholder="Benali"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="youssef@yourcompany.com"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
|
||||
Role
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ROLE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setRole(option.value)}
|
||||
className={[
|
||||
'text-left px-3 py-3 rounded-xl border transition-all',
|
||||
role === option.value
|
||||
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
|
||||
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-zinc-900 dark:text-white">
|
||||
{option.label}
|
||||
</span>
|
||||
{role === option.value && (
|
||||
<span className="w-4 h-4 rounded-full bg-zinc-900 dark:bg-white flex items-center justify-center">
|
||||
<svg className="w-2.5 h-2.5 text-white dark:text-zinc-900" fill="currentColor" viewBox="0 0 12 12">
|
||||
<path d="M10 3L5 8.5 2 5.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-snug">
|
||||
{option.description}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{option.permissions.slice(0, 3).map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-2.5 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2 border-t border-zinc-100 dark:border-zinc-800">
|
||||
<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
|
||||
</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 →'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
type Access = 'full' | 'partial' | 'none'
|
||||
|
||||
interface Feature {
|
||||
label: string
|
||||
manager: Access
|
||||
managerNote?: string
|
||||
agent: Access
|
||||
agentNote?: string
|
||||
}
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
function AccessCell({ access, note }: { access: Access; note?: 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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (access === 'partial') {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PermissionsMatrix() {
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
</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
|
||||
</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
|
||||
</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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{FEATURES.map((feature, i) => (
|
||||
<div
|
||||
key={feature.label}
|
||||
className={[
|
||||
'grid grid-cols-[1fr_120px_120px]',
|
||||
i < FEATURES.length - 1 ? 'border-b border-zinc-100 dark:border-zinc-800' : '',
|
||||
i % 2 === 0 ? '' : 'bg-zinc-50/50 dark:bg-zinc-800/20',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="px-4 py-2.5 text-xs text-zinc-600 dark:text-zinc-400">
|
||||
{feature.label}
|
||||
</div>
|
||||
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
|
||||
<AccessCell access={feature.manager} note={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} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user