diff --git a/apps/api/prisma/migrations/20261023120000_products_on_dashboard/migration.sql b/apps/api/prisma/migrations/20261023120000_products_on_dashboard/migration.sql new file mode 100644 index 0000000..5b1ceb9 --- /dev/null +++ b/apps/api/prisma/migrations/20261023120000_products_on_dashboard/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Product" ADD COLUMN "archivedAt" TIMESTAMP(3); + diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index e287c27..b916233 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -232,12 +232,12 @@ model TabEvent { @@index([venueId, type, occurredAt]) } -// The venue's products as reported by its terminals, with the stock. The server owns the stock: it follows the -// terminals' line events and stock counts and the dashboard's StockAdjustments. A product is never removed because -// one terminal doesn't report it. +// The venue's products and their stock. The dashboard manages the catalog and the terminals get it on every sync; +// the first terminal's own catalog was taken over once. The stock follows the terminals' line events and stock +// counts and the dashboard's StockAdjustments. model Product { 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 name String category String? @@ -246,9 +246,12 @@ model Product { stock Int? // When the stock was last counted; line events that happened before it are already in the count countedAt DateTime? - // Dashboard-only setting, never sent to or overwritten by the terminal + // Warn when the stock drops to this level lowStockThreshold Int? + // When it was created or last changed reportedAt DateTime + // Deleted on the dashboard: hidden on the terminals, kept because tabs and history refer to it + archivedAt DateTime? venue Venue @relation(fields: [venueId], references: [id]) diff --git a/apps/api/src/devices/devices.service.spec.ts b/apps/api/src/devices/devices.service.spec.ts index a22001f..876e4b9 100644 --- a/apps/api/src/devices/devices.service.spec.ts +++ b/apps/api/src/devices/devices.service.spec.ts @@ -247,21 +247,67 @@ describe('DevicesService.sync: settlements feed', () => { }); }); +/** A product added on the dashboard, untracked. */ +function addProduct(prisma: FakePrisma, productId = 'cola', fields: Record = {}) { + 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', () => { async function trackedCola(sync: ReturnType['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 () => { - const { sync, tabletA } = setup(); + /** Every test here starts with Cola on the dashboard. */ + 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 }]); }); it('counts the sales of every tablet once', async () => { - const { sync, tabletA, tabletB } = setup(); + const { sync, tabletA, tabletB } = setupWithCola(); await trackedCola(sync, tabletA); 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 () => { - const { sync, tabletA } = setup(); + const { sync, tabletA } = setupWithCola(); await trackedCola(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 () => { - const { sync, tabletA, tabletB } = setup(); + const { sync, tabletA, tabletB } = setupWithCola(); await trackedCola(sync, tabletA); 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 () => { - const { sync, tabletA, tabletB, prisma } = setup(); + const { sync, tabletA, tabletB, prisma } = setupWithCola(); await trackedCola(sync, tabletA); prisma.stockAdjustment.rows.push({ 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 () => { - const { sync, tabletA, prisma } = setup(); + const { sync, tabletA, prisma } = setupWithCola(); await trackedCola(sync, tabletA); const id = uuidv4(); prisma.stockAdjustment.rows.push({ @@ -340,14 +386,6 @@ describe('DevicesService.sync: the server holds the stock', () => { 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', () => { @@ -362,9 +400,10 @@ describe('DevicesService.sync live update signals', () => { }); 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'); }); diff --git a/apps/api/src/devices/devices.service.ts b/apps/api/src/devices/devices.service.ts index cd168c6..595bc31 100644 --- a/apps/api/src/devices/devices.service.ts +++ b/apps/api/src/devices/devices.service.ts @@ -58,8 +58,10 @@ export class DevicesService { // Locks the venue: other terminals' syncs wait, so no change is lost const ledger = await VenueLedger.open(tx, venueId); - // 1. The catalog: names and prices only, the stock is the server's - const productsChanged = reported ? await this.saveProducts(tx, ledger, reported.products, now) : false; + // 1. The dashboard manages the catalog: a terminal's own catalog is only taken over once, with the tabs + 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 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 } }); 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 { ledger, feed, + catalog, topics: { tabs: ledger.changedTabs, 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 (topics.length > 0) this.realtime.notify(venueId, ...topics); - const { ledger, feed } = synced; + const { ledger, feed, catalog } = synced; return { cursor, commands: commands.map((c) => ({ @@ -182,6 +190,15 @@ export class DevicesService { rejectedStockAdjustmentResults: stockResults.rejected, // The venue's complete state after this request's events: the terminal replaces its own with it 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 }]) => ( { productId, stock, countedAt: countedAt?.toISOString() ?? null })), 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. - * Returns whether anything the dashboard shows changed. + * Takes over the terminal's catalog on the first sync since the server holds the tabs; the dashboard manages it + * 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) { const stored = await tx.product.findMany({ diff --git a/apps/api/src/stock/dto/update-product.dto.ts b/apps/api/src/stock/dto/update-product.dto.ts index a5ab8dc..c0eb36f 100644 --- a/apps/api/src/stock/dto/update-product.dto.ts +++ b/apps/api/src/stock/dto/update-product.dto.ts @@ -1,11 +1,84 @@ -import { IsInt, Min, Max, ValidateIf } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, Min, Max, ValidateIf, IsString, IsNotEmpty, MaxLength, IsOptional, IsBoolean } from 'class-validator'; +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 { - @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) @IsInt() @Min(0) @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; } diff --git a/apps/api/src/stock/stock.controller.ts b/apps/api/src/stock/stock.controller.ts index d335fe6..955d9cf 100644 --- a/apps/api/src/stock/stock.controller.ts +++ b/apps/api/src/stock/stock.controller.ts @@ -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 { JwtAuthGuard } from '../auth/guards/jwt-auth.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 { StockService } from './stock.service'; 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') @ApiBearerAuth() @@ -16,7 +16,7 @@ export class StockController { constructor(private service: StockService) {} @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) { return this.service.findAll(venueId); } @@ -53,14 +53,23 @@ export class StockController { 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') - @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( @Param('venueId') venueId: string, @Param('productId') productId: string, @Body() dto: UpdateProductDto, + @Req() req: any, ) { - return this.service.updateProduct(venueId, productId, dto.lowStockThreshold); + return this.service.updateProduct(venueId, productId, dto, req.user); } } diff --git a/apps/api/src/stock/stock.service.spec.ts b/apps/api/src/stock/stock.service.spec.ts index fde324e..f0fb72d 100644 --- a/apps/api/src/stock/stock.service.spec.ts +++ b/apps/api/src/stock/stock.service.spec.ts @@ -26,7 +26,10 @@ function createWithVenue(products: { productId: string; name: string; stock: num const prisma = createFakePrisma(); prisma.venue.rows.push({ id: 'v1', tabsSeededAt: new Date('2026-10-01') }); 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 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' } }); }); }); + +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'); + }); +}); diff --git a/apps/api/src/stock/stock.service.ts b/apps/api/src/stock/stock.service.ts index f7ab677..134e7a6 100644 --- a/apps/api/src/stock/stock.service.ts +++ b/apps/api/src/stock/stock.service.ts @@ -1,8 +1,11 @@ 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 { AuditService } from '../audit/audit.service'; import { RealtimeService } from '../realtime/realtime.service'; import { StockAdjustmentEntryDto } from './dto/create-stock-adjustments.dto'; +import { CreateProductDto, UpdateProductDto } from './dto/update-product.dto'; import { VenueLedger } from '../tabs/venue-ledger'; import { applyPendingAdjustments } from './stock-adjustments'; @@ -29,36 +32,16 @@ export class StockService { private realtime: RealtimeService, ) {} + /** Adjustments are applied at once, so there is nothing pending to show with the products. */ async findAll(venueId: string) { - const [products, pending] = await Promise.all([ - this.prisma.product.findMany({ - where: { venueId }, - orderBy: [{ category: 'asc' }, { name: 'asc' }], - }), - this.prisma.stockAdjustment.findMany({ - where: { venueId, status: 'pending' }, - orderBy: { createdAt: 'asc' }, - select: adjustmentSelect, - }), - ]); - - const pendingByProduct = new Map(); - for (const adjustment of pending) { - pendingByProduct.set(adjustment.productId, [...(pendingByProduct.get(adjustment.productId) ?? []), adjustment]); - } + const products = await this.prisma.product.findMany({ + where: { venueId }, + orderBy: [{ category: 'asc' }, { name: 'asc' }], + }); return { - products: products.map((p) => ({ - productId: p.productId, - 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 + products: products.map(productView), + // Null until the venue has products reportedAt: products.reduce((latest, p) => (!latest || p.reportedAt > latest ? p.reportedAt : latest), null), }; } @@ -172,17 +155,123 @@ export class StockService { return { ok: true }; } - async updateProduct(venueId: string, productId: string, lowStockThreshold: number | null) { - const { count } = await this.prisma.product.updateMany({ - where: { venueId, productId }, - data: { lowStockThreshold }, - }); - if (count === 0) { - throw new NotFoundException({ code: 'product_not_found', message: 'This product is not on the terminal' }); - } + /** + * Adds a product to the venue's catalog; the terminals get it on their next sync. A starting count tracks its stock + * from now on and is kept in the stock history. + */ + async createProduct(venueId: string, dto: CreateProductDto, user: { id: string; email: string; name?: string }) { + const product = await this.prisma.$transaction(async (tx) => { + 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'); - 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, + }; +} diff --git a/apps/api/src/testing/fake-prisma.ts b/apps/api/src/testing/fake-prisma.ts index 8dc4938..fb1d4e8 100644 --- a/apps/api/src/testing/fake-prisma.ts +++ b/apps/api/src/testing/fake-prisma.ts @@ -60,6 +60,11 @@ function sort(rows: Row[], orderBy?: Row | Row[]) { return [...rows].sort((a, b) => { for (const order of orders) { 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; } @@ -153,7 +158,7 @@ export function createFakePrisma() { venue: new FakeTable(() => ({ tabsSeededAt: null })), device: new FakeTable(), 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(), settlement: new FakeTable(() => ({ createdAt: now() }), 'sequence'), stockAdjustment: new FakeTable(() => ({ status: 'pending', stockAfter: null, result: null, processedAt: null, createdAt: now() })), diff --git a/apps/dashboard/src/app/dashboard/stock/page.tsx b/apps/dashboard/src/app/dashboard/stock/page.tsx index 839ce20..8d55f3b 100644 --- a/apps/dashboard/src/app/dashboard/stock/page.tsx +++ b/apps/dashboard/src/app/dashboard/stock/page.tsx @@ -16,19 +16,20 @@ import { EmptyState } from '@/components/empty-state'; import { LoadingState } from '@/components/loading-state'; import { ErrorState } from '@/components/error-state'; import { toast } from '@/components/ui/toast'; -import { formatCents } from '@/lib/utils'; -import { RefreshCw, Search, Truck, X, History } from 'lucide-react'; +import { centsToInput, formatCents, parseEuros } from '@/lib/utils'; +import { RefreshCw, Search, Truck, History, Plus, Pencil } from 'lucide-react'; import { useLang } from '@/lib/i18n'; import { useRealtime } from '@/lib/realtime'; import { useSession } from '@/lib/session'; -type Filter = 'all' | 'low' | 'untracked'; +type Filter = 'all' | 'low' | 'untracked' | 'archived'; type Mode = 'add' | 'remove' | 'count'; const filters: { value: Filter; label: string }[] = [ { value: 'all', label: 'stock.filter_all' }, { value: 'low', label: 'stock.filter_low' }, { value: 'untracked', label: 'stock.filter_untracked' }, + { value: 'archived', label: 'stock.filter_archived' }, ]; /** At or below its warning level, or out of stock. */ @@ -59,11 +60,13 @@ export default function StockPage() { const { venueId, role } = useSession(); const isAdmin = role === 'admin'; const queryClient = useQueryClient(); - const { t, tn, te, relativeTime } = useLang(); + const { t, tn, te, relativeTime, dateTime } = useLang(); const [search, setSearch] = useState(''); const [filter, setFilter] = useState('all'); const [adjusting, setAdjusting] = useState(null); const [deliveryOpen, setDeliveryOpen] = useState(false); + // null: closed; 'new': adding a product + const [editing, setEditing] = useState(null); const { data, isLoading, error } = useQuery({ queryKey: ['stock', venueId], @@ -77,23 +80,16 @@ export default function StockPage() { 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 ; if (error) return ; - 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 categories = [...new Set(allProducts.map((p) => p.category).filter((c): c is string => c !== null))].sort(); 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 (filter === 'low') return isLow(p); if (filter === 'untracked') return p.stock === null; @@ -121,15 +117,20 @@ export default function StockPage() { {t('common.refresh')} {products.some((p) => p.stock !== null) && ( - )} + {isAdmin && ( + + )} } /> - {products.length === 0 ? ( + {allProducts.length === 0 ? ( ) : ( <> @@ -159,7 +160,7 @@ export default function StockPage() { {t('stock.product')} {t('stock.price')} {t('stock.stock')} - {t('stock.pending')} + {t('stock.counted_at')} {t('stock.actions')} @@ -180,31 +181,30 @@ export default function StockPage() { {group.products.map((product) => ( - {product.name} + + {product.name} + {product.archivedAt !== null && {t('stock.archived')}} + {formatCents(product.priceInCents)} - -
- {product.pendingAdjustments.map((a) => ( - - {formatChange(a)} - - - ))} - {product.pendingAdjustments.length === 0 && —} -
+ + {product.stock === null + ? '—' + : product.countedAt + ? {relativeTime(product.countedAt)} + : t('stock.never_counted')} - - + + {product.archivedAt === null && ( + + )} + {isAdmin && ( + + )}
))} @@ -248,7 +248,15 @@ export default function StockPage() { - {adjusting && setAdjusting(null)} />} + {adjusting && setAdjusting(null)} />} + {editing && ( + setEditing(null)} + /> + )} {deliveryOpen && ( 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 }) { - const { t, te } = useLang(); - const queryClient = useQueryClient(); +function AdjustDialog({ product, venueId, onClose }: { product: Product; venueId: string; onClose: () => void }) { + const { t } = useLang(); const tracked = product.stock !== null; const [mode, setMode] = useState(tracked ? 'add' : 'count'); const [quantity, setQuantity] = useState(''); const [note, setNote] = useState(''); - const [threshold, setThreshold] = useState(product.lowStockThreshold?.toString() ?? ''); const mutation = useAdjustMutation(venueId, onClose); 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!) : 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() { if (!valid) return; mutation.mutate([{ @@ -356,23 +352,6 @@ function AdjustDialog({ product, venueId, canSetThreshold, onClose }: { product: setNote(e.target.value)} /> - {/* The warning level is an admin setting; everyone can change the stock itself */} - {tracked && canSetThreshold && ( -
- -
- setThreshold(e.target.value)} className="max-w-[8rem]" /> - -
-
- )} @@ -443,3 +422,150 @@ function DeliveryDialog({ products, venueId, onClose }: { products: Product[]; v ); } + +/** + * 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 ( + !open && !busy && onClose()}> + + + {isNew ? t('products.new') : t('products.edit_title', { name: product.name })} + {t('products.desc')} + +
+
+ + setName(e.target.value)} autoFocus /> +
+
+
+ + setCategory(e.target.value)} + /> + + {categories.map((c) => +
+
+ + setPrice(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && submit()} + /> +
+
+ {isNew && ( +
+ + {trackStock && ( +
+ + setStock(e.target.value)} className="max-w-[8rem]" /> +
+ )} +
+ )} + {(!isNew ? product.stock !== null : trackStock) && ( +
+ + setThreshold(e.target.value)} + className="max-w-[8rem]" + /> +
+ )} + {!isNew && ( +
+

+ {product.archivedAt === null ? t('products.archive_desc') : t('products.restore_desc')} +

+ +
+ )} +
+ + + + +
+
+ ); +} diff --git a/apps/dashboard/src/lib/api/client.ts b/apps/dashboard/src/lib/api/client.ts index a3d8f09..580d407 100644 --- a/apps/dashboard/src/lib/api/client.ts +++ b/apps/dashboard/src/lib/api/client.ts @@ -116,13 +116,19 @@ export interface Product { name: string; category: string | null; 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; 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; - 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 function getCookie(name: string): string | null { @@ -346,12 +352,17 @@ export const stockApi = { method: 'POST', body: JSON.stringify({ adjustments }), }), - cancel: (venueId: string, id: string) => - api(`/venues/${venueId}/stock/adjustments/${id}/cancel`, { method: 'POST' }), - setLowStockThreshold: (venueId: string, productId: string, lowStockThreshold: number | null) => - api(`/venues/${venueId}/stock/products/${encodeURIComponent(productId)}`, { - method: 'PUT', - body: JSON.stringify({ lowStockThreshold }), + /** [stock] is the starting count; null: not tracked */ + createProduct: (venueId: string, product: ProductFields & { stock: number | null }) => + api(`/venues/${venueId}/stock/products`, { + method: 'POST', + body: JSON.stringify(product), + }), + /** Only the fields that are sent change; archived: true deletes (hides) the product */ + updateProduct: (venueId: string, productId: string, changes: Partial & { archived?: boolean }) => + api(`/venues/${venueId}/stock/products/${encodeURIComponent(productId)}`, { + method: 'PATCH', + body: JSON.stringify(changes), }), }; diff --git a/apps/dashboard/src/lib/i18n.tsx b/apps/dashboard/src/lib/i18n.tsx index 7f05e98..1bbedf0 100644 --- a/apps/dashboard/src/lib/i18n.tsx +++ b/apps/dashboard/src/lib/i18n.tsx @@ -269,31 +269,55 @@ const translations: Record> = { 'reminders.person_history': 'Reminders for {name}', 'reminders.person_history_desc': '{count} reminder 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.stock_not_tracked': "Stock isn't tracked for this product yet. Count it first.", 'errors.invalid_quantity': 'Enter a valid quantity', 'errors.adjustment_not_pending': 'The terminal already processed this change', 'errors.adjustment_not_found': 'This stock change no longer exists', - 'stock.title': 'Stock', + 'stock.title': 'Products & stock', 'stock.count': '{count} product', 'stock.count_plural': '{count} products', 'stock.low_count': '{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.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.filter_all': 'All', 'stock.filter_low': 'Running low', '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_category': 'Other', 'stock.product': 'Product', 'stock.price': 'Price', 'stock.stock': 'Stock', - 'stock.pending': 'Waiting for terminal', + 'stock.counted_at': 'Last counted', + 'stock.never_counted': 'Never', 'stock.actions': 'Actions', 'stock.not_tracked': 'Not tracked', 'stock.low': 'Low', @@ -301,8 +325,6 @@ const translations: Record> = { 'stock.adjust': 'Adjust', '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.cancel_adjustment': 'Cancel {change} for {name}', - 'stock.cancelled': 'Change cancelled', 'stock.current': 'Currently {stock} in stock.', 'stock.mode_add': 'Add (delivery)', 'stock.mode_remove': 'Take out (breakage, own use…)', @@ -315,8 +337,6 @@ const translations: Record> = { 'stock.note_placeholder': 'e.g. delivery from the brewery', 'stock.threshold': 'Warn when stock is at or below', 'stock.threshold_none': 'No warning', - 'stock.threshold_save': 'Save', - 'stock.threshold_saved': 'Warning level saved', 'stock.send': 'Save', 'stock.sending': 'Sending…', 'stock.sent': 'Stock updated', @@ -602,31 +622,55 @@ const translations: Record> = { 'reminders.person_history': 'Herinneringen voor {name}', 'reminders.person_history_desc': '{count} herinnering 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.stock_not_tracked': 'Voor dit product wordt nog geen voorraad bijgehouden. Tel het eerst.', 'errors.invalid_quantity': 'Vul een geldig aantal in', 'errors.adjustment_not_pending': 'De kassa heeft deze wijziging al verwerkt', 'errors.adjustment_not_found': 'Deze voorraadwijziging bestaat niet meer', - 'stock.title': 'Voorraad', + 'stock.title': 'Producten & voorraad', 'stock.count': '{count} product', 'stock.count_plural': '{count} producten', 'stock.low_count': '{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.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.filter_all': 'Alle', 'stock.filter_low': 'Bijna op', '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_category': 'Overige', 'stock.product': 'Product', 'stock.price': 'Prijs', 'stock.stock': 'Voorraad', - 'stock.pending': 'Wacht op kassa', + 'stock.counted_at': 'Laatst geteld', + 'stock.never_counted': 'Nooit', 'stock.actions': 'Acties', 'stock.not_tracked': 'Niet bijgehouden', 'stock.low': 'Bijna op', @@ -634,8 +678,6 @@ const translations: Record> = { 'stock.adjust': 'Aanpassen', '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.cancel_adjustment': '{change} voor {name} annuleren', - 'stock.cancelled': 'Wijziging geannuleerd', 'stock.current': 'Nu {stock} in voorraad.', 'stock.mode_add': 'Bijvullen (levering)', 'stock.mode_remove': 'Afboeken (breuk, eigen gebruik…)', @@ -648,8 +690,6 @@ const translations: Record> = { 'stock.note_placeholder': 'bv. levering brouwerij', 'stock.threshold': 'Waarschuw vanaf', 'stock.threshold_none': 'Geen waarschuwing', - 'stock.threshold_save': 'Opslaan', - 'stock.threshold_saved': 'Waarschuwingsniveau opgeslagen', 'stock.send': 'Opslaan', 'stock.sending': 'Versturen…', 'stock.sent': 'Voorraad bijgewerkt', diff --git a/apps/dashboard/src/lib/utils.ts b/apps/dashboard/src/lib/utils.ts index eb7c69d..8b268e4 100644 --- a/apps/dashboard/src/lib/utils.ts +++ b/apps/dashboard/src/lib/utils.ts @@ -6,6 +6,18 @@ export function formatCents(cents: number, currency: string = 'EUR'): string { }).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() export { cn } from './cn'; \ No newline at end of file diff --git a/docs/api.md b/docs/api.md index d0c3974..98bb0e8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. - `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. -- `products` (optional, at most 2000) upserts names, categories and prices. Its `stock` is ignored (counts arrive as - `stock_set`), a new product starts untracked, and products are never removed because one terminal doesn't report - them. An invalid product is skipped and logged. +- The dashboard manages the products. `products` in the request (at most 2000) is only used on that same first sync, + to take over the terminal's catalog with its stock; after that it is ignored. The response's `catalog` lists every + 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. - Dashboard stock adjustments are applied by the server right away; `stockAdjustments` is always empty. `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) ### 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 `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. `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 `POST /venues/:venueId/commands` - Settle a tab: `{ tabId, type: "close_tab", expectedVersion, paymentMethod }`. The