Add karaoke room timer and reset workflow

This commit is contained in:
Ho Ngoc Hai
2026-06-06 02:38:17 +07:00
parent 3ac6b3cf61
commit bc5c029e09
21 changed files with 1456 additions and 32 deletions

View File

@@ -0,0 +1,45 @@
# 37 - Cafe Loyalty Layout KPI Sidebar Board
Date: 2026-06-06
Base URL: http://localhost:3020
Workflow: `/pos/8d99d966-883e-4806-b247-ee940e6a779c/cafe/loyalty-stamp`
## Scope
Follow-up to workflow 36 visual audit. This slice addresses three issues raised by UX/UI and code explorer agents:
- KPI `Đơn đã thu 0 đ` was misleading because workflow rows used all-time paid orders while dashboard revenue used today only.
- Loyalty order rows below the form looked like loose/debug data without a section title.
- Workflow sidebar had weak bottom scroll affordance and could show a partially cut item.
## Code Changes
- `WorkflowMetricGrid` now derives the revenue card from paid/completed orders in the loaded workflow order list, falling back to dashboard revenue only when there are no paid orders in the list.
- `WorkflowBoard` now has a framed shell with heading, description and row count.
- Loyalty workflow passes board copy: `Đơn đã thanh toán gần đây`.
- Workflow sidebar is compacted on desktop and gets explicit viewport height, bottom padding and `scrollbar-gutter: stable`.
## Verification
- Chrome plugin opened and verified the live workflow at `http://localhost:3020`.
- Revenue KPI now shows `733.000 đ` with `15 đơn đã thu`, not `0 đ`.
- Board section shows `Đơn đã thanh toán gần đây`, explanatory copy and row count `15`.
- Sidebar measurement after the compacting fix: `37` total links, `23` visible links, `0` partially cut links in the 1920x905 viewport.
- Visual review was performed on both saved screenshots below.
- `pnpm run typecheck`: passed.
- `git diff --check`: passed.
- `results.json`: parsed successfully.
## UX/UI Review
Independent visual review found no P0 or P1 issues. Remaining P2 polish only:
- Disabled `Đã tích điểm` button contrast could be stronger.
- Reference board density is acceptable but scan-heavy with 15 orders.
- KPI labels are slightly muted on dark background.
- Screenshot 01 touches the next section at the bottom, but this is not a CSS blocker.
## Screenshots
- `01-chrome-loyalty-layout-after-fixes-port-3020.jpg` - top viewport after KPI/sidebar fixes.
- `02-chrome-loyalty-board-context-port-3020.jpg` - scrolled board context with paid/completed order references.

View File

@@ -0,0 +1,49 @@
{
"date": "2026-06-06",
"baseUrl": "http://localhost:3020",
"workflow": "cafe_loyalty_layout_kpi_sidebar_board",
"status": "chrome_verified_with_screenshots",
"changes": [
"Workflow revenue KPI now uses paid/completed orders from the loaded workflow order list before falling back to dashboard revenue.",
"WorkflowBoard now renders inside a framed section with heading, description and row count.",
"Loyalty workflow board copy now explains that rows are paid/completed order references.",
"Workflow sidebar is compacted on desktop to avoid partially cut nav rows in the verified viewport."
],
"checks": {
"chrome": "passed",
"typecheck": "passed",
"diffCheck": "passed",
"jsonParse": "passed"
},
"chromeMetrics": {
"viewport": {
"width": 1920,
"height": 905
},
"revenueCard": "15 đơn đã thu 733.000 \u20ab Doanh thu",
"board": {
"title": "\u0110\u01a1n \u0111\u00e3 thanh to\u00e1n g\u1ea7n \u0111\u00e2y",
"rowCount": 15,
"visibleAfterScroll": true
},
"sidebar": {
"totalLinks": 37,
"visibleLinks": 23,
"partiallyCutLinks": []
}
},
"screenshots": [
"01-chrome-loyalty-layout-after-fixes-port-3020.jpg",
"02-chrome-loyalty-board-context-port-3020.jpg"
],
"visualReview": [
"KPI doanh thu no longer shows a misleading zero while paid orders are visible.",
"Sidebar rows are fully visible in the top viewport; no half-cut link is visible at the bottom.",
"Paid/completed order references are framed by a board shell with title, description and count instead of appearing as loose rows.",
"UX/UI agent found no P0 or P1 blocker; remaining issues are P2 polish around disabled button contrast, dense reference board scanning and muted KPI labels."
],
"agents": {
"uxUiAgent": "P1 KPI mismatch, P1 unframed order rows, P2 sidebar cut risk.",
"codeExplorer": "Confirmed mixed scope: orders are all-time, dashboard revenue is today; WorkflowBoard lacked heading; sidebar is an internal scroller under fixed viewport."
}
}

View File

@@ -0,0 +1,53 @@
# 38 - Cafe Counter Order Edit Pending
Date: 2026-06-06
Base URL: http://localhost:3020
Workflow: `/pos/8d99d966-883e-4806-b247-ee940e6a779c/dialog/order-edit?vertical=cafe`
## Scope
Follow-up to the Cafe E2E gap audit. This slice turns `Sửa đơn` from an unsupported placeholder into a real counter workflow for Cafe orders that are still unpaid/pending:
- Select pending QR/customer order before counter payment.
- Edit existing line quantities or set a line to `0` to remove it.
- Add a Cafe product with size, sugar, ice, foam, topping and line note.
- Apply manual discount or voucher code before payment.
- Save the corrected order, keep it pending, then continue to method selection/payment.
## Code Changes
- Added `updateOrder` DB mutation for open unpaid orders.
- Mutation releases old reservations, replaces order lines, reserves new stock/recipe ingredients, recalculates total/discount, and blocks edits after barista preparation has started.
- Added BFF route `POST /api/bff/orders/:id/edit` for admin/staff.
- Removed `order-edit` from unsupported workflow list.
- Added the `order-edit` UI panel with pending order select, line quantity controls, add-item controls, discount controls and payment handoff.
- Added order-edit-specific CSS so the form uses full-width rows instead of the default three-column workflow panel.
## Verification
- `pnpm run typecheck`: passed before Chrome verification.
- Service smoke test created a real Cafe `customer_order` pending order and updated it:
- order id: `b7923ed5-192e-41aa-973a-662f83c46bed`
- before: `1` item, `50.000 đ`, status `Pending`, method `customer_order`
- after: `2` lines, `177.000 đ`, manual discount `5.000 đ`, status `Validated`
- Chrome UI test changed first line quantity from `2` to `3` and clicked `Lưu đơn đã sửa`.
- Chrome observed success notice: `Đã cập nhật đơn #C46BED · cần thu 227.000 ₫.`
- Chrome observed totals after save: `Hiện tại 227.000 ₫`, `Sau chỉnh 227.000 ₫`, `Giảm giá 5.000 ₫`.
- Visual review on saved screenshots found the initial two-column compression issue, then CSS was fixed and screenshots were recaptured.
- `git diff --check`: passed.
- `results.json`: parsed successfully.
## UX/UI Review
Independent visual review found no P0 or P1 blockers. Remaining P2 polish only:
- `Thêm món` is dense because modifier dropdowns occupy many compact columns.
- Modifier labels are less explicit than primary labels such as `Sản phẩm` and `Số lượng thêm`.
- Action buttons are intentionally large for POS, but visually heavy.
- Summary `Giảm giá` card leaves empty space to the right in the top view.
- The second screenshot cuts into the verification board at the bottom, but the main workflow remains fully readable.
## Screenshots
- `01-chrome-order-edit-saved-pending-port-3020.jpg` - top of the saved pending order edit workflow.
- `02-chrome-order-edit-controls-port-3020.jpg` - scrolled controls showing add item, discount, save and payment handoff.

View File

@@ -0,0 +1,76 @@
{
"date": "2026-06-06",
"baseUrl": "http://localhost:3020",
"workflow": "cafe_counter_order_edit_pending",
"status": "chrome_verified_with_screenshots",
"implemented": [
"Open unpaid orders can be edited before counter payment.",
"Existing line quantities can be changed or set to zero for removal.",
"Cafe products can be added with size, sugar, ice, foam, topping and line note.",
"Manual discount or voucher code can be saved before payment.",
"Saved pending order can continue to payment method selection."
],
"serverMutation": {
"endpoint": "POST /api/bff/orders/:id/edit",
"guards": [
"admin/staff shop role required",
"only open unpaid statuses can be edited",
"edit is blocked after barista preparation has started"
],
"dataEffects": [
"release old reserved inventory",
"replace order_items",
"reserve new inventory or recipe ingredients",
"recalculate order total and discount",
"log order.edited activity"
]
},
"serviceSmoke": {
"orderId": "b7923ed5-192e-41aa-973a-662f83c46bed",
"before": {
"items": 1,
"total": 50000,
"status": "Pending",
"paymentMethod": "customer_order"
},
"after": {
"items": 2,
"total": 177000,
"discount": 5000,
"discountType": "manual",
"status": "Validated",
"paymentMethod": "customer_order"
}
},
"chromeUi": {
"url": "http://localhost:3020/pos/8d99d966-883e-4806-b247-ee940e6a779c/dialog/order-edit?vertical=cafe",
"action": "changed first row quantity from 2 to 3 and clicked save",
"successNotice": "Đã cập nhật đơn #C46BED · cần thu 227.000 ₫.",
"quantityValues": [
"3",
"1"
],
"totals": [
"HIỆN TẠI 227.000 ₫",
"SAU CHỈNH 227.000 ₫",
"GIẢM GIÁ 5.000 ₫"
]
},
"visualReview": [
"Initial screenshot showed add/discount controls compressed into narrow right columns because order-edit inherited the generic three-column workflow panel.",
"CSS was updated so order-edit uses a single full-width column layout.",
"Final screenshots show full-width line rows, add item controls, discount controls, save action and payment handoff without overlap or text overflow.",
"Row name and price are separated onto readable lines after the final spacing fix.",
"UX/UI agent found no P0 or P1 blocker; remaining issues are P2 polish around dense modifier controls, heavy action buttons, summary spacing and screenshot crop."
],
"checks": {
"typecheck": "passed",
"chrome": "passed",
"jsonParse": "passed",
"diffCheck": "passed"
},
"screenshots": [
"01-chrome-order-edit-saved-pending-port-3020.jpg",
"02-chrome-order-edit-controls-port-3020.jpg"
]
}

