Complete Cafe admin table and happy-hour CRUD

This commit is contained in:
Ho Ngoc Hai
2026-06-05 20:14:04 +07:00
parent 1e861d800a
commit c4092f6a0d
12 changed files with 184 additions and 13 deletions

View File

@@ -0,0 +1,28 @@
# Cafe Admin Table/QR/Happy-Hour Fixes - Port 3020
Chrome test target: `http://localhost:3020`
Shop: `GoodGo Cafe MVP` (`0bc5ab7c-0b6d-4746-a2ef-c61307b90199`)
## Workflow Screenshots
1. `01-admin-tables-crud-port-3020.png`
- `/admin/shop/:shopId/tables`
- Confirms the Cafe admin route is no longer 404.
- Creates table `QA-920111` through the admin CRUD form.
2. `02-admin-qr-regenerate-port-3020.png`
- `/admin/shop/:shopId/qr-codes`
- Selects `QR bàn QA-920111`.
- Regenerates QR from `/table/ca767ce906b94401` to `/table/c1b3754b580e419e`.
3. `03-admin-happy-hour-crud-port-3020.png`
- `/admin/shop/:shopId/happy-hour`
- Confirms route and campaign-backed CRUD console.
- Creates campaign `Happy QA 81191`.
4. `04-pos-queue-display-port-3020.png`
- `/pos/:shopId/cafe/queue-display`
- Confirms the active queue lane title is `Chờ pha / đang pha`, matching tickets that are still `Chờ pha`.
Structured Chrome results are stored in `chrome-table-qr-results.json`.

View File

@@ -0,0 +1,43 @@
{
"baseUrl": "http://localhost:3020",
"shopId": "0bc5ab7c-0b6d-4746-a2ef-c61307b90199",
"tableNumber": "QA-920111",
"happyHourName": "Happy QA 81191",
"checks": {
"tablesRoute": {
"url": "http://localhost:3020/admin/shop/0bc5ab7c-0b6d-4746-a2ef-c61307b90199/tables",
"title": "Bàn/phòng",
"hasCrud": true,
"createdVisible": true,
"successMessage": "Tạo mới thành công"
},
"qrRoute": {
"url": "http://localhost:3020/admin/shop/0bc5ab7c-0b6d-4746-a2ef-c61307b90199/qr-codes",
"title": "QR bàn/phòng",
"selectedText": "QR bàn QA-920111 · Bàn/phòng",
"beforeToken": "/table/ca767ce906b94401",
"afterToken": "/table/c1b3754b580e419e",
"tokenChanged": true,
"successMessage": "Tạo lại QR thành công"
},
"happyHourRoute": {
"url": "http://localhost:3020/admin/shop/0bc5ab7c-0b6d-4746-a2ef-c61307b90199/happy-hour",
"title": "Khung giờ ưu đãi",
"hasCrud": true,
"createdVisible": true,
"successMessage": "Tạo mới thành công"
},
"queueDisplay": {
"url": "http://localhost:3020/pos/0bc5ab7c-0b6d-4746-a2ef-c61307b90199/cafe/queue-display",
"title": "Màn hình chờ lấy nước",
"activeLaneTitle": "Chờ pha / đang pha",
"titleMatchesPendingTickets": true
}
},
"files": {
"tablesCrud": "/Users/velikho/Desktop/WORKING/pos-system/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/22-admin-table-qr-fixes/01-admin-tables-crud-port-3020.png",
"qrRegenerate": "/Users/velikho/Desktop/WORKING/pos-system/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/22-admin-table-qr-fixes/02-admin-qr-regenerate-port-3020.png",
"happyHourCrud": "/Users/velikho/Desktop/WORKING/pos-system/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/22-admin-table-qr-fixes/03-admin-happy-hour-crud-port-3020.png",
"queueDisplay": "/Users/velikho/Desktop/WORKING/pos-system/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/22-admin-table-qr-fixes/04-pos-queue-display-port-3020.png"
}
}

View File

