feat: make products server managed
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-20 23:55:40 +02:00
parent 0ca9f4d16a
commit e7f59f7583
14 changed files with 697 additions and 181 deletions
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "archivedAt" TIMESTAMP(3);
+8 -5
View File
@@ -232,12 +232,12 @@ model TabEvent {
@@index([venueId, type, occurredAt]) @@index([venueId, type, occurredAt])
} }
// The venue's products as reported by its terminals, with the stock. The server owns the stock: it follows the // The venue's products and their stock. The dashboard manages the catalog and the terminals get it on every sync;
// terminals' line events and stock counts and the dashboard's StockAdjustments. A product is never removed because // the first terminal's own catalog was taken over once. The stock follows the terminals' line events and stock
// one terminal doesn't report it. // counts and the dashboard's StockAdjustments.
model Product { model Product {
venueId String venueId String
// Generated by the terminal // Generated by the server, or by the terminal that reported it before the dashboard managed the catalog
productId String productId String
name String name String
category String? category String?
@@ -246,9 +246,12 @@ model Product {
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?
// Dashboard-only setting, never sent to or overwritten by the terminal // Warn when the stock drops to this level
lowStockThreshold Int? lowStockThreshold Int?
// 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
archivedAt DateTime?
venue Venue @relation(fields: [venueId], references: [id]) venue Venue @relation(fields: [venueId], references: [id])
+58 -19
View File
@@ -247,21 +247,67 @@ describe('DevicesService.sync: settlements feed', () => {
}); });
}); });
/** A product added on the dashboard, untracked. */
function addProduct(prisma: FakePrisma, productId = 'cola', fields: Record<string, unknown> = {}) {
prisma.product.rows.push({
venueId: 'v1', productId, name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: null, countedAt: null,
lowStockThreshold: null, archivedAt: null, reportedAt: new Date(), ...fields,
});
}
describe('DevicesService.sync: the dashboard manages the catalog', () => {
it('sends every product, archived ones marked, and ignores what a tablet reports', async () => {
const { sync, tabletA, prisma } = setup();
addProduct(prisma, 'cola', { lowStockThreshold: 6 });
addProduct(prisma, 'old', { name: 'Old', category: null, archivedAt: new Date() });
const result = await sync(tabletA, {
products: [
{ id: 'cola', name: 'Renamed on the tablet', category: null, priceInCents: 999, stock: 3 },
{ id: 'local', name: 'Seeded on the tablet', category: null, priceInCents: 100, stock: 24 },
],
});
// By category, products without one last (as Postgres sorts)
expect(result.catalog).toEqual([
{ id: 'cola', name: 'Cola', category: 'Frisdrank', priceInCents: 250, lowStockThreshold: 6, archived: false },
{ id: 'old', name: 'Old', category: null, priceInCents: 250, lowStockThreshold: null, archived: true },
]);
expect(prisma.product.rows).toHaveLength(2);
});
it("takes over the tablet's catalog when the venue is taken over", async () => {
const { sync, tabletA } = setup({ seeded: false });
const result = await sync(tabletA, { products: catalog(12) });
expect(result.catalog).toMatchObject([{ id: 'cola', name: 'Cola', archived: false }]);
expect(colaStock(result)).toBe(12);
});
});
describe('DevicesService.sync: the server holds the stock', () => { describe('DevicesService.sync: the server holds the stock', () => {
async function trackedCola(sync: ReturnType<typeof setup>['sync'], tablet: Device, stock = 48) { async function trackedCola(sync: ReturnType<typeof setup>['sync'], tablet: Device, stock = 48) {
return sync(tablet, { products: catalog(), events: [events.stockSet('cola', stock, -10)] }); return sync(tablet, { events: [events.stockSet('cola', stock, -10)] });
} }
it('starts a new product untracked and ignores the stock in the catalog', async () => { /** Every test here starts with Cola on the dashboard. */
const { sync, tabletA } = setup(); function setupWithCola() {
const venue = setup();
addProduct(venue.prisma);
return venue;
}
const result = await sync(tabletA, { products: catalog(99) }); it('ignores a count for a product the venue does not have', async () => {
const { sync, tabletA } = setupWithCola();
const result = await sync(tabletA, { events: [events.stockSet('local', 24)] });
expect(result.stock).toEqual([{ productId: 'cola', stock: null, countedAt: null }]); expect(result.stock).toEqual([{ productId: 'cola', stock: null, countedAt: null }]);
}); });
it('counts the sales of every tablet once', async () => { it('counts the sales of every tablet once', async () => {
const { sync, tabletA, tabletB } = setup(); const { sync, tabletA, tabletB } = setupWithCola();
await trackedCola(sync, tabletA); await trackedCola(sync, tabletA);
const sale = events.line('line_added', 't1', 2, { minutes: 1 }); const sale = events.line('line_added', 't1', 2, { minutes: 1 });
@@ -273,7 +319,7 @@ describe('DevicesService.sync: the server holds the stock', () => {
}); });
it('gives stock back for drinks taken off, but not for a settlement', async () => { it('gives stock back for drinks taken off, but not for a settlement', async () => {
const { sync, tabletA } = setup(); const { sync, tabletA } = setupWithCola();
await trackedCola(sync, tabletA); await trackedCola(sync, tabletA);
const result = await sync(tabletA, { const result = await sync(tabletA, {
@@ -289,7 +335,7 @@ describe('DevicesService.sync: the server holds the stock', () => {
}); });
it('keeps a count when sales from before it arrive later', async () => { it('keeps a count when sales from before it arrive later', async () => {
const { sync, tabletA, tabletB } = setup(); const { sync, tabletA, tabletB } = setupWithCola();
await trackedCola(sync, tabletA); await trackedCola(sync, tabletA);
await sync(tabletB, { events: [events.stockSet('cola', 30, 60)] }); await sync(tabletB, { events: [events.stockSet('cola', 30, 60)] });
@@ -309,7 +355,7 @@ describe('DevicesService.sync: the server holds the stock', () => {
}); });
it('applies a pending dashboard adjustment once and no longer sends adjustments', async () => { it('applies a pending dashboard adjustment once and no longer sends adjustments', async () => {
const { sync, tabletA, tabletB, prisma } = setup(); const { sync, tabletA, tabletB, prisma } = setupWithCola();
await trackedCola(sync, tabletA); await trackedCola(sync, tabletA);
prisma.stockAdjustment.rows.push({ prisma.stockAdjustment.rows.push({
id: 'adj-1', venueId: 'v1', productId: 'cola', productName: 'Cola', type: 'add', quantity: 24, id: 'adj-1', venueId: 'v1', productId: 'cola', productName: 'Cola', type: 'add', quantity: 24,
@@ -326,7 +372,7 @@ describe('DevicesService.sync: the server holds the stock', () => {
}); });
it('records results from older tablets that applied an adjustment themselves', async () => { it('records results from older tablets that applied an adjustment themselves', async () => {
const { sync, tabletA, prisma } = setup(); const { sync, tabletA, prisma } = setupWithCola();
await trackedCola(sync, tabletA); await trackedCola(sync, tabletA);
const id = uuidv4(); const id = uuidv4();
prisma.stockAdjustment.rows.push({ prisma.stockAdjustment.rows.push({
@@ -340,14 +386,6 @@ describe('DevicesService.sync: the server holds the stock', () => {
expect(colaStock(result)).toBe(48); expect(colaStock(result)).toBe(48);
}); });
it('never removes a product another tablet does not know', async () => {
const { sync, tabletA, tabletB } = setup();
await trackedCola(sync, tabletA);
const result = await sync(tabletB, { products: [{ id: 'water', name: 'Water', category: null, priceInCents: 200, stock: 3 }] });
expect(result.stock.map((s) => s.productId).sort()).toEqual(['cola', 'water']);
});
}); });
describe('DevicesService.sync live update signals', () => { describe('DevicesService.sync live update signals', () => {
@@ -362,9 +400,10 @@ describe('DevicesService.sync live update signals', () => {
}); });
it('signals "tabs" and "stock" when they changed', async () => { it('signals "tabs" and "stock" when they changed', async () => {
const { sync, tabletA, realtime } = setup(); const { sync, tabletA, realtime, prisma } = setup();
addProduct(prisma);
await sync(tabletA, { products: catalog(), events: [events.opened('t1', 'Alice')] }); await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.stockSet('cola', 5)] });
expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs', 'stock'); expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs', 'stock');
}); });
+22 -5
View File
@@ -58,8 +58,10 @@ export class DevicesService {
// Locks the venue: other terminals' syncs wait, so no change is lost // Locks the venue: other terminals' syncs wait, so no change is lost
const ledger = await VenueLedger.open(tx, venueId); const ledger = await VenueLedger.open(tx, venueId);
// 1. The catalog: names and prices only, the stock is the server's // 1. The dashboard manages the catalog: a terminal's own catalog is only taken over once, with the tabs
const productsChanged = reported ? await this.saveProducts(tx, ledger, reported.products, now) : false; const productsChanged = reported && !ledger.seeded
? await this.saveProducts(tx, ledger, reported.products, now)
: false;
// 2. Events, each applied once: a terminal resends an event until its acknowledgement arrives // 2. Events, each applied once: a terminal resends an event until its acknowledgement arrives
const unique = [...new Map(events.accepted.map((event) => [event.id, event])).values()]; const unique = [...new Map(events.accepted.map((event) => [event.id, event])).values()];
@@ -112,10 +114,16 @@ export class DevicesService {
await tx.device.update({ where: { id: device.id }, data: { lastSeenAt: now } }); await tx.device.update({ where: { id: device.id }, data: { lastSeenAt: now } });
const feed = await settlementsAfter(tx, venueId, dto.settlementsCursor, now); const feed = await settlementsAfter(tx, venueId, dto.settlementsCursor, now);
const catalog = await tx.product.findMany({
where: { venueId },
orderBy: [{ category: 'asc' }, { name: 'asc' }],
select: { productId: true, name: true, category: true, priceInCents: true, lowStockThreshold: true, archivedAt: true },
});
return { return {
ledger, ledger,
feed, feed,
catalog,
topics: { topics: {
tabs: ledger.changedTabs, tabs: ledger.changedTabs,
plannedTabs: plannedDelivered > 0, plannedTabs: plannedDelivered > 0,
@@ -156,7 +164,7 @@ export class DevicesService {
if (now.getTime() - new Date(device.lastSeenAt).getTime() > DEVICE_ONLINE_THRESHOLD_MS) topics.push('device'); if (now.getTime() - new Date(device.lastSeenAt).getTime() > DEVICE_ONLINE_THRESHOLD_MS) topics.push('device');
if (topics.length > 0) this.realtime.notify(venueId, ...topics); if (topics.length > 0) this.realtime.notify(venueId, ...topics);
const { ledger, feed } = synced; const { ledger, feed, catalog } = synced;
return { return {
cursor, cursor,
commands: commands.map((c) => ({ commands: commands.map((c) => ({
@@ -182,6 +190,15 @@ export class DevicesService {
rejectedStockAdjustmentResults: stockResults.rejected, rejectedStockAdjustmentResults: stockResults.rejected,
// The venue's complete state after this request's events: the terminal replaces its own with it // The venue's complete state after this request's events: the terminal replaces its own with it
tabs: ledger.tabs.all().map(tabJson), tabs: ledger.tabs.all().map(tabJson),
// Every product of the venue; an archived one is hidden on the terminal (tabs and history refer to it)
catalog: catalog.map((p) => ({
id: p.productId,
name: p.name,
category: p.category,
priceInCents: p.priceInCents,
lowStockThreshold: p.lowStockThreshold,
archived: p.archivedAt !== null,
})),
stock: ledger.stock.entries().map(([productId, { stock, countedAt }]) => ( stock: ledger.stock.entries().map(([productId, { stock, countedAt }]) => (
{ productId, stock, countedAt: countedAt?.toISOString() ?? null })), { productId, stock, countedAt: countedAt?.toISOString() ?? null })),
settlements: feed.settlements, settlements: feed.settlements,
@@ -190,8 +207,8 @@ export class DevicesService {
} }
/** /**
* Upserts the reported products without their stock, and never removes one: another terminal may still sell it. * Takes over the terminal's catalog on the first sync since the server holds the tabs; the dashboard manages it
* Returns whether anything the dashboard shows changed. * from then on. Never removes a product. Returns whether anything the dashboard shows changed.
*/ */
private async saveProducts(tx: Prisma.TransactionClient, ledger: VenueLedger, products: ParsedProduct[], now: Date) { private async saveProducts(tx: Prisma.TransactionClient, ledger: VenueLedger, products: ParsedProduct[], now: Date) {
const stored = await tx.product.findMany({ const stored = await tx.product.findMany({
+77 -4
View File
@@ -1,11 +1,84 @@
import { IsInt, Min, Max, ValidateIf } from 'class-validator'; import { IsInt, Min, Max, ValidateIf, IsString, IsNotEmpty, MaxLength, IsOptional, IsBoolean } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
const trim = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value);
// An empty category is no category
const trimToNull = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() || null : value);
export class CreateProductDto {
@ApiProperty({ example: 'Cola' })
@Transform(trim)
@IsString()
@IsNotEmpty()
@MaxLength(200)
name: string;
@ApiPropertyOptional({ example: 'Frisdrank', nullable: true })
@IsOptional()
@Transform(trimToNull)
@IsString()
@MaxLength(100)
category?: string | null;
@ApiProperty({ example: 250 })
@IsInt()
@Min(0)
@Max(1_000_000)
priceInCents: number;
@ApiPropertyOptional({ example: 24, nullable: true, description: 'The starting count; null or left out: stock is not tracked' })
@IsOptional()
@ValidateIf((dto: CreateProductDto) => dto.stock !== null)
@IsInt()
@Min(0)
@Max(100000)
stock?: number | null;
@ApiPropertyOptional({ example: 12, nullable: true, description: 'Warn when stock drops to this level; null for no warning' })
@IsOptional()
@ValidateIf((dto: CreateProductDto) => dto.lowStockThreshold !== null)
@IsInt()
@Min(0)
@Max(100000)
lowStockThreshold?: number | null;
}
/** Only the fields that are sent change. The stock itself changes through stock adjustments. */
export class UpdateProductDto { export class UpdateProductDto {
@ApiProperty({ example: 12, nullable: true, description: 'Warn when stock drops to this level; null for no warning' }) @ApiPropertyOptional({ example: 'Cola Zero' })
@IsOptional()
@Transform(trim)
@IsString()
@IsNotEmpty()
@MaxLength(200)
name?: string;
@ApiPropertyOptional({ example: 'Frisdrank', nullable: true })
@IsOptional()
@Transform(trimToNull)
@ValidateIf((dto: UpdateProductDto) => dto.category !== null)
@IsString()
@MaxLength(100)
category?: string | null;
@ApiPropertyOptional({ example: 250 })
@IsOptional()
@IsInt()
@Min(0)
@Max(1_000_000)
priceInCents?: number;
@ApiPropertyOptional({ example: 12, nullable: true, description: 'Warn when stock drops to this level; null for no warning' })
@IsOptional()
@ValidateIf((dto: UpdateProductDto) => dto.lowStockThreshold !== null) @ValidateIf((dto: UpdateProductDto) => dto.lowStockThreshold !== null)
@IsInt() @IsInt()
@Min(0) @Min(0)
@Max(100000) @Max(100000)
lowStockThreshold: number | null; lowStockThreshold?: number | null;
@ApiPropertyOptional({ description: 'true hides the product on the terminals (deleting it); false brings it back' })
@IsOptional()
@IsBoolean()
archived?: boolean;
} }
+15 -6
View File
@@ -1,4 +1,4 @@
import { Controller, Get, Post, Put, Param, Query, Body, UseGuards, HttpCode, HttpStatus, Req } from '@nestjs/common'; import { Controller, Get, Post, Patch, Param, Query, Body, UseGuards, HttpCode, HttpStatus, Req } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { VenueAccessGuard } from '../common/guards/venue-access.guard'; import { VenueAccessGuard } from '../common/guards/venue-access.guard';
@@ -6,7 +6,7 @@ import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator'; import { Roles } from '../common/decorators/roles.decorator';
import { StockService } from './stock.service'; import { StockService } from './stock.service';
import { CreateStockAdjustmentsDto } from './dto/create-stock-adjustments.dto'; import { CreateStockAdjustmentsDto } from './dto/create-stock-adjustments.dto';
import { UpdateProductDto } from './dto/update-product.dto'; import { CreateProductDto, UpdateProductDto } from './dto/update-product.dto';
@ApiTags('Stock') @ApiTags('Stock')
@ApiBearerAuth() @ApiBearerAuth()
@@ -16,7 +16,7 @@ export class StockController {
constructor(private service: StockService) {} constructor(private service: StockService) {}
@Get() @Get()
@ApiOperation({ summary: "The terminal's products with their stock and pending adjustments" }) @ApiOperation({ summary: "The venue's products (also archived ones) with their stock" })
async findAll(@Param('venueId') venueId: string) { async findAll(@Param('venueId') venueId: string) {
return this.service.findAll(venueId); return this.service.findAll(venueId);
} }
@@ -53,14 +53,23 @@ export class StockController {
return this.service.cancelAdjustment(venueId, id, req.user); return this.service.cancelAdjustment(venueId, id, req.user);
} }
@Put('products/:productId') // The catalog is managed here; the terminals get it on every sync
@Post('products')
@Roles('admin') @Roles('admin')
@ApiOperation({ summary: 'Set the low stock warning level of a product (admin)' }) @ApiOperation({ summary: 'Add a product (admin)' })
async createProduct(@Param('venueId') venueId: string, @Body() dto: CreateProductDto, @Req() req: any) {
return this.service.createProduct(venueId, dto, req.user);
}
@Patch('products/:productId')
@Roles('admin')
@ApiOperation({ summary: 'Change, archive (delete) or restore a product (admin)' })
async updateProduct( async updateProduct(
@Param('venueId') venueId: string, @Param('venueId') venueId: string,
@Param('productId') productId: string, @Param('productId') productId: string,
@Body() dto: UpdateProductDto, @Body() dto: UpdateProductDto,
@Req() req: any,
) { ) {
return this.service.updateProduct(venueId, productId, dto.lowStockThreshold); return this.service.updateProduct(venueId, productId, dto, req.user);
} }
} }
+83 -1
View File
@@ -26,7 +26,10 @@ function createWithVenue(products: { productId: string; name: string; stock: num
const prisma = createFakePrisma(); const prisma = createFakePrisma();
prisma.venue.rows.push({ id: 'v1', tabsSeededAt: new Date('2026-10-01') }); prisma.venue.rows.push({ id: 'v1', tabsSeededAt: new Date('2026-10-01') });
for (const p of products) { for (const p of products) {
prisma.product.rows.push({ venueId: 'v1', ...p, category: null, priceInCents: 250, countedAt: null, reportedAt: new Date() }); prisma.product.rows.push({
venueId: 'v1', ...p, category: null, priceInCents: 250, countedAt: null, lowStockThreshold: null, archivedAt: null,
reportedAt: new Date(),
});
} }
const realtime = { notify: jest.fn() }; const realtime = { notify: jest.fn() };
const service = new StockService(prisma as any, { log: jest.fn() } as any, realtime as any); const service = new StockService(prisma as any, { log: jest.fn() } as any, realtime as any);
@@ -101,3 +104,82 @@ describe('StockService.cancelAdjustment', () => {
.rejects.toMatchObject({ response: { code: 'adjustment_not_found' } }); .rejects.toMatchObject({ response: { code: 'adjustment_not_found' } });
}); });
}); });
describe('StockService products', () => {
const admin = { id: 'u1', email: 'bram@example.com', name: 'Bram' };
it('adds a product with a starting count, kept in the stock history', async () => {
const { service, prisma, realtime } = createWithVenue([cola]);
const product = await service.createProduct('v1', { name: 'Water', category: 'Frisdrank', priceInCents: 200, stock: 24 }, admin);
expect(product).toMatchObject({ name: 'Water', category: 'Frisdrank', priceInCents: 200, stock: 24, archivedAt: null });
expect(prisma.product.rows.find((p) => p.productId === product.productId)!.countedAt).toBeInstanceOf(Date);
expect(prisma.stockAdjustment.rows).toMatchObject([
{ productId: product.productId, type: 'set', quantity: 24, status: 'applied', stockAfter: 24, requestedBy: 'Bram' },
]);
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
});
it('adds an untracked product without history', async () => {
const { service, prisma } = createWithVenue([]);
const product = await service.createProduct('v1', { name: 'Tap water', priceInCents: 0 }, admin);
expect(product).toMatchObject({ stock: null, category: null, lowStockThreshold: null });
expect(prisma.stockAdjustment.rows).toEqual([]);
});
it('refuses a second active product with the same name, ignoring case', async () => {
const { service } = createWithVenue([cola]);
await expect(service.createProduct('v1', { name: ' cola ', priceInCents: 250 }, admin))
.rejects.toMatchObject({ response: { code: 'product_name_exists' } });
});
it('changes only the fields that are sent', async () => {
const { service, prisma } = createWithVenue([cola]);
await service.updateProduct('v1', 'p1', { priceInCents: 300, category: null }, admin);
expect(prisma.product.rows[0]).toMatchObject({ name: 'Cola', priceInCents: 300, category: null, stock: 10, archivedAt: null });
});
it('archives a product and brings it back, unless its name was taken meanwhile', async () => {
const { service, prisma } = createWithVenue([cola]);
await service.updateProduct('v1', 'p1', { archived: true }, admin);
expect(prisma.product.rows[0].archivedAt).toBeInstanceOf(Date);
await service.createProduct('v1', { name: 'Cola', priceInCents: 250 }, admin);
await expect(service.updateProduct('v1', 'p1', { archived: false }, admin))
.rejects.toMatchObject({ response: { code: 'product_name_exists' } });
await service.updateProduct('v1', 'p1', { archived: false, name: 'Cola (old)' }, admin);
expect(prisma.product.rows[0]).toMatchObject({ name: 'Cola (old)', archivedAt: null });
});
it('404s for a product of another venue', async () => {
const { service } = createWithVenue([cola]);
await expect(service.updateProduct('v1', 'p9', { priceInCents: 1 }, admin))
.rejects.toMatchObject({ response: { code: 'product_not_found' } });
});
});
describe('StockService.findAll', () => {
it('sends when each product was last counted, archived ones included', async () => {
const countedAt = new Date('2026-10-20T08:00:00Z');
const { service, prisma } = createWithVenue([cola, water]);
prisma.product.rows[0].countedAt = countedAt;
prisma.product.rows[1].archivedAt = new Date();
const { products } = await service.findAll('v1');
expect(products).toEqual([
expect.objectContaining({ productId: 'p1', stock: 10, countedAt, archivedAt: null }),
expect.objectContaining({ productId: 'p2', countedAt: null, archivedAt: expect.any(Date) }),
]);
expect(products[0]).not.toHaveProperty('pendingAdjustments');
});
});
+125 -36
View File
@@ -1,8 +1,11 @@
import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { RealtimeService } from '../realtime/realtime.service'; import { RealtimeService } from '../realtime/realtime.service';
import { StockAdjustmentEntryDto } from './dto/create-stock-adjustments.dto'; import { StockAdjustmentEntryDto } from './dto/create-stock-adjustments.dto';
import { CreateProductDto, UpdateProductDto } from './dto/update-product.dto';
import { VenueLedger } from '../tabs/venue-ledger'; import { VenueLedger } from '../tabs/venue-ledger';
import { applyPendingAdjustments } from './stock-adjustments'; import { applyPendingAdjustments } from './stock-adjustments';
@@ -29,36 +32,16 @@ export class StockService {
private realtime: RealtimeService, private realtime: RealtimeService,
) {} ) {}
/** Adjustments are applied at once, so there is nothing pending to show with the products. */
async findAll(venueId: string) { async findAll(venueId: string) {
const [products, pending] = await Promise.all([ const products = await this.prisma.product.findMany({
this.prisma.product.findMany({ where: { venueId },
where: { venueId }, orderBy: [{ category: 'asc' }, { name: 'asc' }],
orderBy: [{ category: 'asc' }, { name: 'asc' }], });
}),
this.prisma.stockAdjustment.findMany({
where: { venueId, status: 'pending' },
orderBy: { createdAt: 'asc' },
select: adjustmentSelect,
}),
]);
const pendingByProduct = new Map<string, typeof pending>();
for (const adjustment of pending) {
pendingByProduct.set(adjustment.productId, [...(pendingByProduct.get(adjustment.productId) ?? []), adjustment]);
}
return { return {
products: products.map((p) => ({ products: products.map(productView),
productId: p.productId, // Null until the venue has products
name: p.name,
category: p.category,
priceInCents: p.priceInCents,
stock: p.stock,
lowStockThreshold: p.lowStockThreshold,
reportedAt: p.reportedAt,
pendingAdjustments: pendingByProduct.get(p.productId) ?? [],
})),
// Null until a terminal running an app version with stock has synced
reportedAt: products.reduce<Date | null>((latest, p) => (!latest || p.reportedAt > latest ? p.reportedAt : latest), null), reportedAt: products.reduce<Date | null>((latest, p) => (!latest || p.reportedAt > latest ? p.reportedAt : latest), null),
}; };
} }
@@ -172,17 +155,123 @@ export class StockService {
return { ok: true }; return { ok: true };
} }
async updateProduct(venueId: string, productId: string, lowStockThreshold: number | null) { /**
const { count } = await this.prisma.product.updateMany({ * Adds a product to the venue's catalog; the terminals get it on their next sync. A starting count tracks its stock
where: { venueId, productId }, * from now on and is kept in the stock history.
data: { lowStockThreshold }, */
}); async createProduct(venueId: string, dto: CreateProductDto, user: { id: string; email: string; name?: string }) {
if (count === 0) { const product = await this.prisma.$transaction(async (tx) => {
throw new NotFoundException({ code: 'product_not_found', message: 'This product is not on the terminal' }); const now = new Date();
} // Locked like a sync, so a name check and the stock can't race with one
await VenueLedger.open(tx, venueId);
await this.ensureNameFree(tx, venueId, dto.name);
const stock = dto.stock ?? null;
const created = await tx.product.create({
data: {
venueId,
productId: uuidv4(),
name: dto.name,
category: dto.category ?? null,
priceInCents: dto.priceInCents,
stock,
countedAt: stock === null ? null : now,
lowStockThreshold: dto.lowStockThreshold ?? null,
reportedAt: now,
},
});
if (stock !== null) {
await tx.stockAdjustment.create({
data: {
venueId,
productId: created.productId,
productName: created.name,
type: 'set',
quantity: stock,
requestedBy: user.name || user.email,
requestedById: user.id,
status: 'applied',
stockAfter: stock,
createdAt: now,
processedAt: now,
},
});
}
return created;
});
await this.auditService.log(venueId, 'client_user', user.id, 'product_created', {
productId: product.productId, name: product.name, priceInCents: product.priceInCents,
});
this.realtime.notify(venueId, 'stock'); this.realtime.notify(venueId, 'stock');
return { ok: true }; return productView(product);
}
/**
* Changes a product's name, category, price, warning level, or archives it (a deleted product stays, hidden, because
* tabs and history refer to it). Lines already on a tab keep the name and price they were rung up with.
*/
async updateProduct(venueId: string, productId: string, dto: UpdateProductDto, user: { id: string }) {
const product = await this.prisma.$transaction(async (tx) => {
await VenueLedger.open(tx, venueId);
const existing = await tx.product.findUnique({ where: { venueId_productId: { venueId, productId } } });
if (!existing) {
throw new NotFoundException({ code: 'product_not_found', message: 'Product not found' });
}
const archived = dto.archived ?? existing.archivedAt != null;
const name = dto.name ?? existing.name;
// A product coming back, or renamed, must not take an active product's name
if (!archived && (name !== existing.name || existing.archivedAt != null)) {
await this.ensureNameFree(tx, venueId, name, productId);
}
return tx.product.update({
where: { venueId_productId: { venueId, productId } },
data: {
name,
...(dto.category !== undefined ? { category: dto.category } : {}),
...(dto.priceInCents != null ? { priceInCents: dto.priceInCents } : {}),
...(dto.lowStockThreshold !== undefined ? { lowStockThreshold: dto.lowStockThreshold } : {}),
archivedAt: archived ? (existing.archivedAt ?? new Date()) : null,
reportedAt: new Date(),
},
});
});
await this.auditService.log(venueId, 'client_user', user.id, 'product_updated', { productId, changes: dto });
this.realtime.notify(venueId, 'stock');
return productView(product);
}
private async ensureNameFree(tx: Prisma.TransactionClient, venueId: string, name: string, exceptProductId?: string) {
const products = await tx.product.findMany({
where: { venueId, archivedAt: null },
select: { productId: true, name: true },
});
const key = name.trim().toLowerCase();
if (products.some((p) => p.productId !== exceptProductId && p.name.trim().toLowerCase() === key)) {
throw new ConflictException({ code: 'product_name_exists', message: 'A product with this name already exists' });
}
} }
} }
function productView(p: {
productId: string; name: string; category: string | null; priceInCents: number; stock: number | null;
lowStockThreshold: number | null; countedAt: Date | null; reportedAt: Date; archivedAt: Date | null;
}) {
return {
productId: p.productId,
name: p.name,
category: p.category,
priceInCents: p.priceInCents,
stock: p.stock,
lowStockThreshold: p.lowStockThreshold,
// The latest count, on the dashboard or a terminal: how fresh the stock is
countedAt: p.countedAt,
reportedAt: p.reportedAt,
archivedAt: p.archivedAt,
};
}
+6 -1
View File
@@ -60,6 +60,11 @@ function sort(rows: Row[], orderBy?: Row | Row[]) {
return [...rows].sort((a, b) => { return [...rows].sort((a, b) => {
for (const order of orders) { for (const order of orders) {
const [field, direction] = Object.entries(order)[0]; const [field, direction] = Object.entries(order)[0];
// Postgres puts nulls last when ascending, first when descending
if ((a[field] ?? null) === null || (b[field] ?? null) === null) {
if ((a[field] ?? null) === (b[field] ?? null)) continue;
return ((a[field] ?? null) === null ? 1 : -1) * (direction === 'asc' ? 1 : -1);
}
if (a[field] < b[field]) return direction === 'asc' ? -1 : 1; if (a[field] < b[field]) return direction === 'asc' ? -1 : 1;
if (a[field] > b[field]) return direction === 'asc' ? 1 : -1; if (a[field] > b[field]) return direction === 'asc' ? 1 : -1;
} }
@@ -153,7 +158,7 @@ export function createFakePrisma() {
venue: new FakeTable(() => ({ tabsSeededAt: null })), venue: new FakeTable(() => ({ tabsSeededAt: null })),
device: new FakeTable(), device: new FakeTable(),
tab: new FakeTable(() => ({ updatedAt: now() })), tab: new FakeTable(() => ({ updatedAt: now() })),
product: new FakeTable(() => ({ stock: null, countedAt: null, lowStockThreshold: null })), product: new FakeTable(() => ({ stock: null, countedAt: null, lowStockThreshold: null, archivedAt: null })),
tabEvent: new FakeTable(), tabEvent: new FakeTable(),
settlement: new FakeTable(() => ({ createdAt: now() }), 'sequence'), settlement: new FakeTable(() => ({ createdAt: now() }), 'sequence'),
stockAdjustment: new FakeTable(() => ({ status: 'pending', stockAfter: null, result: null, processedAt: null, createdAt: now() })), stockAdjustment: new FakeTable(() => ({ status: 'pending', stockAfter: null, result: null, processedAt: null, createdAt: now() })),
+199 -73
View File
@@ -16,19 +16,20 @@ 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';
import { toast } from '@/components/ui/toast'; import { toast } from '@/components/ui/toast';
import { formatCents } from '@/lib/utils'; import { centsToInput, formatCents, parseEuros } from '@/lib/utils';
import { RefreshCw, Search, Truck, X, History } from 'lucide-react'; import { RefreshCw, Search, Truck, History, Plus, Pencil } from 'lucide-react';
import { useLang } from '@/lib/i18n'; import { useLang } from '@/lib/i18n';
import { useRealtime } from '@/lib/realtime'; import { useRealtime } from '@/lib/realtime';
import { useSession } from '@/lib/session'; import { useSession } from '@/lib/session';
type Filter = 'all' | 'low' | 'untracked'; type Filter = 'all' | 'low' | 'untracked' | 'archived';
type Mode = 'add' | 'remove' | 'count'; type Mode = 'add' | 'remove' | 'count';
const filters: { value: Filter; label: string }[] = [ const filters: { value: Filter; label: string }[] = [
{ value: 'all', label: 'stock.filter_all' }, { value: 'all', label: 'stock.filter_all' },
{ value: 'low', label: 'stock.filter_low' }, { value: 'low', label: 'stock.filter_low' },
{ value: 'untracked', label: 'stock.filter_untracked' }, { value: 'untracked', label: 'stock.filter_untracked' },
{ value: 'archived', label: 'stock.filter_archived' },
]; ];
/** At or below its warning level, or out of stock. */ /** At or below its warning level, or out of stock. */
@@ -59,11 +60,13 @@ export default function StockPage() {
const { venueId, role } = useSession(); const { venueId, role } = useSession();
const isAdmin = role === 'admin'; const isAdmin = role === 'admin';
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t, tn, te, relativeTime } = useLang(); const { t, tn, te, relativeTime, dateTime } = useLang();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [filter, setFilter] = useState<Filter>('all'); const [filter, setFilter] = useState<Filter>('all');
const [adjusting, setAdjusting] = useState<Product | null>(null); const [adjusting, setAdjusting] = useState<Product | null>(null);
const [deliveryOpen, setDeliveryOpen] = useState(false); const [deliveryOpen, setDeliveryOpen] = useState(false);
// null: closed; 'new': adding a product
const [editing, setEditing] = useState<Product | 'new' | null>(null);
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ['stock', venueId], queryKey: ['stock', venueId],
@@ -77,23 +80,16 @@ export default function StockPage() {
refetchInterval: live ? false : 15000, refetchInterval: live ? false : 15000,
}); });
const cancelMutation = useMutation({
mutationFn: (id: string) => stockApi.cancel(venueId, id),
onSuccess: () => toast({ title: t('stock.cancelled') }),
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['stock'] });
queryClient.invalidateQueries({ queryKey: ['stock-history'] });
},
});
if (isLoading) return <LoadingState />; if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={te(error, 'stock.load_failed')} />; if (error) return <ErrorState message={te(error, 'stock.load_failed')} />;
const products = data?.products ?? []; const allProducts = data?.products ?? [];
// Archived products are deleted for the terminals; they only show under their own filter
const products = allProducts.filter((p) => p.archivedAt === null);
const lowCount = products.filter(isLow).length; const lowCount = products.filter(isLow).length;
const categories = [...new Set(allProducts.map((p) => p.category).filter((c): c is string => c !== null))].sort();
const query = search.trim().toLowerCase(); const query = search.trim().toLowerCase();
const visible = products.filter((p) => { const visible = (filter === 'archived' ? allProducts.filter((p) => p.archivedAt !== null) : products).filter((p) => {
if (query && !p.name.toLowerCase().includes(query) && !(p.category ?? '').toLowerCase().includes(query)) return false; if (query && !p.name.toLowerCase().includes(query) && !(p.category ?? '').toLowerCase().includes(query)) return false;
if (filter === 'low') return isLow(p); if (filter === 'low') return isLow(p);
if (filter === 'untracked') return p.stock === null; if (filter === 'untracked') return p.stock === null;
@@ -121,15 +117,20 @@ export default function StockPage() {
<RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')} <RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}
</Button> </Button>
{products.some((p) => p.stock !== null) && ( {products.some((p) => p.stock !== null) && (
<Button size="sm" onClick={() => setDeliveryOpen(true)}> <Button size="sm" variant="outline" onClick={() => setDeliveryOpen(true)}>
<Truck className="h-4 w-4 mr-2" />{t('stock.delivery')} <Truck className="h-4 w-4 mr-2" />{t('stock.delivery')}
</Button> </Button>
)} )}
{isAdmin && (
<Button size="sm" onClick={() => setEditing('new')}>
<Plus className="h-4 w-4 mr-2" />{t('products.new')}
</Button>
)}
</> </>
} }
/> />
{products.length === 0 ? ( {allProducts.length === 0 ? (
<EmptyState title={t('stock.empty')} description={t('stock.empty_desc')} /> <EmptyState title={t('stock.empty')} description={t('stock.empty_desc')} />
) : ( ) : (
<> <>
@@ -159,7 +160,7 @@ export default function StockPage() {
<TableHead>{t('stock.product')}</TableHead> <TableHead>{t('stock.product')}</TableHead>
<TableHead>{t('stock.price')}</TableHead> <TableHead>{t('stock.price')}</TableHead>
<TableHead>{t('stock.stock')}</TableHead> <TableHead>{t('stock.stock')}</TableHead>
<TableHead>{t('stock.pending')}</TableHead> <TableHead>{t('stock.counted_at')}</TableHead>
<TableHead className="text-right">{t('stock.actions')}</TableHead> <TableHead className="text-right">{t('stock.actions')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -180,31 +181,30 @@ export default function StockPage() {
</TableRow> </TableRow>
{group.products.map((product) => ( {group.products.map((product) => (
<TableRow key={product.productId}> <TableRow key={product.productId}>
<TableCell className="font-medium">{product.name}</TableCell> <TableCell className="font-medium">
{product.name}
{product.archivedAt !== null && <Badge variant="secondary" className="ml-2">{t('stock.archived')}</Badge>}
</TableCell>
<TableCell className="tabular-nums">{formatCents(product.priceInCents)}</TableCell> <TableCell className="tabular-nums">{formatCents(product.priceInCents)}</TableCell>
<TableCell><StockLevel product={product} /></TableCell> <TableCell><StockLevel product={product} /></TableCell>
<TableCell> <TableCell className="text-muted-foreground">
<div className="flex flex-wrap gap-1"> {product.stock === null
{product.pendingAdjustments.map((a) => ( ? '—'
<span key={a.id} className="inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs tabular-nums" title={a.note ?? undefined}> : product.countedAt
{formatChange(a)} ? <span title={dateTime(product.countedAt)}>{relativeTime(product.countedAt)}</span>
<button : t('stock.never_counted')}
onClick={() => cancelMutation.mutate(a.id)}
disabled={cancelMutation.isPending}
className="text-muted-foreground hover:text-destructive"
aria-label={t('stock.cancel_adjustment', { change: formatChange(a), name: product.name })}
>
<X className="h-3 w-3" />
</button>
</span>
))}
{product.pendingAdjustments.length === 0 && <span className="text-muted-foreground">—</span>}
</div>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right whitespace-nowrap">
<Button size="sm" variant="outline" onClick={() => setAdjusting(product)}> {product.archivedAt === null && (
{product.stock === null ? t('stock.start_tracking') : t('stock.adjust')} <Button size="sm" variant="outline" onClick={() => setAdjusting(product)}>
</Button> {product.stock === null ? t('stock.start_tracking') : t('stock.adjust')}
</Button>
)}
{isAdmin && (
<Button size="sm" variant="ghost" className="ml-1" onClick={() => setEditing(product)} aria-label={t('products.edit_title', { name: product.name })}>
<Pencil className="h-4 w-4" />
</Button>
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -248,7 +248,15 @@ export default function StockPage() {
</CardContent> </CardContent>
</Card> </Card>
{adjusting && <AdjustDialog product={adjusting} venueId={venueId} canSetThreshold={isAdmin} onClose={() => setAdjusting(null)} />} {adjusting && <AdjustDialog product={adjusting} venueId={venueId} onClose={() => setAdjusting(null)} />}
{editing && (
<ProductDialog
product={editing === 'new' ? null : editing}
categories={categories}
venueId={venueId}
onClose={() => setEditing(null)}
/>
)}
{deliveryOpen && ( {deliveryOpen && (
<DeliveryDialog products={products.filter((p) => p.stock !== null)} venueId={venueId} onClose={() => setDeliveryOpen(false)} /> <DeliveryDialog products={products.filter((p) => p.stock !== null)} venueId={venueId} onClose={() => setDeliveryOpen(false)} />
)} )}
@@ -286,14 +294,12 @@ function useAdjustMutation(venueId: string, onDone: () => void) {
}); });
} }
function AdjustDialog({ product, venueId, canSetThreshold, onClose }: { product: Product; venueId: string; canSetThreshold: boolean; onClose: () => void }) { function AdjustDialog({ product, venueId, onClose }: { product: Product; venueId: string; onClose: () => void }) {
const { t, te } = useLang(); const { t } = useLang();
const queryClient = useQueryClient();
const tracked = product.stock !== null; const tracked = product.stock !== null;
const [mode, setMode] = useState<Mode>(tracked ? 'add' : 'count'); const [mode, setMode] = useState<Mode>(tracked ? 'add' : 'count');
const [quantity, setQuantity] = useState(''); const [quantity, setQuantity] = useState('');
const [note, setNote] = useState(''); const [note, setNote] = useState('');
const [threshold, setThreshold] = useState(product.lowStockThreshold?.toString() ?? '');
const mutation = useAdjustMutation(venueId, onClose); const mutation = useAdjustMutation(venueId, onClose);
const amount = /^\d+$/.test(quantity.trim()) ? parseInt(quantity, 10) : null; const amount = /^\d+$/.test(quantity.trim()) ? parseInt(quantity, 10) : null;
@@ -302,16 +308,6 @@ function AdjustDialog({ product, venueId, canSetThreshold, onClose }: { product:
? mode === 'count' ? amount! : product.stock! + (mode === 'add' ? amount! : -amount!) ? mode === 'count' ? amount! : product.stock! + (mode === 'add' ? amount! : -amount!)
: null; : null;
const thresholdValue = threshold.trim() === '' ? null : /^\d+$/.test(threshold.trim()) ? parseInt(threshold, 10) : undefined;
const thresholdMutation = useMutation({
mutationFn: () => stockApi.setLowStockThreshold(venueId, product.productId, thresholdValue!),
onSuccess: () => {
toast({ title: t('stock.threshold_saved') });
queryClient.invalidateQueries({ queryKey: ['stock'] });
},
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
});
function submit() { function submit() {
if (!valid) return; if (!valid) return;
mutation.mutate([{ mutation.mutate([{
@@ -356,23 +352,6 @@ function AdjustDialog({ product, venueId, canSetThreshold, onClose }: { product:
<Label htmlFor="note">{t('stock.note')}</Label> <Label htmlFor="note">{t('stock.note')}</Label>
<Input id="note" value={note} maxLength={200} placeholder={t('stock.note_placeholder')} onChange={(e) => setNote(e.target.value)} /> <Input id="note" value={note} maxLength={200} placeholder={t('stock.note_placeholder')} onChange={(e) => setNote(e.target.value)} />
</div> </div>
{/* The warning level is an admin setting; everyone can change the stock itself */}
{tracked && canSetThreshold && (
<div className="space-y-1.5 border-t pt-4">
<Label htmlFor="threshold">{t('stock.threshold')}</Label>
<div className="flex gap-2">
<Input id="threshold" inputMode="numeric" value={threshold} placeholder={t('stock.threshold_none')} onChange={(e) => setThreshold(e.target.value)} className="max-w-[8rem]" />
<Button
variant="outline"
size="sm"
disabled={thresholdValue === undefined || thresholdValue === product.lowStockThreshold || thresholdMutation.isPending}
onClick={() => thresholdMutation.mutate()}
>
{t('stock.threshold_save')}
</Button>
</div>
</div>
)}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} disabled={mutation.isPending}>{t('common.cancel')}</Button> <Button variant="outline" onClick={onClose} disabled={mutation.isPending}>{t('common.cancel')}</Button>
@@ -443,3 +422,150 @@ function DeliveryDialog({ products, venueId, onClose }: { products: Product[]; v
</Dialog> </Dialog>
); );
} }
/**
* Adds a product, or changes or archives one (admin). The terminals get it on their next sync; drinks already on a tab
* keep the name and price they were rung up with.
*/
function ProductDialog({ product, categories, venueId, onClose }: {
product: Product | null;
categories: string[];
venueId: string;
onClose: () => void;
}) {
const { t, te } = useLang();
const queryClient = useQueryClient();
const isNew = product === null;
const [name, setName] = useState(product?.name ?? '');
const [category, setCategory] = useState(product?.category ?? '');
const [price, setPrice] = useState(product ? centsToInput(product.priceInCents) : '');
const [threshold, setThreshold] = useState(product?.lowStockThreshold?.toString() ?? '');
const [trackStock, setTrackStock] = useState(true);
const [stock, setStock] = useState('');
const priceInCents = parseEuros(price);
const thresholdValue = threshold.trim() === '' ? null : /^\d+$/.test(threshold.trim()) ? parseInt(threshold, 10) : undefined;
const stockValue = /^\d+$/.test(stock.trim()) ? parseInt(stock, 10) : null;
const valid = name.trim() !== '' && priceInCents !== null && thresholdValue !== undefined
&& (!isNew || !trackStock || stockValue !== null);
function done(title: string) {
toast({ title, description: t('products.sync_desc'), variant: 'success' });
queryClient.invalidateQueries({ queryKey: ['stock'] });
queryClient.invalidateQueries({ queryKey: ['stock-history'] });
onClose();
}
const save = useMutation({
mutationFn: () => {
const fields = { name: name.trim(), category: category.trim() || null, priceInCents: priceInCents!, lowStockThreshold: thresholdValue! };
return product === null
? stockApi.createProduct(venueId, { ...fields, stock: trackStock ? stockValue : null })
: stockApi.updateProduct(venueId, product.productId, fields);
},
onSuccess: (saved) => done(t(isNew ? 'products.created' : 'products.saved', { name: saved.name })),
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
});
const archive = useMutation({
mutationFn: (archived: boolean) => stockApi.updateProduct(venueId, product!.productId, { archived }),
onSuccess: (saved, archived) => done(t(archived ? 'products.archived' : 'products.restored', { name: saved.name })),
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
});
const busy = save.isPending || archive.isPending;
function submit() {
if (valid && !busy) save.mutate();
}
return (
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isNew ? t('products.new') : t('products.edit_title', { name: product.name })}</DialogTitle>
<DialogDescription>{t('products.desc')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label htmlFor="product-name">{t('products.name')}</Label>
<Input id="product-name" value={name} maxLength={200} onChange={(e) => setName(e.target.value)} autoFocus />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="product-category">{t('products.category')}</Label>
<Input
id="product-category"
list="product-categories"
value={category}
maxLength={100}
placeholder={t('products.category_placeholder')}
onChange={(e) => setCategory(e.target.value)}
/>
<datalist id="product-categories">
{categories.map((c) => <option key={c} value={c} />)}
</datalist>
</div>
<div className="space-y-1.5">
<Label htmlFor="product-price">{t('products.price')}</Label>
<Input
id="product-price"
inputMode="decimal"
value={price}
placeholder="2,50"
onChange={(e) => setPrice(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
/>
</div>
</div>
{isNew && (
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium">
<input type="checkbox" checked={trackStock} onChange={(e) => setTrackStock(e.target.checked)} className="h-4 w-4" />
{t('products.track_stock')}
</label>
{trackStock && (
<div className="space-y-1.5">
<Label htmlFor="product-stock">{t('products.starting_stock')}</Label>
<Input id="product-stock" inputMode="numeric" value={stock} onChange={(e) => setStock(e.target.value)} className="max-w-[8rem]" />
</div>
)}
</div>
)}
{(!isNew ? product.stock !== null : trackStock) && (
<div className="space-y-1.5">
<Label htmlFor="product-threshold">{t('stock.threshold')}</Label>
<Input
id="product-threshold"
inputMode="numeric"
value={threshold}
placeholder={t('stock.threshold_none')}
onChange={(e) => setThreshold(e.target.value)}
className="max-w-[8rem]"
/>
</div>
)}
{!isNew && (
<div className="border-t pt-4 space-y-2">
<p className="text-xs text-muted-foreground">
{product.archivedAt === null ? t('products.archive_desc') : t('products.restore_desc')}
</p>
<Button
variant={product.archivedAt === null ? 'outline' : 'secondary'}
size="sm"
disabled={busy}
onClick={() => archive.mutate(product.archivedAt === null)}
>
{product.archivedAt === null ? t('products.archive') : t('products.restore')}
</Button>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={busy}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={!valid || busy}>{save.isPending ? t('products.saving') : t('products.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+19 -8
View File
@@ -116,13 +116,19 @@ export interface Product {
name: string; name: string;
category: string | null; category: string | null;
priceInCents: number; priceInCents: number;
/** Null when the terminal doesn't track stock 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;
/** The latest count, on the dashboard or a terminal; null when never counted */
countedAt: string | null;
/** When it was created or last changed */
reportedAt: string; reportedAt: string;
pendingAdjustments: StockAdjustment[]; /** Archived (deleted): hidden on the terminals */
archivedAt: string | null;
} }
export type ProductFields = { name: string; category: string | null; priceInCents: number; lowStockThreshold: number | null };
export type NewStockAdjustment = { productId: string; type: StockAdjustmentType; quantity: number; note?: string }; export type NewStockAdjustment = { productId: string; type: StockAdjustmentType; quantity: number; note?: string };
export function getCookie(name: string): string | null { export function getCookie(name: string): string | null {
@@ -346,12 +352,17 @@ export const stockApi = {
method: 'POST', method: 'POST',
body: JSON.stringify({ adjustments }), body: JSON.stringify({ adjustments }),
}), }),
cancel: (venueId: string, id: string) => /** [stock] is the starting count; null: not tracked */
api(`/venues/${venueId}/stock/adjustments/${id}/cancel`, { method: 'POST' }), createProduct: (venueId: string, product: ProductFields & { stock: number | null }) =>
setLowStockThreshold: (venueId: string, productId: string, lowStockThreshold: number | null) => api<Product>(`/venues/${venueId}/stock/products`, {
api(`/venues/${venueId}/stock/products/${encodeURIComponent(productId)}`, { method: 'POST',
method: 'PUT', body: JSON.stringify(product),
body: JSON.stringify({ lowStockThreshold }), }),
/** Only the fields that are sent change; archived: true deletes (hides) the product */
updateProduct: (venueId: string, productId: string, changes: Partial<ProductFields> & { archived?: boolean }) =>
api<Product>(`/venues/${venueId}/stock/products/${encodeURIComponent(productId)}`, {
method: 'PATCH',
body: JSON.stringify(changes),
}), }),
}; };
+58 -18
View File
@@ -269,31 +269,55 @@ const translations: Record<Lang, Record<string, string>> = {
'reminders.person_history': 'Reminders for {name}', 'reminders.person_history': 'Reminders for {name}',
'reminders.person_history_desc': '{count} reminder sent', 'reminders.person_history_desc': '{count} reminder sent',
'reminders.person_history_desc_plural': '{count} reminders sent', 'reminders.person_history_desc_plural': '{count} reminders sent',
'nav.stock': 'Stock', 'nav.stock': 'Products',
'errors.product_not_found': 'This product is no longer on the terminal', 'errors.product_not_found': 'This product is no longer on the terminal',
'errors.stock_not_tracked': "Stock isn't tracked for this product yet. Count it first.", 'errors.stock_not_tracked': "Stock isn't tracked for this product yet. Count it first.",
'errors.invalid_quantity': 'Enter a valid quantity', 'errors.invalid_quantity': 'Enter a valid quantity',
'errors.adjustment_not_pending': 'The terminal already processed this change', 'errors.adjustment_not_pending': 'The terminal already processed this change',
'errors.adjustment_not_found': 'This stock change no longer exists', 'errors.adjustment_not_found': 'This stock change no longer exists',
'stock.title': 'Stock', 'stock.title': 'Products & stock',
'stock.count': '{count} product', 'stock.count': '{count} product',
'stock.count_plural': '{count} products', 'stock.count_plural': '{count} products',
'stock.low_count': '{count} running low', 'stock.low_count': '{count} running low',
'stock.low_count_plural': '{count} running low', 'stock.low_count_plural': '{count} running low',
'stock.reported': 'reported {time}', 'stock.reported': 'updated {time}',
'stock.load_failed': 'Failed to load stock', 'stock.load_failed': 'Failed to load stock',
'stock.empty': 'No products yet', 'stock.empty': 'No products yet',
'stock.empty_desc': 'The terminal reports its products and stock once it runs an app version with stock.', 'stock.empty_desc': 'Add the products you sell. The terminals get them on their next sync.',
'stock.search_placeholder': 'Search products', 'stock.search_placeholder': 'Search products',
'stock.filter_all': 'All', 'stock.filter_all': 'All',
'stock.filter_low': 'Running low', 'stock.filter_low': 'Running low',
'stock.filter_untracked': 'Not tracked', 'stock.filter_untracked': 'Not tracked',
'stock.filter_archived': 'Archived',
'stock.archived': 'Archived',
'products.new': 'New product',
'products.edit_title': 'Edit {name}',
'products.desc': 'The terminals get it on their next sync. Drinks already on a tab keep their name and price.',
'products.name': 'Name',
'products.category': 'Category',
'products.category_placeholder': 'e.g. Frisdrank',
'products.price': 'Price (€)',
'products.track_stock': 'Track stock',
'products.starting_stock': 'In stock now',
'products.save': 'Save',
'products.saving': 'Saving…',
'products.created': '{name} added',
'products.saved': '{name} saved',
'products.sync_desc': 'The terminals show it after their next sync.',
'products.archive': 'Archive',
'products.archive_desc': 'An archived product disappears from the terminals. Open tabs and the history keep it.',
'products.archived': '{name} archived',
'products.restore': 'Restore',
'products.restore_desc': 'This product is archived. Restoring it puts it back on the terminals.',
'products.restored': '{name} is back',
'errors.product_name_exists': 'A product with this name already exists',
'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': 'Price',
'stock.stock': 'Stock', 'stock.stock': 'Stock',
'stock.pending': 'Waiting for terminal', 'stock.counted_at': 'Last counted',
'stock.never_counted': 'Never',
'stock.actions': 'Actions', 'stock.actions': 'Actions',
'stock.not_tracked': 'Not tracked', 'stock.not_tracked': 'Not tracked',
'stock.low': 'Low', 'stock.low': 'Low',
@@ -301,8 +325,6 @@ const translations: Record<Lang, Record<string, string>> = {
'stock.adjust': 'Adjust', 'stock.adjust': 'Adjust',
'stock.start_tracking': 'Track stock', 'stock.start_tracking': 'Track stock',
'stock.start_tracking_desc': "Stock isn't tracked for this product yet. Enter how many you have to start tracking it.", 'stock.start_tracking_desc': "Stock isn't tracked for this product yet. Enter how many you have to start tracking it.",
'stock.cancel_adjustment': 'Cancel {change} for {name}',
'stock.cancelled': 'Change cancelled',
'stock.current': 'Currently {stock} in stock.', 'stock.current': 'Currently {stock} in stock.',
'stock.mode_add': 'Add (delivery)', 'stock.mode_add': 'Add (delivery)',
'stock.mode_remove': 'Take out (breakage, own use…)', 'stock.mode_remove': 'Take out (breakage, own use…)',
@@ -315,8 +337,6 @@ 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.threshold_save': 'Save',
'stock.threshold_saved': 'Warning level saved',
'stock.send': 'Save', 'stock.send': 'Save',
'stock.sending': 'Sending…', 'stock.sending': 'Sending…',
'stock.sent': 'Stock updated', 'stock.sent': 'Stock updated',
@@ -602,31 +622,55 @@ const translations: Record<Lang, Record<string, string>> = {
'reminders.person_history': 'Herinneringen voor {name}', 'reminders.person_history': 'Herinneringen voor {name}',
'reminders.person_history_desc': '{count} herinnering verstuurd', 'reminders.person_history_desc': '{count} herinnering verstuurd',
'reminders.person_history_desc_plural': '{count} herinneringen verstuurd', 'reminders.person_history_desc_plural': '{count} herinneringen verstuurd',
'nav.stock': 'Voorraad', 'nav.stock': 'Producten',
'errors.product_not_found': 'Dit product staat niet meer op de kassa', 'errors.product_not_found': 'Dit product staat niet meer op de kassa',
'errors.stock_not_tracked': 'Voor dit product wordt nog geen voorraad bijgehouden. Tel het eerst.', 'errors.stock_not_tracked': 'Voor dit product wordt nog geen voorraad bijgehouden. Tel het eerst.',
'errors.invalid_quantity': 'Vul een geldig aantal in', 'errors.invalid_quantity': 'Vul een geldig aantal in',
'errors.adjustment_not_pending': 'De kassa heeft deze wijziging al verwerkt', 'errors.adjustment_not_pending': 'De kassa heeft deze wijziging al verwerkt',
'errors.adjustment_not_found': 'Deze voorraadwijziging bestaat niet meer', 'errors.adjustment_not_found': 'Deze voorraadwijziging bestaat niet meer',
'stock.title': 'Voorraad', 'stock.title': 'Producten & voorraad',
'stock.count': '{count} product', 'stock.count': '{count} product',
'stock.count_plural': '{count} producten', 'stock.count_plural': '{count} producten',
'stock.low_count': '{count} bijna op', 'stock.low_count': '{count} bijna op',
'stock.low_count_plural': '{count} bijna op', 'stock.low_count_plural': '{count} bijna op',
'stock.reported': 'gemeld {time}', 'stock.reported': 'bijgewerkt {time}',
'stock.load_failed': 'Voorraad laden mislukt', 'stock.load_failed': 'Voorraad laden mislukt',
'stock.empty': 'Nog geen producten', 'stock.empty': 'Nog geen producten',
'stock.empty_desc': 'De kassa meldt haar producten en voorraad zodra ze een app-versie met voorraad heeft.', 'stock.empty_desc': "Voeg de producten toe die je verkoopt. De kassa's krijgen ze bij hun volgende synchronisatie.",
'stock.search_placeholder': 'Zoek producten', 'stock.search_placeholder': 'Zoek producten',
'stock.filter_all': 'Alle', 'stock.filter_all': 'Alle',
'stock.filter_low': 'Bijna op', 'stock.filter_low': 'Bijna op',
'stock.filter_untracked': 'Niet bijgehouden', 'stock.filter_untracked': 'Niet bijgehouden',
'stock.filter_archived': 'Gearchiveerd',
'stock.archived': 'Gearchiveerd',
'products.new': 'Nieuw product',
'products.edit_title': '{name} bewerken',
'products.desc': "De kassa's krijgen het bij hun volgende synchronisatie. Wat al op een poef staat, houdt zijn naam en prijs.",
'products.name': 'Naam',
'products.category': 'Categorie',
'products.category_placeholder': 'bv. Frisdrank',
'products.price': 'Prijs (€)',
'products.track_stock': 'Voorraad bijhouden',
'products.starting_stock': 'Nu in voorraad',
'products.save': 'Opslaan',
'products.saving': 'Opslaan…',
'products.created': '{name} toegevoegd',
'products.saved': '{name} opgeslagen',
'products.sync_desc': "De kassa's tonen het na hun volgende synchronisatie.",
'products.archive': 'Archiveren',
'products.archive_desc': "Een gearchiveerd product verdwijnt van de kassa's. Open poefs en de geschiedenis houden het.",
'products.archived': '{name} gearchiveerd',
'products.restore': 'Terugzetten',
'products.restore_desc': "Dit product is gearchiveerd. Terugzetten zet het weer op de kassa's.",
'products.restored': '{name} staat terug',
'errors.product_name_exists': 'Er bestaat al een product met deze naam',
'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': 'Prijs',
'stock.stock': 'Voorraad', 'stock.stock': 'Voorraad',
'stock.pending': 'Wacht op kassa', 'stock.counted_at': 'Laatst geteld',
'stock.never_counted': 'Nooit',
'stock.actions': 'Acties', 'stock.actions': 'Acties',
'stock.not_tracked': 'Niet bijgehouden', 'stock.not_tracked': 'Niet bijgehouden',
'stock.low': 'Bijna op', 'stock.low': 'Bijna op',
@@ -634,8 +678,6 @@ const translations: Record<Lang, Record<string, string>> = {
'stock.adjust': 'Aanpassen', 'stock.adjust': 'Aanpassen',
'stock.start_tracking': 'Voorraad bijhouden', 'stock.start_tracking': 'Voorraad bijhouden',
'stock.start_tracking_desc': 'Voor dit product wordt nog geen voorraad bijgehouden. Vul in hoeveel je er hebt om te beginnen.', 'stock.start_tracking_desc': 'Voor dit product wordt nog geen voorraad bijgehouden. Vul in hoeveel je er hebt om te beginnen.',
'stock.cancel_adjustment': '{change} voor {name} annuleren',
'stock.cancelled': 'Wijziging geannuleerd',
'stock.current': 'Nu {stock} in voorraad.', 'stock.current': 'Nu {stock} in voorraad.',
'stock.mode_add': 'Bijvullen (levering)', 'stock.mode_add': 'Bijvullen (levering)',
'stock.mode_remove': 'Afboeken (breuk, eigen gebruik…)', 'stock.mode_remove': 'Afboeken (breuk, eigen gebruik…)',
@@ -648,8 +690,6 @@ 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.threshold_save': 'Opslaan',
'stock.threshold_saved': 'Waarschuwingsniveau opgeslagen',
'stock.send': 'Opslaan', 'stock.send': 'Opslaan',
'stock.sending': 'Versturen…', 'stock.sending': 'Versturen…',
'stock.sent': 'Voorraad bijgewerkt', 'stock.sent': 'Voorraad bijgewerkt',
+12
View File
@@ -6,6 +6,18 @@ export function formatCents(cents: number, currency: string = 'EUR'): string {
}).format(euros); }).format(euros);
} }
/** "2,50", "2.5" or "2" as cents; null when it isn't an amount. */
export function parseEuros(text: string): number | null {
const value = text.trim().replace(/^€\s*/, '');
if (!/^\d+([.,]\d{1,2})?$/.test(value)) return null;
return Math.round(parseFloat(value.replace(',', '.')) * 100);
}
/** Cents as an amount to edit: 250 → "2,50". */
export function centsToInput(cents: number): string {
return (cents / 100).toFixed(2).replace('.', ',');
}
// Relative times and dates are language-dependent: use relativeTime/dateTime from useLang() // Relative times and dates are language-dependent: use relativeTime/dateTime from useLang()
export { cn } from './cn'; export { cn } from './cn';
+12 -5
View File
@@ -62,9 +62,10 @@ and `docs/server-stock-source-of-truth.md`.
first, at most 500; the response's `settlementsCursor` is sent back next time. first, at most 500; the response's `settlementsCursor` is sent back next time.
- `openTabs` is only used once per venue, on the first sync after the server started holding the tabs: the terminal's - `openTabs` is only used once per venue, on the first sync after the server started holding the tabs: the terminal's
own tabs (and catalog stock) are taken over and the events of that request are stored without applying them again. own tabs (and catalog stock) are taken over and the events of that request are stored without applying them again.
- `products` (optional, at most 2000) upserts names, categories and prices. Its `stock` is ignored (counts arrive as - The dashboard manages the products. `products` in the request (at most 2000) is only used on that same first sync,
`stock_set`), a new product starts untracked, and products are never removed because one terminal doesn't report to take over the terminal's catalog with its stock; after that it is ignored. The response's `catalog` lists every
them. An invalid product is skipped and logged. product of the venue: `[{ id, name, category, priceInCents, lowStockThreshold, archived }]`. The terminal adds and
updates its products from it and hides archived products and products the catalog doesn't list.
- Planned customers are opened as tabs by the server, once; `plannedTabNames` is always empty. - Planned customers are opened as tabs by the server, once; `plannedTabNames` is always empty.
- Dashboard stock adjustments are applied by the server right away; `stockAdjustments` is always empty. - Dashboard stock adjustments are applied by the server right away; `stockAdjustments` is always empty.
`stockAdjustmentResults` from older app versions are still recorded and acknowledged. `stockAdjustmentResults` from older app versions are still recorded and acknowledged.
@@ -91,12 +92,18 @@ Names match case-insensitively and keep their existing spelling; `replace` also
`POST /venues/:venueId/reminders/test/:plannedTabId` - Send a test email (admin) `POST /venues/:venueId/reminders/test/:plannedTabId` - Send a test email (admin)
### Stock ### Stock
`GET /venues/:venueId/stock` - The terminal's products with stock, low stock level and pending adjustments `GET /venues/:venueId/stock` - The venue's products (archived ones too, with `archivedAt`) with stock, low stock
level and pending adjustments
`GET /venues/:venueId/stock/adjustments?productId=&limit=&offset=` - Stock adjustment history, newest first `GET /venues/:venueId/stock/adjustments?productId=&limit=&offset=` - Stock adjustment history, newest first
`POST /venues/:venueId/stock/adjustments` - Request stock changes (admins and viewers): `{ adjustments: [{ productId, type: 'add' | 'set', quantity, note? }] }`. `POST /venues/:venueId/stock/adjustments` - Request stock changes (admins and viewers): `{ adjustments: [{ productId, type: 'add' | 'set', quantity, note? }] }`.
`add` takes a non-zero quantity (negative for breakage) and needs a tracked product; `set` takes 0 or more. `add` takes a non-zero quantity (negative for breakage) and needs a tracked product; `set` takes 0 or more.
`POST /venues/:venueId/stock/adjustments/:id/cancel` - Cancel an adjustment the terminal hasn't received yet (admins and viewers) `POST /venues/:venueId/stock/adjustments/:id/cancel` - Cancel an adjustment the terminal hasn't received yet (admins and viewers)
`PUT /venues/:venueId/stock/products/:productId` - Set `{ lowStockThreshold: number | null }` (admin, dashboard only) `POST /venues/:venueId/stock/products` - Add a product (admin): `{ name, category?, priceInCents, stock?, lowStockThreshold? }`.
`stock` is the starting count (recorded as an applied `set` adjustment); null or left out: not tracked.
`product_name_exists` when an active product has the same name (ignoring case).
`PATCH /venues/:venueId/stock/products/:productId` - Change a product (admin): any of `{ name, category, priceInCents,
lowStockThreshold, archived }`. `archived: true` deletes it for the terminals (kept for tabs and history),
`archived: false` brings it back. The stock itself changes through adjustments.
### Commands ### Commands
`POST /venues/:venueId/commands` - Settle a tab: `{ tabId, type: "close_tab", expectedVersion, paymentMethod }`. The `POST /venues/:venueId/commands` - Settle a tab: `{ tabId, type: "close_tab", expectedVersion, paymentMethod }`. The