feat: add stuff
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-27 23:27:56 +01:00
parent 97572fe459
commit bca6e88138
27 changed files with 885 additions and 126 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "buyPriceInCents" INTEGER;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "desiredStock" INTEGER;
+5
View File
@@ -212,13 +212,18 @@ model Product {
productId String productId String
name String name String
category String? category String?
// The sell price: what the terminals charge
priceInCents Int 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. // Null when stock is not tracked for this product. Can be negative: a sale is never blocked.
stock Int? stock Int?
// When the stock was last counted; line events that happened before it are already in the count // When the stock was last counted; line events that happened before it are already in the count
countedAt DateTime? countedAt DateTime?
// Warn when the stock drops to this level // Warn when the stock drops to this level
lowStockThreshold Int? 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 // When it was created or last changed
reportedAt DateTime reportedAt DateTime
// Deleted on the dashboard: hidden on the terminals, kept because tabs and history refer to it // Deleted on the dashboard: hidden on the terminals, kept because tabs and history refer to it
+4 -1
View File
@@ -153,7 +153,10 @@ export class AdminService {
if (copies.length > 0) { if (copies.length > 0) {
await tx.product.createMany({ await tx.product.createMany({
data: copies.map((p) => { 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 { return {
venueId, venueId,
productId: uuidv4(), productId: uuidv4(),
+16 -2
View File
@@ -7,8 +7,12 @@ import { StockService } from '../stock/stock.service';
type Device = { id: string; venueId: string; revokedAt: null; lastSeenAt: Date }; type Device = { id: string; venueId: string; revokedAt: null; lastSeenAt: Date };
type SyncResult = Awaited<ReturnType<DevicesService['sync']>>; type SyncResult = Awaited<ReturnType<DevicesService['sync']>>;
// In the past: a time ahead of the server clock would be capped // Yesterday at 20:00 UTC. In the past, as a time ahead of the server clock would be capped, and recent, as a new
const at = (minutes: number) => new Date(Date.UTC(2026, 8, 24, 20, minutes)).toISOString(); // 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 } = {}) { function setup({ seeded = true } = {}) {
const prisma = createFakePrisma(); 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 })]); 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 () => { 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(); const { sync, tabletA, stock, prisma } = setupWithWebsite();
addProduct(prisma, 'cola', { stock: 20, countedAt: new Date('2026-01-01') }); addProduct(prisma, 'cola', { stock: 20, countedAt: new Date('2026-01-01') });
+33 -1
View File
@@ -21,12 +21,20 @@ export class CreateProductDto {
@MaxLength(100) @MaxLength(100)
category?: string | null; category?: string | null;
@ApiProperty({ example: 250 }) @ApiProperty({ example: 250, description: 'The sell price, charged on the terminals' })
@IsInt() @IsInt()
@Min(0) @Min(0)
@Max(1_000_000) @Max(1_000_000)
priceInCents: number; 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' }) @ApiPropertyOptional({ example: 24, nullable: true, description: 'The starting count; null or left out: stock is not tracked' })
@IsOptional() @IsOptional()
@ValidateIf((dto: CreateProductDto) => dto.stock !== null) @ValidateIf((dto: CreateProductDto) => dto.stock !== null)
@@ -42,6 +50,14 @@ export class CreateProductDto {
@Min(0) @Min(0)
@Max(100000) @Max(100000)
lowStockThreshold?: number | null; 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. */ /** 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) @Max(1_000_000)
priceInCents?: number; 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' }) @ApiPropertyOptional({ example: 12, nullable: true, description: 'Warn when stock drops to this level; null for no warning' })
@IsOptional() @IsOptional()
@ValidateIf((dto: UpdateProductDto) => dto.lowStockThreshold !== null) @ValidateIf((dto: UpdateProductDto) => dto.lowStockThreshold !== null)
@@ -77,6 +101,14 @@ export class UpdateProductDto {
@Max(100000) @Max(100000)
lowStockThreshold?: number | null; 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' }) @ApiPropertyOptional({ description: 'true hides the product on the terminals (deleting it); false brings it back' })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
+3 -1
View File
@@ -1,14 +1,16 @@
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
/** The fields of a product that the dashboard and the terminals change; stock changes through its own rules. */ /** 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 ProductField = (typeof PRODUCT_FIELDS)[number];
export type ProductChanges = { export type ProductChanges = {
name?: string; name?: string;
category?: string | null; category?: string | null;
priceInCents?: number; priceInCents?: number;
buyPriceInCents?: number | null;
lowStockThreshold?: number | null; lowStockThreshold?: number | null;
desiredStock?: number | null;
archived?: boolean; archived?: boolean;
imageId?: string | null; imageId?: string | null;
}; };
+27 -1
View File
@@ -145,6 +145,32 @@ describe('StockService products', () => {
expect(prisma.product.rows[0]).toMatchObject({ name: 'Cola', priceInCents: 300, category: null, stock: 10, archivedAt: null }); 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 () => { it('archives a product and brings it back, unless its name was taken meanwhile', async () => {
const { service, prisma } = createWithVenue([cola]); 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 created = await service.createProduct('v1', { name: 'Water', priceInCents: 200 }, admin);
const times = prisma.product.rows.find((p) => p.productId === created.productId)!.fieldUpdatedAt; 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']);
}); });
}); });
+9 -3
View File
@@ -172,7 +172,9 @@ export class StockService {
name: dto.name, name: dto.name,
category: dto.category ?? null, category: dto.category ?? null,
priceInCents: dto.priceInCents, priceInCents: dto.priceInCents,
buyPriceInCents: dto.buyPriceInCents ?? null,
lowStockThreshold: dto.lowStockThreshold ?? null, lowStockThreshold: dto.lowStockThreshold ?? null,
desiredStock: dto.desiredStock ?? null,
}; };
const created = await tx.product.create({ const created = await tx.product.create({
data: { 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. * 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 }) { 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.name != null ? { name: dto.name } : {}),
...(dto.category !== undefined ? { category: dto.category } : {}), ...(dto.category !== undefined ? { category: dto.category } : {}),
...(dto.priceInCents != null ? { priceInCents: dto.priceInCents } : {}), ...(dto.priceInCents != null ? { priceInCents: dto.priceInCents } : {}),
...(dto.buyPriceInCents !== undefined ? { buyPriceInCents: dto.buyPriceInCents } : {}),
...(dto.lowStockThreshold !== undefined ? { lowStockThreshold: dto.lowStockThreshold } : {}), ...(dto.lowStockThreshold !== undefined ? { lowStockThreshold: dto.lowStockThreshold } : {}),
...(dto.desiredStock !== undefined ? { desiredStock: dto.desiredStock } : {}),
...(dto.archived !== undefined ? { archived: dto.archived } : {}), ...(dto.archived !== undefined ? { archived: dto.archived } : {}),
}, maxDate(now, existing.fieldUpdatedAt), existing); }, maxDate(now, existing.fieldUpdatedAt), existing);
@@ -306,8 +310,8 @@ function maxDate(now: Date, times: unknown): Date {
} }
function productView(p: { function productView(p: {
productId: string; name: string; category: string | null; priceInCents: number; stock: number | null; productId: string; name: string; category: string | null; priceInCents: number; buyPriceInCents?: number | null; stock: number | null;
lowStockThreshold: number | null; countedAt: Date | null; reportedAt: Date; archivedAt: Date | null; lowStockThreshold: number | null; desiredStock?: number | null; countedAt: Date | null; reportedAt: Date; archivedAt: Date | null;
imageId?: string | null; imageId?: string | null;
}) { }) {
return { return {
@@ -315,8 +319,10 @@ function productView(p: {
name: p.name, name: p.name,
category: p.category, category: p.category,
priceInCents: p.priceInCents, priceInCents: p.priceInCents,
buyPriceInCents: p.buyPriceInCents ?? null,
stock: p.stock, stock: p.stock,
lowStockThreshold: p.lowStockThreshold, lowStockThreshold: p.lowStockThreshold,
desiredStock: p.desiredStock ?? null,
// The latest count, on the dashboard or a terminal: how fresh the stock is // The latest count, on the dashboard or a terminal: how fresh the stock is
countedAt: p.countedAt, countedAt: p.countedAt,
reportedAt: p.reportedAt, reportedAt: p.reportedAt,
+21
View File
@@ -19,6 +19,27 @@ export class TabsController {
return this.tabsService.findAll(venueId); 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') @Delete(':tabId')
@Roles('admin', 'manager') @Roles('admin', 'manager')
@ApiOperation({ summary: 'Delete an open tab without settling it' }) @ApiOperation({ summary: 'Delete an open tab without settling it' })
+64
View File
@@ -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<string, unknown> = {}) =>
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']);
});
});
+61 -1
View File
@@ -1,9 +1,10 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service'; import { RealtimeService } from '../realtime/realtime.service';
import { customerNameKey } from '../planned-tabs/customer-name'; import { customerNameKey } from '../planned-tabs/customer-name';
import { tabSummary } from './tab-state'; import { SettledLine, tabSummary } from './tab-state';
import { VenueLedger } from './venue-ledger'; import { VenueLedger } from './venue-ledger';
@Injectable() @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 * 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 * out of the stock. The terminals drop it on their next sync; one that still has unsent drinks for it brings it
+17 -4
View File
@@ -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 * 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 * queries the tab, stock and device services use: equality, `in`, `notIn`, `not`, `gt`, `gte`, `lte`, `contains` and
* unique keys, `orderBy` on one or more fields, `take` and `select`. Transactions run directly on the same data. * 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'; import { randomUUID } from 'crypto';
@@ -28,6 +28,10 @@ function matches(row: Row, where: Row = {}): boolean {
case 'gt': return value !== null && value > operand; case 'gt': return value !== null && value > operand;
case 'gte': return value !== null && value >= operand; case 'gte': return value !== null && value >= operand;
case 'lte': 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}`); default: throw new Error(`fake-prisma: unsupported operator ${op}`);
} }
}); });
@@ -88,7 +92,12 @@ class FakeTable {
async findMany(args: Row = {}) { async findMany(args: Row = {}) {
const found = sort(this.rows.filter((row) => matches(row, args.where)), args.orderBy); 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) { async findUnique(args: Row) {
@@ -148,7 +157,11 @@ class FakeTable {
field, field,
found.length === 0 ? null : Math.max(...found.map((row) => row[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 };
} }
} }
@@ -8,6 +8,9 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; 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 { DeviceStatusBadge } from '@/components/device-status-badge';
import { ConfirmDialog } from '@/components/confirm-dialog'; import { ConfirmDialog } from '@/components/confirm-dialog';
import { LoadingState } from '@/components/loading-state'; import { LoadingState } from '@/components/loading-state';
@@ -16,11 +19,15 @@ import { toast } from '@/components/ui/toast';
import { useLang } from '@/lib/i18n'; import { useLang } from '@/lib/i18n';
import { Checkbox } from './shared'; 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() { export function DevicesSection() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t, te, relativeTime, dateTime } = useLang(); const { t, te, relativeTime, dateTime } = useLang();
const [showUnpaired, setShowUnpaired] = useState(false); const [showUnpaired, setShowUnpaired] = useState(false);
const [unpairing, setUnpairing] = useState<AdminDevice | null>(null); const [unpairing, setUnpairing] = useState<AdminDevice | null>(null);
const sort = useSort<'name' | 'venue' | 'status' | 'lastSeen' | 'tokenExpires' | 'pending'>();
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ['admin', 'devices'], queryKey: ['admin', 'devices'],
@@ -54,7 +61,14 @@ export function DevicesSection() {
if (error) return <ErrorState message={te(error)} />; if (error) return <ErrorState message={te(error)} />;
const all = data?.devices ?? []; 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(); const now = Date.now();
return ( return (
@@ -70,12 +84,12 @@ export function DevicesSection() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t('admin.devices.name')}</TableHead> <SortableHead sort={sort} column="name">{t('admin.devices.name')}</SortableHead>
<TableHead>{t('admin.devices.venue')}</TableHead> <SortableHead sort={sort} column="venue">{t('admin.devices.venue')}</SortableHead>
<TableHead>{t('admin.devices.status')}</TableHead> <SortableHead sort={sort} column="status">{t('admin.devices.status')}</SortableHead>
<TableHead>{t('admin.devices.last_seen')}</TableHead> <SortableHead sort={sort} column="lastSeen">{t('admin.devices.last_seen')}</SortableHead>
<TableHead>{t('admin.devices.token_expires')}</TableHead> <SortableHead sort={sort} column="tokenExpires">{t('admin.devices.token_expires')}</SortableHead>
<TableHead className="text-right">{t('admin.devices.pending')}</TableHead> <SortableHead sort={sort} column="pending" className="text-right">{t('admin.devices.pending')}</SortableHead>
<TableHead /> <TableHead />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -10,6 +10,8 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; 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, 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 { ConfirmDialog } from '@/components/confirm-dialog';
import { LoadingState } from '@/components/loading-state'; import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-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 [action, setAction] = useState<{ kind: 'password' | 'logout' | 'delete'; user: AdminUser } | null>(null);
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [logoutAll, setLogoutAll] = useState(false); const [logoutAll, setLogoutAll] = useState(false);
const sort = useSort<'name' | 'venue' | 'role' | 'sessions' | 'created'>();
const { data, isLoading, error } = useQuery({ queryKey: ['admin', 'users'], queryFn: adminApi.users }); const { data, isLoading, error } = useQuery({ queryKey: ['admin', 'users'], queryFn: adminApi.users });
@@ -73,8 +76,14 @@ export function UsersSection() {
if (error) return <ErrorState message={te(error)} />; if (error) return <ErrorState message={te(error)} />;
const query = search.trim().toLowerCase(); const query = search.trim().toLowerCase();
const users = (data?.users ?? []).filter((u) => const users = sort.apply((data?.users ?? []).filter((u) =>
!query || [u.name, u.email, u.venueName].some((field) => field.toLowerCase().includes(query))); !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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -94,11 +103,11 @@ export function UsersSection() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t('users.name')}</TableHead> <SortableHead sort={sort} column="name">{t('users.name')}</SortableHead>
<TableHead>{t('admin.devices.venue')}</TableHead> <SortableHead sort={sort} column="venue">{t('admin.devices.venue')}</SortableHead>
<TableHead>{t('users.role')}</TableHead> <SortableHead sort={sort} column="role">{t('users.role')}</SortableHead>
<TableHead className="text-right">{t('admin.users.sessions')}</TableHead> <SortableHead sort={sort} column="sessions" className="text-right">{t('admin.users.sessions')}</SortableHead>
<TableHead>{t('users.created')}</TableHead> <SortableHead sort={sort} column="created">{t('users.created')}</SortableHead>
<TableHead /> <TableHead />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -11,7 +11,9 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; 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 { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state'; import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast'; import { toast } from '@/components/ui/toast';
@@ -31,6 +33,7 @@ export function VenuesSection() {
const { t, te, relativeTime } = useLang(); const { t, te, relativeTime } = useLang();
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false); 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 { data, isLoading, error } = useQuery({ queryKey: ['admin', 'venues'], queryFn: adminApi.venues });
const venues = data?.venues ?? []; const venues = data?.venues ?? [];
@@ -57,16 +60,23 @@ export function VenuesSection() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t('admin.venues.name')}</TableHead> <SortableHead sort={sort} column="name">{t('admin.venues.name')}</SortableHead>
<TableHead className="text-right">{t('admin.venues.users')}</TableHead> <SortableHead sort={sort} column="users" className="text-right">{t('admin.venues.users')}</SortableHead>
<TableHead className="text-right">{t('admin.venues.terminals')}</TableHead> <SortableHead sort={sort} column="terminals" className="text-right">{t('admin.venues.terminals')}</SortableHead>
<TableHead className="text-right">{t('admin.venues.open_tabs')}</TableHead> <SortableHead sort={sort} column="openTabs" className="text-right">{t('admin.venues.open_tabs')}</SortableHead>
<TableHead className="text-right">{t('admin.venues.products')}</TableHead> <SortableHead sort={sort} column="products" className="text-right">{t('admin.venues.products')}</SortableHead>
<TableHead>{t('admin.venues.last_sync')}</TableHead> <SortableHead sort={sort} column="lastSync">{t('admin.venues.last_sync')}</SortableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{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) => (
<TableRow <TableRow
key={venue.id} key={venue.id}
className={venue.id === selectedId ? 'bg-muted' : 'cursor-pointer'} className={venue.id === selectedId ? 'bg-muted' : 'cursor-pointer'}
@@ -0,0 +1,208 @@
'use client';
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query';
import { Fragment, useEffect, useState } from 'react';
import { tabsApi, type PaidTab } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/empty-state';
import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state';
import { PaymentMethodBadge } from '@/components/payment-method-badge';
import { formatCents } from '@/lib/utils';
import { RefreshCw, ChevronRight, ChevronDown, Search } from 'lucide-react';
import { useLang } from '@/lib/i18n';
import { useRealtime } from '@/lib/realtime';
import { useSession } from '@/lib/session';
const paymentFilters = [
{ value: '', label: 'history.all_methods' },
{ value: 'cash', label: 'payment.cash' },
{ value: 'payconiq', label: 'payment.payconiq' },
{ value: 'payment_pending', label: 'payment.payment_pending' },
];
const limit = 25;
export default function HistoryPage() {
// Live updates make polling unnecessary; it only runs while the socket is down
const { connected: live } = useRealtime();
const { t, tn, te, dateTime } = useLang();
const { venueId } = useSession();
const queryClient = useQueryClient();
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [paymentMethod, setPaymentMethod] = useState('');
const [page, setPage] = useState(0);
const [expanded, setExpanded] = useState<Set<string>>(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 <LoadingState />;
if (error) return <ErrorState message={te(error, 'history.load_failed')} />;
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 (
<div>
<PageHeader
title={t('history.title')}
description={`${tn('history.count', total)} · ${t('history.total', { amount: formatCents(data?.totalAmountInCents ?? 0) })}`}
actions={<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['tab-history'] })}><RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}</Button>}
/>
{total === 0 && !filtered ? (
<EmptyState title={t('history.empty')} description={t('history.empty_desc')} />
) : (
<>
<div className="flex gap-2 mb-3 flex-wrap items-center">
<div className="relative flex-1 min-w-[12rem] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder={t('history.search_placeholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
aria-label={t('history.search_placeholder')}
/>
</div>
<div className="flex gap-2 flex-wrap">
{paymentFilters.map((f) => (
<Button
key={f.value}
variant={paymentMethod === f.value ? 'default' : 'outline'}
size="sm"
onClick={() => { setPaymentMethod(f.value); setPage(0); }}
>
{t(f.label)}
</Button>
))}
</div>
</div>
<div className="rounded-md border mb-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('history.customer')}</TableHead>
<TableHead>{t('history.paid_at')}</TableHead>
<TableHead>{t('history.amount')}</TableHead>
<TableHead>{t('history.items')}</TableHead>
<TableHead>{t('history.payment')}</TableHead>
<TableHead>{t('history.settled_by')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{settlements.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-6 text-center text-sm text-muted-foreground">
{t('history.no_matches')}
</TableCell>
</TableRow>
)}
{settlements.map((paid) => {
const open = expanded.has(paid.id);
return (
<Fragment key={paid.id}>
<TableRow>
<TableCell className="font-medium">
<button onClick={() => toggle(paid.id)} className="flex items-center gap-1 hover:text-primary" aria-expanded={open} aria-label={t(open ? 'history.hide_items' : 'history.show_items', { name: paid.customerName })}>
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
{paid.customerName}
</button>
</TableCell>
<TableCell className="whitespace-nowrap">{dateTime(paid.settledAt)}</TableCell>
<TableCell className="tabular-nums">{formatCents(paid.amountInCents)}</TableCell>
<TableCell>{paid.itemCount}</TableCell>
<TableCell><PaymentMethodBadge method={paid.paymentMethod} /></TableCell>
<TableCell className="text-muted-foreground">
{paid.onDashboard ? t('history.dashboard', { name: paid.settledBy ?? '—' }) : paid.settledBy ?? '—'}
</TableCell>
</TableRow>
{open && (
<TableRow className="bg-muted/30 hover:bg-muted/30">
<TableCell colSpan={6} className="py-3 pl-4 sm:pl-10">
{/* Pinned to the visible area so lines stay readable while the table scrolls sideways on phones */}
<div className="sticky left-4 max-w-[min(28rem,calc(100vw-7rem))]">
<PaidLines items={paid.items} />
</div>
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>
</Table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{t('history.page', { page: page + 1, total: totalPages })}
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
{t('history.previous')}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages - 1} onClick={() => setPage(page + 1)}>
{t('history.next')}
</Button>
</div>
</div>
)}
</>
)}
</div>
);
}
/** 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 <p className="text-sm text-muted-foreground">{t('tabs.lines_empty')}</p>;
}
return (
<ul className="space-y-1 text-sm">
{items.map((item, i) => (
<li key={i} className="flex justify-between gap-4">
<span className={item.quantity === 0 ? 'text-muted-foreground line-through' : undefined}>
<span className="font-medium">{item.quantity}×</span> {item.productName}
<span className="text-muted-foreground"> · {formatCents(item.unitPriceInCents)} {t('tabs.each')}</span>
{item.removed > 0 && <span className="text-destructive text-xs"> · {t('history.removed', { count: item.removed })}</span>}
</span>
<span className="tabular-nums">{formatCents(item.quantity * item.unitPriceInCents)}</span>
</li>
))}
</ul>
);
}
@@ -17,6 +17,7 @@ import {
UserCog, UserCog,
ShieldAlert, ShieldAlert,
Eye, Eye,
History,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/cn'; 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 }[] = [ const navItems: { href: string; key: string; icon: typeof LayoutDashboard; access: Access }[] = [
{ href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, access: 'all' }, { href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, access: 'all' },
{ href: '/dashboard/tabs', key: 'nav.tabs', icon: ClipboardList, 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/reminders', key: 'nav.reminders', icon: Bell, access: 'all' },
{ href: '/dashboard/stock', key: 'nav.stock', icon: Package, access: 'all' }, { href: '/dashboard/stock', key: 'nav.stock', icon: Package, access: 'all' },
{ href: '/dashboard/users', key: 'nav.users', icon: Monitor, access: 'admin' }, { href: '/dashboard/users', key: 'nav.users', icon: Monitor, access: 'admin' },
@@ -6,6 +6,8 @@ import { remindersApi, type ReminderCandidate } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; 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 { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input'; 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 // Null until the admin changes the selection: then every eligible customer is selected
const [picked, setPicked] = useState<Set<string> | null>(null); const [picked, setPicked] = useState<Set<string> | null>(null);
const [historyFor, setHistoryFor] = useState<string | null>(null); const [historyFor, setHistoryFor] = useState<string | null>(null);
const historySort = useSort<'customer' | 'email' | 'amount' | 'sentAt' | 'status'>();
const candidateSort = useSort<'customer' | 'email' | 'amount' | 'count' | 'lastSent' | 'status'>();
const candidatesQuery = useQuery({ const candidatesQuery = useQuery({
queryKey: ['reminder-candidates', venueId], queryKey: ['reminder-candidates', venueId],
@@ -71,9 +75,17 @@ export default function RemindersPage() {
const selectedInCooldown = selectedCandidates.filter((c) => !c.eligible).length; const selectedInCooldown = selectedCandidates.filter((c) => !c.eligible).length;
const query = search.trim().toLowerCase(); 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.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 visibleSendable = visible.filter((c) => c.email);
const allVisibleSelected = visibleSendable.length > 0 && visibleSendable.every((c) => selected.has(c.tabId)); const allVisibleSelected = visibleSendable.length > 0 && visibleSendable.every((c) => selected.has(c.tabId));
@@ -122,7 +134,13 @@ export default function RemindersPage() {
return <Badge variant="warning">{tn('reminders.state_cooldown', days)}</Badge>; return <Badge variant="warning">{tn('reminders.state_cooldown', days)}</Badge>;
}; };
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 ?? []; const personReminders = personHistoryQuery.data?.reminders ?? [];
return ( return (
@@ -152,11 +170,11 @@ export default function RemindersPage() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t('reminders.customer')}</TableHead> <SortableHead sort={historySort} column="customer">{t('reminders.customer')}</SortableHead>
<TableHead>{t('reminders.email')}</TableHead> <SortableHead sort={historySort} column="email">{t('reminders.email')}</SortableHead>
<TableHead>{t('reminders.amount')}</TableHead> <SortableHead sort={historySort} column="amount">{t('reminders.amount')}</SortableHead>
<TableHead>{t('reminders.sent_at')}</TableHead> <SortableHead sort={historySort} column="sentAt">{t('reminders.sent_at')}</SortableHead>
<TableHead>{t('reminders.status')}</TableHead> <SortableHead sort={historySort} column="status">{t('reminders.status')}</SortableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -236,12 +254,12 @@ export default function RemindersPage() {
/> />
</TableHead> </TableHead>
)} )}
<TableHead>{t('reminders.customer')}</TableHead> <SortableHead sort={candidateSort} column="customer">{t('reminders.customer')}</SortableHead>
<TableHead>{t('reminders.email')}</TableHead> <SortableHead sort={candidateSort} column="email">{t('reminders.email')}</SortableHead>
<TableHead className="text-right">{t('reminders.amount')}</TableHead> <SortableHead sort={candidateSort} column="amount" className="text-right">{t('reminders.amount')}</SortableHead>
<TableHead className="text-center">{t('reminders.count')}</TableHead> <SortableHead sort={candidateSort} column="count" className="text-center">{t('reminders.count')}</SortableHead>
<TableHead>{t('reminders.last_sent')}</TableHead> <SortableHead sort={candidateSort} column="lastSent">{t('reminders.last_sent')}</SortableHead>
<TableHead>{t('reminders.status')}</TableHead> <SortableHead sort={candidateSort} column="status">{t('reminders.status')}</SortableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
+88 -19
View File
@@ -7,6 +7,8 @@ import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; 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 { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; 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. */ /** 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 { function isLow(product: Product): boolean {
if (product.stock === null) return false; if (product.stock === null) return false;
return product.stock <= 0 || (product.lowStockThreshold !== null && product.stock <= product.lowStockThreshold); return product.stock <= 0 || (product.lowStockThreshold !== null && product.stock <= product.lowStockThreshold);
@@ -68,6 +76,7 @@ export default function StockPage() {
const [deliveryOpen, setDeliveryOpen] = useState(false); const [deliveryOpen, setDeliveryOpen] = useState(false);
// null: closed; 'new': adding a product // null: closed; 'new': adding a product
const [editing, setEditing] = useState<Product | 'new' | null>(null); const [editing, setEditing] = useState<Product | 'new' | null>(null);
const sort = useSort<'product' | 'price' | 'buyPrice' | 'stock' | 'toOrder' | 'countedAt'>();
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ['stock', venueId], queryKey: ['stock', venueId],
@@ -100,13 +109,23 @@ export default function StockPage() {
return true; 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[] }[] = []; const groups: { category: string | null; products: Product[] }[] = [];
for (const product of visible) { for (const product of visible) {
const last = groups[groups.length - 1]; const last = groups[groups.length - 1];
if (last && last.category === product.category) last.products.push(product); if (last && last.category === product.category) last.products.push(product);
else groups.push({ category: product.category, products: [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 ( return (
<div> <div>
@@ -161,17 +180,21 @@ export default function StockPage() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t('stock.product')}</TableHead> <SortableHead sort={sort} column="product">{t('stock.product')}</SortableHead>
<TableHead>{t('stock.price')}</TableHead> <SortableHead sort={sort} column="price">{t('stock.price')}</SortableHead>
<TableHead>{t('stock.stock')}</TableHead> <SortableHead sort={sort} column="buyPrice">{t('stock.buy_price')}</SortableHead>
<TableHead>{t('stock.counted_at')}</TableHead> <SortableHead sort={sort} column="stock">{t('stock.stock')}</SortableHead>
<SortableHead sort={sort} column="toOrder" className="text-right">
<span title={t('stock.to_order_desc')}>{t('stock.to_order')}</span>
</SortableHead>
<SortableHead sort={sort} column="countedAt">{t('stock.counted_at')}</SortableHead>
<TableHead className="text-right">{t('stock.actions')}</TableHead> <TableHead className="text-right">{t('stock.actions')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{visible.length === 0 && ( {visible.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={5} className="py-6 text-center text-sm text-muted-foreground"> <TableCell colSpan={7} className="py-6 text-center text-sm text-muted-foreground">
{t('stock.no_matches')} {t('stock.no_matches')}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -179,7 +202,7 @@ export default function StockPage() {
{groups.map((group) => ( {groups.map((group) => (
<Fragment key={group.category ?? ''}> <Fragment key={group.category ?? ''}>
<TableRow className="bg-muted/40 hover:bg-muted/40"> <TableRow className="bg-muted/40 hover:bg-muted/40">
<TableCell colSpan={5} className="py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <TableCell colSpan={7} className="py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{group.category ?? t('stock.no_category')} {group.category ?? t('stock.no_category')}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -196,7 +219,11 @@ export default function StockPage() {
</div> </div>
</TableCell> </TableCell>
<TableCell className="tabular-nums">{formatCents(product.priceInCents)}</TableCell> <TableCell className="tabular-nums">{formatCents(product.priceInCents)}</TableCell>
<TableCell className="tabular-nums text-muted-foreground">
{product.buyPriceInCents === null ? '—' : formatCents(product.buyPriceInCents)}
</TableCell>
<TableCell><StockLevel product={product} /></TableCell> <TableCell><StockLevel product={product} /></TableCell>
<TableCell className="text-right tabular-nums"><ToOrder product={product} /></TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{product.stock === null {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 <span className="text-muted-foreground">—</span>;
return (
<span className={count > 0 ? 'font-medium' : 'text-muted-foreground'} title={`${t('stock.desired')}: ${product.desiredStock}`}>
{count}
</span>
);
}
function StockLevel({ product }: { product: Product }) { function StockLevel({ product }: { product: Product }) {
const { t } = useLang(); const { t } = useLang();
if (product.stock === null) return <span className="text-muted-foreground">{t('stock.not_tracked')}</span>; if (product.stock === null) return <span className="text-muted-foreground">{t('stock.not_tracked')}</span>;
@@ -460,7 +498,9 @@ function ProductDialog({ product, categories, venueId, onClose }: {
const [name, setName] = useState(product?.name ?? ''); const [name, setName] = useState(product?.name ?? '');
const [category, setCategory] = useState(product?.category ?? ''); const [category, setCategory] = useState(product?.category ?? '');
const [price, setPrice] = useState(product ? centsToInput(product.priceInCents) : ''); 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 [threshold, setThreshold] = useState(product?.lowStockThreshold?.toString() ?? '');
const [desired, setDesired] = useState(product?.desiredStock?.toString() ?? '');
const [trackStock, setTrackStock] = useState(true); const [trackStock, setTrackStock] = useState(true);
const [stock, setStock] = useState(''); const [stock, setStock] = useState('');
// A picked picture is uploaded on save; the server crops it to a square // 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 shownPicture = preview ?? (!removePicture && product?.imageId ? imageUrl(product.imageId) : null);
const priceInCents = parseEuros(price); 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 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 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); && (!isNew || !trackStock || stockValue !== null);
function done(title: string) { function done(title: string) {
@@ -495,7 +539,10 @@ function ProductDialog({ product, categories, venueId, onClose }: {
const save = useMutation({ const save = useMutation({
mutationFn: async () => { 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 const saved = product === null
? await stockApi.createProduct(venueId, { ...fields, stock: trackStock ? stockValue : null }) ? await stockApi.createProduct(venueId, { ...fields, stock: trackStock ? stockValue : null })
: await stockApi.updateProduct(venueId, product.productId, fields); : await stockApi.updateProduct(venueId, product.productId, fields);
@@ -571,11 +618,11 @@ function ProductDialog({ product, categories, venueId, onClose }: {
</div> </div>
</div> </div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="product-name">{t('products.name')}</Label> <Label htmlFor="product-name">{t('products.name')}</Label>
<Input id="product-name" value={name} maxLength={200} onChange={(e) => setName(e.target.value)} autoFocus /> <Input id="product-name" value={name} maxLength={200} onChange={(e) => setName(e.target.value)} autoFocus />
</div> </div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="product-category">{t('products.category')}</Label> <Label htmlFor="product-category">{t('products.category')}</Label>
<Input <Input
@@ -590,6 +637,8 @@ function ProductDialog({ product, categories, venueId, onClose }: {
{categories.map((c) => <option key={c} value={c} />)} {categories.map((c) => <option key={c} value={c} />)}
</datalist> </datalist>
</div> </div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="product-price">{t('products.price')}</Label> <Label htmlFor="product-price">{t('products.price')}</Label>
<Input <Input
@@ -601,22 +650,42 @@ function ProductDialog({ product, categories, venueId, onClose }: {
onKeyDown={(e) => e.key === 'Enter' && submit()} onKeyDown={(e) => e.key === 'Enter' && submit()}
/> />
</div> </div>
<div className="space-y-1.5">
<Label htmlFor="product-buy-price">{t('products.buy_price')}</Label>
<Input
id="product-buy-price"
inputMode="decimal"
value={buyPrice}
placeholder="1,20"
onChange={(e) => setBuyPrice(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
/>
</div>
</div> </div>
{isNew && ( {isNew && (
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium"> <label className="flex items-center gap-2 text-sm font-medium">
<input type="checkbox" checked={trackStock} onChange={(e) => setTrackStock(e.target.checked)} className="h-4 w-4" /> <input type="checkbox" checked={trackStock} onChange={(e) => setTrackStock(e.target.checked)} className="h-4 w-4" />
{t('products.track_stock')} {t('products.track_stock')}
</label> </label>
{trackStock && (
<div className="space-y-1.5">
<Label htmlFor="product-stock">{t('products.starting_stock')}</Label>
<Input id="product-stock" inputMode="numeric" value={stock} onChange={(e) => setStock(e.target.value)} className="max-w-[8rem]" />
</div>
)}
</div>
)} )}
{(!isNew ? product.stock !== null : trackStock) && ( {(!isNew ? product.stock !== null : trackStock) && (
<div className="grid gap-4 sm:grid-cols-2">
{isNew && (
<div className="space-y-1.5">
<Label htmlFor="product-stock">{t('products.starting_stock')}</Label>
<Input id="product-stock" inputMode="numeric" value={stock} onChange={(e) => setStock(e.target.value)} />
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="product-desired">{t('stock.desired')}</Label>
<Input
id="product-desired"
inputMode="numeric"
value={desired}
placeholder={t('stock.desired_none')}
onChange={(e) => setDesired(e.target.value)}
/>
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="product-threshold">{t('stock.threshold')}</Label> <Label htmlFor="product-threshold">{t('stock.threshold')}</Label>
<Input <Input
@@ -625,9 +694,9 @@ function ProductDialog({ product, categories, venueId, onClose }: {
value={threshold} value={threshold}
placeholder={t('stock.threshold_none')} placeholder={t('stock.threshold_none')}
onChange={(e) => setThreshold(e.target.value)} onChange={(e) => setThreshold(e.target.value)}
className="max-w-[8rem]"
/> />
</div> </div>
</div>
)} )}
{!isNew && ( {!isNew && (
<div className="border-t pt-4 space-y-2"> <div className="border-t pt-4 space-y-2">
+15 -23
View File
@@ -7,12 +7,13 @@ import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; 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 { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; 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 { EmptyState } from '@/components/empty-state';
import { LoadingState } from '@/components/loading-state'; import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state'; import { ErrorState } from '@/components/error-state';
@@ -25,12 +26,6 @@ import { useLang } from '@/lib/i18n';
import { useRealtime } from '@/lib/realtime'; import { useRealtime } from '@/lib/realtime';
import { canManageVenue, useSession } from '@/lib/session'; import { canManageVenue, useSession } from '@/lib/session';
type SortKey = 'name' | 'amount';
const comparators: Record<SortKey, (a: Tab, b: Tab) => number> = {
name: (a, b) => a.customerName.localeCompare(b.customerName, undefined, { sensitivity: 'base' }),
amount: (a, b) => b.totalInCents - a.totalInCents,
};
export default function TabsPage() { export default function TabsPage() {
// Live updates make polling unnecessary; it only runs while the socket is down // 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 [paymentMethod, setPaymentMethod] = useState('');
const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set()); const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set());
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortKey>('name'); const sort = useSort<'customer' | 'email' | 'amount' | 'items' | 'reported'>({ key: 'customer', direction: 'asc' });
const [plannedName, setPlannedName] = useState(''); const [plannedName, setPlannedName] = useState('');
const [plannedEmail, setPlannedEmail] = useState(''); const [plannedEmail, setPlannedEmail] = useState('');
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
@@ -143,9 +138,13 @@ export default function TabsPage() {
const tabs = data?.tabs ?? []; const tabs = data?.tabs ?? [];
const outstanding = tabs.reduce((sum, tab) => sum + tab.totalInCents, 0); const outstanding = tabs.reduce((sum, tab) => sum + tab.totalInCents, 0);
const query = search.trim().toLowerCase(); const query = search.trim().toLowerCase();
const visibleTabs = tabs const visibleTabs = sort.apply(tabs.filter((tab) => !query || tab.customerName.toLowerCase().includes(query)), {
.filter((tab) => !query || tab.customerName.toLowerCase().includes(query)) customer: (tab) => tab.customerName,
.sort(comparators[sort]); email: (tab) => tab.email,
amount: (tab) => tab.totalInCents,
items: (tab) => tab.itemCount,
reported: (tab) => new Date(tab.lastChangedAt),
});
return ( return (
<div> <div>
@@ -225,24 +224,17 @@ export default function TabsPage() {
aria-label={t('tabs.search_placeholder')} aria-label={t('tabs.search_placeholder')}
/> />
</div> </div>
<Select value={sort} onValueChange={(value) => setSort(value as SortKey)}>
<SelectTrigger className="w-44" aria-label={t('tabs.sort')}><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="name">{t('tabs.sort_name')}</SelectItem>
<SelectItem value="amount">{t('tabs.sort_amount')}</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t('tabs.customer')}</TableHead> <SortableHead sort={sort} column="customer">{t('tabs.customer')}</SortableHead>
<TableHead>{t('tabs.email')}</TableHead> <SortableHead sort={sort} column="email">{t('tabs.email')}</SortableHead>
<TableHead>{t('tabs.amount')}</TableHead> <SortableHead sort={sort} column="amount">{t('tabs.amount')}</SortableHead>
<TableHead>{t('tabs.items')}</TableHead> <SortableHead sort={sort} column="items">{t('tabs.items')}</SortableHead>
<TableHead>{t('tabs.reported')}</TableHead> <SortableHead sort={sort} column="reported">{t('tabs.reported')}</SortableHead>
<TableHead className="text-right">{t('tabs.actions')}</TableHead> <TableHead className="text-right">{t('tabs.actions')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -5,6 +5,8 @@ import { useState } from 'react';
import { usersApi, type User } from '@/lib/api/client'; import { usersApi, type User } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; 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 { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
@@ -28,6 +30,7 @@ export default function UsersPage() {
const [deleting, setDeleting] = useState<User | null>(null); const [deleting, setDeleting] = useState<User | null>(null);
const [form, setForm] = useState({ email: '', password: '', name: '', role: 'manager' }); const [form, setForm] = useState({ email: '', password: '', name: '', role: 'manager' });
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const sort = useSort<'name' | 'email' | 'role' | 'created'>();
const { t, tn, te, relativeTime } = useLang(); const { t, tn, te, relativeTime } = useLang();
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
@@ -92,15 +95,20 @@ export default function UsersPage() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t('users.name')}</TableHead> <SortableHead sort={sort} column="name">{t('users.name')}</SortableHead>
<TableHead>{t('users.email')}</TableHead> <SortableHead sort={sort} column="email">{t('users.email')}</SortableHead>
<TableHead>{t('users.role')}</TableHead> <SortableHead sort={sort} column="role">{t('users.role')}</SortableHead>
<TableHead>{t('users.created')}</TableHead> <SortableHead sort={sort} column="created">{t('users.created')}</SortableHead>
{isAdmin && <TableHead className="text-right">{t('tabs.actions')}</TableHead>} {isAdmin && <TableHead className="text-right">{t('tabs.actions')}</TableHead>}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{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) => (
<TableRow key={u.id}> <TableRow key={u.id}>
<TableCell className="font-medium">{u.name}</TableCell> <TableCell className="font-medium">{u.name}</TableCell>
<TableCell>{u.email}</TableCell> <TableCell>{u.email}</TableCell>
@@ -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<K extends string>({ sort, column, className, children }: {
sort: Sort<K>;
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 (
<TableHead className={className} aria-sort={active ? (sort.direction === 'asc' ? 'ascending' : 'descending') : 'none'}>
<button
type="button"
onClick={() => sort.toggle(column)}
className={cn(
'inline-flex items-center gap-1 -mx-1 px-1 rounded select-none hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
active && 'text-foreground',
right && 'flex-row-reverse',
center && 'justify-center',
)}
>
{children}
<Icon className={cn('h-3.5 w-3.5 shrink-0', !active && 'opacity-40')} />
</button>
</TableHead>
);
}
+35 -2
View File
@@ -26,7 +26,21 @@ export interface Tab {
email: string | null; 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 { export interface Command {
id: string; id: string;
@@ -119,10 +133,15 @@ export interface Product {
productId: string; productId: string;
name: string; name: string;
category: string | null; category: string | null;
/** The sell price, charged on the terminals */
priceInCents: number; 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 */ /** Null when stock is not tracked for this product; can be negative */
stock: number | null; stock: number | null;
lowStockThreshold: 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 */ /** The latest count, on the dashboard or a terminal; null when never counted */
countedAt: string | null; countedAt: string | null;
/** When it was created or last changed */ /** When it was created or last changed */
@@ -138,7 +157,10 @@ export function imageUrl(imageId: string): string {
return `${API_URL}/images/${imageId}`; 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 }; export type NewStockAdjustment = { productId: string; type: StockAdjustmentType; quantity: number; note?: string };
@@ -280,6 +302,17 @@ export const authApi = {
export const tabsApi = { export const tabsApi = {
list: (venueId: string) => list: (venueId: string) =>
api<{ tabs: Tab[] }>(`/venues/${venueId}/tabs`), 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] */ /** Deletes the tab without settling it; refused when it changed since [expectedVersion] */
remove: (venueId: string, tabId: string, expectedVersion: number) => remove: (venueId: string, tabId: string, expectedVersion: number) =>
api(`/venues/${venueId}/tabs/${encodeURIComponent(tabId)}?expectedVersion=${expectedVersion}`, { method: 'DELETE' }), api(`/venues/${venueId}/tabs/${encodeURIComponent(tabId)}?expectedVersion=${expectedVersion}`, { method: 'DELETE' }),
+66 -10
View File
@@ -81,9 +81,6 @@ const translations: Record<Lang, Record<string, string>> = {
'tabs.amount': 'Amount', 'tabs.amount': 'Amount',
'tabs.items': 'Items', 'tabs.items': 'Items',
'tabs.search_placeholder': 'Search customers…', 'tabs.search_placeholder': 'Search customers…',
'tabs.sort': 'Sort by',
'tabs.sort_name': 'Name',
'tabs.sort_amount': 'Highest amount',
'tabs.outstanding': '{amount} outstanding', 'tabs.outstanding': '{amount} outstanding',
'tabs.closing': 'Closing…', 'tabs.closing': 'Closing…',
'tabs.no_matches': 'No tabs match "{query}"', 'tabs.no_matches': 'No tabs match "{query}"',
@@ -110,6 +107,30 @@ const translations: Record<Lang, Record<string, string>> = {
'tabs.deleted': "{name}'s tab is deleted", 'tabs.deleted': "{name}'s tab is deleted",
'tabs.settled': 'Tab settled', 'tabs.settled': 'Tab settled',
'tabs.settled_desc': '{name}: {amount}', '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.title': 'Planned Customers',
'planned.name_placeholder': 'Add customer name...', 'planned.name_placeholder': 'Add customer name...',
'planned.email_placeholder': 'Email (optional)', 'planned.email_placeholder': 'Email (optional)',
@@ -328,7 +349,9 @@ const translations: Record<Lang, Record<string, string>> = {
'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.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': 'Category',
'products.category_placeholder': 'e.g. Frisdrank', '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.track_stock': 'Track stock',
'products.starting_stock': 'In stock now', 'products.starting_stock': 'In stock now',
'products.save': 'Save', 'products.save': 'Save',
@@ -346,7 +369,8 @@ const translations: Record<Lang, Record<string, string>> = {
'stock.no_matches': 'No products match', 'stock.no_matches': 'No products match',
'stock.no_category': 'Other', 'stock.no_category': 'Other',
'stock.product': 'Product', 'stock.product': 'Product',
'stock.price': 'Price', 'stock.price': 'Sell price',
'stock.buy_price': 'Buy price',
'stock.stock': 'Stock', 'stock.stock': 'Stock',
'stock.counted_at': 'Last counted', 'stock.counted_at': 'Last counted',
'stock.never_counted': 'Never', 'stock.never_counted': 'Never',
@@ -369,6 +393,10 @@ const translations: Record<Lang, Record<string, string>> = {
'stock.note_placeholder': 'e.g. delivery from the brewery', 'stock.note_placeholder': 'e.g. delivery from the brewery',
'stock.threshold': 'Warn when stock is at or below', 'stock.threshold': 'Warn when stock is at or below',
'stock.threshold_none': 'No warning', '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.send': 'Save',
'stock.sending': 'Sending…', 'stock.sending': 'Sending…',
'stock.sent': 'Stock updated', 'stock.sent': 'Stock updated',
@@ -635,9 +663,6 @@ const translations: Record<Lang, Record<string, string>> = {
'tabs.amount': 'Bedrag', 'tabs.amount': 'Bedrag',
'tabs.items': 'Items', 'tabs.items': 'Items',
'tabs.search_placeholder': 'Zoek klant…', 'tabs.search_placeholder': 'Zoek klant…',
'tabs.sort': 'Sorteer op',
'tabs.sort_name': 'Naam',
'tabs.sort_amount': 'Hoogste bedrag',
'tabs.outstanding': '{amount} openstaand', 'tabs.outstanding': '{amount} openstaand',
'tabs.closing': 'Wordt gesloten…', 'tabs.closing': 'Wordt gesloten…',
'tabs.no_matches': 'Geen poefs gevonden voor "{query}"', 'tabs.no_matches': 'Geen poefs gevonden voor "{query}"',
@@ -664,6 +689,30 @@ const translations: Record<Lang, Record<string, string>> = {
'tabs.deleted': 'Poef van {name} is verwijderd', 'tabs.deleted': 'Poef van {name} is verwijderd',
'tabs.settled': 'Poef afgerekend', 'tabs.settled': 'Poef afgerekend',
'tabs.settled_desc': '{name}: {amount}', '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.title': 'Geplande klanten',
'planned.name_placeholder': 'Naam van de klant...', 'planned.name_placeholder': 'Naam van de klant...',
'planned.email_placeholder': 'E-mail (optioneel)', 'planned.email_placeholder': 'E-mail (optioneel)',
@@ -882,7 +931,9 @@ const translations: Record<Lang, Record<string, string>> = {
'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.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': 'Categorie',
'products.category_placeholder': 'bv. Frisdrank', '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.track_stock': 'Voorraad bijhouden',
'products.starting_stock': 'Nu in voorraad', 'products.starting_stock': 'Nu in voorraad',
'products.save': 'Opslaan', 'products.save': 'Opslaan',
@@ -900,7 +951,8 @@ const translations: Record<Lang, Record<string, string>> = {
'stock.no_matches': 'Geen producten gevonden', 'stock.no_matches': 'Geen producten gevonden',
'stock.no_category': 'Overige', 'stock.no_category': 'Overige',
'stock.product': 'Product', 'stock.product': 'Product',
'stock.price': 'Prijs', 'stock.price': 'Verkoopprijs',
'stock.buy_price': 'Aankoopprijs',
'stock.stock': 'Voorraad', 'stock.stock': 'Voorraad',
'stock.counted_at': 'Laatst geteld', 'stock.counted_at': 'Laatst geteld',
'stock.never_counted': 'Nooit', 'stock.never_counted': 'Nooit',
@@ -923,6 +975,10 @@ const translations: Record<Lang, Record<string, string>> = {
'stock.note_placeholder': 'bv. levering brouwerij', 'stock.note_placeholder': 'bv. levering brouwerij',
'stock.threshold': 'Waarschuw vanaf', 'stock.threshold': 'Waarschuw vanaf',
'stock.threshold_none': 'Geen waarschuwing', '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.send': 'Opslaan',
'stock.sending': 'Versturen…', 'stock.sending': 'Versturen…',
'stock.sent': 'Voorraad bijgewerkt', 'stock.sent': 'Voorraad bijgewerkt',
+2 -1
View File
@@ -8,7 +8,8 @@ import { useSession } from '@/lib/session';
// Server topics mapped to the dashboard queries that show that data // Server topics mapped to the dashboard queries that show that data
const TOPIC_QUERIES: Record<string, string[][]> = { const TOPIC_QUERIES: Record<string, string[][]> = {
tabs: [['tabs'], ['reminder-candidates']], // A settlement changes the tabs, so it comes with this topic
tabs: [['tabs'], ['reminder-candidates'], ['tab-history']],
commands: [['commands']], commands: [['commands']],
'planned-tabs': [['planned-tabs'], ['reminder-candidates']], 'planned-tabs': [['planned-tabs'], ['reminder-candidates']],
device: [['device']], device: [['device']],
+53
View File
@@ -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<K extends string> {
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: <T>(rows: readonly T[], columns: Record<K, (row: T) => 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<K extends string>(initial?: { key: K; direction: SortDirection }): Sort<K> {
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);
});
},
};
}