Code review fixes: - Wrap createFromRepairTicket() in DB transaction for atomicity - Wrap complete() inventory + status updates in DB transaction - Repair ticket status update now atomic with transaction completion - Add Zod validation on from-repair route body - Fix requiresDiscountOverride: threshold and manual_discount are independent checks - Order discount distributes proportionally across line items (not first-only) - Extract shared receipt calculations into useReceiptData/useBarcode hooks - Add error handling for barcode generation Tests: - Unit: consumable tax category mapping, exempt rate short-circuit - API: ready-for-pickup listing + search, from-repair transaction creation, consumable exclusion from line items, tax rate verification (labor=service, part=goods), duplicate prevention, ticket auto-pickup on payment completion, isConsumable product filter Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
202 lines
6.9 KiB
TypeScript
202 lines
6.9 KiB
TypeScript
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { api } from '@/lib/api-client'
|
|
import { Delete, ShieldCheck } from 'lucide-react'
|
|
|
|
interface ManagerOverrideDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
action: string
|
|
onAuthorized: () => void
|
|
}
|
|
|
|
interface PinUser {
|
|
id: string
|
|
role: string
|
|
firstName: string
|
|
lastName: string
|
|
}
|
|
|
|
export function ManagerOverrideDialog({ open, onOpenChange, action, onAuthorized }: ManagerOverrideDialogProps) {
|
|
const [code, setCode] = useState('')
|
|
const [error, setError] = useState('')
|
|
const [loading, setLoading] = useState(false)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setCode('')
|
|
setError('')
|
|
containerRef.current?.focus()
|
|
}
|
|
}, [open])
|
|
|
|
const handleDigit = useCallback((digit: string) => {
|
|
setError('')
|
|
setCode((p) => (p.length >= 10 ? p : p + digit))
|
|
}, [])
|
|
|
|
const handleBackspace = useCallback(() => {
|
|
setError('')
|
|
setCode((p) => p.slice(0, -1))
|
|
}, [])
|
|
|
|
const handleClear = useCallback(() => {
|
|
setError('')
|
|
setCode('')
|
|
}, [])
|
|
|
|
const handleSubmit = useCallback(async (submitCode: string) => {
|
|
if (submitCode.length < 8) {
|
|
setError('Enter manager employee # + PIN')
|
|
return
|
|
}
|
|
setLoading(true)
|
|
setError('')
|
|
try {
|
|
const res = await api.post<{ user: PinUser; token: string }>('/v1/auth/pin-login', { code: submitCode })
|
|
if (res.user.role === 'admin' || res.user.role === 'manager') {
|
|
onAuthorized()
|
|
onOpenChange(false)
|
|
} else {
|
|
setError('Manager or admin access required')
|
|
setCode('')
|
|
}
|
|
} catch {
|
|
setError('Invalid code')
|
|
setCode('')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [onAuthorized, onOpenChange])
|
|
|
|
useEffect(() => {
|
|
if (code.length === 8) {
|
|
handleSubmit(code)
|
|
}
|
|
}, [code, handleSubmit])
|
|
|
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
if (e.key >= '0' && e.key <= '9') handleDigit(e.key)
|
|
else if (e.key === 'Backspace') handleBackspace()
|
|
else if (e.key === 'Enter' && code.length >= 8) handleSubmit(code)
|
|
else if (e.key === 'Escape') handleClear()
|
|
}, [handleDigit, handleBackspace, handleSubmit, handleClear, code])
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-xs">
|
|
<div
|
|
ref={containerRef}
|
|
onKeyDown={handleKeyDown}
|
|
tabIndex={0}
|
|
className="outline-none space-y-4"
|
|
>
|
|
<div className="text-center space-y-1">
|
|
<ShieldCheck className="h-8 w-8 mx-auto text-amber-500" />
|
|
<DialogTitle className="text-base">Manager Override</DialogTitle>
|
|
<p className="text-xs text-muted-foreground">{action}</p>
|
|
</div>
|
|
|
|
{/* Code dots */}
|
|
<div className="flex justify-center items-center gap-2">
|
|
<div className="flex gap-1.5">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<div
|
|
key={`e${i}`}
|
|
className={`w-3 h-3 rounded-full border-2 ${
|
|
i < Math.min(code.length, 4) ? 'bg-foreground border-foreground' : 'border-muted-foreground/30'
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
<span className="text-muted-foreground/40">-</span>
|
|
<div className="flex gap-1.5">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<div
|
|
key={`p${i}`}
|
|
className={`w-3 h-3 rounded-full border-2 ${
|
|
i < Math.max(0, code.length - 4) ? 'bg-foreground border-foreground' : 'border-muted-foreground/30'
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{error && <p className="text-xs text-destructive text-center">{error}</p>}
|
|
|
|
{/* Numpad */}
|
|
<div className="grid grid-cols-3 gap-1.5">
|
|
{['1', '2', '3', '4', '5', '6', '7', '8', '9'].map((d) => (
|
|
<Button key={d} variant="outline" className="h-12 text-lg font-medium" onClick={() => handleDigit(d)} disabled={loading}>
|
|
{d}
|
|
</Button>
|
|
))}
|
|
<Button variant="outline" className="h-12 text-xs" onClick={handleClear} disabled={loading}>Clear</Button>
|
|
<Button variant="outline" className="h-12 text-lg font-medium" onClick={() => handleDigit('0')} disabled={loading}>0</Button>
|
|
<Button variant="outline" className="h-12" onClick={handleBackspace} disabled={loading}>
|
|
<Delete className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{loading && <p className="text-xs text-muted-foreground text-center">Verifying...</p>}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// --- Config types & helpers ---
|
|
|
|
export const OVERRIDE_ACTIONS = [
|
|
{ key: 'void_transaction', label: 'Void Transaction', description: 'Cancel an in-progress sale' },
|
|
{ key: 'refund', label: 'Refund', description: 'Process a return or refund' },
|
|
{ key: 'manual_discount', label: 'Manual Discount', description: 'Apply a discount not from a preset' },
|
|
{ key: 'price_override', label: 'Price Override', description: 'Change an item price at the register' },
|
|
{ key: 'no_sale_drawer', label: 'No-Sale Drawer Open', description: 'Open the drawer without a transaction' },
|
|
{ key: 'cash_in_out', label: 'Cash In / Cash Out', description: 'Add or remove cash from the drawer' },
|
|
] as const
|
|
|
|
export type OverrideAction = typeof OVERRIDE_ACTIONS[number]['key']
|
|
|
|
const STORAGE_KEY = 'pos_manager_overrides'
|
|
|
|
export function getRequiredOverrides(): Set<OverrideAction> {
|
|
try {
|
|
const stored = localStorage.getItem(STORAGE_KEY)
|
|
if (!stored) return new Set()
|
|
return new Set(JSON.parse(stored) as OverrideAction[])
|
|
} catch {
|
|
return new Set()
|
|
}
|
|
}
|
|
|
|
export function setRequiredOverrides(actions: Set<OverrideAction>) {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify([...actions]))
|
|
}
|
|
|
|
export function requiresOverride(action: OverrideAction): boolean {
|
|
return getRequiredOverrides().has(action)
|
|
}
|
|
|
|
// Discount threshold — discounts above this percentage require manager override
|
|
const DISCOUNT_THRESHOLD_KEY = 'pos_discount_threshold_pct'
|
|
|
|
export function getDiscountThreshold(): number {
|
|
const stored = localStorage.getItem(DISCOUNT_THRESHOLD_KEY)
|
|
return stored ? parseInt(stored, 10) : 0 // 0 = disabled
|
|
}
|
|
|
|
export function setDiscountThreshold(pct: number) {
|
|
localStorage.setItem(DISCOUNT_THRESHOLD_KEY, String(pct))
|
|
}
|
|
|
|
export function requiresDiscountOverride(discountPct: number): boolean {
|
|
// Check percentage threshold first
|
|
const threshold = getDiscountThreshold()
|
|
if (threshold > 0 && discountPct >= threshold) return true
|
|
// Fall back to the blanket manual_discount toggle
|
|
return requiresOverride('manual_discount')
|
|
}
|