feat: add plannedTabs

This commit is contained in:
2026-09-20 12:20:55 +02:00
parent 4eb9bf7643
commit db3d8f30f0
28 changed files with 1172 additions and 782 deletions
@@ -1,152 +0,0 @@
-- CreateTable
CREATE TABLE "Venue" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Device" (
"id" TEXT NOT NULL PRIMARY KEY,
"venueId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"tokenPrefix" TEXT NOT NULL,
"tokenExpiresAt" DATETIME NOT NULL,
"lastSeenAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Device_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ClientUser" (
"id" TEXT NOT NULL PRIMARY KEY,
"venueId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'viewer',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ClientUser_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "RegistrationCode" (
"id" TEXT NOT NULL PRIMARY KEY,
"venueId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"usedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RegistrationCode_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "OpenTabSnapshot" (
"id" TEXT NOT NULL PRIMARY KEY,
"deviceId" TEXT NOT NULL,
"tabId" TEXT NOT NULL,
"customerName" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"totalInCents" INTEGER NOT NULL,
"itemCount" INTEGER NOT NULL,
"reportedAt" DATETIME NOT NULL,
CONSTRAINT "OpenTabSnapshot_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "TabOwnership" (
"tabId" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"lastReportedAt" DATETIME NOT NULL,
PRIMARY KEY ("venueId", "tabId"),
CONSTRAINT "TabOwnership_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "TabOwnership_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "RemoteCommand" (
"id" TEXT NOT NULL PRIMARY KEY,
"venueId" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"targetTabId" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'close_tab',
"expectedVersion" INTEGER NOT NULL,
"paymentMethod" TEXT NOT NULL,
"requestedBy" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"result" TEXT,
"acknowledgedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
CONSTRAINT "RemoteCommand_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "RemoteCommand_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL PRIMARY KEY,
"venueId" TEXT NOT NULL,
"actorType" TEXT NOT NULL,
"actorId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"details" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"revokedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "Device_tokenHash_key" ON "Device"("tokenHash");
-- CreateIndex
CREATE INDEX "Device_venueId_revokedAt_idx" ON "Device"("venueId", "revokedAt");
-- CreateIndex
CREATE UNIQUE INDEX "ClientUser_email_key" ON "ClientUser"("email");
-- CreateIndex
CREATE INDEX "ClientUser_venueId_idx" ON "ClientUser"("venueId");
-- CreateIndex
CREATE UNIQUE INDEX "RegistrationCode_code_key" ON "RegistrationCode"("code");
-- CreateIndex
CREATE INDEX "RegistrationCode_venueId_idx" ON "RegistrationCode"("venueId");
-- CreateIndex
CREATE INDEX "OpenTabSnapshot_deviceId_idx" ON "OpenTabSnapshot"("deviceId");
-- CreateIndex
CREATE INDEX "OpenTabSnapshot_tabId_reportedAt_idx" ON "OpenTabSnapshot"("tabId", "reportedAt");
-- CreateIndex
CREATE UNIQUE INDEX "OpenTabSnapshot_deviceId_tabId_key" ON "OpenTabSnapshot"("deviceId", "tabId");
-- CreateIndex
CREATE INDEX "RemoteCommand_deviceId_status_createdAt_idx" ON "RemoteCommand"("deviceId", "status", "createdAt");
-- CreateIndex
CREATE INDEX "RemoteCommand_venueId_status_createdAt_idx" ON "RemoteCommand"("venueId", "status", "createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_venueId_createdAt_idx" ON "AuditLog"("venueId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash");
-- CreateIndex
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
@@ -0,0 +1,189 @@
-- CreateTable
CREATE TABLE "Venue" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Venue_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Device" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"tokenPrefix" TEXT NOT NULL,
"tokenExpiresAt" TIMESTAMP(3) NOT NULL,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Device_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ClientUser" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'viewer',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ClientUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RegistrationCode" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RegistrationCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OpenTabSnapshot" (
"id" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"tabId" TEXT NOT NULL,
"customerName" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"totalInCents" INTEGER NOT NULL,
"itemCount" INTEGER NOT NULL,
"reportedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OpenTabSnapshot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TabOwnership" (
"tabId" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"lastReportedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TabOwnership_pkey" PRIMARY KEY ("venueId","tabId")
);
-- CreateTable
CREATE TABLE "RemoteCommand" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"targetTabId" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'close_tab',
"expectedVersion" INTEGER NOT NULL,
"paymentMethod" TEXT NOT NULL,
"requestedBy" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"result" TEXT,
"acknowledgedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RemoteCommand_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"actorType" TEXT NOT NULL,
"actorId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"details" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Device_venueId_key" ON "Device"("venueId");
-- CreateIndex
CREATE UNIQUE INDEX "Device_tokenHash_key" ON "Device"("tokenHash");
-- CreateIndex
CREATE INDEX "Device_venueId_revokedAt_idx" ON "Device"("venueId", "revokedAt");
-- CreateIndex
CREATE UNIQUE INDEX "ClientUser_email_key" ON "ClientUser"("email");
-- CreateIndex
CREATE INDEX "ClientUser_venueId_idx" ON "ClientUser"("venueId");
-- CreateIndex
CREATE UNIQUE INDEX "RegistrationCode_code_key" ON "RegistrationCode"("code");
-- CreateIndex
CREATE INDEX "RegistrationCode_venueId_idx" ON "RegistrationCode"("venueId");
-- CreateIndex
CREATE INDEX "OpenTabSnapshot_deviceId_idx" ON "OpenTabSnapshot"("deviceId");
-- CreateIndex
CREATE INDEX "OpenTabSnapshot_tabId_reportedAt_idx" ON "OpenTabSnapshot"("tabId", "reportedAt");
-- CreateIndex
CREATE UNIQUE INDEX "OpenTabSnapshot_deviceId_tabId_key" ON "OpenTabSnapshot"("deviceId", "tabId");
-- CreateIndex
CREATE INDEX "RemoteCommand_deviceId_status_createdAt_idx" ON "RemoteCommand"("deviceId", "status", "createdAt");
-- CreateIndex
CREATE INDEX "RemoteCommand_venueId_status_createdAt_idx" ON "RemoteCommand"("venueId", "status", "createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_venueId_createdAt_idx" ON "AuditLog"("venueId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash");
-- CreateIndex
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ClientUser" ADD CONSTRAINT "ClientUser_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegistrationCode" ADD CONSTRAINT "RegistrationCode_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OpenTabSnapshot" ADD CONSTRAINT "OpenTabSnapshot_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TabOwnership" ADD CONSTRAINT "TabOwnership_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TabOwnership" ADD CONSTRAINT "TabOwnership_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RemoteCommand" ADD CONSTRAINT "RemoteCommand_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RemoteCommand" ADD CONSTRAINT "RemoteCommand_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "PlannedTab" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"customerName" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PlannedTab_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PlannedTab_venueId_idx" ON "PlannedTab"("venueId");
-- CreateIndex
CREATE UNIQUE INDEX "PlannedTab_venueId_customerName_key" ON "PlannedTab"("venueId", "customerName");
-- AddForeignKey
ALTER TABLE "PlannedTab" ADD CONSTRAINT "PlannedTab_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"
provider = "postgresql"
+15 -1
View File
@@ -19,11 +19,12 @@ model Venue {
remoteCommands RemoteCommand[]
auditLogs AuditLog[]
regCodes RegistrationCode[]
plannedTabs PlannedTab[]
}
model Device {
id String @id @default(uuid())
venueId String
venueId String @unique
name String
tokenHash String @unique
tokenPrefix String
@@ -142,3 +143,16 @@ model RefreshToken {
@@index([userId])
}
model PlannedTab {
id String @id @default(uuid())
venueId String
customerName String
position Int @default(0)
createdAt DateTime @default(now())
venue Venue @relation(fields: [venueId], references: [id])
@@unique([venueId, customerName])
@@index([venueId])
}
+15 -1
View File
@@ -19,11 +19,12 @@ model Venue {
remoteCommands RemoteCommand[]
auditLogs AuditLog[]
regCodes RegistrationCode[]
plannedTabs PlannedTab[]
}
model Device {
id String @id @default(uuid())
venueId String
venueId String @unique
name String
tokenHash String @unique
tokenPrefix String
@@ -142,3 +143,16 @@ model RefreshToken {
@@index([userId])
}
model PlannedTab {
id String @id @default(uuid())
venueId String
customerName String
position Int @default(0)
createdAt DateTime @default(now())
venue Venue @relation(fields: [venueId], references: [id])
@@unique([venueId, customerName])
@@index([venueId])
}
+18 -80
View File
@@ -55,103 +55,43 @@ async function main() {
});
console.log('Created viewer user: viewer@example.com / Viewer123!');
// Create devices
const device1Id = uuidv4();
const device2Id = uuidv4();
const token1 = 'dev_' + crypto.randomBytes(48).toString('base64url');
const token2 = 'dev_' + crypto.randomBytes(48).toString('base64url');
// Create one device
const deviceId = uuidv4();
const token = 'dev_' + crypto.randomBytes(48).toString('base64url');
await prisma.device.create({
data: {
id: device1Id,
id: deviceId,
venueId,
name: 'Bar terminal 1',
tokenHash: crypto.createHash('sha256').update(token1).digest('hex'),
tokenPrefix: token1.substring(0, 8),
tokenHash: crypto.createHash('sha256').update(token).digest('hex'),
tokenPrefix: token.substring(0, 8),
tokenExpiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
lastSeenAt: new Date(Date.now() - 1 * 60 * 1000), // seen 1 minute ago (online)
lastSeenAt: new Date(Date.now() - 1 * 60 * 1000),
},
});
await prisma.device.create({
data: {
id: device2Id,
venueId,
name: 'Bar terminal 2',
tokenHash: crypto.createHash('sha256').update(token2).digest('hex'),
tokenPrefix: token2.substring(0, 8),
tokenExpiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
lastSeenAt: new Date(Date.now() - 15 * 60 * 1000), // seen 15 minutes ago (stale)
},
});
console.log(`Created device: Bar terminal 1 (${deviceId})`);
console.log(`Created device: Bar terminal 1 (${device1Id})`);
console.log(`Created device: Bar terminal 2 (${device2Id})`);
console.log(`Device 1 token: ${token1}`);
console.log(`Device 2 token: ${token2}`);
// Create tab ownership and snapshots for device 1
const tabsData = [
{ tabId: uuidv4(), customerName: 'Jan', version: 5, totalInCents: 2450, itemCount: 3, reportedAt: new Date(Date.now() - 2 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'Marie', version: 3, totalInCents: 1800, itemCount: 2, reportedAt: new Date(Date.now() - 5 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'Thomas', version: 7, totalInCents: 4200, itemCount: 5, reportedAt: new Date(Date.now() - 3 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'Lisa', version: 2, totalInCents: 900, itemCount: 1, reportedAt: new Date(Date.now() - 4 * 60 * 1000) },
];
for (const tab of tabsData) {
await prisma.tabOwnership.create({
data: {
tabId: tab.tabId,
venueId,
deviceId: device1Id,
lastReportedAt: tab.reportedAt,
},
});
await prisma.openTabSnapshot.create({
data: {
deviceId: device1Id,
tabId: tab.tabId,
customerName: tab.customerName,
version: tab.version,
totalInCents: tab.totalInCents,
itemCount: tab.itemCount,
reportedAt: tab.reportedAt,
},
});
}
// Create tab ownership and snapshots for device 2 (stale)
const staleTabs = [
{ tabId: uuidv4(), customerName: 'Emma', version: 4, totalInCents: 3200, itemCount: 4, reportedAt: new Date(Date.now() - 16 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'David', version: 1, totalInCents: 1500, itemCount: 2, reportedAt: new Date(Date.now() - 20 * 60 * 1000) },
];
for (const tab of staleTabs) {
for (const tab of tabsData) {
await prisma.tabOwnership.create({
data: {
tabId: tab.tabId,
venueId,
deviceId: device2Id,
lastReportedAt: tab.reportedAt,
},
data: { tabId: tab.tabId, venueId, deviceId, lastReportedAt: tab.reportedAt },
});
await prisma.openTabSnapshot.create({
data: {
deviceId: device2Id,
tabId: tab.tabId,
customerName: tab.customerName,
version: tab.version,
totalInCents: tab.totalInCents,
itemCount: tab.itemCount,
reportedAt: tab.reportedAt,
},
data: { deviceId, tabId: tab.tabId, customerName: tab.customerName, version: tab.version, totalInCents: tab.totalInCents, itemCount: tab.itemCount, reportedAt: tab.reportedAt },
});
}
console.log('Created 6 open tabs across 2 devices');
console.log('Created 6 open tabs');
// Create sample commands
const now = new Date();
@@ -161,7 +101,7 @@ async function main() {
data: {
id: uuidv4(),
venueId,
deviceId: device1Id,
deviceId,
targetTabId: tabsData[1].tabId,
type: 'close_tab',
expectedVersion: 3,
@@ -180,7 +120,7 @@ async function main() {
data: {
id: uuidv4(),
venueId,
deviceId: device1Id,
deviceId,
targetTabId: tabsData[0].tabId,
type: 'close_tab',
expectedVersion: 5,
@@ -197,8 +137,8 @@ async function main() {
data: {
id: uuidv4(),
venueId,
deviceId: device2Id,
targetTabId: staleTabs[0].tabId,
deviceId,
targetTabId: tabsData[4].tabId,
type: 'close_tab',
expectedVersion: 4,
paymentMethod: 'payment_pending',
@@ -216,7 +156,7 @@ async function main() {
data: {
id: uuidv4(),
venueId,
deviceId: device1Id,
deviceId,
targetTabId: tabsData[2].tabId,
type: 'close_tab',
expectedVersion: 7,
@@ -249,9 +189,7 @@ async function main() {
console.log('Demo credentials:');
console.log(' Admin: admin@example.com / Admin123!');
console.log(' Viewer: viewer@example.com / Viewer123!');
console.log(`\nDevice tokens (for simulator):`);
console.log(` Bar terminal 1: ${token1}`);
console.log(` Bar terminal 2: ${token2}`);
console.log(`\nDevice token (for simulator): ${token}`);
}
main()
+60
View File
@@ -0,0 +1,60 @@
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcrypt');
const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');
const prisma = new PrismaClient();
const VENUE_NAME = process.env.SETUP_VENUE_NAME || process.argv[2] || 'My Venue';
const ADMIN_EMAIL = process.env.SETUP_ADMIN_EMAIL || process.argv[3] || 'admin@example.com';
const ADMIN_PASSWORD = process.env.SETUP_ADMIN_PASSWORD || process.argv[4] || 'admin123';
async function main() {
console.log('=== KoolTab Production Setup ===\n');
// Wipe everything
await prisma.auditLog.deleteMany();
await prisma.remoteCommand.deleteMany();
await prisma.openTabSnapshot.deleteMany();
await prisma.tabOwnership.deleteMany();
await prisma.refreshToken.deleteMany();
await prisma.registrationCode.deleteMany();
await prisma.device.deleteMany();
await prisma.clientUser.deleteMany();
await prisma.venue.deleteMany();
console.log('✓ Cleared existing data');
// Create venue
const venueId = uuidv4();
await prisma.venue.create({ data: { id: venueId, name: VENUE_NAME } });
console.log(`✓ Created venue: ${VENUE_NAME}`);
// Create admin user
const hash = await bcrypt.hash(ADMIN_PASSWORD, 10);
await prisma.clientUser.create({
data: {
id: uuidv4(),
venueId,
email: ADMIN_EMAIL,
passwordHash: hash,
name: 'Admin',
role: 'admin',
},
});
console.log(`✓ Created admin: ${ADMIN_EMAIL}`);
console.log('\n=== Setup complete ===');
console.log(` Venue: ${VENUE_NAME}`);
console.log(` Venue ID: ${venueId}`);
console.log(` Admin: ${ADMIN_EMAIL}`);
console.log(` Password: ${ADMIN_PASSWORD}`);
console.log('\nSave the Venue ID — the Flutter app needs it to find your venue.');
console.log('Login at https://kooltab.com with the admin credentials above.');
await prisma.$disconnect();
}
main().catch((err) => {
console.error('Setup failed:', err);
process.exit(1);
});
+2
View File
@@ -10,6 +10,7 @@ import { TabsModule } from './tabs/tabs.module';
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 { HealthController } from './health.controller';
@Module({
@@ -24,6 +25,7 @@ import { HealthController } from './health.controller';
CommandsModule,
AuditModule,
JobsModule,
PlannedTabsModule,
],
controllers: [HealthController],
providers: [
+36
View File
@@ -132,6 +132,15 @@ export class AuthService {
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,
@@ -153,6 +162,33 @@ export class AuthService {
return { deviceId, token: rawToken, tokenExpiresAt: expiresAt };
}
async createUser(venueId: string, email: string, password: string, name: string, role: string) {
const venue = await this.prisma.venue.findUnique({ where: { id: venueId } });
if (!venue) {
throw new NotFoundException({ code: 'venue_not_found', message: 'Venue not found. Your session may be stale — try logging out and back in.' });
}
const existingUser = await this.prisma.clientUser.findUnique({ where: { email } });
if (existingUser) {
throw new ConflictException({ code: 'email_exists', message: 'A user with this email already exists' });
}
const passwordHash = await bcrypt.hash(password, 10);
const user = await this.prisma.clientUser.create({
data: {
id: uuidv4(),
venueId,
email,
passwordHash,
name,
role,
},
select: { id: true, email: true, name: true, role: true, venueId: true, createdAt: true },
});
return user;
}
async validateDevice(deviceId: string, token: string): Promise<any> {
const hash = crypto.createHash('sha256').update(token).digest('hex');
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
+8
View File
@@ -142,6 +142,13 @@ export class DevicesService {
orderBy: { createdAt: 'asc' },
});
// Fetch planned tab names for this venue
const plannedTabs = await this.prisma.plannedTab.findMany({
where: { venueId: device.venueId },
orderBy: { position: 'asc' },
select: { customerName: true },
});
return {
cursor,
commands: commands.map((c) => ({
@@ -153,6 +160,7 @@ export class DevicesService {
requestedBy: c.requestedBy,
createdAt: c.createdAt,
})),
plannedTabNames: plannedTabs.map((t) => t.customerName),
};
}
@@ -0,0 +1,36 @@
import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { PlannedTabsService } from './planned-tabs.service';
@ApiTags('Planned Tabs')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('venues/:venueId/planned-tabs')
export class PlannedTabsController {
constructor(private service: PlannedTabsService) {}
@Get()
@ApiOperation({ summary: 'List planned tab names' })
async findAll(@Param('venueId') venueId: string) {
return this.service.findAll(venueId);
}
@Post()
@ApiOperation({ summary: 'Add a planned tab name' })
async create(@Param('venueId') venueId: string, @Body() dto: { customerName: string }) {
return this.service.create(venueId, dto.customerName);
}
@Put(':id')
@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);
}
@Delete(':id')
@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,10 @@
import { Module } from '@nestjs/common';
import { PlannedTabsController } from './planned-tabs.controller';
import { PlannedTabsService } from './planned-tabs.service';
@Module({
controllers: [PlannedTabsController],
providers: [PlannedTabsService],
exports: [PlannedTabsService],
})
export class PlannedTabsModule {}
@@ -0,0 +1,72 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class PlannedTabsService {
constructor(private prisma: PrismaService) {}
async findAll(venueId: string) {
const tabs = await this.prisma.plannedTab.findMany({
where: { venueId },
orderBy: { position: 'asc' },
select: { id: true, customerName: true, position: true, createdAt: true },
});
return { plannedTabs: tabs };
}
async create(venueId: string, customerName: string) {
const name = customerName.trim();
if (!name) throw new NotFoundException({ code: 'name_required', message: 'Customer name is required' });
// Check for duplicate
const existing = await this.prisma.plannedTab.findUnique({
where: { venueId_customerName: { venueId, customerName: name } },
});
if (existing) {
throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' });
}
// Get next position
const last = await this.prisma.plannedTab.findFirst({
where: { venueId },
orderBy: { position: 'desc' },
});
const tab = await this.prisma.plannedTab.create({
data: {
id: uuidv4(),
venueId,
customerName: name,
position: (last?.position ?? -1) + 1,
},
select: { id: true, customerName: true, position: true, createdAt: true },
});
return tab;
}
async update(venueId: string, id: string, customerName: string) {
const name = customerName.trim();
if (!name) throw new NotFoundException({ 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 result = await this.prisma.plannedTab.update({
where: { id },
data: { customerName: name },
select: { id: true, customerName: true, position: true, createdAt: true },
});
return result;
}
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' });
await this.prisma.plannedTab.delete({ where: { id } });
return { ok: true };
}
}
+36 -15
View File
@@ -1,7 +1,10 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { Controller, Get, Param, UseGuards, Post, Body } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
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 { AuthService } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
@ApiTags('Venues')
@@ -9,6 +12,7 @@ import { PrismaService } from '../prisma/prisma.service';
export class VenuesController {
constructor(
private venuesService: VenuesService,
private authService: AuthService,
private prisma: PrismaService,
) {}
@@ -26,24 +30,41 @@ export class VenuesController {
return this.venuesService.findOne(venueId);
}
@Get(':venueId/users')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
@ApiOperation({ summary: 'List users for a venue' })
async findUsers(@Param('venueId') venueId: string) {
const users = await this.prisma.clientUser.findMany({
where: { venueId },
select: { id: true, email: true, name: true, role: true, createdAt: true },
orderBy: { createdAt: 'desc' },
});
return { users };
}
@Post(':venueId/users')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, 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 },
) {
return this.authService.createUser(venueId, dto.email, dto.password, dto.name, dto.role);
}
@Get(':venueId/devices')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'List devices for a venue' })
@ApiOperation({ summary: 'Get venue device info' })
async findDevices(@Param('venueId') venueId: string) {
const devices = await this.prisma.device.findMany({
where: { venueId },
select: {
id: true,
name: true,
lastSeenAt: true,
revokedAt: true,
createdAt: true,
tokenExpiresAt: true,
tokenPrefix: true,
},
orderBy: { createdAt: 'desc' },
const device = await this.prisma.device.findFirst({
where: { venueId, revokedAt: null },
select: { id: true, name: true, lastSeenAt: true, createdAt: true },
});
return { devices };
return { device };
}
}
+2
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { VenuesController } from './venues.controller';
import { VenuesService } from './venues.service';
@Module({
imports: [AuthModule],
controllers: [VenuesController],
providers: [VenuesService],
exports: [VenuesService],
@@ -87,26 +87,22 @@ export default function CommandsPage() {
<>
<div className="rounded-md border mb-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>Tab</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Device</TableHead>
<TableHead>Requested By</TableHead>
<TableHead>Payment</TableHead>
<TableHead>Created</TableHead>
<TableHead>Status</TableHead>
<TableHead>Processed</TableHead>
<TableHead>Result</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{commands.map((cmd: any) => (
<TableRow key={cmd.id}>
<TableCell className="font-mono text-xs">{cmd.tabId.substring(0, 8)}</TableCell>
<TableCell>-</TableCell>
<TableCell className="text-xs">{cmd.deviceId?.substring(0, 8)}</TableCell>
<TableCell>{cmd.requestedBy}</TableCell>
<TableHeader>
<TableRow>
<TableHead>Tab</TableHead>
<TableHead>Requested By</TableHead>
<TableHead>Payment</TableHead>
<TableHead>Created</TableHead>
<TableHead>Status</TableHead>
<TableHead>Processed</TableHead>
<TableHead>Result</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{commands.map((cmd: any) => (
<TableRow key={cmd.id}>
<TableCell className="font-mono text-xs">{cmd.tabId.substring(0, 8)}</TableCell>
<TableCell>{cmd.requestedBy}</TableCell>
<TableCell><PaymentMethodBadge method={cmd.paymentMethod} /></TableCell>
<TableCell>{formatRelativeTime(cmd.createdAt)}</TableCell>
<TableCell><CommandStatusBadge status={cmd.status} /></TableCell>
@@ -1,229 +0,0 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { api, devicesApi } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { DeviceStatusBadge } from '@/components/device-status-badge';
import { ConfirmDialog } from '@/components/confirm-dialog';
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 { formatRelativeTime } from '@/lib/utils';
import { RefreshCw, Plus, Copy } from 'lucide-react';
export default function DevicesPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [venueId, setVenueId] = useState<string | null>(null);
const [userRole, setUserRole] = useState<string | null>(null);
const [revokeDialog, setRevokeDialog] = useState<{ open: boolean; device: any }>({ open: false, device: null });
const [revoking, setRevoking] = useState(false);
const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null);
const [generating, setGenerating] = 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);
setUserRole(payload.role);
}, [router]);
const { data, isLoading, error } = useQuery({
queryKey: ['devices', venueId],
queryFn: () => api(`/venues/${venueId}/devices`),
enabled: !!venueId,
refetchInterval: 15000,
});
const devices = data?.devices || [];
const revokeMutation = useMutation({
mutationFn: (deviceId: string) => devicesApi.revoke(venueId!, deviceId),
onSuccess: () => {
toast({ title: 'Device revoked', description: 'Device has been revoked successfully', variant: 'destructive' });
queryClient.invalidateQueries({ queryKey: ['devices'] });
},
onError: (err: any) => {
toast({ title: 'Failed to revoke device', description: err.message, variant: 'destructive' });
},
});
function handleRevoke(device: any) {
setRevokeDialog({ open: true, device });
}
function confirmRevoke() {
setRevoking(true);
revokeMutation.mutate(revokeDialog.device.id, {
onSettled: () => {
setRevoking(false);
setRevokeDialog({ open: false, device: null });
},
});
}
async function handleGenerateCode() {
setGenerating(true);
try {
const result = await devicesApi.generateCode();
setRegDialog({ open: true, code: result.code, expiresAt: result.expiresAt });
} catch (err: any) {
toast({ title: 'Failed to generate code', description: err.message, variant: 'destructive' });
} finally {
setGenerating(false);
}
}
function copyCode() {
if (regDialog) {
navigator.clipboard.writeText(regDialog.code);
toast({ title: 'Code copied', description: 'Registration code copied to clipboard' });
}
}
if (!venueId) return <LoadingState />;
if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={(error as any).message || 'Failed to load devices'} />;
return (
<div>
<PageHeader
title="Devices"
description={`${devices.length} device${devices.length !== 1 ? 's' : ''} registered`}
actions={
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: ['devices'] })}
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
{userRole === 'admin' && (
<Button size="sm" onClick={handleGenerateCode} disabled={generating}>
<Plus className="h-4 w-4 mr-2" />
{generating ? 'Generating...' : 'Add Device'}
</Button>
)}
</div>
}
/>
{devices.length === 0 ? (
<EmptyState title="No devices registered" description="Register a device using the Add Device button to get started." />
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>ID</TableHead>
<TableHead>Last Seen</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{devices.map((device: any) => (
<TableRow key={device.id}>
<TableCell className="font-medium">{device.name}</TableCell>
<TableCell className="text-xs text-muted-foreground font-mono">
{device.id.substring(0, 8)}...
</TableCell>
<TableCell>{formatRelativeTime(device.lastSeenAt)}</TableCell>
<TableCell>
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} revokedAt={device.revokedAt} />
</TableCell>
<TableCell>{formatRelativeTime(device.createdAt)}</TableCell>
<TableCell className="text-right">
{device.revokedAt ? (
<span className="text-sm text-muted-foreground">Revoked</span>
) : userRole === 'admin' ? (
<Button
size="sm"
variant="destructive"
onClick={() => handleRevoke(device)}
>
Revoke
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{revokeDialog.device && (
<ConfirmDialog
open={revokeDialog.open}
onOpenChange={(open) => !open && setRevokeDialog({ open: false, device: null })}
title="Revoke device?"
description={`Are you sure you want to revoke "${revokeDialog.device.name}"? This action cannot be undone.`}
confirmLabel="Revoke"
variant="destructive"
onConfirm={confirmRevoke}
loading={revoking}
/>
)}
{regDialog && (
<Dialog open={regDialog.open} onOpenChange={(open) => !open && setRegDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Device Registration</DialogTitle>
<DialogDescription>
Share this code with the bar terminal to register it.
</DialogDescription>
</DialogHeader>
<div className="py-6 space-y-4">
<div className="text-center">
<p className="text-sm text-muted-foreground mb-2">Registration Code</p>
<div className="text-4xl font-bold tracking-widest text-primary bg-primary/5 py-4 rounded-lg">
{regDialog.code}
</div>
</div>
<div className="text-center text-sm text-muted-foreground">
Expires at {new Date(regDialog.expiresAt).toLocaleTimeString()}
</div>
<div className="space-y-1 text-sm">
<p className="font-medium">Instructions:</p>
<ol className="list-decimal list-inside text-muted-foreground space-y-1">
<li>Open Admin on the bar terminal</li>
<li>Enter the Admin PIN</li>
<li>Enter this registration code</li>
<li>Give the terminal a name</li>
<li>Complete registration</li>
</ol>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setRegDialog(null)}>
Close
</Button>
<Button onClick={copyCode}>
<Copy className="h-4 w-4 mr-2" />
Copy code
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
}
+1 -1
View File
@@ -21,7 +21,7 @@ import { toast } from '@/components/ui/toast';
const navItems = [
{ href: '/dashboard', label: 'Overview', icon: LayoutDashboard },
{ href: '/dashboard/tabs', label: 'Open Tabs', icon: ClipboardList },
{ href: '/dashboard/devices', label: 'Devices', icon: Monitor },
{ href: '/dashboard/users', label: 'Users', icon: Monitor },
{ href: '/dashboard/commands', label: 'Command History', icon: Terminal },
{ href: '/dashboard/settings', label: 'Settings', icon: Settings },
];
+38 -91
View File
@@ -9,10 +9,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { LoadingState } from '@/components/loading-state';
import { CommandStatusBadge } from '@/components/command-status-badge';
import { DeviceStatusBadge } from '@/components/device-status-badge';
import { formatRelativeTime, formatCents } from '@/lib/utils';
import { getDeviceStatus } from '@/lib/device-status';
import { ClipboardList, Monitor, Terminal, AlertTriangle } from 'lucide-react';
import { formatRelativeTime } from '@/lib/utils';
import { ClipboardList, Terminal, Monitor } from 'lucide-react';
export default function DashboardPage() {
const router = useRouter();
@@ -37,8 +35,8 @@ export default function DashboardPage() {
refetchInterval: 10000,
});
const devicesQuery = useQuery({
queryKey: ['devices', venueId],
const deviceQuery = useQuery({
queryKey: ['device', venueId],
queryFn: () => api(`/venues/${venueId}/devices`),
enabled: !!venueId,
refetchInterval: 30000,
@@ -54,64 +52,39 @@ export default function DashboardPage() {
if (!venueId) return <LoadingState />;
const tabs = tabsQuery.data?.tabs || [];
const devices = devicesQuery.data?.devices || [];
const device = deviceQuery.data?.device;
const commands = commandsQuery.data?.commands || [];
const onlineDevices = devices.filter((d: any) => !d.revokedAt && getDeviceStatus(d.lastSeenAt) === 'online');
const staleDevices = devices.filter((d: any) => !d.revokedAt && (getDeviceStatus(d.lastSeenAt) === 'stale' || getDeviceStatus(d.lastSeenAt) === 'offline'));
const pendingCommands = commands.filter((c: any) => c.status === 'pending');
const commandsToday = commands.length;
const isLoading = tabsQuery.isLoading || devicesQuery.isLoading || commandsQuery.isLoading;
const isLoading = tabsQuery.isLoading || deviceQuery.isLoading || commandsQuery.isLoading;
return (
<div>
<PageHeader
title="Overview"
description="Real-time overview of your venue"
/>
<PageHeader title="Overview" description="Real-time overview of your venue" />
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-8">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-4" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-3 mb-8">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i}><CardHeader className="flex flex-row items-center justify-between pb-2"><Skeleton className="h-4 w-24" /><Skeleton className="h-4 w-4" /></CardHeader><CardContent><Skeleton className="h-8 w-16" /></CardContent></Card>
))}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-8">
<div className="grid gap-4 md:grid-cols-3 mb-8">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Open Tabs</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{tabs.length}</div>
</CardContent>
<CardContent><div className="text-2xl font-bold">{tabs.length}</div></CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Online Devices</CardTitle>
<Monitor className="h-4 w-4 text-green-600" />
<CardTitle className="text-sm font-medium">Device</CardTitle>
<Monitor className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{onlineDevices.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Stale/Offline Devices</CardTitle>
<AlertTriangle className="h-4 w-4 text-yellow-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-600">{staleDevices.length}</div>
<div className="text-2xl font-bold">{device?.name || 'Not connected'}</div>
{device && <p className="text-xs text-muted-foreground mt-1">Last seen {formatRelativeTime(device.lastSeenAt)}</p>}
</CardContent>
</Card>
<Card>
@@ -119,60 +92,34 @@ export default function DashboardPage() {
<CardTitle className="text-sm font-medium">Pending Commands</CardTitle>
<Terminal className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{pendingCommands.length}</div>
</CardContent>
<CardContent><div className="text-2xl font-bold">{pendingCommands.length}</div></CardContent>
</Card>
</div>
)}
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-lg">Recent Commands</CardTitle>
</CardHeader>
<CardContent>
{commands.length === 0 ? (
<p className="text-sm text-muted-foreground">No commands yet</p>
) : (
<div className="space-y-3">
{commands.map((cmd: any) => (
<div key={cmd.id} className="flex items-center justify-between text-sm">
<div>
<span className="font-medium">{cmd.tabId?.substring(0, 8)}</span>
<span className="text-muted-foreground ml-2">{cmd.requestedBy}</span>
</div>
<div className="flex items-center gap-2">
<CommandStatusBadge status={cmd.status} />
<span className="text-muted-foreground">{formatRelativeTime(cmd.createdAt)}</span>
</div>
<Card>
<CardHeader><CardTitle className="text-lg">Recent Commands</CardTitle></CardHeader>
<CardContent>
{commands.length === 0 ? (
<p className="text-sm text-muted-foreground">No commands yet</p>
) : (
<div className="space-y-3">
{commands.map((cmd: any) => (
<div key={cmd.id} className="flex items-center justify-between text-sm">
<div>
<span className="font-medium">{cmd.tabId?.substring(0, 8)}</span>
<span className="text-muted-foreground ml-2">{cmd.requestedBy}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Device Health</CardTitle>
</CardHeader>
<CardContent>
{devices.length === 0 ? (
<p className="text-sm text-muted-foreground">No devices registered</p>
) : (
<div className="space-y-3">
{devices.map((device: any) => (
<div key={device.id} className="flex items-center justify-between text-sm">
<span className="font-medium">{device.name}</span>
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} revokedAt={device.revokedAt} />
<div className="flex items-center gap-2">
<CommandStatusBadge status={cmd.status} />
<span className="text-muted-foreground">{formatRelativeTime(cmd.createdAt)}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -1,66 +1,168 @@
'use client';
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { api, devicesApi } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { toast } from '@/components/ui/toast';
import { formatRelativeTime, formatDateTime } from '@/lib/utils';
import { Monitor, Copy, Plus } from 'lucide-react';
export default function SettingsPage() {
const router = useRouter();
const [venueId, setVenueId] = useState<string | null>(null);
const [userRole, setUserRole] = useState<string | null>(null);
const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null);
const [generating, setGenerating] = 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);
setUserRole(payload.role);
}, [router]);
const { data: venue } = useQuery({
queryKey: ['venue', venueId],
queryFn: () => api(`/venues/${venueId}`),
enabled: !!venueId,
});
const { data: deviceData } = useQuery({
queryKey: ['device', venueId],
queryFn: () => devicesApi.get(venueId!),
enabled: !!venueId,
refetchInterval: 15000,
});
const device = deviceData?.device;
async function handleGenerateCode() {
setGenerating(true);
try {
const result = await devicesApi.generateCode();
setRegDialog({ open: true, code: result.code, expiresAt: result.expiresAt });
} catch (err: any) {
toast({ title: 'Failed', description: err.message, variant: 'destructive' });
} finally {
setGenerating(false);
}
}
function copyCode() {
if (regDialog) {
navigator.clipboard.writeText(regDialog.code);
toast({ title: 'Copied', description: 'Registration code copied to clipboard' });
}
}
return (
<div>
<PageHeader title="Settings" description="Venue and account settings" />
<PageHeader title="Settings" description="Venue and device management" />
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Venue Information</CardTitle>
<CardTitle>Venue</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Venue name</span>
<span>KoolTab Demo Bar</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Plan</span>
<Badge variant="secondary">Demo</Badge>
</div>
<CardContent className="text-sm">
<p className="font-medium">{venue?.name || 'Loading...'}</p>
<p className="text-muted-foreground font-mono text-xs mt-1">ID: {venueId}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Demo Credentials</CardTitle>
<CardTitle>Device</CardTitle>
<CardDescription>
{device
? `Paired as "${device.name}" — last seen ${formatRelativeTime(device.lastSeenAt)}`
: 'No device paired yet'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="p-3 bg-muted rounded-md">
<p className="font-medium mb-1">Admin account</p>
<p className="text-muted-foreground">admin@example.com / Admin123!</p>
</div>
<div className="p-3 bg-muted rounded-md">
<p className="font-medium mb-1">Viewer account</p>
<p className="text-muted-foreground">viewer@example.com / Viewer123!</p>
</div>
<p className="text-xs text-muted-foreground">
These credentials are for demo purposes only. Do not use in production.
</p>
<CardContent>
{device ? (
<div className="flex items-center gap-2 text-sm">
<Monitor className="h-4 w-4 text-green-600" />
<Badge variant="success">Connected</Badge>
<span className="text-muted-foreground">Since {formatDateTime(device.createdAt)}</span>
</div>
) : (
<p className="text-sm text-muted-foreground mb-3">
Pair a bar terminal to start receiving tabs.
</p>
)}
{userRole === 'admin' && (
<Button onClick={handleGenerateCode} disabled={generating} size="sm" className="mt-3">
<Plus className="h-4 w-4 mr-2" />
{generating ? 'Generating...' : device ? 'Re-pair Device' : 'Pair Device'}
</Button>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>API Information</CardTitle>
<CardTitle>API</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">API Base URL</span>
<span className="text-muted-foreground">Base URL</span>
<span className="font-mono text-xs">{process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Swagger Docs</span>
<span className="font-mono text-xs">{process.env.NEXT_PUBLIC_API_URL?.replace('/api/v1', '/api/docs') || 'http://localhost:3001/api/docs'}</span>
<span className="text-muted-foreground">Swagger</span>
<span className="font-mono text-xs">
{process.env.NEXT_PUBLIC_API_URL?.replace('/api/v1', '/api/docs') || 'http://localhost:3001/api/docs'}
</span>
</div>
</CardContent>
</Card>
</div>
{regDialog && (
<Dialog open={regDialog.open} onOpenChange={(open) => !open && setRegDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Pair a Device</DialogTitle>
<DialogDescription>
Enter this code on the bar terminal to connect it to your venue.
</DialogDescription>
</DialogHeader>
<div className="py-6 space-y-4">
<div className="text-center">
<p className="text-sm text-muted-foreground mb-2">Registration Code</p>
<div className="text-4xl font-bold tracking-widest text-primary bg-primary/5 py-4 rounded-lg">
{regDialog.code}
</div>
</div>
<div className="text-center text-sm text-muted-foreground">
Expires at {new Date(regDialog.expiresAt).toLocaleTimeString()}
</div>
<div className="space-y-1 text-sm">
<p className="font-medium">Instructions:</p>
<ol className="list-decimal list-inside text-muted-foreground space-y-1">
<li>Open the bar terminal app</li>
<li>Select your venue from the list</li>
<li>Enter this registration code</li>
<li>Give the terminal a name</li>
<li>Complete registration</li>
</ol>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setRegDialog(null)}>Close</Button>
<Button onClick={copyCode}><Copy className="h-4 w-4 mr-2" />Copy Code</Button>
</div>
</DialogContent>
</Dialog>
)}
</div>
);
}
+111 -149
View File
@@ -3,23 +3,21 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { api, commandsApi } from '@/lib/api/client';
import { api, commandsApi, plannedTabsApi } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DeviceStatusBadge } from '@/components/device-status-badge';
import { EmptyState } from '@/components/empty-state';
import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { EmptyState } from '@/components/empty-state';
import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast';
import { formatCents, formatRelativeTime } from '@/lib/utils';
import { getDeviceStatus } from '@/lib/device-status';
import { RefreshCw, XCircle, AlertTriangle } from 'lucide-react';
import { RefreshCw, Plus, X, Users } from 'lucide-react';
export default function TabsPage() {
const router = useRouter();
@@ -29,17 +27,15 @@ export default function TabsPage() {
const [paymentMethod, setPaymentMethod] = useState<string>('');
const [creating, setCreating] = useState(false);
const [pendingCommandIds, setPendingCommandIds] = useState<Set<string>>(new Set());
const [plannedName, setPlannedName] = useState('');
const [userRole, setUserRole] = useState<string | null>(null);
useEffect(() => {
const cookie = document.cookie
.split('; ')
.find((row) => row.startsWith('access_token='));
if (!cookie) {
router.push('/login');
return;
}
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);
setUserRole(payload.role);
}, [router]);
const { data, isLoading, error } = useQuery({
@@ -49,7 +45,6 @@ export default function TabsPage() {
refetchInterval: 10000,
});
// Poll pending commands to check their status
const commandsQuery = useQuery({
queryKey: ['commands', venueId, 'pending'],
queryFn: () => api(`/venues/${venueId}/commands?status=pending`),
@@ -57,6 +52,12 @@ export default function TabsPage() {
refetchInterval: 3000,
});
const plannedQuery = useQuery({
queryKey: ['planned-tabs', venueId],
queryFn: () => plannedTabsApi.list(venueId!),
enabled: !!venueId,
});
useEffect(() => {
if (commandsQuery.data?.commands) {
for (const cmd of commandsQuery.data.commands) {
@@ -64,11 +65,7 @@ export default function TabsPage() {
const newSet = new Set(pendingCommandIds);
newSet.delete(cmd.id);
setPendingCommandIds(newSet);
toast({
title: `Command ${cmd.status}`,
description: `Close request for tab ${cmd.tabId?.substring(0, 8)} is now ${cmd.status}`,
variant: cmd.status === 'completed' ? 'success' : 'destructive',
});
toast({ title: `Command ${cmd.status}`, description: `Close request for tab ${cmd.tabId?.substring(0, 8)} is now ${cmd.status}`, variant: cmd.status === 'completed' ? 'success' : 'destructive' });
queryClient.invalidateQueries({ queryKey: ['tabs'] });
queryClient.invalidateQueries({ queryKey: ['commands'] });
}
@@ -81,44 +78,41 @@ export default function TabsPage() {
commandsApi.create(venueId!, { ...data, type: 'close_tab' }),
onSuccess: (result) => {
setPendingCommandIds(new Set([...pendingCommandIds, result.id]));
toast({
title: 'Close request sent',
description: result.warning || 'Command has been created and sent to the device',
variant: result.warning ? 'default' : 'success',
});
toast({ title: 'Close request sent', description: result.warning || 'Command sent to the terminal', variant: result.warning ? 'default' : 'success' });
queryClient.invalidateQueries({ queryKey: ['commands'] });
},
onError: (err: any) => {
toast({
title: 'Failed to create command',
description: err.message || 'An error occurred',
variant: 'destructive',
});
toast({ title: 'Failed', description: err.message || 'An error occurred', variant: 'destructive' });
},
});
function handleCloseTab(tab: any) {
setCloseDialog({ open: true, tab });
setPaymentMethod('');
}
function confirmClose() {
if (!paymentMethod) return;
setCreating(true);
createCommandMutation.mutate(
{
tabId: closeDialog.tab.tabId,
expectedVersion: closeDialog.tab.version,
paymentMethod,
},
{
onSettled: () => {
setCreating(false);
setCloseDialog({ open: false, tab: null });
setPaymentMethod('');
},
},
);
createCommandMutation.mutate({ tabId: closeDialog.tab.tabId, expectedVersion: closeDialog.tab.version, paymentMethod }, {
onSettled: () => { setCreating(false); setCloseDialog({ open: false, tab: null }); setPaymentMethod(''); },
});
}
async function addPlannedName() {
if (!plannedName.trim()) return;
try {
await plannedTabsApi.create(venueId!, plannedName.trim());
setPlannedName('');
queryClient.invalidateQueries({ queryKey: ['planned-tabs'] });
toast({ title: 'Added', description: `"${plannedName.trim()}" added to planned tabs` });
} catch (err: any) {
toast({ title: 'Failed', description: err.message, variant: 'destructive' });
}
}
async function removePlannedName(id: string) {
try {
await plannedTabsApi.remove(venueId!, id);
queryClient.invalidateQueries({ queryKey: ['planned-tabs'] });
} catch (err: any) {
toast({ title: 'Failed', description: err.message, variant: 'destructive' });
}
}
if (!venueId) return <LoadingState />;
@@ -131,21 +125,53 @@ export default function TabsPage() {
<div>
<PageHeader
title="Open Tabs"
description={`${tabs.length} open tab${tabs.length !== 1 ? 's' : ''} across all devices`}
actions={
<Button
variant="outline"
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: ['tabs'] })}
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
}
description={`${tabs.length} open tab${tabs.length !== 1 ? 's' : ''}`}
actions={<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['tabs'] })}><RefreshCw className="h-4 w-4 mr-2" />Refresh</Button>}
/>
{/* 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>
</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">
<Input
placeholder="Add customer name..."
value={plannedName}
onChange={(e) => setPlannedName(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>
</div>
)}
<div className="flex flex-wrap gap-2">
{plannedQuery.data?.plannedTabs?.length === 0 && (
<p className="text-sm text-muted-foreground">No planned customers. Add names above and they'll sync to the terminal.</p>
)}
{(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}
{userRole === 'admin' && (
<button onClick={() => removePlannedName(pt.id)} className="text-muted-foreground hover:text-destructive ml-1">
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</CardContent>
</Card>
{tabs.length === 0 ? (
<EmptyState title="No open tabs" description="All tabs are closed. Open tabs will appear here when devices sync." />
<EmptyState title="No open tabs" description="Open tabs will appear here when the terminal syncs." />
) : (
<div className="rounded-md border">
<Table>
@@ -154,117 +180,53 @@ export default function TabsPage() {
<TableHead>Customer</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Items</TableHead>
<TableHead>Device</TableHead>
<TableHead>Device Status</TableHead>
<TableHead>Version</TableHead>
<TableHead>Last Reported</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tabs.map((tab: any) => {
const deviceStale = getDeviceStatus(tab.deviceLastSeenAt, tab.deviceRevokedAt) === 'stale'
|| getDeviceStatus(tab.deviceLastSeenAt, tab.deviceRevokedAt) === 'offline';
return (
<TableRow key={tab.tabId}>
<TableCell className="font-medium">{tab.customerName}</TableCell>
<TableCell>{formatCents(tab.totalInCents)}</TableCell>
<TableCell>{tab.itemCount}</TableCell>
<TableCell>{tab.deviceName}</TableCell>
<TableCell>
<DeviceStatusBadge lastSeenAt={tab.deviceLastSeenAt} revokedAt={tab.deviceRevokedAt} />
</TableCell>
<TableCell>{tab.version}</TableCell>
<TableCell>{formatRelativeTime(tab.lastReportedAt)}</TableCell>
<TableCell className="text-right">
<Button
size="sm"
onClick={() => handleCloseTab(tab)}
disabled={!!tab.deviceRevokedAt}
>
{tab.deviceRevokedAt ? 'Device revoked' : 'Close'}
</Button>
</TableCell>
</TableRow>
);
})}
{tabs.map((tab: any) => (
<TableRow key={tab.tabId}>
<TableCell className="font-medium">{tab.customerName}</TableCell>
<TableCell>{formatCents(tab.totalInCents)}</TableCell>
<TableCell>{tab.itemCount}</TableCell>
<TableCell>{tab.version}</TableCell>
<TableCell>{formatRelativeTime(tab.lastReportedAt)}</TableCell>
<TableCell className="text-right">
<Button size="sm" onClick={() => { setCloseDialog({ open: true, tab }); setPaymentMethod(''); }}>Close</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* Close Tab Dialog */}
{closeDialog.tab && (
<Dialog open={closeDialog.open} onOpenChange={(open) => !open && setCloseDialog({ open: false, tab: null })}>
<DialogContent>
<DialogHeader>
<DialogTitle>Close {closeDialog.tab.customerName}'s tab?</DialogTitle>
<DialogDescription>
This will send a close request to the bar terminal.
</DialogDescription>
<DialogDescription>Send a close request to the terminal.</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Amount</span>
<span className="font-semibold">{formatCents(closeDialog.tab.totalInCents)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Items</span>
<span>{closeDialog.tab.itemCount}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Device</span>
<span>{closeDialog.tab.deviceName}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Version</span>
<span>{closeDialog.tab.version}</span>
</div>
{closeDialog.tab.deviceLastSeenAt && getDeviceStatus(closeDialog.tab.deviceLastSeenAt) !== 'online' && (
<div className="flex items-center gap-2 text-sm text-yellow-600 bg-yellow-50 p-3 rounded-md">
<AlertTriangle className="h-4 w-4" />
Device has not been seen recently
</div>
)}
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Amount</span><span className="font-semibold">{formatCents(closeDialog.tab.totalInCents)}</span></div>
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Items</span><span>{closeDialog.tab.itemCount}</span></div>
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Version</span><span>{closeDialog.tab.version}</span></div>
<div className="space-y-3 pt-2">
<Label className="text-base font-semibold">Payment method</Label>
<RadioGroup value={paymentMethod} onValueChange={setPaymentMethod}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="cash" id="cash" />
<Label htmlFor="cash">Cash</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="payconiq" id="payconiq" />
<Label htmlFor="payconiq">Payconiq</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="payment_pending" id="payment_pending" />
<Label htmlFor="payment_pending">Payment pending</Label>
</div>
<div className="flex items-center space-x-2"><RadioGroupItem value="cash" id="cash" /><Label htmlFor="cash">Cash</Label></div>
<div className="flex items-center space-x-2"><RadioGroupItem value="payconiq" id="payconiq" /><Label htmlFor="payconiq">Payconiq</Label></div>
<div className="flex items-center space-x-2"><RadioGroupItem value="payment_pending" id="payment_pending" /><Label htmlFor="payment_pending">Payment pending</Label></div>
</RadioGroup>
{!paymentMethod && (
<p className="text-xs text-destructive">Please select a payment method</p>
)}
{!paymentMethod && <p className="text-xs text-destructive">Please select a payment method</p>}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setCloseDialog({ open: false, tab: null })}
disabled={creating}
>
Cancel
</Button>
<Button
onClick={confirmClose}
disabled={creating || !paymentMethod}
>
{creating ? 'Sending...' : 'Send close request'}
</Button>
<Button variant="outline" onClick={() => setCloseDialog({ open: false, tab: null })} disabled={creating}>Cancel</Button>
<Button onClick={confirmClose} disabled={creating || !paymentMethod}>{creating ? 'Sending...' : 'Send close request'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,155 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { usersApi } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
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 { formatRelativeTime } from '@/lib/utils';
import { RefreshCw, Plus } from 'lucide-react';
export default function UsersPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [venueId, setVenueId] = useState<string | null>(null);
const [userRole, setUserRole] = useState<string | null>(null);
const [addDialog, setAddDialog] = useState(false);
const [form, setForm] = useState({ email: '', password: '', name: '', role: 'viewer' });
const [submitting, setSubmitting] = 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);
setUserRole(payload.role);
}, [router]);
const { data, isLoading, error } = useQuery({
queryKey: ['users', venueId],
queryFn: () => usersApi.list(venueId!),
enabled: !!venueId,
});
const users = data?.users || [];
async function handleCreateUser() {
if (!form.email || !form.password || !form.name) return;
setSubmitting(true);
try {
await usersApi.create(venueId!, form);
toast({ title: 'User created', description: `${form.email} added as ${form.role}`, variant: 'success' });
setAddDialog(false);
setForm({ email: '', password: '', name: '', role: 'viewer' });
queryClient.invalidateQueries({ queryKey: ['users'] });
} catch (err: any) {
toast({ title: 'Failed to create user', description: err.message, variant: 'destructive' });
} finally {
setSubmitting(false);
}
}
if (!venueId) return <LoadingState />;
if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={(error as any).message || 'Failed to load users'} />;
return (
<div>
<PageHeader
title="Users"
description={`${users.length} user${users.length !== 1 ? 's' : ''} registered`}
actions={
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['users'] })}>
<RefreshCw className="h-4 w-4 mr-2" /> Refresh
</Button>
{userRole === 'admin' && (
<Button size="sm" onClick={() => setAddDialog(true)}>
<Plus className="h-4 w-4 mr-2" /> Add User
</Button>
)}
</div>
}
/>
{users.length === 0 ? (
<EmptyState title="No users" description="Add users who can manage tabs from this dashboard." />
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((u: any) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.name}</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell><Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>{u.role}</Badge></TableCell>
<TableCell>{formatRelativeTime(u.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<Dialog open={addDialog} onOpenChange={setAddDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add User</DialogTitle>
<DialogDescription>Create a new user who can access the dashboard.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="Bartender name" />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} placeholder="email@example.com" />
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" value={form.password} onChange={e => setForm({ ...form, password: e.target.value })} placeholder="Min 6 characters" />
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={form.role} onValueChange={v => setForm({ ...form, role: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="viewer">Viewer (can close tabs)</SelectItem>
<SelectItem value="admin">Admin (full access)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddDialog(false)} disabled={submitting}>Cancel</Button>
<Button onClick={handleCreateUser} disabled={submitting || !form.email || !form.password || !form.name}>
{submitting ? 'Creating...' : 'Create User'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+21 -4
View File
@@ -128,13 +128,19 @@ export const tabsApi = {
// Devices API
export const devicesApi = {
list: (venueId: string) =>
api<{ devices: any[] }>(`/venues/${venueId}/devices`),
revoke: (venueId: string, deviceId: string) =>
api(`/venues/${venueId}/devices/${deviceId}/revoke`, { method: 'POST' }),
get: (venueId: string) =>
api<{ device: { id: string; name: string; lastSeenAt: string; createdAt: string } | null }>(`/venues/${venueId}/devices`),
generateCode: () => api<{ code: string; expiresAt: string }>('/devices/generation-code'),
};
// Users API
export const usersApi = {
list: (venueId: string) =>
api<{ users: any[] }>(`/venues/${venueId}/users`),
create: (venueId: string, data: { email: string; password: string; name: string; role: string }) =>
api(`/venues/${venueId}/users`, { method: 'POST', body: JSON.stringify(data) }),
};
// Commands API
export const commandsApi = {
list: (venueId: string, params?: { status?: string; limit?: number; offset?: number }) => {
@@ -160,4 +166,15 @@ export const venuesApi = {
get: (id: string) => api<{ id: string; name: string }>(`/venues/${id}`),
};
// 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 }) }),
remove: (venueId: string, id: string) =>
api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'DELETE' }),
};
export { setTokens, clearTokens };
+88
View File
@@ -0,0 +1,88 @@
## KoolTab Flutter App — Planned Tab Sync
Add support for **planned tab names** that are synced from the server. The server now returns a `plannedTabNames` list in the sync response. The app should create open tabs for any planned names that don't already have an open tab.
### Background
The dashboard admin can manage a list of "planned customers" (e.g. "Jan", "Marie", "Thomas"). These names are sent to the device in every sync response. The device should ensure those tabs exist — creating new ones if a planned name doesn't already have an open tab.
### Existing Files (read these first)
- `lib/models/remote_command.dart` — contains `SyncResponse`, `RemoteCommand`, `SnapshotTab`, etc.
- `lib/services/remote_sync_service.dart` — the sync loop that processes the sync response
- `lib/services/bar_tab_service.dart` — creates and manages tabs (has `createTab(String customerName)`)
- `lib/models/bar_tab.dart` — the `BarTab` model
- `lib/database/app_database.dart` — the Drift database schema
- `lib/viewmodels/remote_sync_view_model.dart` — wraps the sync service as `ChangeNotifier`
- `lib/utils/app_config.dart` — server URL constant
### What to Change
#### 1. Update `SyncResponse` model
In `lib/models/remote_command.dart`, add `plannedTabNames` to the existing `SyncResponse` class (it already exists — add the field):
```dart
class SyncResponse {
final String cursor;
final List<RemoteCommand> commands;
final List<String> plannedTabNames; // ← add this
SyncResponse({
required this.cursor,
required this.commands,
required this.plannedTabNames, // ← add this
});
factory SyncResponse.fromJson(Map<String, dynamic> json) => SyncResponse(
cursor: json['cursor'] as String,
commands: (json['commands'] as List)
.map((e) => RemoteCommand.fromJson(e as Map<String, dynamic>))
.toList(),
plannedTabNames: (json['plannedTabNames'] as List?) // ← add this
?.map((e) => e as String)
.toList() ?? [],
);
}
```
#### 2. Process planned names in the sync service
In `lib/services/remote_sync_service.dart`, inside the `_performSync()` method (or wherever the sync response is handled), add logic to process `plannedTabNames` **after** commands are processed but **before** updating the cursor.
The logic:
```dart
// After processing commands and before updating cursor:
// Ensure planned tabs exist
for (final name in response.plannedTabNames) {
final hasOpenTab = openTabs.any((t) => t.customerName == name);
if (!hasOpenTab) {
await barTabService.createTab(name);
}
}
// Also: if a tab is open for a customer NOT in plannedTabNames,
// should we close it? Probably not — just leave it.
```
Where `openTabs` is the list of currently open tabs (already available from `barTabService.getOpenTabs()` or similar). Look for `getOpenTabs()` in `BarTabService` or access the local database.
#### 3. Handle the first sync (no existing tabs)
On first sync, the device might have zero open tabs and receive a list of planned names. The code should create tabs for all planned names.
### Important Notes
- `BarTabService.createTab(String customerName)` already exists — it opens a new tab with version 1. Call it directly.
- Do NOT close existing tabs that aren't in the planned list — planned tabs are additive only
- If a planned name already has an open tab (same `customerName`), skip creating it
- The sync service already tracks `openTabs` — it sends them in the `SyncRequest` via `SnapshotTab`. You can get the current open tabs from the local database using `BarTabService.getOpenTabs()` or `BarTabService.openTabs` stream
- This logic should run inside the existing sync loop, after processing commands but before saving the cursor
### Testing
- Add "Jan" and "Marie" as planned names in the dashboard
- Run a sync on the Flutter app — tabs for "Jan" and "Marie" should appear
- Remove "Jan" from planned names and re-sync — the "Jan" tab should NOT be closed (planned tabs are additive only)
- Add a new name "Thomas" — a new tab should appear on next sync
### Files to Modify
- `lib/models/remote_command.dart` — add `plannedTabNames` field to `SyncResponse`
- `lib/services/remote_sync_service.dart` — add planned tab creation logic in sync loop
+9 -9
View File
@@ -61,12 +61,21 @@ importers:
passport-jwt:
specifier: ^4.0.1
version: 4.0.1
prisma:
specifier: ^5.10.0
version: 5.22.0
reflect-metadata:
specifier: ^0.2.1
version: 0.2.2
rxjs:
specifier: ^7.8.1
version: 7.8.2
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@20.19.43)(typescript@5.9.3)
typescript:
specifier: ^5.4.0
version: 5.9.3
uuid:
specifier: ^9.0.0
version: 9.0.1
@@ -101,9 +110,6 @@ importers:
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@20.19.43)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3))
prisma:
specifier: ^5.10.0
version: 5.22.0
rimraf:
specifier: ^5.0.5
version: 5.0.10
@@ -116,12 +122,6 @@ importers:
ts-loader:
specifier: ^9.5.1
version: 9.6.2(typescript@5.9.3)(webpack@5.97.1(postcss@8.5.28)(uglify-js@3.19.3))
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@20.19.43)(typescript@5.9.3)
typescript:
specifier: ^5.4.0
version: 5.9.3
apps/dashboard:
dependencies:
+43
View File
@@ -0,0 +1,43 @@
#!/bin/sh
# KoolTab Production Deploy
# Usage: ./scripts/deploy.sh "My Venue" admin@myvenue.com password
VENUE_NAME="${1:-${SETUP_VENUE_NAME}}"
ADMIN_EMAIL="${2:-${SETUP_ADMIN_EMAIL}}"
ADMIN_PASSWORD="${3:-${SETUP_ADMIN_PASSWORD}}"
if [ -z "$VENUE_NAME" ] || [ -z "$ADMIN_EMAIL" ] || [ -z "$ADMIN_PASSWORD" ]; then
echo "Usage: $0 \"Venue Name\" admin@example.com password"
echo " or: SETUP_VENUE_NAME=... SETUP_ADMIN_EMAIL=... SETUP_ADMIN_PASSWORD=... $0"
exit 1
fi
echo "=== Deploying KoolTab ==="
echo " Venue: $VENUE_NAME"
echo " Admin: $ADMIN_EMAIL"
echo ""
# Build and start containers
docker compose -f docker-compose.prod.yml up --build -d || { echo "Deploy failed"; exit 1; }
# Wait for API to be ready
echo "Waiting for API..."
until docker compose -f docker-compose.prod.yml exec api sh -c "node -e 'require(\"http\").get(\"http://localhost:3001/api/v1/health\",r=>{let b=\"\";r.on(\"data\",c=>b+=c);r.on(\"end\",()=>process.exit(b.includes(\"\\\"status\\\":\\\"ok\\\"\")?0:1))})'" 2>/dev/null; do
sleep 2
done
echo "✓ API is ready"
# Seed the database
echo ""
echo "Seeding database..."
docker compose -f docker-compose.prod.yml exec api sh -c "
SETUP_VENUE_NAME='$VENUE_NAME' \
SETUP_ADMIN_EMAIL='$ADMIN_EMAIL' \
SETUP_ADMIN_PASSWORD='$ADMIN_PASSWORD' \
node prisma/setup.js
" || { echo "Seed failed"; exit 1; }
echo ""
echo "=== Done ==="
echo " Dashboard: https://kooltab.com"
echo " Login: $ADMIN_EMAIL / $ADMIN_PASSWORD"
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
# KoolTab Production Setup
# Usage: ./scripts/setup.sh "My Venue" admin@myvenue.com mypassword
#
# Or with env vars:
# SETUP_VENUE_NAME="My Venue" SETUP_ADMIN_EMAIL="admin@myvenue.com" SETUP_ADMIN_PASSWORD="mypassword" ./scripts/setup.sh
VENUE_NAME="${SETUP_VENUE_NAME:-$1}"
ADMIN_EMAIL="${SETUP_ADMIN_EMAIL:-$2}"
ADMIN_PASSWORD="${SETUP_ADMIN_PASSWORD:-$3}"
if [ -z "$VENUE_NAME" ] || [ -z "$ADMIN_EMAIL" ] || [ -z "$ADMIN_PASSWORD" ]; then
echo "Usage: $0 \"Venue Name\" admin@example.com password"
echo " or: SETUP_VENUE_NAME=... SETUP_ADMIN_EMAIL=... SETUP_ADMIN_PASSWORD=... $0"
exit 1
fi
echo "=== KoolTab Production Setup ==="
echo ""
echo " Venue: $VENUE_NAME"
echo " Admin: $ADMIN_EMAIL"
echo ""
# Step 1: Apply database schema
echo "[1/3] Applying database schema..."
npx prisma db push --accept-data-loss 2>&1 || { echo "FAILED"; exit 1; }
echo " ✓ Schema applied"
# Step 2: Generate Prisma client
echo "[2/3] Generating Prisma client..."
npx prisma generate 2>&1 || { echo "FAILED"; exit 1; }
echo " ✓ Client generated"
# Step 3: Seed with your data
echo "[3/3] Seeding database..."
SETUP_VENUE_NAME="$VENUE_NAME" SETUP_ADMIN_EMAIL="$ADMIN_EMAIL" SETUP_ADMIN_PASSWORD="$ADMIN_PASSWORD" \
node prisma/setup.js || { echo "FAILED"; exit 1; }
echo ""
echo "Setup complete. Login at https://kooltab.com"