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 AuthenticatedRouteImport } from './routes/_authenticated'
|
||||||
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
|
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
|
||||||
import { Route as AuthenticatedUsersRouteImport } from './routes/_authenticated/users'
|
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 AuthenticatedProfileRouteImport } from './routes/_authenticated/profile'
|
||||||
import { Route as AuthenticatedHelpRouteImport } from './routes/_authenticated/help'
|
import { Route as AuthenticatedHelpRouteImport } from './routes/_authenticated/help'
|
||||||
import { Route as AuthenticatedRolesIndexRouteImport } from './routes/_authenticated/roles/index'
|
import { Route as AuthenticatedRolesIndexRouteImport } from './routes/_authenticated/roles/index'
|
||||||
@@ -56,6 +57,11 @@ const AuthenticatedUsersRoute = AuthenticatedUsersRouteImport.update({
|
|||||||
path: '/users',
|
path: '/users',
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthenticatedSettingsRoute = AuthenticatedSettingsRouteImport.update({
|
||||||
|
id: '/settings',
|
||||||
|
path: '/settings',
|
||||||
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
|
} as any)
|
||||||
const AuthenticatedProfileRoute = AuthenticatedProfileRouteImport.update({
|
const AuthenticatedProfileRoute = AuthenticatedProfileRouteImport.update({
|
||||||
id: '/profile',
|
id: '/profile',
|
||||||
path: '/profile',
|
path: '/profile',
|
||||||
@@ -194,6 +200,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/help': typeof AuthenticatedHelpRoute
|
'/help': typeof AuthenticatedHelpRoute
|
||||||
'/profile': typeof AuthenticatedProfileRoute
|
'/profile': typeof AuthenticatedProfileRoute
|
||||||
|
'/settings': typeof AuthenticatedSettingsRoute
|
||||||
'/users': typeof AuthenticatedUsersRoute
|
'/users': typeof AuthenticatedUsersRoute
|
||||||
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
|
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
|
||||||
'/accounts/new': typeof AuthenticatedAccountsNewRoute
|
'/accounts/new': typeof AuthenticatedAccountsNewRoute
|
||||||
@@ -221,6 +228,7 @@ export interface FileRoutesByTo {
|
|||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/help': typeof AuthenticatedHelpRoute
|
'/help': typeof AuthenticatedHelpRoute
|
||||||
'/profile': typeof AuthenticatedProfileRoute
|
'/profile': typeof AuthenticatedProfileRoute
|
||||||
|
'/settings': typeof AuthenticatedSettingsRoute
|
||||||
'/users': typeof AuthenticatedUsersRoute
|
'/users': typeof AuthenticatedUsersRoute
|
||||||
'/': typeof AuthenticatedIndexRoute
|
'/': typeof AuthenticatedIndexRoute
|
||||||
'/accounts/new': typeof AuthenticatedAccountsNewRoute
|
'/accounts/new': typeof AuthenticatedAccountsNewRoute
|
||||||
@@ -250,6 +258,7 @@ export interface FileRoutesById {
|
|||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/_authenticated/help': typeof AuthenticatedHelpRoute
|
'/_authenticated/help': typeof AuthenticatedHelpRoute
|
||||||
'/_authenticated/profile': typeof AuthenticatedProfileRoute
|
'/_authenticated/profile': typeof AuthenticatedProfileRoute
|
||||||
|
'/_authenticated/settings': typeof AuthenticatedSettingsRoute
|
||||||
'/_authenticated/users': typeof AuthenticatedUsersRoute
|
'/_authenticated/users': typeof AuthenticatedUsersRoute
|
||||||
'/_authenticated/': typeof AuthenticatedIndexRoute
|
'/_authenticated/': typeof AuthenticatedIndexRoute
|
||||||
'/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
|
'/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
|
||||||
@@ -281,6 +290,7 @@ export interface FileRouteTypes {
|
|||||||
| '/login'
|
| '/login'
|
||||||
| '/help'
|
| '/help'
|
||||||
| '/profile'
|
| '/profile'
|
||||||
|
| '/settings'
|
||||||
| '/users'
|
| '/users'
|
||||||
| '/accounts/$accountId'
|
| '/accounts/$accountId'
|
||||||
| '/accounts/new'
|
| '/accounts/new'
|
||||||
@@ -308,6 +318,7 @@ export interface FileRouteTypes {
|
|||||||
| '/login'
|
| '/login'
|
||||||
| '/help'
|
| '/help'
|
||||||
| '/profile'
|
| '/profile'
|
||||||
|
| '/settings'
|
||||||
| '/users'
|
| '/users'
|
||||||
| '/'
|
| '/'
|
||||||
| '/accounts/new'
|
| '/accounts/new'
|
||||||
@@ -336,6 +347,7 @@ export interface FileRouteTypes {
|
|||||||
| '/login'
|
| '/login'
|
||||||
| '/_authenticated/help'
|
| '/_authenticated/help'
|
||||||
| '/_authenticated/profile'
|
| '/_authenticated/profile'
|
||||||
|
| '/_authenticated/settings'
|
||||||
| '/_authenticated/users'
|
| '/_authenticated/users'
|
||||||
| '/_authenticated/'
|
| '/_authenticated/'
|
||||||
| '/_authenticated/accounts/$accountId'
|
| '/_authenticated/accounts/$accountId'
|
||||||
@@ -396,6 +408,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthenticatedUsersRouteImport
|
preLoaderRoute: typeof AuthenticatedUsersRouteImport
|
||||||
parentRoute: typeof AuthenticatedRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
|
'/_authenticated/settings': {
|
||||||
|
id: '/_authenticated/settings'
|
||||||
|
path: '/settings'
|
||||||
|
fullPath: '/settings'
|
||||||
|
preLoaderRoute: typeof AuthenticatedSettingsRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRoute
|
||||||
|
}
|
||||||
'/_authenticated/profile': {
|
'/_authenticated/profile': {
|
||||||
id: '/_authenticated/profile'
|
id: '/_authenticated/profile'
|
||||||
path: '/profile'
|
path: '/profile'
|
||||||
@@ -590,6 +609,7 @@ const AuthenticatedAccountsAccountIdRouteWithChildren =
|
|||||||
interface AuthenticatedRouteChildren {
|
interface AuthenticatedRouteChildren {
|
||||||
AuthenticatedHelpRoute: typeof AuthenticatedHelpRoute
|
AuthenticatedHelpRoute: typeof AuthenticatedHelpRoute
|
||||||
AuthenticatedProfileRoute: typeof AuthenticatedProfileRoute
|
AuthenticatedProfileRoute: typeof AuthenticatedProfileRoute
|
||||||
|
AuthenticatedSettingsRoute: typeof AuthenticatedSettingsRoute
|
||||||
AuthenticatedUsersRoute: typeof AuthenticatedUsersRoute
|
AuthenticatedUsersRoute: typeof AuthenticatedUsersRoute
|
||||||
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
|
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
|
||||||
AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren
|
AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren
|
||||||
@@ -613,6 +633,7 @@ interface AuthenticatedRouteChildren {
|
|||||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||||
AuthenticatedHelpRoute: AuthenticatedHelpRoute,
|
AuthenticatedHelpRoute: AuthenticatedHelpRoute,
|
||||||
AuthenticatedProfileRoute: AuthenticatedProfileRoute,
|
AuthenticatedProfileRoute: AuthenticatedProfileRoute,
|
||||||
|
AuthenticatedSettingsRoute: AuthenticatedSettingsRoute,
|
||||||
AuthenticatedUsersRoute: AuthenticatedUsersRoute,
|
AuthenticatedUsersRoute: AuthenticatedUsersRoute,
|
||||||
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
|
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
|
||||||
AuthenticatedAccountsAccountIdRoute:
|
AuthenticatedAccountsAccountIdRoute:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useAuthStore } from '@/stores/auth.store'
|
|||||||
import { myPermissionsOptions } from '@/api/rbac'
|
import { myPermissionsOptions } from '@/api/rbac'
|
||||||
import { Avatar } from '@/components/shared/avatar-upload'
|
import { Avatar } from '@/components/shared/avatar-upload'
|
||||||
import { Button } from '@/components/ui/button'
|
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')({
|
export const Route = createFileRoute('/_authenticated')({
|
||||||
beforeLoad: () => {
|
beforeLoad: () => {
|
||||||
@@ -97,6 +97,7 @@ function AuthenticatedLayout() {
|
|||||||
<>
|
<>
|
||||||
<NavLink to="/users" icon={<UserCog className="h-4 w-4" />} label="Users" />
|
<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="/roles" icon={<Shield className="h-4 w-4" />} label="Roles" />
|
||||||
|
<NavLink to="/settings" icon={<Settings className="h-4 w-4" />} label="Settings" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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,
|
"when": 1774820000000,
|
||||||
"tag": "0022_shared_file_storage",
|
"tag": "0022_shared_file_storage",
|
||||||
"breakpoints": true
|
"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(),
|
name: varchar('name', { length: 255 }).notNull(),
|
||||||
phone: varchar('phone', { length: 50 }),
|
phone: varchar('phone', { length: 50 }),
|
||||||
email: varchar('email', { length: 255 }),
|
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'),
|
timezone: varchar('timezone', { length: 100 }).notNull().default('America/Chicago'),
|
||||||
|
logoFileId: uuid('logo_file_id'),
|
||||||
notes: text('notes'),
|
notes: text('notes'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_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', {
|
export const locations = pgTable('location', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
companyId: uuid('company_id')
|
|
||||||
.notNull()
|
|
||||||
.references(() => companies.id),
|
|
||||||
name: varchar('name', { length: 255 }).notNull(),
|
name: varchar('name', { length: 255 }).notNull(),
|
||||||
address: jsonb('address').$type<{
|
address: jsonb('address').$type<{
|
||||||
street?: string
|
street?: string
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ async function seed() {
|
|||||||
.insert(locations)
|
.insert(locations)
|
||||||
.values({
|
.values({
|
||||||
id: DEV_LOCATION_ID,
|
id: DEV_LOCATION_ID,
|
||||||
companyId: DEV_COMPANY_ID,
|
|
||||||
name: 'Main Store',
|
name: 'Main Store',
|
||||||
address: {
|
address: {
|
||||||
street: '123 Main St',
|
street: '123 Main St',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { fileRoutes } from './routes/v1/files.js'
|
|||||||
import { rbacRoutes } from './routes/v1/rbac.js'
|
import { rbacRoutes } from './routes/v1/rbac.js'
|
||||||
import { repairRoutes } from './routes/v1/repairs.js'
|
import { repairRoutes } from './routes/v1/repairs.js'
|
||||||
import { storageRoutes } from './routes/v1/storage.js'
|
import { storageRoutes } from './routes/v1/storage.js'
|
||||||
|
import { storeRoutes } from './routes/v1/store.js'
|
||||||
import { RbacService } from './services/rbac.service.js'
|
import { RbacService } from './services/rbac.service.js'
|
||||||
|
|
||||||
export async function buildApp() {
|
export async function buildApp() {
|
||||||
@@ -69,6 +70,7 @@ export async function buildApp() {
|
|||||||
await app.register(rbacRoutes, { prefix: '/v1' })
|
await app.register(rbacRoutes, { prefix: '/v1' })
|
||||||
await app.register(repairRoutes, { prefix: '/v1' })
|
await app.register(repairRoutes, { prefix: '/v1' })
|
||||||
await app.register(storageRoutes, { prefix: '/v1' })
|
await app.register(storageRoutes, { prefix: '/v1' })
|
||||||
|
await app.register(storeRoutes, { prefix: '/v1' })
|
||||||
|
|
||||||
// Auto-seed system permissions on startup
|
// Auto-seed system permissions on startup
|
||||||
app.addHook('onReady', async () => {
|
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({
|
await app.db.insert(locations).values({
|
||||||
id: TEST_LOCATION_ID,
|
id: TEST_LOCATION_ID,
|
||||||
companyId: TEST_COMPANY_ID,
|
|
||||||
name: 'Test Location',
|
name: 'Test Location',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user