feat: repair-POS integration, receipt formats, manager overrides, price adjustments

- Add thermal/full-page receipt format toggle (per-device, localStorage)
- Full-page receipt uses clean invoice layout matching repair PDF style
- Settings page reorganized into tabbed sections (Store, Locations, Modules, Receipt, POS Security, Advanced)
- Manager override system: configurable PIN prompt for void, refund, discount, cash in/out
- Discount threshold setting: require manager approval above X%
- Consumable product type: tracked for internal job costing, excluded from POS search, receipts, and customer-facing totals
- Repair line item dialog: product picker dropdown for parts/consumables from inventory
- Repair → POS checkout: load ready-for-pickup tickets into repair_payment transactions with proper tax categories (labor=service, parts=goods)
- Transaction completion auto-updates repair ticket status to picked_up
- POS Repairs dialog with Pickup and New Intake tabs, customer account lookup
- Inline price adjustment on cart items: % off, $ off, or set price with live preview
- Order-level discount button with same three input modes
- Backend: migration 0043 (consumable enum + is_consumable flag), createFromRepairTicket service, ready-for-pickup endpoint
- Fix: backend dev script uses --env-file for turbo compatibility

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ryan
2026-04-05 01:32:28 +00:00
parent e16655d133
commit 9d51fb2118
32 changed files with 1507 additions and 199 deletions

View File

