03376b3ea1
Rewrites dashboard and finder Dockerfiles to use a clean multi-stage build (deps → builder → runner) that installs npm packages inside Alpine Linux, avoiding the SWC musl binary issue when building from Windows host. Uses registry.npmmirror.com for reliable installs from restricted networks (Iran). - docker/api/Dockerfile: .NET 10 multi-stage build - docker/web/Dockerfile: Node 20-alpine multi-stage, npmmirror - docker/finder/Dockerfile: Node 20-alpine multi-stage, npmmirror - docker/website/Dockerfile: marketing website build - scripts/: PowerShell helper scripts for local dev Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1213 lines
32 KiB
Markdown
1213 lines
32 KiB
Markdown
# Meezi — Sprint Plan: POS Operations Polish + Hardening
|
||
> **Copy-paste this entire file into Cursor as your implementation guide.**
|
||
> Stack: ASP.NET Core 10 · Next.js 14 · PostgreSQL 16 · Redis · SignalR
|
||
> Last updated: 2026-05-21
|
||
|
||
---
|
||
|
||
## Overview
|
||
|
||
Five self-contained PRs, ordered by dependency. Do them in sequence.
|
||
|
||
| PR | Title | Effort |
|
||
|----|-------|--------|
|
||
| PR-1 | Void Order Line | ~2h |
|
||
| PR-2 | Transfer Table | ~2h |
|
||
| PR-3 | SignalR Table Board Realtime | ~3h |
|
||
| PR-4 | Receipt Print Preview | ~2h |
|
||
| PR-5 | Integration Test Coverage | ~2h |
|
||
| PR-6 | Fix NU1903 Package Warnings | ~1h |
|
||
|
||
---
|
||
|
||
## PR-1 — Void Order Line
|
||
|
||
### Goal
|
||
Allow staff to void (cancel) an individual line item on an open order. Only `Manager` role can void.
|
||
|
||
### Files to create / edit
|
||
|
||
```
|
||
src/Meezi.Core/Entities/OrderItem.cs ← add IsVoided, VoidedAt, VoidedByUserId
|
||
src/Meezi.Core/Entities/Order.cs ← update TotalAmount computed property
|
||
src/Meezi.Infrastructure/Data/Migrations/ ← new migration: VoidOrderLine
|
||
src/Meezi.API/Services/OrderService.cs ← add VoidOrderItemAsync
|
||
src/Meezi.API/Controllers/OrdersController.cs ← add PATCH endpoint
|
||
src/Meezi.API/DTOs/OrderItemDto.cs ← add IsVoided
|
||
web/dashboard/src/components/pos/pos-screen.tsx ← void button per line
|
||
web/dashboard/src/lib/api/orders.ts ← voidOrderItem()
|
||
messages/fa.json, en.json, ar.json ← void strings
|
||
```
|
||
|
||
---
|
||
|
||
### Step 1 — Domain: `OrderItem.cs`
|
||
|
||
Add to the existing `OrderItem` entity:
|
||
|
||
```csharp
|
||
// src/Meezi.Core/Entities/OrderItem.cs
|
||
public bool IsVoided { get; set; } = false;
|
||
public DateTime? VoidedAt { get; set; }
|
||
public Guid? VoidedByUserId { get; set; }
|
||
```
|
||
|
||
---
|
||
|
||
### Step 2 — Domain: `Order.cs`
|
||
|
||
Update `TotalAmount` (or wherever line totals are summed) to exclude voided items:
|
||
|
||
```csharp
|
||
// In Order.cs — wherever TotalAmount is computed
|
||
public decimal TotalAmount => Items
|
||
.Where(i => !i.IsVoided)
|
||
.Sum(i => i.UnitPrice * i.Quantity);
|
||
```
|
||
|
||
If `TotalAmount` is a stored column rather than computed, update `OrderService` instead (see Step 4).
|
||
|
||
---
|
||
|
||
### Step 3 — EF Migration
|
||
|
||
```bash
|
||
cd src/Meezi.Infrastructure
|
||
dotnet ef migrations add VoidOrderLine \
|
||
--startup-project ../../src/Meezi.API \
|
||
--output-dir Data/Migrations
|
||
```
|
||
|
||
Verify the generated migration adds:
|
||
- `is_voided boolean not null default false`
|
||
- `voided_at timestamp with time zone null`
|
||
- `voided_by_user_id uuid null`
|
||
|
||
---
|
||
|
||
### Step 4 — Service: `OrderService.cs`
|
||
|
||
Add this method:
|
||
|
||
```csharp
|
||
// src/Meezi.API/Services/OrderService.cs
|
||
|
||
public async Task<ApiResponse<OrderDto>> VoidOrderItemAsync(
|
||
Guid orderId, Guid itemId, Guid voidedByUserId, CancellationToken ct = default)
|
||
{
|
||
var order = await _db.Orders
|
||
.Include(o => o.Items)
|
||
.FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == _tenant.CafeId, ct);
|
||
|
||
if (order is null)
|
||
return ApiResponse<OrderDto>.Fail("ORDER_NOT_FOUND");
|
||
|
||
if (order.Status == OrderStatus.Closed)
|
||
return ApiResponse<OrderDto>.Fail("ORDER_ALREADY_CLOSED");
|
||
|
||
var item = order.Items.FirstOrDefault(i => i.Id == itemId);
|
||
if (item is null)
|
||
return ApiResponse<OrderDto>.Fail("ITEM_NOT_FOUND");
|
||
|
||
if (item.IsVoided)
|
||
return ApiResponse<OrderDto>.Fail("ITEM_ALREADY_VOIDED");
|
||
|
||
item.IsVoided = true;
|
||
item.VoidedAt = DateTime.UtcNow;
|
||
item.VoidedByUserId = voidedByUserId;
|
||
|
||
// Recompute stored total if not computed property
|
||
order.TotalAmount = order.Items
|
||
.Where(i => !i.IsVoided)
|
||
.Sum(i => i.UnitPrice * i.Quantity);
|
||
|
||
await _db.SaveChangesAsync(ct);
|
||
return ApiResponse<OrderDto>.Ok(_mapper.Map<OrderDto>(order));
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Step 5 — Controller: `OrdersController.cs`
|
||
|
||
Add endpoint (inside the existing controller class):
|
||
|
||
```csharp
|
||
// src/Meezi.API/Controllers/OrdersController.cs
|
||
|
||
/// <summary>Void a single line item. Requires Manager role.</summary>
|
||
[HttpPatch("{orderId}/items/{itemId}/void")]
|
||
[Authorize(Roles = "Manager,Owner")]
|
||
public async Task<IActionResult> VoidOrderItem(
|
||
Guid cafeId, Guid orderId, Guid itemId, CancellationToken ct)
|
||
{
|
||
var userId = User.GetUserId(); // existing helper
|
||
var result = await _orderService.VoidOrderItemAsync(orderId, itemId, userId, ct);
|
||
return result.IsSuccess ? Ok(result) : BadRequest(result);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Step 6 — DTO: `OrderItemDto.cs`
|
||
|
||
```csharp
|
||
public bool IsVoided { get; set; }
|
||
public DateTime? VoidedAt { get; set; }
|
||
```
|
||
|
||
Add to AutoMapper profile if explicit mappings exist.
|
||
|
||
---
|
||
|
||
### Step 7 — Dashboard: API helper
|
||
|
||
```typescript
|
||
// web/dashboard/src/lib/api/orders.ts
|
||
|
||
export async function voidOrderItem(
|
||
cafeId: string,
|
||
orderId: string,
|
||
itemId: string
|
||
): Promise<void> {
|
||
await apiClient.patch(
|
||
`/cafes/${cafeId}/orders/${orderId}/items/${itemId}/void`
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Step 8 — Dashboard: UI in `pos-screen.tsx`
|
||
|
||
Inside the line-item render loop, add a void button visible only to managers:
|
||
|
||
```tsx
|
||
// web/dashboard/src/components/pos/pos-screen.tsx
|
||
|
||
{isManager && !item.isVoided && (
|
||
<button
|
||
className="text-red-500 text-xs ms-2 hover:underline"
|
||
onClick={() => handleVoidItem(item.id)}
|
||
aria-label={t("pos.voidItem")}
|
||
>
|
||
{t("pos.void")}
|
||
</button>
|
||
)}
|
||
|
||
{item.isVoided && (
|
||
<span className="text-muted-foreground line-through text-xs">
|
||
{t("pos.voided")}
|
||
</span>
|
||
)}
|
||
```
|
||
|
||
Handler:
|
||
|
||
```tsx
|
||
const handleVoidItem = async (itemId: string) => {
|
||
if (!confirm(t("pos.confirmVoid"))) return;
|
||
await voidOrderItem(cafeId, activeOrderId, itemId);
|
||
await reloadOrder(); // existing reload helper
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
### Step 9 — i18n strings
|
||
|
||
```json
|
||
// messages/fa.json
|
||
"pos": {
|
||
"void": "ابطال",
|
||
"voided": "ابطال شده",
|
||
"confirmVoid": "آیا مطمئن هستید که میخواهید این آیتم را ابطال کنید؟"
|
||
}
|
||
|
||
// messages/en.json
|
||
"pos": {
|
||
"void": "Void",
|
||
"voided": "Voided",
|
||
"confirmVoid": "Are you sure you want to void this item?"
|
||
}
|
||
|
||
// messages/ar.json
|
||
"pos": {
|
||
"void": "إلغاء",
|
||
"voided": "ملغى",
|
||
"confirmVoid": "هل أنت متأكد أنك تريد إلغاء هذا الصنف؟"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### PR-1 Test Plan
|
||
|
||
**Unit/integration (add to `OrderSessionTests.cs`):**
|
||
```
|
||
✓ Void item reduces order total
|
||
✓ Void already-voided item returns ITEM_ALREADY_VOIDED
|
||
✓ Void item on closed order returns ORDER_ALREADY_CLOSED
|
||
✓ Non-manager role returns 403
|
||
```
|
||
|
||
**Manual:**
|
||
1. Open table, add 3 items.
|
||
2. As Manager, click Void on item 2 — confirm dialog appears.
|
||
3. Confirm — item shows strikethrough, total updates immediately.
|
||
4. As Cashier (non-manager), verify void button is hidden.
|
||
5. Pay remaining items — order closes normally.
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## PR-2 — Transfer Table
|
||
|
||
### Goal
|
||
Move an open order from one table to another. Clears source table, assigns order to target table. Blocked if target table is already occupied.
|
||
|
||
### Files to create / edit
|
||
|
||
```
|
||
src/Meezi.API/Services/OrderService.cs ← TransferTableAsync
|
||
src/Meezi.API/Services/TableService.cs ← helper status check
|
||
src/Meezi.API/Controllers/OrdersController.cs ← POST endpoint
|
||
src/Meezi.API/DTOs/TransferTableRequest.cs ← new DTO
|
||
web/dashboard/src/components/pos/pos-table-board.tsx ← transfer UI
|
||
web/dashboard/src/lib/api/orders.ts ← transferTable()
|
||
messages/fa.json, en.json, ar.json ← transfer strings
|
||
```
|
||
|
||
---
|
||
|
||
### Step 1 — Request DTO
|
||
|
||
```csharp
|
||
// src/Meezi.API/DTOs/TransferTableRequest.cs
|
||
public record TransferTableRequest(Guid TargetTableId);
|
||
```
|
||
|
||
---
|
||
|
||
### Step 2 — Service: `OrderService.cs`
|
||
|
||
```csharp
|
||
public async Task<ApiResponse<OrderDto>> TransferTableAsync(
|
||
Guid orderId, Guid targetTableId, CancellationToken ct = default)
|
||
{
|
||
var order = await _db.Orders
|
||
.Include(o => o.Items)
|
||
.FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == _tenant.CafeId, ct);
|
||
|
||
if (order is null)
|
||
return ApiResponse<OrderDto>.Fail("ORDER_NOT_FOUND");
|
||
|
||
if (order.Status == OrderStatus.Closed)
|
||
return ApiResponse<OrderDto>.Fail("ORDER_ALREADY_CLOSED");
|
||
|
||
// Check target table exists and belongs to this cafe
|
||
var targetTable = await _db.Tables
|
||
.FirstOrDefaultAsync(t => t.Id == targetTableId && t.CafeId == _tenant.CafeId, ct);
|
||
|
||
if (targetTable is null)
|
||
return ApiResponse<OrderDto>.Fail("TABLE_NOT_FOUND");
|
||
|
||
// Check target not occupied
|
||
var targetOccupied = await _db.Orders.AnyAsync(
|
||
o => o.TableId == targetTableId &&
|
||
o.CafeId == _tenant.CafeId &&
|
||
o.Status == OrderStatus.Open &&
|
||
o.Id != orderId, ct);
|
||
|
||
if (targetOccupied)
|
||
return ApiResponse<OrderDto>.Fail("TABLE_OCCUPIED");
|
||
|
||
if (targetTable.IsCleaning)
|
||
return ApiResponse<OrderDto>.Fail("TABLE_CLEANING");
|
||
|
||
// Free source table (nothing to update on Table entity — occupancy is inferred from open orders)
|
||
var sourceTableId = order.TableId;
|
||
|
||
order.TableId = targetTableId;
|
||
await _db.SaveChangesAsync(ct);
|
||
|
||
return ApiResponse<OrderDto>.Ok(_mapper.Map<OrderDto>(order));
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Step 3 — Controller: `OrdersController.cs`
|
||
|
||
```csharp
|
||
/// <summary>Transfer an open order to another table.</summary>
|
||
[HttpPost("{orderId}/transfer")]
|
||
[Authorize(Roles = "Manager,Owner,Waiter")]
|
||
public async Task<IActionResult> TransferTable(
|
||
Guid cafeId,
|
||
Guid orderId,
|
||
[FromBody] TransferTableRequest request,
|
||
CancellationToken ct)
|
||
{
|
||
var result = await _orderService.TransferTableAsync(orderId, request.TargetTableId, ct);
|
||
return result.IsSuccess ? Ok(result) : BadRequest(result);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Step 4 — Dashboard: API helper
|
||
|
||
```typescript
|
||
// web/dashboard/src/lib/api/orders.ts
|
||
|
||
export async function transferTable(
|
||
cafeId: string,
|
||
orderId: string,
|
||
targetTableId: string
|
||
): Promise<void> {
|
||
await apiClient.post(
|
||
`/cafes/${cafeId}/orders/${orderId}/transfer`,
|
||
{ targetTableId }
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Step 5 — Dashboard: Transfer UI in `pos-table-board.tsx`
|
||
|
||
Add a "Transfer" button in the order action bar (next to Pay). On click, show a table-picker modal listing free tables only:
|
||
|
||
```tsx
|
||
// In pos-table-board.tsx — inside the active order action row
|
||
|
||
<button
|
||
className="btn-outline text-sm"
|
||
onClick={() => setShowTransferPicker(true)}
|
||
>
|
||
{t("pos.transferTable")}
|
||
</button>
|
||
|
||
{showTransferPicker && (
|
||
<TablePickerModal
|
||
freeTables={tables.filter(t => !t.isOccupied && !t.isCleaning && t.id !== currentTableId)}
|
||
onSelect={async (tableId) => {
|
||
await transferTable(cafeId, activeOrderId, tableId);
|
||
setShowTransferPicker(false);
|
||
router.push(`/pos?tableId=${tableId}&orderId=${activeOrderId}`);
|
||
}}
|
||
onClose={() => setShowTransferPicker(false)}
|
||
/>
|
||
)}
|
||
```
|
||
|
||
`TablePickerModal` is a simple grid of table buttons — reuse the existing table card style from the board.
|
||
|
||
---
|
||
|
||
### Step 6 — i18n strings
|
||
|
||
```json
|
||
// messages/fa.json
|
||
"pos": {
|
||
"transferTable": "انتقال میز",
|
||
"selectTargetTable": "میز مقصد را انتخاب کنید",
|
||
"transferSuccess": "سفارش با موفقیت منتقل شد"
|
||
}
|
||
|
||
// messages/en.json
|
||
"pos": {
|
||
"transferTable": "Transfer Table",
|
||
"selectTargetTable": "Select destination table",
|
||
"transferSuccess": "Order transferred successfully"
|
||
}
|
||
|
||
// messages/ar.json
|
||
"pos": {
|
||
"transferTable": "نقل الطاولة",
|
||
"selectTargetTable": "اختر الطاولة المستهدفة",
|
||
"transferSuccess": "تم نقل الطلب بنجاح"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### PR-2 Test Plan
|
||
|
||
**Integration tests:**
|
||
```
|
||
✓ Transfer moves order to free target table
|
||
✓ Transfer to occupied table returns TABLE_OCCUPIED
|
||
✓ Transfer to cleaning table returns TABLE_CLEANING
|
||
✓ Transfer closed order returns ORDER_ALREADY_CLOSED
|
||
✓ Transfer to table in different cafe returns TABLE_NOT_FOUND
|
||
```
|
||
|
||
**Manual:**
|
||
1. Open two tables (A occupied, B free).
|
||
2. On table A's order, click Transfer Table.
|
||
3. Pick table B — verify board shows B occupied, A free.
|
||
4. Verify URL updates to `?tableId=B&orderId=...`.
|
||
5. Try transferring to occupied table — verify blocked with error toast.
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## PR-3 — SignalR Table Board Realtime
|
||
|
||
### Goal
|
||
When any order/payment/cleaning event fires, all connected dashboard clients see the board update without refresh.
|
||
|
||
### Files to create / edit
|
||
|
||
```
|
||
src/Meezi.API/Hubs/TableBoardHub.cs ← new SignalR hub
|
||
src/Meezi.API/Services/BoardNotifier.cs ← new service, sends hub events
|
||
src/Meezi.API/Extensions/ServiceCollectionExtensions.cs ← register hub + notifier
|
||
src/Meezi.API/Program.cs ← map hub endpoint
|
||
src/Meezi.API/Services/OrderService.cs ← call notifier after mutations
|
||
src/Meezi.API/Services/TableService.cs ← call notifier after cleaning toggle
|
||
web/dashboard/src/lib/hooks/useTableBoard.ts ← new hook, SignalR subscription
|
||
web/dashboard/src/components/pos/pos-table-board.tsx ← use hook
|
||
```
|
||
|
||
---
|
||
|
||
### Step 1 — Hub: `TableBoardHub.cs`
|
||
|
||
```csharp
|
||
// src/Meezi.API/Hubs/TableBoardHub.cs
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.SignalR;
|
||
|
||
namespace Meezi.API.Hubs;
|
||
|
||
[Authorize]
|
||
public class TableBoardHub : Hub
|
||
{
|
||
// Clients join a cafe-specific group on connect
|
||
public async Task JoinCafe(string cafeId)
|
||
{
|
||
await Groups.AddToGroupAsync(Context.ConnectionId, $"cafe:{cafeId}");
|
||
}
|
||
|
||
public async Task LeaveCafe(string cafeId)
|
||
{
|
||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"cafe:{cafeId}");
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Step 2 — Notifier: `BoardNotifier.cs`
|
||
|
||
```csharp
|
||
// src/Meezi.API/Services/BoardNotifier.cs
|
||
using Microsoft.AspNetCore.SignalR;
|
||
using Meezi.API.Hubs;
|
||
|
||
namespace Meezi.API.Services;
|
||
|
||
public interface IBoardNotifier
|
||
{
|
||
Task TableUpdatedAsync(Guid cafeId, Guid tableId);
|
||
Task OrderUpdatedAsync(Guid cafeId, Guid orderId, Guid? tableId);
|
||
}
|
||
|
||
public class BoardNotifier : IBoardNotifier
|
||
{
|
||
private readonly IHubContext<TableBoardHub> _hub;
|
||
|
||
public BoardNotifier(IHubContext<TableBoardHub> hub)
|
||
{
|
||
_hub = hub;
|
||
}
|
||
|
||
public async Task TableUpdatedAsync(Guid cafeId, Guid tableId)
|
||
{
|
||
await _hub.Clients
|
||
.Group($"cafe:{cafeId}")
|
||
.SendAsync("TableUpdated", new { tableId });
|
||
}
|
||
|
||
public async Task OrderUpdatedAsync(Guid cafeId, Guid orderId, Guid? tableId)
|
||
{
|
||
await _hub.Clients
|
||
.Group($"cafe:{cafeId}")
|
||
.SendAsync("OrderUpdated", new { orderId, tableId });
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Step 3 — Register in DI and map endpoint
|
||
|
||
In `ServiceCollectionExtensions.cs`, add:
|
||
```csharp
|
||
services.AddScoped<IBoardNotifier, BoardNotifier>();
|
||
services.AddSignalR(); // already added if KDS uses it — ensure not duplicated
|
||
```
|
||
|
||
In `Program.cs`, after `app.MapControllers()`:
|
||
```csharp
|
||
app.MapHub<TableBoardHub>("/hubs/table-board")
|
||
.RequireAuthorization();
|
||
```
|
||
|
||
---
|
||
|
||
### Step 4 — Hook into services
|
||
|
||
In `OrderService.cs`, inject `IBoardNotifier` via constructor. After every mutation that changes table state, call the notifier:
|
||
|
||
```csharp
|
||
// After order closed (payment completes):
|
||
await _boardNotifier.OrderUpdatedAsync(order.CafeId, order.Id, order.TableId);
|
||
|
||
// After void item:
|
||
await _boardNotifier.OrderUpdatedAsync(order.CafeId, order.Id, order.TableId);
|
||
|
||
// After transfer:
|
||
await _boardNotifier.TableUpdatedAsync(order.CafeId, sourceTableId);
|
||
await _boardNotifier.OrderUpdatedAsync(order.CafeId, order.Id, targetTableId);
|
||
```
|
||
|
||
In `TableService.cs`, after cleaning toggle:
|
||
```csharp
|
||
await _boardNotifier.TableUpdatedAsync(table.CafeId, table.Id);
|
||
```
|
||
|
||
---
|
||
|
||
### Step 5 — Dashboard hook: `useTableBoard.ts`
|
||
|
||
```typescript
|
||
// web/dashboard/src/lib/hooks/useTableBoard.ts
|
||
import { useEffect, useCallback } from "react";
|
||
import * as signalR from "@microsoft/signalr";
|
||
|
||
let connection: signalR.HubConnection | null = null;
|
||
|
||
export function useTableBoardRealtime(
|
||
cafeId: string,
|
||
onTableUpdated: (tableId: string) => void,
|
||
onOrderUpdated: (orderId: string, tableId: string | null) => void
|
||
) {
|
||
const connect = useCallback(async () => {
|
||
if (connection) return;
|
||
|
||
connection = new signalR.HubConnectionBuilder()
|
||
.withUrl("/hubs/table-board", {
|
||
accessTokenFactory: () => getAccessToken(), // existing auth helper
|
||
})
|
||
.withAutomaticReconnect()
|
||
.build();
|
||
|
||
connection.on("TableUpdated", ({ tableId }: { tableId: string }) => {
|
||
onTableUpdated(tableId);
|
||
});
|
||
|
||
connection.on("OrderUpdated", ({ orderId, tableId }: { orderId: string; tableId: string | null }) => {
|
||
onOrderUpdated(orderId, tableId);
|
||
});
|
||
|
||
await connection.start();
|
||
await connection.invoke("JoinCafe", cafeId);
|
||
}, [cafeId, onTableUpdated, onOrderUpdated]);
|
||
|
||
useEffect(() => {
|
||
connect();
|
||
return () => {
|
||
connection?.stop();
|
||
connection = null;
|
||
};
|
||
}, [connect]);
|
||
}
|
||
```
|
||
|
||
Install SignalR client if not already present:
|
||
```bash
|
||
cd web/dashboard
|
||
npm install @microsoft/signalr
|
||
```
|
||
|
||
---
|
||
|
||
### Step 6 — Wire into `pos-table-board.tsx`
|
||
|
||
```tsx
|
||
// web/dashboard/src/components/pos/pos-table-board.tsx
|
||
|
||
useTableBoardRealtime(
|
||
cafeId,
|
||
(_tableId) => {
|
||
// Refresh the full board (or targeted table)
|
||
reloadBoard();
|
||
},
|
||
(_orderId, _tableId) => {
|
||
reloadBoard();
|
||
}
|
||
);
|
||
```
|
||
|
||
`reloadBoard` should call the existing `GET /tables` + `GET /orders/open` (or whatever the board currently uses).
|
||
|
||
---
|
||
|
||
### PR-3 Test Plan
|
||
|
||
**Manual (two browser tabs):**
|
||
1. Open board in Tab A, same cafe in Tab B.
|
||
2. In Tab B, pay an order on Table 3.
|
||
3. Tab A — verify Table 3 changes to green (free) without refresh.
|
||
4. In Tab B, mark Table 5 as cleaning.
|
||
5. Tab A — verify Table 5 shows cleaning state within ~1 second.
|
||
6. Reload Tab A — verify reconnect works (SignalR `withAutomaticReconnect`).
|
||
|
||
**Iran/network note:** SignalR falls back to long-polling if WebSocket is blocked. No extra config needed — `HubConnectionBuilder` handles this automatically.
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## PR-4 — Receipt Print Preview
|
||
|
||
### Goal
|
||
Thermal-style (80mm) print preview for a paid or open order. Uses `window.print()` — no hardware SDK required.
|
||
|
||
### Files to create / edit
|
||
|
||
```
|
||
web/dashboard/src/components/pos/pos-receipt-modal.tsx ← new component
|
||
web/dashboard/src/components/pos/pos-receipt-print.css ← thermal print styles
|
||
web/dashboard/src/components/pos/pos-pay-panel.tsx ← trigger after payment
|
||
web/dashboard/src/components/pos/pos-table-board.tsx ← "Print" button on closed orders (optional)
|
||
web/dashboard/src/lib/api/orders.ts ← getOrder() if not already
|
||
messages/fa.json, en.json, ar.json ← receipt strings
|
||
```
|
||
|
||
---
|
||
|
||
### Step 1 — Print CSS: `pos-receipt-print.css`
|
||
|
||
```css
|
||
/* web/dashboard/src/components/pos/pos-receipt-print.css */
|
||
|
||
@media print {
|
||
body * { visibility: hidden; }
|
||
#receipt-print-area,
|
||
#receipt-print-area * { visibility: visible; }
|
||
#receipt-print-area {
|
||
position: absolute;
|
||
inset: 0;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
}
|
||
|
||
#receipt-print-area {
|
||
width: 80mm;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 12px;
|
||
direction: rtl;
|
||
text-align: right;
|
||
padding: 4mm;
|
||
}
|
||
|
||
.receipt-divider {
|
||
border-top: 1px dashed #000;
|
||
margin: 3mm 0;
|
||
}
|
||
|
||
.receipt-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.receipt-total {
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Step 2 — Receipt Component: `pos-receipt-modal.tsx`
|
||
|
||
```tsx
|
||
// web/dashboard/src/components/pos/pos-receipt-modal.tsx
|
||
"use client";
|
||
|
||
import { useTranslations } from "next-intl";
|
||
import "./pos-receipt-print.css";
|
||
import type { OrderDto } from "@/lib/api/types";
|
||
|
||
interface Props {
|
||
order: OrderDto;
|
||
cafeName: string;
|
||
onClose: () => void;
|
||
}
|
||
|
||
export function PosReceiptModal({ order, cafeName, onClose }: Props) {
|
||
const t = useTranslations("receipt");
|
||
|
||
const handlePrint = () => window.print();
|
||
|
||
const activeItems = order.items.filter((i) => !i.isVoided);
|
||
const formattedDate = new Intl.DateTimeFormat("fa-IR", {
|
||
dateStyle: "short",
|
||
timeStyle: "short",
|
||
}).format(new Date(order.createdAt));
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||
<div className="bg-white rounded-xl p-4 w-[340px] shadow-xl">
|
||
{/* Screen preview */}
|
||
<div id="receipt-print-area" className="border border-dashed border-gray-300 p-3 mb-4">
|
||
<div className="text-center font-bold text-base mb-1">{cafeName}</div>
|
||
<div className="text-center text-xs text-gray-500 mb-2">{formattedDate}</div>
|
||
<div className="text-xs mb-1">
|
||
{t("table")}: {order.tableName ?? "—"} | {t("order")}: #{order.orderNumber}
|
||
</div>
|
||
{order.guestName && (
|
||
<div className="text-xs mb-1">{t("guest")}: {order.guestName}</div>
|
||
)}
|
||
|
||
<div className="receipt-divider" />
|
||
|
||
{activeItems.map((item) => (
|
||
<div key={item.id} className="receipt-row text-xs mb-1">
|
||
<span>{item.productName} × {item.quantity}</span>
|
||
<span>{formatCurrency(item.unitPrice * item.quantity)}</span>
|
||
</div>
|
||
))}
|
||
|
||
<div className="receipt-divider" />
|
||
|
||
<div className="receipt-row receipt-total">
|
||
<span>{t("total")}</span>
|
||
<span>{formatCurrency(order.totalAmount)}</span>
|
||
</div>
|
||
|
||
{order.payments?.map((p, i) => (
|
||
<div key={i} className="receipt-row text-xs mt-1">
|
||
<span>{t(`payment.${p.method.toLowerCase()}`)}</span>
|
||
<span>{formatCurrency(p.amount)}</span>
|
||
</div>
|
||
))}
|
||
|
||
<div className="receipt-divider" />
|
||
<div className="text-center text-xs mt-2">{t("thankYou")}</div>
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div className="flex gap-2">
|
||
<button
|
||
className="flex-1 btn-primary text-sm"
|
||
onClick={handlePrint}
|
||
>
|
||
{t("print")}
|
||
</button>
|
||
<button
|
||
className="flex-1 btn-outline text-sm"
|
||
onClick={onClose}
|
||
>
|
||
{t("close")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function formatCurrency(amount: number) {
|
||
return new Intl.NumberFormat("fa-IR").format(amount) + " تومان";
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Step 3 — Trigger from `pos-pay-panel.tsx`
|
||
|
||
After a successful payment response, show the receipt:
|
||
|
||
```tsx
|
||
// After successful payment call in pos-pay-panel.tsx
|
||
|
||
const [receiptOrder, setReceiptOrder] = useState<OrderDto | null>(null);
|
||
|
||
// On payment success:
|
||
setReceiptOrder(paidOrder); // paidOrder = the OrderDto returned by the payment API
|
||
|
||
// In JSX:
|
||
{receiptOrder && (
|
||
<PosReceiptModal
|
||
order={receiptOrder}
|
||
cafeName={cafe.name}
|
||
onClose={() => {
|
||
setReceiptOrder(null);
|
||
onPaymentComplete(); // existing callback
|
||
}}
|
||
/>
|
||
)}
|
||
```
|
||
|
||
---
|
||
|
||
### Step 4 — i18n strings
|
||
|
||
```json
|
||
// messages/fa.json
|
||
"receipt": {
|
||
"table": "میز",
|
||
"order": "سفارش",
|
||
"guest": "مهمان",
|
||
"total": "مجموع",
|
||
"print": "چاپ",
|
||
"close": "بستن",
|
||
"thankYou": "ممنون از انتخاب شما",
|
||
"payment": {
|
||
"cash": "نقد",
|
||
"card": "کارت",
|
||
"credit": "اعتبار"
|
||
}
|
||
}
|
||
|
||
// messages/en.json
|
||
"receipt": {
|
||
"table": "Table",
|
||
"order": "Order",
|
||
"guest": "Guest",
|
||
"total": "Total",
|
||
"print": "Print",
|
||
"close": "Close",
|
||
"thankYou": "Thank you for your visit",
|
||
"payment": {
|
||
"cash": "Cash",
|
||
"card": "Card",
|
||
"credit": "Credit"
|
||
}
|
||
}
|
||
|
||
// messages/ar.json
|
||
"receipt": {
|
||
"table": "الطاولة",
|
||
"order": "الطلب",
|
||
"guest": "الضيف",
|
||
"total": "الإجمالي",
|
||
"print": "طباعة",
|
||
"close": "إغلاق",
|
||
"thankYou": "شكراً على زيارتكم",
|
||
"payment": {
|
||
"cash": "نقداً",
|
||
"card": "بطاقة",
|
||
"credit": "رصيد"
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### PR-4 Test Plan
|
||
|
||
**Manual:**
|
||
1. Open table, add items, pay (split cash + card).
|
||
2. Verify receipt modal auto-opens after payment.
|
||
3. Verify all items listed, voided items excluded.
|
||
4. Verify payment methods shown (Cash X, Card Y).
|
||
5. Click Print — browser print dialog opens; preview shows 80mm receipt layout.
|
||
6. Test in Persian (fa) — text is RTL, numbers in Persian numerals.
|
||
7. Close modal — board reflects freed table.
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## PR-5 — Integration Test Coverage
|
||
|
||
### Goal
|
||
Fill the gaps in `OrderSessionTests.cs` and add auth + payment integration tests.
|
||
|
||
### Files to create / edit
|
||
|
||
```
|
||
tests/Meezi.API.Tests/OrderSessionTests.cs ← extend existing
|
||
tests/Meezi.API.Tests/OrderVoidTransferTests.cs ← new
|
||
tests/Meezi.API.Tests/PaymentSplitTests.cs ← new
|
||
tests/Meezi.API.Tests/AuthTests.cs ← new
|
||
```
|
||
|
||
---
|
||
|
||
### `OrderVoidTransferTests.cs` — full file
|
||
|
||
```csharp
|
||
// tests/Meezi.API.Tests/OrderVoidTransferTests.cs
|
||
using System.Net;
|
||
using System.Net.Http.Json;
|
||
using Meezi.API.DTOs;
|
||
using Xunit;
|
||
|
||
namespace Meezi.API.Tests;
|
||
|
||
public class OrderVoidTransferTests : IClassFixture<MeeziWebApplicationFactory>
|
||
{
|
||
private readonly HttpClient _client;
|
||
|
||
public OrderVoidTransferTests(MeeziWebApplicationFactory factory)
|
||
{
|
||
_client = factory.CreateAuthenticatedClient(role: "Manager");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task VoidItem_ReducesOrderTotal()
|
||
{
|
||
// Arrange: create order with 2 items
|
||
var (cafeId, orderId, items) = await CreateOrderWithItems(2);
|
||
var itemId = items[0].Id;
|
||
var originalTotal = items.Sum(i => i.UnitPrice * i.Quantity);
|
||
|
||
// Act
|
||
var response = await _client.PatchAsJsonAsync(
|
||
$"/api/cafes/{cafeId}/orders/{orderId}/items/{itemId}/void", new { });
|
||
|
||
// Assert
|
||
response.EnsureSuccessStatusCode();
|
||
var order = await GetOrder(cafeId, orderId);
|
||
Assert.Equal(originalTotal - items[0].UnitPrice, order.TotalAmount);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task VoidItem_AlreadyVoided_ReturnsBadRequest()
|
||
{
|
||
var (cafeId, orderId, items) = await CreateOrderWithItems(1);
|
||
var itemId = items[0].Id;
|
||
|
||
await _client.PatchAsJsonAsync(
|
||
$"/api/cafes/{cafeId}/orders/{orderId}/items/{itemId}/void", new { });
|
||
|
||
var response = await _client.PatchAsJsonAsync(
|
||
$"/api/cafes/{cafeId}/orders/{orderId}/items/{itemId}/void", new { });
|
||
|
||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||
var error = await response.Content.ReadFromJsonAsync<ApiError>();
|
||
Assert.Equal("ITEM_ALREADY_VOIDED", error?.Code);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task TransferTable_MovesOrderToFreeTable()
|
||
{
|
||
var (cafeId, orderId, sourceTableId, targetTableId) = await SetupTwoTables();
|
||
|
||
var response = await _client.PostAsJsonAsync(
|
||
$"/api/cafes/{cafeId}/orders/{orderId}/transfer",
|
||
new { targetTableId });
|
||
|
||
response.EnsureSuccessStatusCode();
|
||
|
||
// Source table should be free
|
||
Assert.False(await IsTableOccupied(cafeId, sourceTableId));
|
||
// Target table should be occupied
|
||
Assert.True(await IsTableOccupied(cafeId, targetTableId));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task TransferTable_ToOccupiedTable_ReturnsTableOccupied()
|
||
{
|
||
// Both tables occupied
|
||
var (cafeId, order1Id, table1Id, _) = await SetupTwoTables();
|
||
var (_, order2Id, table2Id, _) = await SetupTwoTables(cafeId);
|
||
|
||
var response = await _client.PostAsJsonAsync(
|
||
$"/api/cafes/{cafeId}/orders/{order1Id}/transfer",
|
||
new { targetTableId = table2Id });
|
||
|
||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||
var error = await response.Content.ReadFromJsonAsync<ApiError>();
|
||
Assert.Equal("TABLE_OCCUPIED", error?.Code);
|
||
}
|
||
|
||
// Helper methods omitted for brevity — follow MeeziWebApplicationFactory patterns
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### `PaymentSplitTests.cs` — key cases
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task SplitPayment_CashAndCard_ClosesOrder()
|
||
{
|
||
var (cafeId, orderId) = await CreateOrderWithTotal(1000m);
|
||
|
||
var response = await _client.PostAsJsonAsync(
|
||
$"/api/cafes/{cafeId}/orders/{orderId}/payments",
|
||
new {
|
||
payments = new[] {
|
||
new { method = "Cash", amount = 600 },
|
||
new { method = "Card", amount = 400 }
|
||
}
|
||
});
|
||
|
||
response.EnsureSuccessStatusCode();
|
||
var order = await GetOrder(cafeId, orderId);
|
||
Assert.Equal("Closed", order.Status);
|
||
Assert.Equal(2, order.Payments.Length);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Payment_FreesTableOnBoard()
|
||
{
|
||
var (cafeId, orderId, tableId) = await CreateOrderOnTable();
|
||
|
||
await _client.PostAsJsonAsync(
|
||
$"/api/cafes/{cafeId}/orders/{orderId}/payments",
|
||
new { payments = new[] { new { method = "Cash", amount = 500 } } });
|
||
|
||
var tableStatus = await GetTableStatus(cafeId, tableId);
|
||
Assert.Equal("Free", tableStatus);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### `AuthTests.cs` — key cases
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task UnauthorizedRequest_Returns401()
|
||
{
|
||
var anonClient = _factory.CreateClient(); // no auth
|
||
var response = await anonClient.GetAsync("/api/cafes/00000000/orders");
|
||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task WrongCafeId_Returns403OrEmpty()
|
||
{
|
||
var response = await _client.GetAsync("/api/cafes/99999999/orders");
|
||
// Either 403 or empty list — not another cafe's data
|
||
Assert.True(
|
||
response.StatusCode == HttpStatusCode.Forbidden ||
|
||
(await response.Content.ReadFromJsonAsync<ApiResponse<List<OrderDto>>>())?.Data?.Count == 0
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Run tests
|
||
|
||
```bash
|
||
dotnet test tests/Meezi.API.Tests/Meezi.API.Tests.csproj -c Release --logger "console;verbosity=normal"
|
||
```
|
||
|
||
Expected: all existing 13 + new ~12 tests pass.
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## PR-6 — Fix NU1903 Package Warnings
|
||
|
||
### Goal
|
||
Eliminate the two vulnerability warnings that appear on every build.
|
||
|
||
### Warning 1: AutoMapper 12.0.1
|
||
|
||
**Option A — Bump AutoMapper (preferred):**
|
||
```xml
|
||
<!-- Directory.Packages.props -->
|
||
<!-- Replace: -->
|
||
<PackageVersion Include="AutoMapper" Version="12.0.1" />
|
||
<!-- With: -->
|
||
<PackageVersion Include="AutoMapper" Version="13.0.1" />
|
||
```
|
||
|
||
Then verify no breaking changes (AutoMapper 13 removed some static APIs):
|
||
```bash
|
||
dotnet build src/Meezi.API/Meezi.API.csproj -c Release 2>&1 | grep -i error
|
||
```
|
||
|
||
**Option B — Replace with Mapperly (zero-reflection, .NET 10 ideal):**
|
||
|
||
Only do this if AutoMapper 13 has breaking changes. Mapperly generates mapping code at compile time. Migration guide: https://mapperly.riok.app/docs/getting-started/migration/automapper/
|
||
|
||
---
|
||
|
||
### Warning 2: System.Security.Cryptography.Xml 9.0.0
|
||
|
||
This is a transitive dependency. Pin it to the fixed version:
|
||
```xml
|
||
<!-- Directory.Packages.props — add: -->
|
||
<PackageVersion Include="System.Security.Cryptography.Xml" Version="9.0.5" />
|
||
```
|
||
|
||
Then in the project that pulls it transitively (likely `Meezi.Infrastructure`):
|
||
```xml
|
||
<!-- Meezi.Infrastructure.csproj — add: -->
|
||
<PackageReference Include="System.Security.Cryptography.Xml" />
|
||
```
|
||
|
||
(No version needed — central package management handles it.)
|
||
|
||
---
|
||
|
||
### Verify
|
||
|
||
```bash
|
||
dotnet build src/Meezi.API/Meezi.API.csproj -c Release 2>&1 | grep NU1903
|
||
# Expected: no output
|
||
```
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## Implementation Order & Branching
|
||
|
||
```
|
||
main
|
||
└── feature/void-order-line (PR-1, ~2h)
|
||
└── feature/transfer-table (PR-2, depends on PR-1 merge)
|
||
└── feature/signalr-board (PR-3, depends on PR-2)
|
||
└── feature/receipt (PR-4, independent of PR-3 but nice with it)
|
||
|
||
feature/integration-tests (PR-5, can start any time in parallel)
|
||
feature/fix-nu1903 (PR-6, can start any time in parallel)
|
||
```
|
||
|
||
PRs 5 and 6 are independent — open them alongside PR-1 in parallel.
|
||
|
||
---
|
||
|
||
## Non-Negotiables (from `.cursorrules`)
|
||
|
||
- Every EF query: `CafeId == _tenant.CafeId`
|
||
- Responses: `ApiResponse<T>` / `ApiError` with error codes
|
||
- No hardcoded UI strings — all in `messages/{fa,ar,en}.json`
|
||
- Dashboard CSS: `ms-*` / `me-*` only (RTL-safe)
|
||
- Iran Docker: if any PR adds a new NuGet or npm package, document it; Docker pulls may need VPN/mirror
|
||
- Tests: in-memory EF, `Testing:Enabled=true`, no live Redis/Hangfire in test mode
|
||
|
||
---
|
||
|
||
## Quick Reference — Error Codes Added This Sprint
|
||
|
||
| Code | Meaning |
|
||
|------|---------|
|
||
| `ITEM_NOT_FOUND` | Line item ID not on this order |
|
||
| `ITEM_ALREADY_VOIDED` | Tried to void an already-voided line |
|
||
| `TABLE_CLEANING` | Target table is in cleaning state |
|
||
| `ORDER_ALREADY_CLOSED` | Mutation attempted on a closed order |
|
||
|
||
---
|
||
|
||
*End of plan — paste this file into Cursor and start with PR-1.*
|