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

This commit is contained in:
2026-10-20 14:45:22 +02:00
parent ee06af8395
commit 1e4438ec5a
24 changed files with 1734 additions and 14 deletions
@@ -0,0 +1,45 @@
-- CreateTable
CREATE TABLE "Product" (
"venueId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT,
"priceInCents" INTEGER NOT NULL,
"stock" INTEGER,
"lowStockThreshold" INTEGER,
"reportedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Product_pkey" PRIMARY KEY ("venueId","productId")
);
-- CreateTable
CREATE TABLE "StockAdjustment" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"productName" TEXT NOT NULL,
"type" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"note" TEXT,
"requestedBy" TEXT NOT NULL,
"requestedById" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"stockAfter" INTEGER,
"result" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processedAt" TIMESTAMP(3),
CONSTRAINT "StockAdjustment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "StockAdjustment_venueId_status_createdAt_idx" ON "StockAdjustment"("venueId", "status", "createdAt");
-- CreateIndex
CREATE INDEX "StockAdjustment_venueId_productId_createdAt_idx" ON "StockAdjustment"("venueId", "productId", "createdAt");
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockAdjustment" ADD CONSTRAINT "StockAdjustment_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+58 -9
View File
@@ -13,15 +13,17 @@ model Venue {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
devices Device[]
clientUsers ClientUser[]
tabOwnerships TabOwnership[]
remoteCommands RemoteCommand[]
auditLogs AuditLog[]
regCodes RegistrationCode[]
plannedTabs PlannedTab[]
tabReminders TabReminder[]
tabEvents TabEvent[]
devices Device[]
clientUsers ClientUser[]
tabOwnerships TabOwnership[]
remoteCommands RemoteCommand[]
auditLogs AuditLog[]
regCodes RegistrationCode[]
plannedTabs PlannedTab[]
tabReminders TabReminder[]
tabEvents TabEvent[]
products Product[]
stockAdjustments StockAdjustment[]
}
model Device {
@@ -214,3 +216,50 @@ model TabEvent {
@@index([venueId, occurredAt])
@@index([venueId, type, occurredAt])
}
// The terminal's product catalog with its stock, as last reported by the terminal. The terminal owns the
// stock: it counts sales down offline, so the dashboard only changes it through StockAdjustments.
model Product {
venueId String
// Generated by the terminal
productId String
name String
category String?
priceInCents Int
// Null when the terminal doesn't track stock for this product. Can be negative: a sale is never blocked.
stock Int?
// Dashboard-only setting, never sent to or overwritten by the terminal
lowStockThreshold Int?
reportedAt DateTime
venue Venue @relation(fields: [venueId], references: [id])
@@id([venueId, productId])
}
// A stock change requested from the dashboard. It is sent to the terminal on every sync until the terminal
// reports it applied or rejected.
model StockAdjustment {
id String @id @default(uuid())
venueId String
productId String
productName String
// add (signed: a delivery, or breakage when negative) | set (a stock count)
type String
quantity Int
note String?
requestedBy String
requestedById String
// pending | applied | rejected | cancelled
status String @default("pending")
// The terminal's stock right after applying it
stockAfter Int?
result String?
createdAt DateTime @default(now())
processedAt DateTime?
venue Venue @relation(fields: [venueId], references: [id])
@@index([venueId, status, createdAt])
@@index([venueId, productId, createdAt])
}
+2
View File
@@ -12,6 +12,7 @@ import { AuditModule } from './audit/audit.module';
import { JobsModule } from './jobs/jobs.module';
import { PlannedTabsModule } from './planned-tabs/planned-tabs.module';
import { RemindersModule } from './reminders/reminders.module';
import { StockModule } from './stock/stock.module';
import { RealtimeModule } from './realtime/realtime.module';
import { EmailModule } from './email/email.module';
import { HealthController } from './health.controller';
@@ -32,6 +33,7 @@ import { HealthController } from './health.controller';
JobsModule,
PlannedTabsModule,
RemindersModule,
StockModule,
],
controllers: [HealthController],
providers: [
+91 -1
View File
@@ -3,13 +3,20 @@ import { DevicesService } from './devices.service';
type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number; items?: unknown };
function createService({ previous = [] as Snapshot[], delivered = 0, expired = 0 } = {}) {
type StoredProduct = { productId: string; name: string; category: string | null; priceInCents: number; stock: number | null };
function createService({
previous = [] as Snapshot[], delivered = 0, expired = 0, products = [] as StoredProduct[], pendingStock = [] as unknown[],
recordedResults = 1,
} = {}) {
const tx = {
tabOwnership: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
plannedTab: { updateMany: jest.fn().mockResolvedValue({ count: delivered }) },
device: { update: jest.fn() },
tabEvent: { createMany: jest.fn() },
product: { deleteMany: jest.fn(), updateMany: jest.fn(), upsert: jest.fn() },
stockAdjustment: { updateMany: jest.fn().mockResolvedValue({ count: recordedResults }) },
};
const prisma = {
openTabSnapshot: { findMany: jest.fn().mockResolvedValue(previous) },
@@ -17,6 +24,8 @@ function createService({ previous = [] as Snapshot[], delivered = 0, expired = 0
$transaction: jest.fn((fn: (t: typeof tx) => unknown) => fn(tx)),
remoteCommand: { updateMany: jest.fn().mockResolvedValue({ count: expired }), findMany: jest.fn().mockResolvedValue([]) },
plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: 'Els' }]) },
product: { findMany: jest.fn().mockResolvedValue(products) },
stockAdjustment: { findMany: jest.fn().mockResolvedValue(pendingStock) },
};
const audit = { log: jest.fn() };
const realtime = { notify: jest.fn() };
@@ -201,3 +210,84 @@ describe('DevicesService.sync tab events', () => {
expect(result.acknowledgedEventIds).toEqual([]);
});
});
describe('DevicesService.sync stock', () => {
const cola = { id: 'p1', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: 48 };
const storedCola = { productId: 'p1', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: 48 };
it('leaves products alone when the terminal does not report them', async () => {
const { service, prisma, tx } = createService({ products: [storedCola] });
await service.sync(device, { deviceId: 'd1', openTabs: [] });
expect(prisma.product.findMany).not.toHaveBeenCalled();
expect(tx.product.deleteMany).not.toHaveBeenCalled();
});
it('only writes changed products, removes missing ones and signals "stock"', async () => {
const storedWater = { productId: 'p2', name: 'Water', category: null, priceInCents: 200, stock: null };
const { service, tx, realtime } = createService({ products: [storedCola, storedWater] });
await service.sync(device, {
deviceId: 'd1',
openTabs: [],
products: [{ ...cola, stock: 46 }, { id: 'p3', name: ' Bier ', priceInCents: 300, stock: null }],
});
expect(tx.product.deleteMany).toHaveBeenCalledWith({ where: { venueId: 'v1', productId: { notIn: ['p1', 'p3'] } } });
expect(tx.product.upsert).toHaveBeenCalledTimes(2);
expect(tx.product.upsert).toHaveBeenCalledWith(expect.objectContaining({
create: expect.objectContaining({ productId: 'p3', name: 'Bier', category: null, stock: null }),
}));
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
});
it('does not signal anything for an unchanged catalog', async () => {
const { service, tx, realtime } = createService({ products: [storedCola], recordedResults: 0 });
await service.sync(device, { deviceId: 'd1', openTabs: [], products: [cola] });
expect(tx.product.upsert).not.toHaveBeenCalled();
expect(realtime.notify).not.toHaveBeenCalled();
});
it('skips an invalid product without failing the sync', async () => {
const { service, tx } = createService();
await service.sync(device, { deviceId: 'd1', openTabs: [], products: [cola, { id: 'p9', name: 'Broken', priceInCents: -1, stock: 3 }] });
expect(tx.product.deleteMany).toHaveBeenCalledWith({ where: { venueId: 'v1', productId: { notIn: ['p1'] } } });
});
it('records stock adjustment results and acknowledges them', async () => {
const { service, tx, realtime } = createService();
const id = '5e0b8b4e-1d2c-4f3a-9b8c-7d6e5f4a3b2c';
const result = await service.sync(device, {
deviceId: 'd1',
openTabs: [],
stockAdjustmentResults: [{ id, status: 'applied', stockAfter: 72 }, { id: 'nope', status: 'applied' }],
});
expect(tx.stockAdjustment.updateMany).toHaveBeenCalledWith({
where: { id, venueId: 'v1', status: { in: ['pending', 'cancelled'] } },
data: { status: 'applied', stockAfter: 72, result: null, processedAt: expect.any(Date) },
});
expect(result.acknowledgedStockAdjustmentResultIds).toEqual([id]);
expect(result.rejectedStockAdjustmentResults.map((r) => r.id)).toEqual(['nope']);
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
});
it('sends the pending stock adjustments of the venue', async () => {
const pending = [{ id: 'a1', productId: 'p1', type: 'add', quantity: 24, note: null, requestedBy: 'Bram', createdAt: new Date() }];
const { service, prisma } = createService({ pendingStock: pending });
const result = await service.sync(device, { deviceId: 'd1', openTabs: [] });
expect(prisma.stockAdjustment.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: { venueId: 'v1', status: 'pending' },
orderBy: { createdAt: 'asc' },
}));
expect(result.stockAdjustments).toEqual(pending);
});
});
+84
View File
@@ -15,6 +15,7 @@ import { OpenTabDto, OpenTabItemDto, SyncDto } from './dto/sync.dto';
import { customerNameKey } from '../planned-tabs/customer-name';
import { AckCommandDto } from './dto/ack-command.dto';
import { parseTabEvents, tabEventRow } from './tab-events';
import { ParsedProduct, parseProducts, parseStockResults } from './stock-sync';
@Injectable()
export class DevicesService {
@@ -39,6 +40,15 @@ export class DevicesService {
for (const { id, reason } of events.rejected) {
this.logger.warn(`Device ${device.id} sent an invalid tab event ${id ?? '(no id)'}: ${reason}`);
}
// Omitted by app versions without stock: the products are then left as they are
const reported = dto.products ? parseProducts(dto.products) : null;
for (const { id, reason } of reported?.rejected ?? []) {
this.logger.warn(`Device ${device.id} sent an invalid product ${id ?? '(no id)'}: ${reason}`);
}
const stockResults = parseStockResults(dto.stockAdjustmentResults ?? []);
for (const { id, reason } of stockResults.rejected) {
this.logger.warn(`Device ${device.id} sent an invalid stock adjustment result ${id ?? '(no id)'}: ${reason}`);
}
const openTabs = dto.openTabs || [];
const openTabIds = openTabs.map((t) => t.id);
@@ -67,6 +77,22 @@ export class DevicesService {
const tabsChanged = previous.length !== openTabs.length || changedTabs.length > 0;
let plannedDelivered = 0;
// Products belong to the venue rather than the device, so they survive pairing a new terminal
const previousProducts = reported
? await this.prisma.product.findMany({
where: { venueId: device.venueId },
select: { productId: true, name: true, category: true, priceInCents: true, stock: true },
})
: [];
const previousProductsById = new Map(previousProducts.map((p) => [p.productId, p]));
const changedProducts = (reported?.products ?? []).filter((p) => {
const stored = previousProductsById.get(p.id);
return !stored || !sameProduct(stored, p);
});
const productsChanged = reported !== null
&& (previousProducts.length !== reported.products.length || changedProducts.length > 0);
let stockResultsRecorded = 0;
for (const ownership of takenOver) {
await this.auditService.log(
device.venueId,
@@ -148,6 +174,43 @@ export class DevicesService {
}));
}
if (reported) {
const reportedIds = reported.products.map((p) => p.id);
await tx.product.deleteMany({ where: { venueId: device.venueId, productId: { notIn: reportedIds } } });
await tx.product.updateMany({
where: { venueId: device.venueId, productId: { in: reportedIds } },
data: { reportedAt: now },
});
for (const product of changedProducts) {
const data = {
name: product.name,
category: product.category,
priceInCents: product.priceInCents,
stock: product.stock,
reportedAt: now,
};
await tx.product.upsert({
where: { venueId_productId: { venueId: device.venueId, productId: product.id } },
create: { venueId: device.venueId, productId: product.id, ...data },
update: data,
});
}
}
// The terminal's outcome is the truth, even for an adjustment cancelled while it was on its way
for (const result of stockResults.accepted) {
const { count } = await tx.stockAdjustment.updateMany({
where: { id: result.id, venueId: device.venueId, status: { in: ['pending', 'cancelled'] } },
data: {
status: result.status,
stockAfter: result.status === 'applied' ? result.stockAfter : null,
result: result.reason ?? null,
processedAt: now,
},
});
stockResultsRecorded += count;
}
if (events.accepted.length > 0) {
await tx.tabEvent.createMany({
data: events.accepted.map((event) => tabEventRow(event, device.venueId, device.id, now)),
@@ -203,10 +266,19 @@ export class DevicesService {
select: { customerName: true },
});
// Sent on every sync until the terminal reports an outcome; the terminal dedupes by id
const stockAdjustments = await this.prisma.stockAdjustment.findMany({
where: { venueId: device.venueId, status: 'pending' },
orderBy: { createdAt: 'asc' },
take: 500,
select: { id: true, productId: true, type: true, quantity: true, note: true, requestedBy: true, createdAt: true },
});
const topics: RealtimeTopic[] = [];
if (tabsChanged) topics.push('tabs');
if (plannedDelivered > 0) topics.push('planned-tabs');
if (expired.count > 0) topics.push('commands');
if (productsChanged || stockResultsRecorded > 0) topics.push('stock');
// Only when the device comes back online; dashboards poll the device status themselves
if (now.getTime() - new Date(device.lastSeenAt).getTime() > DEVICE_ONLINE_THRESHOLD_MS) topics.push('device');
if (topics.length > 0) this.realtime.notify(device.venueId, ...topics);
@@ -227,6 +299,11 @@ export class DevicesService {
acknowledgedEventIds: events.accepted.map((e) => e.id),
// Invalid and never stored: the terminal should log and drop these too, re-sending won't help
rejectedEvents: events.rejected,
stockAdjustments,
// Recorded (or already recorded earlier): the terminal can drop these results from its outbox
acknowledgedStockAdjustmentResultIds: stockResults.accepted.map((r) => r.id),
// Invalid and never recorded: drop these too, re-sending won't help
rejectedStockAdjustmentResults: stockResults.rejected,
};
}
@@ -314,6 +391,13 @@ export class DevicesService {
}
}
type StoredProduct = { name: string; category: string | null; priceInCents: number; stock: number | null };
function sameProduct(stored: StoredProduct, reported: ParsedProduct): boolean {
return stored.name === reported.name && stored.category === reported.category
&& stored.priceInCents === reported.priceInCents && stored.stock === reported.stock;
}
type StoredItem = { productName: string; quantity: number; unitPriceInCents: number };
function sameItems(stored: StoredItem[] | null, reported: OpenTabItemDto[] | undefined): boolean {
@@ -0,0 +1,55 @@
import { IsString, IsIn, IsInt, Min, IsNotEmpty, MaxLength, IsOptional, IsUUID, ValidateIf } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ReportedProductDto {
@ApiProperty({ example: 'local-product-id', description: "The terminal's own id for the product" })
@IsString()
@IsNotEmpty()
@MaxLength(100)
id: string;
@ApiProperty({ example: 'Cola' })
@IsString()
@IsNotEmpty()
@MaxLength(200)
name: string;
@ApiPropertyOptional({ example: 'Frisdrank' })
@IsOptional()
@IsString()
@MaxLength(100)
category?: string | null;
@ApiProperty({ example: 250 })
@IsInt()
@Min(0)
priceInCents: number;
@ApiProperty({ example: 48, nullable: true, description: 'Null when stock is not tracked; may be negative' })
@ValidateIf((product: ReportedProductDto) => product.stock !== null)
@IsInt()
stock: number | null;
}
export const STOCK_RESULT_STATUSES = ['applied', 'rejected'] as const;
export class StockAdjustmentResultDto {
@ApiProperty({ description: 'The stock adjustment id from the sync response' })
@IsUUID()
id: string;
@ApiProperty({ enum: STOCK_RESULT_STATUSES })
@IsIn(STOCK_RESULT_STATUSES)
status: (typeof STOCK_RESULT_STATUSES)[number];
@ApiPropertyOptional({ example: 72, description: 'applied: the stock right after applying it' })
@ValidateIf((result: StockAdjustmentResultDto) => result.status === 'applied')
@IsInt()
stockAfter?: number;
@ApiPropertyOptional({ example: 'unknown_product', description: 'rejected: why' })
@IsOptional()
@IsString()
@MaxLength(500)
reason?: string;
}
+16 -1
View File
@@ -77,4 +77,19 @@ export class SyncDto {
@IsArray()
@ArrayMaxSize(1000)
events?: unknown[];
}
// Validated one by one in the service, like events
@ApiPropertyOptional({
description: "ReportedProductDto[]: the terminal's complete product catalog with stock. Omitted by app versions without stock",
})
@IsOptional()
@IsArray()
@ArrayMaxSize(2000)
products?: unknown[];
@ApiPropertyOptional({ description: 'StockAdjustmentResultDto[]: outcomes of stock adjustments the terminal received' })
@IsOptional()
@IsArray()
@ArrayMaxSize(1000)
stockAdjustmentResults?: unknown[];
}
+54
View File
@@ -0,0 +1,54 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { ReportedProductDto, StockAdjustmentResultDto } from './dto/stock-sync.dto';
import { describe } from './tab-events';
export type Rejected = { id: string | null; reason: string };
/**
* Validates entries one by one, like tab events: one bad product or result is skipped on its own
* instead of failing the sync (and with it the open tabs and sales).
*/
function parseEach<T extends object>(cls: new () => T, raw: unknown[]): { accepted: T[]; rejected: Rejected[] } {
const accepted: T[] = [];
const rejected: Rejected[] = [];
for (const value of raw) {
const id = typeof (value as { id?: unknown })?.id === 'string' ? (value as { id: string }).id : null;
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
rejected.push({ id, reason: 'must be an object' });
continue;
}
const entry = plainToInstance(cls, value);
const errors = validateSync(entry, { whitelist: true });
if (errors.length > 0) {
rejected.push({ id, reason: describe(errors) });
continue;
}
accepted.push(entry);
}
return { accepted, rejected };
}
export function parseProducts(raw: unknown[]) {
const { accepted, rejected } = parseEach(ReportedProductDto, raw);
// A duplicated id would make the upserts fight; the last one reported wins
const byId = new Map(accepted.map((product) => [product.id, product]));
return {
products: [...byId.values()].map((p) => ({
id: p.id,
name: p.name.trim(),
category: p.category?.trim() || null,
priceInCents: p.priceInCents,
stock: p.stock ?? null,
})),
rejected,
};
}
export type ParsedProduct = ReturnType<typeof parseProducts>['products'][number];
export function parseStockResults(raw: unknown[]) {
return parseEach(StockAdjustmentResultDto, raw);
}
+1 -1
View File
@@ -64,7 +64,7 @@ export function tabEventRow(event: TabEventDto, venueId: string, deviceId: strin
};
}
function describe(errors: ValidationError[]): string {
export function describe(errors: ValidationError[]): string {
return errors
.flatMap((error) => [
...Object.values(error.constraints ?? {}),
+1 -1
View File
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Namespace } from 'socket.io';
/** What changed; the dashboard refetches the matching data over the normal API. */
export type RealtimeTopic = 'tabs' | 'commands' | 'planned-tabs' | 'device' | 'reminders' | 'users';
export type RealtimeTopic = 'tabs' | 'commands' | 'planned-tabs' | 'device' | 'reminders' | 'users' | 'stock';
export const venueRoom = (venueId: string) => `venue:${venueId}`;
@@ -0,0 +1,43 @@
import {
IsString, IsIn, IsInt, Min, Max, IsNotEmpty, MaxLength, IsOptional, IsArray, ArrayMinSize, ArrayMaxSize,
ValidateNested,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export const STOCK_ADJUSTMENT_TYPES = ['add', 'set'] as const;
export type StockAdjustmentType = (typeof STOCK_ADJUSTMENT_TYPES)[number];
export class StockAdjustmentEntryDto {
@ApiProperty({ example: 'local-product-id' })
@IsString()
@IsNotEmpty()
productId: string;
@ApiProperty({ enum: STOCK_ADJUSTMENT_TYPES, description: 'add: change the stock by quantity; set: the stock is now quantity' })
@IsIn(STOCK_ADJUSTMENT_TYPES)
type: StockAdjustmentType;
@ApiProperty({ example: 24, description: 'add: non-zero, negative to take stock out; set: zero or more' })
@IsInt()
@Min(-100000)
@Max(100000)
quantity: number;
@ApiPropertyOptional({ example: 'Delivery from the brewery' })
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value))
@IsString()
@MaxLength(200)
note?: string;
}
export class CreateStockAdjustmentsDto {
@ApiProperty({ type: [StockAdjustmentEntryDto] })
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => StockAdjustmentEntryDto)
adjustments: StockAdjustmentEntryDto[];
}
@@ -0,0 +1,11 @@
import { IsInt, Min, Max, ValidateIf } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateProductDto {
@ApiProperty({ example: 12, nullable: true, description: 'Warn when stock drops to this level; null for no warning' })
@ValidateIf((dto: UpdateProductDto) => dto.lowStockThreshold !== null)
@IsInt()
@Min(0)
@Max(100000)
lowStockThreshold: number | null;
}
+66
View File
@@ -0,0 +1,66 @@
import { Controller, Get, Post, Put, 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';
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';
@ApiTags('Stock')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
@Controller('venues/:venueId/stock')
export class StockController {
constructor(private service: StockService) {}
@Get()
@ApiOperation({ summary: "The terminal's products with their stock and pending adjustments" })
async findAll(@Param('venueId') venueId: string) {
return this.service.findAll(venueId);
}
@Get('adjustments')
@ApiOperation({ summary: 'Stock adjustment history' })
@ApiQuery({ name: 'productId', required: false })
@ApiQuery({ name: 'limit', required: false })
@ApiQuery({ name: 'offset', required: false })
async history(
@Param('venueId') venueId: string,
@Query('productId') productId?: string,
@Query('limit') limit?: string,
@Query('offset') offset?: string,
) {
return this.service.history(venueId, {
productId: productId || undefined,
limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200),
offset: Math.max(parseInt(offset ?? '', 10) || 0, 0),
});
}
// Viewers (bar staff) change stock too; only the warning level is an admin setting
@Post('adjustments')
@ApiOperation({ summary: 'Add to, take from or count the stock of one or more products' })
async create(@Param('venueId') venueId: string, @Body() dto: CreateStockAdjustmentsDto, @Req() req: any) {
return this.service.createAdjustments(venueId, dto.adjustments, req.user);
}
@Post('adjustments/:id/cancel')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Cancel a stock adjustment the terminal has not processed yet' })
async cancel(@Param('venueId') venueId: string, @Param('id') id: string, @Req() req: any) {
return this.service.cancelAdjustment(venueId, id, req.user);
}
@Put('products/:productId')
@Roles('admin')
@ApiOperation({ summary: 'Set the low stock warning level of a product (admin)' })
async updateProduct(
@Param('venueId') venueId: string,
@Param('productId') productId: string,
@Body() dto: UpdateProductDto,
) {
return this.service.updateProduct(venueId, productId, dto.lowStockThreshold);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { StockController } from './stock.controller';
import { StockService } from './stock.service';
@Module({
controllers: [StockController],
providers: [StockService],
})
export class StockModule {}
+86
View File
@@ -0,0 +1,86 @@
import { StockService } from './stock.service';
const user = { id: 'u1', email: 'bram@example.com' };
function createService({ products = [] as { productId: string; name: string; stock: number | null }[], cancelled = 1, existing = null as unknown } = {}) {
const prisma = {
product: { findMany: jest.fn().mockResolvedValue(products), updateMany: jest.fn().mockResolvedValue({ count: 1 }) },
stockAdjustment: {
create: jest.fn((args: { data: Record<string, unknown> }) => ({ id: 'a1', ...args.data })),
updateMany: jest.fn().mockResolvedValue({ count: cancelled }),
findFirst: jest.fn().mockResolvedValue(existing),
},
$transaction: jest.fn((operations: unknown[]) => Promise.all(operations)),
};
const audit = { log: jest.fn() };
const realtime = { notify: jest.fn() };
const service = new StockService(prisma as any, audit as any, realtime as any);
return { service, prisma, audit, realtime };
}
const cola = { productId: 'p1', name: 'Cola', stock: 10 };
const water = { productId: 'p2', name: 'Water', stock: null };
describe('StockService.createAdjustments', () => {
it('creates one pending adjustment per entry with the product name and requester', async () => {
const { service, prisma, realtime } = createService({ products: [cola, water] });
const result = await service.createAdjustments('v1', [
{ productId: 'p1', type: 'add', quantity: 24, note: 'Delivery' },
{ productId: 'p2', type: 'set', quantity: 12 },
], user);
expect(prisma.stockAdjustment.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ venueId: 'v1', productId: 'p1', productName: 'Cola', type: 'add', quantity: 24, note: 'Delivery', requestedBy: 'bram@example.com' }),
}));
expect(result.adjustments).toHaveLength(2);
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
});
it('rejects a negative count and adding zero', async () => {
const { service } = createService({ products: [cola] });
await expect(service.createAdjustments('v1', [{ productId: 'p1', type: 'set', quantity: -1 }], user))
.rejects.toMatchObject({ response: { code: 'invalid_quantity' } });
await expect(service.createAdjustments('v1', [{ productId: 'p1', type: 'add', quantity: 0 }], user))
.rejects.toMatchObject({ response: { code: 'invalid_quantity' } });
});
it('rejects unknown products and adding to an untracked product, creating nothing', async () => {
const { service, prisma } = createService({ products: [cola, water] });
await expect(service.createAdjustments('v1', [{ productId: 'p9', type: 'set', quantity: 1 }], user))
.rejects.toMatchObject({ response: { code: 'product_not_found' } });
await expect(service.createAdjustments('v1', [
{ productId: 'p1', type: 'add', quantity: 6 },
{ productId: 'p2', type: 'add', quantity: 6 },
], user)).rejects.toMatchObject({ response: { code: 'stock_not_tracked' } });
expect(prisma.stockAdjustment.create).not.toHaveBeenCalled();
});
});
describe('StockService.cancelAdjustment', () => {
it('cancels a pending adjustment', async () => {
const { service, prisma, realtime } = createService();
await expect(service.cancelAdjustment('v1', 'a1', user)).resolves.toEqual({ ok: true });
expect(prisma.stockAdjustment.updateMany).toHaveBeenCalledWith({
where: { id: 'a1', venueId: 'v1', status: 'pending' },
data: { status: 'cancelled', processedAt: expect.any(Date) },
});
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
});
it('refuses once the terminal processed it', async () => {
const { service } = createService({ cancelled: 0, existing: { id: 'a1', status: 'applied' } });
await expect(service.cancelAdjustment('v1', 'a1', user)).rejects.toMatchObject({ response: { code: 'adjustment_not_pending' } });
});
it('is idempotent and 404s for another venue', async () => {
await expect(createService({ cancelled: 0, existing: { id: 'a1', status: 'cancelled' } }).service.cancelAdjustment('v1', 'a1', user))
.resolves.toEqual({ ok: true });
await expect(createService({ cancelled: 0 }).service.cancelAdjustment('v1', 'a1', user))
.rejects.toMatchObject({ response: { code: 'adjustment_not_found' } });
});
});
+172
View File
@@ -0,0 +1,172 @@
import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
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';
const adjustmentSelect = {
id: true,
productId: true,
productName: true,
type: true,
quantity: true,
note: true,
requestedBy: true,
status: true,
stockAfter: true,
result: true,
createdAt: true,
processedAt: true,
} as const;
@Injectable()
export class StockService {
constructor(
private prisma: PrismaService,
private auditService: AuditService,
private realtime: RealtimeService,
) {}
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<string, typeof pending>();
for (const adjustment of pending) {
pendingByProduct.set(adjustment.productId, [...(pendingByProduct.get(adjustment.productId) ?? []), adjustment]);
}
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
reportedAt: products.reduce<Date | null>((latest, p) => (!latest || p.reportedAt > latest ? p.reportedAt : latest), null),
};
}
async history(venueId: string, options: { productId?: string; limit: number; offset: number }) {
const where = { venueId, ...(options.productId ? { productId: options.productId } : {}) };
const [adjustments, total] = await Promise.all([
this.prisma.stockAdjustment.findMany({
where,
orderBy: { createdAt: 'desc' },
take: options.limit,
skip: options.offset,
select: adjustmentSelect,
}),
this.prisma.stockAdjustment.count({ where }),
]);
return { adjustments, total };
}
async createAdjustments(venueId: string, entries: StockAdjustmentEntryDto[], user: { id: string; email: string; name?: string }) {
for (const entry of entries) {
if (entry.type === 'set' && entry.quantity < 0) {
throw new BadRequestException({ code: 'invalid_quantity', message: 'A stock count cannot be negative' });
}
if (entry.type === 'add' && entry.quantity === 0) {
throw new BadRequestException({ code: 'invalid_quantity', message: 'Adding zero changes nothing' });
}
}
const products = await this.prisma.product.findMany({
where: { venueId, productId: { in: entries.map((e) => e.productId) } },
select: { productId: true, name: true, stock: true },
});
const productsById = new Map(products.map((p) => [p.productId, p]));
for (const entry of entries) {
const product = productsById.get(entry.productId);
if (!product) {
throw new NotFoundException({ code: 'product_not_found', message: 'This product is not on the terminal' });
}
// Counting is how tracking starts; adding to an untracked product has nothing to add to
if (entry.type === 'add' && product.stock === null) {
throw new BadRequestException({
code: 'stock_not_tracked',
message: `Stock is not tracked for ${product.name}. Count it first.`,
});
}
}
const requestedBy = user.name || user.email;
const created = await this.prisma.$transaction(
entries.map((entry) => this.prisma.stockAdjustment.create({
data: {
venueId,
productId: entry.productId,
productName: productsById.get(entry.productId)!.name,
type: entry.type,
quantity: entry.quantity,
note: entry.note ?? null,
requestedBy,
requestedById: user.id,
},
select: adjustmentSelect,
})),
);
await this.auditService.log(venueId, 'client_user', user.id, 'stock_adjustments_created', {
adjustments: created.map((a) => ({ id: a.id, productId: a.productId, type: a.type, quantity: a.quantity })),
});
this.realtime.notify(venueId, 'stock');
return { adjustments: created };
}
async cancelAdjustment(venueId: string, id: string, user: { id: string }) {
const { count } = await this.prisma.stockAdjustment.updateMany({
where: { id, venueId, status: 'pending' },
data: { status: 'cancelled', processedAt: new Date() },
});
if (count === 0) {
const existing = await this.prisma.stockAdjustment.findFirst({ where: { id, venueId } });
if (!existing) {
throw new NotFoundException({ code: 'adjustment_not_found', message: 'Stock adjustment not found' });
}
if (existing.status === 'cancelled') return { ok: true }; // Idempotent
throw new ConflictException({
code: 'adjustment_not_pending',
message: 'The terminal already processed this stock adjustment',
});
}
await this.auditService.log(venueId, 'client_user', user.id, 'stock_adjustment_cancelled', { adjustmentId: id });
this.realtime.notify(venueId, 'stock');
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' });
}
this.realtime.notify(venueId, 'stock');
return { ok: true };
}
}
@@ -13,6 +13,7 @@ import {
Menu,
X,
Bell,
Package,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/cn';
@@ -28,6 +29,7 @@ const navItems = [
{ href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false },
{ href: '/dashboard/tabs', key: 'nav.tabs', icon: ClipboardList, adminOnly: false },
{ href: '/dashboard/reminders', key: 'nav.reminders', icon: Bell, adminOnly: false },
{ href: '/dashboard/stock', key: 'nav.stock', icon: Package, adminOnly: false },
{ href: '/dashboard/users', key: 'nav.users', icon: Monitor, adminOnly: true },
{ href: '/dashboard/commands', key: 'nav.commands', icon: Terminal, adminOnly: false },
{ href: '/dashboard/settings', key: 'nav.settings', icon: Settings, adminOnly: false },
@@ -0,0 +1,445 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Fragment, useState } from 'react';
import { stockApi, type NewStockAdjustment, type Product, type StockAdjustment } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { 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 { useLang } from '@/lib/i18n';
import { useRealtime } from '@/lib/realtime';
import { useSession } from '@/lib/session';
type Filter = 'all' | 'low' | 'untracked';
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' },
];
/** At or below its warning level, or out of stock. */
function isLow(product: Product): boolean {
if (product.stock === null) return false;
return product.stock <= 0 || (product.lowStockThreshold !== null && product.stock <= product.lowStockThreshold);
}
/** "+24", "−6" or "= 48". */
function formatChange(adjustment: Pick<StockAdjustment, 'type' | 'quantity'>): string {
if (adjustment.type === 'set') return `= ${adjustment.quantity}`;
return adjustment.quantity > 0 ? `+${adjustment.quantity}` : `−${-adjustment.quantity}`;
}
// Rejection reasons the terminal sends as a code; anything else is shown as it was sent
const KNOWN_REASONS = ['unknown_product', 'stock_not_tracked'];
const statusVariant = {
pending: 'warning',
applied: 'success',
rejected: 'destructive',
cancelled: 'secondary',
} as const;
export default function StockPage() {
// Live updates make polling unnecessary; it only runs while the socket is down
const { connected: live } = useRealtime();
const { venueId, role } = useSession();
const isAdmin = role === 'admin';
const queryClient = useQueryClient();
const { t, tn, te, relativeTime } = useLang();
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<Filter>('all');
const [adjusting, setAdjusting] = useState<Product | null>(null);
const [deliveryOpen, setDeliveryOpen] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ['stock', venueId],
queryFn: () => stockApi.list(venueId),
refetchInterval: live ? false : 15000,
});
const historyQuery = useQuery({
queryKey: ['stock-history', venueId],
queryFn: () => stockApi.history(venueId, { limit: 20 }),
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 (error) return <ErrorState message={te(error, 'stock.load_failed')} />;
const products = data?.products ?? [];
const lowCount = products.filter(isLow).length;
const query = search.trim().toLowerCase();
const visible = 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;
return true;
});
// Products arrive sorted by category, then name
const groups: { category: string | null; products: Product[] }[] = [];
for (const product of visible) {
const last = groups[groups.length - 1];
if (last && last.category === product.category) last.products.push(product);
else groups.push({ category: product.category, products: [product] });
}
return (
<div>
<PageHeader
title={t('stock.title')}
description={data?.reportedAt
? `${tn('stock.count', products.length)} · ${tn('stock.low_count', lowCount)} · ${t('stock.reported', { time: relativeTime(data.reportedAt) })}`
: undefined}
actions={
<>
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['stock'] })}>
<RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}
</Button>
{products.some((p) => p.stock !== null) && (
<Button size="sm" onClick={() => setDeliveryOpen(true)}>
<Truck className="h-4 w-4 mr-2" />{t('stock.delivery')}
</Button>
)}
</>
}
/>
{products.length === 0 ? (
<EmptyState title={t('stock.empty')} description={t('stock.empty_desc')} />
) : (
<>
<div className="flex gap-2 mb-3 flex-wrap">
<div className="relative flex-1 min-w-[12rem] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder={t('stock.search_placeholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
aria-label={t('stock.search_placeholder')}
/>
</div>
{filters.map((f) => (
<Button key={f.value} variant={filter === f.value ? 'default' : 'outline'} size="sm" onClick={() => setFilter(f.value)}>
{t(f.label)}
</Button>
))}
</div>
<div className="rounded-md border mb-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('stock.product')}</TableHead>
<TableHead>{t('stock.price')}</TableHead>
<TableHead>{t('stock.stock')}</TableHead>
<TableHead>{t('stock.pending')}</TableHead>
<TableHead className="text-right">{t('stock.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="py-6 text-center text-sm text-muted-foreground">
{t('stock.no_matches')}
</TableCell>
</TableRow>
)}
{groups.map((group) => (
<Fragment key={group.category ?? ''}>
<TableRow className="bg-muted/40 hover:bg-muted/40">
<TableCell colSpan={5} className="py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{group.category ?? t('stock.no_category')}
</TableCell>
</TableRow>
{group.products.map((product) => (
<TableRow key={product.productId}>
<TableCell className="font-medium">{product.name}</TableCell>
<TableCell className="tabular-nums">{formatCents(product.priceInCents)}</TableCell>
<TableCell><StockLevel product={product} /></TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{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}>
{formatChange(a)}
<button
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 className="text-right">
<Button size="sm" variant="outline" onClick={() => setAdjusting(product)}>
{product.stock === null ? t('stock.start_tracking') : t('stock.adjust')}
</Button>
</TableCell>
</TableRow>
))}
</Fragment>
))}
</TableBody>
</Table>
</div>
</>
)}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2"><History className="h-4 w-4" /> {t('stock.history')}</CardTitle>
</CardHeader>
<CardContent>
{(historyQuery.data?.adjustments ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">{t('stock.history_empty')}</p>
) : (
<ul className="space-y-2 text-sm">
{historyQuery.data!.adjustments.map((a) => (
<li key={a.id} className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<span className="font-medium">{a.productName}</span>
<span className="ml-2 tabular-nums">{formatChange(a)}</span>
{a.stockAfter !== null && <span className="ml-2 text-muted-foreground">{t('stock.now', { stock: a.stockAfter })}</span>}
{a.note && <span className="ml-2 text-muted-foreground">· {a.note}</span>}
{a.status === 'rejected' && a.result && (
<span className="ml-2 text-destructive">· {KNOWN_REASONS.includes(a.result) ? t(`stock.reason.${a.result}`) : a.result}</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0 text-muted-foreground">
<Badge variant={statusVariant[a.status]}>{t(`stock.status.${a.status}`)}</Badge>
<span>{a.requestedBy}</span>
<span>{relativeTime(a.createdAt)}</span>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
{adjusting && <AdjustDialog product={adjusting} venueId={venueId} canSetThreshold={isAdmin} onClose={() => setAdjusting(null)} />}
{deliveryOpen && (
<DeliveryDialog products={products.filter((p) => p.stock !== null)} venueId={venueId} onClose={() => setDeliveryOpen(false)} />
)}
</div>
);
}
function StockLevel({ product }: { product: Product }) {
const { t } = useLang();
if (product.stock === null) return <span className="text-muted-foreground">{t('stock.not_tracked')}</span>;
return (
<div className="flex items-center gap-2">
<span className={product.stock <= 0 ? 'font-semibold text-destructive tabular-nums' : 'font-semibold tabular-nums'}>{product.stock}</span>
{product.stock <= 0
? <Badge variant="destructive">{t('stock.out')}</Badge>
: isLow(product) && <Badge variant="warning">{t('stock.low')}</Badge>}
</div>
);
}
function useAdjustMutation(venueId: string, onDone: () => void) {
const queryClient = useQueryClient();
const { t, te } = useLang();
return useMutation({
mutationFn: (adjustments: NewStockAdjustment[]) => stockApi.adjust(venueId, adjustments),
onSuccess: () => {
toast({ title: t('stock.sent'), description: t('stock.sent_desc'), variant: 'success' });
onDone();
},
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['stock'] });
queryClient.invalidateQueries({ queryKey: ['stock-history'] });
},
});
}
function AdjustDialog({ product, venueId, canSetThreshold, onClose }: { product: Product; venueId: string; canSetThreshold: boolean; onClose: () => void }) {
const { t, te } = useLang();
const queryClient = useQueryClient();
const tracked = product.stock !== null;
const [mode, setMode] = useState<Mode>(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;
const valid = amount !== null && (mode === 'count' || amount > 0);
const preview = valid && tracked
? 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([{
productId: product.productId,
type: mode === 'count' ? 'set' : 'add',
quantity: mode === 'remove' ? -amount! : amount!,
note: note.trim() || undefined,
}]);
}
return (
<Dialog open onOpenChange={(open) => !open && !mutation.isPending && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{product.name}</DialogTitle>
<DialogDescription>
{tracked ? t('stock.current', { stock: product.stock! }) : t('stock.start_tracking_desc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{tracked && (
<RadioGroup value={mode} onValueChange={(value) => setMode(value as Mode)}>
<div className="flex items-center space-x-2"><RadioGroupItem value="add" id="mode-add" /><Label htmlFor="mode-add">{t('stock.mode_add')}</Label></div>
<div className="flex items-center space-x-2"><RadioGroupItem value="remove" id="mode-remove" /><Label htmlFor="mode-remove">{t('stock.mode_remove')}</Label></div>
<div className="flex items-center space-x-2"><RadioGroupItem value="count" id="mode-count" /><Label htmlFor="mode-count">{t('stock.mode_count')}</Label></div>
</RadioGroup>
)}
<div className="space-y-1.5">
<Label htmlFor="quantity">{mode === 'count' ? t('stock.counted') : t('stock.quantity')}</Label>
<Input
id="quantity"
inputMode="numeric"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
autoFocus
/>
{preview !== null && <p className="text-xs text-muted-foreground">{t('stock.preview', { from: product.stock!, to: preview })}</p>}
{mode === 'count' && tracked && <p className="text-xs text-muted-foreground">{t('stock.count_hint')}</p>}
</div>
<div className="space-y-1.5">
<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)} />
</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>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={mutation.isPending}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={!valid || mutation.isPending}>{mutation.isPending ? t('stock.sending') : t('stock.send')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** One delivery adds to several products at once. */
function DeliveryDialog({ products, venueId, onClose }: { products: Product[]; venueId: string; onClose: () => void }) {
const { t, tn } = useLang();
const [quantities, setQuantities] = useState<Record<string, string>>({});
const [note, setNote] = useState('');
const [search, setSearch] = useState('');
const mutation = useAdjustMutation(venueId, onClose);
const entries = Object.entries(quantities)
.filter(([, value]) => /^\d+$/.test(value.trim()) && parseInt(value, 10) > 0)
.map(([productId, value]) => ({ productId, quantity: parseInt(value, 10) }));
const invalid = Object.values(quantities).some((value) => value.trim() !== '' && !/^\d+$/.test(value.trim()));
const query = search.trim().toLowerCase();
const visible = products.filter((p) => !query || p.name.toLowerCase().includes(query));
function submit() {
if (entries.length === 0 || invalid) return;
mutation.mutate(entries.map((e) => ({ productId: e.productId, type: 'add', quantity: e.quantity, note: note.trim() || undefined })));
}
return (
<Dialog open onOpenChange={(open) => !open && !mutation.isPending && onClose()}>
<DialogContent className="max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{t('stock.delivery_title')}</DialogTitle>
<DialogDescription>{t('stock.delivery_desc')}</DialogDescription>
</DialogHeader>
<Input type="search" placeholder={t('stock.search_placeholder')} value={search} onChange={(e) => setSearch(e.target.value)} aria-label={t('stock.search_placeholder')} />
<div className="flex-1 overflow-y-auto space-y-1 py-2 min-h-0">
{visible.map((product) => (
<div key={product.productId} className="flex items-center justify-between gap-3">
<Label htmlFor={`delivery-${product.productId}`} className="flex-1 min-w-0 truncate font-normal">
{product.name}
<span className="ml-2 text-muted-foreground tabular-nums">({product.stock})</span>
</Label>
<Input
id={`delivery-${product.productId}`}
inputMode="numeric"
placeholder="0"
value={quantities[product.productId] ?? ''}
onChange={(e) => setQuantities((current) => ({ ...current, [product.productId]: e.target.value }))}
className="w-20 h-8 text-right"
/>
</div>
))}
</div>
<div className="space-y-1.5">
<Label htmlFor="delivery-note">{t('stock.note')}</Label>
<Input id="delivery-note" value={note} maxLength={200} placeholder={t('stock.delivery_note_placeholder')} onChange={(e) => setNote(e.target.value)} />
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={mutation.isPending}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={entries.length === 0 || invalid || mutation.isPending}>
{mutation.isPending ? t('stock.sending') : tn('stock.delivery_send', entries.length)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+61
View File
@@ -92,6 +92,41 @@ export interface Reminder {
status: string;
}
export type StockAdjustmentType = 'add' | 'set';
export type StockAdjustmentStatus = 'pending' | 'applied' | 'rejected' | 'cancelled';
export interface StockAdjustment {
id: string;
productId: string;
productName: string;
/** add: the stock changes by quantity (negative takes stock out); set: the stock is now quantity */
type: StockAdjustmentType;
quantity: number;
note: string | null;
requestedBy: string;
status: StockAdjustmentStatus;
/** The terminal's stock right after applying it */
stockAfter: number | null;
/** Why the terminal rejected it */
result: string | null;
createdAt: string;
processedAt: string | null;
}
export interface Product {
productId: string;
name: string;
category: string | null;
priceInCents: number;
/** Null when the terminal doesn't track stock for this product; can be negative */
stock: number | null;
lowStockThreshold: number | null;
reportedAt: string;
pendingAdjustments: StockAdjustment[];
}
export type NewStockAdjustment = { productId: string; type: StockAdjustmentType; quantity: number; note?: string };
export function getCookie(name: string): string | null {
if (typeof document === 'undefined') return null;
return document.cookie
@@ -294,6 +329,32 @@ export const remindersApi = {
api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }),
};
// Stock API
export const stockApi = {
list: (venueId: string) =>
api<{ products: Product[]; reportedAt: string | null }>(`/venues/${venueId}/stock`),
history: (venueId: string, params?: { productId?: string; limit?: number; offset?: number }) => {
const searchParams = new URLSearchParams();
if (params?.productId) searchParams.set('productId', params.productId);
if (params?.limit) searchParams.set('limit', String(params.limit));
if (params?.offset) searchParams.set('offset', String(params.offset));
const qs = searchParams.toString();
return api<{ adjustments: StockAdjustment[]; total: number }>(`/venues/${venueId}/stock/adjustments${qs ? `?${qs}` : ''}`);
},
adjust: (venueId: string, adjustments: NewStockAdjustment[]) =>
api<{ adjustments: StockAdjustment[] }>(`/venues/${venueId}/stock/adjustments`, {
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 }),
}),
};
export { setTokens, clearTokens, refreshAccessToken };
export function getAccessToken(): string | null {
+134
View File
@@ -262,6 +262,73 @@ const translations: Record<Lang, Record<string, string>> = {
'reminders.person_history': 'Reminders for {name}',
'reminders.person_history_desc': '{count} reminder sent',
'reminders.person_history_desc_plural': '{count} reminders sent',
'nav.stock': 'Stock',
'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.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.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.search_placeholder': 'Search products',
'stock.filter_all': 'All',
'stock.filter_low': 'Running low',
'stock.filter_untracked': 'Not tracked',
'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.actions': 'Actions',
'stock.not_tracked': 'Not tracked',
'stock.low': 'Low',
'stock.out': 'Out',
'stock.adjust': 'Adjust',
'stock.start_tracking': 'Track stock',
'stock.start_tracking_desc': "The terminal doesn't track stock 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 on the terminal.',
'stock.mode_add': 'Add (delivery)',
'stock.mode_remove': 'Take out (breakage, own use…)',
'stock.mode_count': 'Count (set the stock to what you counted)',
'stock.quantity': 'Quantity',
'stock.counted': 'Counted',
'stock.preview': '{from} → {to}',
'stock.count_hint': 'Drinks sold between your count and the terminal receiving it are not subtracted.',
'stock.note': 'Note (optional)',
'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': 'Send to terminal',
'stock.sending': 'Sending…',
'stock.sent': 'Sent to the terminal',
'stock.sent_desc': 'The stock changes on the terminal at its next sync.',
'stock.delivery': 'Register delivery',
'stock.delivery_title': 'Register delivery',
'stock.delivery_desc': 'Enter how many of each product were delivered. Products you leave empty are not changed.',
'stock.delivery_note_placeholder': 'e.g. supplier or delivery note number',
'stock.delivery_send': 'Add {count} product',
'stock.delivery_send_plural': 'Add {count} products',
'stock.history': 'Recent stock changes',
'stock.history_empty': 'No stock changes from the dashboard yet',
'stock.now': '→ {stock}',
'stock.status.pending': 'Waiting',
'stock.status.applied': 'Applied',
'stock.status.rejected': 'Rejected',
'stock.status.cancelled': 'Cancelled',
'stock.reason.unknown_product': 'product not on the terminal',
'stock.reason.stock_not_tracked': 'stock not tracked on the terminal',
},
nl: {
'nav.overview': 'Overzicht',
@@ -521,6 +588,73 @@ const translations: Record<Lang, Record<string, string>> = {
'reminders.person_history': 'Herinneringen voor {name}',
'reminders.person_history_desc': '{count} herinnering verstuurd',
'reminders.person_history_desc_plural': '{count} herinneringen verstuurd',
'nav.stock': 'Voorraad',
'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.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.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.search_placeholder': 'Zoek producten',
'stock.filter_all': 'Alle',
'stock.filter_low': 'Bijna op',
'stock.filter_untracked': 'Niet bijgehouden',
'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.actions': 'Acties',
'stock.not_tracked': 'Niet bijgehouden',
'stock.low': 'Bijna op',
'stock.out': 'Op',
'stock.adjust': 'Aanpassen',
'stock.start_tracking': 'Voorraad bijhouden',
'stock.start_tracking_desc': 'De kassa houdt voor dit product nog geen voorraad bij. 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 op de kassa.',
'stock.mode_add': 'Bijvullen (levering)',
'stock.mode_remove': 'Afboeken (breuk, eigen gebruik…)',
'stock.mode_count': 'Tellen (voorraad wordt wat je telde)',
'stock.quantity': 'Aantal',
'stock.counted': 'Geteld',
'stock.preview': '{from} → {to}',
'stock.count_hint': 'Wat verkocht wordt tussen je telling en het moment dat de kassa ze ontvangt, wordt niet afgetrokken.',
'stock.note': 'Notitie (optioneel)',
'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': 'Naar kassa sturen',
'stock.sending': 'Versturen…',
'stock.sent': 'Naar de kassa gestuurd',
'stock.sent_desc': 'De voorraad wijzigt op de kassa bij de volgende synchronisatie.',
'stock.delivery': 'Levering invoeren',
'stock.delivery_title': 'Levering invoeren',
'stock.delivery_desc': 'Vul in hoeveel er van elk product geleverd is. Lege producten blijven ongewijzigd.',
'stock.delivery_note_placeholder': 'bv. leverancier of leveringsbon',
'stock.delivery_send': '{count} product bijvullen',
'stock.delivery_send_plural': '{count} producten bijvullen',
'stock.history': 'Recente voorraadwijzigingen',
'stock.history_empty': 'Nog geen voorraadwijzigingen vanuit het dashboard',
'stock.now': '→ {stock}',
'stock.status.pending': 'Wacht',
'stock.status.applied': 'Verwerkt',
'stock.status.rejected': 'Geweigerd',
'stock.status.cancelled': 'Geannuleerd',
'stock.reason.unknown_product': 'product staat niet op de kassa',
'stock.reason.stock_not_tracked': 'voorraad niet bijgehouden op de kassa',
},
};
+1
View File
@@ -13,6 +13,7 @@ const TOPIC_QUERIES: Record<string, string[][]> = {
device: [['device']],
reminders: [['reminder-candidates'], ['reminder-history']],
users: [['users']],
stock: [['stock'], ['stock-history']],
};
const RealtimeContext = createContext({ connected: false });