- 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>
200 lines
6.8 KiB
TypeScript
200 lines
6.8 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 {
|
|
const threshold = getDiscountThreshold()
|
|
if (threshold <= 0) return requiresOverride('manual_discount')
|
|
return discountPct >= threshold
|
|
}
|