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
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (i.e. Git)
|
# It should be added in your version-control system (i.e. Git)
|
||||||
provider = "sqlite"
|
provider = "postgresql"
|
||||||
@@ -19,11 +19,12 @@ model Venue {
|
|||||||
remoteCommands RemoteCommand[]
|
remoteCommands RemoteCommand[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
regCodes RegistrationCode[]
|
regCodes RegistrationCode[]
|
||||||
|
plannedTabs PlannedTab[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Device {
|
model Device {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
venueId String
|
venueId String @unique
|
||||||
name String
|
name String
|
||||||
tokenHash String @unique
|
tokenHash String @unique
|
||||||
tokenPrefix String
|
tokenPrefix String
|
||||||
@@ -142,3 +143,16 @@ model RefreshToken {
|
|||||||
|
|
||||||
@@index([userId])
|
@@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[]
|
remoteCommands RemoteCommand[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
regCodes RegistrationCode[]
|
regCodes RegistrationCode[]
|
||||||
|
plannedTabs PlannedTab[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Device {
|
model Device {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
venueId String
|
venueId String @unique
|
||||||
name String
|
name String
|
||||||
tokenHash String @unique
|
tokenHash String @unique
|
||||||
tokenPrefix String
|
tokenPrefix String
|
||||||
@@ -142,3 +143,16 @@ model RefreshToken {
|
|||||||
|
|
||||||
@@index([userId])
|
@@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!');
|
console.log('Created viewer user: viewer@example.com / Viewer123!');
|
||||||
|
|
||||||
// Create devices
|
// Create one device
|
||||||
const device1Id = uuidv4();
|
const deviceId = uuidv4();
|
||||||
const device2Id = uuidv4();
|
const token = 'dev_' + crypto.randomBytes(48).toString('base64url');
|
||||||
|
|
||||||
const token1 = 'dev_' + crypto.randomBytes(48).toString('base64url');
|
|
||||||
const token2 = 'dev_' + crypto.randomBytes(48).toString('base64url');
|
|
||||||
|
|
||||||
await prisma.device.create({
|
await prisma.device.create({
|
||||||
data: {
|
data: {
|
||||||
id: device1Id,
|
id: deviceId,
|
||||||
venueId,
|
venueId,
|
||||||
name: 'Bar terminal 1',
|
name: 'Bar terminal 1',
|
||||||
tokenHash: crypto.createHash('sha256').update(token1).digest('hex'),
|
tokenHash: crypto.createHash('sha256').update(token).digest('hex'),
|
||||||
tokenPrefix: token1.substring(0, 8),
|
tokenPrefix: token.substring(0, 8),
|
||||||
tokenExpiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
|
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({
|
console.log(`Created device: Bar terminal 1 (${deviceId})`);
|
||||||
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 (${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 = [
|
const tabsData = [
|
||||||
{ tabId: uuidv4(), customerName: 'Jan', version: 5, totalInCents: 2450, itemCount: 3, reportedAt: new Date(Date.now() - 2 * 60 * 1000) },
|
{ 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: '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: '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) },
|
{ 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: '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) },
|
{ 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({
|
await prisma.tabOwnership.create({
|
||||||
data: {
|
data: { tabId: tab.tabId, venueId, deviceId, lastReportedAt: tab.reportedAt },
|
||||||
tabId: tab.tabId,
|
|
||||||
venueId,
|
|
||||||
deviceId: device2Id,
|
|
||||||
lastReportedAt: tab.reportedAt,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.openTabSnapshot.create({
|
await prisma.openTabSnapshot.create({
|
||||||
data: {
|
data: { deviceId, tabId: tab.tabId, customerName: tab.customerName, version: tab.version, totalInCents: tab.totalInCents, itemCount: tab.itemCount, reportedAt: tab.reportedAt },
|
||||||
deviceId: device2Id,
|
|
||||||
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
|
// Create sample commands
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -161,7 +101,7 @@ async function main() {
|
|||||||
data: {
|
data: {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
venueId,
|
venueId,
|
||||||
deviceId: device1Id,
|
deviceId,
|
||||||
targetTabId: tabsData[1].tabId,
|
targetTabId: tabsData[1].tabId,
|
||||||
type: 'close_tab',
|
type: 'close_tab',
|
||||||
expectedVersion: 3,
|
expectedVersion: 3,
|
||||||
@@ -180,7 +120,7 @@ async function main() {
|
|||||||
data: {
|
data: {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
venueId,
|
venueId,
|
||||||
deviceId: device1Id,
|
deviceId,
|
||||||
targetTabId: tabsData[0].tabId,
|
targetTabId: tabsData[0].tabId,
|
||||||
type: 'close_tab',
|
type: 'close_tab',
|
||||||
expectedVersion: 5,
|
expectedVersion: 5,
|
||||||
@@ -197,8 +137,8 @@ async function main() {
|
|||||||
data: {
|
data: {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
venueId,
|
venueId,
|
||||||
deviceId: device2Id,
|
deviceId,
|
||||||
targetTabId: staleTabs[0].tabId,
|
targetTabId: tabsData[4].tabId,
|
||||||
type: 'close_tab',
|
type: 'close_tab',
|
||||||
expectedVersion: 4,
|
expectedVersion: 4,
|
||||||
paymentMethod: 'payment_pending',
|
paymentMethod: 'payment_pending',
|
||||||
@@ -216,7 +156,7 @@ async function main() {
|
|||||||
data: {
|
data: {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
venueId,
|
venueId,
|
||||||
deviceId: device1Id,
|
deviceId,
|
||||||
targetTabId: tabsData[2].tabId,
|
targetTabId: tabsData[2].tabId,
|
||||||
type: 'close_tab',
|
type: 'close_tab',
|
||||||
expectedVersion: 7,
|
expectedVersion: 7,
|
||||||
@@ -249,9 +189,7 @@ async function main() {
|
|||||||
console.log('Demo credentials:');
|
console.log('Demo credentials:');
|
||||||
console.log(' Admin: admin@example.com / Admin123!');
|
console.log(' Admin: admin@example.com / Admin123!');
|
||||||
console.log(' Viewer: viewer@example.com / Viewer123!');
|
console.log(' Viewer: viewer@example.com / Viewer123!');
|
||||||
console.log(`\nDevice tokens (for simulator):`);
|
console.log(`\nDevice token (for simulator): ${token}`);
|
||||||
console.log(` Bar terminal 1: ${token1}`);
|
|
||||||
console.log(` Bar terminal 2: ${token2}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
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 { CommandsModule } from './commands/commands.module';
|
||||||
import { AuditModule } from './audit/audit.module';
|
import { AuditModule } from './audit/audit.module';
|
||||||
import { JobsModule } from './jobs/jobs.module';
|
import { JobsModule } from './jobs/jobs.module';
|
||||||
|
import { PlannedTabsModule } from './planned-tabs/planned-tabs.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -24,6 +25,7 @@ import { HealthController } from './health.controller';
|
|||||||
CommandsModule,
|
CommandsModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
JobsModule,
|
JobsModule,
|
||||||
|
PlannedTabsModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -132,6 +132,15 @@ export class AuthService {
|
|||||||
const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken();
|
const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken();
|
||||||
const deviceId = uuidv4();
|
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({
|
await this.prisma.device.create({
|
||||||
data: {
|
data: {
|
||||||
id: deviceId,
|
id: deviceId,
|
||||||
@@ -153,6 +162,33 @@ export class AuthService {
|
|||||||
return { deviceId, token: rawToken, tokenExpiresAt: expiresAt };
|
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> {
|
async validateDevice(deviceId: string, token: string): Promise<any> {
|
||||||
const hash = crypto.createHash('sha256').update(token).digest('hex');
|
const hash = crypto.createHash('sha256').update(token).digest('hex');
|
||||||
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
|
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
|
||||||
|
|||||||
@@ -142,6 +142,13 @@ export class DevicesService {
|
|||||||
orderBy: { createdAt: 'asc' },
|
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 {
|
return {
|
||||||
cursor,
|
cursor,
|
||||||
commands: commands.map((c) => ({
|
commands: commands.map((c) => ({
|
||||||
@@ -153,6 +160,7 @@ export class DevicesService {
|
|||||||
requestedBy: c.requestedBy,
|
requestedBy: c.requestedBy,
|
||||||
createdAt: c.createdAt,
|
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 { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { VenuesService } from './venues.service';
|
import { VenuesService } from './venues.service';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
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';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
@ApiTags('Venues')
|
@ApiTags('Venues')
|
||||||
@@ -9,6 +12,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
export class VenuesController {
|
export class VenuesController {
|
||||||
constructor(
|
constructor(
|
||||||
private venuesService: VenuesService,
|
private venuesService: VenuesService,
|
||||||
|
private authService: AuthService,
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -26,24 +30,41 @@ export class VenuesController {
|
|||||||
return this.venuesService.findOne(venueId);
|
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')
|
@Get(':venueId/devices')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiOperation({ summary: 'List devices for a venue' })
|
@ApiOperation({ summary: 'Get venue device info' })
|
||||||
async findDevices(@Param('venueId') venueId: string) {
|
async findDevices(@Param('venueId') venueId: string) {
|
||||||
const devices = await this.prisma.device.findMany({
|
const device = await this.prisma.device.findFirst({
|
||||||
where: { venueId },
|
where: { venueId, revokedAt: null },
|
||||||
select: {
|
select: { id: true, name: true, lastSeenAt: true, createdAt: true },
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
lastSeenAt: true,
|
|
||||||
revokedAt: true,
|
|
||||||
createdAt: true,
|
|
||||||
tokenExpiresAt: true,
|
|
||||||
tokenPrefix: true,
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
});
|
});
|
||||||
return { devices };
|
return { device };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { VenuesController } from './venues.controller';
|
import { VenuesController } from './venues.controller';
|
||||||
import { VenuesService } from './venues.service';
|
import { VenuesService } from './venues.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
controllers: [VenuesController],
|
controllers: [VenuesController],
|
||||||
providers: [VenuesService],
|
providers: [VenuesService],
|
||||||
exports: [VenuesService],
|
exports: [VenuesService],
|
||||||
|
|||||||
@@ -87,26 +87,22 @@ export default function CommandsPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="rounded-md border mb-4">
|
<div className="rounded-md border mb-4">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Tab</TableHead>
|
<TableHead>Tab</TableHead>
|
||||||
<TableHead>Customer</TableHead>
|
<TableHead>Requested By</TableHead>
|
||||||
<TableHead>Device</TableHead>
|
<TableHead>Payment</TableHead>
|
||||||
<TableHead>Requested By</TableHead>
|
<TableHead>Created</TableHead>
|
||||||
<TableHead>Payment</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>Created</TableHead>
|
<TableHead>Processed</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Result</TableHead>
|
||||||
<TableHead>Processed</TableHead>
|
</TableRow>
|
||||||
<TableHead>Result</TableHead>
|
</TableHeader>
|
||||||
</TableRow>
|
<TableBody>
|
||||||
</TableHeader>
|
{commands.map((cmd: any) => (
|
||||||
<TableBody>
|
<TableRow key={cmd.id}>
|
||||||
{commands.map((cmd: any) => (
|
<TableCell className="font-mono text-xs">{cmd.tabId.substring(0, 8)}</TableCell>
|
||||||
<TableRow key={cmd.id}>
|
<TableCell>{cmd.requestedBy}</TableCell>
|
||||||
<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>
|
|
||||||
<TableCell><PaymentMethodBadge method={cmd.paymentMethod} /></TableCell>
|
<TableCell><PaymentMethodBadge method={cmd.paymentMethod} /></TableCell>
|
||||||
<TableCell>{formatRelativeTime(cmd.createdAt)}</TableCell>
|
<TableCell>{formatRelativeTime(cmd.createdAt)}</TableCell>
|
||||||
<TableCell><CommandStatusBadge status={cmd.status} /></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 = [
|
const navItems = [
|
||||||
{ href: '/dashboard', label: 'Overview', icon: LayoutDashboard },
|
{ href: '/dashboard', label: 'Overview', icon: LayoutDashboard },
|
||||||
{ href: '/dashboard/tabs', label: 'Open Tabs', icon: ClipboardList },
|
{ 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/commands', label: 'Command History', icon: Terminal },
|
||||||
{ href: '/dashboard/settings', label: 'Settings', icon: Settings },
|
{ 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 { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { LoadingState } from '@/components/loading-state';
|
import { LoadingState } from '@/components/loading-state';
|
||||||
import { CommandStatusBadge } from '@/components/command-status-badge';
|
import { CommandStatusBadge } from '@/components/command-status-badge';
|
||||||
import { DeviceStatusBadge } from '@/components/device-status-badge';
|
import { formatRelativeTime } from '@/lib/utils';
|
||||||
import { formatRelativeTime, formatCents } from '@/lib/utils';
|
import { ClipboardList, Terminal, Monitor } from 'lucide-react';
|
||||||
import { getDeviceStatus } from '@/lib/device-status';
|
|
||||||
import { ClipboardList, Monitor, Terminal, AlertTriangle } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -37,8 +35,8 @@ export default function DashboardPage() {
|
|||||||
refetchInterval: 10000,
|
refetchInterval: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const devicesQuery = useQuery({
|
const deviceQuery = useQuery({
|
||||||
queryKey: ['devices', venueId],
|
queryKey: ['device', venueId],
|
||||||
queryFn: () => api(`/venues/${venueId}/devices`),
|
queryFn: () => api(`/venues/${venueId}/devices`),
|
||||||
enabled: !!venueId,
|
enabled: !!venueId,
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
@@ -54,64 +52,39 @@ export default function DashboardPage() {
|
|||||||
if (!venueId) return <LoadingState />;
|
if (!venueId) return <LoadingState />;
|
||||||
|
|
||||||
const tabs = tabsQuery.data?.tabs || [];
|
const tabs = tabsQuery.data?.tabs || [];
|
||||||
const devices = devicesQuery.data?.devices || [];
|
const device = deviceQuery.data?.device;
|
||||||
const commands = commandsQuery.data?.commands || [];
|
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 pendingCommands = commands.filter((c: any) => c.status === 'pending');
|
||||||
const commandsToday = commands.length;
|
const isLoading = tabsQuery.isLoading || deviceQuery.isLoading || commandsQuery.isLoading;
|
||||||
|
|
||||||
const isLoading = tabsQuery.isLoading || devicesQuery.isLoading || commandsQuery.isLoading;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader title="Overview" description="Real-time overview of your venue" />
|
||||||
title="Overview"
|
|
||||||
description="Real-time overview of your venue"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<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">
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<Card key={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>
|
||||||
<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>
|
||||||
) : (
|
) : (
|
||||||
<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>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Open Tabs</CardTitle>
|
<CardTitle className="text-sm font-medium">Open Tabs</CardTitle>
|
||||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent><div className="text-2xl font-bold">{tabs.length}</div></CardContent>
|
||||||
<div className="text-2xl font-bold">{tabs.length}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Online Devices</CardTitle>
|
<CardTitle className="text-sm font-medium">Device</CardTitle>
|
||||||
<Monitor className="h-4 w-4 text-green-600" />
|
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-green-600">{onlineDevices.length}</div>
|
<div className="text-2xl font-bold">{device?.name || 'Not connected'}</div>
|
||||||
</CardContent>
|
{device && <p className="text-xs text-muted-foreground mt-1">Last seen {formatRelativeTime(device.lastSeenAt)}</p>}
|
||||||
</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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -119,60 +92,34 @@ export default function DashboardPage() {
|
|||||||
<CardTitle className="text-sm font-medium">Pending Commands</CardTitle>
|
<CardTitle className="text-sm font-medium">Pending Commands</CardTitle>
|
||||||
<Terminal className="h-4 w-4 text-muted-foreground" />
|
<Terminal className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent><div className="text-2xl font-bold">{pendingCommands.length}</div></CardContent>
|
||||||
<div className="text-2xl font-bold">{pendingCommands.length}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<Card>
|
||||||
<Card>
|
<CardHeader><CardTitle className="text-lg">Recent Commands</CardTitle></CardHeader>
|
||||||
<CardHeader>
|
<CardContent>
|
||||||
<CardTitle className="text-lg">Recent Commands</CardTitle>
|
{commands.length === 0 ? (
|
||||||
</CardHeader>
|
<p className="text-sm text-muted-foreground">No commands yet</p>
|
||||||
<CardContent>
|
) : (
|
||||||
{commands.length === 0 ? (
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-muted-foreground">No commands yet</p>
|
{commands.map((cmd: any) => (
|
||||||
) : (
|
<div key={cmd.id} className="flex items-center justify-between text-sm">
|
||||||
<div className="space-y-3">
|
<div>
|
||||||
{commands.map((cmd: any) => (
|
<span className="font-medium">{cmd.tabId?.substring(0, 8)}</span>
|
||||||
<div key={cmd.id} className="flex items-center justify-between text-sm">
|
<span className="text-muted-foreground ml-2">{cmd.requestedBy}</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<CommandStatusBadge status={cmd.status} />
|
||||||
)}
|
<span className="text-muted-foreground">{formatRelativeTime(cmd.createdAt)}</span>
|
||||||
</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>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,66 +1,168 @@
|
|||||||
'use client';
|
'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 { 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 { 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() {
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader title="Settings" description="Venue and account settings" />
|
<PageHeader title="Settings" description="Venue and device management" />
|
||||||
|
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Venue Information</CardTitle>
|
<CardTitle>Venue</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 text-sm">
|
<CardContent className="text-sm">
|
||||||
<div className="flex justify-between">
|
<p className="font-medium">{venue?.name || 'Loading...'}</p>
|
||||||
<span className="text-muted-foreground">Venue name</span>
|
<p className="text-muted-foreground font-mono text-xs mt-1">ID: {venueId}</p>
|
||||||
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<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>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 text-sm">
|
<CardContent>
|
||||||
<div className="p-3 bg-muted rounded-md">
|
{device ? (
|
||||||
<p className="font-medium mb-1">Admin account</p>
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<p className="text-muted-foreground">admin@example.com / Admin123!</p>
|
<Monitor className="h-4 w-4 text-green-600" />
|
||||||
</div>
|
<Badge variant="success">Connected</Badge>
|
||||||
<div className="p-3 bg-muted rounded-md">
|
<span className="text-muted-foreground">Since {formatDateTime(device.createdAt)}</span>
|
||||||
<p className="font-medium mb-1">Viewer account</p>
|
</div>
|
||||||
<p className="text-muted-foreground">viewer@example.com / Viewer123!</p>
|
) : (
|
||||||
</div>
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
Pair a bar terminal to start receiving tabs.
|
||||||
These credentials are for demo purposes only. Do not use in production.
|
</p>
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>API Information</CardTitle>
|
<CardTitle>API</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 text-sm">
|
<CardContent className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<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>
|
<span className="font-mono text-xs">{process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Swagger 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>
|
<span className="font-mono text-xs">
|
||||||
|
{process.env.NEXT_PUBLIC_API_URL?.replace('/api/v1', '/api/docs') || 'http://localhost:3001/api/docs'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,23 +3,21 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
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 { toast } from '@/components/ui/toast';
|
||||||
import { formatCents, formatRelativeTime } from '@/lib/utils';
|
import { formatCents, formatRelativeTime } from '@/lib/utils';
|
||||||
import { getDeviceStatus } from '@/lib/device-status';
|
import { RefreshCw, Plus, X, Users } from 'lucide-react';
|
||||||
import { RefreshCw, XCircle, AlertTriangle } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function TabsPage() {
|
export default function TabsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -29,17 +27,15 @@ export default function TabsPage() {
|
|||||||
const [paymentMethod, setPaymentMethod] = useState<string>('');
|
const [paymentMethod, setPaymentMethod] = useState<string>('');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [pendingCommandIds, setPendingCommandIds] = useState<Set<string>>(new Set());
|
const [pendingCommandIds, setPendingCommandIds] = useState<Set<string>>(new Set());
|
||||||
|
const [plannedName, setPlannedName] = useState('');
|
||||||
|
const [userRole, setUserRole] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cookie = document.cookie
|
const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token='));
|
||||||
.split('; ')
|
if (!cookie) { router.push('/login'); return; }
|
||||||
.find((row) => row.startsWith('access_token='));
|
|
||||||
if (!cookie) {
|
|
||||||
router.push('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const payload = JSON.parse(atob(cookie.split('.')[1]));
|
const payload = JSON.parse(atob(cookie.split('.')[1]));
|
||||||
setVenueId(payload.venueId);
|
setVenueId(payload.venueId);
|
||||||
|
setUserRole(payload.role);
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
@@ -49,7 +45,6 @@ export default function TabsPage() {
|
|||||||
refetchInterval: 10000,
|
refetchInterval: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Poll pending commands to check their status
|
|
||||||
const commandsQuery = useQuery({
|
const commandsQuery = useQuery({
|
||||||
queryKey: ['commands', venueId, 'pending'],
|
queryKey: ['commands', venueId, 'pending'],
|
||||||
queryFn: () => api(`/venues/${venueId}/commands?status=pending`),
|
queryFn: () => api(`/venues/${venueId}/commands?status=pending`),
|
||||||
@@ -57,6 +52,12 @@ export default function TabsPage() {
|
|||||||
refetchInterval: 3000,
|
refetchInterval: 3000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const plannedQuery = useQuery({
|
||||||
|
queryKey: ['planned-tabs', venueId],
|
||||||
|
queryFn: () => plannedTabsApi.list(venueId!),
|
||||||
|
enabled: !!venueId,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (commandsQuery.data?.commands) {
|
if (commandsQuery.data?.commands) {
|
||||||
for (const cmd of commandsQuery.data.commands) {
|
for (const cmd of commandsQuery.data.commands) {
|
||||||
@@ -64,11 +65,7 @@ export default function TabsPage() {
|
|||||||
const newSet = new Set(pendingCommandIds);
|
const newSet = new Set(pendingCommandIds);
|
||||||
newSet.delete(cmd.id);
|
newSet.delete(cmd.id);
|
||||||
setPendingCommandIds(newSet);
|
setPendingCommandIds(newSet);
|
||||||
toast({
|
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' });
|
||||||
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: ['tabs'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['commands'] });
|
queryClient.invalidateQueries({ queryKey: ['commands'] });
|
||||||
}
|
}
|
||||||
@@ -81,44 +78,41 @@ export default function TabsPage() {
|
|||||||
commandsApi.create(venueId!, { ...data, type: 'close_tab' }),
|
commandsApi.create(venueId!, { ...data, type: 'close_tab' }),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setPendingCommandIds(new Set([...pendingCommandIds, result.id]));
|
setPendingCommandIds(new Set([...pendingCommandIds, result.id]));
|
||||||
toast({
|
toast({ title: 'Close request sent', description: result.warning || 'Command sent to the terminal', variant: result.warning ? 'default' : 'success' });
|
||||||
title: 'Close request sent',
|
|
||||||
description: result.warning || 'Command has been created and sent to the device',
|
|
||||||
variant: result.warning ? 'default' : 'success',
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['commands'] });
|
queryClient.invalidateQueries({ queryKey: ['commands'] });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
toast({
|
toast({ title: 'Failed', description: err.message || 'An error occurred', variant: 'destructive' });
|
||||||
title: 'Failed to create command',
|
|
||||||
description: err.message || 'An error occurred',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleCloseTab(tab: any) {
|
|
||||||
setCloseDialog({ open: true, tab });
|
|
||||||
setPaymentMethod('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmClose() {
|
function confirmClose() {
|
||||||
if (!paymentMethod) return;
|
if (!paymentMethod) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
createCommandMutation.mutate(
|
createCommandMutation.mutate({ tabId: closeDialog.tab.tabId, expectedVersion: closeDialog.tab.version, paymentMethod }, {
|
||||||
{
|
onSettled: () => { setCreating(false); setCloseDialog({ open: false, tab: null }); setPaymentMethod(''); },
|
||||||
tabId: closeDialog.tab.tabId,
|
});
|
||||||
expectedVersion: closeDialog.tab.version,
|
}
|
||||||
paymentMethod,
|
|
||||||
},
|
async function addPlannedName() {
|
||||||
{
|
if (!plannedName.trim()) return;
|
||||||
onSettled: () => {
|
try {
|
||||||
setCreating(false);
|
await plannedTabsApi.create(venueId!, plannedName.trim());
|
||||||
setCloseDialog({ open: false, tab: null });
|
setPlannedName('');
|
||||||
setPaymentMethod('');
|
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 />;
|
if (!venueId) return <LoadingState />;
|
||||||
@@ -131,21 +125,53 @@ export default function TabsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Open Tabs"
|
title="Open Tabs"
|
||||||
description={`${tabs.length} open tab${tabs.length !== 1 ? 's' : ''} across all devices`}
|
description={`${tabs.length} open tab${tabs.length !== 1 ? 's' : ''}`}
|
||||||
actions={
|
actions={<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['tabs'] })}><RefreshCw className="h-4 w-4 mr-2" />Refresh</Button>}
|
||||||
<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 ? (
|
{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">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
@@ -154,117 +180,53 @@ export default function TabsPage() {
|
|||||||
<TableHead>Customer</TableHead>
|
<TableHead>Customer</TableHead>
|
||||||
<TableHead>Amount</TableHead>
|
<TableHead>Amount</TableHead>
|
||||||
<TableHead>Items</TableHead>
|
<TableHead>Items</TableHead>
|
||||||
<TableHead>Device</TableHead>
|
|
||||||
<TableHead>Device Status</TableHead>
|
|
||||||
<TableHead>Version</TableHead>
|
<TableHead>Version</TableHead>
|
||||||
<TableHead>Last Reported</TableHead>
|
<TableHead>Last Reported</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{tabs.map((tab: any) => {
|
{tabs.map((tab: any) => (
|
||||||
const deviceStale = getDeviceStatus(tab.deviceLastSeenAt, tab.deviceRevokedAt) === 'stale'
|
<TableRow key={tab.tabId}>
|
||||||
|| getDeviceStatus(tab.deviceLastSeenAt, tab.deviceRevokedAt) === 'offline';
|
<TableCell className="font-medium">{tab.customerName}</TableCell>
|
||||||
return (
|
<TableCell>{formatCents(tab.totalInCents)}</TableCell>
|
||||||
<TableRow key={tab.tabId}>
|
<TableCell>{tab.itemCount}</TableCell>
|
||||||
<TableCell className="font-medium">{tab.customerName}</TableCell>
|
<TableCell>{tab.version}</TableCell>
|
||||||
<TableCell>{formatCents(tab.totalInCents)}</TableCell>
|
<TableCell>{formatRelativeTime(tab.lastReportedAt)}</TableCell>
|
||||||
<TableCell>{tab.itemCount}</TableCell>
|
<TableCell className="text-right">
|
||||||
<TableCell>{tab.deviceName}</TableCell>
|
<Button size="sm" onClick={() => { setCloseDialog({ open: true, tab }); setPaymentMethod(''); }}>Close</Button>
|
||||||
<TableCell>
|
</TableCell>
|
||||||
<DeviceStatusBadge lastSeenAt={tab.deviceLastSeenAt} revokedAt={tab.deviceRevokedAt} />
|
</TableRow>
|
||||||
</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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Close Tab Dialog */}
|
|
||||||
{closeDialog.tab && (
|
{closeDialog.tab && (
|
||||||
<Dialog open={closeDialog.open} onOpenChange={(open) => !open && setCloseDialog({ open: false, tab: null })}>
|
<Dialog open={closeDialog.open} onOpenChange={(open) => !open && setCloseDialog({ open: false, tab: null })}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Close {closeDialog.tab.customerName}'s tab?</DialogTitle>
|
<DialogTitle>Close {closeDialog.tab.customerName}'s tab?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>Send a close request to the terminal.</DialogDescription>
|
||||||
This will send a close request to the bar terminal.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3 py-4">
|
<div className="space-y-3 py-4">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Amount</span><span className="font-semibold">{formatCents(closeDialog.tab.totalInCents)}</span></div>
|
||||||
<span className="text-muted-foreground">Amount</span>
|
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Items</span><span>{closeDialog.tab.itemCount}</span></div>
|
||||||
<span className="font-semibold">{formatCents(closeDialog.tab.totalInCents)}</span>
|
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Version</span><span>{closeDialog.tab.version}</span></div>
|
||||||
</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="space-y-3 pt-2">
|
<div className="space-y-3 pt-2">
|
||||||
<Label className="text-base font-semibold">Payment method</Label>
|
<Label className="text-base font-semibold">Payment method</Label>
|
||||||
<RadioGroup value={paymentMethod} onValueChange={setPaymentMethod}>
|
<RadioGroup value={paymentMethod} onValueChange={setPaymentMethod}>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2"><RadioGroupItem value="cash" id="cash" /><Label htmlFor="cash">Cash</Label></div>
|
||||||
<RadioGroupItem value="cash" id="cash" />
|
<div className="flex items-center space-x-2"><RadioGroupItem value="payconiq" id="payconiq" /><Label htmlFor="payconiq">Payconiq</Label></div>
|
||||||
<Label htmlFor="cash">Cash</Label>
|
<div className="flex items-center space-x-2"><RadioGroupItem value="payment_pending" id="payment_pending" /><Label htmlFor="payment_pending">Payment pending</Label></div>
|
||||||
</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>
|
</RadioGroup>
|
||||||
{!paymentMethod && (
|
{!paymentMethod && <p className="text-xs text-destructive">Please select a payment method</p>}
|
||||||
<p className="text-xs text-destructive">Please select a payment method</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button variant="outline" onClick={() => setCloseDialog({ open: false, tab: null })} disabled={creating}>Cancel</Button>
|
||||||
variant="outline"
|
<Button onClick={confirmClose} disabled={creating || !paymentMethod}>{creating ? 'Sending...' : 'Send close request'}</Button>
|
||||||
onClick={() => setCloseDialog({ open: false, tab: null })}
|
|
||||||
disabled={creating}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={confirmClose}
|
|
||||||
disabled={creating || !paymentMethod}
|
|
||||||
>
|
|
||||||
{creating ? 'Sending...' : 'Send close request'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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
|
// Devices API
|
||||||
export const devicesApi = {
|
export const devicesApi = {
|
||||||
list: (venueId: string) =>
|
get: (venueId: string) =>
|
||||||
api<{ devices: any[] }>(`/venues/${venueId}/devices`),
|
api<{ device: { id: string; name: string; lastSeenAt: string; createdAt: string } | null }>(`/venues/${venueId}/devices`),
|
||||||
revoke: (venueId: string, deviceId: string) =>
|
|
||||||
api(`/venues/${venueId}/devices/${deviceId}/revoke`, { method: 'POST' }),
|
|
||||||
generateCode: () => api<{ code: string; expiresAt: string }>('/devices/generation-code'),
|
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
|
// Commands API
|
||||||
export const commandsApi = {
|
export const commandsApi = {
|
||||||
list: (venueId: string, params?: { status?: string; limit?: number; offset?: number }) => {
|
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}`),
|
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 };
|
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:
|
passport-jwt:
|
||||||
specifier: ^4.0.1
|
specifier: ^4.0.1
|
||||||
version: 4.0.1
|
version: 4.0.1
|
||||||
|
prisma:
|
||||||
|
specifier: ^5.10.0
|
||||||
|
version: 5.22.0
|
||||||
reflect-metadata:
|
reflect-metadata:
|
||||||
specifier: ^0.2.1
|
specifier: ^0.2.1
|
||||||
version: 0.2.2
|
version: 0.2.2
|
||||||
rxjs:
|
rxjs:
|
||||||
specifier: ^7.8.1
|
specifier: ^7.8.1
|
||||||
version: 7.8.2
|
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:
|
uuid:
|
||||||
specifier: ^9.0.0
|
specifier: ^9.0.0
|
||||||
version: 9.0.1
|
version: 9.0.1
|
||||||
@@ -101,9 +110,6 @@ importers:
|
|||||||
jest:
|
jest:
|
||||||
specifier: ^29.7.0
|
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))
|
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:
|
rimraf:
|
||||||
specifier: ^5.0.5
|
specifier: ^5.0.5
|
||||||
version: 5.0.10
|
version: 5.0.10
|
||||||
@@ -116,12 +122,6 @@ importers:
|
|||||||
ts-loader:
|
ts-loader:
|
||||||
specifier: ^9.5.1
|
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))
|
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:
|
apps/dashboard:
|
||||||
dependencies:
|
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