10 Commits

Author SHA1 Message Date
Ryan Moon
24ddb17ca8 fix: rename migration 0039_app_settings to 0041 to avoid conflict with 0039_cash-rounding
All checks were successful
Build & Release / build (push) Successful in 17s
2026-04-05 10:58:59 -05:00
Ryan Moon
bd2252e426 feat: add email, encryption, and initial user env vars to backend chart
All checks were successful
Build & Release / build (push) Successful in 17s
2026-04-05 10:47:25 -05:00
Ryan Moon
254fe0e5d5 fix: move ts-expect-error inside navigate object to suppress search type error
All checks were successful
Build & Release / build (push) Successful in 1m6s
2026-04-05 10:42:00 -05:00
Ryan Moon
5750af83d8 fix: suppress navigate search type error in usePagination; fix setBillingUnit cast
Some checks failed
Build & Release / build (push) Failing after 35s
- use-pagination.ts: ts-expect-error on navigate call — search type resolves as never without route context, safe with strict:false
- $enrollmentId.tsx: wrap onValueChange to cast string to billingUnit union type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:40:23 -05:00
Ryan Moon
505f8fd4e4 fix: correct TanStack Router search types for all navigate/Link calls
Some checks failed
Build & Release / build (push) Failing after 33s
Each destination route's search must match its validateSearch shape exactly:
- Detail pages (tab-based): { tab: '...' }
- List pages with extra filters: include status, instructorId, view, categoryId etc.
- Form pages (enrollments/new, repairs/new): include only their specific fields
- use-pagination.ts: fix search reducer to use (prev: any) instead of invalid cast

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:37:34 -05:00
Ryan Moon
a84530e80e fix: replace invalid TanStack Router search casts with typed defaults
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>
2026-04-05 10:33:36 -05:00
Ryan Moon
b8e39369f1 feat: add app settings table, encryption utility, and generic email service
Some checks failed
Build & Release / build (push) Failing after 35s
- app_settings table with encrypted field support (AES-256-GCM, key from ENCRYPTION_KEY env)
- SettingsService for transparent encrypt/decrypt on get/set
- EmailService factory with Resend and SendGrid providers (SMTP stub) — provider config lives in app_settings
- Seeds initial admin user and email settings from env vars on first startup if not already present
- Migration 0039_app_settings.sql

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:27:20 -05:00
ryan
81d37a2c68 fix: add lsof and iproute2 to devpod base image
Some checks failed
Build Devpod / build (push) Successful in 2m30s
Build & Release / build (push) Failing after 31s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:45:34 +00:00
ryan
70a924cfba fix: add tini init to devpod to reap zombie bun processes
Some checks failed
Build & Release / build (push) Has been cancelled
Build Devpod / build (push) Has been cancelled
bun --watch spawns new processes on file changes but code-server (PID 1)
doesn't reap orphans, causing zombie accumulation and port conflicts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:43:12 +00:00
2b9e99bbd6 Merge pull request 'feat: POS register screen with touch-optimized layout' (#6) from feature/pos-register into main
Some checks failed
Build & Release / build (push) Failing after 34s
Reviewed-on: #6
2026-04-04 20:14:21 +00:00
41 changed files with 377 additions and 75 deletions

View File

@@ -8,8 +8,8 @@ ENV PATH="/usr/local/bin:/root/.bun/bin:$PATH"
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
curl wget git openssh-server ca-certificates gnupg \ curl wget git openssh-server ca-certificates gnupg \
build-essential unzip zip jq tmux zsh ripgrep \ build-essential unzip zip jq tmux zsh ripgrep \
postgresql-client redis-tools haproxy \ postgresql-client redis-tools haproxy tini \
nano vim htop netcat-openbsd dnsutils iputils-ping \ nano vim htop netcat-openbsd dnsutils iputils-ping lsof iproute2 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Bun — install then move to /usr/local/bin so it's on the image filesystem, not the /root PVC # Bun — install then move to /usr/local/bin so it's on the image filesystem, not the /root PVC
@@ -44,4 +44,4 @@ RUN chmod +x /entrypoint.sh
WORKDIR /root WORKDIR /root
EXPOSE 8080 22 EXPOSE 8080 22
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]

View File

@@ -73,6 +73,50 @@ spec:
secretKeyRef: secretKeyRef:
name: lunarfront-secrets name: lunarfront-secrets
key: jwt-secret key: jwt-secret
- name: ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: encryption-key
- name: RESEND_API_KEY
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: resend-api-key
- name: MAIL_FROM
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: mail-from
- name: BUSINESS_NAME
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: business-name
- name: INITIAL_USER_EMAIL
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: initial-user-email
optional: true
- name: INITIAL_USER_PASSWORD
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: initial-user-password
optional: true
- name: INITIAL_USER_FIRST_NAME
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: initial-user-first-name
optional: true
- name: INITIAL_USER_LAST_NAME
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: initial-user-last-name
optional: true
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /v1/health path: /v1/health

View File

