diff --git a/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/37-cafe-loyalty-layout-kpi-sidebar-board/01-chrome-loyalty-layout-after-fixes-port-3020.jpg b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/37-cafe-loyalty-layout-kpi-sidebar-board/01-chrome-loyalty-layout-after-fixes-port-3020.jpg new file mode 100644 index 00000000..ae208ab4 Binary files /dev/null and b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/37-cafe-loyalty-layout-kpi-sidebar-board/01-chrome-loyalty-layout-after-fixes-port-3020.jpg differ diff --git a/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/37-cafe-loyalty-layout-kpi-sidebar-board/02-chrome-loyalty-board-context-port-3020.jpg b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/37-cafe-loyalty-layout-kpi-sidebar-board/02-chrome-loyalty-board-context-port-3020.jpg new file mode 100644 index 00000000..7268ec6b Binary files /dev/null and b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/37-cafe-loyalty-layout-kpi-sidebar-board/02-chrome-loyalty-board-context-port-3020.jpg differ diff --git a/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/37-cafe-loyalty-layout-kpi-sidebar-board/README.md b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/37-cafe-loyalty-layout-kpi-sidebar-board/README.md new file mode 100644 index 00000000..46625d0c --- /dev/null +++ b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/37-cafe-loyalty-layout-kpi-sidebar-board/README.md @@ -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. diff --git a/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/37-cafe-loyalty-layout-kpi-sidebar-board/results.json b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/37-cafe-loyalty-layout-kpi-sidebar-board/results.json new file mode 100644 index 00000000..3a2fc421 --- /dev/null +++ b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/37-cafe-loyalty-layout-kpi-sidebar-board/results.json @@ -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." + } +} diff --git a/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/38-cafe-counter-order-edit-pending/01-chrome-order-edit-saved-pending-port-3020.jpg b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/38-cafe-counter-order-edit-pending/01-chrome-order-edit-saved-pending-port-3020.jpg new file mode 100644 index 00000000..b4e084b4 Binary files /dev/null and b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/38-cafe-counter-order-edit-pending/01-chrome-order-edit-saved-pending-port-3020.jpg differ diff --git a/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/38-cafe-counter-order-edit-pending/02-chrome-order-edit-controls-port-3020.jpg b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/38-cafe-counter-order-edit-pending/02-chrome-order-edit-controls-port-3020.jpg new file mode 100644 index 00000000..a75fea4e Binary files /dev/null and b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/38-cafe-counter-order-edit-pending/02-chrome-order-edit-controls-port-3020.jpg differ diff --git a/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/38-cafe-counter-order-edit-pending/README.md b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/38-cafe-counter-order-edit-pending/README.md new file mode 100644 index 00000000..7fd06cdb --- /dev/null +++ b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/38-cafe-counter-order-edit-pending/README.md @@ -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. diff --git a/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/38-cafe-counter-order-edit-pending/results.json b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/38-cafe-counter-order-edit-pending/results.json new file mode 100644 index 00000000..dad81f2f --- /dev/null +++ b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/38-cafe-counter-order-edit-pending/results.json @@ -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" + ] +} diff --git a/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/39-karaoke-workflow-room-timer/01-chrome-room-ready-before-open-port-3020.jpg b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/39-karaoke-workflow-room-timer/01-chrome-room-ready-before-open-port-3020.jpg new file mode 100644 index 00000000..cc5e7c01 Binary files /dev/null and b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/39-karaoke-workflow-room-timer/01-chrome-room-ready-before-open-port-3020.jpg differ diff --git a/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/39-karaoke-workflow-room-timer/02-chrome-room-timer-running-after-open-port-3020.jpg b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/39-karaoke-workflow-room-timer/02-chrome-room-timer-running-after-open-port-3020.jpg new file mode 100644 index 00000000..3aca814a Binary files /dev/null and b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/39-karaoke-workflow-room-timer/02-chrome-room-timer-running-after-open-port-3020.jpg differ diff --git a/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/39-karaoke-workflow-room-timer/README.md b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/39-karaoke-workflow-room-timer/README.md new file mode 100644 index 00000000..802d5a7e --- /dev/null +++ b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/39-karaoke-workflow-room-timer/README.md @@ -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`. diff --git a/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/39-karaoke-workflow-room-timer/results.json b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/39-karaoke-workflow-room-timer/results.json new file mode 100644 index 00000000..ba265fe3 --- /dev/null +++ b/microservices/apps/tpos-mvp-next/output/demo-cafe-e2e-audit-3020/39-karaoke-workflow-room-timer/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" + } +} diff --git a/microservices/apps/tpos-mvp-next/src/app/api/bff/[...path]/handlers/post.ts b/microservices/apps/tpos-mvp-next/src/app/api/bff/[...path]/handlers/post.ts index e2cc2495..5fbfc3ce 100644 --- a/microservices/apps/tpos-mvp-next/src/app/api/bff/[...path]/handlers/post.ts +++ b/microservices/apps/tpos-mvp-next/src/app/api/bff/[...path]/handlers/post.ts @@ -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[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 }); diff --git a/microservices/apps/tpos-mvp-next/src/app/styles/globals-part-08.css b/microservices/apps/tpos-mvp-next/src/app/styles/globals-part-08.css index b86845b4..5c527499 100644 --- a/microservices/apps/tpos-mvp-next/src/app/styles/globals-part-08.css +++ b/microservices/apps/tpos-mvp-next/src/app/styles/globals-part-08.css @@ -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; diff --git a/microservices/apps/tpos-mvp-next/src/components/TposWorkflowChrome.tsx b/microservices/apps/tpos-mvp-next/src/components/TposWorkflowChrome.tsx index 49575336..bd7d842e 100644 --- a/microservices/apps/tpos-mvp-next/src/components/TposWorkflowChrome.tsx +++ b/microservices/apps/tpos-mvp-next/src/components/TposWorkflowChrome.tsx @@ -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 (
- +
); } @@ -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 ( -
- {rows.map((row) => ( -
- {row.title} - {row.meta} - {row.value} -
- ))} - {rows.length === 0 ?
Chưa có dữ liệu vận hành cho workflow này
: null} -
+
+
+
+ DỮ LIỆU KIỂM CHỨNG +

{title}

+

{description}

+
+ {rows.length ? {rows.length} : null} +
+
+ {rows.map((row) => ( +
+ {row.title} + {row.meta} + {row.value} +
+ ))} + {rows.length === 0 ?
Chưa có dữ liệu vận hành cho workflow này
: null} +
+
); } diff --git a/microservices/apps/tpos-mvp-next/src/components/TposWorkflowRoutes.ts b/microservices/apps/tpos-mvp-next/src/components/TposWorkflowRoutes.ts index 3604fb0f..1a8799ee 100644 --- a/microservices/apps/tpos-mvp-next/src/components/TposWorkflowRoutes.ts +++ b/microservices/apps/tpos-mvp-next/src/components/TposWorkflowRoutes.ts @@ -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}`, diff --git a/microservices/apps/tpos-mvp-next/src/components/TposWorkflowScreen.tsx b/microservices/apps/tpos-mvp-next/src/components/TposWorkflowScreen.tsx index 850e3201..7428461c 100644 --- a/microservices/apps/tpos-mvp-next/src/components/TposWorkflowScreen.tsx +++ b/microservices/apps/tpos-mvp-next/src/components/TposWorkflowScreen.tsx @@ -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>({}); + 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(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>({}); + const [closedWorkflowRoomOrderIds, setClosedWorkflowRoomOrderIds] = useState>({}); + const [workflowTableSessions, setWorkflowTableSessions] = useState(() => 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; + 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; + 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({ + {roomTimerWorkflow ? ( +
+
+ BỘ TÍNH GIỜ PHÒNG +

{selectedWorkflowRoom ? `Phòng ${selectedWorkflowRoom.tableNumber}` : "Chọn phòng để mở phiên hát"}

+

+ {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."} +

+
+ +
+ + {selectedWorkflowRoomSession ? "Đang đếm giờ" : "Chưa mở phòng"} + {selectedWorkflowRoomDurationLabel} + {selectedWorkflowRoomSession && selectedWorkflowRoom ? `${currency.format(selectedWorkflowRoom.hourlyRate)} / giờ` : "00:00:00 trước khi mở phiên"} +
+
+ Trạng thái{selectedWorkflowRoomSession ? "Đang hát" : "Sẵn sàng mở"} + Tiền giờ{currency.format(selectedWorkflowRoomCharge)} + F&B phòng{currency.format(selectedWorkflowRoomOrder ? orderNetAmount(selectedWorkflowRoomOrder) : 0)} + Cần thu nếu chốt{currency.format(selectedWorkflowRoomCharge + (selectedWorkflowRoomOrder ? orderNetAmount(selectedWorkflowRoomOrder) : 0))} +
+ {roomResetWorkflow ? ( + + ) : null} +
+ {roomResetWorkflow ? ( + + ) : ( + + )} + {selectedWorkflowRoomOrder ? ( + + + Chốt bill & thu tiền + + ) : ( + + + Mở POS phòng + + )} +
+ {openWorkflowRoomSessions.length ? ( +
+ {openWorkflowRoomSessions.slice(0, 6).map(({ table, session, elapsedSeconds, charge }) => ( +
+ Phòng {table.tableNumber} + {formatRoomSessionDuration(elapsedSeconds)} + {currency.format(charge)} · mở {formatRoomStartTime(session.startedAt)} +
+ ))} +
+ ) : ( +
Chưa có phòng nào đang hát. Mở phòng sẽ tạo bộ đếm giờ ngay.
+ )} + {workflowMessage ?
{workflowMessage}
: null} +
+ ) : null} {baristaWorkflow ? (
@@ -1317,20 +1686,141 @@ export function WorkflowScreen({
Chưa thể hoàn tất thao tác này.
) : null} - {["order-edit", "split-bill", "cash-drawer", "shift"].includes(slug) ? ( + {slug === "order-edit" ? ( +
+
+ SỬA ĐƠN TẠI QUẦY +

{selectedEditableOrder ? `Điều chỉnh đơn ${pickupNumberFromValue(selectedEditableOrder.id)}` : "Chọn đơn chờ thanh toán"}

+

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.

+
+ {!editableCounterOrders.length ?
Không có đơn chờ thanh toán để chỉnh sửa.
: null} + {editableCounterOrders.length ? ( + <> + +
+ + Hiện tại + {currency.format(selectedEditableOrder ? orderNetAmount(selectedEditableOrder) : 0)} + + + Sau chỉnh + {currency.format(editPreviewTotal)} + + + Giảm giá + {editVoucherCode.trim() ? editVoucherCode.trim() : currency.format(editPreviewDiscount)} + +
+
+ {selectedEditableOrder?.items.map((item) => { + const detail = orderItemDetail(item); + return ( +
+
+ {item.productName} + {currency.format(item.unitPrice)}{detail ? ` · ${detail}` : ""} +
+ + +
+ ); + })} +
+
+
+ THÊM MÓN + {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"} +
+ + +
+ + + + + +
+ +
+
+ + + + +
+
+ + {selectedEditableOrder ? Sang thu tiền : null} +
+ + ) : null} + {workflowMessage ?
{workflowMessage}
: null} +
+ ) : null} + {["split-bill", "cash-drawer", "shift"].includes(slug) ? (
THAO TÁC THẬT

{workflow?.title ?? slug}

- {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."}

- {slug === "order-edit" && contextOrder ? Xem lịch sử đơn : null} {slug === "shift" ? (
@@ -1386,7 +1876,7 @@ export function WorkflowScreen({
) : null} - + diff --git a/microservices/apps/tpos-mvp-next/src/components/TposWorkflowSupport.ts b/microservices/apps/tpos-mvp-next/src/components/TposWorkflowSupport.ts index 19a7b0fa..e516501b 100644 --- a/microservices/apps/tpos-mvp-next/src/components/TposWorkflowSupport.ts +++ b/microservices/apps/tpos-mvp-next/src/components/TposWorkflowSupport.ts @@ -3,7 +3,6 @@ const unsupportedWorkflowReasons: Record = { "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 = { "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.", diff --git a/microservices/apps/tpos-mvp-next/src/server/db/queries.ts b/microservices/apps/tpos-mvp-next/src/server/db/queries.ts index d1a54397..d2720cd1 100644 --- a/microservices/apps/tpos-mvp-next/src/server/db/queries.ts +++ b/microservices/apps/tpos-mvp-next/src/server/db/queries.ts @@ -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"; diff --git a/microservices/apps/tpos-mvp-next/src/server/db/queries/order-actions.ts b/microservices/apps/tpos-mvp-next/src/server/db/queries/order-actions.ts index d80d4ee0..76a0af82 100644 --- a/microservices/apps/tpos-mvp-next/src/server/db/queries/order-actions.ts +++ b/microservices/apps/tpos-mvp-next/src/server/db/queries/order-actions.ts @@ -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 : 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 | 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( + ` + 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>((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[] }; + const products = new Map(productRows.rows.map((row) => [String(row.id), row as Record])); + 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 { const session = await client.query<{ id: string; diff --git a/microservices/apps/tpos-mvp-next/src/server/services/order.ts b/microservices/apps/tpos-mvp-next/src/server/services/order.ts index 8530470b..2b22a7eb 100644 --- a/microservices/apps/tpos-mvp-next/src/server/services/order.ts +++ b/microservices/apps/tpos-mvp-next/src/server/services/order.ts @@ -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[1] +) { + return updateOrder(orderId, input); +} + export async function createPosOrder(input: OrderCreateInput) { return createOrderService(input); }