fix and update language for company workspace
This commit is contained in:
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 & 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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user