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

@@ -3,11 +3,16 @@ import { usePOSStore } from '@/stores/pos.store'
import { posMutations, posKeys, type Transaction } from '@/api/pos'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { X, Banknote, CreditCard, FileText, Ban, UserRound } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { X, Banknote, CreditCard, FileText, Ban, UserRound, Tag } from 'lucide-react'
import { toast } from 'sonner'
import { useState } from 'react'
import { POSPaymentDialog } from './pos-payment-dialog'
import { POSCustomerDialog } from './pos-customer-dialog'
import { ManagerOverrideDialog, requiresOverride, requiresDiscountOverride } from './pos-manager-override'
import type { TransactionLineItem } from '@/api/pos'
interface POSCartPanelProps {
transaction: Transaction | null
@@ -18,6 +23,10 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
const { currentTransactionId, setTransaction, accountName, accountPhone, accountEmail } = usePOSStore()
const [paymentMethod, setPaymentMethod] = useState<string | null>(null)
const [customerOpen, setCustomerOpen] = useState(false)
const [overrideOpen, setOverrideOpen] = useState(false)
const [priceItemId, setPriceItemId] = useState<string | null>(null)
const [pendingDiscount, setPendingDiscount] = useState<{ lineItemId: string; amount: number; reason: string } | null>(null)
const [discountOverrideOpen, setDiscountOverrideOpen] = useState(false)
const lineItems = transaction?.lineItems ?? []
const drawerSessionId = usePOSStore((s) => s.drawerSessionId)
@@ -32,6 +41,17 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
onError: (err) => toast.error(err.message),
})
const discountMutation = useMutation({
mutationFn: (data: { lineItemId: string; amount: number; reason: string }) =>
posMutations.applyDiscount(currentTransactionId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) })
setPriceItemId(null)
toast.success('Price adjusted')
},
onError: (err) => toast.error(err.message),
})
const voidMutation = useMutation({
mutationFn: () => posMutations.void(currentTransactionId!),
onSuccess: () => {
@@ -93,33 +113,61 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
</div>
) : (
<div className="divide-y divide-border">
{lineItems.map((item) => (
<div key={item.id} className="flex items-center gap-2 px-3 py-2.5 group">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{item.description}</p>
<p className="text-xs text-muted-foreground">
{item.qty} x ${parseFloat(item.unitPrice).toFixed(2)}
{parseFloat(item.taxAmount) > 0 && (
<span className="ml-2">tax ${parseFloat(item.taxAmount).toFixed(2)}</span>
)}
</p>
{lineItems.map((item) => {
const unitPrice = parseFloat(item.unitPrice)
const discount = parseFloat(item.discountAmount)
const hasDiscount = discount > 0
const listTotal = unitPrice * item.qty
const discountPct = listTotal > 0 ? Math.round((discount / listTotal) * 100) : 0
return (
<div key={item.id} className="flex items-center gap-2 px-3 py-2.5 group">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{item.description}</p>
<div className="text-xs text-muted-foreground flex items-center gap-2">
<span>{item.qty} x ${unitPrice.toFixed(2)}</span>
{hasDiscount && (
<span className="text-green-600">-${discount.toFixed(2)} ({discountPct}%)</span>
)}
{parseFloat(item.taxAmount) > 0 && (
<span>tax ${parseFloat(item.taxAmount).toFixed(2)}</span>
)}
</div>
</div>
<span className="text-sm font-medium tabular-nums">
${parseFloat(item.lineTotal).toFixed(2)}
</span>
{isPending && (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 shrink-0">
<PriceAdjuster
item={item}
open={priceItemId === item.id}
onOpenChange={(o) => setPriceItemId(o ? item.id : null)}
onApply={(amount, reason) => {
const pct = listTotal > 0 ? (amount / listTotal) * 100 : 0
if (requiresDiscountOverride(pct)) {
setPendingDiscount({ lineItemId: item.id, amount, reason })
setDiscountOverrideOpen(true)
} else {
discountMutation.mutate({ lineItemId: item.id, amount, reason })
}
}}
isPending={discountMutation.isPending}
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => removeItemMutation.mutate(item.id)}
disabled={removeItemMutation.isPending}
>
<X className="h-4 w-4 text-destructive" />
</Button>
</div>
)}
</div>
<span className="text-sm font-medium tabular-nums">
${parseFloat(item.lineTotal).toFixed(2)}
</span>
{isPending && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 shrink-0"
onClick={() => removeItemMutation.mutate(item.id)}
disabled={removeItemMutation.isPending}
>
<X className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
))}
)
})}
</div>
)}
</div>
@@ -148,6 +196,25 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
</div>
</div>
{/* Order discount button */}
{hasItems && isPending && (
<div className="px-3 pb-1">
<OrderDiscountButton
subtotal={subtotal}
onApply={(amount, reason) => {
const pct = subtotal > 0 ? (amount / subtotal) * 100 : 0
if (requiresDiscountOverride(pct)) {
setPendingDiscount({ lineItemId: lineItems[0].id, amount, reason })
setDiscountOverrideOpen(true)
} else {
discountMutation.mutate({ lineItemId: lineItems[0].id, amount, reason })
}
}}
isPending={discountMutation.isPending}
/>
</div>
)}
{/* Payment buttons */}
<div className="p-3 space-y-2">
{!drawerOpen && hasItems && (
@@ -183,7 +250,13 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
variant="destructive"
className="h-12 text-sm gap-2"
disabled={!hasItems || !isPending}
onClick={() => voidMutation.mutate()}
onClick={() => {
if (requiresOverride('void_transaction')) {
setOverrideOpen(true)
} else {
voidMutation.mutate()
}
}}
>
<Ban className="h-4 w-4" />
Void
@@ -205,6 +278,220 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
{/* Customer dialog */}
<POSCustomerDialog open={customerOpen} onOpenChange={setCustomerOpen} />
{/* Manager override for void */}
<ManagerOverrideDialog
open={overrideOpen}
onOpenChange={setOverrideOpen}
action="Void transaction"
onAuthorized={() => voidMutation.mutate()}
/>
{/* Manager override for discount */}
<ManagerOverrideDialog
open={discountOverrideOpen}
onOpenChange={setDiscountOverrideOpen}
action="Price adjustment"
onAuthorized={() => {
if (pendingDiscount) {
discountMutation.mutate(pendingDiscount)
setPendingDiscount(null)
}
}}
/>
</div>
)
}
// --- Order Discount Button ---
function OrderDiscountButton({ subtotal, onApply, isPending }: {
subtotal: number
onApply: (amount: number, reason: string) => void
isPending: boolean
}) {
const [open, setOpen] = useState(false)
const [mode, setMode] = useState<AdjustMode>('percent')
const [value, setValue] = useState('')
function calculate() {
const v = parseFloat(value) || 0
if (mode === 'amount_off') return Math.min(v, subtotal)
if (mode === 'set_price') return Math.max(0, subtotal - v)
return Math.round(subtotal * (v / 100) * 100) / 100
}
const discountAmount = calculate()
function handleApply() {
if (discountAmount <= 0) return
const reason = mode === 'percent' ? `${parseFloat(value)}% order discount` : mode === 'set_price' ? `Order total set to $${parseFloat(value).toFixed(2)}` : `$${parseFloat(value).toFixed(2)} order discount`
onApply(discountAmount, reason)
setValue('')
setOpen(false)
}
return (
<Popover open={open} onOpenChange={(o) => { setOpen(o); if (!o) setValue('') }}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="w-full gap-2 text-xs h-8">
<Tag className="h-3 w-3" />Order Discount
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3" side="top" align="center">
<div className="space-y-3">
<div className="text-xs text-muted-foreground">
Subtotal: <span className="font-medium text-foreground">${subtotal.toFixed(2)}</span>
</div>
<div className="flex rounded-md border overflow-hidden text-xs">
{([
{ key: 'percent' as const, label: '% Off' },
{ key: 'amount_off' as const, label: '$ Off' },
{ key: 'set_price' as const, label: 'Set Total' },
]).map((m) => (
<button
key={m.key}
type="button"
className={`flex-1 py-1.5 transition-colors ${mode === m.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}
onClick={() => { setMode(m.key); setValue('') }}
>
{m.label}
</button>
))}
</div>
<Input
type="number"
step="0.01"
min="0"
placeholder={mode === 'percent' ? 'e.g. 10' : '0.00'}
value={value}
onChange={(e) => setValue(e.target.value)}
className="h-9"
autoFocus
onKeyDown={(e) => { if (e.key === 'Enter') handleApply() }}
/>
{value && discountAmount > 0 && (
<div className="text-xs flex justify-between font-medium">
<span className="text-green-600">-${discountAmount.toFixed(2)}</span>
<span>New total: ${(subtotal - discountAmount).toFixed(2)}</span>
</div>
)}
<Button size="sm" className="w-full" onClick={handleApply} disabled={isPending || discountAmount <= 0}>
{isPending ? 'Applying...' : 'Apply Discount'}
</Button>
</div>
</PopoverContent>
</Popover>
)
}
// --- Price Adjuster Popover ---
type AdjustMode = 'amount_off' | 'set_price' | 'percent'
function PriceAdjuster({ item, open, onOpenChange, onApply, isPending }: {
item: TransactionLineItem
open: boolean
onOpenChange: (open: boolean) => void
onApply: (amount: number, reason: string) => void
isPending: boolean
}) {
const [mode, setMode] = useState<AdjustMode>('percent')
const [value, setValue] = useState('')
const unitPrice = parseFloat(item.unitPrice)
const listTotal = unitPrice * item.qty
function calculate(): { discountAmount: number; salePrice: number; pct: number } {
const v = parseFloat(value) || 0
if (mode === 'amount_off') {
const d = Math.min(v, listTotal)
return { discountAmount: d, salePrice: listTotal - d, pct: listTotal > 0 ? (d / listTotal) * 100 : 0 }
}
if (mode === 'set_price') {
const d = Math.max(0, listTotal - v)
return { discountAmount: d, salePrice: v, pct: listTotal > 0 ? (d / listTotal) * 100 : 0 }
}
// percent
const d = Math.round(listTotal * (v / 100) * 100) / 100
return { discountAmount: d, salePrice: listTotal - d, pct: v }
}
const calc = calculate()
function handleApply() {
if (calc.discountAmount <= 0) return
const reason = mode === 'percent' ? `${parseFloat(value)}% off` : mode === 'set_price' ? `Price set to $${parseFloat(value).toFixed(2)}` : `$${parseFloat(value).toFixed(2)} off`
onApply(calc.discountAmount, reason)
setValue('')
}
return (
<Popover open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) setValue('') }}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Tag className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3" side="left" align="start">
<div className="space-y-3">
<div className="text-xs text-muted-foreground">
List: <span className="font-medium text-foreground">${listTotal.toFixed(2)}</span>
</div>
{/* Mode tabs */}
<div className="flex rounded-md border overflow-hidden text-xs">
{([
{ key: 'percent' as const, label: '% Off' },
{ key: 'amount_off' as const, label: '$ Off' },
{ key: 'set_price' as const, label: 'Set Price' },
]).map((m) => (
<button
key={m.key}
type="button"
className={`flex-1 py-1.5 transition-colors ${mode === m.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}
onClick={() => { setMode(m.key); setValue('') }}
>
{m.label}
</button>
))}
</div>
<Input
type="number"
step="0.01"
min="0"
placeholder={mode === 'percent' ? 'e.g. 10' : '0.00'}
value={value}
onChange={(e) => setValue(e.target.value)}
className="h-9"
autoFocus
onKeyDown={(e) => { if (e.key === 'Enter') handleApply() }}
/>
{value && parseFloat(value) > 0 && (
<div className="text-xs space-y-0.5">
<div className="flex justify-between">
<span className="text-muted-foreground">Discount</span>
<span className="text-green-600">-${calc.discountAmount.toFixed(2)} ({calc.pct.toFixed(0)}%)</span>
</div>
<div className="flex justify-between font-medium">
<span>Sale Price</span>
<span>${calc.salePrice.toFixed(2)}</span>
</div>
</div>
)}
<Button
size="sm"
className="w-full"
onClick={handleApply}
disabled={isPending || !value || calc.discountAmount <= 0}
>
{isPending ? 'Applying...' : 'Apply'}
</Button>
</div>
</PopoverContent>
</Popover>
)
}