@@ -6,6 +6,7 @@ import {
repairLineItemListOptions, repairLineItemMutations, repairLineItemKeys,
repairServiceTemplateListOptions,
} from '@/api/repairs'
import { api } from '@/lib/api-client'
import { usePagination } from '@/hooks/use-pagination'
import { StatusProgress } from '@/components/repairs/status-progress'
import { TicketPhotos } from '@/components/repairs/ticket-photos'
@@ -157,7 +158,12 @@ function RepairTicketDetailPage() {
}
const lineItemColumns: Column<RepairLineItem>[] = [
{ key: 'item_type', header: 'Type', sortable: true, render: (i) => <Badge variant="outline">{i.itemType.replace('_', ' ')}</Badge> },
{ key: 'item_type', header: 'Type', sortable: true, render: (i) => (
<div className="flex items-center gap-1">
<Badge variant="outline">{i.itemType.replace('_', ' ')}</Badge>
{i.itemType === 'consumable' && <Badge variant="secondary" className="text-[10px]">Internal</Badge>}
</div>
) },
{ key: 'description', header: 'Description', render: (i) => <>{i.description}</> },
{ key: 'qty', header: 'Qty', render: (i) => <>{i.qty}</> },
{ key: 'unit_price', header: 'Unit Price', render: (i) => <>${i.unitPrice}</> },
@@ -391,11 +397,27 @@ function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string;
const [qty, setQty] = useState('1')
const [unitPrice, setUnitPrice] = useState('0')
const [cost, setCost] = useState('')
const [productId, setProductId] = useState<string | null>(null)
const [productSearch, setProductSearch] = useState('')
const [showProducts, setShowProducts] = useState(false)
const showProductPicker = itemType === 'part' || itemType === 'consumable'
const { data: templatesData } = useQuery(
repairServiceTemplateListOptions({ page: 1, limit: 20, q: templateSearch || undefined, order: 'asc', sort: 'name' }),
)
const { data: productsData } = useQuery({
queryKey: ['products', 'repair-picker', productSearch, itemType],
queryFn: () => {
const params: Record<string, string> = { q: productSearch, limit: '10', isActive: 'true' }
if (itemType === 'consumable') params.isConsumable = 'true'
else params.isDualUseRepair = 'true'
return api.get<{ data: { id: string; name: string; sku: string | null; price: string | null; brand: string | null }[] }>('/v1/products', params)
},
enabled: showProductPicker && productSearch.length >= 1,
})
const mutation = useMutation({
mutationFn: (data: Record<string, unknown>) => repairLineItemMutations.create(ticketId, data),
onSuccess: () => {
@@ -408,7 +430,7 @@ function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string;
})
function resetForm() {
setDescription(''); setQty('1'); setUnitPrice('0'); setCost(''); setItemType('labor'); setTemplateSearch(''); setShowTemplates(false)
setDescription(''); setQty('1'); setUnitPrice('0'); setCost(''); setItemType('labor'); setTemplateSearch(''); setShowTemplates(false); setProductId(null); setProductSearch(''); setShowProducts(false)
}
function selectTemplate(template: { name: string; itemCategory: string | null; size: string | null; itemType: string; defaultPrice: string; defaultCost: string | null }) {
@@ -416,15 +438,24 @@ function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string;
setDescription(desc); setItemType(template.itemType); setUnitPrice(template.defaultPrice); setCost(template.defaultCost ?? ''); setShowTemplates(false); setTemplateSearch('')
}
function selectProduct(product: { id: string; name: string; price: string | null; brand: string | null }) {
setProductId(product.id)
setDescription(product.brand ? `${product.brand} ${product.name}` : product.name)
setUnitPrice(product.price ?? '0')
setProductSearch('')
setShowProducts(false)
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const q = parseFloat(qty) || 1
const up = parseFloat(unitPrice) || 0
const c = cost ? parseFloat(cost) : undefined
mutation.mutate({ itemType, description, qty: q, unitPrice: up, totalPrice: q * up, cost: c })
mutation.mutate({ itemType, description, qty: q, unitPrice: up, totalPrice: q * up, cost: c, productId: productId ?? undefined })
}
const templates = templatesData?.data ?? []
const products = productsData?.data ?? []
return (
<Dialog open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) resetForm() }}>
@@ -454,8 +485,38 @@ function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string;
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Type</Label>
<Select value={itemType} onValueChange={setItemType}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="labor">Labor</SelectItem><SelectItem value="part">Part</SelectItem><SelectItem value="flat_rate">Flat Rate</SelectItem><SelectItem value="misc">Misc</SelectItem></SelectContent></Select>
<Select value={itemType} onValueChange={(v) => { setItemType(v); setProductId(null); setProductSearch(''); setShowProducts(false) }}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="labor">Labor</SelectItem><SelectItem value="part">Part</SelectItem><SelectItem value="flat_rate">Flat Rate</SelectItem><SelectItem value="misc">Misc</SelectItem><SelectItem value="consumable">Consumable (internal)</SelectItem></SelectContent></Select>
</div>
{showProductPicker && (
<div className="relative space-y-2">
<Label>Search Inventory</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={itemType === 'consumable' ? 'Search consumables...' : 'Search parts...'}
value={productSearch}
onChange={(e) => { setProductSearch(e.target.value); setShowProducts(true) }}
onFocus={() => productSearch && setShowProducts(true)}
className="pl-9"
/>
</div>
{productId && (
<div className="text-xs text-muted-foreground flex items-center gap-1">
Linked to product <button type="button" className="underline text-destructive" onClick={() => setProductId(null)}>clear</button>
</div>
)}
{showProducts && productSearch.length >= 1 && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-48 overflow-auto">
{products.length === 0 ? <div className="p-3 text-sm text-muted-foreground">No products found</div> : products.map((p) => (
<button key={p.id} type="button" className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex justify-between" onClick={() => selectProduct(p)}>
<span>{p.brand ? `${p.brand} ` : ''}{p.name}{p.sku ? ` (${p.sku})` : ''}</span>
{p.price && <span className="text-muted-foreground">${p.price}</span>}
</button>
))}
</div>
)}
</div>
)}
<div className="space-y-2"><Label>Description *</Label><Input value={description} onChange={(e) => setDescription(e.target.value)} required /></div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2"><Label>Qty</Label><Input type="number" step="0.001" value={qty} onChange={(e) => setQty(e.target.value)} /></div>

View File