View File

@@ -0,0 +1,23 @@
# Karaoke Workflow Room Timer - Port 3020
Scope: bổ sung và test bộ tính giờ trực tiếp trong workflow Karaoke `room-select` / `room-map` / `room-session`.
Shop: `GoodGo Karaoke MVP` (`4d55926b-fcd6-4610-8753-5bada5a13c95`)
QA room created for this slice: `QA-TMR-WF-36679` (`61c621cd-ba0e-461a-8dfd-29e0ca03dcf4`)
## Chrome Evidence
1. `01-chrome-room-ready-before-open-port-3020.jpg`
- URL: `/pos/4d55926b-fcd6-4610-8753-5bada5a13c95/karaoke/room-select`
- Selected QA room is available.
- Timer shows `00:00:00`, status `Sẵn sàng mở`, money metrics are `0 ₫`.
- CTA `Mở phòng & đếm giờ` is enabled.
2. `02-chrome-room-timer-running-after-open-port-3020.jpg`
- Same workflow after pressing `Mở phòng & đếm giờ`.
- Success notice: `Đã mở phòng QA-TMR-WF-36679 và bắt đầu tính giờ.`
- Timer is running, room status becomes `Đang hát`, money counter increases, and `Chốt bill & thu tiền` appears.
- Board `Phiên phòng và bộ đếm giờ` includes the new open room.
Structured evidence: `results.json`.

View File

@@ -0,0 +1,52 @@
{
"slice": 39,
"name": "karaoke-workflow-room-timer",
"port": 3020,
"shop": {
"id": "4d55926b-fcd6-4610-8753-5bada5a13c95",
"name": "GoodGo Karaoke MVP",
"vertical": "karaoke"
},
"createdRoom": {
"id": "61c621cd-ba0e-461a-8dfd-29e0ca03dcf4",
"tableNumber": "QA-TMR-WF-36679",
"hourlyRate": 180000,
"initialStatus": "Available"
},
"chrome": {
"url": "http://localhost:3020/pos/4d55926b-fcd6-4610-8753-5bada5a13c95/karaoke/room-select",
"beforeOpen": {
"selectedRoomId": "61c621cd-ba0e-461a-8dfd-29e0ca03dcf4",
"timer": "00:00:00",
"buttonDisabled": false,
"status": "Sẵn sàng mở",
"roomCharge": "0 ₫",
"fnB": "0 ₫",
"dueIfClosed": "0 ₫"
},
"afterOpen": {
"selectedRoomId": "61c621cd-ba0e-461a-8dfd-29e0ca03dcf4",
"selectedRoomLabel": "Phòng QA-TMR-WF-36679 · đang hát · 180.000 ₫ / giờ",
"timerObserved": "00:02:40",
"notice": "Đã mở phòng QA-TMR-WF-36679 và bắt đầu tính giờ.",
"checkoutHref": "/pos/4d55926b-fcd6-4610-8753-5bada5a13c95/payment/method-select/cfd7c301-c7b9-4589-8205-b8901cd9c046?vertical=karaoke",
"boardTitle": "Phiên phòng và bộ đếm giờ",
"metricTexts": [
"Trạng tháiĐang hát",
"Tiền giờ8.000 ₫",
"F&B phòng0 ₫",
"Cần thu nếu chốt8.000 ₫"
],
"disabledOpenButtonDeEmphasized": true
},
"screenshots": {
"beforeOpen": "01-chrome-room-ready-before-open-port-3020.jpg",
"afterOpen": "02-chrome-room-timer-running-after-open-port-3020.jpg"
}
},
"checks": {
"typecheck": "passed",
"chromeOpenRoomFlow": "passed",
"uxReview": "passed-no-p0-p1"
}
}

View File

