@@ -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
|
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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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') });
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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 { 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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' }),
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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']],
|
||||||
|
|||||||
@@ -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