feat(orders): per-item kitchen/bar notes (POS + QR app + KDS)
CI/CD / CI · API (dotnet build + test) (push) Successful in 57s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 57s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m43s
CI/CD / CI · API (dotnet build + test) (push) Successful in 57s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 57s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m43s
Lets the POS agent and the QR/app customer attach a free-text note to each order line (e.g. "no tomato", "extra hot") that reaches the kitchen/bar. - Backend already supported it (OrderItem.Notes persists; CreateOrderItemRequest and OrderItemDto carry Notes; LiveOrderDto items include it) — this wires the UI. - cart.store: add setNotes(menuItemId, notes); notes already travel in getPendingLines and round-trip via hydrateFromOrder. - POS pos-screen: a note input under each cart line. - QR guest menu: a note input under each cart line (QrCartLine.note). - KDS: render the note prominently under each item so kitchen/bar sees it. - i18n: pos.itemNotePlaceholder + qrMenu.itemNote (fa/ar/en). Note: notes are captured on items being added; editing a note on an already-submitted line is out of scope (no pending delta to re-send). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -270,6 +270,7 @@
|
|||||||
"void": "إلغاء",
|
"void": "إلغاء",
|
||||||
"voidItem": "إلغاء الصنف",
|
"voidItem": "إلغاء الصنف",
|
||||||
"voided": "ملغى",
|
"voided": "ملغى",
|
||||||
|
"itemNotePlaceholder": "ملاحظة للمطبخ/البار (اختياري)",
|
||||||
"confirmVoid": "هل أنت متأكد أنك تريد إلغاء هذا الصنف؟",
|
"confirmVoid": "هل أنت متأكد أنك تريد إلغاء هذا الصنف؟",
|
||||||
"voidError": "تعذر إلغاء الصنف",
|
"voidError": "تعذر إلغاء الصنف",
|
||||||
"transferTable": "نقل الطاولة",
|
"transferTable": "نقل الطاولة",
|
||||||
@@ -883,6 +884,7 @@
|
|||||||
"orderHint": "سيقوم الموظفون بتحضير طلبك قريباً",
|
"orderHint": "سيقوم الموظفون بتحضير طلبك قريباً",
|
||||||
"guestName": "اسمك (اختياري)",
|
"guestName": "اسمك (اختياري)",
|
||||||
"guestPhone": "الجوال (اختياري)",
|
"guestPhone": "الجوال (اختياري)",
|
||||||
|
"itemNote": "ملاحظة (مثلاً بدون طماطم، سكر أقل)",
|
||||||
"addMoreItems": "إضافة المزيد",
|
"addMoreItems": "إضافة المزيد",
|
||||||
"orderError": "تعذر تسجيل الطلب. حاول مرة أخرى.",
|
"orderError": "تعذر تسجيل الطلب. حاول مرة أخرى.",
|
||||||
"rateLimited": "طلبات كثيرة — انتظر بضع دقائق",
|
"rateLimited": "طلبات كثيرة — انتظر بضع دقائق",
|
||||||
|
|||||||
@@ -289,6 +289,7 @@
|
|||||||
"void": "Void",
|
"void": "Void",
|
||||||
"voidItem": "Void item",
|
"voidItem": "Void item",
|
||||||
"voided": "Voided",
|
"voided": "Voided",
|
||||||
|
"itemNotePlaceholder": "Note for kitchen/bar (optional)",
|
||||||
"confirmVoid": "Are you sure you want to void this item?",
|
"confirmVoid": "Are you sure you want to void this item?",
|
||||||
"voidError": "Could not void item",
|
"voidError": "Could not void item",
|
||||||
"transferTable": "Transfer table",
|
"transferTable": "Transfer table",
|
||||||
@@ -952,6 +953,7 @@
|
|||||||
"orderHint": "Staff will prepare your order shortly",
|
"orderHint": "Staff will prepare your order shortly",
|
||||||
"guestName": "Your name (optional)",
|
"guestName": "Your name (optional)",
|
||||||
"guestPhone": "Mobile (optional)",
|
"guestPhone": "Mobile (optional)",
|
||||||
|
"itemNote": "Note (e.g. no tomato, less sugar)",
|
||||||
"addMoreItems": "Add more items",
|
"addMoreItems": "Add more items",
|
||||||
"orderError": "Could not place order. Try again.",
|
"orderError": "Could not place order. Try again.",
|
||||||
"rateLimited": "Too many requests — please wait a few minutes",
|
"rateLimited": "Too many requests — please wait a few minutes",
|
||||||
|
|||||||
@@ -289,6 +289,7 @@
|
|||||||
"void": "ابطال",
|
"void": "ابطال",
|
||||||
"voidItem": "ابطال آیتم",
|
"voidItem": "ابطال آیتم",
|
||||||
"voided": "ابطال شده",
|
"voided": "ابطال شده",
|
||||||
|
"itemNotePlaceholder": "یادداشت برای آشپزخانه/بار (اختیاری)",
|
||||||
"confirmVoid": "آیا مطمئن هستید که میخواهید این آیتم را ابطال کنید؟",
|
"confirmVoid": "آیا مطمئن هستید که میخواهید این آیتم را ابطال کنید؟",
|
||||||
"voidError": "خطا در ابطال آیتم",
|
"voidError": "خطا در ابطال آیتم",
|
||||||
"transferTable": "انتقال میز",
|
"transferTable": "انتقال میز",
|
||||||
@@ -952,6 +953,7 @@
|
|||||||
"orderHint": "کارکنان به زودی سفارش شما را آماده میکنند",
|
"orderHint": "کارکنان به زودی سفارش شما را آماده میکنند",
|
||||||
"guestName": "نام شما (اختیاری)",
|
"guestName": "نام شما (اختیاری)",
|
||||||
"guestPhone": "شماره موبایل (اختیاری)",
|
"guestPhone": "شماره موبایل (اختیاری)",
|
||||||
|
"itemNote": "یادداشت (مثلاً بدون گوجه، کمشکر)",
|
||||||
"addMoreItems": "افزودن آیتم دیگر",
|
"addMoreItems": "افزودن آیتم دیگر",
|
||||||
"orderError": "خطا در ثبت سفارش. دوباره امتحان کنید",
|
"orderError": "خطا در ثبت سفارش. دوباره امتحان کنید",
|
||||||
"orderSaveError": "سفارش ثبت شد اما ذخیره محلی ناموفق بود. صفحه را رفرش نکنید.",
|
"orderSaveError": "سفارش ثبت شد اما ذخیره محلی ناموفق بود. صفحه را رفرش نکنید.",
|
||||||
|
|||||||
@@ -178,6 +178,11 @@ export function KdsScreen() {
|
|||||||
<li key={item.id}>
|
<li key={item.id}>
|
||||||
{formatNumber(item.quantity, numberLocale)}×{" "}
|
{formatNumber(item.quantity, numberLocale)}×{" "}
|
||||||
{item.menuItemName}
|
{item.menuItemName}
|
||||||
|
{item.notes ? (
|
||||||
|
<span className="mt-0.5 block rounded bg-amber-50 px-1.5 py-0.5 text-[11px] font-medium text-amber-800">
|
||||||
|
✍️ {item.notes}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ export function PosScreen() {
|
|||||||
addItem,
|
addItem,
|
||||||
removeItem,
|
removeItem,
|
||||||
updateQty,
|
updateQty,
|
||||||
|
setNotes,
|
||||||
couponCode,
|
couponCode,
|
||||||
appliedCoupon,
|
appliedCoupon,
|
||||||
setCouponCode,
|
setCouponCode,
|
||||||
@@ -1210,10 +1211,11 @@ export function PosScreen() {
|
|||||||
<div
|
<div
|
||||||
key={line.orderItemId ?? line.menuItem.id}
|
key={line.orderItemId ?? line.menuItem.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-lg border border-border p-2",
|
"flex flex-col gap-1.5 rounded-lg border border-border p-2",
|
||||||
line.isVoided && "opacity-60"
|
line.isVoided && "opacity-60"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<MenuItemLabels
|
<MenuItemLabels
|
||||||
item={line.menuItem}
|
item={line.menuItem}
|
||||||
@@ -1291,6 +1293,18 @@ export function PosScreen() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{!line.isVoided && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={line.notes ?? ""}
|
||||||
|
onChange={(e) => setNotes(line.menuItem.id, e.target.value)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
placeholder={t("itemNotePlaceholder")}
|
||||||
|
className="w-full rounded-md border border-border/70 bg-background px-2 py-1 text-[11px] placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -407,29 +407,44 @@ export function QrGuestMenu({ code }: QrGuestMenuProps) {
|
|||||||
{cart.map((c) => (
|
{cart.map((c) => (
|
||||||
<div
|
<div
|
||||||
key={c.item.id}
|
key={c.item.id}
|
||||||
className="flex items-center justify-between gap-3 border-b px-3 py-3 last:border-0"
|
className="flex flex-col gap-2 border-b px-3 py-3 last:border-0"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<MenuItemLabels item={c.item} lines={1} primaryClassName="text-sm" />
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium" style={{ color: primary }}>
|
<MenuItemLabels item={c.item} lines={1} primaryClassName="text-sm" />
|
||||||
{formatCurrency(effectiveLinePrice(c.item), "fa-IR")}
|
<p className="text-sm font-medium" style={{ color: primary }}>
|
||||||
</p>
|
{formatCurrency(effectiveLinePrice(c.item), "fa-IR")}
|
||||||
</div>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
<QtyButton
|
<div className="flex items-center gap-2">
|
||||||
label="−"
|
<QtyButton
|
||||||
onClick={() => removeFromCart(c.item.id)}
|
label="−"
|
||||||
variant="outline"
|
onClick={() => removeFromCart(c.item.id)}
|
||||||
color={primary}
|
variant="outline"
|
||||||
/>
|
color={primary}
|
||||||
<span className="min-w-6 text-center font-semibold">{c.qty}</span>
|
/>
|
||||||
<QtyButton
|
<span className="min-w-6 text-center font-semibold">{c.qty}</span>
|
||||||
label="+"
|
<QtyButton
|
||||||
onClick={() => addToCart(c.item)}
|
label="+"
|
||||||
variant="filled"
|
onClick={() => addToCart(c.item)}
|
||||||
color={primary}
|
variant="filled"
|
||||||
/>
|
color={primary}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={c.note ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCart((prev) =>
|
||||||
|
prev.map((l) =>
|
||||||
|
l.item.id === c.item.id ? { ...l, note: e.target.value } : l
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder={t("itemNote")}
|
||||||
|
className="w-full rounded-md border qr-border bg-transparent px-2 py-1.5 text-xs placeholder:opacity-60 focus:outline-none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface CartState {
|
|||||||
addItem: (item: MenuItem) => void;
|
addItem: (item: MenuItem) => void;
|
||||||
removeItem: (menuItemId: string) => void;
|
removeItem: (menuItemId: string) => void;
|
||||||
updateQty: (menuItemId: string, quantity: number) => void;
|
updateQty: (menuItemId: string, quantity: number) => void;
|
||||||
|
setNotes: (menuItemId: string, notes: string) => void;
|
||||||
setCouponCode: (code: string) => void;
|
setCouponCode: (code: string) => void;
|
||||||
setAppliedCoupon: (coupon: AppliedCoupon | null) => void;
|
setAppliedCoupon: (coupon: AppliedCoupon | null) => void;
|
||||||
clearCoupon: () => void;
|
clearCoupon: () => void;
|
||||||
@@ -135,6 +136,13 @@ export const useCartStore = create<CartState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setNotes: (menuItemId, notes) =>
|
||||||
|
set({
|
||||||
|
items: get().items.map((i) =>
|
||||||
|
i.menuItem.id === menuItemId ? { ...i, notes: notes.trim() || undefined } : i
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
|
||||||
setCouponCode: (code) => set({ couponCode: code }),
|
setCouponCode: (code) => set({ couponCode: code }),
|
||||||
setAppliedCoupon: (coupon) => set({ appliedCoupon: coupon }),
|
setAppliedCoupon: (coupon) => set({ appliedCoupon: coupon }),
|
||||||
clearCoupon: () => set(clearCouponState),
|
clearCoupon: () => set(clearCouponState),
|
||||||
|
|||||||
Reference in New Issue
Block a user