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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user