feat: add plannedTabs
This commit is contained in:
@@ -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"
|
||||
@@ -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
|
||||
@@ -141,4 +142,17 @@ model RefreshToken {
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@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])
|
||||
}
|
||||
@@ -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
|
||||
@@ -141,4 +142,17 @@ model RefreshToken {
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@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
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
Generated
+9
-9
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user