Add store settings page with location management

Company table gains address and logo_file_id columns. New store
settings API: GET/PATCH /store for company info, full CRUD for
/locations. Settings page shows store name, phone, email, address,
timezone with inline edit. Location cards with add/edit/delete.
Settings link in admin sidebar. Fixes leftover company_id on
location table and seed files.
This commit is contained in:
Ryan Moon
2026-03-29 15:56:02 -05:00
parent 0f6cc104d2
commit 653fff6ce2
10 changed files with 497 additions and 6 deletions

View File

@@ -13,6 +13,7 @@ import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
import { Route as AuthenticatedUsersRouteImport } from './routes/_authenticated/users'
import { Route as AuthenticatedSettingsRouteImport } from './routes/_authenticated/settings'
import { Route as AuthenticatedProfileRouteImport } from './routes/_authenticated/profile'
import { Route as AuthenticatedHelpRouteImport } from './routes/_authenticated/help'
import { Route as AuthenticatedRolesIndexRouteImport } from './routes/_authenticated/roles/index'
@@ -56,6 +57,11 @@ const AuthenticatedUsersRoute = AuthenticatedUsersRouteImport.update({
path: '/users',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedSettingsRoute = AuthenticatedSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedProfileRoute = AuthenticatedProfileRouteImport.update({
id: '/profile',
path: '/profile',
@@ -194,6 +200,7 @@ export interface FileRoutesByFullPath {
'/login': typeof LoginRoute
'/help': typeof AuthenticatedHelpRoute
'/profile': typeof AuthenticatedProfileRoute
'/settings': typeof AuthenticatedSettingsRoute
'/users': typeof AuthenticatedUsersRoute
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
'/accounts/new': typeof AuthenticatedAccountsNewRoute
@@ -221,6 +228,7 @@ export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/help': typeof AuthenticatedHelpRoute
'/profile': typeof AuthenticatedProfileRoute
'/settings': typeof AuthenticatedSettingsRoute
'/users': typeof AuthenticatedUsersRoute
'/': typeof AuthenticatedIndexRoute
'/accounts/new': typeof AuthenticatedAccountsNewRoute
@@ -250,6 +258,7 @@ export interface FileRoutesById {
'/login': typeof LoginRoute
'/_authenticated/help': typeof AuthenticatedHelpRoute
'/_authenticated/profile': typeof AuthenticatedProfileRoute
'/_authenticated/settings': typeof AuthenticatedSettingsRoute
'/_authenticated/users': typeof AuthenticatedUsersRoute
'/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
@@ -281,6 +290,7 @@ export interface FileRouteTypes {
| '/login'
| '/help'
| '/profile'
| '/settings'
| '/users'
| '/accounts/$accountId'
| '/accounts/new'
@@ -308,6 +318,7 @@ export interface FileRouteTypes {
| '/login'
| '/help'
| '/profile'
| '/settings'
| '/users'
| '/'
| '/accounts/new'
@@ -336,6 +347,7 @@ export interface FileRouteTypes {
| '/login'
| '/_authenticated/help'
| '/_authenticated/profile'
| '/_authenticated/settings'
| '/_authenticated/users'
| '/_authenticated/'
| '/_authenticated/accounts/$accountId'
@@ -396,6 +408,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedUsersRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/settings': {
id: '/_authenticated/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof AuthenticatedSettingsRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/profile': {
id: '/_authenticated/profile'
path: '/profile'
@@ -590,6 +609,7 @@ const AuthenticatedAccountsAccountIdRouteWithChildren =
interface AuthenticatedRouteChildren {
AuthenticatedHelpRoute: typeof AuthenticatedHelpRoute
AuthenticatedProfileRoute: typeof AuthenticatedProfileRoute
AuthenticatedSettingsRoute: typeof AuthenticatedSettingsRoute
AuthenticatedUsersRoute: typeof AuthenticatedUsersRoute
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren
@@ -613,6 +633,7 @@ interface AuthenticatedRouteChildren {
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedHelpRoute: AuthenticatedHelpRoute,
AuthenticatedProfileRoute: AuthenticatedProfileRoute,
AuthenticatedSettingsRoute: AuthenticatedSettingsRoute,
AuthenticatedUsersRoute: AuthenticatedUsersRoute,
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
AuthenticatedAccountsAccountIdRoute:

View File

@@ -5,7 +5,7 @@ import { useAuthStore } from '@/stores/auth.store'
import { myPermissionsOptions } from '@/api/rbac'
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 } from 'lucide-react'
import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, Settings } from 'lucide-react'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: () => {
@@ -97,6 +97,7 @@ function AuthenticatedLayout() {
<>
<NavLink to="/users" icon={<UserCog className="h-4 w-4" />} label="Users" />
<NavLink to="/roles" icon={<Shield className="h-4 w-4" />} label="Roles" />
<NavLink to="/settings" icon={<Settings className="h-4 w-4" />} label="Settings" />
</>
)}
</div>

View File

@@ -0,0 +1,376 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { useAuthStore } from '@/stores/auth.store'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { AvatarUpload } from '@/components/shared/avatar-upload'
import { Save, Plus, Trash2, MapPin, Building } from 'lucide-react'
import { toast } from 'sonner'
interface StoreSettings {
id: string
name: string
phone: string | null
email: string | null
address: { street?: string; city?: string; state?: string; zip?: string } | null
timezone: string
logoFileId: string | null
notes: string | null
}
interface Location {
id: string
name: string
phone: string | null
email: string | null
address: { street?: string; city?: string; state?: string; zip?: string } | null
timezone: string | null
isActive: boolean
}
function storeOptions() {
return queryOptions({
queryKey: ['store'],
queryFn: () => api.get<StoreSettings>('/v1/store'),
})
}
function locationsOptions() {
return queryOptions({
queryKey: ['locations'],
queryFn: () => api.get<{ data: Location[] }>('/v1/locations'),
})
}
export const Route = createFileRoute('/_authenticated/settings')({
component: SettingsPage,
})
function SettingsPage() {
const queryClient = useQueryClient()
const { data: store, isLoading } = useQuery(storeOptions())
const { data: locationsData } = useQuery(locationsOptions())
const [addLocationOpen, setAddLocationOpen] = useState(false)
// Store edit state
const [editing, setEditing] = useState(false)
const [fields, setFields] = useState<Record<string, string>>({})
const updateMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => api.patch<StoreSettings>('/v1/store', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['store'] })
toast.success('Store settings updated')
setEditing(false)
},
onError: (err) => toast.error(err.message),
})
function startEdit() {
if (!store) return
setFields({
name: store.name,
phone: store.phone ?? '',
email: store.email ?? '',
street: store.address?.street ?? '',
city: store.address?.city ?? '',
state: store.address?.state ?? '',
zip: store.address?.zip ?? '',
timezone: store.timezone,
notes: store.notes ?? '',
})
setEditing(true)
}
function saveEdit() {
updateMutation.mutate({
name: fields.name,
phone: fields.phone || null,
email: fields.email || null,
address: { street: fields.street, city: fields.city, state: fields.state, zip: fields.zip },
timezone: fields.timezone,
notes: fields.notes || null,
})
}
if (isLoading) {
return <div className="space-y-4 max-w-3xl"><Skeleton className="h-8 w-48" /><Skeleton className="h-64 w-full" /></div>
}
if (!store) {
return <p className="text-muted-foreground">No store configured</p>
}
const locations = locationsData?.data ?? []
return (
<div className="space-y-6 max-w-3xl">
<h1 className="text-2xl font-bold">Settings</h1>
{/* Store Info */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Building className="h-5 w-5" />Store Information
</CardTitle>
{!editing && <Button variant="outline" size="sm" onClick={startEdit}>Edit</Button>}
{editing && (
<div className="flex gap-2">
<Button size="sm" onClick={saveEdit} disabled={updateMutation.isPending}>
<Save className="mr-1 h-3 w-3" />{updateMutation.isPending ? 'Saving...' : 'Save'}
</Button>
<Button variant="ghost" size="sm" onClick={() => setEditing(false)}>Cancel</Button>
</div>
)}
</CardHeader>
<CardContent>
{editing ? (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Store Name *</Label>
<Input value={fields.name} onChange={(e) => setFields((p) => ({ ...p, name: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>Timezone</Label>
<Input value={fields.timezone} onChange={(e) => setFields((p) => ({ ...p, timezone: e.target.value }))} placeholder="America/Chicago" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Phone</Label>
<Input value={fields.phone} onChange={(e) => setFields((p) => ({ ...p, phone: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input value={fields.email} onChange={(e) => setFields((p) => ({ ...p, email: e.target.value }))} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Street</Label>
<Input value={fields.street} onChange={(e) => setFields((p) => ({ ...p, street: e.target.value }))} />
</div>
<div className="grid grid-cols-3 gap-2">
<div className="space-y-2">
<Label>City</Label>
<Input value={fields.city} onChange={(e) => setFields((p) => ({ ...p, city: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>State</Label>
<Input value={fields.state} onChange={(e) => setFields((p) => ({ ...p, state: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>ZIP</Label>
<Input value={fields.zip} onChange={(e) => setFields((p) => ({ ...p, zip: e.target.value }))} />
</div>
</div>
</div>
<div className="space-y-2">
<Label>Notes</Label>
<Textarea value={fields.notes} onChange={(e) => setFields((p) => ({ ...p, notes: e.target.value }))} rows={2} />
</div>
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2 text-sm">
<div className="text-lg font-semibold">{store.name}</div>
<div><span className="text-muted-foreground">Phone:</span> {store.phone ?? '-'}</div>
<div><span className="text-muted-foreground">Email:</span> {store.email ?? '-'}</div>
<div><span className="text-muted-foreground">Timezone:</span> {store.timezone}</div>
</div>
<div className="space-y-2 text-sm">
{store.address && (store.address.street || store.address.city) ? (
<>
<div className="font-medium flex items-center gap-1"><MapPin className="h-3 w-3" />Address</div>
{store.address.street && <div>{store.address.street}</div>}
<div>{[store.address.city, store.address.state, store.address.zip].filter(Boolean).join(', ')}</div>
</>
) : (
<div className="text-muted-foreground">No address set</div>
)}
{store.notes && <div className="mt-2"><span className="text-muted-foreground">Notes:</span> {store.notes}</div>}
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Locations */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<MapPin className="h-5 w-5" />Locations
</CardTitle>
<AddLocationDialog open={addLocationOpen} onOpenChange={setAddLocationOpen} />
</CardHeader>
<CardContent>
{locations.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No locations yet add your first store location</p>
) : (
<div className="space-y-3">
{locations.map((loc) => (
<LocationCard key={loc.id} location={loc} />
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}
function LocationCard({ location }: { location: Location }) {
const queryClient = useQueryClient()
const [editing, setEditing] = useState(false)
const [fields, setFields] = useState<Record<string, string>>({})
const updateMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => api.patch<Location>(`/v1/locations/${location.id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['locations'] })
toast.success('Location updated')
setEditing(false)
},
onError: (err) => toast.error(err.message),
})
const deleteMutation = useMutation({
mutationFn: () => api.del(`/v1/locations/${location.id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['locations'] })
toast.success('Location removed')
},
onError: (err) => toast.error(err.message),
})
function startEdit() {
setFields({
name: location.name,
phone: location.phone ?? '',
email: location.email ?? '',
street: location.address?.street ?? '',
city: location.address?.city ?? '',
state: location.address?.state ?? '',
zip: location.address?.zip ?? '',
})
setEditing(true)
}
function saveEdit() {
updateMutation.mutate({
name: fields.name,
phone: fields.phone || null,
email: fields.email || null,
address: { street: fields.street, city: fields.city, state: fields.state, zip: fields.zip },
})
}
if (editing) {
return (
<div className="rounded-md border p-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1"><Label className="text-xs">Name</Label><Input className="h-8" value={fields.name} onChange={(e) => setFields((p) => ({ ...p, name: e.target.value }))} /></div>
<div className="space-y-1"><Label className="text-xs">Phone</Label><Input className="h-8" value={fields.phone} onChange={(e) => setFields((p) => ({ ...p, phone: e.target.value }))} /></div>
</div>
<div className="space-y-1"><Label className="text-xs">Email</Label><Input className="h-8" value={fields.email} onChange={(e) => setFields((p) => ({ ...p, email: e.target.value }))} /></div>
<div className="grid grid-cols-4 gap-2">
<div className="col-span-2 space-y-1"><Label className="text-xs">Street</Label><Input className="h-8" value={fields.street} onChange={(e) => setFields((p) => ({ ...p, street: e.target.value }))} /></div>
<div className="space-y-1"><Label className="text-xs">City</Label><Input className="h-8" value={fields.city} onChange={(e) => setFields((p) => ({ ...p, city: e.target.value }))} /></div>
<div className="grid grid-cols-2 gap-1">
<div className="space-y-1"><Label className="text-xs">State</Label><Input className="h-8" value={fields.state} onChange={(e) => setFields((p) => ({ ...p, state: e.target.value }))} /></div>
<div className="space-y-1"><Label className="text-xs">ZIP</Label><Input className="h-8" value={fields.zip} onChange={(e) => setFields((p) => ({ ...p, zip: e.target.value }))} /></div>
</div>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={saveEdit} disabled={updateMutation.isPending}>{updateMutation.isPending ? 'Saving...' : 'Save'}</Button>
<Button variant="ghost" size="sm" onClick={() => setEditing(false)}>Cancel</Button>
</div>
</div>
)
}
return (
<div className="flex items-center justify-between p-3 rounded-md border">
<div>
<p className="font-medium">{location.name}</p>
<div className="flex gap-3 text-sm text-muted-foreground">
{location.phone && <span>{location.phone}</span>}
{location.address?.city && <span>{location.address.city}{location.address.state ? `, ${location.address.state}` : ''}</span>}
</div>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={startEdit}>Edit</Button>
<Button variant="ghost" size="sm" onClick={() => deleteMutation.mutate()}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
)
}
function AddLocationDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [email, setEmail] = useState('')
const [street, setStreet] = useState('')
const [city, setCity] = useState('')
const [state, setState] = useState('')
const [zip, setZip] = useState('')
const mutation = useMutation({
mutationFn: (data: Record<string, unknown>) => api.post<Location>('/v1/locations', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['locations'] })
toast.success('Location added')
onOpenChange(false)
setName(''); setPhone(''); setEmail(''); setStreet(''); setCity(''); setState(''); setZip('')
},
onError: (err) => toast.error(err.message),
})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
mutation.mutate({
name, phone: phone || undefined, email: email || undefined,
address: { street, city, state, zip },
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button size="sm"><Plus className="mr-2 h-4 w-4" />Add Location</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Add Location</DialogTitle></DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"><Label>Name *</Label><Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Main Store, Downtown" required /></div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>Phone</Label><Input value={phone} onChange={(e) => setPhone(e.target.value)} /></div>
<div className="space-y-2"><Label>Email</Label><Input value={email} onChange={(e) => setEmail(e.target.value)} /></div>
</div>
<div className="space-y-2"><Label>Street</Label><Input value={street} onChange={(e) => setStreet(e.target.value)} /></div>
<div className="grid grid-cols-3 gap-2">
<div className="space-y-2"><Label>City</Label><Input value={city} onChange={(e) => setCity(e.target.value)} /></div>
<div className="space-y-2"><Label>State</Label><Input value={state} onChange={(e) => setState(e.target.value)} /></div>
<div className="space-y-2"><Label>ZIP</Label><Input value={zip} onChange={(e) => setZip(e.target.value)} /></div>
</div>
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? 'Adding...' : 'Add Location'}</Button>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,6 @@
-- Add address and logo to company (store settings)
ALTER TABLE "company" ADD COLUMN IF NOT EXISTS "address" jsonb;
ALTER TABLE "company" ADD COLUMN IF NOT EXISTS "logo_file_id" uuid REFERENCES "file"("id");
-- Drop leftover company_id from location (missed in 0021)
ALTER TABLE "location" DROP COLUMN IF EXISTS "company_id";

View File

@@ -162,6 +162,13 @@
"when": 1774820000000,
"tag": "0022_shared_file_storage",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1774830000000,
"tag": "0023_store_settings",
"breakpoints": true
}
]
}

View File

@@ -5,7 +5,14 @@ export const companies = pgTable('company', {
name: varchar('name', { length: 255 }).notNull(),
phone: varchar('phone', { length: 50 }),
email: varchar('email', { length: 255 }),
address: jsonb('address').$type<{
street?: string
city?: string
state?: string
zip?: string
}>(),
timezone: varchar('timezone', { length: 100 }).notNull().default('America/Chicago'),
logoFileId: uuid('logo_file_id'),
notes: text('notes'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
@@ -13,9 +20,6 @@ export const companies = pgTable('company', {
export const locations = pgTable('location', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
name: varchar('name', { length: 255 }).notNull(),
address: jsonb('address').$type<{
street?: string

View File

@@ -26,7 +26,6 @@ async function seed() {
.insert(locations)
.values({
id: DEV_LOCATION_ID,
companyId: DEV_COMPANY_ID,
name: 'Main Store',
address: {
street: '123 Main St',

View File

@@ -17,6 +17,7 @@ import { fileRoutes } from './routes/v1/files.js'
import { rbacRoutes } from './routes/v1/rbac.js'
import { repairRoutes } from './routes/v1/repairs.js'
import { storageRoutes } from './routes/v1/storage.js'
import { storeRoutes } from './routes/v1/store.js'
import { RbacService } from './services/rbac.service.js'
export async function buildApp() {
@@ -69,6 +70,7 @@ export async function buildApp() {
await app.register(rbacRoutes, { prefix: '/v1' })
await app.register(repairRoutes, { prefix: '/v1' })
await app.register(storageRoutes, { prefix: '/v1' })
await app.register(storeRoutes, { prefix: '/v1' })
// Auto-seed system permissions on startup
app.addHook('onReady', async () => {

View File

@@ -0,0 +1,76 @@
import type { FastifyPluginAsync } from 'fastify'
import { eq } from 'drizzle-orm'
import { companies, locations } from '../../db/schema/stores.js'
import { ValidationError } from '../../lib/errors.js'
export const storeRoutes: FastifyPluginAsync = async (app) => {
// --- Company / Store Settings ---
app.get('/store', { preHandler: [app.authenticate, app.requirePermission('settings.view')] }, async (request, reply) => {
const [store] = await app.db.select().from(companies).limit(1)
if (!store) return reply.status(404).send({ error: { message: 'No store configured', statusCode: 404 } })
return reply.send(store)
})
app.patch('/store', { preHandler: [app.authenticate, app.requirePermission('settings.edit')] }, async (request, reply) => {
const input = request.body as Record<string, unknown>
const [store] = await app.db.select({ id: companies.id }).from(companies).limit(1)
if (!store) return reply.status(404).send({ error: { message: 'No store configured', statusCode: 404 } })
const updates: Record<string, unknown> = { updatedAt: new Date() }
if (input.name !== undefined) updates.name = input.name
if (input.phone !== undefined) updates.phone = input.phone
if (input.email !== undefined) updates.email = input.email
if (input.address !== undefined) updates.address = input.address
if (input.timezone !== undefined) updates.timezone = input.timezone
if (input.logoFileId !== undefined) updates.logoFileId = input.logoFileId
if (input.notes !== undefined) updates.notes = input.notes
const [updated] = await app.db.update(companies).set(updates).where(eq(companies.id, store.id)).returning()
return reply.send(updated)
})
// --- Locations (Stores) ---
app.get('/locations', { preHandler: [app.authenticate, app.requirePermission('settings.view')] }, async (request, reply) => {
const data = await app.db.select().from(locations).where(eq(locations.isActive, true)).orderBy(locations.name)
return reply.send({ data })
})
app.post('/locations', { preHandler: [app.authenticate, app.requirePermission('settings.edit')] }, async (request, reply) => {
const input = request.body as { name?: string; address?: Record<string, string>; phone?: string; email?: string; timezone?: string }
if (!input.name?.trim()) throw new ValidationError('Location name is required')
const [location] = await app.db.insert(locations).values({
name: input.name.trim(),
address: input.address,
phone: input.phone,
email: input.email,
timezone: input.timezone,
}).returning()
return reply.status(201).send(location)
})
app.patch('/locations/:id', { preHandler: [app.authenticate, app.requirePermission('settings.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const input = request.body as Record<string, unknown>
const updates: Record<string, unknown> = { updatedAt: new Date() }
if (input.name !== undefined) updates.name = input.name
if (input.phone !== undefined) updates.phone = input.phone
if (input.email !== undefined) updates.email = input.email
if (input.address !== undefined) updates.address = input.address
if (input.timezone !== undefined) updates.timezone = input.timezone
const [location] = await app.db.update(locations).set(updates).where(eq(locations.id, id)).returning()
if (!location) return reply.status(404).send({ error: { message: 'Location not found', statusCode: 404 } })
return reply.send(location)
})
app.delete('/locations/:id', { preHandler: [app.authenticate, app.requirePermission('settings.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const [location] = await app.db.update(locations).set({ isActive: false, updatedAt: new Date() }).where(eq(locations.id, id)).returning()
if (!location) return reply.status(404).send({ error: { message: 'Location not found', statusCode: 404 } })
return reply.send(location)
})
}

View File

@@ -38,7 +38,6 @@ export async function seedTestCompany(app: FastifyInstance): Promise<void> {
})
await app.db.insert(locations).values({
id: TEST_LOCATION_ID,
companyId: TEST_COMPANY_ID,
name: 'Test Location',
})