Skip to content

Commit 39a6f39

Browse files
feat: add bill
1 parent 703f1f5 commit 39a6f39

9 files changed

Lines changed: 491 additions & 28 deletions

File tree

app/Http/Controllers/PosController.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ public function getOrders(Request $request): JsonResponse
7373
public function getOrder(Order $order): JsonResponse
7474
{
7575
$order->load([
76+
'outlet:id,name',
7677
'customer:id,name,phone',
7778
'orderTables.diningTable:id,name',
7879
'items' => fn ($q) => $q->where('status', '!=', 'cancelled'),

resources/js/components/order-bill.tsx

Lines changed: 336 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { useEffect } from 'react';
2+
3+
export function useEscapeKey(onEscape: () => void, enabled = true) {
4+
useEffect(() => {
5+
if (!enabled) return;
6+
function handler(e: KeyboardEvent) {
7+
if (e.key === 'Escape') onEscape();
8+
}
9+
document.addEventListener('keydown', handler);
10+
return () => document.removeEventListener('keydown', handler);
11+
}, [enabled, onEscape]);
12+
}

resources/js/pages/dining-tables/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ export default function DiningTablesIndex({ diningTables, outlets, diningAreas,
220220
const { table, svg } = qrModal;
221221
const areaLine = table.dining_area ? `<p style="margin:4px 0 0;color:#888;font-size:13px;">${table.dining_area.name}</p>` : '';
222222
const outletLine = table.outlet ? `<p style="margin:2px 0 0;color:#aaa;font-size:12px;">${table.outlet.name}</p>` : '';
223-
const html = `<!DOCTYPE html><html><head><title>QR Code ${table.name}</title>
223+
const html = `<!DOCTYPE html><html><head><title>QR Code - ${table.name}</title>
224224
<style>
225225
*{box-sizing:border-box;margin:0;padding:0}
226226
body{display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:system-ui,sans-serif;background:#fff}

resources/js/pages/orders/index.tsx

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Head, Link, router } from '@inertiajs/react';
2-
import { Filter } from 'lucide-react';
2+
import { Filter, Printer } from 'lucide-react';
33
import { useEffect, useMemo, useRef, useState } from 'react';
4+
import { PrintModal, type BillOrder } from '@/components/order-bill';
45
import { useConfirm } from '@/hooks/use-confirm';
56
import { useCan } from '@/hooks/use-can';
67
import { PageHeader } from '@/components/page-header';
@@ -92,6 +93,23 @@ export default function OrdersIndex({ orders, outlets, filters }: Props) {
9293
const { can } = useCan();
9394
const { confirm, dialog } = useConfirm();
9495
const [openMenuId, setOpenMenuId] = useState<string | number | null>(null);
96+
97+
const [printOrder, setPrintOrder] = useState<BillOrder | null>(null);
98+
const [printMode, setPrintMode] = useState<'pos' | 'invoice' | null>(null);
99+
const [printLoading, setPrintLoading] = useState(false);
100+
101+
async function openPrint(orderId: number, mode: 'pos' | 'invoice') {
102+
setPrintLoading(true);
103+
try {
104+
const csrf = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? '';
105+
const res = await fetch(`/pos/orders/${orderId}`, { headers: { Accept: 'application/json', 'X-CSRF-TOKEN': csrf } });
106+
const data = await res.json();
107+
setPrintOrder(data);
108+
setPrintMode(mode);
109+
} finally {
110+
setPrintLoading(false);
111+
}
112+
}
95113
const filterPopoverRef = useRef<HTMLDetailsElement | null>(null);
96114

97115
const [form, setForm] = useState({
@@ -375,13 +393,33 @@ export default function OrdersIndex({ orders, outlets, filters }: Props) {
375393
{new Date(order.created_at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}
376394
</td>
377395
<td className="px-6 py-4 text-right">
378-
<ActionDropdown
379-
isOpen={openMenuId === order.id}
380-
itemId={order.id}
381-
itemLabel={order.order_number}
382-
onToggle={(id) => setOpenMenuId((cur) => (id === null ? null : cur === id ? null : id as number))}
383-
actions={buildActions(order)}
384-
/>
396+
<div className="flex items-center justify-end gap-1">
397+
<button
398+
type="button"
399+
title="POS Bill"
400+
disabled={printLoading}
401+
onClick={() => openPrint(order.id, 'pos')}
402+
className="flex h-7 w-7 items-center justify-center rounded text-violet-500 transition hover:bg-violet-50 disabled:opacity-40 dark:text-violet-400 dark:hover:bg-violet-900/20"
403+
>
404+
<Printer className="h-3.5 w-3.5" />
405+
</button>
406+
<button
407+
type="button"
408+
title="Invoice"
409+
disabled={printLoading}
410+
onClick={() => openPrint(order.id, 'invoice')}
411+
className="flex h-7 w-7 items-center justify-center rounded text-muted-foreground transition hover:bg-muted disabled:opacity-40 dark:hover:bg-stone-800"
412+
>
413+
<span className="material-symbols-outlined text-[16px]">description</span>
414+
</button>
415+
<ActionDropdown
416+
isOpen={openMenuId === order.id}
417+
itemId={order.id}
418+
itemLabel={order.order_number}
419+
onToggle={(id) => setOpenMenuId((cur) => (id === null ? null : cur === id ? null : id as number))}
420+
actions={buildActions(order)}
421+
/>
422+
</div>
385423
</td>
386424
</tr>
387425
))}
@@ -391,6 +429,14 @@ export default function OrdersIndex({ orders, outlets, filters }: Props) {
391429
</TableCard>
392430

393431
{dialog}
432+
433+
{printOrder && printMode && (
434+
<PrintModal
435+
order={printOrder}
436+
mode={printMode}
437+
onClose={() => { setPrintOrder(null); setPrintMode(null); }}
438+
/>
439+
)}
394440
</>
395441
);
396442
}

resources/js/pages/orders/show.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { SearchableSelect } from '@/components/ui/searchable-select';
88
import { FormField } from '@/components/ui/form-field';
99
import { Button } from '@/components/ui/button';
1010
import { Input } from '@/components/ui/input';
11+
import { PrintModal } from '@/components/order-bill';
1112
import { dashboard } from '@/routes';
1213
import { index as ordersIndex, edit as ordersEdit, destroy as ordersDestroy } from '@/routes/orders';
1314
import { update as ordersStatusUpdate } from '@/routes/orders/status';
@@ -170,6 +171,8 @@ export default function OrdersShow({ order, availableFoods, availableTables }: P
170171
const { can } = useCan();
171172
const { confirm, dialog } = useConfirm();
172173

174+
const [billMode, setBillMode] = useState<'pos' | 'invoice' | null>(null);
175+
173176
const isActive = !['completed', 'cancelled'].includes(order.status);
174177
const nextStatuses = STATUS_TRANSITIONS[order.status] ?? [];
175178

@@ -269,6 +272,24 @@ export default function OrdersShow({ order, availableFoods, availableTables }: P
269272
{STATUS_LABELS[order.status] ?? order.status}
270273
</span>
271274

275+
<button
276+
type="button"
277+
onClick={() => setBillMode('pos')}
278+
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm font-semibold text-foreground transition-colors hover:bg-muted"
279+
>
280+
<span className="material-symbols-outlined text-[16px]">receipt</span>
281+
POS Bill
282+
</button>
283+
284+
<button
285+
type="button"
286+
onClick={() => setBillMode('invoice')}
287+
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm font-semibold text-foreground transition-colors hover:bg-muted"
288+
>
289+
<span className="material-symbols-outlined text-[16px]">description</span>
290+
Invoice
291+
</button>
292+
272293
{can('orders-update') && (
273294
<Link
274295
href={ordersEdit.url(order.id)}
@@ -657,6 +678,14 @@ export default function OrdersShow({ order, availableFoods, availableTables }: P
657678
</div>
658679

659680
{dialog}
681+
682+
{billMode && (
683+
<PrintModal
684+
order={order}
685+
mode={billMode}
686+
onClose={() => setBillMode(null)}
687+
/>
688+
)}
660689
</>
661690
);
662691
}

resources/js/pages/pos/index.tsx

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { Head, Link, router } from '@inertiajs/react';
2-
import { Search, Trash2, Plus, Minus, ShoppingCart, ChevronRight, ChevronLeft, UserPlus, ZoomIn, ZoomOut, Home, RefreshCw, Maximize2, Minimize2, Keyboard, CheckCircle, Pencil, FileText, Receipt, X } from 'lucide-react';
2+
import { Search, Trash2, Plus, Minus, ShoppingCart, ChevronRight, ChevronLeft, UserPlus, ZoomIn, ZoomOut, Home, RefreshCw, Maximize2, Minimize2, Keyboard, CheckCircle, Pencil, X, Printer } from 'lucide-react';
33
import { useEffect, useMemo, useRef, useState } from 'react';
44
import { toast } from 'react-toastify';
55
import { SearchableSelect } from '@/components/ui/searchable-select';
66
import { useConfirm } from '@/hooks/use-confirm';
77
import { cn } from '@/lib/utils';
88
import { placeOrder as posPlaceOrder } from '@/routes/pos';
9+
import { PrintModal, type BillOrder } from '@/components/order-bill';
910

1011
type Outlet = { id: number; name: string };
1112
type Customer = { id: number; name: string; phone: string };
@@ -115,6 +116,29 @@ export default function PosIndex({ outlets, categories, foods, customers: initia
115116

116117
const [itemsModalOrder, setItemsModalOrder] = useState<OrderSummary | null>(null);
117118

119+
// Print modal
120+
const [printOrder, setPrintOrder] = useState<BillOrder | null>(null);
121+
const [printMode, setPrintMode] = useState<'pos' | 'invoice' | null>(null);
122+
const [printLoading, setPrintLoading] = useState(false);
123+
124+
async function openPrintModal(order: OrderSummary, mode: 'pos' | 'invoice') {
125+
setPrintLoading(true);
126+
try {
127+
const csrf = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? '';
128+
const res = await fetch(`/pos/orders/${order.id}`, { headers: { Accept: 'application/json', 'X-CSRF-TOKEN': csrf } });
129+
const data = await res.json();
130+
setPrintOrder(data);
131+
setPrintMode(mode);
132+
} finally {
133+
setPrintLoading(false);
134+
}
135+
}
136+
137+
function closePrintModal() {
138+
setPrintOrder(null);
139+
setPrintMode(null);
140+
}
141+
118142
async function openItemsModal(order: OrderSummary) {
119143
setItemsModalOrder(order);
120144
setExpandedItems([]);
@@ -608,6 +632,8 @@ export default function PosIndex({ outlets, categories, foods, customers: initia
608632
const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement).isContentEditable;
609633

610634
if (e.key === 'Escape') {
635+
if (printMode) { closePrintModal(); return; }
636+
if (itemsModalOrder) { setItemsModalOrder(null); return; }
611637
if (showHelp) { setShowHelp(false); return; }
612638
if (modalFood) { closeModal(); return; }
613639
if (showNewCustomer) { setShowNewCustomer(false); return; }
@@ -679,7 +705,7 @@ export default function PosIndex({ outlets, categories, foods, customers: initia
679705

680706
window.addEventListener('keydown', onKeyDown);
681707
return () => window.removeEventListener('keydown', onKeyDown);
682-
}, [showHelp, modalFood, showNewCustomer, search, view, zoom, cart]);
708+
}, [showHelp, modalFood, showNewCustomer, itemsModalOrder, printMode, search, view, zoom, cart]);
683709

684710
return (
685711
<>
@@ -918,24 +944,28 @@ export default function PosIndex({ outlets, categories, foods, customers: initia
918944
</button>
919945

920946
{/* Invoice */}
921-
<Link
922-
href={`/orders/${order.id}`}
947+
<button
948+
type="button"
923949
title="Invoice"
924-
className="flex flex-col items-center justify-center gap-0.5 py-2.5 text-muted-foreground transition hover:bg-muted dark:hover:bg-stone-800"
950+
disabled={printLoading}
951+
onClick={() => openPrintModal(order, 'invoice')}
952+
className="flex flex-col items-center justify-center gap-0.5 py-2.5 text-muted-foreground transition hover:bg-muted disabled:opacity-40 dark:hover:bg-stone-800"
925953
>
926-
<FileText className="h-3.5 w-3.5" />
954+
<Printer className="h-3.5 w-3.5" />
927955
<span className="text-[9px] font-bold">Invoice</span>
928-
</Link>
956+
</button>
929957

930-
{/* POS Invoice */}
931-
<Link
932-
href={`/orders/${order.id}?print=pos`}
933-
title="POS Invoice"
934-
className="flex flex-col items-center justify-center gap-0.5 py-2.5 text-violet-600 transition hover:bg-violet-50 dark:text-violet-400 dark:hover:bg-violet-900/20"
958+
{/* POS Bill */}
959+
<button
960+
type="button"
961+
title="POS Bill"
962+
disabled={printLoading}
963+
onClick={() => openPrintModal(order, 'pos')}
964+
className="flex flex-col items-center justify-center gap-0.5 py-2.5 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 dark:text-violet-400 dark:hover:bg-violet-900/20"
935965
>
936-
<Receipt className="h-3.5 w-3.5" />
966+
<Printer className="h-3.5 w-3.5" />
937967
<span className="text-[9px] font-bold">POS Bill</span>
938-
</Link>
968+
</button>
939969

940970
{/* Delete */}
941971
<button
@@ -1592,7 +1622,7 @@ export default function PosIndex({ outlets, categories, foods, customers: initia
15921622
<div className="flex items-center justify-between border-b border-border px-5 py-4 dark:border-stone-800">
15931623
<div>
15941624
<p className="font-headline text-sm font-extrabold text-foreground dark:text-stone-100">
1595-
{itemsModalOrder.order_number} Items
1625+
{itemsModalOrder.order_number} - Items
15961626
</p>
15971627
<p className="mt-0.5 text-[11px] text-muted-foreground capitalize">
15981628
{itemsModalOrder.status} · {itemsModalOrder.order_type.replace('_', ' ')}
@@ -1620,7 +1650,7 @@ export default function PosIndex({ outlets, categories, foods, customers: initia
16201650
<div className="mb-2 flex items-start justify-between gap-2">
16211651
<div className="min-w-0">
16221652
<p className="font-semibold text-foreground dark:text-stone-100">
1623-
{item.food?.name ?? ''}
1653+
{item.food?.name ?? '-'}
16241654
{item.food_variant && (
16251655
<span className="ml-1 text-xs font-normal text-muted-foreground">({item.food_variant.name})</span>
16261656
)}
@@ -1679,6 +1709,15 @@ export default function PosIndex({ outlets, categories, foods, customers: initia
16791709

16801710
{dialog}
16811711

1712+
{/* ── Print modal ───────────────────────────────────────── */}
1713+
{printOrder && printMode && (
1714+
<PrintModal
1715+
order={printOrder}
1716+
mode={printMode}
1717+
onClose={closePrintModal}
1718+
/>
1719+
)}
1720+
16821721
</>
16831722
);
16841723
}

resources/js/pages/qr-order/menu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ export default function QrMenu({ qrToken, table, outlet, categories, foods }: Pr
213213

214214
return (
215215
<>
216-
<Head title={`${outlet.name} ${table.name}`} />
216+
<Head title={`${outlet.name} - ${table.name}`} />
217217

218218
<div className="flex min-h-screen flex-col bg-muted/30">
219219

@@ -722,7 +722,7 @@ function BottomSheet({ children, onClose, fullHeight }: { children: React.ReactN
722722
ref={sheetRef}
723723
className={`relative flex w-full max-w-lg flex-col overflow-hidden rounded-t-3xl bg-card shadow-2xl ${fullHeight ? 'max-h-[92vh]' : 'max-h-[85vh]'}`}
724724
>
725-
{/* Drag handle touch area intentionally tall for easy grabbing */}
725+
{/* Drag handle - touch area intentionally tall for easy grabbing */}
726726
<div ref={handleRef} className="flex justify-center pb-1 pt-4 shrink-0 cursor-grab">
727727
<div className="h-1 w-10 rounded-full bg-border" />
728728
</div>

resources/js/pages/qr-orders/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ function OrderCard({ order, onAccept, onReject }: {
265265
<div className="flex items-center gap-2 text-sm">
266266
<span className="material-symbols-outlined text-base text-muted-foreground/60">table_restaurant</span>
267267
<span className="text-muted-foreground">Table:</span>
268-
<span className="font-medium text-foreground">{tableNames(order) || ''}</span>
268+
<span className="font-medium text-foreground">{tableNames(order) || '-'}</span>
269269
</div>
270270
<div className="flex items-center gap-2 text-sm">
271271
<span className="material-symbols-outlined text-base text-muted-foreground/60">restaurant_menu</span>

0 commit comments

Comments
 (0)