@@ -3,7 +3,7 @@ import { cookies } from "next/headers";
import { createCatalogCategory, createCatalogProduct, getCatalogCategory } from "@/server/services/catalog";
import { createTableService, createZoneService, getTable, regenerateTableQrService } from "@/server/services/fnb";
import { createInventoryItemService, getInventoryService, inventoryAdjust, recordWastage, stockIn, stocktakeBatch, stockOut } from "@/server/services/inventory";
import { cancelOrderService, createPosOrder, getOrderService, payOrderService, returnOrderService, transferOrderTableService } from "@/server/services/order";
import { cancelOrderService, createPosOrder, getOrderService, payOrderService, returnOrderService, transferOrderTableService, updateOrderService } from "@/server/services/order";
import { createShopService, updateShopService } from "@/server/services/shop";
import { addExperience, assignUserShopRole, checkIn, checkOut, closeDayReport, createAppointment, createCampaign, createFileRecord, createFolder, createKitchenTicket, createLeaveRequest, createMember, createRecipe, createReservation, createReceiptTemplate, createSchedule, createStaff, createStaffWithAccount, getBaristaQueueItem, getCampaign, getFolder, getAiConfig, getSocialConnection, getLeaveRequest, getMember, getStaff, getVoucher, issueCampaignVouchers, loginUser, logoutSession, registerUser, requestPasswordReset, resetPassword, revokeVoucher, redeemVoucher, recordSocialPost, saveAiMessage, sessionCookieName, setCampaignStatus, updateBaristaQueue, updateLeaveStatus, verifyEmail } from "@/server/services/parity";
import { aiProviderCredentialStatus, buildS3ObjectKey, callConfiguredAi, publishSocial, socialProviderCredentialStatus, uploadS3Object } from "@/server/integrations/external";
@@ -253,6 +253,13 @@ export async function handlePost(request: Request, context: RouteContext) {
}
return ok(await createPosOrder({ ...body, shopId: scoped.shopId } as Parameters<typeof createPosOrder>[0]), { status: 201 });
}
if (path[0] === "orders" && path[2] === "edit") {
const shopId = requestShopId(body, url);
if (!shopId) return fail("shopId is required", { status: 400 });
const denied = await requireShopRoleAccess(shopId, ["admin", "staff"]);
if (denied) return fail(denied.message, { status: denied.status });
return ok(await updateOrderService(path[1] ?? "", { ...body, shopId }));
}
if (path[0] === "orders" && path[2] === "pay") {
const shopId = requestShopId(body, url);
if (!shopId) return fail("shopId is required", { status: 400 });

View File

@@ -54,10 +54,36 @@
.workflow-shell {
background: #0a0a0b;
height: calc(100dvh - 48px);
overflow: hidden;
}
.workflow-sidebar {
max-height: calc(100dvh - 48px);
display: grid;
align-content: start;
gap: 5px;
padding: 10px 10px 28px;
scrollbar-gutter: stable;
}
.workflow-sidebar .workflow-link {
min-height: 32px;
gap: 9px;
padding: 0 10px;
font-size: 12.75px;
font-weight: 750;
}
.workflow-sidebar .workflow-link svg {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.workflow-main {
padding: 24px 28px;
min-height: 0;
}
.workflow-hero h1 {
@@ -173,7 +199,8 @@
.workflow-action-panel--queue-display,
.workflow-action-panel--cafe-config,
.workflow-action-panel--loyalty,
.workflow-action-panel--journey {
.workflow-action-panel--journey,
.workflow-action-panel--room-timer {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
}
@@ -263,6 +290,146 @@
gap: 10px;
}
.workflow-room-timer__heading {
display: grid;
gap: 4px;
}
.workflow-room-timer__clock {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 4px 10px;
align-items: center;
min-height: 118px;
padding: 16px;
border: 1px solid #2a2a2e;
border-radius: 12px;
background: #0f0f12;
color: #adadb0;
}
.workflow-room-timer__clock svg {
grid-row: 1 / span 3;
color: #a3a3aa;
}
.workflow-room-timer__clock strong {
color: #ffffff;
font-size: clamp(34px, 6vw, 54px);
font-variant-numeric: tabular-nums;
line-height: 1;
}
.workflow-room-timer__clock small,
.workflow-room-timer__clock span {
min-width: 0;
color: #adadb0;
font-size: 12px;
font-weight: 800;
overflow-wrap: anywhere;
}
.workflow-room-timer__clock--running {
border-color: rgba(34, 197, 94, 0.65);
background: rgba(34, 197, 94, 0.08);
}
.workflow-room-timer__clock--running svg,
.workflow-room-timer__clock--running span {
color: #4ade80;
}
.workflow-room-timer__metrics,
.workflow-room-timer__open-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 10px;
}
.workflow-room-timer__metrics span,
.workflow-room-timer__open-card {
display: grid;
gap: 5px;
min-width: 0;
padding: 12px;
border: 1px solid #2a2a2e;
border-radius: 10px;
background: #101014;
}
.workflow-room-timer__metrics small,
.workflow-room-timer__open-card span,
.workflow-room-timer__open-card small {
min-width: 0;
color: #adadb0;
font-size: 12px;
font-weight: 750;
overflow-wrap: anywhere;
}
.workflow-room-timer__metrics b,
.workflow-room-timer__open-card strong {
min-width: 0;
color: #ffffff;
font-size: 17px;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.workflow-room-timer__open-card--active {
border-color: rgba(255, 92, 0, 0.62);
background: rgba(255, 92, 0, 0.1);
}
.workflow-action-panel--room-timer .pos-btn-checkout:disabled {
border: 1px solid #2a2a2e;
background: #231f1d;
color: #8f8f96;
box-shadow: none;
opacity: 0.72;
}
.workflow-board-shell {
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid #2a2a2e;
border-radius: 14px;
background: #111114;
}
.workflow-board-shell__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.workflow-board-shell__header h2 {
margin: 3px 0;
color: #ffffff;
font-size: 18px;
line-height: 1.2;
}
.workflow-board-shell__header p {
margin: 0;
color: #adadb0;
font-size: 12px;
}
.workflow-board-shell__header strong {
min-width: 42px;
min-height: 42px;
display: grid;
place-items: center;
border: 1px solid #2a2a2e;
border-radius: 12px;
background: #0a0a0b;
color: #ff7a2f;
font-size: 16px;
}
.workflow-queue-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -300,6 +467,67 @@
gap: 10px;
}
.workflow-action-panel--order-edit {
grid-template-columns: 1fr;
align-items: stretch;
}
.workflow-order-edit-list,
.workflow-order-edit-add,
.workflow-order-edit-discount {
display: grid;
gap: 10px;
}
.workflow-order-edit-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(120px, 160px) auto;
align-items: end;
gap: 10px;
padding: 12px;
border: 1px solid #2a2a2e;
border-radius: 10px;
background: #101014;
}
.workflow-order-edit-row > div:first-child {
display: grid;
gap: 4px;
}
.workflow-order-edit-row strong,
.workflow-order-edit-add strong {
color: #ffffff;
font-size: 14px;
}
.workflow-order-edit-row span,
.workflow-order-edit-add span {
color: #a9a9ad;
font-size: 12px;
font-weight: 700;
}
.workflow-order-edit-row .ghost-action {
min-height: 40px;
align-self: end;
}
.workflow-order-edit-add,
.workflow-order-edit-discount {
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
padding: 12px;
border: 1px solid #2a2a2e;
border-radius: 10px;
background: #101014;
}
.workflow-order-edit-add > div:first-child {
display: grid;
gap: 4px;
align-content: center;
}
.workflow-payment-method-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1042,6 +1270,16 @@
padding: 14px;
}
.workflow-order-edit-row {
grid-template-columns: 1fr;
}
.workflow-order-edit-row .ghost-action,
.workflow-action-panel--order-edit .pos-btn-checkout,
.workflow-action-panel--order-edit .workflow-payment-method {
width: 100%;
}
.workflow-hero {
align-items: flex-start;
gap: 10px;

View File

@@ -5,7 +5,7 @@ import { ArrowLeft, Coffee } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { OrderSummary, Product, Shop, TableInfo } from "@/server/domain/types";
import type { VerticalKind } from "./tpos-config";
import { currency, orderNetAmount } from "./TposPosUtils";
import { currency, isPaidOrCompleted, orderNetAmount } from "./TposPosUtils";
import { workflowHref, type WorkflowDataRow } from "./TposWorkflowRoutes";
type WorkflowEntry = { slug: string; title: string; description: string; icon: LucideIcon };
@@ -83,12 +83,17 @@ export function WorkflowMetricGrid({ dashboard, orders, products, tables }: {
products: Product[];
tables: TableInfo[];
}) {
const paidOrders = orders.filter((order) => isPaidOrCompleted(order));
const paidRevenue = paidOrders.reduce((sum, order) => sum + orderNetAmount(order), 0);
const dashboardRevenue = Number(dashboard.revenue ?? 0);
const revenueValue = paidRevenue > 0 ? paidRevenue : dashboardRevenue;
const revenueMeta = paidOrders.length > 0 ? `${paidOrders.length} đơn đã thu` : "Chưa có đơn đã thu";
return (
<div className="workflow-grid workflow-grid--data">
<DataCard title="Sản phẩm" value={products.length} meta="Danh mục đang bán" />
<DataCard title="Bàn/phòng" value={tables.length} meta="Khu vực phục vụ" />
<DataCard title="Đơn hàng" value={orders.length} meta="Lịch sử vận hành" />
<DataCard title="Doanh thu" value={currency.format(Number(dashboard.revenue ?? 0))} meta="Đơn đã thu" />
<DataCard title="Doanh thu" value={currency.format(revenueValue)} meta={revenueMeta} />
</div>
);
}
@@ -119,18 +124,28 @@ export function WorkflowContextPanel({ contextOrder, contextTable, methodSelectW
);
}
export function WorkflowBoard({ rows }: { rows: WorkflowDataRow[] }) {
export function WorkflowBoard({ rows, title = "Dữ liệu vận hành", description = "Các bản ghi thật đang được dùng để kiểm chứng workflow này." }: { rows: WorkflowDataRow[]; title?: string; description?: string }) {
return (
<div className="workflow-board">
{rows.map((row) => (
<article key={row.id} className="workflow-ticket">
<strong>{row.title}</strong>
<span>{row.meta}</span>
<b>{row.value}</b>
</article>
))}
{rows.length === 0 ? <div className="pos-empty">Chưa dữ liệu vận hành cho workflow này</div> : null}
</div>
<section className="workflow-board-shell">
<div className="workflow-board-shell__header">
<div>
<span className="eyebrow">DỮ LIỆU KIỂM CHỨNG</span>
<h2>{title}</h2>
<p>{description}</p>
</div>
{rows.length ? <strong>{rows.length}</strong> : null}
</div>
<div className="workflow-board">
{rows.map((row) => (
<article key={row.id} className="workflow-ticket">
<strong>{row.title}</strong>
<span>{row.meta}</span>
<b>{row.value}</b>
</article>
))}
{rows.length === 0 ? <div className="pos-empty">Chưa dữ liệu vận hành cho workflow này</div> : null}
</div>
</section>
);
}

View File

@@ -205,7 +205,7 @@ export function workflowDataRows(slug: string, data: {
}));
return [...orderRows, ...queueRows];
}
if (slug === "table-map" || slug === "room-map" || slug === "table-select" || slug === "room-select" || slug === "room-session") {
if (slug === "table-map" || slug === "room-map" || slug === "table-select" || slug === "room-select" || slug === "room-session" || slug === "room-reset") {
return data.tables.map((table) => ({
id: table.id,
title: `${slug.includes("room") ? "Phòng" : "Bàn"} ${table.tableNumber}`,

View File

@@ -3,7 +3,7 @@
import Link from "next/link";
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 { Banknote, Building2, Check, Clock, Coffee, CreditCard, DoorOpen, ReceiptText, RotateCcw, Smartphone, Trash2 } from "lucide-react";
import type { InventoryItem, OrderSummary, Product, Shop, TableInfo, TableSessionInfo } from "@/server/domain/types";
import { posWorkflows, type VerticalKind } from "./tpos-config";
import { currency, formatRoomSessionDuration, isPaidOrCompleted, orderItemNetQuantity, orderNetAmount, orderStatusLabel, paymentMethodLabel, roomSessionCharge, roomSessionElapsedSeconds, toFiniteNumber } from "./TposPosUtils";
@@ -57,6 +57,8 @@ const paymentChoiceOptions = [
{ id: "transfer", route: "transfer", label: "Chuyển khoản", icon: Building2, enabled: true }
];
const karaokeRoomTimerSlugs = new Set(["room-map", "room-select", "room-session", "room-reset"]);
function queueText(value: unknown, fallback = "") {
const text = String(value ?? "").trim();
return text || fallback;
@@ -89,6 +91,24 @@ function queueTicketMeta(item: BaristaQueueItem) {
return parts.join(" · ");
}
function isEditableCounterOrder(order: OrderSummary) {
const status = String(order.status ?? "");
return !isPaidOrCompleted(order) && order.statusId !== 6 && order.statusId !== 8 && !/cancel|void|hủy|return|refund|hoàn/i.test(status);
}
function isOpenRoomFnbOrder(order: OrderSummary) {
return String(order.paymentMethod ?? "").toLowerCase() === "room_fnb" && isEditableCounterOrder(order);
}
function orderItemDetail(item: OrderSummary["items"][number]) {
const metadata = item.metadata ?? {};
const parts = [
metadata.modifierSummary ? String(metadata.modifierSummary) : "",
metadata.lineNote ? `Ghi chú: ${String(metadata.lineNote)}` : ""
].filter(Boolean);
return parts.join(" · ");
}
export function WorkflowScreen({
shop,
vertical,
@@ -159,18 +179,37 @@ export function WorkflowScreen({
const [selectedCafeIce, setSelectedCafeIce] = useState("Đá thường");
const [selectedCafeFoam, setSelectedCafeFoam] = useState("none");
const [selectedCafeTopping, setSelectedCafeTopping] = useState("none");
const [selectedEditOrderId, setSelectedEditOrderId] = useState(() => {
if (contextOrder && isEditableCounterOrder(contextOrder)) return contextOrder.id;
return orders.find(isEditableCounterOrder)?.id ?? "";
});
const [editQuantities, setEditQuantities] = useState<Record<string, string>>({});
const [editAddQuantity, setEditAddQuantity] = useState("0");
const [editLineNote, setEditLineNote] = useState("");
const [editManualDiscount, setEditManualDiscount] = useState("");
const [editVoucherCode, setEditVoucherCode] = useState("");
const [editOrderNotes, setEditOrderNotes] = useState("");
const [editRevisionNote, setEditRevisionNote] = useState("Khách đổi món tại quầy");
const [loyaltyMembers, setLoyaltyMembers] = useState<LoyaltyMember[]>(initialLoyaltyMembers);
const [selectedMemberId, setSelectedMemberId] = useState(initialLoyaltyMembers[0]?.id ?? "");
const [loyaltyPoints, setLoyaltyPoints] = useState("10");
const [selectedLoyaltyOrderId, setSelectedLoyaltyOrderId] = useState(() => orders.find((order) => isPaidOrCompleted(order))?.id ?? "");
const [loyaltyStampedReferences, setLoyaltyStampedReferences] = useState<Record<string, true>>({});
const [closedWorkflowRoomOrderIds, setClosedWorkflowRoomOrderIds] = useState<Record<string, true>>({});
const [workflowTableSessions, setWorkflowTableSessions] = useState<TableSessionInfo[]>(() => tableSessions ?? []);
const [selectedWorkflowRoomId, setSelectedWorkflowRoomId] = useState(() => {
if (vertical === "karaoke" && contextTable) return contextTable.id;
const openSession = tableSessions?.find((session) => session.status.toLowerCase() === "open" && !session.closedAt);
return openSession?.tableId ?? tables.find((table) => table.statusId === 1)?.id ?? tables[0]?.id ?? "";
});
const [roomResetReason, setRoomResetReason] = useState("Dọn phòng sau khi khách rời");
const [nowMs, setNowMs] = useState(0);
const [tenderedTouched, setTenderedTouched] = useState(false);
const [isWorkflowPending, startWorkflowTransition] = useTransition();
const contextOrderTable = contextOrder?.tableId ? tables.find((table) => table.id === contextOrder.tableId) : undefined;
const contextRoomSession = vertical === "karaoke" && contextOrder?.tableId
? tableSessions?.find((session) => session.tableId === contextOrder.tableId && session.status.toLowerCase() === "open" && !session.closedAt)
? workflowTableSessions.find((session) => session.tableId === contextOrder.tableId && session.status.toLowerCase() === "open" && !session.closedAt)
: undefined;
const contextRoomElapsedSeconds = roomSessionElapsedSeconds(contextRoomSession?.startedAt, nowMs);
const contextRoomCharge = contextOrder && contextRoomSession && contextOrderTable
@@ -189,6 +228,19 @@ export function WorkflowScreen({
return () => window.clearInterval(timer);
}, [vertical]);
useEffect(() => {
setWorkflowTableSessions(tableSessions ?? []);
}, [tableSessions]);
useEffect(() => {
if (vertical !== "karaoke" || !karaokeRoomTimerSlugs.has(slug)) return;
setSelectedWorkflowRoomId((current) => {
if (current && tables.some((table) => table.id === current)) return current;
const openSession = workflowTableSessions.find((session) => session.status.toLowerCase() === "open" && !session.closedAt);
return contextTable?.id ?? openSession?.tableId ?? tables.find((table) => table.statusId === 1)?.id ?? tables[0]?.id ?? "";
});
}, [contextTable?.id, slug, tables, vertical, workflowTableSessions]);
useEffect(() => {
if (!contextOrder || tenderedTouched) return;
setTendered(String(contextPaymentDue));
@@ -441,6 +493,94 @@ export function WorkflowScreen({
});
}
function startSelectedRoomSession() {
if (!selectedWorkflowRoom) {
setWorkflowMessage("Chọn phòng karaoke trước khi mở phiên hát.");
return;
}
if (selectedWorkflowRoomSession) {
setWorkflowMessage(`Phòng ${selectedWorkflowRoom.tableNumber} đang đếm giờ.`);
return;
}
const room = selectedWorkflowRoom;
startWorkflowTransition(async () => {
const response = await fetch("/api/bff/pos/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
shopId: shop.id,
tableId: room.id,
paymentMethod: "room_fnb",
deferPayment: true,
statusId: 2,
notes: `Mở phòng karaoke ${room.tableNumber} từ workflow ${slug}`,
items: []
})
});
const payload = await response.json().catch(() => ({})) as ApiEnvelope<{ id?: string }>;
if (!response.ok || payload.success === false) {
setWorkflowMessage(payload.error ?? "Không thể mở phòng karaoke.");
return;
}
setWorkflowTableSessions((current) => {
const hasOpenSession = current.some((session) => session.tableId === room.id && session.status.toLowerCase() === "open" && !session.closedAt);
if (hasOpenSession) return current;
return [
{
id: payload.data?.id ? `local-${payload.data.id}` : `local-${room.id}-${Date.now()}`,
shopId: shop.id,
tableId: room.id,
status: "open",
guestCount: 1,
startedAt: new Date().toISOString(),
closedAt: null
},
...current
];
});
setSelectedWorkflowRoomId(room.id);
setWorkflowMessage(`Đã mở phòng ${room.tableNumber} và bắt đầu tính giờ.`);
router.refresh();
});
}
function resetSelectedRoomSession() {
if (!selectedWorkflowRoom) {
setWorkflowMessage("Chọn phòng karaoke trước khi reset.");
return;
}
if (!selectedWorkflowRoomSession) {
setWorkflowMessage(`Phòng ${selectedWorkflowRoom.tableNumber} chưa mở phiên hát.`);
return;
}
if (!selectedWorkflowRoomOrder) {
setWorkflowMessage(`Không tìm thấy bill phòng ${selectedWorkflowRoom.tableNumber} để đóng phiên an toàn.`);
return;
}
const room = selectedWorkflowRoom;
const order = selectedWorkflowRoomOrder;
const reason = roomResetReason.trim() || "Reset phòng karaoke";
startWorkflowTransition(async () => {
const response = await fetch(`/api/bff/orders/${order.id}/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
shopId: shop.id,
reason: `Reset phòng ${room.tableNumber}: ${reason}`
})
});
const payload = await response.json().catch(() => ({})) as ApiEnvelope<OrderSummary>;
if (!response.ok || payload.success === false) {
setWorkflowMessage(payload.error ?? "Không thể reset phòng karaoke.");
return;
}
setWorkflowTableSessions((current) => current.filter((session) => session.tableId !== room.id || session.status.toLowerCase() !== "open"));
setClosedWorkflowRoomOrderIds((current) => ({ ...current, [order.id]: true }));
setWorkflowMessage(`Đã reset phòng ${room.tableNumber}, đóng phiên và đưa về sẵn sàng.`);
router.refresh();
});
}
function updateShiftAttendance(action: "check-in" | "check-out") {
startWorkflowTransition(async () => {
const response = await fetch(`/api/bff/staff/me/attendance/${action}`, { method: "POST" });
@@ -482,6 +622,82 @@ export function WorkflowScreen({
});
}
function updateEditQuantity(itemId: string, value: string) {
const quantity = Math.max(0, Math.trunc(Number(value || 0)));
setEditQuantities((current) => ({ ...current, [itemId]: Number.isFinite(quantity) ? String(quantity) : "0" }));
}
function submitOrderEdit() {
if (!selectedEditableOrder) {
setWorkflowMessage("Không có đơn chờ thanh toán để sửa.");
return;
}
const existingItems: Array<{
id?: string;
productId: string;
quantity: number;
modifiers?: {
size: string;
sugar: string;
ice: string;
foam: string;
topping: string;
};
note?: string | null;
}> = selectedEditableOrder.items.map((item) => ({
id: item.id,
productId: item.productId,
quantity: Math.max(0, Math.trunc(Number(editQuantities[item.id] ?? item.quantity)))
}));
const addQuantity = Math.max(0, Math.trunc(Number(editAddQuantity || 0)));
const items = [...existingItems];
if (selectedEditProduct && addQuantity > 0) {
items.push({
productId: selectedEditProduct.id,
quantity: addQuantity,
modifiers: {
size: selectedCafeSize,
sugar: selectedCafeSugar,
ice: selectedCafeIce,
foam: selectedCafeFoam,
topping: selectedCafeTopping
},
note: editLineNote.trim() || null
});
}
if (!items.some((item) => item.quantity > 0)) {
setWorkflowMessage("Đơn sau sửa phải còn ít nhất một món.");
return;
}
const manualDiscount = Math.max(0, Number(editManualDiscount || 0));
startWorkflowTransition(async () => {
const response = await fetch(`/api/bff/orders/${selectedEditableOrder.id}/edit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
shopId: shop.id,
items,
discountAmount: editVoucherCode.trim() ? 0 : manualDiscount,
discountType: editVoucherCode.trim() ? "voucher" : manualDiscount > 0 ? "manual" : null,
discountReference: editVoucherCode.trim() || null,
voucherCode: editVoucherCode.trim() || null,
notes: editOrderNotes.trim() || undefined,
revisionNote: editRevisionNote.trim() || "Sửa đơn tại quầy"
})
});
const payload = await response.json() as ApiEnvelope<OrderSummary>;
if (!response.ok || !payload.success) {
setWorkflowMessage(payload.error ?? "Không thể cập nhật đơn");
return;
}
const order = payload.data;
setEditAddQuantity("0");
setEditLineNote("");
setWorkflowMessage(`Đã cập nhật đơn ${pickupNumberFromValue(selectedEditableOrder.id)}${order ? ` · cần thu ${currency.format(orderNetAmount(order))}` : ""}.`);
router.refresh();
});
}
function updateBaristaQueueStatus(action: "in-progress" | "ready" | "delivered") {
const queueItem = displayBaristaQueue.find((item) => String(item.id) === selectedQueueItemId);
if (!queueItem) {
@@ -577,11 +793,46 @@ export function WorkflowScreen({
const override = baristaStatusOverrides[String(item.id)];
return override ? { ...item, status: override } : item;
});
const workflowRows = workflowDataRows(slug, { orders, tables, tableSessions, products, inventory, kitchenTickets, baristaQueue: displayBaristaQueue, nowMs });
const roomTimerWorkflow = vertical === "karaoke" && karaokeRoomTimerSlugs.has(slug);
const roomResetWorkflow = vertical === "karaoke" && slug === "room-reset";
const workflowRoomSessionByTableId = new Map(workflowTableSessions
.filter((session) => session.status.toLowerCase() === "open" && !session.closedAt)
.map((session) => [session.tableId, session]));
const selectedWorkflowRoom = tables.find((table) => table.id === selectedWorkflowRoomId) ?? tables[0] ?? null;
const selectedWorkflowRoomSession = selectedWorkflowRoom ? workflowRoomSessionByTableId.get(selectedWorkflowRoom.id) : undefined;
const selectedWorkflowRoomElapsedSeconds = roomSessionElapsedSeconds(selectedWorkflowRoomSession?.startedAt, nowMs);
const selectedWorkflowRoomCharge = selectedWorkflowRoom
? roomSessionCharge(selectedWorkflowRoom.hourlyRate, selectedWorkflowRoomElapsedSeconds)
: 0;
const selectedWorkflowRoomDurationLabel = selectedWorkflowRoomSession
? formatRoomSessionDuration(selectedWorkflowRoomElapsedSeconds)
: "00:00:00";
const selectedWorkflowRoomStartTime = selectedWorkflowRoomSession?.startedAt ? formatRoomStartTime(selectedWorkflowRoomSession.startedAt) : "";
const selectedWorkflowRoomOrder = selectedWorkflowRoom
? orders.find((order) => order.tableId === selectedWorkflowRoom.id && isOpenRoomFnbOrder(order) && !closedWorkflowRoomOrderIds[order.id])
: undefined;
const openWorkflowRoomSessions = tables.flatMap((table) => {
const session = workflowRoomSessionByTableId.get(table.id);
if (!session) return [];
const elapsedSeconds = roomSessionElapsedSeconds(session.startedAt, nowMs);
return [{
table,
session,
elapsedSeconds,
charge: roomSessionCharge(table.hourlyRate, elapsedSeconds)
}];
});
const workflowRows = workflowDataRows(slug, { orders, tables, tableSessions: workflowTableSessions, products, inventory, kitchenTickets, baristaQueue: displayBaristaQueue, nowMs });
const visibleWorkflowRows = workflowRows.filter((row) => {
const needle = workflowQuery.trim().toLowerCase();
return !needle || `${row.title} ${row.meta} ${row.value}`.toLowerCase().includes(needle);
});
const workflowBoardTitle = slug === "loyalty-stamp" ? "Đơn đã thanh toán gần đây" : roomTimerWorkflow ? "Phiên phòng và bộ đếm giờ" : "Dữ liệu vận hành";
const workflowBoardDescription = slug === "loyalty-stamp"
? "Danh sách đơn paid/completed dùng làm tham chiếu tích điểm, không phải dữ liệu rời khỏi form."
: roomTimerWorkflow
? "Mỗi phòng đang mở hiển thị thời lượng live và tiền giờ tạm tính theo đơn giá cấu hình."
: "Các bản ghi thật đang được dùng để kiểm chứng workflow này.";
const returnableContextItems = contextOrder?.items.filter((item) => orderItemNetQuantity(item) > 0) ?? [];
const refundableContextAmount = contextOrder ? orderNetAmount(contextOrder) : 0;
const selectedQueueItem = displayBaristaQueue.find((item) => String(item.id) === selectedQueueItemId);
@@ -609,10 +860,42 @@ export function WorkflowScreen({
const selectedLoyaltyOrder = loyaltyPaidOrders.find((order) => order.id === selectedLoyaltyOrderId) ?? loyaltyPaidOrders[0] ?? null;
const selectedLoyaltySuggestedPoints = selectedLoyaltyOrder ? Math.max(1, Math.floor(orderNetAmount(selectedLoyaltyOrder) / 10000)) : 10;
const selectedLoyaltyOrderStamped = Boolean(selectedMember && selectedLoyaltyOrder && loyaltyStampedReferences[`${selectedMember.id}:${selectedLoyaltyOrder.id}`]);
const pendingCounterOrders = orders.filter((order) => {
const status = String(order.status ?? "");
return !isPaidOrCompleted(order) && order.statusId !== 6 && order.statusId !== 8 && !/cancel|void|hủy|return|refund|hoàn/i.test(status);
});
const editableCounterOrders = orders.filter(isEditableCounterOrder);
const pendingCounterOrders = editableCounterOrders;
const selectedEditableOrder = (contextOrder && isEditableCounterOrder(contextOrder) && (!selectedEditOrderId || selectedEditOrderId === contextOrder.id)
? contextOrder
: editableCounterOrders.find((order) => order.id === selectedEditOrderId))
?? editableCounterOrders[0]
?? null;
const selectedEditProduct = products.find((product) => product.id === selectedCafeProductId) ?? products[0] ?? null;
const editAddQuantityNumber = Math.max(0, Math.trunc(Number(editAddQuantity || 0)));
const editExistingSubtotal = selectedEditableOrder
? selectedEditableOrder.items.reduce((sum, item) => {
const quantity = Math.max(0, Math.trunc(Number(editQuantities[item.id] ?? item.quantity)));
return sum + quantity * toFiniteNumber(item.unitPrice, 0);
}, 0)
: 0;
const editAddLineTotal = selectedEditProduct && editAddQuantityNumber > 0
? editAddQuantityNumber * ((selectedEditProduct.price ?? 0) + cafeModifierPrice({ size: selectedCafeSize, sugar: selectedCafeSugar, ice: selectedCafeIce, foam: selectedCafeFoam, topping: selectedCafeTopping }))
: 0;
const editPreviewSubtotal = editExistingSubtotal + editAddLineTotal;
const editPreviewDiscount = editVoucherCode.trim()
? selectedEditableOrder?.discountType === "voucher" && selectedEditableOrder.discountReference?.toLowerCase() === editVoucherCode.trim().toLowerCase()
? Math.min(editPreviewSubtotal, selectedEditableOrder.discountAmount)
: 0
: Math.min(editPreviewSubtotal, Math.max(0, Number(editManualDiscount || 0)));
const editPreviewTotal = Math.max(0, editPreviewSubtotal - editPreviewDiscount);
useEffect(() => {
if (slug !== "order-edit") return;
const nextOrder = selectedEditableOrder;
if (!nextOrder) return;
if (selectedEditOrderId !== nextOrder.id) setSelectedEditOrderId(nextOrder.id);
setEditQuantities(Object.fromEntries(nextOrder.items.map((item) => [item.id, String(item.quantity)])));
setEditManualDiscount(nextOrder.discountType === "manual" ? String(nextOrder.discountAmount || "") : "");
setEditVoucherCode(nextOrder.discountType === "voucher" ? nextOrder.discountReference ?? "" : "");
setEditOrderNotes("");
}, [selectedEditableOrder?.id, selectedEditOrderId, slug]);
const unsupportedReason = workflowUnsupportedReason(slug);
if (unsupportedReason) {
return (
@@ -687,6 +970,92 @@ export function WorkflowScreen({
<WorkflowHero icon={WorkflowIcon} vertical={vertical} title={workflow?.title ?? slug} description={workflow?.description ?? "Luồng vận hành TPOS."} />
<WorkflowMetricGrid dashboard={dashboard} orders={orders} products={products} tables={tables} />
<WorkflowContextPanel contextOrder={contextOrder} contextTable={contextTable} methodSelectWorkflow={methodSelectWorkflow} paymentWorkflow={paymentWorkflow} shop={shop} />
{roomTimerWorkflow ? (
<div className="workflow-action-panel workflow-action-panel--room-timer">
<div className="workflow-room-timer__heading">
<span className="eyebrow">BỘ TÍNH GIỜ PHÒNG</span>
<h2>{selectedWorkflowRoom ? `Phòng ${selectedWorkflowRoom.tableNumber}` : "Chọn phòng để mở phiên hát"}</h2>
<p>
{selectedWorkflowRoomSession && selectedWorkflowRoom
? roomResetWorkflow
? `Đang đếm từ ${selectedWorkflowRoomStartTime}; reset sẽ hủy bill chưa thu, đóng phiên và đưa phòng về sẵn sàng.`
: `Đang đếm từ ${selectedWorkflowRoomStartTime}; tiền giờ được cộng vào bill khi chốt phòng.`
: roomResetWorkflow
? "Chọn phòng đang hát để đóng phiên, dọn phòng và đưa về trạng thái sẵn sàng."
: "Bấm mở phòng để tạo phiên karaoke, bắt đầu đếm giờ và giữ bill chờ thanh toán."}
</p>
</div>
<label>
<span>Phòng karaoke</span>
<select value={selectedWorkflowRoom?.id ?? ""} onChange={(event) => setSelectedWorkflowRoomId(event.target.value)}>
{tables.map((table) => {
const session = workflowRoomSessionByTableId.get(table.id);
return (
<option key={table.id} value={table.id}>
Phòng {table.tableNumber} · {session ? "đang hát" : table.status} · {currency.format(table.hourlyRate)} / giờ
</option>
);
})}
</select>
</label>
<div className={selectedWorkflowRoomSession ? "workflow-room-timer__clock workflow-room-timer__clock--running" : "workflow-room-timer__clock"}>
<Clock size={22} />
<span>{selectedWorkflowRoomSession ? "Đang đếm giờ" : "Chưa mở phòng"}</span>
<strong suppressHydrationWarning>{selectedWorkflowRoomDurationLabel}</strong>
<small>{selectedWorkflowRoomSession && selectedWorkflowRoom ? `${currency.format(selectedWorkflowRoom.hourlyRate)} / giờ` : "00:00:00 trước khi mở phiên"}</small>
</div>
<div className="workflow-room-timer__metrics">
<span><small>Trạng thái</small><b>{selectedWorkflowRoomSession ? "Đang hát" : "Sẵn sàng mở"}</b></span>
<span><small>Tiền giờ</small><b suppressHydrationWarning>{currency.format(selectedWorkflowRoomCharge)}</b></span>
<span><small>F&B phòng</small><b>{currency.format(selectedWorkflowRoomOrder ? orderNetAmount(selectedWorkflowRoomOrder) : 0)}</b></span>
<span><small>Cần thu nếu chốt</small><b suppressHydrationWarning>{currency.format(selectedWorkflowRoomCharge + (selectedWorkflowRoomOrder ? orderNetAmount(selectedWorkflowRoomOrder) : 0))}</b></span>
</div>
{roomResetWorkflow ? (
<label>
<span> do reset phòng</span>
<input value={roomResetReason} onChange={(event) => setRoomResetReason(event.target.value)} placeholder="Dọn phòng, khách rời, hủy phiên..." />
</label>
) : null}
<div className="workflow-payment-method-grid">
{roomResetWorkflow ? (
<button className="pos-btn-checkout" type="button" disabled={isWorkflowPending || !selectedWorkflowRoom || !selectedWorkflowRoomSession || !selectedWorkflowRoomOrder} onClick={resetSelectedRoomSession}>
<RotateCcw size={17} />
{isWorkflowPending ? "Đang reset phòng" : "Reset phòng & hủy bill"}
</button>
) : (
<button className="pos-btn-checkout" type="button" disabled={isWorkflowPending || !selectedWorkflowRoom || Boolean(selectedWorkflowRoomSession)} onClick={startSelectedRoomSession}>
<DoorOpen size={17} />
{selectedWorkflowRoomSession ? "Phòng đang đếm giờ" : isWorkflowPending ? "Đang mở phòng" : "Mở phòng & đếm giờ"}
</button>
)}
{selectedWorkflowRoomOrder ? (
<Link className="workflow-payment-method" href={`/pos/${shop.id}/payment/method-select/${selectedWorkflowRoomOrder.id}?vertical=${encodeURIComponent(vertical)}`}>
<ReceiptText size={17} />
<span>Chốt bill & thu tiền</span>
</Link>
) : (
<Link className="workflow-payment-method" href={`/pos/${shop.id}/${vertical}`}>
<Coffee size={17} />
<span>Mở POS phòng</span>
</Link>
)}
</div>
{openWorkflowRoomSessions.length ? (
<div className="workflow-room-timer__open-list">
{openWorkflowRoomSessions.slice(0, 6).map(({ table, session, elapsedSeconds, charge }) => (
<article key={session.id} className={table.id === selectedWorkflowRoom?.id ? "workflow-room-timer__open-card workflow-room-timer__open-card--active" : "workflow-room-timer__open-card"}>
<span>Phòng {table.tableNumber}</span>
<strong suppressHydrationWarning>{formatRoomSessionDuration(elapsedSeconds)}</strong>
<small suppressHydrationWarning>{currency.format(charge)} · mở {formatRoomStartTime(session.startedAt)}</small>
</article>
))}
</div>
) : (
<div className="pos-notice">Chưa phòng nào đang hát. Mở phòng sẽ tạo bộ đếm giờ ngay.</div>
)}
{workflowMessage ? <div className={workflowMessage.includes("Không") || workflowMessage.includes("không") ? "pos-notice pos-notice--error" : "pos-notice pos-notice--success"}>{workflowMessage}</div> : null}
</div>
) : null}
{baristaWorkflow ? (
<div className="workflow-action-panel workflow-action-panel--barista">
<div>
@@ -1317,20 +1686,141 @@ export function WorkflowScreen({
<div className="pos-notice pos-notice--error">Chưa thể hoàn tất thao tác này.</div>
</div>
) : null}
{["order-edit", "split-bill", "cash-drawer", "shift"].includes(slug) ? (
{slug === "order-edit" ? (
<div className="workflow-action-panel workflow-action-panel--order-edit">
<div>
<span className="eyebrow">SỬA ĐƠN TẠI QUẦY</span>
<h2>{selectedEditableOrder ? `Điều chỉnh đơn ${pickupNumberFromValue(selectedEditableOrder.id)}` : "Chọn đơn chờ thanh toán"}</h2>
<p>Sửa đơn QR/pending trước khi thu tiền: đổi số lượng, xóa dòng, thêm món, áp giảm giá giữ dữ liệu cuối cùng cho bước thanh toán.</p>
</div>
{!editableCounterOrders.length ? <div className="pos-notice pos-notice--error">Không đơn chờ thanh toán đ chỉnh sửa.</div> : null}
{editableCounterOrders.length ? (
<>
<label>
<span>Đơn chờ thanh toán</span>
<select value={selectedEditableOrder?.id ?? ""} onChange={(event) => setSelectedEditOrderId(event.target.value)}>
{editableCounterOrders.map((order) => (
<option key={order.id} value={order.id}>
{pickupNumberFromValue(order.id)} · {order.tableNumber ? `Bàn ${order.tableNumber}` : "Tại quầy"} · {currency.format(orderNetAmount(order))}
</option>
))}
</select>
</label>
<div className="workflow-cash-settlement" aria-live="polite">
<span>
<small>Hiện tại</small>
<b>{currency.format(selectedEditableOrder ? orderNetAmount(selectedEditableOrder) : 0)}</b>
</span>
<span>
<small>Sau chỉnh</small>
<b>{currency.format(editPreviewTotal)}</b>
</span>
<span>
<small>Giảm giá</small>
<b>{editVoucherCode.trim() ? editVoucherCode.trim() : currency.format(editPreviewDiscount)}</b>
</span>
</div>
<div className="workflow-order-edit-list">
{selectedEditableOrder?.items.map((item) => {
const detail = orderItemDetail(item);
return (
<article key={item.id} className="workflow-order-edit-row">
<div>
<strong>{item.productName}</strong>
<span>{currency.format(item.unitPrice)}{detail ? ` · ${detail}` : ""}</span>
</div>
<label>
<span>Số lượng</span>
<input value={editQuantities[item.id] ?? String(item.quantity)} onChange={(event) => updateEditQuantity(item.id, event.target.value)} inputMode="numeric" />
</label>
<button type="button" className="ghost-action" onClick={() => updateEditQuantity(item.id, "0")}>
<Trash2 size={15} />
Xóa
</button>
</article>
);
})}
</div>
<div className="workflow-order-edit-add">
<div>
<span className="eyebrow">THÊM MÓN</span>
<strong>{selectedEditProduct ? `${selectedEditProduct.name} · ${currency.format((selectedEditProduct.price ?? 0) + cafeModifierPrice({ size: selectedCafeSize, sugar: selectedCafeSugar, ice: selectedCafeIce, foam: selectedCafeFoam, topping: selectedCafeTopping }))}` : "Chọn sản phẩm"}</strong>
</div>
<label>
<span>Sản phẩm</span>
<select value={selectedCafeProductId} onChange={(event) => setSelectedCafeProductId(event.target.value)}>
{products.map((product) => <option key={product.id} value={product.id}>{product.name} · {currency.format(product.price)}</option>)}
</select>
</label>
<label>
<span>Số lượng thêm</span>
<input value={editAddQuantity} onChange={(event) => setEditAddQuantity(event.target.value)} inputMode="numeric" />
</label>
<div className="pos-cart-modifier-grid">
<select value={selectedCafeSize} onChange={(event) => setSelectedCafeSize(event.target.value)} aria-label="Size">
{cafeSizeOptions.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
</select>
<select value={selectedCafeSugar} onChange={(event) => setSelectedCafeSugar(event.target.value)} aria-label="Đường">
{cafeSugarOptions.map((option) => <option key={option} value={option}>{option} đưng</option>)}
</select>
<select value={selectedCafeIce} onChange={(event) => setSelectedCafeIce(event.target.value)} aria-label="Đá">
{cafeIceOptions.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<select value={selectedCafeFoam} onChange={(event) => setSelectedCafeFoam(event.target.value)} aria-label="Foam">
{cafeFoamOptions.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
</select>
<select value={selectedCafeTopping} onChange={(event) => setSelectedCafeTopping(event.target.value)} aria-label="Topping">
{cafeToppingOptions.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
</select>
</div>
<label>
<span>Ghi chú món mới</span>
<input value={editLineNote} onChange={(event) => setEditLineNote(event.target.value)} placeholder="Ít ngọt, mang đi..." />
</label>
</div>
<div className="workflow-order-edit-discount">
<label>
<span>Voucher</span>
<input value={editVoucherCode} onChange={(event) => setEditVoucherCode(event.target.value)} placeholder="Bỏ trống nếu giảm thủ công" />
</label>
<label>
<span>Giảm thủ công</span>
<input value={editManualDiscount} onChange={(event) => setEditManualDiscount(event.target.value)} inputMode="numeric" disabled={Boolean(editVoucherCode.trim())} />
</label>
<label>
<span>Ghi chú hóa đơn</span>
<input value={editOrderNotes} onChange={(event) => setEditOrderNotes(event.target.value)} placeholder="Giữ trống để không đổi ghi chú khách" />
</label>
<label>
<span> do sửa</span>
<input value={editRevisionNote} onChange={(event) => setEditRevisionNote(event.target.value)} />
</label>
</div>
<div className="workflow-payment-method-grid">
<button className="pos-btn-checkout" disabled={isWorkflowPending || !selectedEditableOrder} onClick={submitOrderEdit}>
<Check size={17} />
{isWorkflowPending ? "Đang cập nhật" : "Lưu đơn đã sửa"}
</button>
{selectedEditableOrder ? <Link className="workflow-payment-method" href={`/pos/${shop.id}/payment/method-select/${selectedEditableOrder.id}?vertical=${encodeURIComponent(vertical)}`}>Sang thu tiền</Link> : null}
</div>
</>
) : null}
{workflowMessage ? <div className={workflowMessage.includes("Không") || workflowMessage.includes("không") ? "pos-notice pos-notice--error" : "pos-notice pos-notice--success"}>{workflowMessage}</div> : null}
</div>
) : null}
{["split-bill", "cash-drawer", "shift"].includes(slug) ? (
<div className="workflow-action-panel">
<div>
<span className="eyebrow">THAO TÁC THẬT</span>
<h2>{workflow?.title ?? slug}</h2>
<p>
{slug === "order-edit" || slug === "split-bill"
? "Chưa thể chỉnh sửa hoặc tách hóa đơn cho tới khi quy trình đối soát được hoàn thiện."
{slug === "split-bill"
? "Chưa thể tách hóa đơn cho tới khi quy trình đối soát được hoàn thiện."
: slug === "shift"
? "Check-in/check-out dùng dữ liệu chấm công. Mở/đóng ca bán và két tiền cần quy trình ca riêng."
: "Cash drawer cần device registry và drawer event table trước khi cho mở két."}
</p>
</div>
{slug === "order-edit" && contextOrder ? <Link className="workflow-payment-method" href={`/pos/${shop.id}/${vertical}?tab=history`}>Xem lịch sử đơn</Link> : null}
{slug === "shift" ? (
<div className="workflow-payment-method-grid">
<button className="workflow-payment-method" disabled={isWorkflowPending} onClick={() => updateShiftAttendance("check-in")}>Check-in</button>
@@ -1386,7 +1876,7 @@ export function WorkflowScreen({
</div>
</div>
) : null}
<WorkflowBoard rows={visibleWorkflowRows} />
<WorkflowBoard rows={visibleWorkflowRows} title={workflowBoardTitle} description={workflowBoardDescription} />
</section>
</section>
</main>

View File

@@ -3,7 +3,6 @@ const unsupportedWorkflowReasons: Record<string, string> = {
"partial-payment": "Cần split payment ledger trước khi tách nhiều phương thức thu.",
"payment-pending": "Cần payment session/provider callback trước khi hiển thị trạng thái chờ thật.",
tip: "Cần shift gratuity ledger trước khi ghi nhận tip.",
"order-edit": "Cần order revision ledger trước khi cho sửa món, số lượng hoặc phụ thu.",
"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.",
@@ -20,7 +19,6 @@ const unsupportedWorkflowReasons: Record<string, string> = {
"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-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ụ.",
"karaoke-journey": "Cần event timeline theo phòng trước khi dựng hành trình karaoke.",
"appointment-book": "Cần booking calendar trong POS workflow trước khi đặt lịch.",

View File

@@ -87,7 +87,8 @@ export {
export {
cancelOrder,
payOrder,
transferOrderTable
transferOrderTable,
updateOrder
} from "./queries/order-actions";
export {
listOrderReturns,
@@ -105,3 +106,4 @@ export {
} from "./queries/dashboard";
export type { ReturnOrderInput } from "./queries/order-returns";
export type { CreateOrderInput } from "./queries/order-create";
export type { UpdateOrderInput } from "./queries/order-actions";

View File

@@ -1,8 +1,11 @@
import { randomUUID } from "node:crypto";
import type { PoolClient } from "pg";
import { productTypeNameById } from "../../domain/catalog";
import { cafeModifierPrice, cafeOrderItemMetadata, type CafeModifierPayload } from "../../../lib/cafe-modifiers";
import { withTransaction } from "../pool";
import {
consumeReservedOrDecreaseInventoryForOrderLine,
reserveInventoryForOrderLine,
releaseReservedInventoryForOrderLine
} from "./order-inventory";
import { getOrderById } from "./order-read";
@@ -19,6 +22,38 @@ type RoomSessionCharge = {
amount: number;
};
type UpdateOrderItemInput = {
id?: string | null;
orderItemId?: string | null;
productId?: string | null;
quantity?: number | string | null;
modifiers?: CafeModifierPayload | null;
note?: string | null;
};
export type UpdateOrderInput = {
shopId?: string | null;
items?: UpdateOrderItemInput[] | null;
discountAmount?: number | string | null;
discountType?: string | null;
discountReference?: string | null;
voucherCode?: string | null;
notes?: string | null;
revisionNote?: string | null;
};
type ExistingOrderLine = {
id: string;
product_id: string;
product_name: string;
product_type: string;
quantity: number;
unit_price: number;
status: string | null;
track_inventory: boolean | null;
metadata: unknown;
};
function roomSessionElapsedSeconds(startedAt: Date) {
return Math.max(0, Math.floor((Date.now() - startedAt.getTime()) / 1000));
}
@@ -39,6 +74,25 @@ function metadataRecord(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function stringValue(value: unknown) {
const text = String(value ?? "").trim();
return text || null;
}
function hasModifierPayload(item: UpdateOrderItemInput) {
return item.modifiers && typeof item.modifiers === "object";
}
function updateLineMetadata(item: UpdateOrderItemInput) {
const note = stringValue(item.note)?.slice(0, 160) ?? "";
const metadata = hasModifierPayload(item) ? cafeOrderItemMetadata(item.modifiers) : null;
if (!metadata && !note) return null;
return {
...(metadata ?? {}),
...(note ? { lineNote: note } : {})
};
}
function baristaMetadata(orderItemId: string, metadata: Record<string, unknown> | null) {
return {
...(metadata ?? {}),
@@ -52,6 +106,18 @@ function baristaProductName(item: { product_name: string; quantity: number; meta
return `${baseName}${summary ? ` · ${summary}` : ""}`.slice(0, 200);
}
function customerNameFromNotes(notes: string | null | undefined) {
return notes?.match(/Khách:\s*([^|]+)/)?.[1]?.trim() ?? null;
}
function isOpenOrderStatus(statusId: number) {
return [1, 2, 4, 7].includes(statusId);
}
function lineTrackInventory(value: unknown) {
return value !== false && value !== "false";
}
async function resolveRoomChargeProductId(client: PoolClient, shopId: string) {
const existing = await client.query<{ id: string }>(
`
@@ -78,6 +144,308 @@ async function resolveRoomChargeProductId(client: PoolClient, shopId: string) {
return id;
}
export async function updateOrder(orderId: string, input: UpdateOrderInput) {
await withTransaction(async (client) => {
const orderResult = await client.query<{
id: string;
shop_id: string;
status_id: number;
notes: string | null;
payment_method: string | null;
discount_reference: string | null;
}>(
`
SELECT id, shop_id, status_id, notes, payment_method, discount_reference
FROM orders
WHERE id = $1
AND ($2::uuid IS NULL OR shop_id = $2::uuid)
FOR UPDATE
`,
[orderId, input.shopId || null]
);
const order = orderResult.rows[0];
if (!order) throw new Error("Order not found");
if (!isOpenOrderStatus(int(order.status_id))) throw new Error("Only open unpaid orders can be edited");
const activeQueue = await client.query(
`
SELECT 1
FROM barista_queue
WHERE order_id = $1::uuid
AND lower(COALESCE(status, 'pending')) <> 'pending'
LIMIT 1
`,
[orderId]
);
if (activeQueue.rows[0]) throw new Error("Order cannot be edited after barista preparation has started");
const requestedItems = Array.isArray(input.items) ? input.items : [];
if (!requestedItems.length) throw new Error("Order must contain at least one item");
const existingLines = await client.query<ExistingOrderLine>(
`
SELECT id, product_id, product_name, product_type, quantity, unit_price, status, track_inventory, metadata
FROM order_items
WHERE order_id = $1::uuid
ORDER BY product_name
FOR UPDATE
`,
[orderId]
);
const existingById = new Map(existingLines.rows.map((line) => [line.id, line]));
const normalizedItems = requestedItems.reduce<Array<{
input: UpdateOrderItemInput;
existing?: ExistingOrderLine;
productId: string;
quantity: number;
preserveExistingLine: boolean;
}>>((items, item) => {
const existingId = stringValue(item.orderItemId) ?? stringValue(item.id);
const existing = existingId ? existingById.get(existingId) : undefined;
const productId = stringValue(item.productId) ?? existing?.product_id;
const quantity = Math.trunc(Number(item.quantity ?? existing?.quantity ?? 0));
if (!productId || !Number.isFinite(quantity) || quantity <= 0) return items;
const preserveExistingLine = Boolean(
existing
&& existing.product_id === productId
&& !hasModifierPayload(item)
&& !stringValue(item.note)
);
items.push({ input: item, existing, productId, quantity, preserveExistingLine });
return items;
}, []);
if (!normalizedItems.length) throw new Error("Order must contain at least one active item");
const productIdsToLoad = [...new Set(normalizedItems
.filter((item) => !item.preserveExistingLine)
.map((item) => item.productId))];
const productRows = productIdsToLoad.length
? await client.query(
`
SELECT id, name, price, type_id
FROM products
WHERE shop_id = $1::uuid
AND id = ANY($2::uuid[])
AND COALESCE(is_active, true) = true
`,
[order.shop_id, productIdsToLoad]
)
: { rows: [] as Record<string, unknown>[] };
const products = new Map(productRows.rows.map((row) => [String(row.id), row as Record<string, unknown>]));
if (products.size !== productIdsToLoad.length) throw new Error("Some products are not available");
for (const line of existingLines.rows) {
if (lineTrackInventory(line.track_inventory) && String(line.status ?? "Pending").toLowerCase() !== "completed") {
await releaseReservedInventoryForOrderLine(client, {
shopId: order.shop_id,
productId: line.product_id,
productName: line.product_name,
quantity: int(line.quantity),
referenceId: orderId,
notes: `Released reservation before editing order ${orderId}`
});
}
}
await client.query(`DELETE FROM barista_queue WHERE order_id = $1::uuid AND lower(COALESCE(status, 'pending')) = 'pending'`, [orderId]);
await client.query(`DELETE FROM order_items WHERE order_id = $1::uuid`, [orderId]);
const lines = normalizedItems.map((item) => {
if (item.preserveExistingLine && item.existing) {
return {
id: item.existing.id,
productId: item.existing.product_id,
productName: item.existing.product_name,
productType: item.existing.product_type,
quantity: item.quantity,
unitPrice: money(item.existing.unit_price),
trackInventory: lineTrackInventory(item.existing.track_inventory),
metadata: metadataRecord(item.existing.metadata)
};
}
const product = products.get(item.productId)!;
const productType = productTypeNameById[int(product.type_id)] ?? "Physical";
return {
id: randomUUID(),
productId: String(product.id),
productName: String(product.name),
productType,
quantity: item.quantity,
unitPrice: money(product.price) + (hasModifierPayload(item.input) ? cafeModifierPrice(item.input.modifiers) : 0),
trackInventory: productType !== "Service",
metadata: updateLineMetadata(item.input)
};
});
const subtotal = lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
const requestedDiscountType = stringValue(input.discountType)?.toLowerCase() ?? "";
const requestedDiscountAmount = Math.max(0, money(input.discountAmount ?? 0));
const requestedVoucher = stringValue(input.voucherCode) ?? stringValue(input.discountReference);
const manualDiscountRequested = requestedDiscountAmount > 0 && !requestedVoucher && ["manual", "custom", "promotion", "discount"].includes(requestedDiscountType);
if (requestedDiscountAmount > 0 && !requestedVoucher && !manualDiscountRequested) {
throw new Error("Manual discount type is required for discounts without a voucher");
}
const voucher = requestedVoucher
? await client.query<{
id: string;
code: string;
discount_type: string;
discount_value: number;
redeemed_order_id: string | null;
}>(
`
SELECT v.id, v.code, v.discount_type, v.discount_value, v.redeemed_order_id
FROM vouchers v
LEFT JOIN campaigns c ON c.id = v.campaign_id
WHERE (lower(v.code) = lower($1) OR v.id::text = $1)
AND v.shop_id = $2::uuid
AND (
v.status = 'active'
OR v.redeemed_order_id = $3::uuid
)
AND (
c.id IS NULL OR (
c.status = 'active'
AND (c.start_date IS NULL OR c.start_date <= now())
AND (c.end_date IS NULL OR c.end_date >= now())
)
)
LIMIT 1
FOR UPDATE OF v
`,
[requestedVoucher, order.shop_id, orderId]
)
: null;
const voucherRow = voucher?.rows[0];
if (requestedVoucher && !voucherRow) throw new Error("Voucher is not available for this shop");
const oldVoucher = stringValue(order.discount_reference);
if (oldVoucher && (!voucherRow || oldVoucher.toLowerCase() !== String(voucherRow.code).toLowerCase())) {
await client.query(
`
UPDATE vouchers
SET status = 'active',
redeemed_order_id = NULL,
redeemed_at = NULL
WHERE shop_id = $1::uuid
AND lower(code) = lower($2)
AND redeemed_order_id = $3::uuid
`,
[order.shop_id, oldVoucher, orderId]
);
}
const voucherDiscountType = voucherRow ? String(voucherRow.discount_type).toLowerCase() : null;
const voucherDiscountValue = voucherRow ? money(voucherRow.discount_value) : 0;
const discountAmount = voucherRow
? Math.min(subtotal, Math.max(0, voucherDiscountType === "percent" || voucherDiscountType === "percentage" ? Math.round(subtotal * voucherDiscountValue / 100) : voucherDiscountValue))
: manualDiscountRequested
? Math.min(subtotal, requestedDiscountAmount)
: 0;
const resolvedDiscountType = voucherRow ? "voucher" : manualDiscountRequested ? "manual" : null;
const resolvedDiscountReference = voucherRow ? String(voucherRow.code) : null;
const total = Math.max(0, subtotal - discountAmount);
if (voucherRow) {
const redeemed = await client.query(
`
UPDATE vouchers
SET status = 'redeemed',
redeemed_order_id = $2::uuid,
redeemed_at = COALESCE(redeemed_at, now())
WHERE id = $1::uuid
AND (status = 'active' OR redeemed_order_id = $2::uuid)
RETURNING id
`,
[voucherRow.id, orderId]
);
if (!redeemed.rows[0]) throw new Error("Voucher has already been redeemed");
}
for (const line of lines) {
await client.query(
`INSERT INTO order_items (
id, order_id, product_id, product_name, product_type, quantity, unit_price, status, track_inventory, metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'Pending', $8, $9::jsonb)`,
[
line.id,
orderId,
line.productId,
line.productName,
line.productType,
line.quantity,
line.unitPrice,
line.trackInventory,
line.metadata ? JSON.stringify(line.metadata) : null
]
);
if (line.trackInventory) {
await reserveInventoryForOrderLine(client, {
shopId: order.shop_id,
productId: line.productId,
productName: line.productName,
quantity: line.quantity,
referenceId: orderId,
notes: `Reserved after editing order ${orderId}`
});
}
if (line.productType === "PreparedFood" && normalizePaymentMethod(order.payment_method) !== "customer_order") {
await client.query(
`INSERT INTO barista_queue (id, shop_id, order_id, product_name, customer_name, status, metadata)
VALUES ($1, $2, $3, $4, $5, 'Pending', $6::jsonb)`,
[
randomUUID(),
order.shop_id,
orderId,
baristaProductName({ product_name: line.productName, quantity: line.quantity, metadata: line.metadata }),
customerNameFromNotes(order.notes),
JSON.stringify(baristaMetadata(line.id, line.metadata))
]
);
}
}
const nextNotes = input.notes === undefined
? order.notes
: stringValue(input.notes);
const revisionNote = stringValue(input.revisionNote);
const mergedNotes = revisionNote
? nextNotes
? `${nextNotes}\nSửa đơn: ${revisionNote}`.slice(0, 1000)
: `Sửa đơn: ${revisionNote}`.slice(0, 1000)
: nextNotes;
await client.query(
`
UPDATE orders
SET total_amount = $2,
discount_amount = $3,
discount_type = $4,
discount_reference = $5,
notes = $6,
updated_at = now()
WHERE id = $1::uuid
`,
[orderId, total, discountAmount, resolvedDiscountType, resolvedDiscountReference, mergedNotes]
);
await logActivity(client, "order.edited", "order", orderId, order.shop_id, {
subtotal,
discountAmount,
total,
itemCount: lines.length,
revisionNote: revisionNote ?? null
});
});
return getOrderById(orderId, input.shopId ?? null);
}
async function calculateOpenRoomSessionCharge(client: PoolClient, shopId: string, tableId: string): Promise<RoomSessionCharge | null> {
const session = await client.query<{
id: string;

View File

@@ -9,7 +9,8 @@ import {
listOrdersPaged,
payOrder,
returnOrder,
transferOrderTable
transferOrderTable,
updateOrder
} from "../db/queries";
import type { OrderFilters, PagedOrderResult } from "../domain/types";
@@ -106,6 +107,13 @@ export async function transferOrderTableService(
return transferOrderTable(orderId, input);
}
export async function updateOrderService(
orderId: string,
input: Parameters<typeof updateOrder>[1]
) {
return updateOrder(orderId, input);
}
export async function createPosOrder(input: OrderCreateInput) {
return createOrderService(input);
}