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 a48da03289
commit 95cf017b4b
32 changed files with 1507 additions and 199 deletions

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)