Fix Cafe POS flows and admin report formatting

This commit is contained in:
Ho Ngoc Hai
2026-06-05 12:05:22 +07:00
parent bc2452f949
commit a1a459bd3a
94 changed files with 436 additions and 97 deletions

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -317,14 +317,14 @@ async function loadItems(section: string, shop?: Shop | null, allowedShopIds?: s
if (section === "finance" && shopId) {
const [revenue, products] = await Promise.all([scopedRevenueRows(shopId, allowedShopIds), scopedTopProductRows(shopId, allowedShopIds)]);
return [
...revenue.slice(0, 6).map((row) => ({ title: String(row.day), meta: `${row.order_count} đơn`, value: formatMoney(Number(row.revenue ?? 0)) })),
...revenue.slice(0, 6).map((row) => ({ title: formatReportDay(row.day), meta: `${row.order_count} đơn`, value: formatMoney(Number(row.revenue ?? 0)) })),
...products.slice(0, 6).map((row) => ({ title: String(row.product_name), meta: `${row.quantity_sold ?? 0} bán`, value: formatMoney(Number(row.revenue ?? 0)) }))
];
}
if (section === "reports" || section === "reports/eod" || section === "reports/revenue") {
const [revenue, products] = await Promise.all([scopedRevenueRows(shopId, allowedShopIds), scopedTopProductRows(shopId, allowedShopIds)]);
return [
...revenue.slice(0, 8).map((row) => ({ title: String(row.day), meta: `${row.order_count} đơn`, value: formatMoney(Number(row.revenue ?? 0)) })),
...revenue.slice(0, 8).map((row) => ({ title: formatReportDay(row.day), meta: `${row.order_count} đơn`, value: formatMoney(Number(row.revenue ?? 0)) })),
...products.slice(0, 6).map((row) => ({ title: String(row.product_name), meta: `${row.quantity_sold ?? 0} bán`, value: formatMoney(Number(row.revenue ?? 0)) }))
];
}
@@ -624,3 +624,11 @@ function formatDate(value: unknown) {
const date = new Date(String(value));
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("vi-VN");
}
function formatReportDay(value: unknown) {
if (!value) return "";
const date = new Date(String(value));
return Number.isNaN(date.getTime())
? String(value)
: date.toLocaleDateString("vi-VN", { weekday: "short", day: "2-digit", month: "2-digit", year: "numeric" });
}

View File

@@ -3,7 +3,7 @@ import { TposPosExperience } from "@/components/TposPosExperience";
import { requirePortalRole } from "@/server/auth/portal";
import { getShopService } from "@/server/services/shop";
import { listCatalogCategoriesByShop, listCatalogProductsByShop } from "@/server/services/catalog";
import { listTablesByShop } from "@/server/services/fnb";
import { listOpenTableSessionsByShop, listTablesByShop } from "@/server/services/fnb";
import { listInventoryItems } from "@/server/services/inventory";
import { getOrderService, getPosDashboardService, listOrdersService } from "@/server/services/order";
import { listBaristaQueue, listKitchenTickets } from "@/server/services/parity";
@@ -29,10 +29,11 @@ export async function renderPosExperience(
await requirePortalRole(["admin", "staff"], `/pos/${shopId}/${requestedVertical}${workflow?.length ? `/${workflow.join("/")}` : ""}`, shopId);
const contextOrderId = workflow?.[1] ?? null;
const [products, categories, tables, inventory, orders, dashboard, kitchenTickets, baristaQueue, paymentContextOrder] = await Promise.all([
const [products, categories, tables, tableSessions, inventory, orders, dashboard, kitchenTickets, baristaQueue, paymentContextOrder] = await Promise.all([
listCatalogProductsByShop(shopId),
listCatalogCategoriesByShop(shopId),
listTablesByShop(shopId),
listOpenTableSessionsByShop(shopId),
listInventoryItems(shopId),
listOrdersService({ shopId, page: 1, pageSize: 80, filter: "all" }),
getPosDashboardService(shopId, "today"),
@@ -53,6 +54,7 @@ export async function renderPosExperience(
products={products}
categories={categories}
tables={tables}
tableSessions={tableSessions}
inventory={inventory}
orders={orderItems}
initialHistoryPeriod={initialHistoryPeriod}

View File

@@ -225,6 +225,7 @@
padding: 0;
border-left-color: #1f1f23;
background: #1a1a1d;
overflow-y: auto;
}
.pos-cart-header {
@@ -238,6 +239,13 @@
}
.pos-cart-items {
flex: 0 1 auto;
min-height: 58px;
padding: 8px;
}
.pos-cart-items .pos-empty {
min-height: 42px;
padding: 8px;
}
@@ -274,8 +282,9 @@
}
.pos-cart-footer {
padding: 16px;
padding: 12px 16px 14px;
border-top: 1px solid #1f1f23;
background: #1a1a1d;
}
.pos-voucher-row input,
@@ -292,18 +301,19 @@
}
.pos-payment-methods {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
padding: 0;
}
.pos-payment-method-btn {
min-height: 82px;
min-height: 52px;
flex-direction: column;
gap: 8px;
gap: 5px;
border: 2px solid #2a2a2e;
border-radius: 12px;
background: #0a0a0b;
font-size: 13px;
font-size: 11px;
font-weight: 600;
}
@@ -318,9 +328,9 @@
}
.pos-total-box {
gap: 8px 12px;
gap: 5px 12px;
color: #adadb0;
font-size: 14px;
font-size: 13px;
}
.pos-total-box strong:last-child {
@@ -329,14 +339,14 @@
}
.pos-btn-checkout {
height: 48px;
height: 44px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
}
.pos-btn-secondary {
min-height: 44px;
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -349,6 +359,102 @@
font-weight: 700;
}
.pos-table-tile {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 3px 7px;
align-content: start;
}
.pos-table-tile > svg {
grid-row: 1 / span 2;
}
.pos-table-tile strong,
.pos-table-tile span,
.pos-table-tile small {
min-width: 0;
overflow-wrap: anywhere;
}
.pos-table-tile small {
grid-column: 2;
color: #adadb0;
font-size: 11px;
font-weight: 700;
}
.pos-table-tile--occupied {
border-color: rgba(34, 197, 94, 0.62);
background: rgba(34, 197, 94, 0.1);
}
.pos-room-session-panel {
display: grid;
gap: 8px;
margin: 0 8px 6px;
padding: 10px;
border: 1px solid #2a2a2e;
border-radius: 12px;
background: #111114;
}
.pos-room-session-panel span {
color: #8b8b90;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
}
.pos-room-session-panel strong {
display: block;
margin-top: 2px;
color: #ffffff;
font-size: 15px;
}
.pos-room-session-panel dl {
display: grid;
gap: 4px;
margin: 0;
}
.pos-room-session-panel dl div {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.pos-room-session-panel dt,
.pos-room-session-panel dd {
margin: 0;
font-size: 12px;
}
.pos-room-session-panel dt {
color: #8b8b90;
}
.pos-room-session-panel dd {
color: #ffffff;
font-weight: 800;
}
.pos-held-order-link {
min-height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid rgba(34, 197, 94, 0.5);
border-radius: 12px;
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
font-size: 13px;
font-weight: 800;
text-decoration: none;
}
.pos-clone .pos-bottom-nav {
width: 64px;
display: flex;

View File

@@ -284,6 +284,13 @@
color: #fbbf24;
}
.pos-history__print--collect {
margin-right: 8px;
border-color: rgba(34, 197, 94, 0.45);
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
}
.pos-history__receipt {
width: min(520px, 100%);
display: grid;

View File

@@ -2,7 +2,7 @@
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import {
ArrowLeft,
Banknote,
@@ -28,14 +28,15 @@ import {
} from "lucide-react";
import { DashboardPanel, HistoryPanel } from "./TposPosPanels";
import { WorkflowScreen } from "./TposWorkflowScreen";
import { currency } from "./TposPosUtils";
import { currency, formatRoomSessionDuration, isPaidOrCompleted, roomSessionCharge, roomSessionElapsedSeconds } from "./TposPosUtils";
import { normalizeWorkflowSlug, uniqueWorkflows, workflowHref, type BaristaQueueItem, type KitchenTicket } from "./TposWorkflowRoutes";
import { verticals, type VerticalKind } from "./tpos-config";
import type { InventoryItem, OrderSummary, Product, ProductCategory, Shop, TableInfo } from "@/server/domain/types";
import type { InventoryItem, OrderSummary, Product, ProductCategory, Shop, TableInfo, TableSessionInfo } from "@/server/domain/types";
type CartLine = { product: Product; quantity: number };
type PosTab = "sale" | "history" | "dashboard" | "settings";
type HistoryPeriod = "today" | "7d" | "30d" | "all";
type HoldOrderKind = "restaurant" | "karaoke" | "spa";
const verticalIcons: Record<string, typeof Coffee> = {
cafe: Coffee,
restaurant: UtensilsCrossed,
@@ -68,6 +69,7 @@ export function TposPosExperience({
products,
categories,
tables,
tableSessions = [],
inventory,
orders,
initialHistoryPeriod = "today",
@@ -82,6 +84,7 @@ export function TposPosExperience({
products: Product[];
categories: ProductCategory[];
tables: TableInfo[];
tableSessions?: TableSessionInfo[];
inventory: InventoryItem[];
orders: OrderSummary[];
initialHistoryPeriod?: HistoryPeriod;
@@ -102,13 +105,53 @@ export function TposPosExperience({
const [discount, setDiscount] = useState(0);
const [message, setMessage] = useState<string | null>(null);
const [dataRevision, setDataRevision] = useState(0);
const [activeTableSessions, setActiveTableSessions] = useState<TableSessionInfo[]>(tableSessions);
const [nowMs, setNowMs] = useState(() => Date.now());
const [lastHeldOrder, setLastHeldOrder] = useState<{ id: string; tableLabel: string; kind: HoldOrderKind } | null>(null);
const [isPending, startTransition] = useTransition();
const Icon = verticalIcons[vertical] ?? Coffee;
const rawWorkflowSlug = workflow?.[0];
const workflowSlug = rawWorkflowSlug ? normalizeWorkflowSlug(vertical, rawWorkflowSlug) : undefined;
const workflowPath = workflowSlug ? [workflowSlug, ...(workflow?.slice(1) ?? [])] : [];
const usesTableContext = vertical === "restaurant" || vertical === "karaoke";
const canHoldOrder = cart.length > 0 && Boolean(selectedTable) && (vertical === "restaurant" || vertical === "karaoke");
const supportsHeldOrder = vertical === "restaurant" || vertical === "karaoke" || vertical === "spa";
useEffect(() => {
setActiveTableSessions(tableSessions);
}, [tableSessions]);
useEffect(() => {
setLastHeldOrder(null);
setSelectedTable((current) => tables.some((table) => table.id === current) ? current : tables[0]?.id ?? "");
}, [shop.id, tables, vertical]);
useEffect(() => {
if (vertical !== "karaoke") return;
const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [vertical]);
const sessionByTableId = useMemo(() => new Map(activeTableSessions
.filter((session) => session.status.toLowerCase() === "open" && !session.closedAt)
.map((session) => [session.tableId, session])), [activeTableSessions]);
const selectedTableInfo = tables.find((table) => table.id === selectedTable) ?? null;
const selectedRoomSession = vertical === "karaoke" ? sessionByTableId.get(selectedTable) : undefined;
const selectedRoomElapsedSeconds = roomSessionElapsedSeconds(selectedRoomSession?.startedAt, nowMs);
const selectedRoomCharge = selectedTableInfo ? roomSessionCharge(selectedTableInfo.hourlyRate, selectedRoomElapsedSeconds) : 0;
const selectedDeferredOrder = useMemo(() => orders.find((order) => {
if (!supportsHeldOrder) return false;
if (isPaidOrCompleted(order) || /cancel|void|hủy/i.test(order.status)) return false;
if (vertical === "spa") return !order.tableId;
return order.tableId === selectedTable;
}), [orders, selectedTable, supportsHeldOrder, vertical]);
const lastHeldOrderForVertical = lastHeldOrder?.kind === vertical ? lastHeldOrder : null;
const heldOrderId = lastHeldOrderForVertical?.id ?? selectedDeferredOrder?.id ?? "";
const heldOrderTableLabel = lastHeldOrderForVertical?.tableLabel ?? selectedDeferredOrder?.tableNumber ?? selectedTableInfo?.tableNumber ?? "";
const canHoldOrder = vertical === "restaurant"
? cart.length > 0 && Boolean(selectedTable)
: vertical === "karaoke"
? Boolean(selectedTable) && (cart.length > 0 || !selectedRoomSession)
: vertical === "spa" && cart.length > 0;
function changeTab(tab: PosTab) {
setActiveTab(tab);
@@ -206,6 +249,7 @@ export function TposPosExperience({
setAmountTendered("");
setDiscount(0);
setVoucher("");
setLastHeldOrder(null);
setMessage(`Thanh toán thành công ${payload.data?.transactionId ?? ""}`);
setDataRevision((current) => current + 1);
});
@@ -213,6 +257,9 @@ export function TposPosExperience({
async function submitHoldOrder() {
if (!canHoldOrder) return;
const cartHadItems = cart.length > 0;
const tableLabel = vertical === "spa" ? "" : selectedTableInfo?.tableNumber ?? tables.find((item) => item.id === selectedTable)?.tableNumber ?? "";
const holdKind: HoldOrderKind = vertical === "restaurant" ? "restaurant" : vertical === "karaoke" ? "karaoke" : "spa";
setMessage(null);
startTransition(async () => {
const response = await fetch("/api/bff/pos/orders", {
@@ -220,11 +267,11 @@ export function TposPosExperience({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
shopId: shop.id,
tableId: selectedTable,
paymentMethod: vertical === "restaurant" ? "kitchen_order" : "room_fnb",
tableId: usesTableContext ? selectedTable : null,
paymentMethod: vertical === "restaurant" ? "kitchen_order" : vertical === "karaoke" ? "room_fnb" : "customer_order",
deferPayment: true,
statusId: 2,
notes: vertical === "restaurant" ? "Gửi bếp từ POS" : "Gửi F&B theo phòng",
notes: vertical === "restaurant" ? "Gửi bếp từ POS" : vertical === "karaoke" ? cartHadItems ? "Gửi F&B theo phòng" : "Mở phòng karaoke" : "Bắt đầu liệu trình, chờ thanh toán",
items: cart.map((line) => ({ productId: line.product.id, quantity: line.quantity }))
})
});
@@ -233,6 +280,8 @@ export function TposPosExperience({
setMessage(payload.error ?? "Không thể lưu order");
return;
}
const heldOrderId = payload.data?.id ?? "";
if (heldOrderId) setLastHeldOrder({ id: heldOrderId, tableLabel, kind: holdKind });
if (vertical === "restaurant") {
const table = tables.find((item) => item.id === selectedTable);
@@ -253,22 +302,42 @@ export function TposPosExperience({
setDiscount(0);
setVoucher("");
setDataRevision((current) => current + 1);
if (heldOrderId) setLastHeldOrder({ id: heldOrderId, tableLabel: table?.tableNumber ?? tableLabel, kind: "restaurant" });
setMessage("Đã lưu order, nhưng chưa gửi được bếp. Kiểm tra Kitchen tickets.");
return;
}
}
if (vertical === "karaoke" && !selectedRoomSession) {
setActiveTableSessions((current) => [
{
id: heldOrderId ? `local-${heldOrderId}` : `local-${selectedTable}-${Date.now()}`,
shopId: shop.id,
tableId: selectedTable,
status: "open",
guestCount: 1,
startedAt: new Date().toISOString(),
closedAt: null
},
...current
]);
}
setCart([]);
setAmountTendered("");
setDiscount(0);
setVoucher("");
setDataRevision((current) => current + 1);
setMessage(vertical === "restaurant" ? "Đã gửi bếp và giữ order tại bàn" : "Đã lưu order F&B cho phòng");
setMessage(vertical === "restaurant"
? "Đã gửi bếp và giữ order tại bàn"
: vertical === "karaoke"
? cartHadItems ? "Đã lưu F&B và giữ thanh toán cho phòng" : "Đã mở phòng và bắt đầu tính giờ"
: "Đã bắt đầu liệu trình và giữ thanh toán");
});
}
if (workflowSlug) {
return <WorkflowScreen shop={shop} vertical={vertical} slug={workflowSlug} workflowPath={workflowPath} products={products} tables={tables} inventory={inventory} orders={orders} dashboard={dashboard} kitchenTickets={kitchenTickets} baristaQueue={baristaQueue} />;
return <WorkflowScreen shop={shop} vertical={vertical} slug={workflowSlug} workflowPath={workflowPath} products={products} tables={tables} tableSessions={activeTableSessions} inventory={inventory} orders={orders} dashboard={dashboard} kitchenTickets={kitchenTickets} baristaQueue={baristaQueue} />;
}
return (
@@ -338,13 +407,29 @@ export function TposPosExperience({
{(vertical === "restaurant" || vertical === "karaoke") && tables.length > 0 ? (
<div className="pos-table-strip">
{tables.map((table) => (
<button key={table.id} className={selectedTable === table.id ? "pos-table-tile pos-table-tile--active" : "pos-table-tile"} onClick={() => setSelectedTable(table.id)}>
<Grid3X3 size={16} />
<strong>{vertical === "karaoke" ? "Phòng" : "Bàn"} {table.tableNumber}</strong>
<span>{table.zone ?? table.status}</span>
</button>
))}
{tables.map((table) => {
const roomSession = vertical === "karaoke" ? sessionByTableId.get(table.id) : undefined;
const elapsedSeconds = roomSessionElapsedSeconds(roomSession?.startedAt, nowMs);
const tileClass = [
"pos-table-tile",
selectedTable === table.id ? "pos-table-tile--active" : "",
roomSession ? "pos-table-tile--occupied" : ""
].filter(Boolean).join(" ");
return (
<button key={table.id} className={tileClass} onClick={() => setSelectedTable(table.id)}>
<Grid3X3 size={16} />
<strong>{vertical === "karaoke" ? "Phòng" : "Bàn"} {table.tableNumber}</strong>
<span>{vertical === "karaoke" && roomSession ? "Đang hát" : table.zone ?? table.status}</span>
{vertical === "karaoke" ? (
<small className="pos-room-timer">
{roomSession
? `${formatRoomSessionDuration(elapsedSeconds)} · ${currency.format(roomSessionCharge(table.hourlyRate, elapsedSeconds))}`
: table.hourlyRate > 0 ? `${currency.format(table.hourlyRate)} / giờ` : "Chưa có giá giờ"}
</small>
) : null}
</button>
);
})}
</div>
) : null}
@@ -390,60 +475,108 @@ export function TposPosExperience({
))}
{cart.length === 0 ? <div className="pos-empty">Chọn món từ thực đơn bên trái</div> : null}
</div>
<div className="pos-cart-footer">
<div className="pos-voucher-row">
<input value={voucher} onChange={(event) => setVoucher(event.target.value)} placeholder="Mã voucher" />
<button onClick={validateVoucherCode}>Áp dụng</button>
</div>
<div className="pos-payment-methods">
{paymentMethods.map((method) => {
const PayIcon = method.icon;
const selected = paymentMethod === method.id;
return (
<button
key={method.id}
className={selected ? "pos-payment-method-btn pos-payment-method-btn--selected" : "pos-payment-method-btn"}
disabled={!method.enabled}
title={method.enabled ? method.label : "Chưa cấu hình cổng thanh toán"}
onClick={() => {
if (!method.enabled) {
setMessage("Phương thức này chưa cấu hình cổng thanh toán");
return;
}
setPaymentMethod(method.id);
}}
>
<PayIcon size={22} />
<span>{method.label}</span>
</button>
);
})}
</div>
{paymentMethod === "cash" ? (
<div className="pos-cash-box">
<input value={amountTendered} onChange={(event) => setAmountTendered(event.target.value)} placeholder="Khách đưa" inputMode="numeric" />
<div className="pos-payment-quick-amounts">
{quickAmounts.map((amount) => <button key={amount} onClick={() => setAmountTendered(String(amount))}>{currency.format(amount)}</button>)}
</div>
{vertical === "karaoke" && selectedTableInfo ? (
<div className="pos-room-session-panel">
<div>
<span>Phòng đang chọn</span>
<strong>Phòng {selectedTableInfo.tableNumber}</strong>
</div>
<dl>
<div>
<dt>Trạng thái</dt>
<dd>{selectedRoomSession ? "Đang hát" : "Chưa mở phòng"}</dd>
</div>
<div>
<dt>Thời lượng</dt>
<dd>{selectedRoomSession ? formatRoomSessionDuration(selectedRoomElapsedSeconds) : "00:00:00"}</dd>
</div>
<div>
<dt>Đơn giá</dt>
<dd>{selectedTableInfo.hourlyRate > 0 ? `${currency.format(selectedTableInfo.hourlyRate)} / giờ` : "Chưa cấu hình"}</dd>
</div>
<div>
<dt>Tiền giờ tạm tính</dt>
<dd>{currency.format(selectedRoomCharge)}</dd>
</div>
</dl>
</div>
) : null}
<div className="pos-cart-footer">
{cart.length > 0 ? (
<>
<div className="pos-voucher-row">
<input value={voucher} onChange={(event) => setVoucher(event.target.value)} placeholder="Mã voucher" />
<button onClick={validateVoucherCode}>Áp dụng</button>
</div>
<div className="pos-payment-methods">
{paymentMethods.map((method) => {
const PayIcon = method.icon;
const selected = paymentMethod === method.id;
return (
<button
key={method.id}
className={selected ? "pos-payment-method-btn pos-payment-method-btn--selected" : "pos-payment-method-btn"}
disabled={!method.enabled}
title={method.enabled ? method.label : "Chưa cấu hình cổng thanh toán"}
onClick={() => {
if (!method.enabled) {
setMessage("Phương thức này chưa cấu hình cổng thanh toán");
return;
}
setPaymentMethod(method.id);
}}
>
<PayIcon size={22} />
<span>{method.label}</span>
</button>
);
})}
</div>
{paymentMethod === "cash" ? (
<div className="pos-cash-box">
<input value={amountTendered} onChange={(event) => setAmountTendered(event.target.value)} placeholder="Khách đưa" inputMode="numeric" />
<div className="pos-payment-quick-amounts">
{quickAmounts.map((amount) => <button key={amount} onClick={() => setAmountTendered(String(amount))}>{currency.format(amount)}</button>)}
</div>
</div>
) : null}
</>
) : null}
<div className="pos-total-box">
<span>Tạm tính</span><b>{currency.format(subtotal)}</b>
<span>Giảm giá</span><b>{currency.format(discount)}</b>
{vertical === "karaoke" && selectedRoomSession ? (
<>
<span>Tiền giờ tạm tính</span><b>{currency.format(selectedRoomCharge)}</b>
</>
) : null}
<span>Tiền thối</span><b>{currency.format(change)}</b>
<strong>Tổng cộng</strong><strong>{currency.format(total)}</strong>
<strong>{vertical === "karaoke" && selectedRoomSession ? "Tổng F&B" : "Tổng cộng"}</strong><strong>{currency.format(total)}</strong>
{vertical === "karaoke" && selectedRoomSession ? (
<>
<strong>Dự kiến phòng + F&B</strong><strong>{currency.format(total + selectedRoomCharge)}</strong>
</>
) : null}
</div>
{message ? <div className={message.includes("Không") ? "pos-notice pos-notice--error" : "pos-notice pos-notice--success"}>{message}</div> : null}
{vertical === "restaurant" || vertical === "karaoke" ? (
{heldOrderId ? (
<Link className="pos-held-order-link" href={`/pos/${shop.id}/payment/method-select/${heldOrderId}?vertical=${encodeURIComponent(vertical)}`}>
<ReceiptText size={17} />
<span>{vertical === "karaoke" ? "Thu tiền phòng" : vertical === "spa" ? "Thu tiền liệu trình" : "Thu tiền bàn"} {heldOrderTableLabel || heldOrderId.slice(0, 8).toUpperCase()}</span>
</Link>
) : null}
{(vertical === "restaurant" || vertical === "karaoke" || vertical === "spa") && (vertical !== "karaoke" || !selectedRoomSession || cart.length > 0) ? (
<button className="pos-btn-secondary" disabled={!canHoldOrder || isPending} onClick={submitHoldOrder}>
<ReceiptText size={18} />
{vertical === "restaurant" ? "Gửi bếp" : "Lưu phòng"}
{vertical === "restaurant" ? "Gửi bếp" : vertical === "spa" ? "Bắt đầu liệu trình" : selectedRoomSession ? "Lưu F&B vào phòng" : cart.length > 0 ? "Mở phòng & lưu F&B" : "Mở phòng"}
</button>
) : null}
{cart.length > 0 ? (
<button className="pos-btn-checkout" disabled={!canPay || isPending} onClick={submitPayment}>
{isPending ? <ReceiptText size={18} /> : <Check size={18} />}
{isPending ? "Đang xử lý" : "Thanh toán"}
</button>
) : null}
<button className="pos-btn-checkout" disabled={!canPay || isPending} onClick={submitPayment}>
{isPending ? <ReceiptText size={18} /> : <Check size={18} />}
{isPending ? "Đang xử lý" : "Thanh toán"}
</button>
</div>
</aside>
</section>

View File

@@ -5,7 +5,7 @@ import { useEffect, useState } from "react";
import { ArrowLeft, Printer, ReceiptText, RotateCcw, Search } from "lucide-react";
import type { OrderSummary } from "@/server/domain/types";
import type { VerticalKind } from "./tpos-config";
import { currency, isPaidOrCompleted, orderGrossAmount, orderItemNetQuantity, orderNetAmount, orderRefundedAmount, paymentMethodLabel, toFiniteNumber } from "./TposPosUtils";
import { currency, isPaidOrCompleted, orderGrossAmount, orderItemNetQuantity, orderNetAmount, orderRefundedAmount, orderStatusLabel, paymentMethodLabel, toFiniteNumber } from "./TposPosUtils";
type HistoryPeriod = "today" | "7d" | "30d" | "all";
type ApiEnvelope<T> = { success?: boolean; data?: T; error?: string };
@@ -90,7 +90,7 @@ export function HistoryPanel({ shopId, vertical, initialOrders, initialPeriod, r
<strong>#{selectedOrder.id.slice(0, 8).toUpperCase()}</strong>
</div>
<div className="pos-history__receipt-row"><span>Thời gian</span><b>{formatOrderDateTime(selectedOrder.createdAt)}</b></div>
<div className="pos-history__receipt-row"><span>Trạng thái</span><b>{statusLabel(selectedOrder.status)}</b></div>
<div className="pos-history__receipt-row"><span>Trạng thái</span><b>{orderStatusLabel(selectedOrder)}</b></div>
<div className="pos-history__receipt-row"><span>Thanh toán</span><b>{paymentLabel(selectedOrder.paymentMethod)}</b></div>
{selectedOrder.tableNumber ? <div className="pos-history__receipt-row"><span>Bàn/phòng</span><b>{selectedOrder.tableNumber}</b></div> : null}
<div className="pos-history__receipt-items">
@@ -118,6 +118,12 @@ export function HistoryPanel({ shopId, vertical, initialOrders, initialPeriod, r
</div>
</div>
<div className="pos-history__detail-footer">
{canCollectPayment(selectedOrder) ? (
<Link className="pos-history__print pos-history__print--collect" href={`/pos/${shopId}/payment/method-select/${selectedOrder.id}?vertical=${encodeURIComponent(vertical)}`}>
<ReceiptText size={16} />
<span>Thu tiền</span>
</Link>
) : null}
{isPaidOrCompleted(selectedOrder) ? (
<Link className="pos-history__print pos-history__print--refund" href={`/pos/${shopId}/dialog/void-refund/${selectedOrder.id}?vertical=${encodeURIComponent(vertical)}`}>
<RotateCcw size={16} />
@@ -165,7 +171,7 @@ export function HistoryPanel({ shopId, vertical, initialOrders, initialPeriod, r
<button key={order.id} className="pos-history__card" onClick={() => setSelectedOrderId(order.id)}>
<div className="pos-history__card-header">
<span className="pos-history__order-id">{order.id.slice(0, 8).toUpperCase()}</span>
<span className={`pos-history__status pos-history__status--${statusTone(order.status)}`}>{statusLabel(order.status)}</span>
<span className={`pos-history__status pos-history__status--${statusTone(order.status)}`}>{orderStatusLabel(order)}</span>
</div>
<div className="pos-history__card-body">
<span className="pos-history__items-preview">
@@ -503,15 +509,8 @@ function statusTone(status: string) {
return "pending";
}
function statusLabel(status: string) {
const normalized = status.toLowerCase();
if (normalized.includes("paid")) return "Đã thanh toán";
if (normalized.includes("valid")) return "Đã xác nhận";
if (normalized.includes("refund")) return "Đã hoàn tiền";
if (normalized.includes("return")) return "Đã trả hàng";
if (normalized.includes("cancel")) return "Đã hủy";
if (normalized.includes("complete")) return "Hoàn tất";
return status || "Đang xử lý";
function canCollectPayment(order: OrderSummary) {
return !isPaidOrCompleted(order) && order.statusId !== 6 && !/cancel|void|hủy/i.test(order.status);
}
function paymentLabel(method: string | null) {

View File

@@ -1,10 +1,14 @@
export {
currency,
formatRoomSessionDuration,
isPaidOrCompleted,
orderGrossAmount,
orderItemNetQuantity,
orderNetAmount,
orderRefundedAmount,
orderStatusLabel,
paymentMethodLabel,
roomSessionCharge,
roomSessionElapsedSeconds,
toFiniteNumber
} from "./tpos-order-format";

View File

@@ -122,7 +122,7 @@ export function WorkflowContextPanel({ contextOrder, contextTable, methodSelectW
export function WorkflowBoard({ rows }: { rows: WorkflowDataRow[] }) {
return (
<div className="workflow-board">
{rows.slice(0, 10).map((row) => (
{rows.map((row) => (
<article key={row.id} className="workflow-ticket">
<strong>{row.title}</strong>
<span>{row.meta}</span>

View File

@@ -1,6 +1,6 @@
import type { InventoryItem, OrderSummary, Product, TableInfo } from "@/server/domain/types";
import type { InventoryItem, OrderSummary, Product, TableInfo, TableSessionInfo } from "@/server/domain/types";
import { posWorkflows, type VerticalKind } from "./tpos-config";
import { currency, orderNetAmount } from "./TposPosUtils";
import { currency, formatRoomSessionDuration, orderNetAmount, roomSessionCharge, roomSessionElapsedSeconds } from "./TposPosUtils";
export type KitchenTicket = Record<string, unknown>;
export type BaristaQueueItem = Record<string, unknown>;
@@ -133,11 +133,16 @@ export function paymentMethodFromSlug(slug: string) {
export function workflowDataRows(slug: string, data: {
orders: OrderSummary[];
tables: TableInfo[];
tableSessions?: TableSessionInfo[];
products: Product[];
inventory: InventoryItem[];
kitchenTickets: KitchenTicket[];
baristaQueue: BaristaQueueItem[];
nowMs?: number;
}): WorkflowDataRow[] {
const roomSessionByTableId = new Map((data.tableSessions ?? [])
.filter((session) => session.status.toLowerCase() === "open" && !session.closedAt)
.map((session) => [session.tableId, session]));
if (slug === "kitchen-display") {
return data.kitchenTickets.map((ticket) => ({
id: String(ticket.id),
@@ -154,12 +159,14 @@ export function workflowDataRows(slug: string, data: {
value: item.order_id ? String(item.order_id).slice(0, 8).toUpperCase() : "Queue"
}));
}
if (slug === "table-map" || slug === "room-map" || slug === "table-select" || slug === "room-select") {
if (slug === "table-map" || slug === "room-map" || slug === "table-select" || slug === "room-select" || slug === "room-session") {
return data.tables.map((table) => ({
id: table.id,
title: `${slug.includes("room") ? "Phòng" : "Bàn"} ${table.tableNumber}`,
meta: `${table.zone ?? "Khu chính"} · ${table.capacity} chỗ`,
value: table.status
value: roomSessionByTableId.has(table.id) && slug.includes("room")
? `${formatRoomSessionDuration(roomSessionElapsedSeconds(roomSessionByTableId.get(table.id)?.startedAt, data.nowMs))} · ${currency.format(roomSessionCharge(table.hourlyRate, roomSessionElapsedSeconds(roomSessionByTableId.get(table.id)?.startedAt, data.nowMs)))}`
: table.status
}));
}
if (slug === "stock-check" || slug === "stock-in" || slug === "stock-out" || slug === "stock-transfer") {

View File

@@ -1,10 +1,10 @@
"use client";
import Link from "next/link";
import { useState, useTransition } from "react";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Banknote, Building2, Check, Coffee, CreditCard, ReceiptText, RotateCcw, Smartphone, Trash2 } from "lucide-react";
import type { InventoryItem, OrderSummary, Product, Shop, TableInfo } from "@/server/domain/types";
import type { InventoryItem, OrderSummary, Product, Shop, TableInfo, TableSessionInfo } from "@/server/domain/types";
import { posWorkflows, type VerticalKind } from "./tpos-config";
import { currency, isPaidOrCompleted, orderItemNetQuantity, orderNetAmount, paymentMethodLabel, toFiniteNumber } from "./TposPosUtils";
import {
@@ -34,6 +34,7 @@ export function WorkflowScreen({
workflowPath,
products,
tables,
tableSessions,
inventory,
orders,
dashboard,
@@ -46,6 +47,7 @@ export function WorkflowScreen({
workflowPath: string[];
products: Product[];
tables: TableInfo[];
tableSessions?: TableSessionInfo[];
inventory: InventoryItem[];
orders: OrderSummary[];
dashboard: Record<string, unknown>;
@@ -79,8 +81,15 @@ export function WorkflowScreen({
const [stockNotes, setStockNotes] = useState("");
const [workflowQuery, setWorkflowQuery] = useState("");
const [workflowMessage, setWorkflowMessage] = useState<string | null>(null);
const [nowMs, setNowMs] = useState(() => Date.now());
const [isWorkflowPending, startWorkflowTransition] = useTransition();
useEffect(() => {
if (vertical !== "karaoke") return;
const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [vertical]);
function payContextOrder() {
if (!contextOrder) return;
const dueAmount = orderNetAmount(contextOrder);
@@ -293,7 +302,7 @@ export function WorkflowScreen({
if (!workflow) {
return <WorkflowNotFoundScreen shopId={shop.id} vertical={vertical} />;
}
const workflowRows = workflowDataRows(slug, { orders, tables, products, inventory, kitchenTickets, baristaQueue });
const workflowRows = workflowDataRows(slug, { orders, tables, tableSessions, products, inventory, kitchenTickets, baristaQueue, nowMs });
const visibleWorkflowRows = workflowRows.filter((row) => {
const needle = workflowQuery.trim().toLowerCase();
return !needle || `${row.title} ${row.meta} ${row.value}`.toLowerCase().includes(needle);
@@ -315,7 +324,6 @@ export function WorkflowScreen({
<WorkflowMetricGrid dashboard={dashboard} orders={orders} products={products} tables={tables} />
<WorkflowContextPanel contextOrder={contextOrder} contextTable={contextTable} methodSelectWorkflow={methodSelectWorkflow} paymentWorkflow={paymentWorkflow} shop={shop} />
<WorkflowUnsupportedPanel title={workflow.title} reason={unsupportedReason} />
<WorkflowBoard rows={visibleWorkflowRows} />
</section>
</section>
</main>

View File

@@ -10,6 +10,7 @@ const unsupportedWorkflowReasons: Record<string, string> = {
"split-bill": "Cần split bill/payment ledger trước khi tách hóa đơn.",
"cash-drawer": "Cần device registry và drawer event table trước khi mở két.",
"stock-transfer": "Cần mô hình kho nguồn/đích trước khi chuyển kho.",
"loyalty-stamp": "Cần loyalty/stamp ledger được scope vào POS trước khi tích tem hoặc đổi thưởng.",
"menu-management": "Quản lý menu thật đang nằm ở portal admin, chưa có editor POS riêng.",
"order-customize": "Cần modifier/topping schema trước khi tùy biến món tại workflow riêng.",
"milk-foam-options": "Cần cấu hình topping/foam trước khi bán tuỳ chọn đồ uống riêng.",
@@ -26,7 +27,6 @@ const unsupportedWorkflowReasons: Record<string, string> = {
"happy-hour": "Cần pricing rule engine trước khi áp happy hour tại POS.",
"member-card": "Cần member-card binding riêng cho karaoke trước khi thao tác.",
"peak-warning": "Cần rule cảnh báo giờ cao điểm trước khi hiển thị.",
"room-session": "Cần room session ledger trước khi điều khiển phiên hát.",
"room-extend": "Cần room duration billing trước khi gia hạn phòng.",
"room-reset": "Cần room cleanup/session close workflow trước khi reset phòng.",
"service-display": "Cần service queue display trước khi dùng màn phục vụ.",

View File

@@ -69,3 +69,22 @@ export function paymentMethodLabel(method?: string | null) {
if (!normalized) return "Chưa thanh toán";
return method ?? "Khác";
}
export function roomSessionElapsedSeconds(startedAt?: string | null, nowMs = Date.now()) {
if (!startedAt) return 0;
const startedMs = Date.parse(startedAt);
if (!Number.isFinite(startedMs)) return 0;
return Math.max(0, Math.floor((nowMs - startedMs) / 1000));
}
export function formatRoomSessionDuration(totalSeconds: number) {
const safeSeconds = Math.max(0, Math.floor(totalSeconds));
const hours = Math.floor(safeSeconds / 3600);
const minutes = Math.floor((safeSeconds % 3600) / 60);
const seconds = safeSeconds % 60;
return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
}
export function roomSessionCharge(hourlyRate: number, totalSeconds: number) {
return Math.ceil(Math.max(0, hourlyRate) * Math.max(0, totalSeconds) / 3600);
}

View File

@@ -67,6 +67,7 @@ export {
deleteTable,
getTableById,
getTableByToken,
listOpenTableSessions,
listTables,
regenerateTableQr,
updateTable,

View File

@@ -19,7 +19,9 @@ export type CreateOrderInput = {
};
export async function createOrder(input: CreateOrderInput) {
if (!input.items.length) throw new Error("Order must contain at least one item");
const requestedPaymentMethod = normalizePaymentMethod(input.paymentMethod);
const canOpenRoomWithoutItems = input.deferPayment === true && requestedPaymentMethod === "room_fnb" && Boolean(input.tableId);
if (!input.items.length && !canOpenRoomWithoutItems) throw new Error("Order must contain at least one item");
return withTransaction(async (client) => {
const productIds = input.items.map((item) => item.productId);
@@ -107,7 +109,7 @@ export async function createOrder(input: CreateOrderInput) {
const orderId = randomUUID();
const transactionId = deferPayment ? null : `POS-${Date.now()}-${orderId.slice(0, 8).toUpperCase()}`;
const amountTendered = deferPayment ? null : input.amountTendered ?? total;
const paymentMethod = normalizePaymentMethod(input.paymentMethod);
const paymentMethod = requestedPaymentMethod;
if (!deferPayment) assertImmediatePayment(paymentMethod);
if (!deferPayment && paymentMethod === "cash" && money(amountTendered) < total) {
throw new Error("Amount tendered must be greater than or equal to order total");

View File

@@ -17,7 +17,8 @@ import type {
Product,
ProductCategory,
Shop,
TableInfo
TableInfo,
TableSessionInfo
} from "../../domain/types";
export const money = (value: unknown) => Number(value ?? 0);
@@ -165,6 +166,18 @@ export function mapTable(row: Record<string, unknown>): TableInfo {
};
}
export function mapTableSession(row: Record<string, unknown>): TableSessionInfo {
return {
id: String(row.id),
shopId: String(row.shop_id),
tableId: String(row.table_id),
status: String(row.status ?? "open"),
guestCount: int(row.guest_count),
startedAt: maybeDate(row.started_at),
closedAt: row.closed_at ? maybeDate(row.closed_at) : null
};
}
export function mapOrder(row: Record<string, unknown>): OrderSummary {
const statusId = int(row.status_id);
const rawItems = Array.isArray(row.items) ? row.items : [];

View File

@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { query, withTransaction } from "../pool";
import { logActivity, mapTable } from "./shared";
import { logActivity, mapTable, mapTableSession } from "./shared";
export async function listTables(shopId: string) {
const rows = await query(
@@ -16,6 +16,21 @@ export async function listTables(shopId: string) {
return rows.map(mapTable);
}
export async function listOpenTableSessions(shopId: string) {
const rows = await query(
`
SELECT *
FROM table_sessions
WHERE shop_id = $1
AND lower(status) = 'open'
AND closed_at IS NULL
ORDER BY started_at ASC
`,
[shopId]
);
return rows.map(mapTableSession);
}
export async function getTableById(tableId: string) {
const rows = await query(
`

View File

@@ -86,6 +86,16 @@ export type TableInfo = {
qrToken: string | null;
};
export type TableSessionInfo = {
id: string;
shopId: string;
tableId: string;
status: string;
guestCount: number;
startedAt: string;
closedAt: string | null;
};
export type OrderItem = {
id: string;
productId: string;

View File

@@ -3,6 +3,7 @@ import {
deleteTable,
getTableById,
getTableByToken,
listOpenTableSessions,
listTables,
regenerateTableQr,
updateTable,
@@ -13,6 +14,10 @@ export async function listTablesByShop(shopId: string) {
return listTables(shopId);
}
export async function listOpenTableSessionsByShop(shopId: string) {
return listOpenTableSessions(shopId);
}
export async function getTable(tableId: string) {
return getTableById(tableId);
}