diff --git a/packages/admin/src/routeTree.gen.ts b/packages/admin/src/routeTree.gen.ts index 8fce3e2..04259ad 100644 --- a/packages/admin/src/routeTree.gen.ts +++ b/packages/admin/src/routeTree.gen.ts @@ -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: diff --git a/packages/admin/src/routes/_authenticated.tsx b/packages/admin/src/routes/_authenticated.tsx index f94f9be..7b5e1b7 100644 --- a/packages/admin/src/routes/_authenticated.tsx +++ b/packages/admin/src/routes/_authenticated.tsx @@ -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() { <> } label="Users" /> } label="Roles" /> + } label="Settings" /> > )} diff --git a/packages/admin/src/routes/_authenticated/settings.tsx b/packages/admin/src/routes/_authenticated/settings.tsx new file mode 100644 index 0000000..16ebbe0 --- /dev/null +++ b/packages/admin/src/routes/_authenticated/settings.tsx @@ -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('/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>({}) + + const updateMutation = useMutation({ + mutationFn: (data: Record) => api.patch('/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 + } + + if (!store) { + return No store configured + } + + const locations = locationsData?.data ?? [] + + return ( + + Settings + + {/* Store Info */} + + + + Store Information + + {!editing && Edit} + {editing && ( + + + {updateMutation.isPending ? 'Saving...' : 'Save'} + + setEditing(false)}>Cancel + + )} + + + {editing ? ( + + + + Store Name * + setFields((p) => ({ ...p, name: e.target.value }))} /> + + + Timezone + setFields((p) => ({ ...p, timezone: e.target.value }))} placeholder="America/Chicago" /> + + + + + Phone + setFields((p) => ({ ...p, phone: e.target.value }))} /> + + + Email + setFields((p) => ({ ...p, email: e.target.value }))} /> + + + + + Street + setFields((p) => ({ ...p, street: e.target.value }))} /> + + + + City + setFields((p) => ({ ...p, city: e.target.value }))} /> + + + State + setFields((p) => ({ ...p, state: e.target.value }))} /> + + + ZIP + setFields((p) => ({ ...p, zip: e.target.value }))} /> + + + + + Notes + setFields((p) => ({ ...p, notes: e.target.value }))} rows={2} /> + + + ) : ( + + + + {store.name} + Phone: {store.phone ?? '-'} + Email: {store.email ?? '-'} + Timezone: {store.timezone} + + + {store.address && (store.address.street || store.address.city) ? ( + <> + Address + {store.address.street && {store.address.street}} + {[store.address.city, store.address.state, store.address.zip].filter(Boolean).join(', ')} + > + ) : ( + No address set + )} + {store.notes && Notes: {store.notes}} + + + + )} + + + + {/* Locations */} + + + + Locations + + + + + {locations.length === 0 ? ( + No locations yet — add your first store location + ) : ( + + {locations.map((loc) => ( + + ))} + + )} + + + + ) +} + +function LocationCard({ location }: { location: Location }) { + const queryClient = useQueryClient() + const [editing, setEditing] = useState(false) + const [fields, setFields] = useState>({}) + + const updateMutation = useMutation({ + mutationFn: (data: Record) => api.patch(`/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 ( + + + Name setFields((p) => ({ ...p, name: e.target.value }))} /> + Phone setFields((p) => ({ ...p, phone: e.target.value }))} /> + + Email setFields((p) => ({ ...p, email: e.target.value }))} /> + + Street setFields((p) => ({ ...p, street: e.target.value }))} /> + City setFields((p) => ({ ...p, city: e.target.value }))} /> + + State setFields((p) => ({ ...p, state: e.target.value }))} /> + ZIP setFields((p) => ({ ...p, zip: e.target.value }))} /> + + + + {updateMutation.isPending ? 'Saving...' : 'Save'} + setEditing(false)}>Cancel + + + ) + } + + return ( + + + {location.name} + + {location.phone && {location.phone}} + {location.address?.city && {location.address.city}{location.address.state ? `, ${location.address.state}` : ''}} + + + + Edit + deleteMutation.mutate()}> + + + + + ) +} + +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) => api.post('/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 ( + + + Add Location + + + Add Location + + Name * setName(e.target.value)} placeholder="e.g. Main Store, Downtown" required /> + + Phone setPhone(e.target.value)} /> + Email setEmail(e.target.value)} /> + + Street setStreet(e.target.value)} /> + + City setCity(e.target.value)} /> + State setState(e.target.value)} /> + ZIP setZip(e.target.value)} /> + + {mutation.isPending ? 'Adding...' : 'Add Location'} + + + + ) +} diff --git a/packages/backend/src/db/migrations/0023_store_settings.sql b/packages/backend/src/db/migrations/0023_store_settings.sql new file mode 100644 index 0000000..1c8b3e2 --- /dev/null +++ b/packages/backend/src/db/migrations/0023_store_settings.sql @@ -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"; diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index 4af7ee9..fe711bd 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/stores.ts b/packages/backend/src/db/schema/stores.ts index af1d373..0d569d0 100644 --- a/packages/backend/src/db/schema/stores.ts +++ b/packages/backend/src/db/schema/stores.ts @@ -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 diff --git a/packages/backend/src/db/seed.ts b/packages/backend/src/db/seed.ts index 7a5ce14..a52d689 100644 --- a/packages/backend/src/db/seed.ts +++ b/packages/backend/src/db/seed.ts @@ -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', diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index b49bf23..1c87296 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -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 () => { diff --git a/packages/backend/src/routes/v1/store.ts b/packages/backend/src/routes/v1/store.ts new file mode 100644 index 0000000..87ea296 --- /dev/null +++ b/packages/backend/src/routes/v1/store.ts @@ -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 + 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 = { 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; 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 + + const updates: Record = { 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) + }) +} diff --git a/packages/backend/src/test/helpers.ts b/packages/backend/src/test/helpers.ts index 7946093..57a4f7d 100644 --- a/packages/backend/src/test/helpers.ts +++ b/packages/backend/src/test/helpers.ts @@ -38,7 +38,6 @@ export async function seedTestCompany(app: FastifyInstance): Promise { }) await app.db.insert(locations).values({ id: TEST_LOCATION_ID, - companyId: TEST_COMPANY_ID, name: 'Test Location', })
No store configured
No locations yet — add your first store location
{location.name}