Some checks failed
Build & Release / build (push) Failing after 32s
Newer TanStack Router enforces strict types on search params — 'search: {} as Record<string, unknown>' no longer satisfies routes with validateSearch. Replace all occurrences with the correct search shape for each destination route (pagination defaults for list routes, tab/field defaults for detail routes).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
265 lines
12 KiB
TypeScript
265 lines
12 KiB
TypeScript
import { createFileRoute, Outlet, Link, redirect, useRouter } from '@tanstack/react-router'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { queryOptions } from '@tanstack/react-query'
|
|
import { useEffect, useState } from 'react'
|
|
import { api } from '@/lib/api-client'
|
|
import { useAuthStore } from '@/stores/auth.store'
|
|
import { myPermissionsOptions } from '@/api/rbac'
|
|
import { moduleListOptions } from '@/api/modules'
|
|
import { Avatar } from '@/components/shared/avatar-upload'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, KeyRound, Settings, PanelLeftClose, PanelLeft, CalendarDays, GraduationCap, CalendarRange, BookOpen, BookMarked, Package2, Tag, Truck, ShoppingCart } from 'lucide-react'
|
|
|
|
export const Route = createFileRoute('/_authenticated')({
|
|
beforeLoad: () => {
|
|
const { token } = useAuthStore.getState()
|
|
if (!token) {
|
|
throw redirect({ to: '/login' })
|
|
}
|
|
},
|
|
component: AuthenticatedLayout,
|
|
})
|
|
|
|
function StoreLogo() {
|
|
const token = useAuthStore((s) => s.token)
|
|
const [src, setSrc] = useState<string | null>(null)
|
|
|
|
const { data: storeData } = useQuery(queryOptions({
|
|
queryKey: ['store'],
|
|
queryFn: () => api.get<{ id: string; name: string }>('/v1/store'),
|
|
enabled: !!token,
|
|
}))
|
|
|
|
const { data: filesData } = useQuery(queryOptions({
|
|
queryKey: ['files', 'company', storeData?.id ?? ''],
|
|
queryFn: () => api.get<{ data: { id: string; path: string }[] }>('/v1/files', { entityType: 'company', entityId: storeData?.id }),
|
|
enabled: !!storeData?.id,
|
|
}))
|
|
|
|
const logoFile = filesData?.data?.find((f) => f.path.includes('/logo-'))
|
|
|
|
useEffect(() => {
|
|
if (!logoFile || !token) { setSrc(null); return }
|
|
let cancelled = false
|
|
let blobUrl: string | null = null
|
|
async function load() {
|
|
try {
|
|
const res = await fetch(`/v1/files/serve/${logoFile!.path}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
})
|
|
if (!res.ok || cancelled) return
|
|
const blob = await res.blob()
|
|
if (!cancelled) { blobUrl = URL.createObjectURL(blob); setSrc(blobUrl) }
|
|
} catch { /* ignore */ }
|
|
}
|
|
load()
|
|
return () => { cancelled = true; if (blobUrl) URL.revokeObjectURL(blobUrl) }
|
|
}, [logoFile?.path, token])
|
|
|
|
if (src) {
|
|
return <img src={src} alt={storeData?.name ?? 'Store'} className="max-h-10 max-w-[180px] object-contain" />
|
|
}
|
|
|
|
return <h2 className="text-lg font-semibold text-sidebar-foreground">{storeData?.name ?? 'LunarFront'}</h2>
|
|
}
|
|
|
|
function NavLink({ to, icon, label, collapsed }: { to: string; icon: React.ReactNode; label: string; collapsed?: boolean }) {
|
|
return (
|
|
<Link
|
|
to={to as '/accounts'}
|
|
search={{ page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const }}
|
|
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent"
|
|
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground' }}
|
|
title={collapsed ? label : undefined}
|
|
>
|
|
{icon}
|
|
{!collapsed && label}
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
function NavGroup({ label, children, collapsed }: { label: string; children: React.ReactNode; collapsed?: boolean }) {
|
|
const [open, setOpen] = useState(true)
|
|
|
|
if (collapsed) return <div className="space-y-1">{children}</div>
|
|
return (
|
|
<div>
|
|
<button
|
|
onClick={() => setOpen(!open)}
|
|
className="flex items-center justify-between w-full px-3 mb-1 mt-3 group cursor-pointer"
|
|
>
|
|
<span className="text-xs font-semibold text-sidebar-foreground/50 uppercase tracking-wide group-hover:text-sidebar-foreground/70">{label}</span>
|
|
<svg
|
|
className={`h-3 w-3 text-sidebar-foreground/40 transition-transform ${open ? '' : '-rotate-90'}`}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
{open && <div className="space-y-1">{children}</div>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AuthenticatedLayout() {
|
|
const router = useRouter()
|
|
const user = useAuthStore((s) => s.user)
|
|
const logout = useAuthStore((s) => s.logout)
|
|
const hasPermission = useAuthStore((s) => s.hasPermission)
|
|
const setPermissions = useAuthStore((s) => s.setPermissions)
|
|
const permissionsLoaded = useAuthStore((s) => s.permissionsLoaded)
|
|
|
|
// Fetch permissions on mount
|
|
const { data: permData } = useQuery({
|
|
...myPermissionsOptions(),
|
|
enabled: !!useAuthStore.getState().token,
|
|
})
|
|
|
|
// Fetch enabled modules
|
|
const { data: modulesData } = useQuery({
|
|
...moduleListOptions(),
|
|
enabled: !!useAuthStore.getState().token,
|
|
})
|
|
|
|
const enabledModules = new Set(
|
|
(modulesData?.data ?? []).filter((m) => m.enabled && m.licensed).map((m) => m.slug),
|
|
)
|
|
const isModuleEnabled = (slug: string) => enabledModules.has(slug)
|
|
|
|
useEffect(() => {
|
|
if (permData?.permissions) {
|
|
setPermissions(permData.permissions)
|
|
}
|
|
}, [permData, setPermissions])
|
|
|
|
function handleLogout() {
|
|
logout()
|
|
router.navigate({ to: '/login', replace: true })
|
|
}
|
|
|
|
const canViewAccounts = !permissionsLoaded || hasPermission('accounts.view')
|
|
const canViewRepairs = !permissionsLoaded || hasPermission('repairs.view')
|
|
const canViewLessons = !permissionsLoaded || hasPermission('lessons.view')
|
|
const canViewInventory = !permissionsLoaded || hasPermission('inventory.view')
|
|
const canViewUsers = !permissionsLoaded || hasPermission('users.view')
|
|
const canViewPOS = !permissionsLoaded || hasPermission('pos.view')
|
|
|
|
const [collapsed, setCollapsed] = useState(false)
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background text-foreground">
|
|
<div className="flex">
|
|
<nav className={`${collapsed ? 'w-14' : 'w-56'} border-r border-border bg-sidebar h-screen flex flex-col sticky top-0 transition-[width] duration-200`}>
|
|
{/* Header — logo & collapse toggle */}
|
|
<div className={`p-3 flex items-center ${collapsed ? 'justify-center' : 'justify-between'}`}>
|
|
{!collapsed && (
|
|
<div className="min-w-0">
|
|
<StoreLogo />
|
|
<p className="text-[10px] text-sidebar-foreground/40 tracking-wide">Powered by <span className="font-semibold text-sidebar-foreground/60">LunarFront</span></p>
|
|
</div>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 shrink-0 text-sidebar-foreground/50 hover:text-sidebar-foreground"
|
|
onClick={() => setCollapsed(!collapsed)}
|
|
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
>
|
|
{collapsed ? <PanelLeft className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Scrollable nav links */}
|
|
<div className="flex-1 overflow-y-auto px-2 space-y-1 scrollbar-thin">
|
|
{isModuleEnabled('pos') && canViewPOS && (
|
|
<div className="mb-2">
|
|
<NavLink to="/pos" icon={<ShoppingCart className="h-4 w-4" />} label="Point of Sale" collapsed={collapsed} />
|
|
</div>
|
|
)}
|
|
{canViewAccounts && (
|
|
<NavGroup label="Customers" collapsed={collapsed}>
|
|
<NavLink to="/accounts" icon={<Users className="h-4 w-4" />} label="Accounts" collapsed={collapsed} />
|
|
<NavLink to="/members" icon={<UserRound className="h-4 w-4" />} label="Members" collapsed={collapsed} />
|
|
</NavGroup>
|
|
)}
|
|
{isModuleEnabled('inventory') && canViewInventory && (
|
|
<NavGroup label="Inventory" collapsed={collapsed}>
|
|
<NavLink to="/inventory" icon={<Package2 className="h-4 w-4" />} label="Products" collapsed={collapsed} />
|
|
<NavLink to="/inventory/categories" icon={<Tag className="h-4 w-4" />} label="Categories" collapsed={collapsed} />
|
|
<NavLink to="/inventory/suppliers" icon={<Truck className="h-4 w-4" />} label="Suppliers" collapsed={collapsed} />
|
|
</NavGroup>
|
|
)}
|
|
{isModuleEnabled('repairs') && canViewRepairs && (
|
|
<NavGroup label="Repairs" collapsed={collapsed}>
|
|
<NavLink to="/repairs" icon={<Wrench className="h-4 w-4" />} label="Tickets" collapsed={collapsed} />
|
|
<NavLink to="/repair-batches" icon={<Package className="h-4 w-4" />} label="Batches" collapsed={collapsed} />
|
|
{hasPermission('repairs.admin') && (
|
|
<NavLink to="/repairs/templates" icon={<ClipboardList className="h-4 w-4" />} label="Templates" collapsed={collapsed} />
|
|
)}
|
|
</NavGroup>
|
|
)}
|
|
{isModuleEnabled('lessons') && canViewLessons && (
|
|
<NavGroup label="Lessons" collapsed={collapsed}>
|
|
<NavLink to="/lessons/schedule" icon={<CalendarDays className="h-4 w-4" />} label="Schedule" collapsed={collapsed} />
|
|
<NavLink to="/lessons/enrollments" icon={<GraduationCap className="h-4 w-4" />} label="Enrollments" collapsed={collapsed} />
|
|
<NavLink to="/lessons/sessions" icon={<CalendarRange className="h-4 w-4" />} label="Sessions" collapsed={collapsed} />
|
|
<NavLink to="/lessons/plans" icon={<BookOpen className="h-4 w-4" />} label="Lesson Plans" collapsed={collapsed} />
|
|
{hasPermission('lessons.admin') && (
|
|
<NavLink to="/lessons/templates" icon={<BookMarked className="h-4 w-4" />} label="Templates" collapsed={collapsed} />
|
|
)}
|
|
</NavGroup>
|
|
)}
|
|
{(isModuleEnabled('files') || isModuleEnabled('vault')) && (
|
|
<NavGroup label="Storage" collapsed={collapsed}>
|
|
{isModuleEnabled('files') && (
|
|
<NavLink to="/files" icon={<FolderOpen className="h-4 w-4" />} label="Files" collapsed={collapsed} />
|
|
)}
|
|
{isModuleEnabled('vault') && (
|
|
<NavLink to="/vault" icon={<KeyRound className="h-4 w-4" />} label="Vault" collapsed={collapsed} />
|
|
)}
|
|
</NavGroup>
|
|
)}
|
|
{canViewUsers && (
|
|
<NavGroup label="Admin" collapsed={collapsed}>
|
|
<NavLink to="/users" icon={<UserCog className="h-4 w-4" />} label="Users" collapsed={collapsed} />
|
|
<NavLink to="/roles" icon={<Shield className="h-4 w-4" />} label="Roles" collapsed={collapsed} />
|
|
<NavLink to="/settings" icon={<Settings className="h-4 w-4" />} label="Settings" collapsed={collapsed} />
|
|
</NavGroup>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pinned footer — help, profile, sign out */}
|
|
<div className="shrink-0 p-2 border-t border-sidebar-border space-y-1">
|
|
<NavLink to="/help" icon={<HelpCircle className="h-4 w-4" />} label="Help" collapsed={collapsed} />
|
|
<Link
|
|
to="/profile"
|
|
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent w-full"
|
|
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground w-full' }}
|
|
title={collapsed ? `${user?.firstName} ${user?.lastName}` : undefined}
|
|
>
|
|
{user?.id ? <Avatar entityType="user" entityId={user.id} size="sm" /> : <User className="h-4 w-4" />}
|
|
{!collapsed && <span className="truncate">{user?.firstName} {user?.lastName}</span>}
|
|
</Link>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className={`w-full justify-start gap-2 text-sm text-sidebar-foreground/70 hover:text-sidebar-foreground ${collapsed ? 'px-3' : ''}`}
|
|
onClick={handleLogout}
|
|
title={collapsed ? 'Sign out' : undefined}
|
|
>
|
|
<LogOut className="h-4 w-4" />
|
|
{!collapsed && 'Sign out'}
|
|
</Button>
|
|
</div>
|
|
</nav>
|
|
<main className="flex-1 p-6 min-h-screen">
|
|
<Outlet />
|
|
</main>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|