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

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