@@ -23,12 +23,13 @@ export function usePagination() {
function setParams(updates: Partial<PaginationSearch>) { function setParams(updates: Partial<PaginationSearch>) {
navigate({ navigate({
search: ((prev: Record<string, unknown>) => ({ // @ts-expect-error: navigate without a route context resolves search as never; safe here since we use strict:false
search: (prev: any) => ({
...prev, ...prev,
...updates, ...updates,
// Reset to page 1 when search or sort changes // Reset to page 1 when search or sort changes
page: updates.q !== undefined || updates.sort !== undefined ? 1 : (updates.page ?? (prev as PaginationSearch).page), page: updates.q !== undefined || updates.sort !== undefined ? 1 : (updates.page ?? (prev as PaginationSearch).page),
})) as (prev: Record<string, unknown>) => Record<string, unknown>, }),
replace: true, replace: true,
}) })
} }

View File

@@ -67,7 +67,7 @@ function NavLink({ to, icon, label, collapsed }: { to: string; icon: React.React
return ( return (
<Link <Link
to={to as '/accounts'} to={to as '/accounts'}
search={{} as Record<string, unknown>} 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" 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' }} 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} title={collapsed ? label : undefined}

View File

@@ -41,7 +41,7 @@ function AccountEnrollmentsTab() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{data?.pagination.total ?? 0} enrollment(s)</p> <p className="text-sm text-muted-foreground">{data?.pagination.total ?? 0} enrollment(s)</p>
{hasPermission('lessons.edit') && ( {hasPermission('lessons.edit') && (
<Button size="sm" onClick={() => navigate({ to: '/lessons/enrollments/new', search: {} as Record<string, unknown> })}> <Button size="sm" onClick={() => navigate({ to: '/lessons/enrollments/new', search: { memberId: undefined, accountId: undefined } })}>
<Plus className="h-4 w-4 mr-1" />Enroll a Member <Plus className="h-4 w-4 mr-1" />Enroll a Member
</Button> </Button>
)} )}
@@ -55,7 +55,7 @@ function AccountEnrollmentsTab() {
total={data?.data?.length ?? 0} total={data?.data?.length ?? 0}
onPageChange={() => {}} onPageChange={() => {}}
onSort={() => {}} onSort={() => {}}
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as Record<string, unknown> })} onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: { tab: 'details' } })}
/> />
</div> </div>
) )

View File

@@ -281,7 +281,7 @@ function MembersTab() {
<DropdownMenuItem onClick={() => navigate({ <DropdownMenuItem onClick={() => navigate({
to: '/members/$memberId', to: '/members/$memberId',
params: { memberId: m.id }, params: { memberId: m.id },
search: {} as Record<string, unknown>, search: { tab: 'details' },
})}> })}>
<Pencil className="mr-2 h-4 w-4" /> <Pencil className="mr-2 h-4 w-4" />
Edit Edit

View File

@@ -2,6 +2,6 @@ import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/')({ export const Route = createFileRoute('/_authenticated/')({
beforeLoad: () => { beforeLoad: () => {
throw redirect({ to: '/accounts', search: {} as Record<string, unknown> }) throw redirect({ to: '/accounts', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })
}, },
}) })

View File

