feat: orders lookup with receipt reprint, refresh stock after sale

- "Orders" button in POS quick actions shows recent transactions
- Search by transaction number, tap to view receipt, print or save PDF
- Product stock counts refresh after completing a sale
- Invalidate product search queries on payment completion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ryan
2026-04-04 23:38:03 +00:00
parent e19cdc76e0
commit a48da03289
3 changed files with 158 additions and 1 deletions

View File

@@ -7,8 +7,9 @@ import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Search, ScanBarcode, Wrench, PenLine } from 'lucide-react' import { Search, ScanBarcode, Wrench, PenLine, ClipboardList } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { POSTransactionsDialog } from './pos-transactions-dialog'
interface POSItemPanelProps { interface POSItemPanelProps {
transaction: Transaction | null transaction: Transaction | null
@@ -19,6 +20,7 @@ export function POSItemPanel({ transaction: _transaction }: POSItemPanelProps) {
const { currentTransactionId, setTransaction, locationId, accountId } = usePOSStore() const { currentTransactionId, setTransaction, locationId, accountId } = usePOSStore()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [customOpen, setCustomOpen] = useState(false) const [customOpen, setCustomOpen] = useState(false)
const [txnDialogOpen, setTxnDialogOpen] = useState(false)
const [customDesc, setCustomDesc] = useState('') const [customDesc, setCustomDesc] = useState('')
const [customPrice, setCustomPrice] = useState('') const [customPrice, setCustomPrice] = useState('')
const [customQty, setCustomQty] = useState('1') const [customQty, setCustomQty] = useState('1')
@@ -211,6 +213,14 @@ export function POSItemPanel({ transaction: _transaction }: POSItemPanelProps) {
<PenLine className="h-4 w-4" /> <PenLine className="h-4 w-4" />
Custom Custom
</Button> </Button>
<Button
variant="outline"
className="flex-1 h-11 text-sm gap-2"
onClick={() => setTxnDialogOpen(true)}
>
<ClipboardList className="h-4 w-4" />
Orders
</Button>
</div> </div>
{/* Custom item dialog */} {/* Custom item dialog */}
@@ -264,6 +274,9 @@ export function POSItemPanel({ transaction: _transaction }: POSItemPanelProps) {
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Transactions dialog */}
<POSTransactionsDialog open={txnDialogOpen} onOpenChange={setTxnDialogOpen} />
</div> </div>
) )
} }

View File

@@ -44,6 +44,7 @@ export function POSPaymentDialog({ open, onOpenChange, paymentMethod, transactio
}, },
onSuccess: (txn) => { onSuccess: (txn) => {
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) }) queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) })
queryClient.invalidateQueries({ queryKey: ['pos', 'products'] })
setResult(txn) setResult(txn)
setCompleted(true) setCompleted(true)
}, },

View File

@@ -0,0 +1,143 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Printer } from 'lucide-react'
import { POSReceipt, printReceipt, downloadReceiptPDF } from './pos-receipt'
import type { Transaction } from '@/api/pos'
interface ReceiptData {
transaction: Transaction & { lineItems: { description: string; qty: number; unitPrice: string; taxAmount: string; lineTotal: string; discountAmount: string }[] }
company: { name: string; phone: string | null; email: string | null; address: { street?: string; city?: string; state?: string; zip?: string } | null }
location: { name: string; phone: string | null; email: string | null; address: { street?: string; city?: string; state?: string; zip?: string } | null }
}
interface AppConfigEntry { key: string; value: string | null }
function recentTransactionsOptions(search: string) {
return queryOptions({
queryKey: ['pos', 'recent-transactions', search],
queryFn: () => api.get<{ data: Transaction[] }>('/v1/transactions', {
limit: 15,
sort: 'created_at',
order: 'desc',
...(search ? { q: search } : {}),
}),
})
}
interface POSTransactionsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function POSTransactionsDialog({ open, onOpenChange }: POSTransactionsDialogProps) {
const [search, setSearch] = useState('')
const [receiptTxnId, setReceiptTxnId] = useState<string | null>(null)
const { data: txnData } = useQuery({
...recentTransactionsOptions(search),
enabled: open,
})
const transactions = txnData?.data ?? []
// Fetch receipt for selected transaction
const { data: receiptData } = useQuery({
queryKey: ['pos', 'receipt', receiptTxnId],
queryFn: () => api.get<ReceiptData>(`/v1/transactions/${receiptTxnId}/receipt`),
enabled: !!receiptTxnId,
})
const { data: configData } = useQuery({
queryKey: ['config'],
queryFn: () => api.get<{ data: AppConfigEntry[] }>('/v1/config'),
enabled: !!receiptTxnId,
})
const receiptConfig = {
header: configData?.data?.find((c) => c.key === 'receipt_header')?.value || undefined,
footer: configData?.data?.find((c) => c.key === 'receipt_footer')?.value || undefined,
returnPolicy: configData?.data?.find((c) => c.key === 'receipt_return_policy')?.value || undefined,
social: configData?.data?.find((c) => c.key === 'receipt_social')?.value || undefined,
}
// Receipt view
if (receiptTxnId && receiptData) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-2">
<Button variant="ghost" size="sm" onClick={() => setReceiptTxnId(null)}>Back</Button>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => downloadReceiptPDF(receiptData.transaction.transactionNumber)}>
Save PDF
</Button>
<Button size="sm" onClick={printReceipt} className="gap-2">
<Printer className="h-4 w-4" />Print
</Button>
</div>
</div>
<div id="pos-receipt-print">
<POSReceipt data={receiptData} config={receiptConfig} />
</div>
</DialogContent>
</Dialog>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Recent Transactions</DialogTitle>
</DialogHeader>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by transaction number..."
className="h-10"
autoFocus
/>
<div className="flex-1 overflow-y-auto">
{transactions.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No transactions found</p>
) : (
<div className="divide-y divide-border">
{transactions.map((txn) => (
<button
key={txn.id}
onClick={() => setReceiptTxnId(txn.id)}
className="w-full text-left py-2.5 px-1 hover:bg-accent/50 rounded transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-sm font-mono">{txn.transactionNumber}</span>
<span className="text-sm font-semibold">${parseFloat(txn.total).toFixed(2)}</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<Badge
variant={txn.status === 'completed' ? 'default' : txn.status === 'voided' ? 'destructive' : 'outline'}
className="text-[10px]"
>
{txn.status}
</Badge>
{txn.paymentMethod && (
<span className="text-xs text-muted-foreground">{txn.paymentMethod.replace('_', ' ')}</span>
)}
<span className="text-xs text-muted-foreground ml-auto">
{new Date(txn.completedAt ?? txn.createdAt).toLocaleString()}
</span>
</div>
</button>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}