Files
lunarfront-app/packages/admin/src/routes/_authenticated.tsx
Ryan Moon a84530e80e
Some checks failed
Build & Release / build (push) Failing after 32s
fix: replace invalid TanStack Router search casts with typed defaults
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>
2026-04-05 10:33:36 -05:00

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>
)
}