Skip to content

Commit 24a809e

Browse files
feat: handle split and merge order with status sync
1 parent 00764cc commit 24a809e

7 files changed

Lines changed: 125 additions & 79 deletions

File tree

app/Http/Controllers/Orders/OrderController.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,11 @@ public function split(Request $request, Order $order): JsonResponse
118118
$validated = $request->validate([
119119
'items' => ['required', 'array', 'min:1'],
120120
'items.*.order_item_id' => ['required', 'integer'],
121-
'items.*.quantity' => ['required', 'numeric', 'min:0.01'],
121+
'items.*.quantity' => ['required', 'integer', 'min:1'],
122+
'dining_table_id' => ['nullable', 'integer', 'exists:dining_tables,id'],
122123
]);
123124

124-
$newOrder = $this->orderService->splitOrder($order, $validated['items']);
125+
$newOrder = $this->orderService->splitOrder($order, $validated['items'], $validated['dining_table_id'] ?? null);
125126

126127
return response()->json([
127128
'id' => $newOrder->id,

app/Http/Controllers/PosController.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,11 @@ public function splitOrder(Request $request, Order $order): JsonResponse
121121
$validated = $request->validate([
122122
'items' => ['required', 'array', 'min:1'],
123123
'items.*.order_item_id' => ['required', 'integer'],
124-
'items.*.quantity' => ['required', 'numeric', 'min:0.01'],
124+
'items.*.quantity' => ['required', 'integer', 'min:1'],
125+
'dining_table_id' => ['nullable', 'integer', 'exists:dining_tables,id'],
125126
]);
126127

127-
$newOrder = $this->orderService->splitOrder($order, $validated['items']);
128+
$newOrder = $this->orderService->splitOrder($order, $validated['items'], $validated['dining_table_id'] ?? null);
128129

129130
return response()->json([
130131
'id' => $newOrder->id,

app/Services/OrderService.php

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -619,7 +619,7 @@ private function autoTransferIngredientsToWarehouse(Warehouse $targetWh, array $
619619
}
620620
}
621621

622-
// Nothing had stock throw detailed error
622+
// Nothing had stock - throw detailed error
623623
foreach ($deductions as $ingredientId => $deduction) {
624624
$ingredient = Ingredient::findOrFail((int) $ingredientId);
625625
$available = $this->warehouseAvailableStock($targetWh->id, $ingredientId);
@@ -692,9 +692,9 @@ public function sendItemsToKitchen(Order $order): void
692692
});
693693
}
694694

695-
public function splitOrder(Order $order, array $itemSplits): Order
695+
public function splitOrder(Order $order, array $itemSplits, ?int $diningTableId = null): Order
696696
{
697-
return DB::transaction(function () use ($order, $itemSplits) {
697+
return DB::transaction(function () use ($order, $itemSplits, $diningTableId) {
698698
abort_if(
699699
in_array($order->status, ['completed', 'cancelled']),
700700
422,
@@ -740,13 +740,22 @@ public function splitOrder(Order $order, array $itemSplits): Order
740740
'updated_by' => Auth::id(),
741741
]);
742742

743-
foreach ($order->orderTables as $ot) {
743+
if ($diningTableId !== null) {
744744
OrderTable::create([
745745
'order_id' => $newOrder->id,
746-
'dining_table_id' => $ot->dining_table_id,
747-
'assignment_type' => $ot->assignment_type,
746+
'dining_table_id' => $diningTableId,
747+
'assignment_type' => 'changed_by_staff',
748748
'assigned_by' => Auth::id(),
749749
]);
750+
} else {
751+
foreach ($order->orderTables as $ot) {
752+
OrderTable::create([
753+
'order_id' => $newOrder->id,
754+
'dining_table_id' => $ot->dining_table_id,
755+
'assignment_type' => $ot->assignment_type,
756+
'assigned_by' => Auth::id(),
757+
]);
758+
}
750759
}
751760

752761
foreach ($itemSplits as $splitData) {
@@ -789,6 +798,13 @@ public function splitOrder(Order $order, array $itemSplits): Order
789798
}
790799
}
791800

801+
// Derive new order status from its actual items rather than copying parent status
802+
$this->syncOrderStatus($newOrder->fresh('items'));
803+
$newOrder->refresh();
804+
805+
$this->syncOrderStatus($order->fresh('items'));
806+
$order->refresh();
807+
792808
$this->recordStatusHistory($newOrder, null, $newOrder->status, "Split from order #{$order->order_number}");
793809
$this->recordStatusHistory($order, $order->status, $order->status, "Items split to new order #{$newOrder->order_number}");
794810

@@ -829,9 +845,11 @@ public function mergeOrders(Order $target, array $sourceOrderIds): void
829845
]);
830846
}
831847

848+
$this->syncOrderStatus($target->fresh('items'));
849+
$target->refresh();
850+
832851
$count = $sourceOrders->count();
833852
$this->recordStatusHistory($target, $target->status, $target->status, "Merged {$count} order(s) into this order.");
834-
$target->touch();
835853
});
836854
}
837855

