Add karaoke room timer and reset workflow
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
@@ -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.
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
@@ -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`.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 có 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 có dữ liệu vận hành cho workflow này</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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>Lý 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 có 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á và 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 có đơ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>Lý 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>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user