@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN "buyPriceInCents" INTEGER;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN "desiredStock" INTEGER;
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -7,8 +7,12 @@ import { StockService } from '../stock/stock.service';
|
||||
type Device = { id: string; venueId: string; revokedAt: null; lastSeenAt: Date };
|
||||
type SyncResult = Awaited<ReturnType<DevicesService['sync']>>;
|
||||
|
||||
// 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') });
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AdminDevice | null>(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 <ErrorState message={te(error)} />;
|
||||
|
||||
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() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('admin.devices.name')}</TableHead>
|
||||
<TableHead>{t('admin.devices.venue')}</TableHead>
|
||||
<TableHead>{t('admin.devices.status')}</TableHead>
|
||||
<TableHead>{t('admin.devices.last_seen')}</TableHead>
|
||||
<TableHead>{t('admin.devices.token_expires')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.devices.pending')}</TableHead>
|
||||
<SortableHead sort={sort} column="name">{t('admin.devices.name')}</SortableHead>
|
||||
<SortableHead sort={sort} column="venue">{t('admin.devices.venue')}</SortableHead>
|
||||
<SortableHead sort={sort} column="status">{t('admin.devices.status')}</SortableHead>
|
||||
<SortableHead sort={sort} column="lastSeen">{t('admin.devices.last_seen')}</SortableHead>
|
||||
<SortableHead sort={sort} column="tokenExpires">{t('admin.devices.token_expires')}</SortableHead>
|
||||
<SortableHead sort={sort} column="pending" className="text-right">{t('admin.devices.pending')}</SortableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@@ -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 <ErrorState message={te(error)} />;
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
@@ -94,11 +103,11 @@ export function UsersSection() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('users.name')}</TableHead>
|
||||
<TableHead>{t('admin.devices.venue')}</TableHead>
|
||||
<TableHead>{t('users.role')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.users.sessions')}</TableHead>
|
||||
<TableHead>{t('users.created')}</TableHead>
|
||||
<SortableHead sort={sort} column="name">{t('users.name')}</SortableHead>
|
||||
<SortableHead sort={sort} column="venue">{t('admin.devices.venue')}</SortableHead>
|
||||
<SortableHead sort={sort} column="role">{t('users.role')}</SortableHead>
|
||||
<SortableHead sort={sort} column="sessions" className="text-right">{t('admin.users.sessions')}</SortableHead>
|
||||
<SortableHead sort={sort} column="created">{t('users.created')}</SortableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@@ -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<string | null>(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() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('admin.venues.name')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.venues.users')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.venues.terminals')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.venues.open_tabs')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.venues.products')}</TableHead>
|
||||
<TableHead>{t('admin.venues.last_sync')}</TableHead>
|
||||
<SortableHead sort={sort} column="name">{t('admin.venues.name')}</SortableHead>
|
||||
<SortableHead sort={sort} column="users" className="text-right">{t('admin.venues.users')}</SortableHead>
|
||||
<SortableHead sort={sort} column="terminals" className="text-right">{t('admin.venues.terminals')}</SortableHead>
|
||||
<SortableHead sort={sort} column="openTabs" className="text-right">{t('admin.venues.open_tabs')}</SortableHead>
|
||||
<SortableHead sort={sort} column="products" className="text-right">{t('admin.venues.products')}</SortableHead>
|
||||
<SortableHead sort={sort} column="lastSync">{t('admin.venues.last_sync')}</SortableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<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
|
||||
key={venue.id}
|
||||
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,
|
||||
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' },
|
||||
|
||||
@@ -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<Set<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({
|
||||
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 <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 ?? [];
|
||||
|
||||
return (
|
||||
@@ -152,11 +170,11 @@ export default function RemindersPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('reminders.customer')}</TableHead>
|
||||
<TableHead>{t('reminders.email')}</TableHead>
|
||||
<TableHead>{t('reminders.amount')}</TableHead>
|
||||
<TableHead>{t('reminders.sent_at')}</TableHead>
|
||||
<TableHead>{t('reminders.status')}</TableHead>
|
||||
<SortableHead sort={historySort} column="customer">{t('reminders.customer')}</SortableHead>
|
||||
<SortableHead sort={historySort} column="email">{t('reminders.email')}</SortableHead>
|
||||
<SortableHead sort={historySort} column="amount">{t('reminders.amount')}</SortableHead>
|
||||
<SortableHead sort={historySort} column="sentAt">{t('reminders.sent_at')}</SortableHead>
|
||||
<SortableHead sort={historySort} column="status">{t('reminders.status')}</SortableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -236,12 +254,12 @@ export default function RemindersPage() {
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead>{t('reminders.customer')}</TableHead>
|
||||
<TableHead>{t('reminders.email')}</TableHead>
|
||||
<TableHead className="text-right">{t('reminders.amount')}</TableHead>
|
||||
<TableHead className="text-center">{t('reminders.count')}</TableHead>
|
||||
<TableHead>{t('reminders.last_sent')}</TableHead>
|
||||
<TableHead>{t('reminders.status')}</TableHead>
|
||||
<SortableHead sort={candidateSort} column="customer">{t('reminders.customer')}</SortableHead>
|
||||
<SortableHead sort={candidateSort} column="email">{t('reminders.email')}</SortableHead>
|
||||
<SortableHead sort={candidateSort} column="amount" className="text-right">{t('reminders.amount')}</SortableHead>
|
||||
<SortableHead sort={candidateSort} column="count" className="text-center">{t('reminders.count')}</SortableHead>
|
||||
<SortableHead sort={candidateSort} column="lastSent">{t('reminders.last_sent')}</SortableHead>
|
||||
<SortableHead sort={candidateSort} column="status">{t('reminders.status')}</SortableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -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<Product | 'new' | null>(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 (
|
||||
<div>
|
||||
@@ -161,17 +180,21 @@ export default function StockPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('stock.product')}</TableHead>
|
||||
<TableHead>{t('stock.price')}</TableHead>
|
||||
<TableHead>{t('stock.stock')}</TableHead>
|
||||
<TableHead>{t('stock.counted_at')}</TableHead>
|
||||
<SortableHead sort={sort} column="product">{t('stock.product')}</SortableHead>
|
||||
<SortableHead sort={sort} column="price">{t('stock.price')}</SortableHead>
|
||||
<SortableHead sort={sort} column="buyPrice">{t('stock.buy_price')}</SortableHead>
|
||||
<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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visible.length === 0 && (
|
||||
<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')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -179,7 +202,7 @@ export default function StockPage() {
|
||||
{groups.map((group) => (
|
||||
<Fragment key={group.category ?? ''}>
|
||||
<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')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -196,7 +219,11 @@ export default function StockPage() {
|
||||
</div>
|
||||
</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 className="text-right tabular-nums"><ToOrder product={product} /></TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{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 }) {
|
||||
const { t } = useLang();
|
||||
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 [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 }: {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="product-name">{t('products.name')}</Label>
|
||||
<Input id="product-name" value={name} maxLength={200} onChange={(e) => setName(e.target.value)} autoFocus />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="product-category">{t('products.category')}</Label>
|
||||
<Input
|
||||
@@ -590,6 +637,8 @@ function ProductDialog({ product, categories, venueId, onClose }: {
|
||||
{categories.map((c) => <option key={c} value={c} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="product-price">{t('products.price')}</Label>
|
||||
<Input
|
||||
@@ -601,22 +650,42 @@ function ProductDialog({ product, categories, venueId, onClose }: {
|
||||
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
||||
/>
|
||||
</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>
|
||||
{isNew && (
|
||||
<div className="space-y-2">
|
||||
<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" />
|
||||
{t('products.track_stock')}
|
||||
</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) && (
|
||||
<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">
|
||||
<Label htmlFor="product-threshold">{t('stock.threshold')}</Label>
|
||||
<Input
|
||||
@@ -625,9 +694,9 @@ function ProductDialog({ product, categories, venueId, onClose }: {
|
||||
value={threshold}
|
||||
placeholder={t('stock.threshold_none')}
|
||||
onChange={(e) => setThreshold(e.target.value)}
|
||||
className="max-w-[8rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isNew && (
|
||||
<div className="border-t pt-4 space-y-2">
|
||||
|
||||
@@ -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<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() {
|
||||
// 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<Set<string>>(new Set());
|
||||
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 [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 (
|
||||
<div>
|
||||
@@ -225,24 +224,17 @@ export default function TabsPage() {
|
||||
aria-label={t('tabs.search_placeholder')}
|
||||
/>
|
||||
</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 className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('tabs.customer')}</TableHead>
|
||||
<TableHead>{t('tabs.email')}</TableHead>
|
||||
<TableHead>{t('tabs.amount')}</TableHead>
|
||||
<TableHead>{t('tabs.items')}</TableHead>
|
||||
<TableHead>{t('tabs.reported')}</TableHead>
|
||||
<SortableHead sort={sort} column="customer">{t('tabs.customer')}</SortableHead>
|
||||
<SortableHead sort={sort} column="email">{t('tabs.email')}</SortableHead>
|
||||
<SortableHead sort={sort} column="amount">{t('tabs.amount')}</SortableHead>
|
||||
<SortableHead sort={sort} column="items">{t('tabs.items')}</SortableHead>
|
||||
<SortableHead sort={sort} column="reported">{t('tabs.reported')}</SortableHead>
|
||||
<TableHead className="text-right">{t('tabs.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@@ -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<User | null>(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() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('users.name')}</TableHead>
|
||||
<TableHead>{t('users.email')}</TableHead>
|
||||
<TableHead>{t('users.role')}</TableHead>
|
||||
<TableHead>{t('users.created')}</TableHead>
|
||||
<SortableHead sort={sort} column="name">{t('users.name')}</SortableHead>
|
||||
<SortableHead sort={sort} column="email">{t('users.email')}</SortableHead>
|
||||
<SortableHead sort={sort} column="role">{t('users.role')}</SortableHead>
|
||||
<SortableHead sort={sort} column="created">{t('users.created')}</SortableHead>
|
||||
{isAdmin && <TableHead className="text-right">{t('tabs.actions')}</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<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}>
|
||||
<TableCell className="font-medium">{u.name}</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>
|
||||
);
|
||||
}
|
||||
@@ -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' }),
|
||||
|
||||
@@ -81,9 +81,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'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<Lang, Record<string, string>> = {
|
||||
'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<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.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<Lang, Record<string, string>> = {
|
||||
'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<Lang, Record<string, string>> = {
|
||||
'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<Lang, Record<string, string>> = {
|
||||
'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<Lang, Record<string, string>> = {
|
||||
'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<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.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<Lang, Record<string, string>> = {
|
||||
'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<Lang, Record<string, string>> = {
|
||||
'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',
|
||||
|
||||
@@ -8,7 +8,8 @@ import { useSession } from '@/lib/session';
|
||||
|
||||
// Server topics mapped to the dashboard queries that show that data
|
||||
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']],
|
||||
'planned-tabs': [['planned-tabs'], ['reminder-candidates']],
|
||||
device: [['device']],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user