@@ -371,7 +371,7 @@ async function loadItems(section: string, shop?: Shop | null, allowedShopIds?: s
id: String(row.id),
kind: "leave-request",
title: String(row.reason ?? "Nghỉ phép"),
meta: `${formatDate(row.from_date)} - ${formatDate(row.to_date)} · ${`${row.first_name ?? "Nhân viên"} ${row.last_name ?? ""}`.trim()}`,
meta: `${formatDateOnly(row.from_date)} - ${formatDateOnly(row.to_date)} · ${`${row.first_name ?? "Nhân viên"} ${row.last_name ?? ""}`.trim()}`,
value: displayStatus(row.status),
edit: {
staffId: String(row.staff_id ?? ""),
@@ -405,7 +405,18 @@ async function loadItems(section: string, shop?: Shop | null, allowedShopIds?: s
if (section === "promotions" || section === "happy-hour") {
const campaigns = await listCampaigns(shopId);
return campaigns
.map((item) => ({ id: String(item.id), kind: "campaign", title: String(item.name), meta: String(item.description ?? "Chiến dịch"), value: displayStatus(item.status) }));
.map((item) => ({
id: String(item.id),
kind: "campaign",
title: String(item.name),
meta: String(item.description ?? "Chiến dịch"),
value: displayStatus(item.status),
edit: {
description: String(item.description ?? ""),
amount: String(item.discount_value ?? item.face_value ?? ""),
status: String(item.status ?? "draft")
}
}));
}
if (section === "appointments" || section === "spa/appointments") {
const appointments = shopId ? await listAppointments(shopId) : [];
@@ -623,6 +634,10 @@ function displayStatus(value: unknown, fallback = "Chưa cấu hình") {
draft: "Đang thiết lập",
published: "Đang hoạt động",
pending: "Chờ xử lý",
checked_in: "Đã check-in",
checked_out: "Đã check-out",
approved: "Đã duyệt",
rejected: "Từ chối",
current: "Đang làm",
next: "Tiếp theo",
done: "Đã xong",
@@ -715,7 +730,11 @@ function isKnownAdminSection(section: string, shop?: Shop | null) {
"doctors",
"history",
"orders",
"tables",
"rooms",
"zones",
"qr-codes",
"happy-hour",
"reports/eod",
"reports/revenue",
"reports/staff"
@@ -791,6 +810,14 @@ function formatDate(value: unknown) {
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("vi-VN");
}
function formatDateOnly(value: unknown) {
if (!value) return "";
const date = new Date(String(value));
return Number.isNaN(date.getTime())
? String(value)
: date.toLocaleDateString("vi-VN", { day: "2-digit", month: "2-digit", year: "numeric" });
}
function formatReportDay(value: unknown) {
if (!value) return "";
const date = new Date(String(value));

View File

@@ -562,7 +562,7 @@ export function WorkflowScreen({
)) : <div className="customer-queue-empty">Chưa món sẵn sàng</div>}
</div>
<div className="customer-queue-display__lane">
<h2>Đang pha chế</h2>
<h2>Chờ pha / đang pha</h2>
{activeItems.length ? activeItems.map((item) => (
<article key={String(item.id)} className="customer-queue-ticket">
<span>{pickupNumberFromValue(item.order_id ?? item.id)}</span>

View File

@@ -114,6 +114,7 @@ const crudSections = new Set([
"staff",
"customers",
"promotions",
"happy-hour",
"drive",
"receipt-templates",
"ai-chat"
@@ -526,8 +527,8 @@ function AdminCrudConsole({ section, shop, vertical, items }: { section: string;
const amountName = selectedKind === "campaign" ? "discountValue" : selectedKind === "member" ? "points" : selectedKind === "inventory" ? "costPerUnit" : selectedKind === "file" ? "byteSize" : selectedKind === "table" ? "hourlyRate" : "price";
const statusName = selectedKind === "staff" ? "role" : selectedKind === "inventory" ? "unit" : selectedKind === "file" ? "accessLevel" : selectedKind === "table" ? "statusId" : "status";
const descriptionLabel = selectedKind === "file" ? "MIME type" : selectedKind === "folder" ? "ID thư mục cha" : selectedKind === "table" ? "Khu vực" : "Mô tả / ghi chú";
const amountLabel = selectedKind === "file" ? "Dung lượng byte" : selectedKind === "table" ? "Giá theo giờ" : "Giá / chi phí / điểm";
const statusLabel = selectedKind === "file" ? "Quyền truy cập" : selectedKind === "folder" ? "Trạng thái" : selectedKind === "table" ? "Trạng thái bàn/phòng" : "Trạng thái / vai trò / đơn vị";
const amountLabel = selectedKind === "file" ? "Dung lượng byte" : selectedKind === "table" ? "Giá theo giờ" : selectedKind === "campaign" ? "Giá trị giảm" : "Giá / chi phí / điểm";
const statusLabel = selectedKind === "file" ? "Quyền truy cập" : selectedKind === "folder" ? "Trạng thái" : selectedKind === "table" ? "Trạng thái bàn/phòng" : selectedKind === "campaign" ? "Trạng thái" : "Trạng thái / vai trò / đơn vị";
const deleteLabel = selectedKind === "file" || selectedKind === "folder" ? "Ẩn metadata" : selectedKind === "receipt-template" ? "Tắt mẫu" : "Xóa / tắt";
const canEditAmountAndStatus = selectedKind !== "folder";
const categoryItems = items.filter((item) => item.kind === "category" && item.id);
@@ -623,6 +624,44 @@ function AdminCrudConsole({ section, shop, vertical, items }: { section: string;
<BooleanFormToggle name="isActive" label="Đang hiển thị" defaultChecked={selectedItem?.edit?.isActive ?? true} />
</div>
</>
) : selectedKind === "table" ? (
<>
<div className="admin-form-grid">
<label className="admin-form-group">
<span className="admin-form-label">{vertical === "karaoke" ? "Số phòng" : "Số bàn"}</span>
<input className="admin-form-input" name="tableNumber" defaultValue={selectedItem?.edit?.tableNumber ?? selectedItem?.title.replace(/^(Bàn|Phòng|QR bàn|QR phòng)\s+/i, "") ?? ""} />
</label>
<label className="admin-form-group">
<span className="admin-form-label">Sức chứa</span>
<input className="admin-form-input" name="capacity" inputMode="numeric" defaultValue={selectedItem?.edit?.capacity ?? "2"} />
</label>
</div>
<div className="admin-form-grid">
<label className="admin-form-group">
<span className="admin-form-label">Khu vực</span>
<input className="admin-form-input" name="zone" defaultValue={selectedItem?.edit?.zone ?? ""} placeholder="Tầng 1 / Khu chính" />
</label>
<label className="admin-form-group">
<span className="admin-form-label">{vertical === "karaoke" ? "Giá phòng/giờ" : "Giá theo giờ"}</span>
<input className="admin-form-input" name="hourlyRate" inputMode="numeric" defaultValue={selectedItem?.edit?.hourlyRate ?? "0"} />
</label>
</div>
<div className="admin-form-grid">
<label className="admin-form-group">
<span className="admin-form-label">Trạng thái</span>
<select className="admin-form-input" name="statusId" defaultValue={selectedItem?.edit?.statusId ?? "1"}>
<option value="1">Trống</option>
<option value="2">Đang dùng</option>
<option value="3">Đã đt</option>
<option value="4">Tạm khóa</option>
</select>
</label>
<label className="admin-form-group">
<span className="admin-form-label">QR token</span>
<input className="admin-form-input" value={selectedItem?.edit?.qrToken ? `/table/${selectedItem.edit.qrToken}` : "Chưa tạo"} readOnly />
</label>
</div>
</>
) : selectedKind === "receipt-template" ? (
<>
<div className="admin-form-grid">
@@ -758,6 +797,13 @@ function AdminCrudConsole({ section, shop, vertical, items }: { section: string;
</div>
) : null}
{selectedItem?.kind === "table" ? (
<div className="admin-crud-actions">
<button className="admin-btn-secondary admin-btn-primary--small" type="button" disabled={isPending} onClick={handleTableQr}>Tạo lại QR</button>
{selectedItem.edit?.qrToken ? <Link className="admin-panel__action" href={`/table/${selectedItem.edit.qrToken}`}>Mở menu QR</Link> : null}
</div>
) : null}
{message ? <div className={message.includes("Không") || message.includes("not") || message.includes("required") ? "admin-error-message admin-crud-message" : "admin-crud-message admin-crud-message--success"}>{message}</div> : null}
</div>
</section>
@@ -780,6 +826,7 @@ function createEndpoint(action: string) {
product: "products",
inventory: "inventory/items",
recipe: "recipes",
table: "tables",
staff: "staff",
member: "members",
campaign: "campaigns",
@@ -793,9 +840,10 @@ function createEndpoint(action: string) {
function crudCoverageLabel(section: string) {
if (section === "ai-chat") return "Cấu hình";
if (section === "drive") return "Metadata thư mục/tệp";
if (section === "recipes") return "Recipe CRUD";
return "Create · Update · Delete";
if (section === "drive") return "Thông tin thư mục/tệp";
if (section === "recipes") return "Tạo · Sửa · Xóa công thức";
if (section === "tables" || section === "rooms" || section === "qr-codes") return "Bàn/phòng · QR";
return "Tạo · Sửa · Xóa";
}
function kindLabel(kind?: string) {
@@ -804,6 +852,7 @@ function kindLabel(kind?: string) {
product: "Sản phẩm",
inventory: "Tồn kho",
recipe: "Công thức",
table: "Bàn/phòng",
staff: "Nhân sự",
member: "Khách hàng",
campaign: "Khuyến mãi",
@@ -857,6 +906,24 @@ function renderCreateForms(section: string, onSubmit: (event: FormEvent<HTMLForm
</>
);
}
if (section === "tables" || section === "rooms" || section === "qr-codes") {
const isRoom = section === "rooms" || vertical === "karaoke";
return (
<form className="admin-crud-card" onSubmit={onSubmit}>
<input type="hidden" name="action" value="table" />
<h4>{isRoom ? "Tạo phòng" : "Tạo bàn"}</h4>
<div className="admin-form-grid">
<label className="admin-form-group"><span className="admin-form-label">{isRoom ? "Số phòng" : "Số bàn"}</span><input className="admin-form-input" name="tableNumber" required placeholder={isRoom ? "VIP 01" : "A01"} /></label>
<label className="admin-form-group"><span className="admin-form-label">Sức chứa</span><input className="admin-form-input" name="capacity" inputMode="numeric" defaultValue={isRoom ? "8" : "2"} /></label>
</div>
<div className="admin-form-grid">
<label className="admin-form-group"><span className="admin-form-label">Khu vực</span><input className="admin-form-input" name="zone" placeholder={isRoom ? "Tầng 2" : "Khu chính"} /></label>
<label className="admin-form-group"><span className="admin-form-label">{isRoom ? "Giá phòng/giờ" : "Giá theo giờ"}</span><input className="admin-form-input" name="hourlyRate" inputMode="numeric" defaultValue={isRoom ? "180000" : "0"} /></label>
</div>
<button className="admin-btn-primary admin-btn-primary--small" disabled={isPending}>{isRoom ? "Tạo phòng" : "Tạo bàn"}</button>
</form>
);
}
if (section === "inventory") {
return (
<form className="admin-crud-card" onSubmit={onSubmit}>
@@ -917,18 +984,24 @@ function renderCreateForms(section: string, onSubmit: (event: FormEvent<HTMLForm
</form>
);
}
if (section === "promotions") {
if (section === "promotions" || section === "happy-hour") {
const isHappyHour = section === "happy-hour";
return (
<form className="admin-crud-card" onSubmit={onSubmit}>
<input type="hidden" name="action" value="campaign" />
<h4>Tạo khuyến mãi</h4>
<h4>{isHappyHour ? "Tạo khung ưu đãi" : "Tạo khuyến mãi"}</h4>
<div className="admin-form-grid">
<label className="admin-form-group"><span className="admin-form-label">Tên chiến dịch</span><input className="admin-form-input" name="name" required placeholder="Morning coffee" /></label>
<label className="admin-form-group"><span className="admin-form-label">Tên chiến dịch</span><input className="admin-form-input" name="name" required placeholder={isHappyHour ? "Happy hour 14h" : "Morning coffee"} /></label>
<label className="admin-form-group"><span className="admin-form-label">Giá trị giảm</span><input className="admin-form-input" name="discountValue" inputMode="numeric" defaultValue="10000" /></label>
</div>
<label className="admin-form-group"><span className="admin-form-label"> tả</span><input className="admin-form-input" name="description" placeholder={isHappyHour ? "Áp dụng tại quầy trong khung giờ thấp điểm" : "Điều kiện áp dụng"} /></label>
<div className="admin-form-grid">
<label className="admin-form-group"><span className="admin-form-label">Bắt đu</span><input className="admin-form-input" name="startDate" type="datetime-local" /></label>
<label className="admin-form-group"><span className="admin-form-label">Kết thúc</span><input className="admin-form-input" name="endDate" type="datetime-local" /></label>
</div>
<input type="hidden" name="discountType" value="fixed" />
<input type="hidden" name="totalVouchers" value="100" />
<button className="admin-btn-primary admin-btn-primary--small" disabled={isPending}>Tạo khuyến mãi</button>
<button className="admin-btn-primary admin-btn-primary--small" disabled={isPending}>{isHappyHour ? "Tạo khung ưu đãi" : "Tạo khuyến mãi"}</button>
</form>
);
}

View File

@@ -73,7 +73,7 @@ export function shopPrimaryAction(shopId: string, vertical: VerticalKind, sectio
return { href: `/admin/shop/${shopId}/ai-chat#crud-console`, label: "Cấu hình AI", Icon: Database };
}
if (["tables", "rooms", "zones", "qr-codes", "reservations"].includes(section)) {
return { href: `/admin/shop/${shopId}/${section}`, label: section === "rooms" ? "Quản lý phòng" : "Quản lý bàn", Icon: PlusCircle };
return { href: `/admin/shop/${shopId}/${section}#crud-console`, label: section === "rooms" ? "Quản lý phòng" : section === "qr-codes" ? "Tạo QR" : "Quản lý bàn", Icon: PlusCircle };
}
if (["history", "orders"].includes(section)) {
return { href: `/pos/${shopId}/${vertical}?tab=history&filter=30d`, label: "Mở lịch sử bán hàng", Icon: Receipt };