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