feat: POS register screen with touch-optimized layout #6
@@ -80,7 +80,7 @@ export interface Product {
|
|||||||
sku: string | null
|
sku: string | null
|
||||||
upc: string | null
|
upc: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
sellingPrice: string | null
|
price: string | null
|
||||||
costPrice: string | null
|
costPrice: string | null
|
||||||
qtyOnHand: number | null
|
qtyOnHand: number | null
|
||||||
taxCategory: string
|
taxCategory: string
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
|
|||||||
const [paymentMethod, setPaymentMethod] = useState<string | null>(null)
|
const [paymentMethod, setPaymentMethod] = useState<string | null>(null)
|
||||||
const lineItems = transaction?.lineItems ?? []
|
const lineItems = transaction?.lineItems ?? []
|
||||||
|
|
||||||
|
const drawerSessionId = usePOSStore((s) => s.drawerSessionId)
|
||||||
|
const drawerOpen = !!drawerSessionId
|
||||||
|
|
||||||
const removeItemMutation = useMutation({
|
const removeItemMutation = useMutation({
|
||||||
mutationFn: (lineItemId: string) =>
|
mutationFn: (lineItemId: string) =>
|
||||||
posMutations.removeLineItem(currentTransactionId!, lineItemId),
|
posMutations.removeLineItem(currentTransactionId!, lineItemId),
|
||||||
@@ -126,41 +129,46 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment buttons */}
|
{/* Payment buttons */}
|
||||||
<div className="p-3 grid grid-cols-2 gap-2">
|
<div className="p-3 space-y-2">
|
||||||
<Button
|
{!drawerOpen && hasItems && (
|
||||||
className="h-12 text-sm gap-2"
|
<p className="text-xs text-destructive text-center">Open the drawer before accepting payment</p>
|
||||||
disabled={!hasItems || !isPending}
|
)}
|
||||||
onClick={() => setPaymentMethod('cash')}
|
<div className="grid grid-cols-2 gap-2">
|
||||||
>
|
<Button
|
||||||
<Banknote className="h-4 w-4" />
|
className="h-12 text-sm gap-2"
|
||||||
Cash
|
disabled={!hasItems || !isPending || !drawerOpen}
|
||||||
</Button>
|
onClick={() => setPaymentMethod('cash')}
|
||||||
<Button
|
>
|
||||||
className="h-12 text-sm gap-2"
|
<Banknote className="h-4 w-4" />
|
||||||
disabled={!hasItems || !isPending}
|
Cash
|
||||||
onClick={() => setPaymentMethod('card_present')}
|
</Button>
|
||||||
>
|
<Button
|
||||||
<CreditCard className="h-4 w-4" />
|
className="h-12 text-sm gap-2"
|
||||||
Card
|
disabled={!hasItems || !isPending || !drawerOpen}
|
||||||
</Button>
|
onClick={() => setPaymentMethod('card_present')}
|
||||||
<Button
|
>
|
||||||
variant="outline"
|
<CreditCard className="h-4 w-4" />
|
||||||
className="h-12 text-sm gap-2"
|
Card
|
||||||
disabled={!hasItems || !isPending}
|
</Button>
|
||||||
onClick={() => setPaymentMethod('check')}
|
<Button
|
||||||
>
|
variant="outline"
|
||||||
<FileText className="h-4 w-4" />
|
className="h-12 text-sm gap-2"
|
||||||
Check
|
disabled={!hasItems || !isPending || !drawerOpen}
|
||||||
</Button>
|
onClick={() => setPaymentMethod('check')}
|
||||||
<Button
|
>
|
||||||
variant="destructive"
|
<FileText className="h-4 w-4" />
|
||||||
className="h-12 text-sm gap-2"
|
Check
|
||||||
disabled={!hasItems || !isPending}
|
</Button>
|
||||||
onClick={() => voidMutation.mutate()}
|
<Button
|
||||||
>
|
variant="destructive"
|
||||||
<Ban className="h-4 w-4" />
|
className="h-12 text-sm gap-2"
|
||||||
Void
|
disabled={!hasItems || !isPending}
|
||||||
</Button>
|
onClick={() => voidMutation.mutate()}
|
||||||
|
>
|
||||||
|
<Ban className="h-4 w-4" />
|
||||||
|
Void
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -48,11 +48,12 @@ export function POSItemPanel({ transaction }: POSItemPanelProps) {
|
|||||||
productId: product.id,
|
productId: product.id,
|
||||||
description: product.name,
|
description: product.name,
|
||||||
qty: 1,
|
qty: 1,
|
||||||
unitPrice: parseFloat(product.sellingPrice ?? '0'),
|
unitPrice: parseFloat(product.price ?? '0'),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId ?? '') })
|
const txnId = usePOSStore.getState().currentTransactionId
|
||||||
|
queryClient.invalidateQueries({ queryKey: posKeys.transaction(txnId ?? '') })
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
@@ -76,7 +77,8 @@ export function POSItemPanel({ transaction }: POSItemPanelProps) {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId ?? '') })
|
const txnId = usePOSStore.getState().currentTransactionId
|
||||||
|
queryClient.invalidateQueries({ queryKey: posKeys.transaction(txnId ?? '') })
|
||||||
setCustomOpen(false)
|
setCustomOpen(false)
|
||||||
setCustomDesc('')
|
setCustomDesc('')
|
||||||
setCustomPrice('')
|
setCustomPrice('')
|
||||||
@@ -102,7 +104,7 @@ export function POSItemPanel({ transaction }: POSItemPanelProps) {
|
|||||||
productId: product.id,
|
productId: product.id,
|
||||||
description: product.name,
|
description: product.name,
|
||||||
qty: 1,
|
qty: 1,
|
||||||
unitPrice: parseFloat(product.sellingPrice ?? '0'),
|
unitPrice: parseFloat(product.price ?? '0'),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -158,7 +160,7 @@ export function POSItemPanel({ transaction }: POSItemPanelProps) {
|
|||||||
>
|
>
|
||||||
<span className="font-medium text-sm line-clamp-2">{product.name}</span>
|
<span className="font-medium text-sm line-clamp-2">{product.name}</span>
|
||||||
<div className="mt-auto flex items-center justify-between w-full pt-1">
|
<div className="mt-auto flex items-center justify-between w-full pt-1">
|
||||||
<span className="text-base font-semibold">${parseFloat(product.sellingPrice ?? '0').toFixed(2)}</span>
|
<span className="text-base font-semibold">${parseFloat(product.price ?? '0').toFixed(2)}</span>
|
||||||
{product.sku && <span className="text-xs text-muted-foreground">{product.sku}</span>}
|
{product.sku && <span className="text-xs text-muted-foreground">{product.sku}</span>}
|
||||||
</div>
|
</div>
|
||||||
{product.isSerialized ? (
|
{product.isSerialized ? (
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
allowedHosts: ['dev.lunarfront.tech'],
|
||||||
proxy: {
|
proxy: {
|
||||||
'/v1': {
|
'/v1': {
|
||||||
target: 'http://localhost:8000',
|
target: 'http://localhost:8000',
|
||||||
|
|||||||
@@ -273,6 +273,36 @@ suite('POS', { tags: ['pos'] }, (t) => {
|
|||||||
|
|
||||||
// ─── Complete Transaction ──────────────────────────────────────────────────
|
// ─── Complete Transaction ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
t.test('rejects completing transaction without open drawer', { tags: ['transactions', 'complete', 'validation', 'drawer'] }, async () => {
|
||||||
|
// Ensure no drawer is open at LOCATION_ID
|
||||||
|
const current = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
|
||||||
|
if (current.status === 200 && current.data.id) {
|
||||||
|
await t.api.post(`/v1/drawer/${current.data.id}/close`, { closingBalance: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||||
|
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||||
|
description: 'No Drawer Item',
|
||||||
|
qty: 1,
|
||||||
|
unitPrice: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
||||||
|
paymentMethod: 'cash',
|
||||||
|
amountTendered: 20,
|
||||||
|
})
|
||||||
|
t.assert.status(res, 400)
|
||||||
|
|
||||||
|
// Void to clean up
|
||||||
|
await t.api.post(`/v1/transactions/${txn.data.id}/void`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Open a drawer for the remaining complete tests
|
||||||
|
t.test('opens drawer for complete tests', { tags: ['transactions', 'complete', 'setup'] }, async () => {
|
||||||
|
const res = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 200 })
|
||||||
|
t.assert.status(res, 201)
|
||||||
|
})
|
||||||
|
|
||||||
t.test('completes a cash transaction with change', { tags: ['transactions', 'complete', 'cash'] }, async () => {
|
t.test('completes a cash transaction with change', { tags: ['transactions', 'complete', 'cash'] }, async () => {
|
||||||
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||||
@@ -427,6 +457,18 @@ suite('POS', { tags: ['pos'] }, (t) => {
|
|||||||
|
|
||||||
// ─── Cash Rounding ─────────────────────────────────────────────────────────
|
// ─── Cash Rounding ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Close the LOCATION_ID drawer and open one at ROUNDING_LOCATION_ID
|
||||||
|
t.test('setup drawer for rounding tests', { tags: ['transactions', 'rounding', 'setup'] }, async () => {
|
||||||
|
// Close drawer at LOCATION_ID
|
||||||
|
const current = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
|
||||||
|
if (current.status === 200 && current.data.id) {
|
||||||
|
await t.api.post(`/v1/drawer/${current.data.id}/close`, { closingBalance: 200 })
|
||||||
|
}
|
||||||
|
// Open drawer at ROUNDING_LOCATION_ID
|
||||||
|
const res = await t.api.post('/v1/drawer/open', { locationId: ROUNDING_LOCATION_ID, openingBalance: 200 })
|
||||||
|
t.assert.status(res, 201)
|
||||||
|
})
|
||||||
|
|
||||||
t.test('cash rounding adjusts total to nearest nickel', { tags: ['transactions', 'rounding'] }, async () => {
|
t.test('cash rounding adjusts total to nearest nickel', { tags: ['transactions', 'rounding'] }, async () => {
|
||||||
// Create transaction at the rounding-enabled location
|
// Create transaction at the rounding-enabled location
|
||||||
const txn = await t.api.post('/v1/transactions', {
|
const txn = await t.api.post('/v1/transactions', {
|
||||||
@@ -484,6 +526,10 @@ suite('POS', { tags: ['pos'] }, (t) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.test('no rounding at non-rounding location', { tags: ['transactions', 'rounding'] }, async () => {
|
t.test('no rounding at non-rounding location', { tags: ['transactions', 'rounding'] }, async () => {
|
||||||
|
// Open drawer at LOCATION_ID for this test
|
||||||
|
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
||||||
|
t.assert.status(drawer, 201)
|
||||||
|
|
||||||
const txn = await t.api.post('/v1/transactions', {
|
const txn = await t.api.post('/v1/transactions', {
|
||||||
transactionType: 'sale',
|
transactionType: 'sale',
|
||||||
locationId: LOCATION_ID,
|
locationId: LOCATION_ID,
|
||||||
@@ -500,6 +546,17 @@ suite('POS', { tags: ['pos'] }, (t) => {
|
|||||||
})
|
})
|
||||||
t.assert.status(res, 200)
|
t.assert.status(res, 200)
|
||||||
t.assert.equal(parseFloat(res.data.roundingAdjustment), 0)
|
t.assert.equal(parseFloat(res.data.roundingAdjustment), 0)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Close rounding location drawer
|
||||||
|
t.test('cleanup rounding drawer', { tags: ['transactions', 'rounding', 'setup'] }, async () => {
|
||||||
|
const current = await t.api.get('/v1/drawer/current', { locationId: ROUNDING_LOCATION_ID })
|
||||||
|
if (current.status === 200 && current.data.id) {
|
||||||
|
await t.api.post(`/v1/drawer/${current.data.id}/close`, { closingBalance: 200 })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Full POS Flow ────────────────────────────────────────────────────────
|
// ─── Full POS Flow ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import postgres from 'postgres'
|
import postgres from 'postgres'
|
||||||
|
|
||||||
const DB_URL = process.env.DATABASE_URL ?? 'postgresql://lunarfront:lunarfront@localhost:5432/lunarfront'
|
const DB_URL = process.env.DATABASE_URL ?? 'postgresql://lunarfront:lunarfront@localhost:5432/lunarfront'
|
||||||
const COMPANY_ID = 'a0000000-0000-0000-0000-000000000001'
|
const COMPANY_ID = 'a0000000-0000-4000-8000-000000000001'
|
||||||
|
|
||||||
const sql = postgres(DB_URL)
|
const sql = postgres(DB_URL)
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ async function seed() {
|
|||||||
const [company] = await sql`SELECT id FROM company WHERE id = ${COMPANY_ID}`
|
const [company] = await sql`SELECT id FROM company WHERE id = ${COMPANY_ID}`
|
||||||
if (!company) {
|
if (!company) {
|
||||||
await sql`INSERT INTO company (id, name, timezone) VALUES (${COMPANY_ID}, 'Demo Store', 'America/Chicago')`
|
await sql`INSERT INTO company (id, name, timezone) VALUES (${COMPANY_ID}, 'Demo Store', 'America/Chicago')`
|
||||||
await sql`INSERT INTO location (id, name) VALUES ('a0000000-0000-0000-0000-000000000002', 'Main Store')`
|
await sql`INSERT INTO location (id, name, tax_rate, service_tax_rate) VALUES ('a0000000-0000-4000-8000-000000000002', 'Main Store', '0.0825', '0.0825')`
|
||||||
console.log(' Created company and location')
|
console.log(' Created company and location')
|
||||||
|
|
||||||
// Seed RBAC
|
// Seed RBAC
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import postgres from 'postgres'
|
import postgres from 'postgres'
|
||||||
|
|
||||||
const DB_URL = process.env.DATABASE_URL ?? 'postgresql://lunarfront:lunarfront@localhost:5432/lunarfront'
|
const DB_URL = process.env.DATABASE_URL ?? 'postgresql://lunarfront:lunarfront@localhost:5432/lunarfront'
|
||||||
const COMPANY_ID = 'a0000000-0000-0000-0000-000000000001'
|
const COMPANY_ID = 'a0000000-0000-4000-8000-000000000001'
|
||||||
|
|
||||||
const sql = postgres(DB_URL)
|
const sql = postgres(DB_URL)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
transactionLineItems,
|
transactionLineItems,
|
||||||
discountAudits,
|
discountAudits,
|
||||||
discounts,
|
discounts,
|
||||||
|
drawerSessions,
|
||||||
} from '../db/schema/pos.js'
|
} from '../db/schema/pos.js'
|
||||||
import { products, inventoryUnits } from '../db/schema/inventory.js'
|
import { products, inventoryUnits } from '../db/schema/inventory.js'
|
||||||
import { companies, locations } from '../db/schema/stores.js'
|
import { companies, locations } from '../db/schema/stores.js'
|
||||||
@@ -231,6 +232,18 @@ export const TransactionService = {
|
|||||||
if (!txn) throw new NotFoundError('Transaction')
|
if (!txn) throw new NotFoundError('Transaction')
|
||||||
if (txn.status !== 'pending') throw new ConflictError('Transaction is not pending')
|
if (txn.status !== 'pending') throw new ConflictError('Transaction is not pending')
|
||||||
|
|
||||||
|
// Require an open drawer session at the transaction's location
|
||||||
|
if (txn.locationId) {
|
||||||
|
const [openDrawer] = await db
|
||||||
|
.select({ id: drawerSessions.id })
|
||||||
|
.from(drawerSessions)
|
||||||
|
.where(and(eq(drawerSessions.locationId, txn.locationId), eq(drawerSessions.status, 'open')))
|
||||||
|
.limit(1)
|
||||||
|
if (!openDrawer) {
|
||||||
|
throw new ValidationError('Cannot complete transaction without an open drawer at this location')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate cash payment (with optional nickel rounding)
|
// Validate cash payment (with optional nickel rounding)
|
||||||
let changeGiven: string | undefined
|
let changeGiven: string | undefined
|
||||||
let roundingAdjustment = 0
|
let roundingAdjustment = 0
|
||||||
|
|||||||
Reference in New Issue
Block a user