diff --git a/apps/api/prisma/migrations/20261027120000_product_buy_price/migration.sql b/apps/api/prisma/migrations/20261027120000_product_buy_price/migration.sql new file mode 100644 index 0000000..8720462 --- /dev/null +++ b/apps/api/prisma/migrations/20261027120000_product_buy_price/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Product" ADD COLUMN "buyPriceInCents" INTEGER; diff --git a/apps/api/prisma/migrations/20261027130000_product_desired_stock/migration.sql b/apps/api/prisma/migrations/20261027130000_product_desired_stock/migration.sql new file mode 100644 index 0000000..4780469 --- /dev/null +++ b/apps/api/prisma/migrations/20261027130000_product_desired_stock/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Product" ADD COLUMN "desiredStock" INTEGER; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c84bed9..2b877bb 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -212,13 +212,18 @@ model Product { productId String name String category String? + // The sell price: what the terminals charge priceInCents Int + // What the bar pays for one; null when not entered. Kept on the dashboard only, the terminals never get it + buyPriceInCents Int? // Null when stock is not tracked for this product. Can be negative: a sale is never blocked. stock Int? // When the stock was last counted; line events that happened before it are already in the count countedAt DateTime? // Warn when the stock drops to this level lowStockThreshold Int? + // How many the bar wants in stock; the dashboard shows what to order to get there. Not sent to the terminals + desiredStock Int? // When it was created or last changed reportedAt DateTime // Deleted on the dashboard: hidden on the terminals, kept because tabs and history refer to it diff --git a/apps/api/src/admin/admin.service.ts b/apps/api/src/admin/admin.service.ts index b1de315..15e78fc 100644 --- a/apps/api/src/admin/admin.service.ts +++ b/apps/api/src/admin/admin.service.ts @@ -153,7 +153,10 @@ export class AdminService { if (copies.length > 0) { await tx.product.createMany({ data: copies.map((p) => { - const fields = { name: p.name, category: p.category, priceInCents: p.priceInCents, lowStockThreshold: p.lowStockThreshold }; + const fields = { + name: p.name, category: p.category, priceInCents: p.priceInCents, buyPriceInCents: p.buyPriceInCents, + lowStockThreshold: p.lowStockThreshold, desiredStock: p.desiredStock, + }; return { venueId, productId: uuidv4(), diff --git a/apps/api/src/devices/devices.service.spec.ts b/apps/api/src/devices/devices.service.spec.ts index 725a1df..af9eb22 100644 --- a/apps/api/src/devices/devices.service.spec.ts +++ b/apps/api/src/devices/devices.service.spec.ts @@ -7,8 +7,12 @@ import { StockService } from '../stock/stock.service'; type Device = { id: string; venueId: string; revokedAt: null; lastSeenAt: Date }; type SyncResult = Awaited>; -// In the past: a time ahead of the server clock would be capped -const at = (minutes: number) => new Date(Date.UTC(2026, 8, 24, 20, minutes)).toISOString(); +// Yesterday at 20:00 UTC. In the past, as a time ahead of the server clock would be capped, and recent, as a new +// tablet's first settlements feed only goes back a few days +const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); +const at = (minutes: number) => new Date(Date.UTC( + yesterday.getUTCFullYear(), yesterday.getUTCMonth(), yesterday.getUTCDate(), 20, minutes, +)).toISOString(); function setup({ seeded = true } = {}) { const prisma = createFakePrisma(); @@ -509,6 +513,16 @@ describe('DevicesService.sync: website product changes reach the tablets', () => expect(result.stock).toEqual([expect.objectContaining({ productId: created.productId, stock: 24 })]); }); + it('never sends the buy price or desired stock to the tablets', async () => { + const { sync, tabletA, stock } = setupWithWebsite(); + + await stock.createProduct('v1', { name: 'Ice Tea', priceInCents: 280, buyPriceInCents: 110, stock: 4, desiredStock: 24 }, admin); + const result = await sync(tabletA); + + expect(result.catalog![0]).not.toHaveProperty('buyPriceInCents'); + expect(result.catalog![0]).not.toHaveProperty('desiredStock'); + }); + it('marks an archived product, and still counts a sale from a tablet that had not synced yet', async () => { const { sync, tabletA, stock, prisma } = setupWithWebsite(); addProduct(prisma, 'cola', { stock: 20, countedAt: new Date('2026-01-01') }); diff --git a/apps/api/src/stock/dto/update-product.dto.ts b/apps/api/src/stock/dto/update-product.dto.ts index c0eb36f..7f56821 100644 --- a/apps/api/src/stock/dto/update-product.dto.ts +++ b/apps/api/src/stock/dto/update-product.dto.ts @@ -21,12 +21,20 @@ export class CreateProductDto { @MaxLength(100) category?: string | null; - @ApiProperty({ example: 250 }) + @ApiProperty({ example: 250, description: 'The sell price, charged on the terminals' }) @IsInt() @Min(0) @Max(1_000_000) priceInCents: number; + @ApiPropertyOptional({ example: 120, nullable: true, description: 'What the bar pays for one; null for not entered. Not sent to the terminals' }) + @IsOptional() + @ValidateIf((dto: CreateProductDto) => dto.buyPriceInCents !== null) + @IsInt() + @Min(0) + @Max(1_000_000) + buyPriceInCents?: number | null; + @ApiPropertyOptional({ example: 24, nullable: true, description: 'The starting count; null or left out: stock is not tracked' }) @IsOptional() @ValidateIf((dto: CreateProductDto) => dto.stock !== null) @@ -42,6 +50,14 @@ export class CreateProductDto { @Min(0) @Max(100000) lowStockThreshold?: number | null; + + @ApiPropertyOptional({ example: 100, nullable: true, description: 'How many to have in stock; null for none. Not sent to the terminals' }) + @IsOptional() + @ValidateIf((dto: CreateProductDto) => dto.desiredStock !== null) + @IsInt() + @Min(0) + @Max(100000) + desiredStock?: number | null; } /** Only the fields that are sent change. The stock itself changes through stock adjustments. */ @@ -69,6 +85,14 @@ export class UpdateProductDto { @Max(1_000_000) priceInCents?: number; + @ApiPropertyOptional({ example: 120, nullable: true, description: 'What the bar pays for one; null for not entered. Not sent to the terminals' }) + @IsOptional() + @ValidateIf((dto: UpdateProductDto) => dto.buyPriceInCents !== null) + @IsInt() + @Min(0) + @Max(1_000_000) + buyPriceInCents?: number | null; + @ApiPropertyOptional({ example: 12, nullable: true, description: 'Warn when stock drops to this level; null for no warning' }) @IsOptional() @ValidateIf((dto: UpdateProductDto) => dto.lowStockThreshold !== null) @@ -77,6 +101,14 @@ export class UpdateProductDto { @Max(100000) lowStockThreshold?: number | null; + @ApiPropertyOptional({ example: 100, nullable: true, description: 'How many to have in stock; null for none. Not sent to the terminals' }) + @IsOptional() + @ValidateIf((dto: UpdateProductDto) => dto.desiredStock !== null) + @IsInt() + @Min(0) + @Max(100000) + desiredStock?: number | null; + @ApiPropertyOptional({ description: 'true hides the product on the terminals (deleting it); false brings it back' }) @IsOptional() @IsBoolean() diff --git a/apps/api/src/stock/product-fields.ts b/apps/api/src/stock/product-fields.ts index fbd8426..97b3b87 100644 --- a/apps/api/src/stock/product-fields.ts +++ b/apps/api/src/stock/product-fields.ts @@ -1,14 +1,16 @@ import { Prisma } from '@prisma/client'; /** The fields of a product that the dashboard and the terminals change; stock changes through its own rules. */ -export const PRODUCT_FIELDS = ['name', 'category', 'priceInCents', 'lowStockThreshold', 'archived', 'imageId'] as const; +export const PRODUCT_FIELDS = ['name', 'category', 'priceInCents', 'buyPriceInCents', 'lowStockThreshold', 'desiredStock', 'archived', 'imageId'] as const; export type ProductField = (typeof PRODUCT_FIELDS)[number]; export type ProductChanges = { name?: string; category?: string | null; priceInCents?: number; + buyPriceInCents?: number | null; lowStockThreshold?: number | null; + desiredStock?: number | null; archived?: boolean; imageId?: string | null; }; diff --git a/apps/api/src/stock/stock.service.spec.ts b/apps/api/src/stock/stock.service.spec.ts index b07d421..4a55dd9 100644 --- a/apps/api/src/stock/stock.service.spec.ts +++ b/apps/api/src/stock/stock.service.spec.ts @@ -145,6 +145,32 @@ describe('StockService products', () => { expect(prisma.product.rows[0]).toMatchObject({ name: 'Cola', priceInCents: 300, category: null, stock: 10, archivedAt: null }); }); + it('keeps a buy price next to the sell price, and clears it with null', async () => { + const { service, prisma } = createWithVenue([]); + + const product = await service.createProduct('v1', { name: 'Water', priceInCents: 200, buyPriceInCents: 80 }, admin); + expect(product).toMatchObject({ priceInCents: 200, buyPriceInCents: 80 }); + + await service.updateProduct('v1', product.productId, { buyPriceInCents: 90 }, admin); + expect(prisma.product.rows[0]).toMatchObject({ priceInCents: 200, buyPriceInCents: 90 }); + + const cleared = await service.updateProduct('v1', product.productId, { buyPriceInCents: null }, admin); + expect(cleared.buyPriceInCents).toBeNull(); + }); + + it('keeps a desired stock, and clears it with null', async () => { + const { service, prisma } = createWithVenue([]); + + const product = await service.createProduct('v1', { name: 'Cola', priceInCents: 250, stock: 43, desiredStock: 100 }, admin); + expect(product).toMatchObject({ stock: 43, desiredStock: 100 }); + + await service.updateProduct('v1', product.productId, { desiredStock: 120 }, admin); + expect(prisma.product.rows[0]).toMatchObject({ stock: 43, desiredStock: 120 }); + + const cleared = await service.updateProduct('v1', product.productId, { desiredStock: null }, admin); + expect(cleared.desiredStock).toBeNull(); + }); + it('archives a product and brings it back, unless its name was taken meanwhile', async () => { const { service, prisma } = createWithVenue([cola]); @@ -212,6 +238,6 @@ describe('StockService pictures and field times', () => { const created = await service.createProduct('v1', { name: 'Water', priceInCents: 200 }, admin); const times = prisma.product.rows.find((p) => p.productId === created.productId)!.fieldUpdatedAt; - expect(Object.keys(times).sort()).toEqual(['archived', 'category', 'lowStockThreshold', 'name', 'priceInCents']); + expect(Object.keys(times).sort()).toEqual(['archived', 'buyPriceInCents', 'category', 'desiredStock', 'lowStockThreshold', 'name', 'priceInCents']); }); }); diff --git a/apps/api/src/stock/stock.service.ts b/apps/api/src/stock/stock.service.ts index 27a8359..cad34b2 100644 --- a/apps/api/src/stock/stock.service.ts +++ b/apps/api/src/stock/stock.service.ts @@ -172,7 +172,9 @@ export class StockService { name: dto.name, category: dto.category ?? null, priceInCents: dto.priceInCents, + buyPriceInCents: dto.buyPriceInCents ?? null, lowStockThreshold: dto.lowStockThreshold ?? null, + desiredStock: dto.desiredStock ?? null, }; const created = await tx.product.create({ data: { @@ -214,7 +216,7 @@ export class StockService { } /** - * Changes a product's name, category, price, warning level, or archives it (a deleted product stays, hidden, because + * Changes a product's name, category, sell or buy price, warning level, desired stock, or archives it (a deleted product stays, hidden, because * tabs and history refer to it). Lines already on a tab keep the name and price they were rung up with. */ async updateProduct(venueId: string, productId: string, dto: UpdateProductDto, user: { id: string }) { @@ -238,7 +240,9 @@ export class StockService { ...(dto.name != null ? { name: dto.name } : {}), ...(dto.category !== undefined ? { category: dto.category } : {}), ...(dto.priceInCents != null ? { priceInCents: dto.priceInCents } : {}), + ...(dto.buyPriceInCents !== undefined ? { buyPriceInCents: dto.buyPriceInCents } : {}), ...(dto.lowStockThreshold !== undefined ? { lowStockThreshold: dto.lowStockThreshold } : {}), + ...(dto.desiredStock !== undefined ? { desiredStock: dto.desiredStock } : {}), ...(dto.archived !== undefined ? { archived: dto.archived } : {}), }, maxDate(now, existing.fieldUpdatedAt), existing); @@ -306,8 +310,8 @@ function maxDate(now: Date, times: unknown): Date { } function productView(p: { - productId: string; name: string; category: string | null; priceInCents: number; stock: number | null; - lowStockThreshold: number | null; countedAt: Date | null; reportedAt: Date; archivedAt: Date | null; + productId: string; name: string; category: string | null; priceInCents: number; buyPriceInCents?: number | null; stock: number | null; + lowStockThreshold: number | null; desiredStock?: number | null; countedAt: Date | null; reportedAt: Date; archivedAt: Date | null; imageId?: string | null; }) { return { @@ -315,8 +319,10 @@ function productView(p: { name: p.name, category: p.category, priceInCents: p.priceInCents, + buyPriceInCents: p.buyPriceInCents ?? null, stock: p.stock, lowStockThreshold: p.lowStockThreshold, + desiredStock: p.desiredStock ?? null, // The latest count, on the dashboard or a terminal: how fresh the stock is countedAt: p.countedAt, reportedAt: p.reportedAt, diff --git a/apps/api/src/tabs/tabs.controller.ts b/apps/api/src/tabs/tabs.controller.ts index df42818..c7510ff 100644 --- a/apps/api/src/tabs/tabs.controller.ts +++ b/apps/api/src/tabs/tabs.controller.ts @@ -19,6 +19,27 @@ export class TabsController { return this.tabsService.findAll(venueId); } + @Get('history') + @ApiOperation({ summary: 'List paid tabs, newest first' }) + @ApiQuery({ name: 'search', required: false, description: 'Part of the customer name' }) + @ApiQuery({ name: 'paymentMethod', required: false }) + @ApiQuery({ name: 'limit', required: false }) + @ApiQuery({ name: 'offset', required: false }) + async history( + @Param('venueId') venueId: string, + @Query('search') search?: string, + @Query('paymentMethod') paymentMethod?: string, + @Query('limit') limit?: string, + @Query('offset') offset?: string, + ) { + return this.tabsService.history(venueId, { + search, + paymentMethod, + limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200), + offset: Math.max(parseInt(offset ?? '', 10) || 0, 0), + }); + } + @Delete(':tabId') @Roles('admin', 'manager') @ApiOperation({ summary: 'Delete an open tab without settling it' }) diff --git a/apps/api/src/tabs/tabs.service.spec.ts b/apps/api/src/tabs/tabs.service.spec.ts new file mode 100644 index 0000000..769b13a --- /dev/null +++ b/apps/api/src/tabs/tabs.service.spec.ts @@ -0,0 +1,64 @@ +import { TabsService } from './tabs.service'; +import { createFakePrisma } from '../testing/fake-prisma'; + +const at = (minutes: number) => new Date(Date.UTC(2026, 9, 27, 20, minutes)); + +function setup() { + const prisma = createFakePrisma(); + prisma.device.rows.push({ id: 'd1', venueId: 'v1', name: 'Bar tablet' }); + const service = new TabsService(prisma as any, { notify: jest.fn() } as any); + + const settle = (id: string, customerName: string, minutes: number, extra: Record = {}) => + prisma.settlement.create({ + data: { + id, venueId: 'v1', tabId: `tab-${id}`, customerName, settledAt: at(minutes), paymentMethod: 'cash', + amountInCents: 500, deviceId: 'd1', requestedBy: null, + lines: [{ + id: 'l1', productId: 'cola', productName: 'Cola', unitPriceInCents: 250, quantity: 2, + units: [ + { id: 'u1', addedAt: at(0).toISOString(), removedAt: null }, + { id: 'u2', addedAt: at(0).toISOString(), removedAt: null }, + { id: 'u3', addedAt: at(0).toISOString(), removedAt: at(1).toISOString() }, + ], + }], + ...extra, + }, + }); + + return { prisma, service, settle }; +} + +describe('TabsService.history', () => { + it('lists paid tabs newest first, with their lines and who settled them', async () => { + const { service, settle } = setup(); + await settle('s1', 'Alice', 1); + await settle('s2', 'Bob', 5, { deviceId: null, requestedBy: 'manager@bar.be', paymentMethod: 'payconiq' }); + await settle('other', 'Eve', 9, { venueId: 'v2' }); + + const result = await service.history('v1', { limit: 50, offset: 0 }); + + expect(result.total).toBe(2); + expect(result.totalAmountInCents).toBe(1000); + expect(result.settlements.map((s) => s.id)).toEqual(['s2', 's1']); + expect(result.settlements[0]).toMatchObject({ settledBy: 'manager@bar.be', onDashboard: true, paymentMethod: 'payconiq' }); + expect(result.settlements[1]).toMatchObject({ settledBy: 'Bar tablet', onDashboard: false, itemCount: 2 }); + expect(result.settlements[1].items).toEqual([{ productName: 'Cola', quantity: 2, unitPriceInCents: 250, removed: 1 }]); + }); + + it('filters by part of the name and payment method, and pages', async () => { + const { service, settle } = setup(); + await settle('s1', 'Alice', 1); + await settle('s2', 'alicia', 2, { paymentMethod: 'payconiq' }); + await settle('s3', 'Bob', 3); + + const byName = await service.history('v1', { search: ' ALI ', limit: 50, offset: 0 }); + expect(byName.settlements.map((s) => s.id)).toEqual(['s2', 's1']); + + const byMethod = await service.history('v1', { search: 'ali', paymentMethod: 'cash', limit: 50, offset: 0 }); + expect(byMethod.settlements.map((s) => s.id)).toEqual(['s1']); + + const secondPage = await service.history('v1', { limit: 2, offset: 2 }); + expect(secondPage.total).toBe(3); + expect(secondPage.settlements.map((s) => s.id)).toEqual(['s1']); + }); +}); diff --git a/apps/api/src/tabs/tabs.service.ts b/apps/api/src/tabs/tabs.service.ts index d91b85a..1810b09 100644 --- a/apps/api/src/tabs/tabs.service.ts +++ b/apps/api/src/tabs/tabs.service.ts @@ -1,9 +1,10 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import { v4 as uuidv4 } from 'uuid'; import { PrismaService } from '../prisma/prisma.service'; import { RealtimeService } from '../realtime/realtime.service'; import { customerNameKey } from '../planned-tabs/customer-name'; -import { tabSummary } from './tab-state'; +import { SettledLine, tabSummary } from './tab-state'; import { VenueLedger } from './venue-ledger'; @Injectable() @@ -37,6 +38,65 @@ export class TabsService { }; } + /** + * The venue's paid tabs, newest first, as the terminals' history shows them: every settlement made on a terminal + * or on the dashboard. [search] matches the customer name. + */ + async history( + venueId: string, + options: { search?: string; paymentMethod?: string; limit: number; offset: number }, + ) { + const search = options.search?.trim(); + const where: Prisma.SettlementWhereInput = { + venueId, + ...(search ? { customerName: { contains: search, mode: 'insensitive' as const } } : {}), + ...(options.paymentMethod ? { paymentMethod: options.paymentMethod } : {}), + }; + + const [rows, total, sum] = await Promise.all([ + this.prisma.settlement.findMany({ + where, + orderBy: [{ settledAt: 'desc' }, { sequence: 'desc' }], + skip: options.offset, + take: options.limit, + }), + this.prisma.settlement.count({ where }), + this.prisma.settlement.aggregate({ where, _sum: { amountInCents: true } }), + ]); + + const deviceIds = [...new Set(rows.map((row) => row.deviceId).filter((id): id is string => id !== null))]; + const devices = deviceIds.length > 0 + ? await this.prisma.device.findMany({ where: { id: { in: deviceIds } }, select: { id: true, name: true } }) + : []; + const deviceNames = new Map(devices.map((device) => [device.id, device.name])); + + return { + settlements: rows.map((row) => { + const items = (row.lines as unknown as SettledLine[]).map((line) => ({ + productName: line.productName, + quantity: line.quantity, + unitPriceInCents: line.unitPriceInCents, + // Rung up and taken off again before paying + removed: line.units.filter((unit) => unit.removedAt !== null).length, + })); + return { + id: row.id, + tabId: row.tabId, + customerName: row.customerName, + settledAt: row.settledAt, + paymentMethod: row.paymentMethod, + amountInCents: row.amountInCents, + itemCount: items.reduce((count, item) => count + item.quantity, 0), + items, + settledBy: (row.deviceId ? deviceNames.get(row.deviceId) : row.requestedBy) ?? null, + onDashboard: row.deviceId === null, + }; + }), + total, + totalAmountInCents: sum._sum.amountInCents ?? 0, + }; + } + /** * Deletes a tab without settling it, as tab_deleted does on a terminal: what is still on it is not paid and stays * out of the stock. The terminals drop it on their next sync; one that still has unsent drinks for it brings it diff --git a/apps/api/src/testing/fake-prisma.ts b/apps/api/src/testing/fake-prisma.ts index e48f939..b649a20 100644 --- a/apps/api/src/testing/fake-prisma.ts +++ b/apps/api/src/testing/fake-prisma.ts @@ -1,7 +1,7 @@ /** * An in-memory stand-in for the Prisma client, for tests that follow a venue through several syncs. It supports the - * queries the tab, stock and device services use: equality, `in`, `notIn`, `not`, `gt`, `gte`, `lte` and compound - * unique keys, `orderBy` on one or more fields, `take` and `select`. Transactions run directly on the same data. + * queries the tab, stock and device services use: equality, `in`, `notIn`, `not`, `gt`, `gte`, `lte`, `contains` and + * compound unique keys, `orderBy` on one or more fields, `skip`, `take`, `select`, `count` and `_max`/`_sum`. Transactions run directly on the same data. */ import { randomUUID } from 'crypto'; @@ -28,6 +28,10 @@ function matches(row: Row, where: Row = {}): boolean { case 'gt': return value !== null && value > operand; case 'gte': return value !== null && value >= operand; case 'lte': return value !== null && value <= operand; + case 'contains': return typeof value === 'string' && (condition.mode === 'insensitive' + ? value.toLowerCase().includes(operand.toLowerCase()) + : value.includes(operand)); + case 'mode': return true; default: throw new Error(`fake-prisma: unsupported operator ${op}`); } }); @@ -88,7 +92,12 @@ class FakeTable { async findMany(args: Row = {}) { const found = sort(this.rows.filter((row) => matches(row, args.where)), args.orderBy); - return found.slice(0, args.take ?? found.length).map((row) => pick(row, args.select)); + const skip = args.skip ?? 0; + return found.slice(skip, skip + (args.take ?? found.length)).map((row) => pick(row, args.select)); + } + + async count(args: Row = {}) { + return this.rows.filter((row) => matches(row, args.where)).length; } async findUnique(args: Row) { @@ -148,7 +157,11 @@ class FakeTable { field, found.length === 0 ? null : Math.max(...found.map((row) => row[field])), ])); - return { _max: max }; + const sum = Object.fromEntries(Object.keys(args._sum ?? {}).map((field) => [ + field, + found.length === 0 ? null : found.reduce((total, row) => total + row[field], 0), + ])); + return { _max: max, _sum: sum }; } } diff --git a/apps/dashboard/src/app/dashboard/admin/devices-section.tsx b/apps/dashboard/src/app/dashboard/admin/devices-section.tsx index 2456cd0..0adf86d 100644 --- a/apps/dashboard/src/app/dashboard/admin/devices-section.tsx +++ b/apps/dashboard/src/app/dashboard/admin/devices-section.tsx @@ -8,6 +8,9 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { SortableHead } from '@/components/ui/sortable-head'; +import { useSort } from '@/lib/use-sort'; +import { getDeviceStatus } from '@/lib/device-status'; import { DeviceStatusBadge } from '@/components/device-status-badge'; import { ConfirmDialog } from '@/components/confirm-dialog'; import { LoadingState } from '@/components/loading-state'; @@ -16,11 +19,15 @@ import { toast } from '@/components/ui/toast'; import { useLang } from '@/lib/i18n'; import { Checkbox } from './shared'; +// Sorting on status puts the terminals that are online first +const STATUS_ORDER = ['online', 'recently_seen', 'stale', 'offline', 'revoked']; + export function DevicesSection() { const queryClient = useQueryClient(); const { t, te, relativeTime, dateTime } = useLang(); const [showUnpaired, setShowUnpaired] = useState(false); const [unpairing, setUnpairing] = useState(null); + const sort = useSort<'name' | 'venue' | 'status' | 'lastSeen' | 'tokenExpires' | 'pending'>(); const { data, isLoading, error } = useQuery({ queryKey: ['admin', 'devices'], @@ -54,7 +61,14 @@ export function DevicesSection() { if (error) return ; const all = data?.devices ?? []; - const devices = showUnpaired ? all : all.filter((d) => !d.revokedAt); + const devices = sort.apply(showUnpaired ? all : all.filter((d) => !d.revokedAt), { + name: (d) => d.name, + venue: (d) => d.venueName, + status: (d) => STATUS_ORDER.indexOf(getDeviceStatus(d.lastSeenAt, d.revokedAt)), + lastSeen: (d) => new Date(d.lastSeenAt), + tokenExpires: (d) => new Date(d.tokenExpiresAt), + pending: (d) => d.pendingCommands, + }); const now = Date.now(); return ( @@ -70,12 +84,12 @@ export function DevicesSection() { - {t('admin.devices.name')} - {t('admin.devices.venue')} - {t('admin.devices.status')} - {t('admin.devices.last_seen')} - {t('admin.devices.token_expires')} - {t('admin.devices.pending')} + {t('admin.devices.name')} + {t('admin.devices.venue')} + {t('admin.devices.status')} + {t('admin.devices.last_seen')} + {t('admin.devices.token_expires')} + {t('admin.devices.pending')} diff --git a/apps/dashboard/src/app/dashboard/admin/users-section.tsx b/apps/dashboard/src/app/dashboard/admin/users-section.tsx index 3bff47f..23da6de 100644 --- a/apps/dashboard/src/app/dashboard/admin/users-section.tsx +++ b/apps/dashboard/src/app/dashboard/admin/users-section.tsx @@ -10,6 +10,8 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { SortableHead } from '@/components/ui/sortable-head'; +import { useSort } from '@/lib/use-sort'; import { ConfirmDialog } from '@/components/confirm-dialog'; import { LoadingState } from '@/components/loading-state'; import { ErrorState } from '@/components/error-state'; @@ -28,6 +30,7 @@ export function UsersSection() { const [action, setAction] = useState<{ kind: 'password' | 'logout' | 'delete'; user: AdminUser } | null>(null); const [password, setPassword] = useState(''); const [logoutAll, setLogoutAll] = useState(false); + const sort = useSort<'name' | 'venue' | 'role' | 'sessions' | 'created'>(); const { data, isLoading, error } = useQuery({ queryKey: ['admin', 'users'], queryFn: adminApi.users }); @@ -73,8 +76,14 @@ export function UsersSection() { if (error) return ; const query = search.trim().toLowerCase(); - const users = (data?.users ?? []).filter((u) => - !query || [u.name, u.email, u.venueName].some((field) => field.toLowerCase().includes(query))); + const users = sort.apply((data?.users ?? []).filter((u) => + !query || [u.name, u.email, u.venueName].some((field) => field.toLowerCase().includes(query))), { + name: (u) => u.name, + venue: (u) => u.venueName, + role: (u) => t(`users.role.${u.role}`), + sessions: (u) => u.activeSessions, + created: (u) => new Date(u.createdAt), + }); return (
@@ -94,11 +103,11 @@ export function UsersSection() {
- {t('users.name')} - {t('admin.devices.venue')} - {t('users.role')} - {t('admin.users.sessions')} - {t('users.created')} + {t('users.name')} + {t('admin.devices.venue')} + {t('users.role')} + {t('admin.users.sessions')} + {t('users.created')} diff --git a/apps/dashboard/src/app/dashboard/admin/venues-section.tsx b/apps/dashboard/src/app/dashboard/admin/venues-section.tsx index 7af9195..e92dc5e 100644 --- a/apps/dashboard/src/app/dashboard/admin/venues-section.tsx +++ b/apps/dashboard/src/app/dashboard/admin/venues-section.tsx @@ -11,7 +11,9 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Table, TableBody, TableCell, TableHeader, TableRow } from '@/components/ui/table'; +import { SortableHead } from '@/components/ui/sortable-head'; +import { useSort } from '@/lib/use-sort'; import { LoadingState } from '@/components/loading-state'; import { ErrorState } from '@/components/error-state'; import { toast } from '@/components/ui/toast'; @@ -31,6 +33,7 @@ export function VenuesSection() { const { t, te, relativeTime } = useLang(); const [selectedId, setSelectedId] = useState(null); const [creating, setCreating] = useState(false); + const sort = useSort<'name' | 'users' | 'terminals' | 'openTabs' | 'products' | 'lastSync'>(); const { data, isLoading, error } = useQuery({ queryKey: ['admin', 'venues'], queryFn: adminApi.venues }); const venues = data?.venues ?? []; @@ -57,16 +60,23 @@ export function VenuesSection() {
- {t('admin.venues.name')} - {t('admin.venues.users')} - {t('admin.venues.terminals')} - {t('admin.venues.open_tabs')} - {t('admin.venues.products')} - {t('admin.venues.last_sync')} + {t('admin.venues.name')} + {t('admin.venues.users')} + {t('admin.venues.terminals')} + {t('admin.venues.open_tabs')} + {t('admin.venues.products')} + {t('admin.venues.last_sync')} - {venues.map((venue) => ( + {sort.apply(venues, { + name: (v) => v.name, + users: (v) => v.users, + terminals: (v) => v.devices, + openTabs: (v) => v.openTabs, + products: (v) => v.products, + lastSync: (v) => (v.lastSyncAt ? new Date(v.lastSyncAt) : null), + }).map((venue) => ( >(new Set()); + + // The search runs on the server, so wait until typing stops + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(search.trim()); + setPage(0); + }, 300); + return () => clearTimeout(timer); + }, [search]); + + const { data, isLoading, error } = useQuery({ + queryKey: ['tab-history', venueId, debouncedSearch, paymentMethod, page], + queryFn: () => tabsApi.history(venueId, { search: debouncedSearch, paymentMethod, limit, offset: page * limit }), + placeholderData: keepPreviousData, + refetchInterval: live ? false : 10000, + }); + + if (isLoading) return ; + if (error) return ; + + const settlements = data?.settlements ?? []; + const total = data?.total ?? 0; + const totalPages = Math.ceil(total / limit); + const filtered = debouncedSearch !== '' || paymentMethod !== ''; + + function toggle(id: string) { + setExpanded((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + } + + return ( +
+ queryClient.invalidateQueries({ queryKey: ['tab-history'] })}>{t('common.refresh')}} + /> + + {total === 0 && !filtered ? ( + + ) : ( + <> +
+
+ + setSearch(e.target.value)} + className="pl-8" + aria-label={t('history.search_placeholder')} + /> +
+
+ {paymentFilters.map((f) => ( + + ))} +
+
+ +
+
+ + + {t('history.customer')} + {t('history.paid_at')} + {t('history.amount')} + {t('history.items')} + {t('history.payment')} + {t('history.settled_by')} + + + + {settlements.length === 0 && ( + + + {t('history.no_matches')} + + + )} + {settlements.map((paid) => { + const open = expanded.has(paid.id); + return ( + + + + + + {dateTime(paid.settledAt)} + {formatCents(paid.amountInCents)} + {paid.itemCount} + + + {paid.onDashboard ? t('history.dashboard', { name: paid.settledBy ?? '—' }) : paid.settledBy ?? '—'} + + + {open && ( + + + {/* Pinned to the visible area so lines stay readable while the table scrolls sideways on phones */} +
+ +
+
+
+ )} +
+ ); + })} +
+
+ + + {totalPages > 1 && ( +
+

+ {t('history.page', { page: page + 1, total: totalPages })} +

+
+ + +
+
+ )} + + )} + + ); +} + +/** The paid lines, e.g. "10× Cola · €2.50 each · €25.00", with what was taken off before paying. */ +function PaidLines({ items }: { items: PaidTab['items'] }) { + const { t } = useLang(); + if (items.length === 0) { + return

{t('tabs.lines_empty')}

; + } + return ( +
    + {items.map((item, i) => ( +
  • + + {item.quantity}× {item.productName} + · {formatCents(item.unitPriceInCents)} {t('tabs.each')} + {item.removed > 0 && · {t('history.removed', { count: item.removed })}} + + {formatCents(item.quantity * item.unitPriceInCents)} +
  • + ))} +
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/layout.tsx b/apps/dashboard/src/app/dashboard/layout.tsx index bd5dde3..6c24769 100644 --- a/apps/dashboard/src/app/dashboard/layout.tsx +++ b/apps/dashboard/src/app/dashboard/layout.tsx @@ -17,6 +17,7 @@ import { UserCog, ShieldAlert, Eye, + History, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/cn'; @@ -34,6 +35,7 @@ type Access = 'all' | 'admin' | 'superadmin'; const navItems: { href: string; key: string; icon: typeof LayoutDashboard; access: Access }[] = [ { href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, access: 'all' }, { href: '/dashboard/tabs', key: 'nav.tabs', icon: ClipboardList, access: 'all' }, + { href: '/dashboard/history', key: 'nav.history', icon: History, access: 'all' }, { href: '/dashboard/reminders', key: 'nav.reminders', icon: Bell, access: 'all' }, { href: '/dashboard/stock', key: 'nav.stock', icon: Package, access: 'all' }, { href: '/dashboard/users', key: 'nav.users', icon: Monitor, access: 'admin' }, diff --git a/apps/dashboard/src/app/dashboard/reminders/page.tsx b/apps/dashboard/src/app/dashboard/reminders/page.tsx index ac32acb..f5440aa 100644 --- a/apps/dashboard/src/app/dashboard/reminders/page.tsx +++ b/apps/dashboard/src/app/dashboard/reminders/page.tsx @@ -6,6 +6,8 @@ import { remindersApi, type ReminderCandidate } from '@/lib/api/client'; import { PageHeader } from '@/components/page-header'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { SortableHead } from '@/components/ui/sortable-head'; +import { useSort } from '@/lib/use-sort'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; @@ -36,6 +38,8 @@ export default function RemindersPage() { // Null until the admin changes the selection: then every eligible customer is selected const [picked, setPicked] = useState | null>(null); const [historyFor, setHistoryFor] = useState(null); + const historySort = useSort<'customer' | 'email' | 'amount' | 'sentAt' | 'status'>(); + const candidateSort = useSort<'customer' | 'email' | 'amount' | 'count' | 'lastSent' | 'status'>(); const candidatesQuery = useQuery({ queryKey: ['reminder-candidates', venueId], @@ -71,9 +75,17 @@ export default function RemindersPage() { const selectedInCooldown = selectedCandidates.filter((c) => !c.eligible).length; const query = search.trim().toLowerCase(); - const visible = query + const visible = candidateSort.apply(query ? candidates.filter((c) => c.customerName.toLowerCase().includes(query) || c.email?.toLowerCase().includes(query)) - : candidates; + : candidates, { + customer: (c) => c.customerName, + email: (c) => c.email, + amount: (c) => c.amountInCents, + count: (c) => c.reminderCount, + lastSent: (c) => (c.lastReminderAt ? new Date(c.lastReminderAt) : null), + // Ready to send first, then by when the cooldown ends; without an email last + status: (c) => (c.email ? (c.eligible ? 0 : new Date(c.nextEligibleAt!).getTime()) : null), + }); const visibleSendable = visible.filter((c) => c.email); const allVisibleSelected = visibleSendable.length > 0 && visibleSendable.every((c) => selected.has(c.tabId)); @@ -122,7 +134,13 @@ export default function RemindersPage() { return {tn('reminders.state_cooldown', days)}; }; - const reminders = historyQuery.data?.reminders ?? []; + const reminders = historySort.apply(historyQuery.data?.reminders ?? [], { + customer: (r) => r.customerName, + email: (r) => r.email, + amount: (r) => r.amountInCents, + sentAt: (r) => new Date(r.sentAt), + status: (r) => r.status, + }); const personReminders = personHistoryQuery.data?.reminders ?? []; return ( @@ -152,11 +170,11 @@ export default function RemindersPage() { - {t('reminders.customer')} - {t('reminders.email')} - {t('reminders.amount')} - {t('reminders.sent_at')} - {t('reminders.status')} + {t('reminders.customer')} + {t('reminders.email')} + {t('reminders.amount')} + {t('reminders.sent_at')} + {t('reminders.status')} @@ -236,12 +254,12 @@ export default function RemindersPage() { /> )} - {t('reminders.customer')} - {t('reminders.email')} - {t('reminders.amount')} - {t('reminders.count')} - {t('reminders.last_sent')} - {t('reminders.status')} + {t('reminders.customer')} + {t('reminders.email')} + {t('reminders.amount')} + {t('reminders.count')} + {t('reminders.last_sent')} + {t('reminders.status')} diff --git a/apps/dashboard/src/app/dashboard/stock/page.tsx b/apps/dashboard/src/app/dashboard/stock/page.tsx index c34726d..b018c66 100644 --- a/apps/dashboard/src/app/dashboard/stock/page.tsx +++ b/apps/dashboard/src/app/dashboard/stock/page.tsx @@ -7,6 +7,8 @@ import { PageHeader } from '@/components/page-header'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { SortableHead } from '@/components/ui/sortable-head'; +import { useSort } from '@/lib/use-sort'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; @@ -33,6 +35,12 @@ const filters: { value: Filter; label: string }[] = [ ]; /** At or below its warning level, or out of stock. */ +/** How many to order to reach the desired stock; null when stock isn't tracked or no desired stock is set. */ +function toOrder(product: Product): number | null { + if (product.stock === null || product.desiredStock === null) return null; + return Math.max(0, product.desiredStock - product.stock); +} + function isLow(product: Product): boolean { if (product.stock === null) return false; return product.stock <= 0 || (product.lowStockThreshold !== null && product.stock <= product.lowStockThreshold); @@ -68,6 +76,7 @@ export default function StockPage() { const [deliveryOpen, setDeliveryOpen] = useState(false); // null: closed; 'new': adding a product const [editing, setEditing] = useState(null); + const sort = useSort<'product' | 'price' | 'buyPrice' | 'stock' | 'toOrder' | 'countedAt'>(); const { data, isLoading, error } = useQuery({ queryKey: ['stock', venueId], @@ -100,13 +109,23 @@ export default function StockPage() { return true; }); - // Products arrive sorted by category, then name + // Products arrive sorted by category, then name; a sorted column sorts within each category const groups: { category: string | null; products: Product[] }[] = []; for (const product of visible) { const last = groups[groups.length - 1]; if (last && last.category === product.category) last.products.push(product); else groups.push({ category: product.category, products: [product] }); } + for (const group of groups) { + group.products = sort.apply(group.products, { + product: (p) => p.name, + price: (p) => p.priceInCents, + buyPrice: (p) => p.buyPriceInCents, + stock: (p) => p.stock, + toOrder: toOrder, + countedAt: (p) => (p.stock !== null && p.countedAt ? new Date(p.countedAt) : null), + }); + } return (
@@ -161,17 +180,21 @@ export default function StockPage() {
- {t('stock.product')} - {t('stock.price')} - {t('stock.stock')} - {t('stock.counted_at')} + {t('stock.product')} + {t('stock.price')} + {t('stock.buy_price')} + {t('stock.stock')} + + {t('stock.to_order')} + + {t('stock.counted_at')} {t('stock.actions')} {visible.length === 0 && ( - + {t('stock.no_matches')} @@ -179,7 +202,7 @@ export default function StockPage() { {groups.map((group) => ( - + {group.category ?? t('stock.no_category')} @@ -196,7 +219,11 @@ export default function StockPage() { {formatCents(product.priceInCents)} + + {product.buyPriceInCents === null ? '—' : formatCents(product.buyPriceInCents)} + + {product.stock === null ? '—' @@ -274,6 +301,17 @@ export default function StockPage() { ); } +function ToOrder({ product }: { product: Product }) { + const { t } = useLang(); + const count = toOrder(product); + if (count === null) return —; + return ( + 0 ? 'font-medium' : 'text-muted-foreground'} title={`${t('stock.desired')}: ${product.desiredStock}`}> + {count} + + ); +} + function StockLevel({ product }: { product: Product }) { const { t } = useLang(); if (product.stock === null) return {t('stock.not_tracked')}; @@ -460,7 +498,9 @@ function ProductDialog({ product, categories, venueId, onClose }: { const [name, setName] = useState(product?.name ?? ''); const [category, setCategory] = useState(product?.category ?? ''); const [price, setPrice] = useState(product ? centsToInput(product.priceInCents) : ''); + const [buyPrice, setBuyPrice] = useState(product?.buyPriceInCents != null ? centsToInput(product.buyPriceInCents) : ''); const [threshold, setThreshold] = useState(product?.lowStockThreshold?.toString() ?? ''); + const [desired, setDesired] = useState(product?.desiredStock?.toString() ?? ''); const [trackStock, setTrackStock] = useState(true); const [stock, setStock] = useState(''); // A picked picture is uploaded on save; the server crops it to a square @@ -481,9 +521,13 @@ function ProductDialog({ product, categories, venueId, onClose }: { const shownPicture = preview ?? (!removePicture && product?.imageId ? imageUrl(product.imageId) : null); const priceInCents = parseEuros(price); + // Optional: empty is not entered, anything else must be a price + const buyPriceInCents = buyPrice.trim() === '' ? null : parseEuros(buyPrice) ?? undefined; const thresholdValue = threshold.trim() === '' ? null : /^\d+$/.test(threshold.trim()) ? parseInt(threshold, 10) : undefined; + const desiredValue = desired.trim() === '' ? null : /^\d+$/.test(desired.trim()) ? parseInt(desired, 10) : undefined; const stockValue = /^\d+$/.test(stock.trim()) ? parseInt(stock, 10) : null; - const valid = name.trim() !== '' && priceInCents !== null && thresholdValue !== undefined + const valid = name.trim() !== '' && priceInCents !== null && buyPriceInCents !== undefined && thresholdValue !== undefined + && desiredValue !== undefined && (!isNew || !trackStock || stockValue !== null); function done(title: string) { @@ -495,7 +539,10 @@ function ProductDialog({ product, categories, venueId, onClose }: { const save = useMutation({ mutationFn: async () => { - const fields = { name: name.trim(), category: category.trim() || null, priceInCents: priceInCents!, lowStockThreshold: thresholdValue! }; + const fields = { + name: name.trim(), category: category.trim() || null, priceInCents: priceInCents!, buyPriceInCents: buyPriceInCents!, + lowStockThreshold: thresholdValue!, desiredStock: desiredValue!, + }; const saved = product === null ? await stockApi.createProduct(venueId, { ...fields, stock: trackStock ? stockValue : null }) : await stockApi.updateProduct(venueId, product.productId, fields); @@ -571,11 +618,11 @@ function ProductDialog({ product, categories, venueId, onClose }: { -
- - setName(e.target.value)} autoFocus /> -
+
+ + setName(e.target.value)} autoFocus /> +
+
+
e.key === 'Enter' && submit()} />
+
+ + setBuyPrice(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && submit()} + /> +
{isNew && ( -
- - {trackStock && ( -
- - setStock(e.target.value)} className="max-w-[8rem]" /> -
- )} -
+ )} {(!isNew ? product.stock !== null : trackStock) && ( -
- - setThreshold(e.target.value)} - className="max-w-[8rem]" - /> +
+ {isNew && ( +
+ + setStock(e.target.value)} /> +
+ )} +
+ + setDesired(e.target.value)} + /> +
+
+ + setThreshold(e.target.value)} + /> +
)} {!isNew && ( diff --git a/apps/dashboard/src/app/dashboard/tabs/page.tsx b/apps/dashboard/src/app/dashboard/tabs/page.tsx index af69de7..46c8b0c 100644 --- a/apps/dashboard/src/app/dashboard/tabs/page.tsx +++ b/apps/dashboard/src/app/dashboard/tabs/page.tsx @@ -7,12 +7,13 @@ import { PageHeader } from '@/components/page-header'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { SortableHead } from '@/components/ui/sortable-head'; +import { useSort } from '@/lib/use-sort'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { EmptyState } from '@/components/empty-state'; import { LoadingState } from '@/components/loading-state'; import { ErrorState } from '@/components/error-state'; @@ -25,12 +26,6 @@ import { useLang } from '@/lib/i18n'; import { useRealtime } from '@/lib/realtime'; import { canManageVenue, useSession } from '@/lib/session'; -type SortKey = 'name' | 'amount'; - -const comparators: Record number> = { - name: (a, b) => a.customerName.localeCompare(b.customerName, undefined, { sensitivity: 'base' }), - amount: (a, b) => b.totalInCents - a.totalInCents, -}; export default function TabsPage() { // Live updates make polling unnecessary; it only runs while the socket is down @@ -44,7 +39,7 @@ export default function TabsPage() { const [paymentMethod, setPaymentMethod] = useState(''); const [expandedTabs, setExpandedTabs] = useState>(new Set()); const [search, setSearch] = useState(''); - const [sort, setSort] = useState('name'); + const sort = useSort<'customer' | 'email' | 'amount' | 'items' | 'reported'>({ key: 'customer', direction: 'asc' }); const [plannedName, setPlannedName] = useState(''); const [plannedEmail, setPlannedEmail] = useState(''); const [importOpen, setImportOpen] = useState(false); @@ -143,9 +138,13 @@ export default function TabsPage() { const tabs = data?.tabs ?? []; const outstanding = tabs.reduce((sum, tab) => sum + tab.totalInCents, 0); const query = search.trim().toLowerCase(); - const visibleTabs = tabs - .filter((tab) => !query || tab.customerName.toLowerCase().includes(query)) - .sort(comparators[sort]); + const visibleTabs = sort.apply(tabs.filter((tab) => !query || tab.customerName.toLowerCase().includes(query)), { + customer: (tab) => tab.customerName, + email: (tab) => tab.email, + amount: (tab) => tab.totalInCents, + items: (tab) => tab.itemCount, + reported: (tab) => new Date(tab.lastChangedAt), + }); return (
@@ -225,24 +224,17 @@ export default function TabsPage() { aria-label={t('tabs.search_placeholder')} />
-
- {t('tabs.customer')} - {t('tabs.email')} - {t('tabs.amount')} - {t('tabs.items')} - {t('tabs.reported')} + {t('tabs.customer')} + {t('tabs.email')} + {t('tabs.amount')} + {t('tabs.items')} + {t('tabs.reported')} {t('tabs.actions')} diff --git a/apps/dashboard/src/app/dashboard/users/page.tsx b/apps/dashboard/src/app/dashboard/users/page.tsx index c1788b1..2403da5 100644 --- a/apps/dashboard/src/app/dashboard/users/page.tsx +++ b/apps/dashboard/src/app/dashboard/users/page.tsx @@ -5,6 +5,8 @@ import { useState } from 'react'; import { usersApi, type User } from '@/lib/api/client'; import { PageHeader } from '@/components/page-header'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { SortableHead } from '@/components/ui/sortable-head'; +import { useSort } from '@/lib/use-sort'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; @@ -28,6 +30,7 @@ export default function UsersPage() { const [deleting, setDeleting] = useState(null); const [form, setForm] = useState({ email: '', password: '', name: '', role: 'manager' }); const [submitting, setSubmitting] = useState(false); + const sort = useSort<'name' | 'email' | 'role' | 'created'>(); const { t, tn, te, relativeTime } = useLang(); const { data, isLoading, error } = useQuery({ @@ -92,15 +95,20 @@ export default function UsersPage() {
- {t('users.name')} - {t('users.email')} - {t('users.role')} - {t('users.created')} + {t('users.name')} + {t('users.email')} + {t('users.role')} + {t('users.created')} {isAdmin && {t('tabs.actions')}} - {users.map((u) => ( + {sort.apply(users, { + name: (u) => u.name, + email: (u) => u.email, + role: (u) => t(`users.role.${u.role}`), + created: (u) => new Date(u.createdAt), + }).map((u) => ( {u.name} {u.email} diff --git a/apps/dashboard/src/components/ui/sortable-head.tsx b/apps/dashboard/src/components/ui/sortable-head.tsx new file mode 100644 index 0000000..0fd81e2 --- /dev/null +++ b/apps/dashboard/src/components/ui/sortable-head.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react'; +import { TableHead } from '@/components/ui/table'; +import { cn } from '@/lib/cn'; +import type { Sort } from '@/lib/use-sort'; + +/** A column header that sorts the table on click: ascending, descending, then back to the table's own order. */ +export function SortableHead({ sort, column, className, children }: { + sort: Sort; + column: K; + className?: string; + children: React.ReactNode; +}) { + const active = sort.key === column; + const Icon = !active ? ChevronsUpDown : sort.direction === 'asc' ? ArrowUp : ArrowDown; + const right = className?.includes('text-right'); + const center = className?.includes('text-center'); + + return ( + + + + ); +} diff --git a/apps/dashboard/src/lib/api/client.ts b/apps/dashboard/src/lib/api/client.ts index b90e4ff..1779c15 100644 --- a/apps/dashboard/src/lib/api/client.ts +++ b/apps/dashboard/src/lib/api/client.ts @@ -26,7 +26,21 @@ export interface Tab { email: string | null; } -export type CommandStatus = 'pending' | 'completed' | 'failed' | 'conflict' | 'expired'; +/** A paid tab, settled on a terminal or on the dashboard. */ +export interface PaidTab { + id: string; + tabId: string; + customerName: string; + settledAt: string; + paymentMethod: string; + amountInCents: number; + itemCount: number; + items: (TabLine & { removed: number })[]; + settledBy: string | null; + onDashboard: boolean; +} + +export type CommandStatus ='pending' | 'completed' | 'failed' | 'conflict' | 'expired'; export interface Command { id: string; @@ -119,10 +133,15 @@ export interface Product { productId: string; name: string; category: string | null; + /** The sell price, charged on the terminals */ priceInCents: number; + /** What the bar pays for one; null when not entered. Never sent to the terminals */ + buyPriceInCents: number | null; /** Null when stock is not tracked for this product; can be negative */ stock: number | null; lowStockThreshold: number | null; + /** How many the bar wants in stock; null when not set. Never sent to the terminals */ + desiredStock: number | null; /** The latest count, on the dashboard or a terminal; null when never counted */ countedAt: string | null; /** When it was created or last changed */ @@ -138,7 +157,10 @@ export function imageUrl(imageId: string): string { return `${API_URL}/images/${imageId}`; } -export type ProductFields = { name: string; category: string | null; priceInCents: number; lowStockThreshold: number | null }; +export type ProductFields = { + name: string; category: string | null; priceInCents: number; buyPriceInCents: number | null; lowStockThreshold: number | null; + desiredStock: number | null; +}; export type NewStockAdjustment = { productId: string; type: StockAdjustmentType; quantity: number; note?: string }; @@ -280,6 +302,17 @@ export const authApi = { export const tabsApi = { list: (venueId: string) => api<{ tabs: Tab[] }>(`/venues/${venueId}/tabs`), + history: (venueId: string, params: { search?: string; paymentMethod?: string; limit?: number; offset?: number }) => { + const searchParams = new URLSearchParams(); + if (params.search) searchParams.set('search', params.search); + if (params.paymentMethod) searchParams.set('paymentMethod', params.paymentMethod); + if (params.limit) searchParams.set('limit', String(params.limit)); + if (params.offset) searchParams.set('offset', String(params.offset)); + const qs = searchParams.toString(); + return api<{ settlements: PaidTab[]; total: number; totalAmountInCents: number }>( + `/venues/${venueId}/tabs/history${qs ? `?${qs}` : ''}`, + ); + }, /** Deletes the tab without settling it; refused when it changed since [expectedVersion] */ remove: (venueId: string, tabId: string, expectedVersion: number) => api(`/venues/${venueId}/tabs/${encodeURIComponent(tabId)}?expectedVersion=${expectedVersion}`, { method: 'DELETE' }), diff --git a/apps/dashboard/src/lib/i18n.tsx b/apps/dashboard/src/lib/i18n.tsx index 6908157..14d20f4 100644 --- a/apps/dashboard/src/lib/i18n.tsx +++ b/apps/dashboard/src/lib/i18n.tsx @@ -81,9 +81,6 @@ const translations: Record> = { 'tabs.amount': 'Amount', 'tabs.items': 'Items', 'tabs.search_placeholder': 'Search customers…', - 'tabs.sort': 'Sort by', - 'tabs.sort_name': 'Name', - 'tabs.sort_amount': 'Highest amount', 'tabs.outstanding': '{amount} outstanding', 'tabs.closing': 'Closing…', 'tabs.no_matches': 'No tabs match "{query}"', @@ -110,6 +107,30 @@ const translations: Record> = { 'tabs.deleted': "{name}'s tab is deleted", 'tabs.settled': 'Tab settled', 'tabs.settled_desc': '{name}: {amount}', + 'nav.history': 'Tab History', + 'history.title': 'Tab History', + 'history.count': '{count} paid tab', + 'history.count_plural': '{count} paid tabs', + 'history.total': '{amount} paid', + 'history.search_placeholder': 'Search by name...', + 'history.all_methods': 'All', + 'history.customer': 'Customer', + 'history.paid_at': 'Paid', + 'history.amount': 'Amount', + 'history.items': 'Items', + 'history.payment': 'Payment', + 'history.settled_by': 'Settled by', + 'history.dashboard': 'Dashboard ({name})', + 'history.removed': '{count} removed', + 'history.show_items': "Show {name}'s items", + 'history.hide_items': "Hide {name}'s items", + 'history.empty': 'No paid tabs yet', + 'history.empty_desc': 'Tabs settled on a terminal or here appear once the terminal has synced.', + 'history.no_matches': 'No paid tabs match your filters.', + 'history.load_failed': 'Failed to load the tab history', + 'history.previous': 'Previous', + 'history.next': 'Next', + 'history.page': 'Page {page} of {total}', 'planned.title': 'Planned Customers', 'planned.name_placeholder': 'Add customer name...', 'planned.email_placeholder': 'Email (optional)', @@ -328,7 +349,9 @@ const translations: Record> = { 'products.duplicate_desc': 'Another product has the same name, for example created on two terminals at once. Archive the one you do not need.', 'products.category': 'Category', 'products.category_placeholder': 'e.g. Frisdrank', - 'products.price': 'Price (€)', + 'products.price': 'Sell price (€)', + 'products.buy_price': 'Buy price (€)', + 'products.buy_price_hint': 'What you pay for one. Only shown here, never on the terminals.', 'products.track_stock': 'Track stock', 'products.starting_stock': 'In stock now', 'products.save': 'Save', @@ -346,7 +369,8 @@ const translations: Record> = { 'stock.no_matches': 'No products match', 'stock.no_category': 'Other', 'stock.product': 'Product', - 'stock.price': 'Price', + 'stock.price': 'Sell price', + 'stock.buy_price': 'Buy price', 'stock.stock': 'Stock', 'stock.counted_at': 'Last counted', 'stock.never_counted': 'Never', @@ -369,6 +393,10 @@ const translations: Record> = { 'stock.note_placeholder': 'e.g. delivery from the brewery', 'stock.threshold': 'Warn when stock is at or below', 'stock.threshold_none': 'No warning', + 'stock.desired': 'Desired stock', + 'stock.desired_none': 'Not set', + 'stock.to_order': 'To order', + 'stock.to_order_desc': 'Desired stock minus the current stock', 'stock.send': 'Save', 'stock.sending': 'Sending…', 'stock.sent': 'Stock updated', @@ -635,9 +663,6 @@ const translations: Record> = { 'tabs.amount': 'Bedrag', 'tabs.items': 'Items', 'tabs.search_placeholder': 'Zoek klant…', - 'tabs.sort': 'Sorteer op', - 'tabs.sort_name': 'Naam', - 'tabs.sort_amount': 'Hoogste bedrag', 'tabs.outstanding': '{amount} openstaand', 'tabs.closing': 'Wordt gesloten…', 'tabs.no_matches': 'Geen poefs gevonden voor "{query}"', @@ -664,6 +689,30 @@ const translations: Record> = { 'tabs.deleted': 'Poef van {name} is verwijderd', 'tabs.settled': 'Poef afgerekend', 'tabs.settled_desc': '{name}: {amount}', + 'nav.history': 'Poefgeschiedenis', + 'history.title': 'Poefgeschiedenis', + 'history.count': '{count} betaalde poef', + 'history.count_plural': '{count} betaalde poefs', + 'history.total': '{amount} betaald', + 'history.search_placeholder': 'Zoek op naam...', + 'history.all_methods': 'Alle', + 'history.customer': 'Klant', + 'history.paid_at': 'Betaald', + 'history.amount': 'Bedrag', + 'history.items': 'Items', + 'history.payment': 'Betaling', + 'history.settled_by': 'Afgerekend door', + 'history.dashboard': 'Dashboard ({name})', + 'history.removed': '{count} verwijderd', + 'history.show_items': 'Toon de items van {name}', + 'history.hide_items': 'Verberg de items van {name}', + 'history.empty': 'Nog geen betaalde poefs', + 'history.empty_desc': 'Poefs die op een kassa of hier afgerekend zijn, verschijnen zodra de kassa gesynchroniseerd is.', + 'history.no_matches': 'Geen betaalde poefs voor deze filters.', + 'history.load_failed': 'Kon de poefgeschiedenis niet laden', + 'history.previous': 'Vorige', + 'history.next': 'Volgende', + 'history.page': 'Pagina {page} van {total}', 'planned.title': 'Geplande klanten', 'planned.name_placeholder': 'Naam van de klant...', 'planned.email_placeholder': 'E-mail (optioneel)', @@ -882,7 +931,9 @@ const translations: Record> = { 'products.duplicate_desc': 'Een ander product heeft dezelfde naam, bijvoorbeeld tegelijk aangemaakt op twee kassa\'s. Archiveer het product dat je niet nodig hebt.', 'products.category': 'Categorie', 'products.category_placeholder': 'bv. Frisdrank', - 'products.price': 'Prijs (€)', + 'products.price': 'Verkoopprijs (€)', + 'products.buy_price': 'Aankoopprijs (€)', + 'products.buy_price_hint': 'Wat je zelf betaalt per stuk. Enkel hier zichtbaar, nooit op de terminals.', 'products.track_stock': 'Voorraad bijhouden', 'products.starting_stock': 'Nu in voorraad', 'products.save': 'Opslaan', @@ -900,7 +951,8 @@ const translations: Record> = { 'stock.no_matches': 'Geen producten gevonden', 'stock.no_category': 'Overige', 'stock.product': 'Product', - 'stock.price': 'Prijs', + 'stock.price': 'Verkoopprijs', + 'stock.buy_price': 'Aankoopprijs', 'stock.stock': 'Voorraad', 'stock.counted_at': 'Laatst geteld', 'stock.never_counted': 'Nooit', @@ -923,6 +975,10 @@ const translations: Record> = { 'stock.note_placeholder': 'bv. levering brouwerij', 'stock.threshold': 'Waarschuw vanaf', 'stock.threshold_none': 'Geen waarschuwing', + 'stock.desired': 'Gewenste stock', + 'stock.desired_none': 'Niet ingesteld', + 'stock.to_order': 'Bij te bestellen', + 'stock.to_order_desc': 'Gewenste stock min de huidige stock', 'stock.send': 'Opslaan', 'stock.sending': 'Versturen…', 'stock.sent': 'Voorraad bijgewerkt', diff --git a/apps/dashboard/src/lib/realtime.tsx b/apps/dashboard/src/lib/realtime.tsx index ebfe947..fa4e6f4 100644 --- a/apps/dashboard/src/lib/realtime.tsx +++ b/apps/dashboard/src/lib/realtime.tsx @@ -8,7 +8,8 @@ import { useSession } from '@/lib/session'; // Server topics mapped to the dashboard queries that show that data const TOPIC_QUERIES: Record = { - tabs: [['tabs'], ['reminder-candidates']], + // A settlement changes the tabs, so it comes with this topic + tabs: [['tabs'], ['reminder-candidates'], ['tab-history']], commands: [['commands']], 'planned-tabs': [['planned-tabs'], ['reminder-candidates']], device: [['device']], diff --git a/apps/dashboard/src/lib/use-sort.ts b/apps/dashboard/src/lib/use-sort.ts new file mode 100644 index 0000000..f945af4 --- /dev/null +++ b/apps/dashboard/src/lib/use-sort.ts @@ -0,0 +1,53 @@ +import { useState } from 'react'; + +export type SortDirection = 'asc' | 'desc'; +export type SortValue = string | number | boolean | Date | null | undefined; + +export interface Sort { + key: K | null; + direction: SortDirection; + /** A click on a column header: ascending first, then descending, then back to the table's own order */ + toggle: (key: K) => void; + /** [rows] in the chosen order; [columns] gives the value each column sorts on */ + apply: (rows: readonly T[], columns: Record SortValue>) => T[]; +} + +const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); + +const isEmpty = (v: SortValue) => v === null || v === undefined || v === ''; + +/** Text ignores case and puts "Beer 9" before "Beer 10"; dates and numbers by value. */ +function compare(a: SortValue, b: SortValue): number { + if (typeof a === 'string' && typeof b === 'string') return collator.compare(a, b); + const toNumber = (v: SortValue) => (v instanceof Date ? v.getTime() : Number(v)); + return toNumber(a) - toNumber(b); +} + +/** + * Click-to-sort for a table whose rows are all loaded. Without a click (or [initial]) the rows keep the order they + * came in. The hook holds only the choice, so it can be called before a page's loading returns. + */ +export function useSort(initial?: { key: K; direction: SortDirection }): Sort { + const [state, setState] = useState<{ key: K | null; direction: SortDirection }>(initial ?? { key: null, direction: 'asc' }); + + return { + ...state, + toggle: (key) => setState((current) => { + if (current.key !== key) return { key, direction: 'asc' }; + if (current.direction === 'asc') return { key, direction: 'desc' }; + return initial ?? { key: null, direction: 'asc' }; + }), + apply: (rows, columns) => { + const { key, direction } = state; + if (key === null) return [...rows]; + const value = columns[key]; + // Empty values stay last in both directions, like in a spreadsheet; equal values keep their order + return [...rows].sort((a, b) => { + const va = value(a); + const vb = value(b); + if (isEmpty(va) || isEmpty(vb)) return Number(isEmpty(va)) - Number(isEmpty(vb)); + return direction === 'asc' ? compare(va, vb) : compare(vb, va); + }); + }, + }; +}