fix and update language for company workspace

This commit is contained in:
root
2026-05-11 23:40:40 -04:00
committed by Administrator
parent 193aeae834
commit 1a39aa8433
43 changed files with 5034 additions and 802 deletions
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@
import { useEffect, useState, useCallback } from 'react'
import { ChevronLeft, ChevronRight, Plus, X, Wrench, Ban, CalendarDays } from 'lucide-react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type EventType = 'RESERVATION' | 'MAINTENANCE' | 'BLOCK'
@@ -43,6 +44,8 @@ function daysBetween(start: Date, end: Date): number {
}
export default function VehicleCalendar({ vehicleId }: Props) {
const { dict, language } = useDashboardI18n()
const c = dict.calendar
const today = new Date()
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth() + 1)
@@ -71,11 +74,11 @@ export default function VehicleCalendar({ vehicleId }: Props) {
)
setEvents(data)
} catch (e: any) {
setError(e.message ?? 'Failed to load calendar')
setError(e.message ?? c.failedLoad)
} finally {
setLoading(false)
}
}, [vehicleId, year, month])
}, [vehicleId, year, month, c])
useEffect(() => { fetchCalendar() }, [fetchCalendar])
@@ -142,8 +145,8 @@ export default function VehicleCalendar({ vehicleId }: Props) {
}
const handleSaveBlock = async () => {
if (!blockStart || !blockEnd) { setSaveError('Start and end dates are required.'); return }
if (blockEnd <= blockStart) { setSaveError('End date must be after start date.'); return }
if (!blockStart || !blockEnd) { setSaveError(c.datesRequired); return }
if (blockEnd <= blockStart) { setSaveError(c.endAfterStart); return }
setSaving(true)
setSaveError(null)
try {
@@ -154,23 +157,24 @@ export default function VehicleCalendar({ vehicleId }: Props) {
setShowModal(false)
fetchCalendar()
} catch (e: any) {
setSaveError(e.message ?? 'Failed to create block')
setSaveError(e.message ?? c.failedCreateBlock)
} finally {
setSaving(false)
}
}
const handleDeleteBlock = async (id: string) => {
if (!confirm('Remove this block?')) return
if (!confirm(c.removeBlockConfirm)) return
try {
await apiFetch(`/vehicles/${vehicleId}/calendar/blocks/${id}`, { method: 'DELETE' })
fetchCalendar()
} catch (e: any) {
alert(e.message ?? 'Failed to delete block')
alert(e.message ?? c.failedDeleteBlock)
}
}
const monthLabel = new Date(year, month - 1, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const monthLabel = new Date(year, month - 1, 1).toLocaleDateString(localeCode, { month: 'long', year: 'numeric' })
// Separate reservations vs blocks for the legend/list below
const reservationEvents = events.filter((e) => e.type === 'RESERVATION')
@@ -182,7 +186,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CalendarDays className="w-5 h-5 text-slate-600" />
<h3 className="text-base font-semibold text-slate-900">Availability Calendar</h3>
<h3 className="text-base font-semibold text-slate-900">{c.heading}</h3>
</div>
<div className="flex items-center gap-2">
<button onClick={prevMonth} className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors">
@@ -193,24 +197,24 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<ChevronRight className="w-4 h-4" />
</button>
<button onClick={openModal} className="ml-2 btn-primary flex items-center gap-1.5 text-xs py-1.5 px-3">
<Plus className="w-3.5 h-3.5" /> Block dates
<Plus className="w-3.5 h-3.5" /> {c.blockDates}
</button>
</div>
</div>
{selectStart && (
<div className="text-xs text-blue-600 bg-blue-50 border border-blue-200 rounded-lg px-3 py-2">
Click a second day to set the block end date (selected: {selectStart})
<button className="ml-2 underline" onClick={() => setSelectStart(null)}>cancel</button>
{c.clickSecondDay(selectStart!)}
<button className="ml-2 underline" onClick={() => setSelectStart(null)}>{c.cancelSelection}</button>
</div>
)}
{/* Legend */}
<div className="flex flex-wrap gap-4 text-xs text-slate-600">
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-blue-500 inline-block" />Reserved</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-amber-500 inline-block" />Maintenance</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-red-500 inline-block" />Blocked</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-emerald-500 inline-block" />Available</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-blue-500 inline-block" />{c.legendReserved}</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-amber-500 inline-block" />{c.legendMaintenance}</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-red-500 inline-block" />{c.legendBlocked}</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-emerald-500 inline-block" />{c.legendAvailable}</span>
</div>
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{error}</div>}
@@ -219,7 +223,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<div className={loading ? 'opacity-50 pointer-events-none' : ''}>
{/* Day headers */}
<div className="grid grid-cols-7 mb-1">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
{c.dayHeaders.map((d) => (
<div key={d} className="text-center text-xs font-medium text-slate-400 py-1">{d}</div>
))}
</div>
@@ -261,7 +265,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
)
})}
{dayEvents.length > 2 && (
<div className="text-[10px] text-slate-400 pl-1">+{dayEvents.length - 2} more</div>
<div className="text-[10px] text-slate-400 pl-1">{c.moreDays(dayEvents.length - 2)}</div>
)}
</div>
</div>
@@ -275,7 +279,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<div className="space-y-3 pt-2">
{reservationEvents.length > 0 && (
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Reservations this month</p>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">{c.reservationsThisMonth}</p>
<div className="space-y-1.5">
{reservationEvents.map((e) => {
const s = EVENT_STYLES[e.type]
@@ -299,7 +303,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
{blockEvents.length > 0 && (
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Blocks &amp; maintenance</p>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">{c.blocksAndMaintenance}</p>
<div className="space-y-1.5">
{blockEvents.map((e) => {
const s = EVENT_STYLES[e.type]
@@ -317,7 +321,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<button
onClick={() => handleDeleteBlock(e.id)}
className={`p-1 rounded hover:bg-white/60 transition-colors ${s.text}`}
title="Remove block"
title={c.removeBlock}
>
<X className="w-3.5 h-3.5" />
</button>
@@ -335,7 +339,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm" onClick={closeModal}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 space-y-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between">
<h4 className="text-base font-semibold text-slate-900">Block vehicle dates</h4>
<h4 className="text-base font-semibold text-slate-900">{c.modalTitle}</h4>
<button onClick={closeModal} className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100">
<X className="w-4 h-4" />
</button>
@@ -343,26 +347,26 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<div className="space-y-3 text-sm">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Block type</label>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{c.blockTypeLabel}</label>
<div className="flex gap-2">
<button
onClick={() => setBlockType('MANUAL')}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border text-sm font-medium transition-colors ${blockType === 'MANUAL' ? 'bg-red-50 border-red-300 text-red-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'}`}
>
<Ban className="w-3.5 h-3.5" /> Manual block
<Ban className="w-3.5 h-3.5" /> {c.manualBlock}
</button>
<button
onClick={() => setBlockType('MAINTENANCE')}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border text-sm font-medium transition-colors ${blockType === 'MAINTENANCE' ? 'bg-amber-50 border-amber-300 text-amber-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'}`}
>
<Wrench className="w-3.5 h-3.5" /> Maintenance
<Wrench className="w-3.5 h-3.5" /> {c.maintenanceBlock}
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Start date</label>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{c.startDate}</label>
<input
type="date"
className="input-field"
@@ -371,7 +375,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">End date (exclusive)</label>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{c.endDate}</label>
<input
type="date"
className="input-field"
@@ -383,11 +387,11 @@ export default function VehicleCalendar({ vehicleId }: Props) {
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Reason <span className="font-normal">(optional)</span></label>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{c.reasonLabel} <span className="font-normal">{c.optional}</span></label>
<input
type="text"
className="input-field"
placeholder={blockType === 'MAINTENANCE' ? 'e.g. Oil change, tire rotation…' : 'e.g. Reserved for VIP, company event…'}
placeholder={blockType === 'MAINTENANCE' ? c.reasonPlaceholderMaintenance : c.reasonPlaceholderManual}
value={blockReason}
onChange={(e) => setBlockReason(e.target.value)}
/>
@@ -399,9 +403,9 @@ export default function VehicleCalendar({ vehicleId }: Props) {
)}
<div className="flex gap-2 pt-1">
<button onClick={closeModal} className="flex-1 btn-secondary">Cancel</button>
<button onClick={closeModal} className="flex-1 btn-secondary">{c.cancel}</button>
<button onClick={handleSaveBlock} disabled={saving} className="flex-1 btn-primary">
{saving ? 'Saving…' : 'Save block'}
{saving ? c.saving : c.saveBlock}
</button>
</div>
</div>
+174 -45
View File
@@ -2,6 +2,7 @@
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import {
LayoutDashboard,
Car,
@@ -15,10 +16,30 @@ import {
Bell,
Settings,
LogOut,
ChevronLeft,
ChevronRight,
} from 'lucide-react'
import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
import { EMPLOYEE_TOKEN_KEY } from '@/lib/api'
import { EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
interface BrandSettings {
displayName: string
logoUrl: string | null
}
interface SidebarUser {
displayName: string
subtitle: string
initials: string
}
function computeInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return 'U'
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase()
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
}
const NAV_ITEMS = [
{ href: '/dashboard', key: 'dashboard', icon: LayoutDashboard, exact: true },
@@ -35,9 +56,67 @@ const NAV_ITEMS = [
] as const
export default function Sidebar() {
const { dict } = useDashboardI18n()
const { dict, language } = useDashboardI18n()
const isRtl = language === 'ar'
const pathname = usePathname()
const router = useRouter()
const [brand, setBrand] = useState<BrandSettings | null>(null)
const [user, setUser] = useState<SidebarUser>({
displayName: dict.workspaceUser,
subtitle: dict.localAuth,
initials: 'W',
})
const [open, setOpen] = useState(false)
useEffect(() => {
apiFetch<BrandSettings>('/companies/me/brand').then(setBrand).catch(() => {})
}, [])
useEffect(() => {
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
if (!token) {
setUser({
displayName: dict.workspaceUser,
subtitle: dict.localAuth,
initials: 'W',
})
return
}
let cancelled = false
apiFetch<{
employee: {
email: string
firstName: string
lastName: string
role: string
}
}>('/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 })
})
.catch(() => {
if (cancelled) return
setUser({
displayName: dict.workspaceUser,
subtitle: dict.localAuth,
initials: 'W',
})
})
return () => { cancelled = true }
}, [dict.localAuth, dict.workspaceUser])
// Close sidebar when navigating on mobile
useEffect(() => { setOpen(false) }, [pathname])
const isActive = (item: typeof NAV_ITEMS[number]) => {
if ('exact' in item && item.exact) return pathname === item.href
@@ -51,52 +130,102 @@ export default function Sidebar() {
}
return (
<aside className="fixed inset-y-0 left-0 z-40 flex w-64 flex-col bg-slate-900">
<Link href={marketplaceUrl} className="flex items-center gap-3 border-b border-slate-800 px-6 py-5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-500">
<Car className="h-4 w-4 text-white" />
</div>
<span className="text-sm font-bold tracking-wide text-white">RentalDriveGo</span>
</Link>
<>
{/* Mobile backdrop */}
{open && (
<div
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
onClick={() => setOpen(false)}
/>
)}
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
{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 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
active ? 'bg-blue-600 text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-white',
].join(' ')}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{dict.nav[item.key]}
</Link>
)
})}
</nav>
<div className="border-t border-slate-800 px-3 py-4">
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-500 text-xs font-semibold text-white">
W
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">{dict.workspaceUser}</p>
<p className="truncate text-xs text-slate-400">{dict.localAuth}</p>
</div>
</div>
{/* Tab to open sidebar — only visible when sidebar is closed on mobile */}
{!open && (
<button
onClick={signOut}
className="mt-2 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
onClick={() => setOpen(true)}
aria-label="Open sidebar"
className={[
'fixed top-1/2 z-50 -translate-y-1/2 flex items-center justify-center w-6 h-14 bg-slate-900 text-white shadow-lg lg:hidden',
isRtl ? 'right-0 rounded-l-lg' : 'left-0 rounded-r-lg',
].join(' ')}
>
<LogOut className="h-4 w-4" />
{dict.signOut}
{isRtl ? <ChevronLeft className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
</div>
</aside>
)}
<aside
className={[
'fixed inset-y-0 z-40 flex w-64 flex-col bg-slate-900 transition-transform duration-300',
isRtl ? 'right-0' : 'left-0',
'lg:translate-x-0',
open ? 'translate-x-0' : (isRtl ? 'translate-x-full' : '-translate-x-full'),
].join(' ')}
>
<Link href={marketplaceUrl} className="flex items-center gap-3 border-b border-slate-800 px-6 py-5">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500 overflow-hidden">
{brand?.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={brand.logoUrl} alt={brand.displayName} className="h-8 w-8 object-cover" />
) : (
<Car className="h-4 w-4 text-white" />
)}
</div>
<span className="truncate text-sm font-bold tracking-wide text-white">
{brand?.displayName ?? 'RentalDriveGo'}
</span>
</Link>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
{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 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
active ? 'bg-blue-600 text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-white',
].join(' ')}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{dict.nav[item.key]}
</Link>
)
})}
</nav>
<div className="border-t border-slate-800 px-3 py-4">
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-500 text-xs font-semibold text-white">
{user.initials}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">{user.displayName}</p>
<p className="truncate text-xs text-slate-400">{user.subtitle}</p>
</div>
</div>
<button
onClick={signOut}
className="mt-2 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
>
<LogOut className="h-4 w-4" />
{dict.signOut}
</button>
</div>
{/* Arrow tab to collapse sidebar (mobile only, sticks to outer edge) */}
<button
onClick={() => setOpen(false)}
aria-label="Close sidebar"
className={[
'absolute top-1/2 z-10 -translate-y-1/2 flex items-center justify-center w-5 h-14 bg-slate-900 text-white shadow-lg lg:hidden',
isRtl ? '-left-5 rounded-l-lg' : '-right-5 rounded-r-lg',
].join(' ')}
>
{isRtl ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronLeft className="h-3.5 w-3.5" />}
</button>
</aside>
</>
)
}
+147 -10
View File
@@ -1,25 +1,127 @@
'use client'
import { Bell } from 'lucide-react'
import { usePathname } from 'next/navigation'
import { usePathname, useRouter } from 'next/navigation'
import { useState, useEffect } from 'react'
import { apiFetch } from '@/lib/api'
import { EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
function computeInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return 'U'
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase()
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
}
export default function TopBar() {
const { dict } = useDashboardI18n()
const pathname = usePathname()
const router = useRouter()
const [unreadCount, setUnreadCount] = useState(0)
const [showNotifs, setShowNotifs] = useState(false)
const [userInitials, setUserInitials] = useState('W')
const [notifications, setNotifications] = useState<Array<{
id: string
title: string
body: string
readAt: string | null
createdAt: string
}>>([])
const [loadingNotifs, setLoadingNotifs] = useState(false)
const title = dict.titles[pathname] ?? dict.titles['/dashboard']
async function refreshUnreadCount() {
try {
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
setUnreadCount(data.unread)
} catch {}
}
useEffect(() => {
apiFetch<{ unread: number }>('/notifications/unread-count')
.then((data) => setUnreadCount(data.unread))
.catch(() => {})
refreshUnreadCount()
}, [pathname])
useEffect(() => {
function onNotificationsUpdated() {
refreshUnreadCount()
}
window.addEventListener('notifications:updated', onNotificationsUpdated)
return () => window.removeEventListener('notifications:updated', onNotificationsUpdated)
}, [])
useEffect(() => {
if (!showNotifs) return
let cancelled = false
setLoadingNotifs(true)
apiFetch<Array<{
id: string
title: string
body: string
readAt: string | null
createdAt: string
}>>('/notifications/company')
.then((data) => {
if (cancelled) return
setNotifications(data.slice(0, 8))
})
.catch(() => {
if (cancelled) return
setNotifications([])
})
.finally(() => {
if (cancelled) return
setLoadingNotifs(false)
})
return () => { cancelled = true }
}, [showNotifs])
useEffect(() => {
setShowNotifs(false)
}, [pathname])
useEffect(() => {
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
if (!token) {
setUserInitials(computeInitials(dict.workspaceUser))
return
}
let cancelled = false
apiFetch<{
employee: {
email: string
firstName: string
lastName: string
}
}>('/auth/employee/me')
.then(({ employee }) => {
if (cancelled) return
const fullName = `${employee.firstName ?? ''} ${employee.lastName ?? ''}`.trim()
const email = employee.email ?? ''
setUserInitials(computeInitials(fullName || email || dict.workspaceUser))
})
.catch(() => {
if (cancelled) return
setUserInitials(computeInitials(dict.workspaceUser))
})
return () => { cancelled = true }
}, [dict.workspaceUser])
async function openNotificationsInbox(notificationId?: string) {
if (notificationId) {
try {
await apiFetch(`/notifications/company/${notificationId}/read`, { method: 'POST' })
setUnreadCount((current) => Math.max(current - 1, 0))
window.dispatchEvent(new Event('notifications:updated'))
} catch {}
}
setShowNotifs(false)
router.push('/dashboard/notifications')
}
return (
<header className="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-6 transition-colors dark:border-slate-800 dark:bg-slate-950">
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</h1>
@@ -40,16 +142,51 @@ export default function TopBar() {
{showNotifs ? (
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-xl border border-slate-200 bg-white p-4 shadow-lg dark:border-slate-700 dark:bg-slate-900">
<p className="mb-2 text-sm font-medium text-slate-900 dark:text-slate-100">{dict.notifications}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
{unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)}
</p>
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{dict.notifications}</p>
<button
type="button"
onClick={() => openNotificationsInbox()}
className="text-xs font-medium text-blue-600 hover:text-blue-700"
>
View all
</button>
</div>
{loadingNotifs ? (
<p className="text-sm text-slate-500 dark:text-slate-400">Loading</p>
) : notifications.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
{unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)}
</p>
) : (
<div className="space-y-1">
{notifications.map((notification) => (
<button
key={notification.id}
type="button"
onClick={() => openNotificationsInbox(notification.id)}
className={[
'w-full rounded-lg px-3 py-2 text-left transition-colors hover:bg-slate-100 dark:hover:bg-slate-800',
notification.readAt ? 'opacity-80' : '',
].join(' ')}
>
<p className="truncate text-sm font-medium text-slate-900 dark:text-slate-100">
{notification.title}
</p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400">
{notification.body}
</p>
</button>
))}
</div>
)}
</div>
) : null}
</div>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-500 text-xs font-semibold text-white">
W
{userInitials}
</div>
</div>
</header>
@@ -2,6 +2,7 @@
import { useState } from 'react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type InspectionType = 'CHECKIN' | 'CHECKOUT'
type FuelLevel = 'FULL' | 'SEVEN_EIGHTHS' | 'THREE_QUARTERS' | 'FIVE_EIGHTHS' | 'HALF' | 'THREE_EIGHTHS' | 'QUARTER' | 'ONE_EIGHTH' | 'EMPTY'
@@ -50,6 +51,9 @@ export default function DamageInspectionCard({
initialInspection?: DamageInspection | null
onSaved: (inspection: DamageInspection) => void
}) {
const { dict } = useDashboardI18n()
const r = dict.reservations
const [inspection, setInspection] = useState<DamageInspection>({
id: initialInspection?.id ?? `${type.toLowerCase()}-draft`,
type,
@@ -80,20 +84,14 @@ export default function DamageInspectionCard({
employeeNotes: inspection.employeeNotes,
customerAgreed: inspection.customerAgreed,
damagePoints: inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
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')
setError(err.message ?? r.failedSaveInspection)
} finally {
setSaving(false)
}
@@ -107,15 +105,7 @@ export default function DamageInspectionCard({
...current,
damagePoints: [
...current.damagePoints,
{
viewType: 'TOP',
x,
y,
damageType,
severity,
description: '',
isPreExisting: type === 'CHECKIN',
},
{ viewType: 'TOP', x, y, damageType, severity, description: '', isPreExisting: type === 'CHECKIN' },
],
}))
}
@@ -123,7 +113,7 @@ export default function DamageInspectionCard({
function removePoint(index: number) {
setInspection((current) => ({
...current,
damagePoints: current.damagePoints.filter((_, pointIndex) => pointIndex !== index),
damagePoints: current.damagePoints.filter((_, i) => i !== index),
}))
}
@@ -131,11 +121,11 @@ export default function DamageInspectionCard({
<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>
<h3 className="text-base font-semibold text-slate-900">{r.inspectionCardTitle(type)}</h3>
<p className="mt-1 text-sm text-slate-500">{r.inspectionSubtitle}</p>
</div>
<button onClick={saveInspection} disabled={saving} className="btn-primary">
{saving ? 'Saving…' : 'Save inspection'}
{saving ? r.savingInspection : r.saveInspection}
</button>
</div>
@@ -151,7 +141,7 @@ export default function DamageInspectionCard({
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}
{r.damageTypeLabels[option]}
</button>
))}
</div>
@@ -164,7 +154,7 @@ export default function DamageInspectionCard({
className="rounded-full px-3 py-1.5 font-semibold text-white"
style={{ backgroundColor: severityColors[option], opacity: severity === option ? 1 : 0.55 }}
>
{option}
{r.severityLabels[option]}
</button>
))}
</div>
@@ -184,61 +174,61 @@ export default function DamageInspectionCard({
</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>
<p className="mt-2 text-xs text-slate-500">{r.diagramHelp}</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>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.mileageLabel}</label>
<input
type="number"
className="input-field"
value={inspection.mileage ?? ''}
onChange={(event) => setInspection((current) => ({ ...current, mileage: event.target.value ? Number(event.target.value) : null }))}
onChange={(e) => setInspection((cur) => ({ ...cur, mileage: e.target.value ? Number(e.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>)}
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.fuelLevelLabel}</label>
<select className="input-field" value={inspection.fuelLevel} onChange={(e) => setInspection((cur) => ({ ...cur, fuelLevel: e.target.value as FuelLevel }))}>
{fuelLevels.map((fl) => <option key={fl} value={fl}>{r.fuelLevels[fl]}</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 }))} />
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.generalConditionLabel}</label>
<textarea className="input-field min-h-24" value={inspection.generalCondition ?? ''} onChange={(e) => setInspection((cur) => ({ ...cur, generalCondition: e.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 }))} />
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.employeeNotesLabel}</label>
<textarea className="input-field min-h-24" value={inspection.employeeNotes ?? ''} onChange={(e) => setInspection((cur) => ({ ...cur, employeeNotes: e.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
<input type="checkbox" checked={inspection.customerAgreed} onChange={(e) => setInspection((cur) => ({ ...cur, customerAgreed: e.target.checked }))} />
{r.customerAcknowledged}
</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>
<p className="text-sm font-semibold text-slate-900">{r.damageMarkersHeading}</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="font-medium text-slate-900">{r.damageTypeLabels[point.damageType]} · {r.severityLabels[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
{r.removeMarker}
</button>
</div>
))}
{inspection.damagePoints.length === 0 && (
<div className="px-4 py-6 text-sm text-slate-400">No damage markers saved yet.</div>
<div className="px-4 py-6 text-sm text-slate-400">{r.noMarkers}</div>
)}
</div>
</div>
@@ -4,6 +4,7 @@ interface StatCardProps {
title: string
value: string | number
change?: number
vsLastMonthLabel?: string
icon: LucideIcon
iconColor?: string
iconBg?: string
@@ -13,6 +14,7 @@ export default function StatCard({
title,
value,
change,
vsLastMonthLabel = 'vs last month',
icon: Icon,
iconColor = 'text-blue-600',
iconBg = 'bg-blue-50',
@@ -36,7 +38,7 @@ export default function StatCard({
>
{isPositive ? '+' : ''}{change.toFixed(1)}%
</span>
<span className="text-xs text-slate-400">vs last month</span>
<span className="text-xs text-slate-400">{vsLastMonthLabel}</span>
</div>
)}
</div>