feat: reminders, Excel customer import and security hardening
Features - Payment reminders via Resend (candidates, send, history, test email) - Bulk import of the customer list from Excel/CSV in the dashboard - Dashboard i18n (en/nl) with language selector Security - Enforce venue isolation on all /venues/:venueId routes - Require admin for planned-tab changes and pairing codes - Rate-limit login and device registration; opt-in TRUST_PROXY - Require JWT_SECRET in production; stop leaking internal errors - Escape customer names in reminder emails Sync and commands - Remove tabs the terminal no longer reports; validate nested sync payload - Return all pending commands on every sync (fixes missed-command race) - First command ack wins; atomic registration code claim - Reject commands for revoked devices, stale versions or duplicates - Remind only unpaid balances, with a 7-day cooldown Dashboard - Share a single token refresh across concurrent 401s (random logouts) - Keep the session alive for the refresh token lifetime Deployment - Replace `db push --accept-data-loss` with migrations (baselines legacy DBs) - Set NODE_ENV=production; pass Resend and TRUST_PROXY env in prod compose
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Applies database migrations on container start.
|
||||
*
|
||||
* Databases created before migrations were used (via `prisma db push`) have no
|
||||
* `_prisma_migrations` table. Those are brought up to date with a non-destructive
|
||||
* `db push` (which refuses to drop data) and then baselined, so every later deploy
|
||||
* can use plain `prisma migrate deploy`.
|
||||
*/
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
function run(command) {
|
||||
console.log(`[deploy] ${command}`);
|
||||
execSync(command, { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
let hasMigrationsTable;
|
||||
let hasTables;
|
||||
try {
|
||||
const [{ migrations, tables }] = await prisma.$queryRaw`
|
||||
SELECT
|
||||
to_regclass('public._prisma_migrations') IS NOT NULL AS migrations,
|
||||
EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public') AS tables`;
|
||||
hasMigrationsTable = migrations;
|
||||
hasTables = tables;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
if (!hasMigrationsTable && hasTables) {
|
||||
console.log('[deploy] Existing database without migration history, baselining');
|
||||
run('npx prisma db push --skip-generate');
|
||||
|
||||
const migrationsDir = path.join(__dirname, 'migrations');
|
||||
const migrations = fs
|
||||
.readdirSync(migrationsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
for (const name of migrations) {
|
||||
run(`npx prisma migrate resolve --applied ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
run('npx prisma migrate deploy');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[deploy] Migration failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "PlannedTab" ADD COLUMN "email" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TabReminder" (
|
||||
"id" TEXT NOT NULL,
|
||||
"venueId" TEXT NOT NULL,
|
||||
"tabId" TEXT NOT NULL,
|
||||
"customerName" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"amountInCents" INTEGER NOT NULL,
|
||||
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"status" TEXT NOT NULL DEFAULT 'sent',
|
||||
|
||||
CONSTRAINT "TabReminder_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TabReminder_venueId_sentAt_idx" ON "TabReminder"("venueId", "sentAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TabReminder_venueId_tabId_idx" ON "TabReminder"("venueId", "tabId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TabReminder" ADD CONSTRAINT "TabReminder_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -20,6 +20,7 @@ model Venue {
|
||||
auditLogs AuditLog[]
|
||||
regCodes RegistrationCode[]
|
||||
plannedTabs PlannedTab[]
|
||||
tabReminders TabReminder[]
|
||||
}
|
||||
|
||||
model Device {
|
||||
@@ -148,6 +149,7 @@ model PlannedTab {
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
customerName String
|
||||
email String?
|
||||
position Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -155,4 +157,20 @@ model PlannedTab {
|
||||
|
||||
@@unique([venueId, customerName])
|
||||
@@index([venueId])
|
||||
}
|
||||
|
||||
model TabReminder {
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
tabId String
|
||||
customerName String
|
||||
email String
|
||||
amountInCents Int
|
||||
sentAt DateTime @default(now())
|
||||
status String @default("sent")
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
|
||||
@@index([venueId, sentAt])
|
||||
@@index([venueId, tabId])
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { CommandsModule } from './commands/commands.module';
|
||||
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 { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
@@ -26,6 +27,7 @@ import { HealthController } from './health.controller';
|
||||
AuditModule,
|
||||
JobsModule,
|
||||
PlannedTabsModule,
|
||||
RemindersModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
|
||||
@ApiTags('Authentication')
|
||||
@Controller('auth')
|
||||
@@ -9,6 +11,7 @@ export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Login with email and password' })
|
||||
async login(@Body() dto: LoginDto) {
|
||||
@@ -18,15 +21,15 @@ export class AuthController {
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Refresh access token' })
|
||||
async refresh(@Body('refreshToken') refreshToken: string) {
|
||||
return this.authService.refresh(refreshToken);
|
||||
async refresh(@Body() dto: RefreshTokenDto) {
|
||||
return this.authService.refresh(dto.refreshToken);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Logout and revoke refresh token' })
|
||||
async logout(@Body('refreshToken') refreshToken: string) {
|
||||
await this.authService.logout(refreshToken);
|
||||
async logout(@Body() dto: RefreshTokenDto) {
|
||||
await this.authService.logout(dto.refreshToken);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,13 @@ import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { DeviceStrategy } from './strategies/device.strategy';
|
||||
import { getJwtSecret } from './jwt-secret';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
|
||||
secret: getJwtSecret(),
|
||||
signOptions: { expiresIn: '24h' },
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -129,33 +129,46 @@ export class AuthService {
|
||||
throw new UnauthorizedException({ code: 'venue_mismatch', message: 'Registration code does not match venue' });
|
||||
}
|
||||
|
||||
const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken();
|
||||
const deviceId = uuidv4();
|
||||
|
||||
// One device per venue — revoke any existing device
|
||||
const existing = await this.prisma.device.findFirst({ where: { venueId, revokedAt: null } });
|
||||
if (existing) {
|
||||
await this.prisma.device.update({
|
||||
where: { id: existing.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.device.create({
|
||||
data: {
|
||||
id: deviceId,
|
||||
venueId,
|
||||
name,
|
||||
tokenHash: hash,
|
||||
tokenPrefix: prefix,
|
||||
tokenExpiresAt: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.registrationCode.update({
|
||||
where: { id: code.id },
|
||||
// Claim the code atomically so it can only ever be used once
|
||||
const claimed = await this.prisma.registrationCode.updateMany({
|
||||
where: { id: code.id, usedAt: null },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
if (claimed.count === 0) {
|
||||
throw new UnauthorizedException({ code: 'invalid_registration_code', message: 'Invalid or expired registration code' });
|
||||
}
|
||||
|
||||
const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken();
|
||||
let deviceId: string;
|
||||
|
||||
// One device per venue — update existing or create new
|
||||
const existing = await this.prisma.device.findFirst({ where: { venueId } });
|
||||
if (existing) {
|
||||
deviceId = existing.id;
|
||||
await this.prisma.device.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
name,
|
||||
tokenHash: hash,
|
||||
tokenPrefix: prefix,
|
||||
tokenExpiresAt: expiresAt,
|
||||
revokedAt: null,
|
||||
lastSeenAt: new Date(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
deviceId = uuidv4();
|
||||
await this.prisma.device.create({
|
||||
data: {
|
||||
id: deviceId,
|
||||
venueId,
|
||||
name,
|
||||
tokenHash: hash,
|
||||
tokenPrefix: prefix,
|
||||
tokenExpiresAt: expiresAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await this.auditService.log(venueId, 'device', deviceId, 'device_registered', { deviceName: name });
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IsString } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty({ example: 'refresh-token-value' })
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
const DEV_FALLBACK_SECRET = 'dev-jwt-secret-change-in-production';
|
||||
|
||||
let warned = false;
|
||||
|
||||
export function getJwtSecret(): string {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (secret) {
|
||||
return secret;
|
||||
}
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('JWT_SECRET must be set in production');
|
||||
}
|
||||
if (!warned) {
|
||||
new Logger('Auth').warn('JWT_SECRET is not set — using an insecure development secret');
|
||||
warned = true;
|
||||
}
|
||||
return DEV_FALLBACK_SECRET;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { getJwtSecret } from '../jwt-secret';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
@@ -8,7 +9,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
|
||||
secretOrKey: getJwtSecret(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,17 +10,18 @@ import {
|
||||
HttpStatus,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { CommandsService } from './commands.service';
|
||||
import { DevicesService } from '../devices/devices.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
|
||||
import { CreateCommandDto } from './dto/create-command.dto';
|
||||
|
||||
@ApiTags('Commands')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, VenueAccessGuard)
|
||||
@Controller('venues/:venueId/commands')
|
||||
export class CommandsController {
|
||||
constructor(
|
||||
@@ -51,15 +52,16 @@ export class CommandsController {
|
||||
) {
|
||||
return this.commandsService.findAll(venueId, {
|
||||
status,
|
||||
limit: limit ? parseInt(limit, 10) : 50,
|
||||
offset: offset ? parseInt(offset, 10) : 0,
|
||||
limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200),
|
||||
offset: Math.max(parseInt(offset ?? '', 10) || 0, 0),
|
||||
});
|
||||
}
|
||||
|
||||
@Post('devices/:deviceId/revoke')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Revoke a device' })
|
||||
@ApiOperation({ summary: 'Revoke a device (admin only)' })
|
||||
@ApiResponse({ status: 200, description: 'Device revoked' })
|
||||
async revoke(
|
||||
@Param('venueId') venueId: string,
|
||||
@Param('deviceId') deviceId: string,
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -54,6 +55,33 @@ export class CommandsService {
|
||||
});
|
||||
}
|
||||
|
||||
if (device.revokedAt) {
|
||||
throw new ConflictException({
|
||||
code: 'device_revoked',
|
||||
message: 'The device owning this tab has been revoked',
|
||||
});
|
||||
}
|
||||
|
||||
const snapshot = await tx.openTabSnapshot.findUnique({
|
||||
where: { deviceId_tabId: { deviceId: device.id, tabId: dto.tabId } },
|
||||
});
|
||||
if (snapshot && snapshot.version !== dto.expectedVersion) {
|
||||
throw new ConflictException({
|
||||
code: 'version_mismatch',
|
||||
message: 'The tab has changed since it was loaded. Refresh and try again.',
|
||||
});
|
||||
}
|
||||
|
||||
const alreadyPending = await tx.remoteCommand.findFirst({
|
||||
where: { venueId, targetTabId: dto.tabId, status: 'pending' },
|
||||
});
|
||||
if (alreadyPending) {
|
||||
throw new ConflictException({
|
||||
code: 'command_already_pending',
|
||||
message: 'A command for this tab is already pending',
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + COMMAND_EXPIRY_HOURS * 60 * 60 * 1000);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
@@ -31,8 +33,13 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
code = resp.code || this.statusToCode(status);
|
||||
}
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
} else {
|
||||
// Never leak internal error details (e.g. database errors) to clients
|
||||
const request = ctx.getRequest();
|
||||
this.logger.error(
|
||||
`Unhandled error on ${request?.method} ${request?.url}`,
|
||||
exception instanceof Error ? exception.stack : String(exception),
|
||||
);
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { VenueAccessGuard } from './venue-access.guard';
|
||||
|
||||
function contextFor(params: Record<string, string>, user?: { venueId: string }): ExecutionContext {
|
||||
return {
|
||||
switchToHttp: () => ({ getRequest: () => ({ params, user }) }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
describe('VenueAccessGuard', () => {
|
||||
const guard = new VenueAccessGuard();
|
||||
|
||||
it('allows routes without a venueId param', () => {
|
||||
expect(guard.canActivate(contextFor({}, { venueId: 'a' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('allows access to the user\'s own venue', () => {
|
||||
expect(guard.canActivate(contextFor({ venueId: 'a' }, { venueId: 'a' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects access to another venue', () => {
|
||||
expect(() => guard.canActivate(contextFor({ venueId: 'b' }, { venueId: 'a' }))).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('rejects when no user is attached', () => {
|
||||
expect(() => guard.canActivate(contextFor({ venueId: 'a' }))).toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Injectable, ExecutionContext, CanActivate, ForbiddenException } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Ensures the authenticated user can only access the venue they belong to.
|
||||
* Must run after JwtAuthGuard so that `request.user` is populated.
|
||||
*/
|
||||
@Injectable()
|
||||
export class VenueAccessGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const venueId = request.params?.venueId;
|
||||
|
||||
if (!venueId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!request.user || request.user.venueId !== venueId) {
|
||||
throw new ForbiddenException({ code: 'venue_forbidden', message: 'You do not have access to this venue' });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
HttpStatus,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { DevicesService } from './devices.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
@@ -29,7 +30,9 @@ export class DevicesController {
|
||||
) {}
|
||||
|
||||
@Post('register')
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||
@ApiOperation({ summary: 'Register a new device' })
|
||||
@ApiResponse({ status: 201, description: 'Device registered successfully' })
|
||||
async register(@Body() dto: RegisterDeviceDto) {
|
||||
return this.authService.registerDevice(dto.venueId, dto.name, dto.registrationCode);
|
||||
}
|
||||
@@ -37,13 +40,15 @@ export class DevicesController {
|
||||
@Post(':deviceId/sync')
|
||||
@UseGuards(DeviceAuthGuard)
|
||||
@ApiOperation({ summary: 'Sync device state with server' })
|
||||
@ApiResponse({ status: 200, description: 'Sync successful, returns pending commands' })
|
||||
async sync(@Param('deviceId') deviceId: string, @Body() dto: SyncDto, @Req() req: any) {
|
||||
return this.devicesService.sync(req.user, dto);
|
||||
}
|
||||
|
||||
@Post(':deviceId/commands/:commandId/ack')
|
||||
@UseGuards(DeviceAuthGuard)
|
||||
@ApiOperation({ summary: 'Acknowledge a command' })
|
||||
@ApiOperation({ summary: 'Acknowledge a command result' })
|
||||
@ApiResponse({ status: 200, description: 'Command acknowledged' })
|
||||
async ackCommand(
|
||||
@Param('deviceId') deviceId: string,
|
||||
@Param('commandId') commandId: string,
|
||||
@@ -54,9 +59,11 @@ export class DevicesController {
|
||||
}
|
||||
|
||||
@Get('generation-code')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Generate a registration code' })
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Generate a registration code for pairing' })
|
||||
@ApiResponse({ status: 200, description: 'Registration code generated' })
|
||||
async generateCode(@Req() req: any) {
|
||||
const venueId = req.user.venueId;
|
||||
return this.authService.generateRegistrationCode(venueId);
|
||||
|
||||
@@ -26,68 +26,75 @@ export class DevicesService {
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const openTabs = dto.openTabs || [];
|
||||
const openTabIds = openTabs.map((t) => t.id);
|
||||
|
||||
// Upsert snapshots and ownership
|
||||
for (const tab of dto.openTabs || []) {
|
||||
await this.prisma.openTabSnapshot.upsert({
|
||||
where: {
|
||||
deviceId_tabId: { deviceId: device.id, tabId: tab.id },
|
||||
},
|
||||
create: {
|
||||
deviceId: device.id,
|
||||
tabId: tab.id,
|
||||
customerName: tab.customerName,
|
||||
version: tab.version,
|
||||
totalInCents: tab.totalInCents,
|
||||
itemCount: tab.itemCount,
|
||||
reportedAt: now,
|
||||
},
|
||||
update: {
|
||||
customerName: tab.customerName,
|
||||
version: tab.version,
|
||||
totalInCents: tab.totalInCents,
|
||||
itemCount: tab.itemCount,
|
||||
reportedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
// Check if tab was owned by another device
|
||||
const existingOwnership = await this.prisma.tabOwnership.findUnique({
|
||||
where: { venueId_tabId: { venueId: device.venueId, tabId: tab.id } },
|
||||
});
|
||||
|
||||
if (existingOwnership && existingOwnership.deviceId !== device.id) {
|
||||
if (existingOwnership.lastReportedAt < now) {
|
||||
await this.auditService.log(
|
||||
device.venueId,
|
||||
'device',
|
||||
device.id,
|
||||
'snapshot_conflict',
|
||||
{ tabId: tab.id, previousDevice: existingOwnership.deviceId, newDevice: device.id },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.tabOwnership.upsert({
|
||||
where: { venueId_tabId: { venueId: device.venueId, tabId: tab.id } },
|
||||
create: {
|
||||
tabId: tab.id,
|
||||
venueId: device.venueId,
|
||||
deviceId: device.id,
|
||||
lastReportedAt: now,
|
||||
},
|
||||
update: {
|
||||
deviceId: device.id,
|
||||
lastReportedAt: now,
|
||||
},
|
||||
});
|
||||
// Ownership changes are detected up front so audit logging stays outside the transaction
|
||||
const takenOver = await this.prisma.tabOwnership.findMany({
|
||||
where: { venueId: device.venueId, tabId: { in: openTabIds }, deviceId: { not: device.id } },
|
||||
});
|
||||
for (const ownership of takenOver) {
|
||||
await this.auditService.log(
|
||||
device.venueId,
|
||||
'device',
|
||||
device.id,
|
||||
'snapshot_conflict',
|
||||
{ tabId: ownership.tabId, previousDevice: ownership.deviceId, newDevice: device.id },
|
||||
);
|
||||
}
|
||||
|
||||
// Update lastSeenAt
|
||||
await this.prisma.device.update({
|
||||
where: { id: device.id },
|
||||
data: { lastSeenAt: now },
|
||||
});
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
// The device reports its full set of open tabs: anything it no longer reports has been closed
|
||||
await tx.tabOwnership.deleteMany({
|
||||
where: { deviceId: device.id, tabId: { notIn: openTabIds } },
|
||||
});
|
||||
await tx.openTabSnapshot.deleteMany({
|
||||
where: { deviceId: device.id, tabId: { notIn: openTabIds } },
|
||||
});
|
||||
|
||||
for (const tab of openTabs) {
|
||||
await tx.openTabSnapshot.upsert({
|
||||
where: {
|
||||
deviceId_tabId: { deviceId: device.id, tabId: tab.id },
|
||||
},
|
||||
create: {
|
||||
deviceId: device.id,
|
||||
tabId: tab.id,
|
||||
customerName: tab.customerName,
|
||||
version: tab.version,
|
||||
totalInCents: tab.totalInCents,
|
||||
itemCount: tab.itemCount,
|
||||
reportedAt: now,
|
||||
},
|
||||
update: {
|
||||
customerName: tab.customerName,
|
||||
version: tab.version,
|
||||
totalInCents: tab.totalInCents,
|
||||
itemCount: tab.itemCount,
|
||||
reportedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.tabOwnership.upsert({
|
||||
where: { venueId_tabId: { venueId: device.venueId, tabId: tab.id } },
|
||||
create: {
|
||||
tabId: tab.id,
|
||||
venueId: device.venueId,
|
||||
deviceId: device.id,
|
||||
lastReportedAt: now,
|
||||
},
|
||||
update: {
|
||||
deviceId: device.id,
|
||||
lastReportedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.device.update({
|
||||
where: { id: device.id },
|
||||
data: { lastSeenAt: now },
|
||||
});
|
||||
}, { timeout: 15000 });
|
||||
|
||||
// Expire old commands
|
||||
await this.prisma.remoteCommand.updateMany({
|
||||
@@ -99,33 +106,15 @@ export class DevicesService {
|
||||
data: { status: 'expired' },
|
||||
});
|
||||
|
||||
// Build cursor from timestamp
|
||||
const cursorTimestamp = now.toISOString();
|
||||
const tieBreaker = uuidv4().substring(0, 8);
|
||||
const cursor = Buffer.from(`${cursorTimestamp}:${tieBreaker}`).toString('base64url');
|
||||
|
||||
// Build the Prisma where clause
|
||||
const commandWhere: any = {
|
||||
// The cursor is kept for client compatibility but no longer filters commands: every pending
|
||||
// command is returned on each sync, so a command committed while a sync was in flight can never
|
||||
// be skipped. Devices dedupe by command id and re-ack commands they already processed.
|
||||
const cursor = Buffer.from(`${now.toISOString()}:${uuidv4().substring(0, 8)}`).toString('base64url');
|
||||
const commandWhere = {
|
||||
deviceId: device.id,
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
// Parse cursor from request if provided — skip filter if cursor is missing or invalid
|
||||
if (dto.cursor) {
|
||||
try {
|
||||
const decoded = Buffer.from(dto.cursor, 'base64url').toString('utf-8');
|
||||
const parts = decoded.split(':');
|
||||
if (parts.length >= 1) {
|
||||
const parsed = new Date(parts[0]);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
commandWhere.createdAt = { gt: parsed };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid cursor — ignore filter, return all pending commands
|
||||
}
|
||||
}
|
||||
|
||||
// Find pending commands for this device
|
||||
const commands = await this.prisma.remoteCommand.findMany({
|
||||
where: commandWhere,
|
||||
@@ -182,13 +171,13 @@ export class DevicesService {
|
||||
throw new ForbiddenException({ code: 'venue_mismatch', message: 'Command belongs to a different venue' });
|
||||
}
|
||||
|
||||
// Idempotent: if already acknowledged with same status, just return ok
|
||||
if (command.status === dto.status && command.acknowledgedAt) {
|
||||
return { ok: true };
|
||||
// Idempotent: the first acknowledgement wins, retries and late duplicates are no-ops
|
||||
if (command.acknowledgedAt) {
|
||||
return { ok: true, status: command.status };
|
||||
}
|
||||
|
||||
await this.prisma.remoteCommand.update({
|
||||
where: { id: commandId },
|
||||
const updated = await this.prisma.remoteCommand.updateMany({
|
||||
where: { id: commandId, acknowledgedAt: null },
|
||||
data: {
|
||||
status: dto.status,
|
||||
result: dto.result || null,
|
||||
@@ -196,6 +185,11 @@ export class DevicesService {
|
||||
},
|
||||
});
|
||||
|
||||
if (updated.count === 0) {
|
||||
// A concurrent ack got there first
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
await this.auditService.log(
|
||||
device.venueId,
|
||||
'device',
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { IsString, IsOptional, IsArray, IsInt, Min, IsUUID } from 'class-validator';
|
||||
import { IsString, IsOptional, IsArray, IsInt, Min, IsUUID, ValidateNested, ArrayMaxSize, IsNotEmpty } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
class OpenTabDto {
|
||||
@ApiProperty({ example: 'local-tab-uuid' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ example: 'Jan' })
|
||||
@@ -38,5 +40,8 @@ export class SyncDto {
|
||||
|
||||
@ApiProperty({ type: [OpenTabDto] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(1000)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OpenTabDto)
|
||||
openTabs: OpenTabDto[];
|
||||
}
|
||||
@@ -1,26 +1,32 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { Controller, Get, HttpStatus, Res } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
import { Response } from 'express';
|
||||
import { PrismaService } from './prisma/prisma.service';
|
||||
|
||||
@ApiTags('Health')
|
||||
@SkipThrottle()
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Health check' })
|
||||
async check() {
|
||||
@ApiResponse({ status: 200, description: 'Server is healthy' })
|
||||
@ApiResponse({ status: 503, description: 'Database is unreachable' })
|
||||
async check(@Res({ passthrough: true }) res: Response) {
|
||||
let dbStatus = 'ok';
|
||||
try {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
} catch {
|
||||
dbStatus = 'error';
|
||||
res.status(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
status: dbStatus === 'ok' ? 'ok' : 'degraded',
|
||||
database: dbStatus,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -2,15 +2,25 @@ import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import helmet from 'helmet';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { AppModule } from './app.module';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
// Bulk customer imports can exceed the 100kb default
|
||||
app.useBodyParser('json', { limit: '1mb' });
|
||||
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
// Behind a reverse proxy, trust X-Forwarded-For so rate limiting is per client instead of per proxy.
|
||||
if (process.env.TRUST_PROXY) {
|
||||
const value = process.env.TRUST_PROXY;
|
||||
app.set('trust proxy', /^\d+$/.test(value) ? Number(value) : value === 'true' ? true : value);
|
||||
}
|
||||
|
||||
app.use(helmet());
|
||||
|
||||
app.enableCors({
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { IsString, IsOptional, IsNotEmpty, IsEmail, MaxLength } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreatePlannedTabDto {
|
||||
@ApiProperty({ example: 'Jan' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
customerName: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'jan@example.com' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value))
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { IsString, IsOptional, IsNotEmpty, IsEmail, MaxLength, IsArray, ArrayMaxSize, ValidateNested, IsIn } from 'class-validator';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ImportPlannedTabEntryDto {
|
||||
@ApiProperty({ example: 'Jan Peeters' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
customerName: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'jan@example.com' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value))
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export class ImportPlannedTabsDto {
|
||||
@ApiProperty({ type: [ImportPlannedTabEntryDto] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(5000)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ImportPlannedTabEntryDto)
|
||||
entries: ImportPlannedTabEntryDto[];
|
||||
|
||||
@ApiProperty({
|
||||
enum: ['merge', 'replace'],
|
||||
description: 'merge adds new names and updates emails; replace also removes names that are not in the import',
|
||||
})
|
||||
@IsIn(['merge', 'replace'])
|
||||
mode: 'merge' | 'replace';
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { IsString, IsOptional, IsNotEmpty, IsEmail, MaxLength } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdatePlannedTabDto {
|
||||
@ApiProperty({ example: 'Jan' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
customerName: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'jan@example.com' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value))
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } 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 { PlannedTabsService } from './planned-tabs.service';
|
||||
import { CreatePlannedTabDto } from './dto/create-planned-tab.dto';
|
||||
import { UpdatePlannedTabDto } from './dto/update-planned-tab.dto';
|
||||
import { ImportPlannedTabsDto } from './dto/import-planned-tabs.dto';
|
||||
|
||||
@ApiTags('Planned Tabs')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
|
||||
@Controller('venues/:venueId/planned-tabs')
|
||||
export class PlannedTabsController {
|
||||
constructor(private service: PlannedTabsService) {}
|
||||
@@ -17,18 +23,29 @@ export class PlannedTabsController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Add a planned tab name' })
|
||||
async create(@Param('venueId') venueId: string, @Body() dto: { customerName: string }) {
|
||||
return this.service.create(venueId, dto.customerName);
|
||||
async create(@Param('venueId') venueId: string, @Body() dto: CreatePlannedTabDto) {
|
||||
return this.service.create(venueId, dto.customerName, dto.email);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@Roles('admin')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Bulk import planned tab names (e.g. from an Excel file)' })
|
||||
async import(@Param('venueId') venueId: string, @Body() dto: ImportPlannedTabsDto) {
|
||||
return this.service.import(venueId, dto.entries, dto.mode);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Rename a planned tab' })
|
||||
async update(@Param('venueId') venueId: string, @Param('id') id: string, @Body() dto: { customerName: string }) {
|
||||
return this.service.update(venueId, id, dto.customerName);
|
||||
async update(@Param('venueId') venueId: string, @Param('id') id: string, @Body() dto: UpdatePlannedTabDto) {
|
||||
return this.service.update(venueId, id, dto.customerName, dto.email);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Remove a planned tab' })
|
||||
async remove(@Param('venueId') venueId: string, @Param('id') id: string) {
|
||||
return this.service.remove(venueId, id);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { PlannedTabsService } from './planned-tabs.service';
|
||||
|
||||
type Row = { id: string; venueId: string; customerName: string; email: string | null; position: number };
|
||||
|
||||
function createService(initial: Omit<Row, 'venueId'>[]) {
|
||||
let rows: Row[] = initial.map((r) => ({ ...r, venueId: 'v1' }));
|
||||
const tx = {
|
||||
plannedTab: {
|
||||
findMany: jest.fn(async () => [...rows].sort((a, b) => a.position - b.position)),
|
||||
create: jest.fn(async ({ data }: { data: Row }) => {
|
||||
rows.push(data);
|
||||
return data;
|
||||
}),
|
||||
update: jest.fn(async ({ where, data }: { where: { id: string }; data: Partial<Row> }) => {
|
||||
const row = rows.find((r) => r.id === where.id)!;
|
||||
Object.assign(row, data);
|
||||
return row;
|
||||
}),
|
||||
deleteMany: jest.fn(async ({ where }: { where: { id: { in: string[] } } }) => {
|
||||
const before = rows.length;
|
||||
rows = rows.filter((r) => !where.id.in.includes(r.id));
|
||||
return { count: before - rows.length };
|
||||
}),
|
||||
},
|
||||
};
|
||||
const prisma = { $transaction: (fn: (t: typeof tx) => unknown) => fn(tx) };
|
||||
const service = new PlannedTabsService(prisma as any);
|
||||
return { service, rows: () => rows };
|
||||
}
|
||||
|
||||
describe('PlannedTabsService.import', () => {
|
||||
it('adds new names, keeps existing spelling and only fills in emails', async () => {
|
||||
const { service, rows } = createService([
|
||||
{ id: 'a', customerName: 'Jan Peeters', email: null, position: 0 },
|
||||
{ id: 'b', customerName: 'Els', email: 'els@example.com', position: 1 },
|
||||
]);
|
||||
|
||||
const result = await service.import('v1', [
|
||||
{ customerName: ' jan peeters ', email: 'jan@example.com' },
|
||||
{ customerName: 'Els' },
|
||||
{ customerName: 'Piet' },
|
||||
], 'merge');
|
||||
|
||||
expect(result).toEqual({ created: 1, updated: 1, unchanged: 1, removed: 0 });
|
||||
expect(rows().find((r) => r.id === 'a')).toMatchObject({ customerName: 'Jan Peeters', email: 'jan@example.com' });
|
||||
expect(rows().find((r) => r.id === 'b')?.email).toBe('els@example.com');
|
||||
expect(rows().find((r) => r.customerName === 'Piet')?.position).toBe(2);
|
||||
});
|
||||
|
||||
it('collapses duplicate names within the import', async () => {
|
||||
const { service, rows } = createService([]);
|
||||
|
||||
const result = await service.import('v1', [
|
||||
{ customerName: 'Piet', email: 'piet@example.com' },
|
||||
{ customerName: 'PIET' },
|
||||
], 'merge');
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(rows()[0].email).toBe('piet@example.com');
|
||||
});
|
||||
|
||||
it('removes names missing from the import only in replace mode', async () => {
|
||||
const initial = [
|
||||
{ id: 'a', customerName: 'Jan', email: null, position: 0 },
|
||||
{ id: 'b', customerName: 'Els', email: null, position: 1 },
|
||||
];
|
||||
|
||||
const merge = createService(initial);
|
||||
expect((await merge.service.import('v1', [{ customerName: 'Jan' }], 'merge')).removed).toBe(0);
|
||||
expect(merge.rows()).toHaveLength(2);
|
||||
|
||||
const replace = createService(initial);
|
||||
expect((await replace.service.import('v1', [{ customerName: 'jan' }], 'replace')).removed).toBe(1);
|
||||
expect(replace.rows().map((r) => r.customerName)).toEqual(['Jan']);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@@ -10,14 +10,14 @@ export class PlannedTabsService {
|
||||
const tabs = await this.prisma.plannedTab.findMany({
|
||||
where: { venueId },
|
||||
orderBy: { position: 'asc' },
|
||||
select: { id: true, customerName: true, position: true, createdAt: true },
|
||||
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
|
||||
});
|
||||
return { plannedTabs: tabs };
|
||||
}
|
||||
|
||||
async create(venueId: string, customerName: string) {
|
||||
async create(venueId: string, customerName: string, email?: string) {
|
||||
const name = customerName.trim();
|
||||
if (!name) throw new NotFoundException({ code: 'name_required', message: 'Customer name is required' });
|
||||
if (!name) throw new BadRequestException({ code: 'name_required', message: 'Customer name is required' });
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await this.prisma.plannedTab.findUnique({
|
||||
@@ -38,30 +38,90 @@ export class PlannedTabsService {
|
||||
id: uuidv4(),
|
||||
venueId,
|
||||
customerName: name,
|
||||
email: email?.trim() || null,
|
||||
position: (last?.position ?? -1) + 1,
|
||||
},
|
||||
select: { id: true, customerName: true, position: true, createdAt: true },
|
||||
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
|
||||
});
|
||||
|
||||
return tab;
|
||||
}
|
||||
|
||||
async update(venueId: string, id: string, customerName: string) {
|
||||
async update(venueId: string, id: string, customerName: string, email?: string) {
|
||||
const name = customerName.trim();
|
||||
if (!name) throw new NotFoundException({ code: 'name_required', message: 'Customer name is required' });
|
||||
if (!name) throw new BadRequestException({ code: 'name_required', message: 'Customer name is required' });
|
||||
|
||||
const tab = await this.prisma.plannedTab.findFirst({ where: { id, venueId } });
|
||||
if (!tab) throw new NotFoundException({ code: 'not_found', message: 'Planned tab not found' });
|
||||
|
||||
const duplicate = await this.prisma.plannedTab.findUnique({
|
||||
where: { venueId_customerName: { venueId, customerName: name } },
|
||||
});
|
||||
if (duplicate && duplicate.id !== id) {
|
||||
throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' });
|
||||
}
|
||||
|
||||
const result = await this.prisma.plannedTab.update({
|
||||
where: { id },
|
||||
data: { customerName: name },
|
||||
select: { id: true, customerName: true, position: true, createdAt: true },
|
||||
data: { customerName: name, email: email?.trim() || null },
|
||||
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk import of the customer list (e.g. from the client's Excel file).
|
||||
* Names match case-insensitively and existing spelling is kept, because the terminal matches
|
||||
* planned names to tabs exactly and a respelled name would open a second tab.
|
||||
* A missing email in the import never clears an existing one.
|
||||
*/
|
||||
async import(venueId: string, entries: { customerName: string; email?: string }[], mode: 'merge' | 'replace') {
|
||||
const incoming = new Map<string, { customerName: string; email?: string }>();
|
||||
for (const entry of entries) {
|
||||
const customerName = entry.customerName.trim().replace(/\s+/g, ' ');
|
||||
if (!customerName) continue;
|
||||
const key = customerName.toLowerCase();
|
||||
const previous = incoming.get(key);
|
||||
incoming.set(key, { customerName, email: entry.email || previous?.email });
|
||||
}
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.plannedTab.findMany({ where: { venueId }, orderBy: { position: 'asc' } });
|
||||
const existingByKey = new Map(existing.map((t) => [t.customerName.toLowerCase(), t]));
|
||||
let nextPosition = (existing[existing.length - 1]?.position ?? -1) + 1;
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let unchanged = 0;
|
||||
let removed = 0;
|
||||
|
||||
for (const [key, entry] of incoming) {
|
||||
const current = existingByKey.get(key);
|
||||
if (!current) {
|
||||
await tx.plannedTab.create({
|
||||
data: { id: uuidv4(), venueId, customerName: entry.customerName, email: entry.email ?? null, position: nextPosition++ },
|
||||
});
|
||||
created++;
|
||||
} else if (entry.email && entry.email !== current.email) {
|
||||
await tx.plannedTab.update({ where: { id: current.id }, data: { email: entry.email } });
|
||||
updated++;
|
||||
} else {
|
||||
unchanged++;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'replace') {
|
||||
const toRemove = existing.filter((t) => !incoming.has(t.customerName.toLowerCase())).map((t) => t.id);
|
||||
if (toRemove.length > 0) {
|
||||
removed = (await tx.plannedTab.deleteMany({ where: { id: { in: toRemove } } })).count;
|
||||
}
|
||||
}
|
||||
|
||||
return { created, updated, unchanged, removed };
|
||||
}, { timeout: 30000 });
|
||||
}
|
||||
|
||||
async remove(venueId: string, id: string) {
|
||||
const tab = await this.prisma.plannedTab.findFirst({ where: { id, venueId } });
|
||||
if (!tab) throw new NotFoundException({ code: 'not_found', message: 'Planned tab not found' });
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Controller, Get, Post, Param, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
|
||||
import { RemindersService } from './reminders.service';
|
||||
|
||||
@ApiTags('Reminders')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, VenueAccessGuard)
|
||||
@Controller('venues/:venueId/reminders')
|
||||
export class RemindersController {
|
||||
constructor(private service: RemindersService) {}
|
||||
|
||||
@Get('candidates')
|
||||
@ApiOperation({ summary: 'List tabs eligible for payment reminders' })
|
||||
@ApiResponse({ status: 200, description: 'List of eligible tabs with customer email and amount' })
|
||||
async listCandidates(@Param('venueId') venueId: string) {
|
||||
return this.service.listCandidates(venueId);
|
||||
}
|
||||
|
||||
@Post('send')
|
||||
@ApiOperation({ summary: 'Send payment reminders via Resend' })
|
||||
@ApiResponse({ status: 201, description: 'Reminders sent' })
|
||||
@ApiResponse({ status: 400, description: 'Resend not configured' })
|
||||
async send(@Param('venueId') venueId: string, @Req() req: any) {
|
||||
return this.service.sendReminders(venueId, req.user?.email || 'unknown');
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get reminder history' })
|
||||
@ApiResponse({ status: 200, description: 'List of previously sent reminders' })
|
||||
async history(@Param('venueId') venueId: string) {
|
||||
return this.service.history(venueId);
|
||||
}
|
||||
|
||||
@Post('test/:plannedTabId')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Send a test email to a planned customer (admin only)' })
|
||||
@ApiResponse({ status: 201, description: 'Test email sent' })
|
||||
@ApiResponse({ status: 400, description: 'Resend not configured or customer has no email' })
|
||||
@ApiResponse({ status: 404, description: 'Planned customer not found' })
|
||||
async sendTest(
|
||||
@Param('venueId') venueId: string,
|
||||
@Param('plannedTabId') plannedTabId: string,
|
||||
) {
|
||||
return this.service.sendTestEmail(venueId, plannedTabId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RemindersController } from './reminders.controller';
|
||||
import { RemindersService } from './reminders.service';
|
||||
|
||||
@Module({
|
||||
controllers: [RemindersController],
|
||||
providers: [RemindersService],
|
||||
})
|
||||
export class RemindersModule {}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
// Tabs stay open per customer (closing only settles them), so a customer can be reminded again
|
||||
// once this many days have passed since their last reminder.
|
||||
const REMINDER_COOLDOWN_DAYS = 7;
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RemindersService {
|
||||
private resendApiKey: string;
|
||||
private fromEmail: string;
|
||||
|
||||
constructor(private prisma: PrismaService) {
|
||||
this.resendApiKey = process.env.RESEND_API_KEY || '';
|
||||
this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTab <onboarding@resend.dev>';
|
||||
}
|
||||
|
||||
async listCandidates(venueId: string) {
|
||||
const ownerships = await this.prisma.tabOwnership.findMany({
|
||||
where: { venueId },
|
||||
});
|
||||
|
||||
// Only the owning device's snapshot is authoritative for a tab
|
||||
const snapshots = ownerships.length === 0 ? [] : await this.prisma.openTabSnapshot.findMany({
|
||||
where: {
|
||||
OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })),
|
||||
},
|
||||
});
|
||||
const latestByTab = new Map(snapshots.map((s) => [s.tabId, s]));
|
||||
|
||||
const emailMap = new Map<string, string>();
|
||||
try {
|
||||
const plannedTabs = await this.prisma.plannedTab.findMany({
|
||||
where: { venueId, email: { not: null } },
|
||||
select: { customerName: true, email: true },
|
||||
});
|
||||
for (const pt of plannedTabs) {
|
||||
if (pt.email) emailMap.set(pt.customerName.toLowerCase(), pt.email);
|
||||
}
|
||||
} catch {
|
||||
// email column may not exist yet if migration hasn't been applied
|
||||
}
|
||||
|
||||
let sentTabIds = new Set<string>();
|
||||
try {
|
||||
const alreadySent = await this.prisma.tabReminder.findMany({
|
||||
where: { venueId, sentAt: { gt: new Date(Date.now() - REMINDER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000) } },
|
||||
select: { tabId: true },
|
||||
});
|
||||
sentTabIds = new Set(alreadySent.map((r) => r.tabId));
|
||||
} catch {
|
||||
// Table may not exist yet if migration hasn't been applied
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
for (const snap of latestByTab.values()) {
|
||||
if (snap.totalInCents <= 0) continue;
|
||||
if (sentTabIds.has(snap.tabId)) continue;
|
||||
const email = emailMap.get(snap.customerName.toLowerCase());
|
||||
if (!email) continue;
|
||||
candidates.push({
|
||||
tabId: snap.tabId,
|
||||
customerName: snap.customerName,
|
||||
email,
|
||||
amountInCents: snap.totalInCents,
|
||||
itemCount: snap.itemCount,
|
||||
});
|
||||
}
|
||||
|
||||
return { candidates };
|
||||
}
|
||||
|
||||
async sendReminders(venueId: string, requestorEmail: string) {
|
||||
const { candidates } = await this.listCandidates(venueId);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { sent: 0, message: 'No candidates to remind' };
|
||||
}
|
||||
|
||||
if (!this.resendApiKey) {
|
||||
throw new BadRequestException({
|
||||
code: 'resend_not_configured',
|
||||
message: 'Resend API key is not configured. Set RESEND_API_KEY environment variable.',
|
||||
});
|
||||
}
|
||||
|
||||
let sent = 0;
|
||||
const errors: { customerName: string; error: string }[] = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const res = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.resendApiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: this.fromEmail,
|
||||
to: candidate.email,
|
||||
subject: `Payment reminder for your tab at the bar`,
|
||||
html: `<p>Hi ${escapeHtml(candidate.customerName)},</p>
|
||||
<p>This is a friendly reminder that you have an open tab of <strong>€${(candidate.amountInCents / 100).toFixed(2)}</strong> (${candidate.itemCount} item${candidate.itemCount !== 1 ? 's' : ''}).</p>
|
||||
<p>Please settle your tab at the bar at your earliest convenience.</p>
|
||||
<p>Thank you!</p>`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text();
|
||||
errors.push({ customerName: candidate.customerName, error: errBody });
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.prisma.tabReminder.create({
|
||||
data: {
|
||||
venueId,
|
||||
tabId: candidate.tabId,
|
||||
customerName: candidate.customerName,
|
||||
email: candidate.email,
|
||||
amountInCents: candidate.amountInCents,
|
||||
},
|
||||
});
|
||||
|
||||
sent++;
|
||||
} catch (err: any) {
|
||||
errors.push({ customerName: candidate.customerName, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
return { sent, total: candidates.length, errors: errors.length > 0 ? errors : undefined };
|
||||
}
|
||||
|
||||
async history(venueId: string) {
|
||||
try {
|
||||
const reminders = await this.prisma.tabReminder.findMany({
|
||||
where: { venueId },
|
||||
orderBy: { sentAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
return { reminders };
|
||||
} catch {
|
||||
return { reminders: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async sendTestEmail(venueId: string, plannedTabId: string) {
|
||||
if (!this.resendApiKey) {
|
||||
throw new BadRequestException({
|
||||
code: 'resend_not_configured',
|
||||
message: 'Resend API key is not configured. Set RESEND_API_KEY environment variable.',
|
||||
});
|
||||
}
|
||||
|
||||
const tab = await this.prisma.plannedTab.findFirst({
|
||||
where: { id: plannedTabId, venueId },
|
||||
});
|
||||
|
||||
if (!tab) {
|
||||
throw new NotFoundException({ code: 'not_found', message: 'Planned customer not found' });
|
||||
}
|
||||
|
||||
if (!tab.email) {
|
||||
throw new BadRequestException({ code: 'no_email', message: 'This customer has no email address set' });
|
||||
}
|
||||
|
||||
const res = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.resendApiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: this.fromEmail,
|
||||
to: tab.email,
|
||||
subject: `Test email from KoolTab`,
|
||||
html: `<p>Hi ${escapeHtml(tab.customerName)},</p>
|
||||
<p>This is a test email from KoolTab to verify that email reminders are working correctly.</p>
|
||||
<p>If you received this, the email configuration is working!</p>
|
||||
<hr>
|
||||
<p style="color: #888; font-size: 12px;">This is a test — no action needed.</p>`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text();
|
||||
throw new BadRequestException({
|
||||
code: 'email_failed',
|
||||
message: `Failed to send test email: ${errBody}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true, email: tab.email, customerName: tab.customerName };
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { TabsService } from './tabs.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
|
||||
|
||||
@ApiTags('Tabs')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, VenueAccessGuard)
|
||||
@Controller('venues/:venueId/tabs')
|
||||
export class TabsController {
|
||||
constructor(private tabsService: TabsService) {}
|
||||
|
||||
@@ -15,12 +15,16 @@ export class TabsService {
|
||||
},
|
||||
});
|
||||
|
||||
const snapshots = await this.prisma.openTabSnapshot.findMany({
|
||||
where: {
|
||||
OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })),
|
||||
},
|
||||
});
|
||||
const snapshotByKey = new Map(snapshots.map((s) => [`${s.deviceId}:${s.tabId}`, s]));
|
||||
|
||||
const tabs = [];
|
||||
for (const ownership of ownerships) {
|
||||
const snapshot = await this.prisma.openTabSnapshot.findFirst({
|
||||
where: { tabId: ownership.tabId, deviceId: ownership.deviceId },
|
||||
orderBy: { reportedAt: 'desc' },
|
||||
});
|
||||
const snapshot = snapshotByKey.get(`${ownership.deviceId}:${ownership.tabId}`);
|
||||
|
||||
if (snapshot) {
|
||||
tabs.push({
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IsString, IsEmail, IsIn, MinLength, IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateUserDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'SecurePass123!' })
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ example: 'John Doe' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ example: 'viewer', enum: ['admin', 'viewer'] })
|
||||
@IsString()
|
||||
@IsIn(['admin', 'viewer'])
|
||||
role: string;
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import { VenuesService } from './venues.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
|
||||
@ApiTags('Venues')
|
||||
@Controller('venues')
|
||||
@@ -24,7 +26,7 @@ export class VenuesController {
|
||||
|
||||
@Get(':venueId')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, VenueAccessGuard)
|
||||
@ApiOperation({ summary: 'Get venue by ID' })
|
||||
async findOne(@Param('venueId') venueId: string) {
|
||||
return this.venuesService.findOne(venueId);
|
||||
@@ -32,7 +34,7 @@ export class VenuesController {
|
||||
|
||||
@Get(':venueId/users')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'List users for a venue' })
|
||||
async findUsers(@Param('venueId') venueId: string) {
|
||||
@@ -46,19 +48,19 @@ export class VenuesController {
|
||||
|
||||
@Post(':venueId/users')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Create a new user for the venue' })
|
||||
async createUser(
|
||||
@Param('venueId') venueId: string,
|
||||
@Body() dto: { email: string; password: string; name: string; role: string },
|
||||
@Body() dto: CreateUserDto,
|
||||
) {
|
||||
return this.authService.createUser(venueId, dto.email, dto.password, dto.name, dto.role);
|
||||
}
|
||||
|
||||
@Get(':venueId/devices')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, VenueAccessGuard)
|
||||
@ApiOperation({ summary: 'Get venue device info' })
|
||||
async findDevices(@Param('venueId') venueId: string) {
|
||||
const device = await this.prisma.device.findFirst({
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.49.0",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -12,18 +12,22 @@ import {
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Bell,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { LanguageSelector } from '@/components/language-selector';
|
||||
import { authApi, api } from '@/lib/api/client';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', label: 'Overview', icon: LayoutDashboard, neededRole: 'viewer' },
|
||||
{ href: '/dashboard/tabs', label: 'Open Tabs', icon: ClipboardList, neededRole: 'viewer' },
|
||||
{ href: '/dashboard/users', label: 'Users', icon: Monitor, neededRole: 'admin' },
|
||||
{ href: '/dashboard/commands', label: 'Command History', icon: Terminal, neededRole: 'viewer' },
|
||||
{ href: '/dashboard/settings', label: 'Settings', icon: Settings, neededRole: 'viewer' },
|
||||
{ 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/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 },
|
||||
];
|
||||
|
||||
export default function DashboardLayout({
|
||||
@@ -36,6 +40,7 @@ export default function DashboardLayout({
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [user, setUser] = useState<{ name: string; email: string; role: string; venueId: string } | null>(null);
|
||||
const [venue, setVenue] = useState<{ name: string } | null>(null);
|
||||
const { t } = useLang();
|
||||
|
||||
useEffect(() => {
|
||||
// Check auth
|
||||
@@ -111,7 +116,7 @@ export default function DashboardLayout({
|
||||
const Icon = item.icon;
|
||||
const active = pathname === item.href;
|
||||
|
||||
if (item.neededRole === 'admin' && user?.role !== 'admin') {
|
||||
if (item.adminOnly && user?.role !== 'admin') {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -128,13 +133,14 @@ export default function DashboardLayout({
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
{t(item.key)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t">
|
||||
<LanguageSelector />
|
||||
{user && (
|
||||
<div className="mb-3 text-sm">
|
||||
<p className="font-medium truncate">{user.name}</p>
|
||||
@@ -147,7 +153,7 @@ export default function DashboardLayout({
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
{t('nav.logout')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { remindersApi } from '@/lib/api/client';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
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 { 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, formatRelativeTime } from '@/lib/utils';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { Send, History } from 'lucide-react';
|
||||
|
||||
export default function RemindersPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useLang();
|
||||
const [venueId, setVenueId] = useState<string | null>(null);
|
||||
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token='));
|
||||
if (!cookie) { router.push('/login'); return; }
|
||||
const payload = JSON.parse(atob(cookie.split('.')[1]));
|
||||
setVenueId(payload.venueId);
|
||||
}, [router]);
|
||||
|
||||
const candidatesQuery = useQuery({
|
||||
queryKey: ['reminder-candidates', venueId],
|
||||
queryFn: () => remindersApi.candidates(venueId!),
|
||||
enabled: !!venueId,
|
||||
});
|
||||
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ['reminder-history', venueId],
|
||||
queryFn: () => remindersApi.history(venueId!),
|
||||
enabled: !!venueId && showHistory,
|
||||
});
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: () => remindersApi.send(venueId!),
|
||||
onSuccess: (result: any) => {
|
||||
setSendDialogOpen(false);
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
const firstError = result.errors[0];
|
||||
toast({ title: `${result.sent}/${result.total} sent`, description: `Error: ${firstError.error}`, variant: 'destructive' });
|
||||
} else if (result.sent > 0) {
|
||||
toast({ title: t('reminders.sent_title'), description: t('reminders.success', { sent: result.sent }), variant: 'success' });
|
||||
} else {
|
||||
toast({ title: t('reminders.sent_title'), description: t('reminders.zero_sent'), variant: 'destructive' });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['reminder-candidates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['reminder-history'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({ title: 'Failed', description: err.message || 'An error occurred', variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
|
||||
if (!venueId) return <LoadingState />;
|
||||
|
||||
const candidates = candidatesQuery.data?.candidates || [];
|
||||
const reminders = historyQuery.data?.reminders || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={t('reminders.title')}
|
||||
description={t('reminders.desc')}
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => { setShowHistory(!showHistory); if (!showHistory) queryClient.invalidateQueries({ queryKey: ['reminder-history'] }); }}>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
{showHistory ? t('reminders.candidates') : t('reminders.history')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{showHistory ? (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">{t('reminders.history')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{reminders.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('reminders.history_empty')}</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('reminders.customer')}</TableHead>
|
||||
<TableHead>{t('reminders.email')}</TableHead>
|
||||
<TableHead>{t('reminders.amount')}</TableHead>
|
||||
<TableHead>{t('reminders.sent_at')}</TableHead>
|
||||
<TableHead>{t('reminders.status')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{reminders.map((r: any) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-medium">{r.customerName}</TableCell>
|
||||
<TableCell>{r.email}</TableCell>
|
||||
<TableCell>{formatCents(r.amountInCents)}</TableCell>
|
||||
<TableCell>{formatRelativeTime(r.sentAt)}</TableCell>
|
||||
<TableCell><Badge variant="success">{r.status}</Badge></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Send className="h-4 w-4" />
|
||||
{t('reminders.candidates')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
{t('reminders.candidates_desc')}
|
||||
</p>
|
||||
|
||||
{candidatesQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
|
||||
) : candidatesQuery.error ? (
|
||||
<p className="text-sm text-destructive">{t('common.error')}</p>
|
||||
) : candidates.length === 0 ? (
|
||||
<EmptyState title={t('reminders.no_candidates')} description={t('reminders.no_candidates_desc')} />
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md border mb-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('reminders.customer')}</TableHead>
|
||||
<TableHead>{t('reminders.email')}</TableHead>
|
||||
<TableHead>{t('reminders.amount')}</TableHead>
|
||||
<TableHead>{t('tabs.items')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{candidates.map((c: any) => (
|
||||
<TableRow key={c.tabId}>
|
||||
<TableCell className="font-medium">{c.customerName}</TableCell>
|
||||
<TableCell>{c.email}</TableCell>
|
||||
<TableCell>{formatCents(c.amountInCents)}</TableCell>
|
||||
<TableCell>{c.itemCount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setSendDialogOpen(true)}>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
{t('reminders.send')} ({candidates.length})
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Dialog open={sendDialogOpen} onOpenChange={setSendDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('reminders.send')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('reminders.result', { sent: 0, total: candidates.length })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will send payment reminder emails to <strong>{candidates.length}</strong> customer(s) with open tabs.
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1">
|
||||
{candidates.map((c: any) => (
|
||||
<li key={c.tabId} className="text-sm flex items-center gap-2">
|
||||
<span className="font-medium">{c.customerName}</span>
|
||||
<span className="text-muted-foreground">({c.email})</span>
|
||||
<span className="ml-auto">{formatCents(c.amountInCents)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSendDialogOpen(false)} disabled={sendMutation.isPending}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => sendMutation.mutate()} disabled={sendMutation.isPending}>
|
||||
{sendMutation.isPending ? (
|
||||
<>{t('reminders.sending')}</>
|
||||
) : (
|
||||
<><Send className="h-4 w-4 mr-2" />{t('reminders.send')}</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api, commandsApi, plannedTabsApi } from '@/lib/api/client';
|
||||
import { api, commandsApi, plannedTabsApi, remindersApi } 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';
|
||||
@@ -17,7 +17,9 @@ import { LoadingState } from '@/components/loading-state';
|
||||
import { ErrorState } from '@/components/error-state';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { formatCents, formatRelativeTime } from '@/lib/utils';
|
||||
import { RefreshCw, Plus, X, Users } from 'lucide-react';
|
||||
import { RefreshCw, Plus, X, Users, Mail, Send, Upload } from 'lucide-react';
|
||||
import { ImportCustomersDialog } from '@/components/import-customers-dialog';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
|
||||
export default function TabsPage() {
|
||||
const router = useRouter();
|
||||
@@ -28,7 +30,10 @@ export default function TabsPage() {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [pendingCommandIds, setPendingCommandIds] = useState<Set<string>>(new Set());
|
||||
const [plannedName, setPlannedName] = useState('');
|
||||
const [plannedEmail, setPlannedEmail] = useState('');
|
||||
const [userRole, setUserRole] = useState<string | null>(null);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const { t } = useLang();
|
||||
|
||||
useEffect(() => {
|
||||
const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token='));
|
||||
@@ -97,8 +102,9 @@ export default function TabsPage() {
|
||||
async function addPlannedName() {
|
||||
if (!plannedName.trim()) return;
|
||||
try {
|
||||
await plannedTabsApi.create(venueId!, plannedName.trim());
|
||||
await plannedTabsApi.create(venueId!, plannedName.trim(), plannedEmail.trim() || undefined);
|
||||
setPlannedName('');
|
||||
setPlannedEmail('');
|
||||
queryClient.invalidateQueries({ queryKey: ['planned-tabs'] });
|
||||
toast({ title: 'Added', description: `"${plannedName.trim()}" added to planned tabs` });
|
||||
} catch (err: any) {
|
||||
@@ -115,6 +121,16 @@ export default function TabsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const sendTestMutation = useMutation({
|
||||
mutationFn: (plannedTabId: string) => remindersApi.sendTest(venueId!, plannedTabId),
|
||||
onSuccess: (result) => {
|
||||
toast({ title: 'Test email sent', description: `Sent to ${result.email}`, variant: 'success' });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({ title: 'Test email failed', description: err.message || 'An error occurred', variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
|
||||
if (!venueId) return <LoadingState />;
|
||||
if (isLoading) return <LoadingState />;
|
||||
if (error) return <ErrorState message={(error as any).message || 'Failed to load tabs'} />;
|
||||
@@ -132,14 +148,21 @@ export default function TabsPage() {
|
||||
{/* Planned Customers */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2"><Users className="h-4 w-4" /> Planned Customers</CardTitle>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2"><Users className="h-4 w-4" /> Planned Customers</CardTitle>
|
||||
{userRole === 'admin' && (
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<Upload className="h-4 w-4 mr-2" />{t('import.button')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
These names are synced to the terminal. It will create tabs for these customers automatically.
|
||||
</p>
|
||||
{userRole === 'admin' && (
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<Input
|
||||
placeholder="Add customer name..."
|
||||
value={plannedName}
|
||||
@@ -147,6 +170,14 @@ export default function TabsPage() {
|
||||
onKeyDown={(e) => e.key === 'Enter' && addPlannedName()}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Email (optional)"
|
||||
type="email"
|
||||
value={plannedEmail}
|
||||
onChange={(e) => setPlannedEmail(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addPlannedName()}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Button size="sm" onClick={addPlannedName} disabled={!plannedName.trim()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -159,8 +190,19 @@ export default function TabsPage() {
|
||||
{(plannedQuery.data?.plannedTabs || []).map((pt: any) => (
|
||||
<div key={pt.id} className="flex items-center gap-1 px-3 py-1.5 bg-secondary rounded-full text-sm">
|
||||
{pt.customerName}
|
||||
{pt.email && <Mail className="h-3 w-3 text-muted-foreground" />}
|
||||
{userRole === 'admin' && pt.email && (
|
||||
<button
|
||||
onClick={() => sendTestMutation.mutate(pt.id)}
|
||||
disabled={sendTestMutation.isPending}
|
||||
className="text-muted-foreground hover:text-primary ml-0.5"
|
||||
title="Send test email"
|
||||
>
|
||||
<Send className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{userRole === 'admin' && (
|
||||
<button onClick={() => removePlannedName(pt.id)} className="text-muted-foreground hover:text-destructive ml-1">
|
||||
<button onClick={() => removePlannedName(pt.id)} className="text-muted-foreground hover:text-destructive ml-0.5">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
@@ -194,7 +236,8 @@ export default function TabsPage() {
|
||||
<TableCell>{tab.version}</TableCell>
|
||||
<TableCell>{formatRelativeTime(tab.lastReportedAt)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" onClick={() => { setCloseDialog({ open: true, tab }); setPaymentMethod(''); }}>Close</Button>
|
||||
{/* Closing settles the tab on the terminal; a tab without a balance has nothing to settle */}
|
||||
<Button size="sm" disabled={tab.totalInCents <= 0} onClick={() => { setCloseDialog({ open: true, tab }); setPaymentMethod(''); }}>Close</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -203,6 +246,15 @@ export default function TabsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{venueId && (
|
||||
<ImportCustomersDialog
|
||||
venueId={venueId}
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
onImported={() => queryClient.invalidateQueries({ queryKey: ['planned-tabs'] })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{closeDialog.tab && (
|
||||
<Dialog open={closeDialog.open} onOpenChange={(open) => !open && setCloseDialog({ open: false, tab: null })}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { LanguageProvider } from '@/lib/i18n';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
@@ -18,7 +19,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<LanguageProvider>{children}</LanguageProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Upload } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { plannedTabsApi } from '@/lib/api/client';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { buildPreview, columnLabel, detectMapping, readSheet, type ColumnMapping, type SheetData } from '@/lib/customer-import';
|
||||
|
||||
const PREVIEW_ROWS = 8;
|
||||
|
||||
const selectClassName =
|
||||
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring';
|
||||
|
||||
interface Props {
|
||||
venueId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onImported: () => void;
|
||||
}
|
||||
|
||||
export function ImportCustomersDialog({ venueId, open, onOpenChange, onImported }: Props) {
|
||||
const { t } = useLang();
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [sheet, setSheet] = useState<SheetData | null>(null);
|
||||
const [mapping, setMapping] = useState<ColumnMapping | null>(null);
|
||||
const [mode, setMode] = useState<'merge' | 'replace'>('merge');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const preview = useMemo(() => (sheet && mapping ? buildPreview(sheet, mapping) : null), [sheet, mapping]);
|
||||
|
||||
function reset() {
|
||||
setFileName(null);
|
||||
setSheet(null);
|
||||
setMapping(null);
|
||||
setMode('merge');
|
||||
}
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next) reset();
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
||||
async function handleFile(file: File | undefined) {
|
||||
if (!file) return;
|
||||
try {
|
||||
const data = await readSheet(file);
|
||||
if (data.rows.length === 0) {
|
||||
toast({ title: t('import.failed'), description: t('import.empty_file'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setFileName(file.name);
|
||||
setSheet(data);
|
||||
setMapping(detectMapping(data));
|
||||
} catch {
|
||||
toast({ title: t('import.failed'), description: t('import.unreadable'), variant: 'destructive' });
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!preview || preview.entries.length === 0) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await plannedTabsApi.import(venueId, preview.entries, mode);
|
||||
toast({ title: t('import.done'), description: t('import.result', result), variant: 'success' });
|
||||
onImported();
|
||||
handleOpenChange(false);
|
||||
} catch (err: any) {
|
||||
toast({ title: t('import.failed'), description: err.message, variant: 'destructive' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const header = sheet && mapping?.hasHeader ? sheet.rows[0] : null;
|
||||
const columnOptions = sheet
|
||||
? Array.from({ length: sheet.columnCount }, (_, i) => ({
|
||||
value: i,
|
||||
label: header?.[i] ? `${columnLabel(i)} – ${header[i]}` : columnLabel(i),
|
||||
}))
|
||||
: [];
|
||||
|
||||
function columnSelect(value: number | null, onChange: (value: number | null) => void, optional: boolean) {
|
||||
return (
|
||||
<select
|
||||
className={selectClassName}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value === '' ? null : Number(e.target.value))}
|
||||
>
|
||||
{optional && <option value="">{t('import.column_none')}</option>}
|
||||
{columnOptions.map((c) => (
|
||||
<option key={c.value} value={c.value}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('import.title')}</DialogTitle>
|
||||
<DialogDescription>{t('import.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5">
|
||||
<label className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed p-6 text-sm cursor-pointer hover:bg-accent">
|
||||
<Upload className="h-5 w-5 text-muted-foreground" />
|
||||
<span>{fileName ?? t('import.choose_file')}</span>
|
||||
<span className="text-xs text-muted-foreground">.xlsx, .xls, .csv</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
handleFile(e.target.files?.[0]);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{sheet && mapping && preview && (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('import.name_column')}</Label>
|
||||
{columnSelect(mapping.nameColumn, (v) => setMapping({ ...mapping, nameColumn: v ?? 0 }), false)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('import.last_name_column')}</Label>
|
||||
{columnSelect(mapping.lastNameColumn, (v) => setMapping({ ...mapping, lastNameColumn: v }), true)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('import.email_column')}</Label>
|
||||
{columnSelect(mapping.emailColumn, (v) => setMapping({ ...mapping, emailColumn: v }), true)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mapping.hasHeader}
|
||||
onChange={(e) => setMapping({ ...mapping, hasHeader: e.target.checked })}
|
||||
/>
|
||||
{t('import.has_header')}
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">
|
||||
{t('import.summary', {
|
||||
count: preview.entries.length,
|
||||
emails: preview.entries.filter((e) => e.email).length,
|
||||
})}
|
||||
</p>
|
||||
{(preview.skippedRows > 0 || preview.invalidEmails > 0) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('import.warnings', { skipped: preview.skippedRows, invalid: preview.invalidEmails })}
|
||||
</p>
|
||||
)}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('tabs.customer')}</TableHead>
|
||||
<TableHead>{t('import.email')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{preview.entries.slice(0, PREVIEW_ROWS).map((entry, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="font-medium">{entry.customerName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{entry.email ?? '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{preview.entries.length > PREVIEW_ROWS && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('import.more', { count: preview.entries.length - PREVIEW_ROWS })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base font-semibold">{t('import.mode')}</Label>
|
||||
<RadioGroup value={mode} onValueChange={(v) => setMode(v as 'merge' | 'replace')}>
|
||||
<div className="flex items-start space-x-2">
|
||||
<RadioGroupItem value="merge" id="import-merge" className="mt-0.5" />
|
||||
<Label htmlFor="import-merge" className="font-normal">{t('import.mode_merge')}</Label>
|
||||
</div>
|
||||
<div className="flex items-start space-x-2">
|
||||
<RadioGroupItem value="replace" id="import-replace" className="mt-0.5" />
|
||||
<Label htmlFor="import-replace" className="font-normal">{t('import.mode_replace')}</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={submitting}>
|
||||
{t('tabs.cancel')}
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={submitting || !preview || preview.entries.length === 0}>
|
||||
{submitting ? t('import.importing') : t('import.submit', { count: preview?.entries.length ?? 0 })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { Languages } from 'lucide-react';
|
||||
|
||||
export function LanguageSelector() {
|
||||
const { lang, setLang, t } = useLang();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground">
|
||||
<Languages className="h-3 w-3" />
|
||||
<span>{t('lang.label')}:</span>
|
||||
<button
|
||||
onClick={() => setLang('en')}
|
||||
className={`px-1.5 py-0.5 rounded ${lang === 'en' ? 'bg-primary text-primary-foreground font-medium' : 'hover:text-foreground'}`}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLang('nl')}
|
||||
className={`px-1.5 py-0.5 rounded ${lang === 'nl' ? 'bg-primary text-primary-foreground font-medium' : 'hover:text-foreground'}`}
|
||||
>
|
||||
NL
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,9 @@ function getTokens() {
|
||||
}
|
||||
|
||||
function setTokens(accessToken: string, refreshToken: string) {
|
||||
document.cookie = `access_token=${accessToken}; path=/; max-age=${24 * 60 * 60}; SameSite=Lax`;
|
||||
// The access cookie outlives the JWT on purpose: pages decode it for venue/role, and an
|
||||
// expired JWT triggers a 401 which is transparently refreshed below.
|
||||
document.cookie = `access_token=${accessToken}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`;
|
||||
document.cookie = `refresh_token=${refreshToken}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`;
|
||||
}
|
||||
|
||||
@@ -24,7 +26,19 @@ function clearTokens() {
|
||||
document.cookie = 'refresh_token=; path=/; max-age=0';
|
||||
}
|
||||
|
||||
async function refreshAccessToken(): Promise<string | null> {
|
||||
// Refresh tokens rotate on use, so concurrent 401s must share a single refresh request
|
||||
let refreshInFlight: Promise<string | null> | null = null;
|
||||
|
||||
function refreshAccessToken(): Promise<string | null> {
|
||||
if (!refreshInFlight) {
|
||||
refreshInFlight = doRefreshAccessToken().finally(() => {
|
||||
refreshInFlight = null;
|
||||
});
|
||||
}
|
||||
return refreshInFlight;
|
||||
}
|
||||
|
||||
async function doRefreshAccessToken(): Promise<string | null> {
|
||||
const refreshToken = document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('refresh_token='))
|
||||
@@ -169,12 +183,29 @@ export const venuesApi = {
|
||||
// Planned Tabs API
|
||||
export const plannedTabsApi = {
|
||||
list: (venueId: string) => api<{ plannedTabs: any[] }>(`/venues/${venueId}/planned-tabs`),
|
||||
create: (venueId: string, customerName: string) =>
|
||||
api(`/venues/${venueId}/planned-tabs`, { method: 'POST', body: JSON.stringify({ customerName }) }),
|
||||
update: (venueId: string, id: string, customerName: string) =>
|
||||
api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'PUT', body: JSON.stringify({ customerName }) }),
|
||||
create: (venueId: string, customerName: string, email?: string) =>
|
||||
api(`/venues/${venueId}/planned-tabs`, { method: 'POST', body: JSON.stringify({ customerName, email }) }),
|
||||
update: (venueId: string, id: string, customerName: string, email?: string) =>
|
||||
api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'PUT', body: JSON.stringify({ customerName, email }) }),
|
||||
remove: (venueId: string, id: string) =>
|
||||
api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'DELETE' }),
|
||||
import: (venueId: string, entries: { customerName: string; email?: string }[], mode: 'merge' | 'replace') =>
|
||||
api<{ created: number; updated: number; unchanged: number; removed: number }>(`/venues/${venueId}/planned-tabs/import`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ entries, mode }),
|
||||
}),
|
||||
};
|
||||
|
||||
// Reminders API
|
||||
export const remindersApi = {
|
||||
candidates: (venueId: string) =>
|
||||
api<{ candidates: any[] }>(`/venues/${venueId}/reminders/candidates`),
|
||||
send: (venueId: string) =>
|
||||
api<{ sent: number; total: number; errors?: { customerName: string; error: string }[] }>(`/venues/${venueId}/reminders/send`, { method: 'POST' }),
|
||||
history: (venueId: string) =>
|
||||
api<{ reminders: any[] }>(`/venues/${venueId}/reminders`),
|
||||
sendTest: (venueId: string, plannedTabId: string) =>
|
||||
api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }),
|
||||
};
|
||||
|
||||
export { setTokens, clearTokens };
|
||||
@@ -0,0 +1,122 @@
|
||||
export interface SheetData {
|
||||
rows: string[][];
|
||||
columnCount: number;
|
||||
}
|
||||
|
||||
export interface ColumnMapping {
|
||||
hasHeader: boolean;
|
||||
nameColumn: number;
|
||||
lastNameColumn: number | null;
|
||||
emailColumn: number | null;
|
||||
}
|
||||
|
||||
export interface ImportEntry {
|
||||
customerName: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface ImportPreview {
|
||||
entries: ImportEntry[];
|
||||
skippedRows: number;
|
||||
invalidEmails: number;
|
||||
}
|
||||
|
||||
const NAME_HEADERS = ['naam', 'name', 'volledige naam', 'full name', 'klant', 'klantnaam', 'customer', 'lid', 'member'];
|
||||
const FIRST_NAME_HEADERS = ['voornaam', 'first name', 'firstname', 'given name'];
|
||||
const LAST_NAME_HEADERS = ['achternaam', 'familienaam', 'last name', 'lastname', 'surname', 'family name'];
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function normalizeHeader(value: string) {
|
||||
return value.toLowerCase().replace(/[_\-.:]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/** Reads the first sheet of an .xlsx, .xls or .csv file into trimmed string rows. */
|
||||
export async function readSheet(file: File): Promise<SheetData> {
|
||||
// Loaded on demand: the parser is large and only needed when an admin imports a file
|
||||
const XLSX = await import('xlsx');
|
||||
const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' });
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
if (!sheet) return { rows: [], columnCount: 0 };
|
||||
|
||||
const raw = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, defval: '', raw: false, blankrows: false });
|
||||
const rows = raw
|
||||
.map((row) => row.map((cell) => String(cell ?? '').trim()))
|
||||
.filter((row) => row.some((cell) => cell !== ''));
|
||||
const columnCount = rows.reduce((max, row) => Math.max(max, row.length), 0);
|
||||
return { rows, columnCount };
|
||||
}
|
||||
|
||||
/** Guesses which columns hold the name(s) and email from the first row. */
|
||||
export function detectMapping(sheet: SheetData): ColumnMapping {
|
||||
const header = (sheet.rows[0] ?? []).map(normalizeHeader);
|
||||
const find = (candidates: string[]) => {
|
||||
const index = header.findIndex((h) => candidates.includes(h));
|
||||
return index === -1 ? null : index;
|
||||
};
|
||||
|
||||
const name = find(NAME_HEADERS);
|
||||
const firstName = find(FIRST_NAME_HEADERS);
|
||||
const lastName = find(LAST_NAME_HEADERS);
|
||||
const emailHeaderIndex = header.findIndex((h) => h.includes('mail'));
|
||||
const hasHeader = name !== null || firstName !== null || lastName !== null || emailHeaderIndex !== -1;
|
||||
|
||||
// Without a recognisable header, fall back to the column whose values look like email addresses
|
||||
let email: number | null = emailHeaderIndex === -1 ? null : emailHeaderIndex;
|
||||
if (email === null) {
|
||||
const sample = sheet.rows.slice(hasHeader ? 1 : 0, 50);
|
||||
for (let col = 0; col < sheet.columnCount && email === null; col++) {
|
||||
const filled = sample.map((row) => row[col]).filter(Boolean);
|
||||
if (filled.length > 0 && filled.filter((v) => EMAIL_PATTERN.test(v)).length / filled.length >= 0.5) {
|
||||
email = col;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (firstName !== null && lastName !== null) {
|
||||
return { hasHeader, nameColumn: firstName, lastNameColumn: lastName, emailColumn: email };
|
||||
}
|
||||
return { hasHeader, nameColumn: name ?? firstName ?? lastName ?? 0, lastNameColumn: null, emailColumn: email };
|
||||
}
|
||||
|
||||
export function buildPreview(sheet: SheetData, mapping: ColumnMapping): ImportPreview {
|
||||
const dataRows = mapping.hasHeader ? sheet.rows.slice(1) : sheet.rows;
|
||||
const entries: ImportEntry[] = [];
|
||||
let skippedRows = 0;
|
||||
let invalidEmails = 0;
|
||||
|
||||
for (const row of dataRows) {
|
||||
const parts = [row[mapping.nameColumn]];
|
||||
if (mapping.lastNameColumn !== null) parts.push(row[mapping.lastNameColumn]);
|
||||
const customerName = parts.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim().slice(0, 100);
|
||||
if (!customerName) {
|
||||
skippedRows++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let email: string | undefined;
|
||||
if (mapping.emailColumn !== null) {
|
||||
const value = (row[mapping.emailColumn] ?? '').trim();
|
||||
if (value && EMAIL_PATTERN.test(value)) {
|
||||
email = value;
|
||||
} else if (value) {
|
||||
invalidEmails++;
|
||||
}
|
||||
}
|
||||
|
||||
entries.push(email ? { customerName, email } : { customerName });
|
||||
}
|
||||
|
||||
return { entries, skippedRows, invalidEmails };
|
||||
}
|
||||
|
||||
/** Spreadsheet-style column label: 0 -> A, 26 -> AA. */
|
||||
export function columnLabel(index: number): string {
|
||||
let label = '';
|
||||
let n = index;
|
||||
do {
|
||||
label = String.fromCharCode(65 + (n % 26)) + label;
|
||||
n = Math.floor(n / 26) - 1;
|
||||
} while (n >= 0);
|
||||
return label;
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
|
||||
type Lang = 'en' | 'nl';
|
||||
|
||||
const translations: Record<Lang, Record<string, string>> = {
|
||||
en: {
|
||||
'nav.overview': 'Overview',
|
||||
'nav.tabs': 'Open Tabs',
|
||||
'nav.reminders': 'Reminders',
|
||||
'nav.users': 'Users',
|
||||
'nav.commands': 'Command History',
|
||||
'nav.settings': 'Settings',
|
||||
'nav.logout': 'Logout',
|
||||
'tabs.title': 'Open Tabs',
|
||||
'tabs.count': '{count} open tab',
|
||||
'tabs.count_plural': '{count} open tabs',
|
||||
'tabs.empty': 'No open tabs',
|
||||
'tabs.empty_desc': 'Open tabs will appear here when the terminal syncs.',
|
||||
'tabs.customer': 'Customer',
|
||||
'tabs.amount': 'Amount',
|
||||
'tabs.items': 'Items',
|
||||
'tabs.version': 'Version',
|
||||
'tabs.reported': 'Last Reported',
|
||||
'tabs.close': 'Close',
|
||||
'tabs.close_title': "Close {name}'s tab?",
|
||||
'tabs.close_desc': 'Send a close request to the terminal.',
|
||||
'tabs.payment': 'Payment method',
|
||||
'tabs.cash': 'Cash',
|
||||
'tabs.payconiq': 'Payconiq',
|
||||
'tabs.pending_payment': 'Payment pending',
|
||||
'tabs.select_payment': 'Please select a payment method',
|
||||
'tabs.send': 'Send close request',
|
||||
'tabs.sending': 'Sending...',
|
||||
'tabs.cancel': 'Cancel',
|
||||
'tabs.planned_title': 'Planned Customers',
|
||||
'tabs.planned_desc': 'These names are synced to the terminal. It will create tabs for these customers automatically.',
|
||||
'tabs.planned_add': 'Add customer name...',
|
||||
'tabs.planned_empty': 'No planned customers. Add names above and they\'ll sync to the terminal.',
|
||||
'import.button': 'Import from Excel',
|
||||
'import.title': 'Import customers',
|
||||
'import.description': 'Upload the customer list. Every name becomes a planned customer and gets a tab on the terminal.',
|
||||
'import.choose_file': 'Choose a file',
|
||||
'import.empty_file': 'The file contains no rows.',
|
||||
'import.unreadable': 'This file could not be read. Use an .xlsx, .xls or .csv file.',
|
||||
'import.name_column': 'Name (or first name)',
|
||||
'import.last_name_column': 'Last name',
|
||||
'import.email_column': 'Email',
|
||||
'import.column_none': '— none —',
|
||||
'import.has_header': 'First row contains column headers',
|
||||
'import.summary': '{count} customers found, {emails} with an email address.',
|
||||
'import.warnings': '{skipped} rows without a name skipped, {invalid} invalid email addresses ignored.',
|
||||
'import.email': 'Email',
|
||||
'import.more': 'and {count} more…',
|
||||
'import.mode': 'Existing customers',
|
||||
'import.mode_merge': 'Keep customers that are not in the file',
|
||||
'import.mode_replace': 'Remove customers that are not in the file (their open tabs on the terminal are kept)',
|
||||
'import.submit': 'Import {count} customers',
|
||||
'import.importing': 'Importing...',
|
||||
'import.done': 'Import complete',
|
||||
'import.result': '{created} added, {updated} updated, {unchanged} unchanged, {removed} removed.',
|
||||
'import.failed': 'Import failed',
|
||||
'users.title': 'Users',
|
||||
'users.count': '{count} user',
|
||||
'users.count_plural': '{count} users',
|
||||
'users.empty': 'No users',
|
||||
'users.empty_desc': 'Add users who can manage tabs from this dashboard.',
|
||||
'users.add': 'Add User',
|
||||
'users.name': 'Name',
|
||||
'users.email': 'Email',
|
||||
'users.role': 'Role',
|
||||
'users.created': 'Created',
|
||||
'users.add_title': 'Add User',
|
||||
'users.add_desc': 'Create a new user who can access the dashboard.',
|
||||
'users.password': 'Password',
|
||||
'users.creating': 'Creating...',
|
||||
'users.create': 'Create User',
|
||||
'commands.title': 'Command History',
|
||||
'commands.count': '{count} command',
|
||||
'commands.count_plural': '{count} commands',
|
||||
'commands.empty': 'No commands found',
|
||||
'commands.tab': 'Tab',
|
||||
'commands.requested': 'Requested By',
|
||||
'commands.payment': 'Payment',
|
||||
'commands.created': 'Created',
|
||||
'commands.status': 'Status',
|
||||
'commands.processed': 'Processed',
|
||||
'commands.result': 'Result',
|
||||
'commands.all': 'All',
|
||||
'commands.previous': 'Previous',
|
||||
'commands.next': 'Next',
|
||||
'commands.page': 'Page {page} of {total}',
|
||||
'settings.title': 'Settings',
|
||||
'settings.desc': 'Venue and device management',
|
||||
'settings.venue': 'Venue',
|
||||
'settings.device': 'Device',
|
||||
'settings.paired': 'Paired as "{name}" — last seen {time}',
|
||||
'settings.not_paired': 'No device paired yet',
|
||||
'settings.connected': 'Connected',
|
||||
'settings.since': 'Since {date}',
|
||||
'settings.pair_desc': 'Pair a bar terminal to start receiving tabs.',
|
||||
'settings.pair': 'Pair Device',
|
||||
'settings.repair': 'Re-pair Device',
|
||||
'settings.pair_title': 'Pair a Device',
|
||||
'settings.pair_instructions': 'Enter this code on the bar terminal to connect it to your venue.',
|
||||
'settings.pair_code': 'Registration Code',
|
||||
'settings.pair_expires': 'Expires at {time}',
|
||||
'settings.pair_step1': 'Open the bar terminal app',
|
||||
'settings.pair_step2': 'Select your venue from the list',
|
||||
'settings.pair_step3': 'Enter this registration code',
|
||||
'settings.pair_step4': 'Give the terminal a name',
|
||||
'settings.pair_step5': 'Complete registration',
|
||||
'settings.copy': 'Copy Code',
|
||||
'settings.close': 'Close',
|
||||
'settings.api': 'API',
|
||||
'settings.base_url': 'Base URL',
|
||||
'settings.swagger': 'Swagger',
|
||||
'overview.title': 'Overview',
|
||||
'overview.desc': 'Real-time overview of your venue',
|
||||
'overview.open_tabs': 'Open Tabs',
|
||||
'overview.device': 'Device',
|
||||
'overview.pending': 'Pending Commands',
|
||||
'overview.recent': 'Recent Commands',
|
||||
'overview.no_commands': 'No commands yet',
|
||||
'overview.not_connected': 'Not connected',
|
||||
'overview.last_seen': 'Last seen {time}',
|
||||
'common.refresh': 'Refresh',
|
||||
'common.loading': 'Loading...',
|
||||
'common.error': 'Something went wrong',
|
||||
'common.confirm': 'Confirm',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.processing': 'Processing...',
|
||||
'common.copied': 'Copied',
|
||||
'auth.login': 'Sign in',
|
||||
'auth.logging_in': 'Signing in...',
|
||||
'auth.email': 'Email',
|
||||
'auth.password': 'Password',
|
||||
'auth.invalid': 'Invalid email or password',
|
||||
'lang.en': 'English',
|
||||
'lang.nl': 'Nederlands',
|
||||
'lang.label': 'Language',
|
||||
'reminders.title': 'Payment Reminders',
|
||||
'reminders.desc': 'Send payment reminders to customers with open tabs',
|
||||
'reminders.candidates': 'Eligible for reminder',
|
||||
'reminders.candidates_desc': 'These customers have open tabs and an email address on file. Reminders will be sent via Resend.',
|
||||
'reminders.no_candidates': 'No eligible customers',
|
||||
'reminders.no_candidates_desc': 'Make sure planned customers have email addresses set.',
|
||||
'reminders.send': 'Send Reminders',
|
||||
'reminders.sending': 'Sending...',
|
||||
'reminders.result': 'Sent {sent} of {total} reminder(s)',
|
||||
'reminders.sent_title': 'Reminders Sent',
|
||||
'reminders.zero_sent': 'No reminders were sent. Check that Resend is configured.',
|
||||
'reminders.success': 'Successfully sent {sent} reminder email(s)',
|
||||
'reminders.history': 'Sent Reminders',
|
||||
'reminders.history_empty': 'No reminders have been sent yet',
|
||||
'reminders.customer': 'Customer',
|
||||
'reminders.email': 'Email',
|
||||
'reminders.amount': 'Amount',
|
||||
'reminders.sent_at': 'Sent At',
|
||||
'reminders.status': 'Status',
|
||||
'tabs.email': 'Email (optional)',
|
||||
'tabs.planned_email_hint': 'Add email to enable payment reminders',
|
||||
},
|
||||
nl: {
|
||||
'nav.overview': 'Overzicht',
|
||||
'nav.tabs': 'Open Tabs',
|
||||
'nav.reminders': 'Herinneringen',
|
||||
'nav.users': 'Gebruikers',
|
||||
'nav.commands': 'Opdrachten',
|
||||
'nav.settings': 'Instellingen',
|
||||
'nav.logout': 'Uitloggen',
|
||||
'tabs.title': 'Open Tabs',
|
||||
'tabs.count': '{count} open tab',
|
||||
'tabs.count_plural': '{count} open tabs',
|
||||
'tabs.empty': 'Geen open tabs',
|
||||
'tabs.empty_desc': 'Open tabs verschijnen hier wanneer de terminal synchroniseert.',
|
||||
'tabs.customer': 'Klant',
|
||||
'tabs.amount': 'Bedrag',
|
||||
'tabs.items': 'Artikelen',
|
||||
'tabs.version': 'Versie',
|
||||
'tabs.reported': 'Laatst gemeld',
|
||||
'tabs.close': 'Sluiten',
|
||||
'tabs.close_title': 'Sluit {name}\'s tab?',
|
||||
'tabs.close_desc': 'Stuur een sluitverzoek naar de terminal.',
|
||||
'tabs.payment': 'Betaalmethode',
|
||||
'tabs.cash': 'Contant',
|
||||
'tabs.payconiq': 'Payconiq',
|
||||
'tabs.pending_payment': 'Betaling in behandeling',
|
||||
'tabs.select_payment': 'Selecteer een betaalmethode',
|
||||
'tabs.send': 'Sluitverzoek versturen',
|
||||
'tabs.sending': 'Bezig met verzenden...',
|
||||
'tabs.cancel': 'Annuleren',
|
||||
'tabs.planned_title': 'Geplande Klanten',
|
||||
'tabs.planned_desc': 'Deze namen worden gesynchroniseerd met de terminal. Deze maakt automatisch tabs aan voor deze klanten.',
|
||||
'tabs.planned_add': 'Klantnaam toevoegen...',
|
||||
'tabs.planned_empty': 'Geen geplande klanten. Voeg namen toe en ze worden gesynchroniseerd met de terminal.',
|
||||
'import.button': 'Importeren uit Excel',
|
||||
'import.title': 'Klanten importeren',
|
||||
'import.description': 'Upload de klantenlijst. Elke naam wordt een geplande klant en krijgt een rekening op de terminal.',
|
||||
'import.choose_file': 'Kies een bestand',
|
||||
'import.empty_file': 'Het bestand bevat geen rijen.',
|
||||
'import.unreadable': 'Dit bestand kon niet gelezen worden. Gebruik een .xlsx-, .xls- of .csv-bestand.',
|
||||
'import.name_column': 'Naam (of voornaam)',
|
||||
'import.last_name_column': 'Achternaam',
|
||||
'import.email_column': 'E-mail',
|
||||
'import.column_none': '— geen —',
|
||||
'import.has_header': 'De eerste rij bevat kolomnamen',
|
||||
'import.summary': '{count} klanten gevonden, {emails} met een e-mailadres.',
|
||||
'import.warnings': '{skipped} rijen zonder naam overgeslagen, {invalid} ongeldige e-mailadressen genegeerd.',
|
||||
'import.email': 'E-mail',
|
||||
'import.more': 'en nog {count}…',
|
||||
'import.mode': 'Bestaande klanten',
|
||||
'import.mode_merge': 'Klanten die niet in het bestand staan behouden',
|
||||
'import.mode_replace': 'Klanten die niet in het bestand staan verwijderen (hun open rekeningen op de terminal blijven)',
|
||||
'import.submit': '{count} klanten importeren',
|
||||
'import.importing': 'Bezig met importeren...',
|
||||
'import.done': 'Import voltooid',
|
||||
'import.result': '{created} toegevoegd, {updated} bijgewerkt, {unchanged} ongewijzigd, {removed} verwijderd.',
|
||||
'import.failed': 'Importeren mislukt',
|
||||
'users.title': 'Gebruikers',
|
||||
'users.count': '{count} gebruiker',
|
||||
'users.count_plural': '{count} gebruikers',
|
||||
'users.empty': 'Geen gebruikers',
|
||||
'users.empty_desc': 'Voeg gebruikers toe die tabs kunnen beheren vanaf dit dashboard.',
|
||||
'users.add': 'Gebruiker toevoegen',
|
||||
'users.name': 'Naam',
|
||||
'users.email': 'E-mail',
|
||||
'users.role': 'Rol',
|
||||
'users.created': 'Aangemaakt',
|
||||
'users.add_title': 'Gebruiker toevoegen',
|
||||
'users.add_desc': 'Maak een nieuwe gebruiker aan die toegang heeft tot het dashboard.',
|
||||
'users.password': 'Wachtwoord',
|
||||
'users.creating': 'Bezig...',
|
||||
'users.create': 'Gebruiker aanmaken',
|
||||
'commands.title': 'Opdrachtgeschiedenis',
|
||||
'commands.count': '{count} opdracht',
|
||||
'commands.count_plural': '{count} opdrachten',
|
||||
'commands.empty': 'Geen opdrachten gevonden',
|
||||
'commands.tab': 'Tab',
|
||||
'commands.requested': 'Aangevraagd door',
|
||||
'commands.payment': 'Betaling',
|
||||
'commands.created': 'Aangemaakt',
|
||||
'commands.status': 'Status',
|
||||
'commands.processed': 'Verwerkt',
|
||||
'commands.result': 'Resultaat',
|
||||
'commands.all': 'Alle',
|
||||
'commands.previous': 'Vorige',
|
||||
'commands.next': 'Volgende',
|
||||
'commands.page': 'Pagina {page} van {total}',
|
||||
'settings.title': 'Instellingen',
|
||||
'settings.desc': 'Locatie- en apparaatbeheer',
|
||||
'settings.venue': 'Locatie',
|
||||
'settings.device': 'Apparaat',
|
||||
'settings.paired': 'Gekoppeld als "{name}" — laatst gezien {time}',
|
||||
'settings.not_paired': 'Nog geen apparaat gekoppeld',
|
||||
'settings.connected': 'Verbonden',
|
||||
'settings.since': 'Sinds {date}',
|
||||
'settings.pair_desc': 'Koppel een barterminal om tabs te ontvangen.',
|
||||
'settings.pair': 'Apparaat koppelen',
|
||||
'settings.repair': 'Opnieuw koppelen',
|
||||
'settings.pair_title': 'Apparaat koppelen',
|
||||
'settings.pair_instructions': 'Voer deze code in op de barterminal om deze te verbinden met uw locatie.',
|
||||
'settings.pair_code': 'Registratiecode',
|
||||
'settings.pair_expires': 'Verloopt om {time}',
|
||||
'settings.pair_step1': 'Open de barterminal app',
|
||||
'settings.pair_step2': 'Selecteer uw locatie uit de lijst',
|
||||
'settings.pair_step3': 'Voer deze registratiecode in',
|
||||
'settings.pair_step4': 'Geef de terminal een naam',
|
||||
'settings.pair_step5': 'Voltooi de registratie',
|
||||
'settings.copy': 'Code kopiëren',
|
||||
'settings.close': 'Sluiten',
|
||||
'settings.api': 'API',
|
||||
'settings.base_url': 'Basis-URL',
|
||||
'settings.swagger': 'Swagger',
|
||||
'overview.title': 'Overzicht',
|
||||
'overview.desc': 'Real-time overzicht van uw locatie',
|
||||
'overview.open_tabs': 'Open Tabs',
|
||||
'overview.device': 'Apparaat',
|
||||
'overview.pending': 'Openstaande opdrachten',
|
||||
'overview.recent': 'Recente opdrachten',
|
||||
'overview.no_commands': 'Nog geen opdrachten',
|
||||
'overview.not_connected': 'Niet verbonden',
|
||||
'overview.last_seen': 'Laatst gezien {time}',
|
||||
'common.refresh': 'Verversen',
|
||||
'common.loading': 'Laden...',
|
||||
'common.error': 'Er is iets misgegaan',
|
||||
'common.confirm': 'Bevestigen',
|
||||
'common.cancel': 'Annuleren',
|
||||
'common.processing': 'Bezig...',
|
||||
'common.copied': 'Gekopieerd',
|
||||
'auth.login': 'Inloggen',
|
||||
'auth.logging_in': 'Bezig met inloggen...',
|
||||
'auth.email': 'E-mail',
|
||||
'auth.password': 'Wachtwoord',
|
||||
'auth.invalid': 'Ongeldig e-mailadres of wachtwoord',
|
||||
'lang.en': 'English',
|
||||
'lang.nl': 'Nederlands',
|
||||
'lang.label': 'Taal',
|
||||
'reminders.title': 'Betalingsherinneringen',
|
||||
'reminders.desc': 'Stuur betalingsherinneringen naar klanten met openstaande tabs',
|
||||
'reminders.candidates': 'In aanmerking voor herinnering',
|
||||
'reminders.candidates_desc': 'Deze klanten hebben openstaande tabs en een e-mailadres. Herinneringen worden verzonden via Resend.',
|
||||
'reminders.no_candidates': 'Geen geschikte klanten',
|
||||
'reminders.no_candidates_desc': 'Zorg dat geplande klanten een e-mailadres hebben.',
|
||||
'reminders.send': 'Verstuur herinneringen',
|
||||
'reminders.sending': 'Bezig met versturen...',
|
||||
'reminders.result': '{sent} van {total} herinnering(en) verzonden',
|
||||
'reminders.sent_title': 'Herinneringen verzonden',
|
||||
'reminders.zero_sent': 'Geen herinneringen verzonden. Controleer of Resend is geconfigureerd.',
|
||||
'reminders.success': '{sent} herinnering(en) succesvol verzonden',
|
||||
'reminders.history': 'Verzonden herinneringen',
|
||||
'reminders.history_empty': 'Nog geen herinneringen verzonden',
|
||||
'reminders.customer': 'Klant',
|
||||
'reminders.email': 'E-mail',
|
||||
'reminders.amount': 'Bedrag',
|
||||
'reminders.sent_at': 'Verzonden op',
|
||||
'reminders.status': 'Status',
|
||||
'tabs.email': 'E-mail (optioneel)',
|
||||
'tabs.planned_email_hint': 'Voeg e-mail toe om betalingsherinneringen in te schakelen',
|
||||
},
|
||||
};
|
||||
|
||||
interface LangContextType {
|
||||
lang: Lang;
|
||||
setLang: (lang: Lang) => void;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
const LangContext = createContext<LangContextType>({
|
||||
lang: 'en',
|
||||
setLang: () => {},
|
||||
t: (key) => key,
|
||||
});
|
||||
|
||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const [lang, setLangState] = useState<Lang>('en');
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('kooltab-lang') as Lang | null;
|
||||
if (stored === 'en' || stored === 'nl') setLangState(stored);
|
||||
}, []);
|
||||
|
||||
function setLang(l: Lang) {
|
||||
setLangState(l);
|
||||
localStorage.setItem('kooltab-lang', l);
|
||||
}
|
||||
|
||||
function t(key: string, params?: Record<string, string | number>): string {
|
||||
let s = translations[lang][key];
|
||||
if (!s) s = translations['en'][key] || key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
s = s.replace(`{${k}}`, String(v));
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
return (
|
||||
<LangContext.Provider value={{ lang, setLang, t }}>
|
||||
{children}
|
||||
</LangContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLang() {
|
||||
return useContext(LangContext);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user