Fix Cafe POS flows and admin report formatting
@@ -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.
|
||||
|
||||
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
export {
|
||||
currency,
|
||||
formatRoomSessionDuration,
|
||||
isPaidOrCompleted,
|
||||
orderGrossAmount,
|
||||
orderItemNetQuantity,
|
||||
orderNetAmount,
|
||||
orderRefundedAmount,
|
||||
orderStatusLabel,
|
||||
paymentMethodLabel,
|
||||
roomSessionCharge,
|
||||
roomSessionElapsedSeconds,
|
||||
toFiniteNumber
|
||||
} from "./tpos-order-format";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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ụ.",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ export {
|
||||
deleteTable,
|
||||
getTableById,
|
||||
getTableByToken,
|
||||
listOpenTableSessions,
|
||||
listTables,
|
||||
regenerateTableQr,
|
||||
updateTable,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 : [];
|
||||
|
||||
@@ -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(
|
||||
`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||