@@ -16,7 +16,8 @@ import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { moduleListOptions, moduleMutations, moduleKeys } from '@/api/modules'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Save, Plus, Trash2, MapPin, Building, ImageIcon, Blocks, Lock, Settings2, Receipt } from 'lucide-react'
import { Save, Plus, Trash2, MapPin, Building, ImageIcon, Blocks, Lock, Settings2, Receipt, ShieldCheck } from 'lucide-react'
import { OVERRIDE_ACTIONS, getRequiredOverrides, setRequiredOverrides, getDiscountThreshold, setDiscountThreshold, type OverrideAction } from '@/components/pos/pos-manager-override'
import { toast } from 'sonner'
interface StoreSettings {
@@ -119,142 +120,149 @@ function SettingsPage() {
<div className="space-y-6 max-w-3xl">
<h1 className="text-2xl font-bold">Settings</h1>
<Tabs defaultValue="general">
<Tabs defaultValue="store">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="store">Store</TabsTrigger>
<TabsTrigger value="locations">Locations</TabsTrigger>
<TabsTrigger value="modules">Modules</TabsTrigger>
<TabsTrigger value="receipt">Receipt</TabsTrigger>
<TabsTrigger value="pos">POS Security</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-6 mt-4">
{/* 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 className="space-y-6">
{/* Logo upload */}
<div>
<LogoUpload entityId={store.id} category="logo" label="Store Logo" description="Used on PDFs, sidebar, and login screen" />
</div>
{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" />
<TabsContent value="store" className="mt-4">
<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 className="space-y-6">
<div>
<LogoUpload entityId={store.id} category="logo" label="Store Logo" description="Used on PDFs, sidebar, and login screen" />
</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 }))} />
{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>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 }))} />
<Label>Notes</Label>
<Textarea value={fields.notes} onChange={(e) => setFields((p) => ({ ...p, notes: e.target.value }))} rows={2} />
</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 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>
<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>}
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="locations" className="mt-4">
<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>
</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>
{/* Modules */}
<ModulesCard />
{/* App Configuration */}
<AppConfigCard />
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="modules" className="mt-4">
<ModulesCard />
</TabsContent>
<TabsContent value="receipt" className="mt-4">
<ReceiptSettingsCard />
</TabsContent>
<TabsContent value="pos" className="mt-4">
<ManagerOverridesCard />
</TabsContent>
<TabsContent value="advanced" className="mt-4">
<AppConfigCard />
</TabsContent>
</Tabs>
</div>
)
@@ -492,6 +500,74 @@ function ReceiptSettingsCard() {
)
}
function ManagerOverridesCard() {
const [overrides, setOverrides] = useState<Set<OverrideAction>>(() => getRequiredOverrides())
const [threshold, setThreshold] = useState(() => getDiscountThreshold())
function toggle(action: OverrideAction) {
setOverrides((prev) => {
const next = new Set(prev)
if (next.has(action)) next.delete(action)
else next.add(action)
setRequiredOverrides(next)
return next
})
}
return (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<ShieldCheck className="h-5 w-5" />Manager Overrides
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
When enabled, these actions will require a manager or admin to enter their PIN before proceeding. This setting is stored per device.
</p>
<div className="space-y-2">
{OVERRIDE_ACTIONS.map((action) => (
<div key={action.key} className="flex items-center justify-between p-3 rounded-md border">
<div className="min-w-0">
<span className="font-medium text-sm">{action.label}</span>
<p className="text-xs text-muted-foreground mt-0.5">{action.description}</p>
</div>
<Switch
checked={overrides.has(action.key)}
onCheckedChange={() => toggle(action.key)}
/>
</div>
))}
</div>
<div className="mt-4 p-3 rounded-md border">
<div className="flex items-center justify-between">
<div className="min-w-0">
<span className="font-medium text-sm">Discount Threshold</span>
<p className="text-xs text-muted-foreground mt-0.5">Require manager approval for discounts at or above this percentage. Set to 0 to disable.</p>
</div>
<div className="flex items-center gap-1">
<Input
type="number"
min="0"
max="100"
className="w-20 h-8 text-sm text-right"
value={threshold}
onChange={(e) => {
const v = parseInt(e.target.value) || 0
setThreshold(v)
setDiscountThreshold(v)
}}
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
</div>
</div>
</CardContent>
</Card>
)
}
function LocationCard({ location }: { location: Location }) {
const queryClient = useQueryClient()
const [editing, setEditing] = useState(false)