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:
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
376
packages/admin/src/routes/_authenticated/settings.tsx
Normal file
376
packages/admin/src/routes/_authenticated/settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
76
packages/backend/src/routes/v1/store.ts
Normal file
76
packages/backend/src/routes/v1/store.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user