@@ -1187,7 +1205,7 @@ private function selectPreparationDepartment(OrderItem $item): OutletDepartment
11871205
}
11881206
}
11891207

1190-
// All passes failed report the specific shortage
1208+
// All passes failed - report the specific shortage
11911209
$fallback = $departments->first();
11921210
$fallbackWh = $this->findDepartmentWarehouse($fallback);
11931211

@@ -1276,7 +1294,7 @@ private function resolveReservationWarehouse(Order $order, array $deductions): W
12761294
// Use warehouses from the item's preparation department
12771295
$item = null;
12781296

1279-
// Determine department from context caller passes item via order
1297+
// Determine department from context - caller passes item via order
12801298
// This method is called from reserveOrderItemStock which loads the item
12811299
// We look up the department's warehouses ordered by default first
12821300
$departmentId = null;

resources/js/components/order-split-modal.tsx

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState } from 'react';
22
import { X, Scissors } from 'lucide-react';
33
import { useEscapeKey } from '@/hooks/use-escape-key';
4+
import { SearchableSelect } from '@/components/ui/searchable-select';
45

56
type OrderItem = {
67
id: number;
@@ -14,6 +15,12 @@ type OrderItem = {
1415
addons: { addon: { name: string } | null }[];
1516
};
1617

18+
type DiningTable = {
19+
id: number;
20+
name: string;
21+
dining_area: { name: string } | null;
22+
};
23+
1724
type SplitItem = {
1825
order_item_id: number;
1926
quantity: number;
@@ -23,19 +30,21 @@ type Props = {
2330
orderId: number;
2431
orderNumber: string;
2532
items: OrderItem[];
33+
tables?: DiningTable[];
2634
onClose: () => void;
27-
onSplit: (items: SplitItem[]) => Promise<void>;
35+
onSplit: (items: SplitItem[], diningTableId: number | null) => Promise<void>;
2836
loading?: boolean;
2937
};
3038

31-
export function OrderSplitModal({ orderId, orderNumber, items, onClose, onSplit, loading }: Props) {
39+
export function OrderSplitModal({ orderId, orderNumber, items, tables = [], onClose, onSplit, loading }: Props) {
3240
useEscapeKey(onClose);
3341

3442
const activeItems = items.filter((i) => i.status !== 'cancelled');
3543
const [selected, setSelected] = useState<Record<number, boolean>>({});
3644
const [quantities, setQuantities] = useState<Record<number, number>>(() =>
3745
Object.fromEntries(activeItems.map((i) => [i.id, parseFloat(String(i.quantity))]))
3846
);
47+
const [diningTableId, setDiningTableId] = useState<number | null>(null);
3948
const [submitting, setSubmitting] = useState(false);
4049
const [error, setError] = useState<string | null>(null);
4150

@@ -46,7 +55,7 @@ export function OrderSplitModal({ orderId, orderNumber, items, onClose, onSplit,
4655
}
4756

4857
function setQty(id: number, val: number, max: number) {
49-
setQuantities((prev) => ({ ...prev, [id]: Math.max(0.01, Math.min(val, max)) }));
58+
setQuantities((prev) => ({ ...prev, [id]: Math.max(1, Math.min(Math.floor(val), max)) }));
5059
}
5160

5261
async function handleSubmit() {
@@ -59,7 +68,7 @@ export function OrderSplitModal({ orderId, orderNumber, items, onClose, onSplit,
5968
setSubmitting(true);
6069
setError(null);
6170
try {
62-
await onSplit(splitItems);
71+
await onSplit(splitItems, diningTableId);
6372
} catch (e: any) {
6473
setError(e?.message ?? 'Split failed.');
6574
} finally {
@@ -85,8 +94,29 @@ export function OrderSplitModal({ orderId, orderNumber, items, onClose, onSplit,
8594
</div>
8695

8796
{/* Content */}
88-
<div className="flex-1 overflow-y-auto p-4 space-y-2">
89-
<p className="text-xs text-muted-foreground mb-3">Select items to move into a new order. The original order keeps the remaining items.</p>
97+
<div className="flex-1 overflow-y-auto p-4 space-y-3">
98+
<p className="text-xs text-muted-foreground">Select items to move into a new order. The original order keeps the remaining items.</p>
99+
100+
{/* Table selector for new order */}
101+
{tables.length > 0 && (
102+
<div className="rounded-lg border border-border dark:border-stone-700 p-3 space-y-1.5">
103+
<label className="text-xs font-semibold text-foreground">Table for new order</label>
104+
<SearchableSelect
105+
value={diningTableId ?? ''}
106+
placeholder="Same as original order"
107+
onChange={(e) => setDiningTableId(e.target.value ? Number(e.target.value) : null)}
108+
>
109+
<option value="">Same as original order</option>
110+
{tables.map((t) => (
111+
<option key={t.id} value={t.id}>
112+
{t.dining_area ? `${t.dining_area.name} - ` : ''}{t.name}
113+
</option>
114+
))}
115+
</SearchableSelect>
116+
</div>
117+
)}
118+
119+
{/* Items */}
90120
{activeItems.map((item) => {
91121
const max = parseFloat(String(item.quantity));
92122
const isSelected = !!selected[item.id];
@@ -121,7 +151,7 @@ export function OrderSplitModal({ orderId, orderNumber, items, onClose, onSplit,
121151
<span className="text-xs text-muted-foreground">Qty to split:</span>
122152
<input
123153
type="number"
124-
min="0.01"
154+
min="1"
125155
max={max}
126156
step="1"
127157
value={quantities[item.id]}
@@ -143,20 +173,20 @@ export function OrderSplitModal({ orderId, orderNumber, items, onClose, onSplit,
143173
<div className="border-t border-border dark:border-stone-800 px-4 py-3 flex flex-col gap-2">
144174
{error && <p className="text-xs text-red-500">{error}</p>}
145175
<div className="flex items-center justify-between gap-3">
146-
<span className="text-xs text-muted-foreground">{selectedCount} item{selectedCount !== 1 ? 's' : ''} selected</span>
147-
<div className="flex gap-2">
148-
<button type="button" onClick={onClose} className="rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted dark:border-stone-700">
149-
Cancel
150-
</button>
151-
<button
152-
type="button"
153-
onClick={handleSubmit}
154-
disabled={selectedCount === 0 || submitting}
155-
className="rounded-lg bg-primary px-3 py-1.5 text-xs font-bold text-white disabled:opacity-50 hover:opacity-90"
156-
>
157-
{submitting ? 'Splitting...' : 'Split Order'}
158-
</button>
159-
</div>
176+
<span className="text-xs text-muted-foreground">{selectedCount} item{selectedCount !== 1 ? 's' : ''} selected</span>
177+
<div className="flex gap-2">
178+
<button type="button" onClick={onClose} className="rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted dark:border-stone-700">
179+
Cancel
180+
</button>
181+
<button
182+
type="button"
183+
onClick={handleSubmit}
184+
disabled={selectedCount === 0 || submitting}
185+
className="rounded-lg bg-primary px-3 py-1.5 text-xs font-bold text-white disabled:opacity-50 hover:opacity-90"
186+
>
187+
{submitting ? 'Splitting...' : 'Split Order'}
188+
</button>
189+
</div>
160190
</div>
161191
</div>
162192
</div>

resources/js/pages/orders/items.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ export default function OrderItemsIndex({ items, outlets, departments, filters }
489489

490490
{/* Department */}
491491
<td className="px-6 py-4 text-sm text-muted-foreground dark:text-stone-400">
492-
{item.preparation_department?.name ?? ''}
492+
{item.preparation_department?.name ?? '-'}
493493
</td>
494494

495495
{/* Qty */}
@@ -524,7 +524,7 @@ export default function OrderItemsIndex({ items, outlets, departments, filters }
524524
{isUpdating ? '…' : ITEM_NEXT_LABEL[item.status]}
525525
</button>
526526
) : (
527-
<span className="text-xs text-muted-foreground dark:text-stone-500"></span>
527+
<span className="text-xs text-muted-foreground dark:text-stone-500">-</span>
528528
)}
529529
</td>
530530
)}

resources/js/pages/orders/show.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -659,9 +659,15 @@ export default function OrdersShow({ order, availableFoods, availableTables, dep
659659
{isActive && item.status !== 'cancelled' && item.status !== 'served' && can('order-items-update') && ITEM_NEXT_STATUS[item.status] && (
660660
<div className="mt-2">
661661
<button
662-
onClick={() => {
662+
onClick={async () => {
663663
const next = ITEM_NEXT_STATUS[item.status]!;
664-
router.patch(`/orders/${order.id}/items/${item.id}/status`, { status: next });
664+
const csrf = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? '';
665+
await fetch(`/orders/${order.id}/items/${item.id}/status`, {
666+
method: 'PATCH',
667+
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': csrf },
668+
body: JSON.stringify({ status: next }),
669+
});
670+
router.reload({ only: ['order'] });
665671
}}
666672
className={`inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-[11px] font-bold transition hover:opacity-80 ring-1 ${ITEM_STATUS_COLORS[ITEM_NEXT_STATUS[item.status]!] ?? ''}`}
667673
>
@@ -872,7 +878,7 @@ export default function OrdersShow({ order, availableFoods, availableTables, dep
872878
<p className="mt-0.5 text-xs text-muted-foreground">
873879
Qty: {transferItem.quantity} · Current:{' '}
874880
<span className="font-medium text-foreground dark:text-stone-200">
875-
{transferItem.preparation_department?.name ?? ''}
881+
{transferItem.preparation_department?.name ?? '-'}
876882
</span>
877883
</p>
878884
</div>

0 commit comments

Comments
 (0)