feat: change email template + add list of items
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "OpenTabSnapshot" ADD COLUMN "items" JSONB;
|
||||||
@@ -77,6 +77,9 @@ model OpenTabSnapshot {
|
|||||||
version Int
|
version Int
|
||||||
totalInCents Int
|
totalInCents Int
|
||||||
itemCount Int
|
itemCount Int
|
||||||
|
// The tab's lines as reported by the terminal: [{ productName, quantity, unitPriceInCents }].
|
||||||
|
// Null for terminals running an app version that doesn't report them.
|
||||||
|
items Json?
|
||||||
reportedAt DateTime
|
reportedAt DateTime
|
||||||
|
|
||||||
device Device @relation(fields: [deviceId], references: [id])
|
device Device @relation(fields: [deviceId], references: [id])
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { DevicesService } from './devices.service';
|
import { DevicesService } from './devices.service';
|
||||||
|
|
||||||
type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number };
|
type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number };
|
||||||
@@ -101,3 +102,26 @@ describe('DevicesService.sync live update signals', () => {
|
|||||||
expect(realtime.notify).toHaveBeenCalledWith('v1', 'device');
|
expect(realtime.notify).toHaveBeenCalledWith('v1', 'device');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('DevicesService.sync tab lines', () => {
|
||||||
|
const base = { id: 't1', customerName: 'Bram', version: 4, totalInCents: 2500, itemCount: 10 };
|
||||||
|
|
||||||
|
it('stores the lines a terminal reports', async () => {
|
||||||
|
const { service, tx } = createService();
|
||||||
|
const items = [{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 }];
|
||||||
|
|
||||||
|
await service.sync(device, { deviceId: 'd1', openTabs: [{ ...base, items }] });
|
||||||
|
|
||||||
|
const { create, update } = tx.openTabSnapshot.upsert.mock.calls[0][0];
|
||||||
|
expect(create.items).toEqual(items);
|
||||||
|
expect(update.items).toEqual(items);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores no lines for app versions that do not report them', async () => {
|
||||||
|
const { service, tx } = createService();
|
||||||
|
|
||||||
|
await service.sync(device, { deviceId: 'd1', openTabs: [base] });
|
||||||
|
|
||||||
|
expect(tx.openTabSnapshot.upsert.mock.calls[0][0].update.items).toBe(Prisma.JsonNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service';
|
import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service';
|
||||||
import { DEVICE_ONLINE_THRESHOLD_MS } from '@kooltap/shared';
|
import { DEVICE_ONLINE_THRESHOLD_MS } from '@kooltap/shared';
|
||||||
import { SyncDto } from './dto/sync.dto';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { OpenTabDto, SyncDto } from './dto/sync.dto';
|
||||||
import { AckCommandDto } from './dto/ack-command.dto';
|
import { AckCommandDto } from './dto/ack-command.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -82,6 +83,7 @@ export class DevicesService {
|
|||||||
version: tab.version,
|
version: tab.version,
|
||||||
totalInCents: tab.totalInCents,
|
totalInCents: tab.totalInCents,
|
||||||
itemCount: tab.itemCount,
|
itemCount: tab.itemCount,
|
||||||
|
items: snapshotItems(tab),
|
||||||
reportedAt: now,
|
reportedAt: now,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
@@ -89,6 +91,7 @@ export class DevicesService {
|
|||||||
version: tab.version,
|
version: tab.version,
|
||||||
totalInCents: tab.totalInCents,
|
totalInCents: tab.totalInCents,
|
||||||
itemCount: tab.itemCount,
|
itemCount: tab.itemCount,
|
||||||
|
items: snapshotItems(tab),
|
||||||
reportedAt: now,
|
reportedAt: now,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -275,3 +278,9 @@ export class DevicesService {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The tab's lines in the stored shape, or JSON null when the terminal didn't report them. */
|
||||||
|
function snapshotItems(tab: OpenTabDto) {
|
||||||
|
if (!tab.items) return Prisma.JsonNull;
|
||||||
|
return tab.items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents }));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
import { IsString, IsOptional, IsArray, IsInt, Min, IsUUID, ValidateNested, ArrayMaxSize, IsNotEmpty } from 'class-validator';
|
import { IsString, IsOptional, IsArray, IsInt, Min, IsUUID, ValidateNested, ArrayMaxSize, IsNotEmpty, MaxLength } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
class OpenTabDto {
|
export class OpenTabItemDto {
|
||||||
|
@ApiProperty({ example: 'Cola' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(200)
|
||||||
|
productName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 10 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 250 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
unitPriceInCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OpenTabDto {
|
||||||
@ApiProperty({ example: 'local-tab-uuid' })
|
@ApiProperty({ example: 'local-tab-uuid' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@@ -26,6 +44,14 @@ class OpenTabDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [OpenTabItemDto], description: 'Lines on the tab; omitted by older app versions' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(500)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => OpenTabItemDto)
|
||||||
|
items?: OpenTabItemDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SyncDto {
|
export class SyncDto {
|
||||||
|
|||||||
@@ -15,19 +15,25 @@ function withTemplates(files: Record<string, string>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('EmailTemplatesService', () => {
|
describe('EmailTemplatesService', () => {
|
||||||
|
// The shipped templates are meant to be edited, so these only check that every value is filled in
|
||||||
it('renders the shipped payment reminder with a subject and a text version', async () => {
|
it('renders the shipped payment reminder with a subject and a text version', async () => {
|
||||||
const service = new EmailTemplatesService();
|
const service = new EmailTemplatesService();
|
||||||
|
|
||||||
const email = await service.render('payment-reminder', {
|
const email = await service.render('payment-reminder', {
|
||||||
customerName: 'Jan',
|
customerName: 'Jan',
|
||||||
venueName: 'Café De Kroon',
|
venueName: 'Café De Kroon',
|
||||||
amount: '€12.50',
|
amount: '€ 12,50',
|
||||||
itemCount: 3,
|
itemCount: 3,
|
||||||
|
hasItems: true,
|
||||||
|
items: [{ quantity: 3, productName: 'Cola', unitPrice: '€ 2,50', lineTotal: '€ 7,50' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(email.subject).toBe('Payment reminder for your tab at Café De Kroon');
|
expect(email.subject.length).toBeGreaterThan(0);
|
||||||
expect(email.html).toContain('<strong>€12.50</strong>');
|
expect(email.html).toContain('€ 12,50');
|
||||||
expect(email.text).toContain('Hi Jan,');
|
expect(email.html).toContain('Cola');
|
||||||
|
expect(email.text).toContain('Cola');
|
||||||
|
expect(email.html).toContain('Jan');
|
||||||
|
expect(email.text).toContain('Jan');
|
||||||
expect(email.html).not.toContain('{{');
|
expect(email.html).not.toContain('{{');
|
||||||
expect(email.text).not.toContain('{{');
|
expect(email.text).not.toContain('{{');
|
||||||
});
|
});
|
||||||
@@ -35,7 +41,9 @@ describe('EmailTemplatesService', () => {
|
|||||||
it('renders the shipped test email', async () => {
|
it('renders the shipped test email', async () => {
|
||||||
const email = await new EmailTemplatesService().render('test-email', { customerName: 'Jan', venueName: 'De Kroon' });
|
const email = await new EmailTemplatesService().render('test-email', { customerName: 'Jan', venueName: 'De Kroon' });
|
||||||
|
|
||||||
expect(email.subject).toBe('Test email from De Kroon');
|
expect(email.subject.length).toBeGreaterThan(0);
|
||||||
|
expect(email.html).toContain('Jan');
|
||||||
|
expect(`${email.subject} ${email.html}`).not.toContain('{{');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('escapes values in the HTML but not in the subject or text version', async () => {
|
it('escapes values in the HTML but not in the subject or text version', async () => {
|
||||||
@@ -83,4 +91,59 @@ describe('EmailTemplatesService', () => {
|
|||||||
await expect(service.render('payment-reminder', {})).rejects.toThrow(/not found/);
|
await expect(service.render('payment-reminder', {})).rejects.toThrow(/not found/);
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('blocks', () => {
|
||||||
|
const render = async (html: string, variables: Record<string, any>) => {
|
||||||
|
const { service, cleanup } = withTemplates({ 'test-email.html': `<title>T</title>${html}` });
|
||||||
|
try {
|
||||||
|
return (await service.render('test-email', variables)).html.replace('<title>T</title>', '');
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
it('repeats a list block per item, with outer values still available', async () => {
|
||||||
|
const html = await render('{{#items}}[{{quantity}}x {{productName}} @ {{venueName}}]{{/items}}', {
|
||||||
|
venueName: 'Kroon',
|
||||||
|
items: [{ quantity: 10, productName: 'Cola' }, { quantity: 2, productName: 'Chips' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(html).toBe('[10x Cola @ Kroon][2x Chips @ Kroon]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a conditional block only when its value is set', async () => {
|
||||||
|
const template = '{{#hasItems}}<p>Overzicht</p>{{#items}}{{productName}}{{/items}}{{/hasItems}}end';
|
||||||
|
|
||||||
|
expect(await render(template, { hasItems: true, items: [{ productName: 'Cola' }] })).toBe('<p>Overzicht</p>Colaend');
|
||||||
|
expect(await render(template, { hasItems: false, items: [] })).toBe('end');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes values from list items', async () => {
|
||||||
|
expect(await render('{{#items}}{{productName}}{{/items}}', { items: [{ productName: '<script>' }] })).toBe('<script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never treats values as template syntax', async () => {
|
||||||
|
const html = await render('{{#items}}{{productName}}{{/items}} {{customerName}}', {
|
||||||
|
customerName: '{{venueName}}',
|
||||||
|
venueName: 'secret',
|
||||||
|
items: [{ productName: '{{#items}}' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(html).toBe('{{#items}} {{venueName}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a block that is never closed', async () => {
|
||||||
|
await expect(render('{{#items}}{{productName}}', { items: [] })).rejects.toThrow(/never closed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a list printed as a plain value', async () => {
|
||||||
|
await expect(render('{{items}}', { items: [] })).rejects.toThrow(/is a list/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unknown placeholders inside a list item', async () => {
|
||||||
|
await expect(render('{{#items}}{{prodcutName}}{{/items}}', { items: [{ productName: 'Cola' }] })).rejects.toThrow(
|
||||||
|
/unknown placeholders: prodcutName/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ export interface RenderedEmail {
|
|||||||
text?: string;
|
text?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TemplateVariables = Record<string, string | number>;
|
/** A value, a condition for `{{#key}}...{{/key}}`, or a list repeated by that block. */
|
||||||
|
export type TemplateValue = string | number | boolean | TemplateVariables[];
|
||||||
|
export type TemplateVariables = { [key: string]: TemplateValue };
|
||||||
|
|
||||||
const PLACEHOLDER = /\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g;
|
// `{{name}}`, `{{#name}}` (block start) or `{{/name}}` (block end)
|
||||||
|
const TOKEN = String.raw`\{\{\s*([#/]?)\s*([A-Za-z0-9_]+)\s*\}\}`;
|
||||||
const TITLE = /<title[^>]*>([\s\S]*?)<\/title>/i;
|
const TITLE = /<title[^>]*>([\s\S]*?)<\/title>/i;
|
||||||
|
|
||||||
/** Templates the API sends; each needs `<name>.html` (and optionally `<name>.txt`). */
|
/** Templates the API sends; each needs `<name>.html` (and optionally `<name>.txt`). */
|
||||||
@@ -83,22 +86,90 @@ export class EmailTemplatesService implements OnModuleInit {
|
|||||||
variables: TemplateVariables,
|
variables: TemplateVariables,
|
||||||
encode: (value: string) => string,
|
encode: (value: string) => string,
|
||||||
): string {
|
): string {
|
||||||
const missing = new Set<string>();
|
const problems: Problems = { unknown: new Set(), other: new Set() };
|
||||||
const result = template.replace(PLACEHOLDER, (_, key: string) => {
|
const result = this.renderBlock(template, [variables], encode, problems);
|
||||||
if (!(key in variables)) {
|
|
||||||
missing.add(key);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return encode(String(variables[key]));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Never send an email with a blank where a value should be
|
// Never send an email with a blank where a value should be
|
||||||
if (missing.size > 0) {
|
if (problems.unknown.size > 0) {
|
||||||
throw new EmailTemplateError(`Email template "${name}" uses unknown placeholders: ${[...missing].join(', ')}`);
|
throw new EmailTemplateError(`Email template "${name}" uses unknown placeholders: ${[...problems.unknown].join(', ')}`);
|
||||||
|
}
|
||||||
|
if (problems.other.size > 0) {
|
||||||
|
throw new EmailTemplateError(`Email template "${name}" has errors: ${[...problems.other].join('; ')}`);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders in a single pass so values are never parsed again: a customer called "{{venueName}}"
|
||||||
|
* stays literal text. `scopes` lists the variables visible here, innermost (current list item) first.
|
||||||
|
*/
|
||||||
|
private renderBlock(
|
||||||
|
template: string,
|
||||||
|
scopes: TemplateVariables[],
|
||||||
|
encode: (value: string) => string,
|
||||||
|
problems: Problems,
|
||||||
|
): string {
|
||||||
|
const token = new RegExp(TOKEN, 'g');
|
||||||
|
let output = '';
|
||||||
|
let position = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = token.exec(template))) {
|
||||||
|
const [whole, kind, key] = match;
|
||||||
|
output += template.slice(position, match.index);
|
||||||
|
position = match.index + whole.length;
|
||||||
|
|
||||||
|
if (kind === '/') {
|
||||||
|
problems.other.add(`{{/${key}}} has no matching {{#${key}}}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === '#') {
|
||||||
|
const close = new RegExp(String.raw`\{\{\s*/\s*${key}\s*\}\}`, 'g');
|
||||||
|
close.lastIndex = position;
|
||||||
|
const end = close.exec(template);
|
||||||
|
if (!end) {
|
||||||
|
problems.other.add(`{{#${key}}} is never closed with {{/${key}}}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
output += this.renderSection(key, template.slice(position, end.index), scopes, encode, problems);
|
||||||
|
position = end.index + end[0].length;
|
||||||
|
token.lastIndex = position;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = lookup(key, scopes);
|
||||||
|
if (value === undefined) {
|
||||||
|
problems.unknown.add(key);
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
problems.other.add(`{{${key}}} is a list; repeat it with {{#${key}}}...{{/${key}}}`);
|
||||||
|
} else {
|
||||||
|
output += encode(String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output + template.slice(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A list repeats the block once per item; any other value shows it once when set. */
|
||||||
|
private renderSection(
|
||||||
|
key: string,
|
||||||
|
body: string,
|
||||||
|
scopes: TemplateVariables[],
|
||||||
|
encode: (value: string) => string,
|
||||||
|
problems: Problems,
|
||||||
|
): string {
|
||||||
|
const value = lookup(key, scopes);
|
||||||
|
if (value === undefined) {
|
||||||
|
problems.unknown.add(key);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => this.renderBlock(body, [item, ...scopes], encode, problems)).join('');
|
||||||
|
}
|
||||||
|
return value ? this.renderBlock(body, scopes, encode, problems) : '';
|
||||||
|
}
|
||||||
|
|
||||||
private async read(file: string): Promise<string | null> {
|
private async read(file: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
return await fs.readFile(path.join(this.directory, file), 'utf8');
|
return await fs.readFile(path.join(this.directory, file), 'utf8');
|
||||||
@@ -108,3 +179,12 @@ export class EmailTemplatesService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Problems = { unknown: Set<string>; other: Set<string> };
|
||||||
|
|
||||||
|
function lookup(key: string, scopes: TemplateVariables[]): TemplateValue | undefined {
|
||||||
|
for (const scope of scopes) {
|
||||||
|
if (key in scope) return scope[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { EmailTemplatesService } from '../email/email-templates.service';
|
||||||
|
import { RemindersService } from './reminders.service';
|
||||||
|
|
||||||
|
function createService(items: unknown) {
|
||||||
|
const prisma = {
|
||||||
|
tabOwnership: { findMany: jest.fn().mockResolvedValue([{ tabId: 't1', deviceId: 'd1' }]) },
|
||||||
|
openTabSnapshot: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([
|
||||||
|
{ tabId: 't1', deviceId: 'd1', customerName: 'Bram', totalInCents: 2900, itemCount: 12, items },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: 'Bram', email: 'bram@example.com' }]) },
|
||||||
|
tabReminder: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn() },
|
||||||
|
venue: { findUnique: jest.fn().mockResolvedValue({ name: 'De Kroon' }) },
|
||||||
|
};
|
||||||
|
return new RemindersService(prisma as any, { notify: jest.fn() } as any, new EmailTemplatesService());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RemindersService.sendReminders', () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
let sentBodies: any[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.RESEND_API_KEY = 'test-key';
|
||||||
|
sentBodies = [];
|
||||||
|
global.fetch = jest.fn(async (_url: any, init: any) => {
|
||||||
|
sentBodies.push(JSON.parse(init.body));
|
||||||
|
return { ok: true, text: async () => '' } as Response;
|
||||||
|
}) as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
delete process.env.RESEND_API_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the tab summary with Belgian amounts', async () => {
|
||||||
|
const service = createService([
|
||||||
|
{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 },
|
||||||
|
{ productName: 'Chips', quantity: 2, unitPriceInCents: 200 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.sendReminders('v1', 'admin@example.com');
|
||||||
|
|
||||||
|
expect(result.sent).toBe(1);
|
||||||
|
const [email] = sentBodies;
|
||||||
|
expect(email.to).toBe('bram@example.com');
|
||||||
|
expect(email.text).toMatch(/10x Cola\s+€\s25,00/);
|
||||||
|
expect(email.text).toMatch(/2x Chips\s+€\s4,00/);
|
||||||
|
expect(email.html).toContain('10x Cola');
|
||||||
|
expect(email.subject).toContain('De Kroon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the summary out when the terminal did not report lines', async () => {
|
||||||
|
const service = createService(null);
|
||||||
|
|
||||||
|
await service.sendReminders('v1', 'admin@example.com');
|
||||||
|
|
||||||
|
const [email] = sentBodies;
|
||||||
|
expect(email.text).not.toContain('10x');
|
||||||
|
expect(email.text).toMatch(/€\s29,00/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -70,6 +70,8 @@ export class RemindersService {
|
|||||||
email,
|
email,
|
||||||
amountInCents: snap.totalInCents,
|
amountInCents: snap.totalInCents,
|
||||||
itemCount: snap.itemCount,
|
itemCount: snap.itemCount,
|
||||||
|
// Null when the terminal's app version doesn't report lines
|
||||||
|
items: (snap.items as TabLine[] | null) ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +103,13 @@ export class RemindersService {
|
|||||||
venueName,
|
venueName,
|
||||||
amount: formatEuro(candidate.amountInCents),
|
amount: formatEuro(candidate.amountInCents),
|
||||||
itemCount: candidate.itemCount,
|
itemCount: candidate.itemCount,
|
||||||
|
hasItems: (candidate.items?.length ?? 0) > 0,
|
||||||
|
items: (candidate.items ?? []).map((item) => ({
|
||||||
|
quantity: item.quantity,
|
||||||
|
productName: item.productName,
|
||||||
|
unitPrice: formatEuro(item.unitPriceInCents),
|
||||||
|
lineTotal: formatEuro(item.quantity * item.unitPriceInCents),
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
const error = await this.sendEmail(candidate.email, email);
|
const error = await this.sendEmail(candidate.email, email);
|
||||||
@@ -208,6 +217,11 @@ export class RemindersService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
|
||||||
|
|
||||||
|
// Belgian notation, as on the dashboard: € 25,00
|
||||||
|
const euro = new Intl.NumberFormat('nl-BE', { style: 'currency', currency: 'EUR' });
|
||||||
|
|
||||||
function formatEuro(cents: number): string {
|
function formatEuro(cents: number): string {
|
||||||
return `€${(cents / 100).toFixed(2)}`;
|
return euro.format(cents / 100);
|
||||||
}
|
}
|
||||||
@@ -40,6 +40,8 @@ export class TabsService {
|
|||||||
version: snapshot.version,
|
version: snapshot.version,
|
||||||
totalInCents: snapshot.totalInCents,
|
totalInCents: snapshot.totalInCents,
|
||||||
itemCount: snapshot.itemCount,
|
itemCount: snapshot.itemCount,
|
||||||
|
// null: the terminal's app version doesn't report lines yet
|
||||||
|
items: snapshot.items ?? null,
|
||||||
deviceId: ownership.device.id,
|
deviceId: ownership.device.id,
|
||||||
deviceName: ownership.device.name,
|
deviceName: ownership.device.name,
|
||||||
deviceLastSeenAt: ownership.device.lastSeenAt,
|
deviceLastSeenAt: ownership.device.lastSeenAt,
|
||||||
|
|||||||
@@ -16,6 +16,25 @@ Write `{{name}}` to insert a value. Values are HTML-escaped in the `.html` file
|
|||||||
and inserted as-is in the `.txt` file. A placeholder without a value stops the email from being sent,
|
and inserted as-is in the `.txt` file. A placeholder without a value stops the email from being sent,
|
||||||
so a typo never reaches a customer.
|
so a typo never reaches a customer.
|
||||||
|
|
||||||
|
### Blocks
|
||||||
|
|
||||||
|
`{{#name}} ... {{/name}}` does one of two things, depending on the value:
|
||||||
|
|
||||||
|
- **A list** repeats the block once per item. Inside it you can use that item's placeholders
|
||||||
|
(and every placeholder from outside the block).
|
||||||
|
- **Anything else** shows the block only when the value is set (e.g. `true`), and hides it otherwise.
|
||||||
|
|
||||||
|
```html
|
||||||
|
{{#hasItems}}
|
||||||
|
<p>Overzicht van je poef:</p>
|
||||||
|
<table>
|
||||||
|
{{#items}}<tr><td>{{quantity}}x {{productName}}</td><td>{{lineTotal}}</td></tr>{{/items}}
|
||||||
|
</table>
|
||||||
|
{{/hasItems}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Amounts are already formatted the Belgian way (`€ 25,00`).
|
||||||
|
|
||||||
### `payment-reminder`
|
### `payment-reminder`
|
||||||
|
|
||||||
Sent by "Send reminders" to customers with an unpaid balance.
|
Sent by "Send reminders" to customers with an unpaid balance.
|
||||||
@@ -24,8 +43,14 @@ Sent by "Send reminders" to customers with an unpaid balance.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `{{customerName}}` | Jan Peeters |
|
| `{{customerName}}` | Jan Peeters |
|
||||||
| `{{venueName}}` | Café De Kroon |
|
| `{{venueName}}` | Café De Kroon |
|
||||||
| `{{amount}}` | €12.50 |
|
| `{{amount}}` | € 29,00 |
|
||||||
| `{{itemCount}}` | 3 |
|
| `{{itemCount}}` | 12 |
|
||||||
|
| `{{#hasItems}}…{{/hasItems}}` | Shown when the tab's lines are known. Terminals on an older app version don't report them. |
|
||||||
|
| `{{#items}}…{{/items}}` | Repeated for each line on the tab, with: |
|
||||||
|
| `{{quantity}}` | 10 |
|
||||||
|
| `{{productName}}` | Cola |
|
||||||
|
| `{{unitPrice}}` | € 2,50 |
|
||||||
|
| `{{lineTotal}}` | € 25,00 |
|
||||||
|
|
||||||
### `test-email`
|
### `test-email`
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,28 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Payment reminder for your tab at {{venueName}}</title>
|
<title>Herinnering aan open poef bij {{venueName}}</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<p>Hi {{customerName}},</p>
|
<p>Beste {{customerName}},</p>
|
||||||
<p>This is a friendly reminder that you have an open tab of <strong>{{amount}}</strong> at {{venueName}} (items: {{itemCount}}).</p>
|
<p>Dit is een kleine reminder om je poef van <strong>{{amount}}</strong> nog te betalen (items: {{itemCount}}).</p>
|
||||||
<p>Please settle your tab at the bar at your earliest convenience.</p>
|
{{#hasItems}}
|
||||||
<p>Thank you!</p>
|
<p>Overzicht van je poef:</p>
|
||||||
|
<table cellpadding="4" cellspacing="0" style="border-collapse: collapse;">
|
||||||
|
{{#items}}
|
||||||
|
<tr>
|
||||||
|
<td>{{quantity}}x {{productName}}</td>
|
||||||
|
<td style="text-align: right; padding-left: 24px;">{{lineTotal}}</td>
|
||||||
|
</tr>
|
||||||
|
{{/items}}
|
||||||
|
<tr>
|
||||||
|
<td style="border-top: 1px solid #ccc;"><strong>Totaal</strong></td>
|
||||||
|
<td style="border-top: 1px solid #ccc; text-align: right; padding-left: 24px;"><strong>{{amount}}</strong></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{{/hasItems}}
|
||||||
|
<p>Gelieve je poef zo spoedig mogelijk te betalen.</p>
|
||||||
|
<p>Met vriendelijke groet,</p>
|
||||||
|
<p>{{venueName}}</p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
Hi {{customerName}},
|
Beste {{customerName}},
|
||||||
|
|
||||||
This is a friendly reminder that you have an open tab of {{amount}} at {{venueName}} (items: {{itemCount}}).
|
Dit is een vriendelijke herinnering om je openstaande poef van {{amount}} bij {{venueName}} (items: {{itemCount}}) te betalen.
|
||||||
|
{{#hasItems}}
|
||||||
|
Overzicht van je poef:
|
||||||
|
{{#items}}{{quantity}}x {{productName}} {{lineTotal}}
|
||||||
|
{{/items}}Totaal: {{amount}}
|
||||||
|
{{/hasItems}}
|
||||||
|
Gelieve je poef zo spoedig mogelijk te betalen.
|
||||||
|
|
||||||
Please settle your tab at the bar at your earliest convenience.
|
Met vriendelijke groet,
|
||||||
|
{{venueName}}
|
||||||
Thank you!
|
|
||||||
@@ -142,7 +142,6 @@ export default function DashboardLayout({
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t">
|
<div className="p-4 border-t">
|
||||||
<LiveIndicator />
|
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
{user && (
|
{user && (
|
||||||
<div className="mb-3 text-sm">
|
<div className="mb-3 text-sm">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useEffect, useState } from 'react';
|
import { Fragment, useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { api, commandsApi, plannedTabsApi, remindersApi } from '@/lib/api/client';
|
import { api, commandsApi, plannedTabsApi, remindersApi } from '@/lib/api/client';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
@@ -17,7 +17,7 @@ import { LoadingState } from '@/components/loading-state';
|
|||||||
import { ErrorState } from '@/components/error-state';
|
import { ErrorState } from '@/components/error-state';
|
||||||
import { toast } from '@/components/ui/toast';
|
import { toast } from '@/components/ui/toast';
|
||||||
import { formatCents, formatRelativeTime } from '@/lib/utils';
|
import { formatCents, formatRelativeTime } from '@/lib/utils';
|
||||||
import { RefreshCw, Plus, X, Users, Mail, Send, Upload } from 'lucide-react';
|
import { RefreshCw, Plus, X, Users, Mail, Send, Upload, ChevronRight, ChevronDown } from 'lucide-react';
|
||||||
import { ImportCustomersDialog } from '@/components/import-customers-dialog';
|
import { ImportCustomersDialog } from '@/components/import-customers-dialog';
|
||||||
import { useLang } from '@/lib/i18n';
|
import { useLang } from '@/lib/i18n';
|
||||||
import { useRealtime } from '@/lib/realtime';
|
import { useRealtime } from '@/lib/realtime';
|
||||||
@@ -29,6 +29,7 @@ export default function TabsPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [venueId, setVenueId] = useState<string | null>(null);
|
const [venueId, setVenueId] = useState<string | null>(null);
|
||||||
const [closeDialog, setCloseDialog] = useState<{ open: boolean; tab: any }>({ open: false, tab: null });
|
const [closeDialog, setCloseDialog] = useState<{ open: boolean; tab: any }>({ open: false, tab: null });
|
||||||
|
const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set());
|
||||||
const [paymentMethod, setPaymentMethod] = useState<string>('');
|
const [paymentMethod, setPaymentMethod] = useState<string>('');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [pendingCommandIds, setPendingCommandIds] = useState<Set<string>>(new Set());
|
const [pendingCommandIds, setPendingCommandIds] = useState<Set<string>>(new Set());
|
||||||
@@ -233,9 +234,22 @@ export default function TabsPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{tabs.map((tab: any) => (
|
{tabs.map((tab: any) => {
|
||||||
<TableRow key={tab.tabId}>
|
const expanded = expandedTabs.has(tab.tabId);
|
||||||
<TableCell className="font-medium">{tab.customerName}</TableCell>
|
const toggle = () => setExpandedTabs((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(tab.tabId)) next.delete(tab.tabId); else next.add(tab.tabId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Fragment key={tab.tabId}>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<button onClick={toggle} className="flex items-center gap-1 hover:text-primary" aria-expanded={expanded} aria-label={`${expanded ? 'Hide' : 'Show'} items for ${tab.customerName}`}>
|
||||||
|
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
{tab.customerName}
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{tab.email ? (
|
{tab.email ? (
|
||||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
@@ -265,7 +279,19 @@ export default function TabsPage() {
|
|||||||
<Button size="sm" disabled={tab.totalInCents <= 0} onClick={() => { setCloseDialog({ open: true, tab }); setPaymentMethod(''); }}>Close</Button>
|
<Button size="sm" disabled={tab.totalInCents <= 0} onClick={() => { setCloseDialog({ open: true, tab }); setPaymentMethod(''); }}>Close</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
{expanded && (
|
||||||
|
<TableRow className="bg-muted/30 hover:bg-muted/30">
|
||||||
|
<TableCell colSpan={7} className="py-3 pl-4 sm:pl-10">
|
||||||
|
{/* Pinned to the visible area so lines stay readable while the table scrolls sideways on phones */}
|
||||||
|
<div className="sticky left-4 max-w-[min(28rem,calc(100vw-7rem))]">
|
||||||
|
<TabLines items={tab.items} />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
@@ -290,6 +316,7 @@ export default function TabsPage() {
|
|||||||
<div className="space-y-3 py-4">
|
<div className="space-y-3 py-4">
|
||||||
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Amount</span><span className="font-semibold">{formatCents(closeDialog.tab.totalInCents)}</span></div>
|
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Amount</span><span className="font-semibold">{formatCents(closeDialog.tab.totalInCents)}</span></div>
|
||||||
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Items</span><span>{closeDialog.tab.itemCount}</span></div>
|
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Items</span><span>{closeDialog.tab.itemCount}</span></div>
|
||||||
|
<div className="rounded-md border p-3"><TabLines items={closeDialog.tab.items} /></div>
|
||||||
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Version</span><span>{closeDialog.tab.version}</span></div>
|
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Version</span><span>{closeDialog.tab.version}</span></div>
|
||||||
<div className="space-y-3 pt-2">
|
<div className="space-y-3 pt-2">
|
||||||
<Label className="text-base font-semibold">Payment method</Label>
|
<Label className="text-base font-semibold">Payment method</Label>
|
||||||
@@ -311,3 +338,28 @@ export default function TabsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
|
||||||
|
|
||||||
|
/** The lines on a tab, e.g. "10× Cola · €2.50 each · €25.00". */
|
||||||
|
function TabLines({ items }: { items: TabLine[] | null | undefined }) {
|
||||||
|
if (!items) {
|
||||||
|
return <p className="text-sm text-muted-foreground">Items aren't reported by this terminal yet. Update the app on the tablet to see them.</p>;
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
return <p className="text-sm text-muted-foreground">No items on this tab.</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<li key={i} className="flex justify-between gap-4">
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">{item.quantity}×</span> {item.productName}
|
||||||
|
<span className="text-muted-foreground"> · {formatCents(item.unitPriceInCents)} each</span>
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums">{formatCents(item.quantity * item.unitPriceInCents)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+48
-12
@@ -17,12 +17,38 @@ let token = '';
|
|||||||
let cursor: string | null = null;
|
let cursor: string | null = null;
|
||||||
let venueId = '';
|
let venueId = '';
|
||||||
let registrationCode = '';
|
let registrationCode = '';
|
||||||
const openTabs: Map<string, { customerName: string; version: number; totalInCents: number; itemCount: number }> = new Map();
|
type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
|
||||||
|
type SimTab = { customerName: string; version: number; items: TabLine[] };
|
||||||
|
|
||||||
|
const openTabs: Map<string, SimTab> = new Map();
|
||||||
let pendingCommands: Array<{ id: string; type: string; tabId: string; expectedVersion: number; paymentMethod: string }> = [];
|
let pendingCommands: Array<{ id: string; type: string; tabId: string; expectedVersion: number; paymentMethod: string }> = [];
|
||||||
let tabCounter = 0;
|
let tabCounter = 0;
|
||||||
let plannedNames: string[] = [];
|
let plannedNames: string[] = [];
|
||||||
let plannedIndex = 0;
|
let plannedIndex = 0;
|
||||||
|
|
||||||
|
// A small bar menu to fill tabs with
|
||||||
|
const MENU: Array<{ productName: string; unitPriceInCents: number }> = [
|
||||||
|
{ productName: 'Cola', unitPriceInCents: 250 },
|
||||||
|
{ productName: 'Jupiler', unitPriceInCents: 280 },
|
||||||
|
{ productName: 'Duvel', unitPriceInCents: 450 },
|
||||||
|
{ productName: 'Spa rood', unitPriceInCents: 230 },
|
||||||
|
{ productName: 'Koffie', unitPriceInCents: 250 },
|
||||||
|
{ productName: 'Chips', unitPriceInCents: 200 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tabTotal = (tab: SimTab) => tab.items.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0);
|
||||||
|
const tabItemCount = (tab: SimTab) => tab.items.reduce((sum, line) => sum + line.quantity, 0);
|
||||||
|
const euro = (cents: number) => `€${(cents / 100).toFixed(2)}`;
|
||||||
|
|
||||||
|
/** 1 to 3 different products, 1 to 4 of each. */
|
||||||
|
function randomLines(): TabLine[] {
|
||||||
|
const shuffled = [...MENU].sort(() => Math.random() - 0.5);
|
||||||
|
return shuffled.slice(0, Math.floor(Math.random() * 3) + 1).map((product) => ({
|
||||||
|
...product,
|
||||||
|
quantity: Math.floor(Math.random() * 4) + 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// ── API helpers ──────────────────────────────────────────
|
// ── API helpers ──────────────────────────────────────────
|
||||||
async function api<T = any>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
async function api<T = any>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||||
const { headers: customHeaders, ...rest } = options;
|
const { headers: customHeaders, ...rest } = options;
|
||||||
@@ -42,7 +68,12 @@ async function api<T = any>(endpoint: string, options: RequestInit = {}): Promis
|
|||||||
// The server treats openTabs as the complete set: tabs left out are considered closed
|
// The server treats openTabs as the complete set: tabs left out are considered closed
|
||||||
function buildTabsPayload() {
|
function buildTabsPayload() {
|
||||||
return Array.from(openTabs.entries()).map(([id, t]) => ({
|
return Array.from(openTabs.entries()).map(([id, t]) => ({
|
||||||
id, customerName: t.customerName, version: t.version, totalInCents: t.totalInCents, itemCount: t.itemCount,
|
id,
|
||||||
|
customerName: t.customerName,
|
||||||
|
version: t.version,
|
||||||
|
totalInCents: tabTotal(t),
|
||||||
|
itemCount: tabItemCount(t),
|
||||||
|
items: t.items,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,10 +131,10 @@ function addTab() {
|
|||||||
const names = plannedNames.length > 0 ? plannedNames : fallbackNames;
|
const names = plannedNames.length > 0 ? plannedNames : fallbackNames;
|
||||||
const name = names[plannedIndex % names.length];
|
const name = names[plannedIndex % names.length];
|
||||||
plannedIndex++;
|
plannedIndex++;
|
||||||
const items = Math.floor(Math.random() * 5) + 1;
|
const tab: SimTab = { customerName: name, version: 1, items: randomLines() };
|
||||||
const total = items * (Math.floor(Math.random() * 10) + 5) * 100;
|
openTabs.set(tabId, tab);
|
||||||
openTabs.set(tabId, { customerName: name, version: 1, totalInCents: total, itemCount: items });
|
const lines = tab.items.map((line) => `${line.quantity}x ${line.productName}`).join(', ');
|
||||||
console.log(` 📋 Tab added: ${name} — €${(total / 100).toFixed(2)} (${items} items) [${tabId.substring(0, 8)}]`);
|
console.log(` 📋 Tab added: ${name} — ${euro(tabTotal(tab))} (${lines}) [${tabId.substring(0, 8)}]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sync() {
|
async function sync() {
|
||||||
@@ -151,11 +182,13 @@ async function ackAll() {
|
|||||||
});
|
});
|
||||||
console.log(` ✅ Acked ${cmd.id.substring(0, 8)} as ${status}`);
|
console.log(` ✅ Acked ${cmd.id.substring(0, 8)} as ${status}`);
|
||||||
|
|
||||||
// If completed, remove the tab from local state
|
// Like the real app, closing settles the tab: its lines are cleared but the tab stays open
|
||||||
if (status === 'completed') {
|
const settled = openTabs.get(cmd.tabId);
|
||||||
openTabs.delete(cmd.tabId);
|
if (status === 'completed' && settled) {
|
||||||
console.log(` Removed tab ${cmd.tabId.substring(0, 8)} from local state`);
|
settled.items = [];
|
||||||
} else {
|
settled.version++;
|
||||||
|
console.log(` Settled tab ${cmd.tabId.substring(0, 8)} (now empty, v${settled.version})`);
|
||||||
|
} else if (status !== 'completed') {
|
||||||
// Bump version on failed close
|
// Bump version on failed close
|
||||||
const tab = openTabs.get(cmd.tabId);
|
const tab = openTabs.get(cmd.tabId);
|
||||||
if (tab) {
|
if (tab) {
|
||||||
@@ -177,7 +210,10 @@ function listTabs() {
|
|||||||
}
|
}
|
||||||
console.log(`\n 📋 Open Tabs (${openTabs.size}):`);
|
console.log(`\n 📋 Open Tabs (${openTabs.size}):`);
|
||||||
Array.from(openTabs.entries()).forEach(([id, t]) => {
|
Array.from(openTabs.entries()).forEach(([id, t]) => {
|
||||||
console.log(` ${id.substring(0, 8)} — ${t.customerName} — €${(t.totalInCents / 100).toFixed(2)} (v${t.version})`);
|
console.log(` ${id.substring(0, 8)} — ${t.customerName} — ${euro(tabTotal(t))} (v${t.version})`);
|
||||||
|
for (const line of t.items) {
|
||||||
|
console.log(` ${line.quantity}x ${line.productName} ${euro(line.quantity * line.unitPriceInCents)}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user