@@ -159,7 +159,7 @@ function ProductDetailPage() {
}) })
function setTab(t: string) { function setTab(t: string) {
navigate({ to: '/inventory/$productId', params: { productId }, search: { tab: t } as Record<string, unknown> }) navigate({ to: '/inventory/$productId', params: { productId }, search: { tab: t } })
} }
function handleQtySave() { function handleQtySave() {
@@ -192,7 +192,7 @@ function ProductDetailPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/inventory', search: {} as Record<string, unknown> })}> <Button variant="ghost" size="sm" onClick={() => navigate({ to: '/inventory', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const, categoryId: undefined, isActive: undefined, type: undefined, lowStock: undefined } })}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div className="min-w-0"> <div className="min-w-0">

View File

@@ -71,7 +71,7 @@ function InventoryPage() {
queryClient.invalidateQueries({ queryKey: productKeys.all }) queryClient.invalidateQueries({ queryKey: productKeys.all })
toast.success('Product created') toast.success('Product created')
setCreateOpen(false) setCreateOpen(false)
navigate({ to: '/inventory/$productId', params: { productId: product.id }, search: {} as Record<string, unknown> }) navigate({ to: '/inventory/$productId', params: { productId: product.id }, search: { tab: 'details' } })
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
@@ -83,23 +83,23 @@ function InventoryPage() {
function handleCategoryChange(v: string) { function handleCategoryChange(v: string) {
setCategoryFilter(v === 'all' ? '' : v) setCategoryFilter(v === 'all' ? '' : v)
navigate({ to: '/inventory', search: { ...search, categoryId: v === 'all' ? undefined : v, page: 1 } as Record<string, unknown> }) navigate({ to: '/inventory', search: { ...search, categoryId: v === 'all' ? undefined : v, page: 1 } })
} }
function handleActiveChange(v: string) { function handleActiveChange(v: string) {
setActiveFilter(v === 'all' ? '' : v) setActiveFilter(v === 'all' ? '' : v)
navigate({ to: '/inventory', search: { ...search, isActive: v === 'all' ? undefined : v, page: 1 } as Record<string, unknown> }) navigate({ to: '/inventory', search: { ...search, isActive: v === 'all' ? undefined : v, page: 1 } })
} }
function handleTypeChange(v: string) { function handleTypeChange(v: string) {
setTypeFilter(v === 'all' ? '' : v) setTypeFilter(v === 'all' ? '' : v)
navigate({ to: '/inventory', search: { ...search, type: v === 'all' ? undefined : v, page: 1 } as Record<string, unknown> }) navigate({ to: '/inventory', search: { ...search, type: v === 'all' ? undefined : v, page: 1 } })
} }
function handleLowStockChange(v: string) { function handleLowStockChange(v: string) {
const on = v === 'true' const on = v === 'true'
setLowStockFilter(on) setLowStockFilter(on)
navigate({ to: '/inventory', search: { ...search, lowStock: on ? 'true' : undefined, page: 1 } as Record<string, unknown> }) navigate({ to: '/inventory', search: { ...search, lowStock: on ? 'true' : undefined, page: 1 } })
} }
const columns: Column<Product>[] = [ const columns: Column<Product>[] = [
@@ -246,7 +246,7 @@ function InventoryPage() {
order={params.order} order={params.order}
onPageChange={setPage} onPageChange={setPage}
onSort={setSort} onSort={setSort}
onRowClick={(p) => navigate({ to: '/inventory/$productId', params: { productId: p.id }, search: {} as Record<string, unknown> })} onRowClick={(p) => navigate({ to: '/inventory/$productId', params: { productId: p.id }, search: { tab: 'details' } })}
/> />
</div> </div>
) )

View File

@@ -81,7 +81,7 @@ function EnrollmentDetailPage() {
const tab = search.tab const tab = search.tab
function setTab(t: string) { function setTab(t: string) {
navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId }, search: { tab: t } as Record<string, unknown> }) navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId }, search: { tab: t } })
} }
const { data: enrollment, isLoading } = useQuery(enrollmentDetailOptions(enrollmentId)) const { data: enrollment, isLoading } = useQuery(enrollmentDetailOptions(enrollmentId))
@@ -131,7 +131,7 @@ function EnrollmentDetailPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as Record<string, unknown> })}> <Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/enrollments', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'desc' as const, status: undefined, instructorId: undefined } })}>
<ArrowLeft className="h-4 w-4 mr-1" />Back <ArrowLeft className="h-4 w-4 mr-1" />Back
</Button> </Button>
<div className="flex-1"> <div className="flex-1">
@@ -265,7 +265,7 @@ function DetailsTab({
onChange={(e) => setBillingInterval(e.target.value)} onChange={(e) => setBillingInterval(e.target.value)}
className="w-20" className="w-20"
/> />
<Select value={billingUnit} onValueChange={setBillingUnit}> <Select value={billingUnit} onValueChange={(v) => setBillingUnit(v as typeof billingUnit)}>
<SelectTrigger className="w-36"><SelectValue /></SelectTrigger> <SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
{BILLING_UNITS.map((u) => ( {BILLING_UNITS.map((u) => (
@@ -344,7 +344,7 @@ function SessionsTab({ enrollmentId, onGenerate, generating }: { enrollmentId: s
total={data?.data?.length ?? 0} total={data?.data?.length ?? 0}
onPageChange={() => {}} onPageChange={() => {}}
onSort={() => {}} onSort={() => {}}
onRowClick={(s) => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as Record<string, unknown> })} onRowClick={(s) => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} })}
/> />
</div> </div>
) )
@@ -383,7 +383,7 @@ function LessonPlanTab({ enrollmentId, memberId, canEdit }: { enrollmentId: stri
queryClient.invalidateQueries({ queryKey: lessonPlanKeys.all }) queryClient.invalidateQueries({ queryKey: lessonPlanKeys.all })
toast.success('Plan created from template') toast.success('Plan created from template')
setTemplatePickerOpen(false) setTemplatePickerOpen(false)
navigate({ to: '/lessons/plans/$planId', params: { planId: plan.id }, search: {} as Record<string, unknown> }) navigate({ to: '/lessons/plans/$planId', params: { planId: plan.id }, search: {} })
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
@@ -401,7 +401,7 @@ function LessonPlanTab({ enrollmentId, memberId, canEdit }: { enrollmentId: stri
{Math.round(activePlan.progress)}% complete {Math.round(activePlan.progress)}% complete
</p> </p>
</div> </div>
<Button variant="outline" size="sm" onClick={() => navigate({ to: '/lessons/plans/$planId', params: { planId: activePlan.id }, search: {} as Record<string, unknown> })}> <Button variant="outline" size="sm" onClick={() => navigate({ to: '/lessons/plans/$planId', params: { planId: activePlan.id }, search: {} })}>
View Plan View Plan
</Button> </Button>
</div> </div>

View File

@@ -72,7 +72,7 @@ function EnrollmentsListPage() {
function handleStatusChange(v: string) { function handleStatusChange(v: string) {
const s = v === 'all' ? '' : v const s = v === 'all' ? '' : v
setStatusFilter(s) setStatusFilter(s)
navigate({ to: '/lessons/enrollments', search: { ...search, status: s || undefined, page: 1 } as Record<string, unknown> }) navigate({ to: '/lessons/enrollments', search: { ...search, status: s || undefined, page: 1 } })
} }
return ( return (
@@ -80,7 +80,7 @@ function EnrollmentsListPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Enrollments</h1> <h1 className="text-2xl font-bold">Enrollments</h1>
{hasPermission('lessons.edit') && ( {hasPermission('lessons.edit') && (
<Button onClick={() => navigate({ to: '/lessons/enrollments/new', search: {} as Record<string, unknown> })}> <Button onClick={() => navigate({ to: '/lessons/enrollments/new', search: { memberId: undefined, accountId: undefined } })}>
<Plus className="mr-2 h-4 w-4" />New Enrollment <Plus className="mr-2 h-4 w-4" />New Enrollment
</Button> </Button>
)} )}
@@ -125,7 +125,7 @@ function EnrollmentsListPage() {
order={params.order} order={params.order}
onPageChange={setPage} onPageChange={setPage}
onSort={setSort} onSort={setSort}
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as Record<string, unknown> })} onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: { tab: 'details' } })}
/> />
</div> </div>
) )

View File

@@ -108,7 +108,7 @@ function NewEnrollmentPage() {
}, },
onSuccess: (enrollment) => { onSuccess: (enrollment) => {
toast.success('Enrollment created') toast.success('Enrollment created')
navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: enrollment.id }, search: {} as Record<string, unknown> }) navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: enrollment.id }, search: { tab: 'details' } })
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
@@ -141,7 +141,7 @@ function NewEnrollmentPage() {
return ( return (
<div className="space-y-6 max-w-2xl"> <div className="space-y-6 max-w-2xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as Record<string, unknown> })}> <Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/enrollments', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'desc' as const, status: undefined, instructorId: undefined } })}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<h1 className="text-2xl font-bold">New Enrollment</h1> <h1 className="text-2xl font-bold">New Enrollment</h1>
@@ -282,7 +282,7 @@ function NewEnrollmentPage() {
<Button type="submit" disabled={mutation.isPending || !selectedMember || !selectedSlotId || !startDate} size="lg"> <Button type="submit" disabled={mutation.isPending || !selectedMember || !selectedSlotId || !startDate} size="lg">
{mutation.isPending ? 'Creating...' : 'Create Enrollment'} {mutation.isPending ? 'Creating...' : 'Create Enrollment'}
</Button> </Button>
<Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as Record<string, unknown> })}> <Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/lessons/enrollments', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'desc' as const, status: undefined, instructorId: undefined } })}>
Cancel Cancel
</Button> </Button>
</div> </div>

View File

@@ -93,7 +93,7 @@ function LessonPlanDetailPage() {
return ( return (
<div className="space-y-6 max-w-3xl"> <div className="space-y-6 max-w-3xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/plans', search: {} as Record<string, unknown> })}> <Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/plans', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div className="flex-1"> <div className="flex-1">

View File

@@ -84,7 +84,7 @@ function LessonPlansPage() {
order={params.order} order={params.order}
onPageChange={setPage} onPageChange={setPage}
onSort={setSort} onSort={setSort}
onRowClick={(p) => navigate({ to: '/lessons/plans/$planId', params: { planId: p.id }, search: {} as Record<string, unknown> })} onRowClick={(p) => navigate({ to: '/lessons/plans/$planId', params: { planId: p.id }, search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}
/> />
</div> </div>
) )

View File

@@ -49,7 +49,7 @@ function ScheduleHubPage() {
const canAdmin = hasPermission('lessons.admin') const canAdmin = hasPermission('lessons.admin')
function setTab(t: string) { function setTab(t: string) {
navigate({ to: '/lessons/schedule', search: { ...search, tab: t, page: 1 } as Record<string, unknown> }) navigate({ to: '/lessons/schedule', search: { ...search, tab: t, page: 1 } })
} }
return ( return (
@@ -152,7 +152,7 @@ function InstructorsTab({ canAdmin, search: _search }: { canAdmin: boolean; sear
order={params.order} order={params.order}
onPageChange={setPage} onPageChange={setPage}
onSort={setSort} onSort={setSort}
onRowClick={(i) => navigate({ to: '/lessons/schedule/instructors/$instructorId', params: { instructorId: i.id }, search: {} as Record<string, unknown> })} onRowClick={(i) => navigate({ to: '/lessons/schedule/instructors/$instructorId', params: { instructorId: i.id }, search: { tab: 'overview' } })}
/> />
</div> </div>
) )

View File

@@ -42,7 +42,7 @@ function InstructorDetailPage() {
const tab = search.tab const tab = search.tab
function setTab(t: string) { function setTab(t: string) {
navigate({ to: '/lessons/schedule/instructors/$instructorId', params: { instructorId }, search: { tab: t } as Record<string, unknown> }) navigate({ to: '/lessons/schedule/instructors/$instructorId', params: { instructorId }, search: { tab: t } })
} }
const { data: instructor, isLoading } = useQuery(instructorDetailOptions(instructorId)) const { data: instructor, isLoading } = useQuery(instructorDetailOptions(instructorId))
@@ -62,7 +62,7 @@ function InstructorDetailPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/schedule', search: { tab: 'instructors' } as Record<string, unknown> })}> <Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/schedule', search: { tab: 'instructors', page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}>
<ArrowLeft className="h-4 w-4 mr-1" />Back <ArrowLeft className="h-4 w-4 mr-1" />Back
</Button> </Button>
<div className="flex-1"> <div className="flex-1">

View File

@@ -126,7 +126,7 @@ function SessionDetailPage() {
return ( return (
<div className="space-y-6 max-w-3xl"> <div className="space-y-6 max-w-3xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/sessions', search: {} as Record<string, unknown> })}> <Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/sessions', search: { view: 'list' as const, page: 1, limit: 25, q: undefined, sort: undefined, order: 'desc' as const, status: undefined, instructorId: undefined } })}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div className="flex-1"> <div className="flex-1">
@@ -137,7 +137,7 @@ function SessionDetailPage() {
<Link <Link
to="/lessons/enrollments/$enrollmentId" to="/lessons/enrollments/$enrollmentId"
params={{ enrollmentId: enrollment.id }} params={{ enrollmentId: enrollment.id }}
search={{} as Record<string, unknown>} search={{ tab: 'details' }}
className="text-sm text-primary hover:underline" className="text-sm text-primary hover:underline"
> >
View Enrollment View Enrollment

View File

@@ -92,13 +92,13 @@ function SessionsPage() {
const weekEnd = endOfWeek(weekStart, { weekStartsOn: 0 }) const weekEnd = endOfWeek(weekStart, { weekStartsOn: 0 })
function setView(v: 'list' | 'week') { function setView(v: 'list' | 'week') {
navigate({ to: '/lessons/sessions', search: { ...search, view: v, page: 1 } as Record<string, unknown> }) navigate({ to: '/lessons/sessions', search: { ...search, view: v, page: 1 } })
} }
function handleStatusChange(v: string) { function handleStatusChange(v: string) {
const s = v === 'all' ? '' : v const s = v === 'all' ? '' : v
setStatusFilter(s) setStatusFilter(s)
navigate({ to: '/lessons/sessions', search: { ...search, status: s || undefined, page: 1 } as Record<string, unknown> }) navigate({ to: '/lessons/sessions', search: { ...search, status: s || undefined, page: 1 } })
} }
// List query // List query
@@ -189,7 +189,7 @@ function SessionsPage() {
order={params.order} order={params.order}
onPageChange={setPage} onPageChange={setPage}
onSort={setSort} onSort={setSort}
onRowClick={(s) => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as Record<string, unknown> })} onRowClick={(s) => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}
/> />
</> </>
)} )}
@@ -249,7 +249,7 @@ function SessionsPage() {
{daySessions.map((s) => ( {daySessions.map((s) => (
<button <button
key={s.id} key={s.id}
onClick={() => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as Record<string, unknown> })} onClick={() => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}
className={`w-full text-left rounded border px-1.5 py-1 text-xs hover:opacity-80 transition-opacity ${STATUS_COLORS[s.status] ?? STATUS_COLORS.scheduled}`} className={`w-full text-left rounded border px-1.5 py-1 text-xs hover:opacity-80 transition-opacity ${STATUS_COLORS[s.status] ?? STATUS_COLORS.scheduled}`}
> >
<p className="font-semibold">{formatTime(s.scheduledTime)}</p> <p className="font-semibold">{formatTime(s.scheduledTime)}</p>

View File

@@ -42,7 +42,7 @@ function TemplateDetailPage() {
return ( return (
<div className="space-y-6 max-w-3xl"> <div className="space-y-6 max-w-3xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/templates', search: {} as Record<string, unknown> })}> <Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/templates', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div className="flex-1"> <div className="flex-1">
@@ -218,7 +218,7 @@ function InstantiateDialog({ template, templateId, open, onClose }: {
}), }),
onSuccess: (plan) => { onSuccess: (plan) => {
toast.success('Plan created from template') toast.success('Plan created from template')
navigate({ to: '/lessons/plans/$planId', params: { planId: plan.id }, search: {} as Record<string, unknown> }) navigate({ to: '/lessons/plans/$planId', params: { planId: plan.id }, search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })

View File

@@ -96,7 +96,7 @@ function TemplatesListPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Lesson Plan Templates</h1> <h1 className="text-2xl font-bold">Lesson Plan Templates</h1>
{canAdmin && ( {canAdmin && (
<Button onClick={() => navigate({ to: '/lessons/templates/new', search: {} as Record<string, unknown> })}> <Button onClick={() => navigate({ to: '/lessons/templates/new', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}>
<Plus className="mr-2 h-4 w-4" />New Template <Plus className="mr-2 h-4 w-4" />New Template
</Button> </Button>
)} )}
@@ -126,7 +126,7 @@ function TemplatesListPage() {
order={params.order} order={params.order}
onPageChange={setPage} onPageChange={setPage}
onSort={setSort} onSort={setSort}
onRowClick={(t) => navigate({ to: '/lessons/templates/$templateId', params: { templateId: t.id }, search: {} as Record<string, unknown> })} onRowClick={(t) => navigate({ to: '/lessons/templates/$templateId', params: { templateId: t.id }, search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}
/> />
</div> </div>
) )

View File

@@ -45,7 +45,7 @@ function NewTemplatePage() {
}), }),
onSuccess: (template) => { onSuccess: (template) => {
toast.success('Template created') toast.success('Template created')
navigate({ to: '/lessons/templates/$templateId', params: { templateId: template.id }, search: {} as Record<string, unknown> }) navigate({ to: '/lessons/templates/$templateId', params: { templateId: template.id }, search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
@@ -63,7 +63,7 @@ function NewTemplatePage() {
return ( return (
<form onSubmit={handleSubmit} className="space-y-6 max-w-3xl"> <form onSubmit={handleSubmit} className="space-y-6 max-w-3xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/templates', search: {} as Record<string, unknown> })}> <Button type="button" variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/templates', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<h1 className="text-2xl font-bold">New Template</h1> <h1 className="text-2xl font-bold">New Template</h1>
@@ -112,7 +112,7 @@ function NewTemplatePage() {
<Button type="submit" disabled={mutation.isPending || !name.trim() || !allSectionsValid} size="lg"> <Button type="submit" disabled={mutation.isPending || !name.trim() || !allSectionsValid} size="lg">
{mutation.isPending ? 'Creating...' : 'Create Template'} {mutation.isPending ? 'Creating...' : 'Create Template'}
</Button> </Button>
<Button type="button" variant="secondary" size="lg" onClick={() => navigate({ to: '/lessons/templates', search: {} as Record<string, unknown> })}> <Button type="button" variant="secondary" size="lg" onClick={() => navigate({ to: '/lessons/templates', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}>
Cancel Cancel
</Button> </Button>
</div> </div>

View File

@@ -161,7 +161,7 @@ function MemberDetailPage() {
}) })
function setTab(t: string) { function setTab(t: string) {
navigate({ to: '/members/$memberId', params: { memberId }, search: { tab: t } as Record<string, unknown> }) navigate({ to: '/members/$memberId', params: { memberId }, search: { tab: t } })
} }
if (isLoading) { if (isLoading) {
@@ -188,7 +188,7 @@ function MemberDetailPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/accounts/$accountId/members', params: { accountId: member.accountId }, search: {} as Record<string, unknown> })}> <Button variant="ghost" size="sm" onClick={() => navigate({ to: '/accounts/$accountId/members', params: { accountId: member.accountId }, search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div> <div>
@@ -293,7 +293,7 @@ function MemberDetailPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{enrollmentsData?.pagination.total ?? 0} enrollment(s)</p> <p className="text-sm text-muted-foreground">{enrollmentsData?.pagination.total ?? 0} enrollment(s)</p>
{hasPermission('lessons.edit') && ( {hasPermission('lessons.edit') && (
<Button size="sm" onClick={() => navigate({ to: '/lessons/enrollments/new', search: { memberId } as Record<string, unknown> })}> <Button size="sm" onClick={() => navigate({ to: '/lessons/enrollments/new', search: { memberId, accountId: undefined } })}>
<Plus className="h-4 w-4 mr-1" />Enroll <Plus className="h-4 w-4 mr-1" />Enroll
</Button> </Button>
)} )}
@@ -307,7 +307,7 @@ function MemberDetailPage() {
total={enrollmentsData?.data?.length ?? 0} total={enrollmentsData?.data?.length ?? 0}
onPageChange={() => {}} onPageChange={() => {}}
onSort={() => {}} onSort={() => {}}
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as Record<string, unknown> })} onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: { tab: 'details' } })}
/> />
</div> </div>
)} )}

View File

@@ -84,7 +84,7 @@ function MembersListPage() {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate({ to: '/members/$memberId', params: { memberId: row.id }, search: {} as Record<string, unknown> })}> <DropdownMenuItem onClick={() => navigate({ to: '/members/$memberId', params: { memberId: row.id }, search: { tab: 'details' } })}>
<Pencil className="mr-2 h-4 w-4" /> <Pencil className="mr-2 h-4 w-4" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
@@ -134,7 +134,7 @@ function MembersListPage() {
order={params.order} order={params.order}
onPageChange={setPage} onPageChange={setPage}
onSort={setSort} onSort={setSort}
onRowClick={(member) => navigate({ to: '/members/$memberId', params: { memberId: member.id }, search: {} as Record<string, unknown> })} onRowClick={(member) => navigate({ to: '/members/$memberId', params: { memberId: member.id }, search: { tab: 'details' } })}
/> />
</div> </div>
) )

View File

@@ -96,12 +96,12 @@ function RepairBatchDetailPage() {
const totalActual = tickets.reduce((sum, t) => sum + (t.actualCost ? parseFloat(t.actualCost) : 0), 0) const totalActual = tickets.reduce((sum, t) => sum + (t.actualCost ? parseFloat(t.actualCost) : 0), 0)
function handleTicketClick(ticket: RepairTicket) { function handleTicketClick(ticket: RepairTicket) {
navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: {} as Record<string, unknown> }) navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })
} }
function handleAddRepair() { function handleAddRepair() {
// Navigate to new repair with batch and account pre-linked // Navigate to new repair with batch and account pre-linked
navigate({ to: '/repairs/new', search: { batchId, batchName: batch!.batchNumber ?? '', accountId: batch!.accountId, contactName: batch!.contactName ?? '' } as Record<string, unknown> }) navigate({ to: '/repairs/new', search: { batchId, batchName: batch!.batchNumber ?? '', accountId: batch!.accountId, contactName: batch!.contactName ?? '' } })
} }
async function generateBatchPdf() { async function generateBatchPdf() {
@@ -233,7 +233,7 @@ function RepairBatchDetailPage() {
return ( return (
<div className="space-y-6 max-w-5xl"> <div className="space-y-6 max-w-5xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repair-batches', search: {} as Record<string, unknown> })}> <Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repair-batches', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div className="flex-1"> <div className="flex-1">

View File

@@ -75,7 +75,7 @@ function RepairBatchesListPage() {
} }
function handleRowClick(batch: RepairBatch) { function handleRowClick(batch: RepairBatch) {
navigate({ to: '/repair-batches/$batchId', params: { batchId: batch.id }, search: {} as Record<string, unknown> }) navigate({ to: '/repair-batches/$batchId', params: { batchId: batch.id }, search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })
} }
return ( return (

View File

@@ -50,7 +50,7 @@ function NewRepairBatchPage() {
mutationFn: repairBatchMutations.create, mutationFn: repairBatchMutations.create,
onSuccess: (batch) => { onSuccess: (batch) => {
toast.success('Repair batch created') toast.success('Repair batch created')
navigate({ to: '/repair-batches/$batchId', params: { batchId: batch.id }, search: {} as Record<string, unknown> }) navigate({ to: '/repair-batches/$batchId', params: { batchId: batch.id }, search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
@@ -78,7 +78,7 @@ function NewRepairBatchPage() {
return ( return (
<div className="space-y-6 max-w-3xl"> <div className="space-y-6 max-w-3xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repair-batches', search: {} as Record<string, unknown> })}> <Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repair-batches', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<h1 className="text-2xl font-bold">New Repair Batch</h1> <h1 className="text-2xl font-bold">New Repair Batch</h1>
@@ -176,7 +176,7 @@ function NewRepairBatchPage() {
<Button type="submit" disabled={mutation.isPending} size="lg"> <Button type="submit" disabled={mutation.isPending} size="lg">
{mutation.isPending ? 'Creating...' : 'Create Batch'} {mutation.isPending ? 'Creating...' : 'Create Batch'}
</Button> </Button>
<Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/repair-batches', search: {} as Record<string, unknown> })}> <Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/repair-batches', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}>
Cancel Cancel
</Button> </Button>
</div> </div>

View File

@@ -175,7 +175,7 @@ function RepairTicketDetailPage() {
<div className="space-y-4 max-w-5xl"> <div className="space-y-4 max-w-5xl">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repairs', search: {} as Record<string, unknown> })}> <Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repairs', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div className="flex-1"> <div className="flex-1">

View File

@@ -129,7 +129,7 @@ function RepairsListPage() {
} }
function handleRowClick(ticket: RepairTicket) { function handleRowClick(ticket: RepairTicket) {
navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: {} as Record<string, unknown> }) navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })
} }
return ( return (
@@ -137,7 +137,7 @@ function RepairsListPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Repairs</h1> <h1 className="text-2xl font-bold">Repairs</h1>
{hasPermission('repairs.edit') && ( {hasPermission('repairs.edit') && (
<Button onClick={() => navigate({ to: '/repairs/new', search: {} as Record<string, unknown> })}> <Button onClick={() => navigate({ to: '/repairs/new', search: { batchId: undefined, batchName: undefined, accountId: undefined, contactName: undefined } })}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
New Repair New Repair
</Button> </Button>

View File

@@ -136,7 +136,7 @@ function NewRepairPage() {
}, },
onSuccess: (ticket) => { onSuccess: (ticket) => {
toast.success('Repair ticket created') toast.success('Repair ticket created')
navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: {} as Record<string, unknown> }) navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
@@ -210,7 +210,7 @@ function NewRepairPage() {
return ( return (
<div className="space-y-6 max-w-4xl"> <div className="space-y-6 max-w-4xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repairs', search: {} as Record<string, unknown> })}> <Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repairs', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<h1 className="text-2xl font-bold">New Repair Ticket</h1> <h1 className="text-2xl font-bold">New Repair Ticket</h1>
@@ -486,7 +486,7 @@ function NewRepairPage() {
<Button type="submit" disabled={mutation.isPending} size="lg"> <Button type="submit" disabled={mutation.isPending} size="lg">
{mutation.isPending ? 'Creating...' : 'Create Ticket'} {mutation.isPending ? 'Creating...' : 'Create Ticket'}
</Button> </Button>
<Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/repairs', search: {} as Record<string, unknown> })}> <Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/repairs', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}>
Cancel Cancel
</Button> </Button>
</div> </div>

View File

@@ -100,7 +100,7 @@ function RoleDetailPage() {
return ( return (
<div className="space-y-6 max-w-2xl"> <div className="space-y-6 max-w-2xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/roles', search: {} as Record<string, unknown> })}> <Button variant="ghost" size="sm" onClick={() => navigate({ to: '/roles', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div> <div>
@@ -177,7 +177,7 @@ function RoleDetailPage() {
<Button onClick={handleSave} disabled={updateMutation.isPending}> <Button onClick={handleSave} disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'} {updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</Button> </Button>
<Button variant="secondary" onClick={() => navigate({ to: '/roles', search: {} as Record<string, unknown> })}> <Button variant="secondary" onClick={() => navigate({ to: '/roles', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}>
Cancel Cancel
</Button> </Button>
</div> </div>

View File

@@ -29,7 +29,7 @@ function NewRolePage() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: rbacKeys.roles }) queryClient.invalidateQueries({ queryKey: rbacKeys.roles })
toast.success('Role created') toast.success('Role created')
navigate({ to: '/roles', search: {} as Record<string, unknown> }) navigate({ to: '/roles', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
@@ -153,7 +153,7 @@ function NewRolePage() {
<Button onClick={handleSubmit} disabled={mutation.isPending}> <Button onClick={handleSubmit} disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create Role'} {mutation.isPending ? 'Creating...' : 'Create Role'}
</Button> </Button>
<Button variant="secondary" onClick={() => navigate({ to: '/roles', search: {} as Record<string, unknown> })}> <Button variant="secondary" onClick={() => navigate({ to: '/roles', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}>
Cancel Cancel
</Button> </Button>
</div> </div>

View File

@@ -7,7 +7,7 @@ export const Route = createFileRoute('/login')({
beforeLoad: () => { beforeLoad: () => {
const { token } = useAuthStore.getState() const { token } = useAuthStore.getState()
if (token) { if (token) {
throw redirect({ to: '/accounts', search: {} as Record<string, unknown> }) throw redirect({ to: '/accounts', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })
} }
}, },
component: LoginPage, component: LoginPage,
@@ -30,7 +30,7 @@ function LoginPage() {
const res = await login(email, password) const res = await login(email, password)
setAuth(res.token, res.user) setAuth(res.token, res.user)
await router.invalidate() await router.invalidate()
await router.navigate({ to: '/accounts', search: {} as Record<string, unknown>, replace: true }) await router.navigate({ to: '/accounts', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const }, replace: true })
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Login failed') setError(err instanceof Error ? err.message : 'Login failed')
} finally { } finally {

View File

@@ -3,3 +3,4 @@ export * from './schema/users.js'
export * from './schema/accounts.js' export * from './schema/accounts.js'
export * from './schema/inventory.js' export * from './schema/inventory.js'
export * from './schema/pos.js' export * from './schema/pos.js'
export * from './schema/settings.js'

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS "app_settings" (
"key" varchar(100) PRIMARY KEY,
"value" text,
"is_encrypted" boolean NOT NULL DEFAULT false,
"iv" text,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);

View File

@@ -288,6 +288,13 @@
"when": 1775494000000, "when": 1775494000000,
"tag": "0040_app-config", "tag": "0040_app-config",
"breakpoints": true "breakpoints": true
},
{
"idx": 41,
"version": "7",
"when": 1775580000000,
"tag": "0041_app_settings",
"breakpoints": true
} }
] ]
} }

View File

@@ -0,0 +1,13 @@
import { pgTable, varchar, text, boolean, timestamp } from 'drizzle-orm/pg-core'
export const appSettings = pgTable('app_settings', {
key: varchar('key', { length: 100 }).primaryKey(),
value: text('value'),
isEncrypted: boolean('is_encrypted').notNull().default(false),
iv: text('iv'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export type AppSetting = typeof appSettings.$inferSelect
export type AppSettingInsert = typeof appSettings.$inferInsert

View File

@@ -34,6 +34,42 @@ import { configRoutes } from './routes/v1/config.js'
import { RbacService } from './services/rbac.service.js' import { RbacService } from './services/rbac.service.js'
import { ModuleService } from './services/module.service.js' import { ModuleService } from './services/module.service.js'
import { AppConfigService } from './services/config.service.js' import { AppConfigService } from './services/config.service.js'
import { SettingsService } from './services/settings.service.js'
import { users } from './db/schema/users.js'
import bcrypt from 'bcryptjs'
async function seedInitialUser(app: Awaited<ReturnType<typeof buildApp>>) {
const email = process.env.INITIAL_USER_EMAIL
const password = process.env.INITIAL_USER_PASSWORD
const firstName = process.env.INITIAL_USER_FIRST_NAME
const lastName = process.env.INITIAL_USER_LAST_NAME
if (!email || !password || !firstName || !lastName) return
const existing = await app.db.select({ id: users.id }).from(users).limit(1)
if (existing.length > 0) return
const passwordHash = await bcrypt.hash(password, 10)
await app.db.insert(users).values({ email, passwordHash, firstName, lastName, role: 'admin' })
app.log.info({ email }, 'Initial admin user created')
}
async function seedEmailSettings(app: Awaited<ReturnType<typeof buildApp>>) {
const apiKey = process.env.RESEND_API_KEY
if (!apiKey) return
const existing = await SettingsService.get(app.db, 'email.provider')
if (existing) return
await SettingsService.set(app.db, 'email.provider', 'resend')
await SettingsService.set(app.db, 'email.resend_api_key', apiKey, true)
if (process.env.MAIL_FROM) {
await SettingsService.set(app.db, 'email.from_address', process.env.MAIL_FROM)
}
if (process.env.BUSINESS_NAME) {
await SettingsService.set(app.db, 'email.business_name', process.env.BUSINESS_NAME)
}
app.log.info('Email settings seeded from environment')
}
export async function buildApp() { export async function buildApp() {
const app = Fastify({ const app = Fastify({
@@ -159,6 +195,16 @@ export async function buildApp() {
} catch (err) { } catch (err) {
app.log.error({ err }, 'Failed to load app config') app.log.error({ err }, 'Failed to load app config')
} }
try {
await seedInitialUser(app)
} catch (err) {
app.log.error({ err }, 'Failed to seed initial user')
}
try {
await seedEmailSettings(app)
} catch (err) {
app.log.error({ err }, 'Failed to seed email settings')
}
}) })
return app return app

View File

@@ -6,8 +6,9 @@ import * as userSchema from '../db/schema/users.js'
import * as accountSchema from '../db/schema/accounts.js' import * as accountSchema from '../db/schema/accounts.js'
import * as inventorySchema from '../db/schema/inventory.js' import * as inventorySchema from '../db/schema/inventory.js'
import * as posSchema from '../db/schema/pos.js' import * as posSchema from '../db/schema/pos.js'
import * as settingsSchema from '../db/schema/settings.js'
const schema = { ...storeSchema, ...userSchema, ...accountSchema, ...inventorySchema, ...posSchema } const schema = { ...storeSchema, ...userSchema, ...accountSchema, ...inventorySchema, ...posSchema, ...settingsSchema }
declare module 'fastify' { declare module 'fastify' {
interface FastifyInstance { interface FastifyInstance {

View File

@@ -0,0 +1,106 @@
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { SettingsService } from './settings.service.js'
interface SendOpts {
to: string
subject: string
html: string
text?: string
}
interface EmailProvider {
send(opts: SendOpts): Promise<void>
}
class ResendProvider implements EmailProvider {
constructor(private db: PostgresJsDatabase<any>) {}
async send(opts: SendOpts): Promise<void> {
const apiKey = await SettingsService.get(this.db, 'email.resend_api_key')
?? process.env.RESEND_API_KEY
const from = await SettingsService.get(this.db, 'email.from_address')
?? process.env.MAIL_FROM
if (!apiKey) throw new Error('Resend API key not configured')
if (!from) throw new Error('email.from_address not configured')
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from,
to: opts.to,
subject: opts.subject,
html: opts.html,
...(opts.text ? { text: opts.text } : {}),
}),
})
if (!res.ok) {
const body = await res.text()
throw new Error(`Resend API error ${res.status}: ${body}`)
}
}
}
class SendGridProvider implements EmailProvider {
constructor(private db: PostgresJsDatabase<any>) {}
async send(opts: SendOpts): Promise<void> {
const apiKey = await SettingsService.get(this.db, 'email.sendgrid_api_key')
const from = await SettingsService.get(this.db, 'email.from_address')
?? process.env.MAIL_FROM
if (!apiKey) throw new Error('SendGrid API key not configured')
if (!from) throw new Error('email.from_address not configured')
const res = await fetch('https://api.sendgrid.com/v3/mail/send', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
personalizations: [{ to: [{ email: opts.to }] }],
from: { email: from },
subject: opts.subject,
content: [
{ type: 'text/html', value: opts.html },
...(opts.text ? [{ type: 'text/plain', value: opts.text }] : []),
],
}),
})
if (!res.ok) {
const body = await res.text()
throw new Error(`SendGrid API error ${res.status}: ${body}`)
}
}
}
class SmtpProvider implements EmailProvider {
async send(_opts: SendOpts): Promise<void> {
throw new Error('SMTP email provider is not yet implemented')
}
}
export const EmailService = {
async send(db: PostgresJsDatabase<any>, opts: SendOpts): Promise<void> {
const provider = await SettingsService.get(db, 'email.provider')
?? process.env.MAIL_PROVIDER
switch (provider) {
case 'resend':
return new ResendProvider(db).send(opts)
case 'sendgrid':
return new SendGridProvider(db).send(opts)
case 'smtp':
return new SmtpProvider().send(opts)
default:
throw new Error('Email provider not configured. Set email.provider in app_settings.')
}
},
}

View File

@@ -0,0 +1,41 @@
import { eq } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { appSettings } from '../db/schema/settings.js'
import { encrypt, decrypt } from '../utils/encryption.js'
export const SettingsService = {
async get(db: PostgresJsDatabase<any>, key: string): Promise<string | null> {
const [row] = await db
.select()
.from(appSettings)
.where(eq(appSettings.key, key))
.limit(1)
if (!row || row.value === null) return null
if (row.isEncrypted && row.iv) return decrypt(row.value, row.iv)
return row.value
},
async set(db: PostgresJsDatabase<any>, key: string, value: string, encrypted = false): Promise<void> {
let storedValue = value
let iv: string | null = null
if (encrypted) {
const result = encrypt(value)
storedValue = result.ciphertext
iv = result.iv
}
await db
.insert(appSettings)
.values({ key, value: storedValue, isEncrypted: encrypted, iv, updatedAt: new Date() })
.onConflictDoUpdate({
target: appSettings.key,
set: { value: storedValue, isEncrypted: encrypted, iv, updatedAt: new Date() },
})
},
async delete(db: PostgresJsDatabase<any>, key: string): Promise<void> {
await db.delete(appSettings).where(eq(appSettings.key, key))
},
}

View File

@@ -0,0 +1,34 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
const ALGORITHM = 'aes-256-gcm'
function getKey(): Buffer {
const hex = process.env.ENCRYPTION_KEY
if (!hex) throw new Error('ENCRYPTION_KEY env var is required for encrypted settings')
const key = Buffer.from(hex, 'hex')
if (key.length !== 32) throw new Error('ENCRYPTION_KEY must be a 64-character hex string (32 bytes)')
return key
}
export function encrypt(plaintext: string): { ciphertext: string; iv: string } {
const key = getKey()
const iv = randomBytes(12)
const cipher = createCipheriv(ALGORITHM, key, iv)
let encrypted = cipher.update(plaintext, 'utf8', 'hex')
encrypted += cipher.final('hex')
const authTag = cipher.getAuthTag().toString('hex')
return {
ciphertext: `${encrypted}:${authTag}`,
iv: iv.toString('hex'),
}
}
export function decrypt(ciphertext: string, iv: string): string {
const key = getKey()
const [encrypted, authTag] = ciphertext.split(':')
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'hex'))
decipher.setAuthTag(Buffer.from(authTag, 'hex'))
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
}