[FEAT] 優化一鍵補貨 UI 與 RWD 響應式佈局

1. 實作一鍵補貨智慧重新計算邏輯:切換倉庫或機台時自動更新預覽。
2. 新增多語系支援:補齊繁中、日文、英文語系檔中的庫存/上限、重新計算等關鍵字。
3. 優化 RWD 響應式佈局:在手機版自動切換為卡片式 (Card) 佈局,提升行動裝置操作體驗。
4. 新增刪除功能:預覽清單中每一行貨道皆可獨立移除,並配合唯一辨識碼 (slot_no) 修正 state 殘留 bug。
5. 完善一鍵補貨 Modal 腳底控制項:新增手動「重新計算」按鈕與樣式優化。
6. 完成補貨單管理功能:包含新增、取消、分配人員等後台邏輯與多租戶隔離。
7. 優化庫存轉移 (Transfers) 介面與詳情面板 RWD 表現。
This commit is contained in:
sky121113 2026-04-24 17:32:11 +08:00
parent b0e523067f
commit 2012c5a321
29 changed files with 4671 additions and 2585 deletions

View File

@ -29,6 +29,12 @@ class DashboardController extends Controller
->orWhere('serial_no', 'like', "%{$search}%"); ->orWhere('serial_no', 'like', "%{$search}%");
}); });
}) })
->withSum('slots', 'stock')
->withSum('slots', 'max_stock')
->withSum(['orders as today_sales_sum' => function($query) {
$query->whereDate('payment_at', now()->toDateString())
->where('payment_status', 'paid');
}], 'pay_amount')
->orderByDesc('last_heartbeat_at') ->orderByDesc('last_heartbeat_at')
->paginate($perPage) ->paginate($perPage)
->withQueryString(); ->withQueryString();

View File

@ -181,6 +181,7 @@ class WarehouseController extends Controller
// 2. Stock-In Orders (stock-in) // 2. Stock-In Orders (stock-in)
if (!$isAjax || $tab === 'stock-in') { if (!$isAjax || $tab === 'stock-in') {
$query = StockInOrder::with(['warehouse:id,name', 'creator:id,name']) $query = StockInOrder::with(['warehouse:id,name', 'creator:id,name'])
->withCount('items')
->latest(); ->latest();
if ($request->filled('status')) { if ($request->filled('status')) {
@ -193,13 +194,14 @@ class WarehouseController extends Controller
$query->whereBetween('created_at', [$request->start_date, $request->end_date]); $query->whereBetween('created_at', [$request->start_date, $request->end_date]);
} }
$data['orders'] = $query->paginate($request->input('per_page', 10), ['*'], 'order_page')->withQueryString(); $data['orders'] = $query->paginate($request->input('per_page', 10), ['*'], 'order_page')->withQueryString();
$data['stock_in_products'] = Product::orderBy('name')->get(['id', 'name', 'image_url']); $data['stock_in_products'] = Product::with('translations')->orderBy('name')->get(['id', 'name', 'image_url', 'name_dictionary_key']);
} }
// 3. Movement History (movements) // 3. Movement History (movements)
if (!$isAjax || $tab === 'movements') { if (!$isAjax || $tab === 'movements') {
$query = StockMovement::with(['warehouse:id,name', 'product:id,name', 'creator:id,name']) $query = StockMovement::with(['warehouse:id,name', 'product.translations', 'creator:id,name'])
->orderBy('created_at', 'desc'); ->orderBy('created_at', 'desc')
->orderBy('id', 'desc');
if ($request->filled('warehouse_id')) { if ($request->filled('warehouse_id')) {
$query->where('warehouse_id', $request->input('warehouse_id')); $query->where('warehouse_id', $request->input('warehouse_id'));
@ -235,13 +237,28 @@ class WarehouseController extends Controller
'items' => 'required|array|min:1', 'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id', 'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|integer|min:1', 'items.*.quantity' => 'required|integer|min:1',
], [
'warehouse_id.required' => __('Please select target warehouse'),
'items.required' => __('Please add at least one item'),
'items.*.product_id.required' => __('Please select product for item :num', ['num' => ':position']),
'items.*.quantity.min' => __('Please enter valid quantity for item :num', ['num' => ':position']),
]); ]);
DB::transaction(function () use ($validated) { DB::transaction(function () use ($validated) {
$prefix = 'SI-' . now()->format('Ymd') . '-';
$lastOrder = StockInOrder::withoutGlobalScopes()
->withTrashed()
->where('order_no', 'like', $prefix . '%')
->orderBy('order_no', 'desc')
->first();
$nextNumber = $lastOrder ? (int)substr($lastOrder->order_no, -4) + 1 : 1;
$order_no = $prefix . str_pad($nextNumber, 4, '0', STR_PAD_LEFT);
$order = StockInOrder::create([ $order = StockInOrder::create([
'company_id' => Auth::user()->company_id, 'company_id' => Auth::user()->company_id,
'warehouse_id' => $validated['warehouse_id'], 'warehouse_id' => $validated['warehouse_id'],
'order_no' => 'SI-' . now()->format('Ymd') . '-' . str_pad(StockInOrder::whereDate('created_at', today())->count() + 1, 4, '0', STR_PAD_LEFT), 'order_no' => $order_no,
'status' => 'draft', 'status' => 'draft',
'note' => $validated['note'] ?? null, 'note' => $validated['note'] ?? null,
'created_by' => Auth::id(), 'created_by' => Auth::id(),
@ -253,14 +270,15 @@ class WarehouseController extends Controller
}); });
if ($request->ajax()) { if ($request->ajax()) {
session()->flash('success', __('Stock-in order created successfully'));
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'message' => __('Stock-in order created') 'message' => __('Stock-in order created successfully')
]); ]);
} }
return redirect()->route('admin.warehouses.inventory', ['tab' => 'stock-in']) return redirect()->route('admin.warehouses.inventory', ['tab' => 'stock-in'])
->with('success', __('Stock-in order created')); ->with('success', __('Stock-in order created successfully'));
} }
/** /**
@ -268,13 +286,16 @@ class WarehouseController extends Controller
*/ */
public function confirmStockIn(StockInOrder $stockInOrder) public function confirmStockIn(StockInOrder $stockInOrder)
{ {
if ($stockInOrder->status !== 'draft') { if ($stockInOrder->status !== StockInOrder::STATUS_DRAFT) {
if (request()->ajax()) {
return response()->json(['success' => false, 'message' => __('Order already processed')], 422);
}
return back()->with('error', __('Order already processed')); return back()->with('error', __('Order already processed'));
} }
DB::transaction(function () use ($stockInOrder) { DB::transaction(function () use ($stockInOrder) {
$stockInOrder->update([ $stockInOrder->update([
'status' => 'completed', 'status' => StockInOrder::STATUS_COMPLETED,
'completed_at' => now(), 'completed_at' => now(),
]); ]);
@ -301,19 +322,50 @@ class WarehouseController extends Controller
'reference_id' => $stockInOrder->id, 'reference_id' => $stockInOrder->id,
'note' => __('Stock-in order') . ' #' . $stockInOrder->order_no, 'note' => __('Stock-in order') . ' #' . $stockInOrder->order_no,
'created_by' => Auth::id(), 'created_by' => Auth::id(),
'created_at' => now(),
]); ]);
} }
}); });
if (request()->ajax()) {
return response()->json([
'success' => true,
'message' => __('Stock-in order confirmed and inventory updated')
]);
}
return back()->with('success', __('Stock-in order confirmed and inventory updated')); return back()->with('success', __('Stock-in order confirmed and inventory updated'));
} }
/**
* 刪除入庫單(僅限草稿)
*/
public function destroyStockIn(StockInOrder $stockInOrder)
{
if ($stockInOrder->status !== StockInOrder::STATUS_DRAFT) {
if (request()->ajax()) {
return response()->json(['success' => false, 'message' => __('Only draft orders can be deleted')], 422);
}
return back()->with('error', __('Only draft orders can be deleted'));
}
$stockInOrder->delete(); // Soft delete
if (request()->ajax()) {
return response()->json([
'success' => true,
'message' => __('Stock-in order deleted successfully')
]);
}
return back()->with('success', __('Stock-in order deleted successfully'));
}
// ─── 模組 3調撥單 ─── // ─── 模組 3調撥單 ───
public function transfers(Request $request) public function transfers(Request $request)
{ {
$query = TransferOrder::with(['fromWarehouse:id,name', 'toWarehouse:id,name', 'fromMachine:id,name,serial_no', 'creator:id,name', 'items']) $query = TransferOrder::with(['fromWarehouse:id,name', 'toWarehouse:id,name', 'fromMachine:id,name,serial_no', 'creator:id,name'])
->withCount('items')
->latest(); ->latest();
if ($request->filled('type')) { if ($request->filled('type')) {
@ -322,11 +374,25 @@ class WarehouseController extends Controller
if ($request->filled('status')) { if ($request->filled('status')) {
$query->where('status', $request->input('status')); $query->where('status', $request->input('status'));
} }
if ($search = $request->input('search_order')) {
$query->where('order_no', 'like', "%{$search}%");
}
if ($request->filled('start_date') && $request->filled('end_date')) {
$query->whereBetween('created_at', [$request->start_date, $request->end_date]);
}
$orders = $query->paginate(10)->withQueryString(); $orders = $query->paginate($request->input('per_page', 15))->withQueryString();
$warehouses = Warehouse::active()->orderBy('name')->get(['id', 'name', 'type']);
if ($request->ajax()) {
return response()->json([
'success' => true,
'html' => view('admin.warehouses.partials.table-transfers', compact('orders'))->render()
]);
}
$warehouses = Warehouse::active()->orderBy('name')->get(['id', 'name']);
$machines = Machine::orderBy('name')->get(['id', 'name', 'serial_no']); $machines = Machine::orderBy('name')->get(['id', 'name', 'serial_no']);
$products = Product::orderBy('name')->get(['id', 'name', 'image_url']); $products = Product::with('translations')->orderBy('name')->get(['id', 'name', 'image_url', 'name_dictionary_key']);
return view('admin.warehouses.transfers', compact('orders', 'warehouses', 'machines', 'products')); return view('admin.warehouses.transfers', compact('orders', 'warehouses', 'machines', 'products'));
} }
@ -345,12 +411,29 @@ class WarehouseController extends Controller
'items' => 'required|array|min:1', 'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id', 'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|integer|min:1', 'items.*.quantity' => 'required|integer|min:1',
], [
'to_warehouse_id.required' => __('Please select target warehouse'),
'from_warehouse_id.required_if' => __('Please select source warehouse'),
'from_machine_id.required_if' => __('Please select source machine'),
'items.required' => __('Please add at least one item'),
'items.*.product_id.required' => __('Please select product for item :num', ['num' => ':position']),
'items.*.quantity.min' => __('Please enter valid quantity for item :num', ['num' => ':position']),
]); ]);
DB::transaction(function () use ($validated) { DB::transaction(function () use ($validated) {
$prefix = 'TR-' . now()->format('Ymd') . '-';
$lastOrder = TransferOrder::withoutGlobalScopes()
->withTrashed()
->where('order_no', 'like', $prefix . '%')
->orderBy('order_no', 'desc')
->first();
$nextNumber = $lastOrder ? (int)substr($lastOrder->order_no, -4) + 1 : 1;
$order_no = $prefix . str_pad($nextNumber, 4, '0', STR_PAD_LEFT);
$order = TransferOrder::create([ $order = TransferOrder::create([
'company_id' => Auth::user()->company_id, 'company_id' => Auth::user()->company_id,
'order_no' => 'TR-' . now()->format('Ymd') . '-' . str_pad(TransferOrder::whereDate('created_at', today())->count() + 1, 4, '0', STR_PAD_LEFT), 'order_no' => $order_no,
'type' => $validated['type'], 'type' => $validated['type'],
'from_warehouse_id' => $validated['from_warehouse_id'] ?? null, 'from_warehouse_id' => $validated['from_warehouse_id'] ?? null,
'from_machine_id' => $validated['from_machine_id'] ?? null, 'from_machine_id' => $validated['from_machine_id'] ?? null,
@ -366,14 +449,15 @@ class WarehouseController extends Controller
}); });
if ($request->ajax()) { if ($request->ajax()) {
session()->flash('success', __('Transfer order created successfully'));
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'message' => __('Transfer order created') 'message' => __('Transfer order created successfully')
]); ]);
} }
return redirect()->route('admin.warehouses.transfers') return redirect()->route('admin.warehouses.transfers')
->with('success', __('Transfer order created')); ->with('success', __('Transfer order created successfully'));
} }
/** /**
@ -382,6 +466,9 @@ class WarehouseController extends Controller
public function confirmTransfer(TransferOrder $transferOrder) public function confirmTransfer(TransferOrder $transferOrder)
{ {
if ($transferOrder->status !== TransferOrder::STATUS_DRAFT) { if ($transferOrder->status !== TransferOrder::STATUS_DRAFT) {
if (request()->ajax()) {
return response()->json(['success' => false, 'message' => __('Order already processed')], 422);
}
return back()->with('error', __('Order already processed')); return back()->with('error', __('Order already processed'));
} }
@ -413,7 +500,6 @@ class WarehouseController extends Controller
'reference_id' => $transferOrder->id, 'reference_id' => $transferOrder->id,
'note' => __('Transfer out') . ' #' . $transferOrder->order_no, 'note' => __('Transfer out') . ' #' . $transferOrder->order_no,
'created_by' => Auth::id(), 'created_by' => Auth::id(),
'created_at' => now(),
]); ]);
} }
@ -437,7 +523,6 @@ class WarehouseController extends Controller
'reference_id' => $transferOrder->id, 'reference_id' => $transferOrder->id,
'note' => __('Transfer in') . ' #' . $transferOrder->order_no, 'note' => __('Transfer in') . ' #' . $transferOrder->order_no,
'created_by' => Auth::id(), 'created_by' => Auth::id(),
'created_at' => now(),
]); ]);
} }
@ -447,7 +532,38 @@ class WarehouseController extends Controller
]); ]);
}); });
return back()->with('success', __('Transfer completed')); if (request()->ajax()) {
return response()->json([
'success' => true,
'message' => __('Transfer order confirmed and inventory updated')
]);
}
return back()->with('success', __('Transfer order confirmed and inventory updated'));
}
/**
* 刪除調撥單(僅限草稿)
*/
public function destroyTransfer(TransferOrder $transferOrder)
{
if ($transferOrder->status !== TransferOrder::STATUS_DRAFT) {
if (request()->ajax()) {
return response()->json(['success' => false, 'message' => __('Only draft orders can be deleted')], 422);
}
return back()->with('error', __('Only draft orders can be deleted'));
}
$transferOrder->delete(); // Soft delete
if (request()->ajax()) {
return response()->json([
'success' => true,
'message' => __('Transfer order deleted successfully')
]);
}
return back()->with('success', __('Transfer order deleted successfully'));
} }
// ─── 模組 4機台庫存總覽 ─── // ─── 模組 4機台庫存總覽 ───
@ -478,11 +594,15 @@ class WarehouseController extends Controller
public function machineSlots(Machine $machine) public function machineSlots(Machine $machine)
{ {
$slots = $machine->slots() $slots = $machine->slots()
->with('product:id,name,image_url') ->with(['product:id,name,image_url,barcode'])
->orderByRaw('CAST(slot_no AS UNSIGNED) ASC') ->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')
->get(); ->get();
return response()->json(['success' => true, 'slots' => $slots]); return response()->json([
'success' => true,
'machine' => $machine,
'slots' => $slots
]);
} }
// ─── 模組 5機台補貨 ─── // ─── 模組 5機台補貨 ───
@ -496,16 +616,34 @@ class WarehouseController extends Controller
if ($request->filled('status')) { if ($request->filled('status')) {
$query->where('status', $request->input('status')); $query->where('status', $request->input('status'));
} }
if ($search = $request->input('search_order')) {
$query->where('order_no', 'like', "%{$search}%");
}
$orders = $query->paginate(10)->withQueryString(); $orders = $query->paginate(10)->withQueryString();
if ($request->ajax()) {
return response()->json([
'success' => true,
'html' => view('admin.warehouses.partials.table-replenishments', compact('orders'))->render()
]);
}
$warehouses = Warehouse::active()->orderBy('name')->get(['id', 'name']); $warehouses = Warehouse::active()->orderBy('name')->get(['id', 'name']);
$machines = Machine::orderBy('name')->get(['id', 'name', 'serial_no']); $machines = Machine::orderBy('name')->get(['id', 'name', 'serial_no']);
$products = Product::with('translations')->orderBy('name')->get(['id', 'name', 'image_url', 'name_dictionary_key']);
return view('admin.warehouses.replenishments', compact('orders', 'warehouses', 'machines')); // 同租戶帳號(用於指派人員)
$users = \App\Models\System\User::where('company_id', Auth::user()->company_id)
->where('status', 'active')
->orderBy('name')
->get(['id', 'name']);
return view('admin.warehouses.replenishments', compact('orders', 'warehouses', 'machines', 'products', 'users'));
} }
/** /**
* 建立補貨單 * 建立補貨單(含 current_stock/max_stock 快照)
*/ */
public function storeReplenishment(Request $request) public function storeReplenishment(Request $request)
{ {
@ -517,12 +655,30 @@ class WarehouseController extends Controller
'items.*.product_id' => 'required|exists:products,id', 'items.*.product_id' => 'required|exists:products,id',
'items.*.slot_no' => 'required|integer', 'items.*.slot_no' => 'required|integer',
'items.*.quantity' => 'required|integer|min:1', 'items.*.quantity' => 'required|integer|min:1',
'items.*.current_stock' => 'nullable|integer|min:0',
'items.*.max_stock' => 'nullable|integer|min:0',
], [
'warehouse_id.required' => __('Please select source warehouse'),
'machine_id.required' => __('Please select target machine'),
'items.required' => __('Please add at least one item'),
'items.*.product_id.required' => __('Please select product for item :num', ['num' => ':position']),
'items.*.quantity.min' => __('Please enter valid quantity for item :num', ['num' => ':position']),
]); ]);
DB::transaction(function () use ($validated) { DB::transaction(function () use ($validated) {
$prefix = 'RP-' . now()->format('Ymd') . '-';
$lastOrder = ReplenishmentOrder::withoutGlobalScopes()
->withTrashed()
->where('order_no', 'like', $prefix . '%')
->orderBy('order_no', 'desc')
->first();
$nextNumber = $lastOrder ? (int)substr($lastOrder->order_no, -4) + 1 : 1;
$order_no = $prefix . str_pad($nextNumber, 4, '0', STR_PAD_LEFT);
$order = ReplenishmentOrder::create([ $order = ReplenishmentOrder::create([
'company_id' => Auth::user()->company_id, 'company_id' => Auth::user()->company_id,
'order_no' => 'RP-' . now()->format('Ymd') . '-' . str_pad(ReplenishmentOrder::whereDate('created_at', today())->count() + 1, 4, '0', STR_PAD_LEFT), 'order_no' => $order_no,
'warehouse_id' => $validated['warehouse_id'], 'warehouse_id' => $validated['warehouse_id'],
'machine_id' => $validated['machine_id'], 'machine_id' => $validated['machine_id'],
'status' => ReplenishmentOrder::STATUS_PENDING, 'status' => ReplenishmentOrder::STATUS_PENDING,
@ -530,40 +686,194 @@ class WarehouseController extends Controller
'created_by' => Auth::id(), 'created_by' => Auth::id(),
]); ]);
// 批量寫入明細(遵循 DB Best Practice: 不在迴圈中逐筆 create
$itemsData = [];
foreach ($validated['items'] as $item) { foreach ($validated['items'] as $item) {
$order->items()->create($item); $itemsData[] = [
'replenishment_order_id' => $order->id,
'product_id' => $item['product_id'],
'slot_no' => $item['slot_no'],
'quantity' => $item['quantity'],
'current_stock' => $item['current_stock'] ?? 0,
'max_stock' => $item['max_stock'] ?? 0,
'created_at' => now(),
'updated_at' => now(),
];
} }
ReplenishmentOrderItem::insert($itemsData);
}); });
if ($request->ajax()) { if ($request->ajax()) {
session()->flash('success', __('Replenishment order created successfully'));
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'message' => __('Replenishment order created') 'message' => __('Replenishment order created successfully')
]); ]);
} }
return redirect()->route('admin.warehouses.replenishments') return redirect()->route('admin.warehouses.replenishments')
->with('success', __('Replenishment order created')); ->with('success', __('Replenishment order created successfully'));
} }
/** /**
* 確認補貨完成(扣倉庫庫存 + 增加機台貨道庫存) * 一鍵補貨 自動讀取機台所有貨道,計算 max_stock - stock
*/ */
public function confirmReplenishment(ReplenishmentOrder $replenishmentOrder) public function autoReplenishment(Request $request)
{ {
if ($replenishmentOrder->status === ReplenishmentOrder::STATUS_COMPLETED) { $validated = $request->validate([
return back()->with('error', __('Order already completed')); 'warehouse_id' => 'required|exists:warehouses,id',
'machine_id' => 'required|exists:machines,id',
'note' => 'nullable|string|max:500',
'items' => 'nullable|array',
'items.*.slot_no' => 'required_with:items|integer',
'items.*.product_id' => 'required_with:items|exists:products,id',
'items.*.quantity' => 'required_with:items|integer|min:1',
'items.*.current_stock' => 'nullable|integer|min:0',
'items.*.max_stock' => 'nullable|integer|min:0',
]);
$machine = Machine::findOrFail($validated['machine_id']);
// 如果前端未提供 items首次請求自動計算補貨清單
if (empty($validated['items'])) {
$slots = MachineSlot::with('product:id,name,image_url,name_dictionary_key')
->where('machine_id', $machine->id)
->whereNotNull('product_id')
->whereColumn('stock', '<', 'max_stock')
->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')
->get();
if ($slots->isEmpty()) {
return response()->json([
'success' => false,
'message' => __('All slots are fully stocked')
], 422);
}
// 檢查倉庫庫存
$warehouse = Warehouse::findOrFail($validated['warehouse_id']);
$warehouseStocks = WarehouseStock::where('warehouse_id', $warehouse->id)
->pluck('quantity', 'product_id');
// 按商品彙總需求量
$demandByProduct = [];
foreach ($slots as $slot) {
$pid = $slot->product_id;
$need = $slot->max_stock - $slot->stock;
$demandByProduct[$pid] = ($demandByProduct[$pid] ?? 0) + $need;
}
// 檢查是否有庫存不足的商品
$insufficientProducts = [];
foreach ($demandByProduct as $pid => $totalNeed) {
$available = $warehouseStocks[$pid] ?? 0;
if ($available < $totalNeed) {
$insufficientProducts[$pid] = [
'available' => $available,
'needed' => $totalNeed,
];
}
}
$slotData = $slots->map(fn($s) => [
'slot_no' => $s->slot_no,
'product_id' => $s->product_id,
'product_name' => $s->product?->localized_name ?? 'Unknown',
'image_url' => $s->product?->image_url,
'current_stock' => $s->stock,
'max_stock' => $s->max_stock,
'quantity' => $s->max_stock - $s->stock,
]);
return response()->json([
'success' => true,
'preview' => true,
'machine_name' => $machine->name,
'slots' => $slotData,
'insufficient' => $insufficientProducts,
'warehouse_stocks' => $warehouseStocks,
'has_warning' => !empty($insufficientProducts),
]);
} }
DB::transaction(function () use ($replenishmentOrder) { // 有 items = 確認建單
DB::transaction(function () use ($validated) {
$prefix = 'RP-' . now()->format('Ymd') . '-';
$lastOrder = ReplenishmentOrder::withoutGlobalScopes()
->withTrashed()
->where('order_no', 'like', $prefix . '%')
->orderBy('order_no', 'desc')
->first();
$nextNumber = $lastOrder ? (int)substr($lastOrder->order_no, -4) + 1 : 1;
$order_no = $prefix . str_pad($nextNumber, 4, '0', STR_PAD_LEFT);
$order = ReplenishmentOrder::create([
'company_id' => Auth::user()->company_id,
'order_no' => $order_no,
'warehouse_id' => $validated['warehouse_id'],
'machine_id' => $validated['machine_id'],
'status' => ReplenishmentOrder::STATUS_PENDING,
'note' => $validated['note'] ?? __('Auto Replenishment'),
'created_by' => Auth::id(),
]);
$itemsData = [];
foreach ($validated['items'] as $item) {
$itemsData[] = [
'replenishment_order_id' => $order->id,
'product_id' => $item['product_id'],
'slot_no' => $item['slot_no'],
'quantity' => $item['quantity'],
'current_stock' => $item['current_stock'] ?? 0,
'max_stock' => $item['max_stock'] ?? 0,
'created_at' => now(),
'updated_at' => now(),
];
}
ReplenishmentOrderItem::insert($itemsData);
});
session()->flash('success', __('Replenishment order created successfully'));
return response()->json([
'success' => true,
'message' => __('Replenishment order created successfully')
]);
}
/**
* 狀態推進 (pending prepared delivering completed)
*/
public function updateReplenishmentStatus(Request $request, ReplenishmentOrder $replenishmentOrder)
{
$validated = $request->validate([
'status' => 'required|in:prepared,delivering,completed',
]);
$newStatus = $validated['status'];
if (!$replenishmentOrder->canTransitionTo($newStatus)) {
return response()->json([
'success' => false,
'message' => __('Invalid status transition')
], 422);
}
DB::transaction(function () use ($replenishmentOrder, $newStatus) {
$replenishmentOrder->loadMissing('items'); $replenishmentOrder->loadMissing('items');
foreach ($replenishmentOrder->items as $item) { // pending → prepared扣減倉庫庫存
// 扣除倉庫庫存 if ($newStatus === ReplenishmentOrder::STATUS_PREPARED) {
$stock = WarehouseStock::where('warehouse_id', $replenishmentOrder->warehouse_id) foreach ($replenishmentOrder->items as $item) {
->where('product_id', $item->product_id)->first(); $stock = WarehouseStock::where('warehouse_id', $replenishmentOrder->warehouse_id)
->where('product_id', $item->product_id)
->lockForUpdate()
->first();
if (!$stock || $stock->quantity < $item->quantity) {
throw new \Exception(__('Warehouse stock insufficient') . " ({$item->product?->name})");
}
if ($stock && $stock->quantity >= $item->quantity) {
$before = $stock->quantity; $before = $stock->quantity;
$stock->decrement('quantity', $item->quantity); $stock->decrement('quantity', $item->quantity);
@ -577,26 +887,208 @@ class WarehouseController extends Controller
'after_qty' => $before - $item->quantity, 'after_qty' => $before - $item->quantity,
'reference_type' => ReplenishmentOrder::class, 'reference_type' => ReplenishmentOrder::class,
'reference_id' => $replenishmentOrder->id, 'reference_id' => $replenishmentOrder->id,
'note' => __('Replenishment') . ' #' . $replenishmentOrder->order_no, 'note' => __('Replenishment prepare') . ' #' . $replenishmentOrder->order_no,
'created_by' => Auth::id(), 'created_by' => Auth::id(),
'created_at' => now(),
]); ]);
} }
}
// 增加機台貨道庫存 // delivering → completed增加機台貨道庫存
MachineSlot::where('machine_id', $replenishmentOrder->machine_id) if ($newStatus === ReplenishmentOrder::STATUS_COMPLETED) {
->where('slot_no', $item->slot_no) foreach ($replenishmentOrder->items as $item) {
->increment('stock', $item->quantity); MachineSlot::where('machine_id', $replenishmentOrder->machine_id)
->where('slot_no', $item->slot_no)
->increment('stock', $item->quantity);
}
$replenishmentOrder->completed_at = now();
}
$replenishmentOrder->status = $newStatus;
$replenishmentOrder->save();
});
$statusLabels = [
'prepared' => __('Confirm Prepare'),
'delivering' => __('Start Delivery'),
'completed' => __('Confirm Complete'),
];
return response()->json([
'success' => true,
'message' => __('Status updated successfully')
]);
}
/**
* 取消補貨單 若已備貨/配送中則退回倉庫庫存
*/
public function cancelReplenishment(ReplenishmentOrder $replenishmentOrder)
{
if (!$replenishmentOrder->canTransitionTo(ReplenishmentOrder::STATUS_CANCELLED)) {
return response()->json([
'success' => false,
'message' => __('Cannot cancel this order')
], 422);
}
DB::transaction(function () use ($replenishmentOrder) {
// 若已備貨(prepared)或配送中(delivering),需退回倉庫庫存
if (in_array($replenishmentOrder->status, [ReplenishmentOrder::STATUS_PREPARED, ReplenishmentOrder::STATUS_DELIVERING])) {
$replenishmentOrder->loadMissing('items');
foreach ($replenishmentOrder->items as $item) {
$stock = WarehouseStock::where('warehouse_id', $replenishmentOrder->warehouse_id)
->where('product_id', $item->product_id)
->lockForUpdate()
->first();
if ($stock) {
$before = $stock->quantity;
$stock->increment('quantity', $item->quantity);
StockMovement::create([
'company_id' => $replenishmentOrder->company_id,
'warehouse_id' => $replenishmentOrder->warehouse_id,
'product_id' => $item->product_id,
'type' => 'in',
'quantity' => $item->quantity,
'before_qty' => $before,
'after_qty' => $before + $item->quantity,
'reference_type' => ReplenishmentOrder::class,
'reference_id' => $replenishmentOrder->id,
'note' => __('Replenishment cancelled, stock returned') . ' #' . $replenishmentOrder->order_no,
'created_by' => Auth::id(),
]);
}
}
} }
$replenishmentOrder->update([ $replenishmentOrder->update([
'status' => ReplenishmentOrder::STATUS_COMPLETED, 'status' => ReplenishmentOrder::STATUS_CANCELLED,
'completed_at' => now(),
]); ]);
}); });
return back()->with('success', __('Replenishment completed')); return response()->json([
'success' => true,
'message' => __('Order has been cancelled'),
'stock_returned' => in_array($replenishmentOrder->getOriginal('status'), [ReplenishmentOrder::STATUS_PREPARED, ReplenishmentOrder::STATUS_DELIVERING]),
]);
} }
/**
* 指派補貨人員(僅限同租戶帳號)
*/
public function assignReplenishment(Request $request, ReplenishmentOrder $replenishmentOrder)
{
$validated = $request->validate([
'assigned_to' => 'required|exists:users,id',
]);
// 確保指派人員屬於同租戶
$user = \App\Models\System\User::where('id', $validated['assigned_to'])
->where('company_id', Auth::user()->company_id)
->first();
if (!$user) {
return response()->json([
'success' => false,
'message' => __('Invalid personnel selection')
], 422);
}
$replenishmentOrder->update(['assigned_to' => $user->id]);
return response()->json([
'success' => true,
'message' => __('Personnel assigned successfully'),
'assignee_name' => $user->name,
]);
}
/**
* AJAX取得特定機台的貨道資訊供補貨選擇
*/
public function getMachineSlotsForReplenishment(Machine $machine)
{
$slots = $machine->slots()
->with(['product' => fn($q) => $q->select('id', 'name', 'image_url', 'name_dictionary_key')->with('translations')])
->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')
->get();
return response()->json([
'success' => true,
'machine' => [
'id' => $machine->id,
'name' => $machine->name,
'serial_no' => $machine->serial_no,
],
'slots' => $slots->map(fn($s) => [
'slot_no' => $s->slot_no,
'product_id' => $s->product_id,
'product_name' => $s->product?->localized_name ?? null,
'image_url' => $s->product?->image_url,
'stock' => $s->stock,
'max_stock' => $s->max_stock,
'needs_replenishment' => $s->product_id && $s->stock < $s->max_stock,
]),
]);
}
/**
* AJAX取得補貨單詳情
*/
public function replenishmentOrderDetails(Request $request, $id)
{
$order = ReplenishmentOrder::with(['machine', 'warehouse', 'creator', 'assignee'])
->where('id', $id)
->firstOrFail();
$items = ReplenishmentOrderItem::with('product.translations')
->where('replenishment_order_id', $id)
->get()
->map(function ($item) {
return [
'product_id' => $item->product_id,
'product_name' => $item->product?->localized_name ?? 'Unknown',
'image_url' => $item->product?->image_url,
'quantity' => $item->quantity,
'slot_no' => $item->slot_no,
'current_stock' => $item->current_stock,
'max_stock' => $item->max_stock,
];
});
return response()->json([
'success' => true,
'order' => [
'id' => $order->id,
'order_no' => $order->order_no,
'status' => $order->status,
'machine_name' => $order->machine?->name,
'machine_serial' => $order->machine?->serial_no,
'warehouse_name' => $order->warehouse?->name,
'creator_name' => $order->creator?->name,
'assignee_name' => $order->assignee?->name,
'note' => $order->note,
'created_at' => $order->created_at->format('Y-m-d H:i:s'),
'completed_at' => $order->completed_at?->format('Y-m-d H:i:s'),
],
'items' => $items
]);
}
/**
* 舊版 confirmReplenishment 已重構為 updateReplenishmentStatus
* 保留向下相容
*/
public function confirmReplenishment(ReplenishmentOrder $replenishmentOrder)
{
// 模擬直接完成流程pending → completed
request()->merge(['status' => 'completed']);
return $this->updateReplenishmentStatus(request(), $replenishmentOrder);
}
/** /**
* AJAX取得來源庫存倉庫或機台 * AJAX取得來源庫存倉庫或機台
*/ */
@ -609,21 +1101,21 @@ class WarehouseController extends Controller
if ($warehouseId) { if ($warehouseId) {
// Warehouse uses TenantScoped, findOrFail will respect it // Warehouse uses TenantScoped, findOrFail will respect it
$warehouse = Warehouse::findOrFail($warehouseId); $warehouse = Warehouse::findOrFail($warehouseId);
$stocks = WarehouseStock::with('product:id,name,image_url') $stocks = WarehouseStock::with(['product' => fn($q) => $q->select('id', 'name', 'image_url', 'name_dictionary_key')->with('translations')])
->where('warehouse_id', $warehouse->id) ->where('warehouse_id', $warehouse->id)
->where('quantity', '>', 0) ->where('quantity', '>', 0)
->get(); ->get();
$data = $stocks->map(fn($s) => [ $data = $stocks->map(fn($s) => [
'id' => $s->product_id, 'id' => $s->product_id,
'name' => $s->product?->name ?? 'Unknown', 'name' => $s->product?->localized_name ?? 'Unknown',
'quantity' => $s->quantity, 'quantity' => $s->quantity,
'image' => $s->product?->image_url 'image' => $s->product?->image_url
]); ]);
} elseif ($machineId) { } elseif ($machineId) {
// Machine uses TenantScoped and machine_access scope // Machine uses TenantScoped and machine_access scope
$machine = Machine::findOrFail($machineId); $machine = Machine::findOrFail($machineId);
$slots = MachineSlot::with('product:id,name,image_url') $slots = MachineSlot::with(['product' => fn($q) => $q->select('id', 'name', 'image_url', 'name_dictionary_key')->with('translations')])
->where('machine_id', $machine->id) ->where('machine_id', $machine->id)
->where('stock', '>', 0) ->where('stock', '>', 0)
->get() ->get()
@ -633,13 +1125,13 @@ class WarehouseController extends Controller
$p = $group->first()->product; $p = $group->first()->product;
return [ return [
'id' => $group->first()->product_id, 'id' => $group->first()->product_id,
'name' => $p?->name ?? 'Unknown', 'name' => $p?->localized_name ?? 'Unknown',
'quantity' => $group->sum('stock'), 'quantity' => $group->sum('stock'),
'image' => $p?->image_url 'image' => $p?->image_url
]; ];
})->values(); })->values();
} else { } else {
return response()->json(['success' => false, 'message' => 'No source provided']); return response()->json(['success' => false, 'message' => __('No source provided')]);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json(['success' => false, 'message' => __('Unauthorized or not found')]); return response()->json(['success' => false, 'message' => __('Unauthorized or not found')]);
@ -653,7 +1145,7 @@ class WarehouseController extends Controller
*/ */
public function warehouseStocks(Warehouse $warehouse, Request $request) public function warehouseStocks(Warehouse $warehouse, Request $request)
{ {
$query = $warehouse->stocks()->with('product:id,name,image_url'); $query = $warehouse->stocks()->with(['product' => fn($q) => $q->select('id', 'name', 'image_url', 'name_dictionary_key')->with('translations')]);
if ($search = $request->input('search')) { if ($search = $request->input('search')) {
$query->whereHas('product', fn($q) => $q->where('name', 'like', "%{$search}%")); $query->whereHas('product', fn($q) => $q->where('name', 'like', "%{$search}%"));
@ -671,7 +1163,7 @@ class WarehouseController extends Controller
'stocks' => $stocks->map(fn($s) => [ 'stocks' => $stocks->map(fn($s) => [
'id' => $s->id, 'id' => $s->id,
'product_id' => $s->product_id, 'product_id' => $s->product_id,
'product_name' => $s->product?->name ?? 'Unknown', 'product_name' => $s->product?->localized_name ?? 'Unknown',
'image_url' => $s->product?->image_url, 'image_url' => $s->product?->image_url,
'quantity' => (int)$s->quantity, 'quantity' => (int)$s->quantity,
'safety_stock' => (int)$s->safety_stock, 'safety_stock' => (int)$s->safety_stock,
@ -684,7 +1176,7 @@ class WarehouseController extends Controller
*/ */
public function stockInOrderDetails(StockInOrder $order) public function stockInOrderDetails(StockInOrder $order)
{ {
$order->load(['warehouse', 'creator', 'items.product']); $order->load(['warehouse', 'creator', 'items.product.translations']);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
@ -699,69 +1191,32 @@ class WarehouseController extends Controller
'note' => $order->note, 'note' => $order->note,
], ],
'items' => $order->items->map(fn($item) => [ 'items' => $order->items->map(fn($item) => [
'product_name' => $item->product?->name ?? 'Unknown', 'product_name' => $item->product?->localized_name ?? 'Unknown',
'image_url' => $item->product?->image_url, 'image_url' => $item->product?->image_url,
'quantity' => $item->quantity, 'quantity' => $item->quantity,
]) ])
]); ]);
} }
/**
* Get replenishment order details for AJAX.
*/
public function replenishmentOrderDetails(Request $request, $id)
{
$order = \App\Models\WarehouseReplenishment::with(['machine', 'warehouse', 'creator'])
->where('id', $id)
->firstOrFail();
$items = \App\Models\WarehouseReplenishmentItem::with('product')
->where('replenishment_id', $id)
->get()
->map(function($item) {
return [
'product_id' => $item->product_id,
'product_name' => $item->product?->name ?? 'Unknown',
'image_url' => $item->product?->image_url,
'quantity' => $item->quantity,
'slot_no' => $item->slot_no,
];
});
return response()->json([
'success' => true,
'order' => [
'id' => $order->id,
'order_no' => $order->order_no,
'status' => $order->status,
'machine_name' => $order->machine?->name,
'warehouse_name' => $order->warehouse?->name,
'creator_name' => $order->creator?->name,
'note' => $order->note,
'created_at' => $order->created_at->format('Y-m-d H:i:s'),
'completed_at' => $order->completed_at?->format('Y-m-d H:i:s'),
],
'items' => $items
]);
}
/** /**
* Get transfer order details for AJAX. * Get transfer order details for AJAX.
*/ */
public function transferOrderDetails(Request $request, $id) public function transferOrderDetails(Request $request, $id)
{ {
$order = \App\Models\WarehouseTransfer::with(['fromWarehouse', 'toWarehouse', 'fromMachine', 'creator']) $order = TransferOrder::with(['fromWarehouse', 'toWarehouse', 'fromMachine', 'creator'])
->where('id', $id) ->where('id', $id)
->firstOrFail(); ->firstOrFail();
$items = \App\Models\WarehouseTransferItem::with('product') $items = \App\Models\Warehouse\TransferOrderItem::with('product.translations')
->where('transfer_id', $id) ->where('transfer_order_id', $id)
->get() ->get()
->map(function($item) { ->map(function($item) {
return [ return [
'product_id' => $item->product_id, 'product_id' => $item->product_id,
'product_name' => $item->product?->name ?? 'Unknown', 'product_name' => $item->product?->localized_name ?? __('Unknown'),
'image_url' => $item->product?->image_url, 'image_url' => $item->product?->image_url,
'quantity' => $item->quantity, 'quantity' => (int)$item->quantity,
]; ];
}); });
@ -770,11 +1225,13 @@ class WarehouseController extends Controller
'order' => [ 'order' => [
'id' => $order->id, 'id' => $order->id,
'order_no' => $order->order_no, 'order_no' => $order->order_no,
'status' => $order->status,
'type' => $order->type, 'type' => $order->type,
'from_name' => $order->type === 'warehouse_to_warehouse' ? $order->fromWarehouse?->name : $order->fromMachine?->name, 'from_name' => $order->type === 'warehouse_to_warehouse'
'to_warehouse_name' => $order->toWarehouse?->name, ? ($order->fromWarehouse?->name ?? __('Unknown'))
'creator_name' => $order->creator?->name, : ($order->fromMachine?->name ?? __('Unknown')),
'to_name' => $order->toWarehouse?->name ?? __('Unknown'),
'status' => $order->status,
'creator_name' => $order->creator?->name ?? __('System'),
'note' => $order->note, 'note' => $order->note,
'created_at' => $order->created_at->format('Y-m-d H:i:s'), 'created_at' => $order->created_at->format('Y-m-d H:i:s'),
'completed_at' => $order->completed_at?->format('Y-m-d H:i:s'), 'completed_at' => $order->completed_at?->format('Y-m-d H:i:s'),

View File

@ -274,4 +274,9 @@ class Machine extends Model
{ {
return $this->belongsToMany(\App\Models\System\User::class); return $this->belongsToMany(\App\Models\System\User::class);
} }
public function orders()
{
return $this->hasMany(\App\Models\Transaction\Order::class);
}
} }

View File

@ -36,6 +36,42 @@ class ReplenishmentOrder extends Model
public const STATUS_COMPLETED = 'completed'; public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled'; public const STATUS_CANCELLED = 'cancelled';
/**
* 合法狀態轉換定義
* key = 當前狀態, value = 可轉換至的狀態陣列
*/
public const TRANSITIONS = [
self::STATUS_PENDING => [self::STATUS_PREPARED, self::STATUS_CANCELLED],
self::STATUS_PREPARED => [self::STATUS_DELIVERING, self::STATUS_CANCELLED],
self::STATUS_DELIVERING => [self::STATUS_COMPLETED, self::STATUS_CANCELLED],
self::STATUS_COMPLETED => [],
self::STATUS_CANCELLED => [],
];
/**
* 檢查是否可轉換至指定狀態
*/
public function canTransitionTo(string $status): bool
{
return in_array($status, self::TRANSITIONS[$this->status] ?? []);
}
/**
* 排除已取消的訂單
*/
public function scopeActive($query)
{
return $query->where('status', '!=', self::STATUS_CANCELLED);
}
/**
* 補貨總數量
*/
public function getTotalQuantityAttribute(): int
{
return $this->items->sum('quantity');
}
/** /**
* 出貨倉庫 * 出貨倉庫
*/ */

View File

@ -14,10 +14,14 @@ class ReplenishmentOrderItem extends Model
'product_id', 'product_id',
'slot_no', 'slot_no',
'quantity', 'quantity',
'current_stock',
'max_stock',
]; ];
protected $casts = [ protected $casts = [
'quantity' => 'integer', 'quantity' => 'integer',
'current_stock' => 'integer',
'max_stock' => 'integer',
]; ];
/** /**

View File

@ -21,6 +21,10 @@ class StockInOrder extends Model
'completed_at', 'completed_at',
]; ];
public const STATUS_DRAFT = 'draft';
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
protected $casts = [ protected $casts = [
'completed_at' => 'datetime', 'completed_at' => 'datetime',
]; ];

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 新增建單時快照欄位,記錄當下機台庫存狀態
*/
public function up(): void
{
Schema::table('replenishment_order_items', function (Blueprint $table) {
$table->integer('current_stock')->default(0)->after('quantity')->comment('建單時的機台庫存');
$table->integer('max_stock')->default(0)->after('current_stock')->comment('貨道最大容量');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('replenishment_order_items', function (Blueprint $table) {
$table->dropColumn(['current_stock', 'max_stock']);
});
}
};

File diff suppressed because it is too large Load Diff

View File

@ -4,56 +4,84 @@
"Add Warehouse": "倉庫を追加", "Add Warehouse": "倉庫を追加",
"Adjustment": "在庫調整", "Adjustment": "在庫調整",
"All Companies": "すべての会社", "All Companies": "すべての会社",
"All slots are fully stocked": "全スロットが満在庫です",
"All Statuses": "すべてのステータス", "All Statuses": "すべてのステータス",
"All Types": "すべてのタイプ", "All Types": "すべてのタイプ",
"All Warehouses": "すべての倉庫", "All Warehouses": "すべての倉庫",
"Approved At": "承認日時", "Approved At": "承認日時",
"Are you sure you want to cancel this order? If the order has been prepared, stock will be returned to the warehouse.": "この注文をキャンセルしますか?準備済みの場合、在庫は倉庫に返却されます。",
"Are you sure you want to confirm this stock-in order? This action will update your inventory levels.": "この入庫伝票を確定してもよろしいですか?この操作により在庫レベルが更新されます。", "Are you sure you want to confirm this stock-in order? This action will update your inventory levels.": "この入庫伝票を確定してもよろしいですか?この操作により在庫レベルが更新されます。",
"Are you sure you want to confirm this transfer? This will deduct stock from source and add to target.": "この転送を確定してもよろしいですか?この操作により発送元の在庫が減算され、対象の在庫に加算されます。",
"Are you sure you want to delete this draft order? This action cannot be undone.": "この下書き伝票を削除してもよろしいですか?この操作は取り消せません。",
"Assign Personnel": "担当者指定",
"Assigned To": "担当者",
"Auto Replenishment": "一括補充",
"Automatically calculate replenishment needs based on machine capacity": "マシン容量に基づいて補充ニーズを自動計算",
"Branch": "分倉庫", "Branch": "分倉庫",
"Branch Warehouses": "分倉庫数", "Branch Warehouses": "分倉庫数",
"CREATE STOCK TRANSFERS BETWEEN WAREHOUSES AND MACHINE RETURNS": "倉庫間の転送および機台からの返品伝票を作成", "Calculate Replenishment": "補充量を計算",
"Cancel Order": "注文取消",
"Cancelled": "キャンセル済み", "Cancelled": "キャンセル済み",
"Cannot cancel this order": "この注文はキャンセルできません",
"Cannot delete warehouse with existing stock": "在庫がある倉庫は削除できません。", "Cannot delete warehouse with existing stock": "在庫がある倉庫は削除できません。",
"Card System": "カード決済システム", "Card System": "カード決済システム",
"Card Terminal System": "カード端末システム", "Card Terminal System": "カード端末システム",
"Cash Module": "現金決済モジュール", "Cash Module": "現金決済モジュール",
"Cash Module Feature": "現金モジュール機能", "Cash Module Feature": "現金モジュール機能",
"Coin\/Banknote Machine": "硬貨\/紙幣モジュール", "Coin/Banknote Machine": "硬貨/紙幣モジュール",
"Coin\/Bill Acceptor": "コイン\/紙幣機", "Coin/Bill Acceptor": "コイン/紙幣機",
"Completed": "完了", "Completed": "完了",
"Completed At": "完了日時",
"Confirm": "確認", "Confirm": "確認",
"Confirm Stock-in Order": "入庫伝票を確認", "Confirm Cancel": "キャンセル確認",
"Confirm Complete": "完了を確認",
"Confirm Deletion": "削除の確認",
"Confirm Prepare": "準備確認",
"Confirm replenishment completed?": "補充が完了したことを確認しますか?", "Confirm replenishment completed?": "補充が完了したことを確認しますか?",
"Confirm Stock-In": "入庫を確認",
"Confirm Stock-in Order": "入庫伝票を確認",
"Confirm this stock-in order?": "この入庫伝票を確認しますか?", "Confirm this stock-in order?": "この入庫伝票を確認しますか?",
"Confirm this transfer?": "この転送を確認しますか?", "Confirm this transfer?": "この転送を確認しますか?",
"Create Replenishment Order": "補充伝票を作成", "Confirm Transfer": "転送を確認",
"Create Transfer Order": "転送伝票を作成", "Confirm Transfer Order": "転送伝票を確認",
"Create and manage replenishment orders from warehouse to machines": "倉庫から機台への補充伝票の作成と管理", "Create and manage replenishment orders from warehouse to machines": "倉庫から機台への補充伝票の作成と管理",
"Create Replenishment Order": "補充伝票を作成",
"CREATE STOCK TRANSFERS BETWEEN WAREHOUSES AND MACHINE RETURNS": "倉庫間の転送および機台からの返品伝票を作成",
"Create stock transfers between warehouses and machine returns": "倉庫間の転送および機台からの返品伝票を作成", "Create stock transfers between warehouses and machine returns": "倉庫間の転送および機台からの返品伝票を作成",
"Create Transfer Order": "転送伝票を作成",
"Created At": "作成日時", "Created At": "作成日時",
"Created By": "作成者", "Created By": "作成者",
"Credit Card": "クレジットカード", "Credit Card": "クレジットカード",
"Current Stock": "現在在庫",
"Damage": "破損", "Damage": "破損",
"Delete Permanently": "永久に削除",
"Delivering": "配送中", "Delivering": "配送中",
"Display Material Code": "素材コードを表示", "Display Material Code": "素材コードを表示",
"Draft": "下書き", "Draft": "下書き",
"E.SUN Bank Scan": "玉山銀行スキャン", "E.SUN Bank Scan": "玉山銀行スキャン",
"E.SUN Pay": "E.SUN Pay", "E.SUN Pay": "E.SUN Pay",
"E.SUN QR Pay": "玉山QR決済", "E.SUN QR Pay": "玉山QR決済",
"ESUN": "玉山銀行 (ESUN)",
"Electronic Invoice": "電子翻訳", "Electronic Invoice": "電子翻訳",
"Empty": "空", "Empty": "空",
"Enable Points Mechanism": "ポイント機能を有効化", "Enable Points Mechanism": "ポイント機能を有効化",
"Enabled Features": "有効な機能", "Enabled Features": "有効な機能",
"Error": "エラー",
"ESUN": "玉山銀行 (ESUN)",
"Expected Stock": "予想在庫",
"Expiry": "有効期限", "Expiry": "有効期限",
"Failed to load preview": "プレビューの読み込みに失敗しました",
"Fill Quantity": "補充数量",
"Fill Rate": "充填率", "Fill Rate": "充填率",
"From": "発送元", "From": "発送元",
"From Machine": "発送元機台", "From Machine": "発送元機台",
"From Warehouse": "発送元倉庫", "From Warehouse": "発送元倉庫",
"Go Back": "戻る",
"Goods & System Settings": "商品とシステム設定", "Goods & System Settings": "商品とシステム設定",
"Image": "画像", "Image": "画像",
"In Stock": "現在庫", "In Stock": "現在庫",
"Includes Credit Card\/Mobile Pay": "クレジットカード\/モバイル決済を含む", "Includes Credit Card/Mobile Pay": "クレジットカード/モバイル決済を含む",
"Invalid personnel selection": "無効な担当者選択",
"Invalid status transition": "無効なステータス遷移",
"Inventory Management": "在庫管理", "Inventory Management": "在庫管理",
"LinePay": "LinePay", "LinePay": "LinePay",
"LinePay Payment": "LinePay 決済", "LinePay Payment": "LinePay 決済",
@ -71,8 +99,11 @@
"Manage stock levels, stock-in orders, and movement history": "在庫レベル、入庫伝票、および移動履歴の管理", "Manage stock levels, stock-in orders, and movement history": "在庫レベル、入庫伝票、および移動履歴の管理",
"Manage stock transfers between warehouses and machine returns": "倉庫間の転送および機台からの返品伝票を作成", "Manage stock transfers between warehouses and machine returns": "倉庫間の転送および機台からの返品伝票を作成",
"Material Code": "素材コード", "Material Code": "素材コード",
"Max Capacity": "最大容量",
"Max Stock": "最大在庫",
"Movement History": "移動履歴", "Movement History": "移動履歴",
"Movement Logs": "移動ログ", "Movement Logs": "移動ログ",
"Needs replenishment": "補充が必要",
"New Replenishment": "新規補充", "New Replenishment": "新規補充",
"New Stock-In Order": "新規入庫伝票", "New Stock-In Order": "新規入庫伝票",
"New Transfer": "新規転送", "New Transfer": "新規転送",
@ -80,42 +111,60 @@
"No products found in this warehouse": "この倉庫に在庫はありません", "No products found in this warehouse": "この倉庫に在庫はありません",
"No replenishment orders found": "補充伝票が見つかりません", "No replenishment orders found": "補充伝票が見つかりません",
"No slot data": "貨道データなし", "No slot data": "貨道データなし",
"No slots need replenishment": "補充が必要なスロットはありません",
"No stock data found": "在庫データが見つかりません", "No stock data found": "在庫データが見つかりません",
"No stock-in orders found": "入庫伝票が見つかりません", "No stock-in orders found": "入庫伝票が見つかりません",
"No transfer orders found": "転送伝票が見つかりません", "No transfer orders found": "転送伝票が見つかりません",
"Normal": "通常", "Normal": "通常",
"Note": "備考", "Note": "備考",
"Note (optional)": "備考 (任意)", "Note (optional)": "備考 (任意)",
"Offline": "オフライン",
"Online": "オンライン",
"Operation failed": "操作に失敗しました",
"Optional": "任意", "Optional": "任意",
"Order Details": "入庫伝票詳細", "Order Details": "入庫伝票詳細",
"Order has been cancelled": "注文がキャンセルされました",
"Order No.": "伝票番号", "Order No.": "伝票番号",
"Out of Stock": "在庫切れ", "Out of Stock": "在庫切れ",
"Pending": "保留中", "Pending": "保留中",
"Personnel assigned successfully": "担当者の指定が完了しました",
"Please enter configuration name": "設定名を入力してください", "Please enter configuration name": "設定名を入力してください",
"Please select a company": "会社を選択してください", "Please select a company": "会社を選択してください",
"Please select warehouse and machine": "倉庫とマシンを選択してください",
"Points": "ポイント", "Points": "ポイント",
"Prepared": "準備済み", "Prepared": "準備済み",
"Preparing": "準備中",
"Product": "商品", "Product": "商品",
"Product Details": "商品詳細", "Product Details": "商品詳細",
"Product ID": "商品ID", "Product ID": "商品ID",
"Products": "商品", "Products": "商品",
"Products \/ Stock": "商品 \/ 在庫", "Products / Stock": "商品 / 在庫",
"Publish": "配信開始", "Publish": "配信開始",
"QR Scan Payment": "QRスキャン決済機能", "QR Scan Payment": "QRスキャン決済機能",
"Qty": "数量", "Qty": "数量",
"Qty Change": "数量変更", "Qty Change": "数量変更",
"Quantity": "数量", "Quantity": "数量",
"Quick Replenish": "クイック補充",
"Real-time slot-level inventory across all machines": "すべての機台におけるリアルタイムの貨道別在庫", "Real-time slot-level inventory across all machines": "すべての機台におけるリアルタイムの貨道別在庫",
"Replenishment cancelled, stock returned": "補充キャンセル、在庫返却",
"Replenishment Items": "補充項目", "Replenishment Items": "補充項目",
"Replenishment order confirmed and inventory updated": "補充伝票が確定され、在庫が更新されました",
"Replenishment order created successfully": "補充伝票が正常に作成されました",
"Replenishment Orders": "機台補充伝票", "Replenishment Orders": "機台補充伝票",
"Replenishment prepare": "補充準備",
"Reset Filters": "フィルターをリセット", "Reset Filters": "フィルターをリセット",
"Safety Stock": "安全在庫", "Safety Stock": "安全在庫",
"Search Product": "商品を検索", "Sale Price": "販売価格",
"Search by name...": "名称で検索...", "Search by name...": "名称で検索...",
"Search machines...": "機台を検索...", "Search machines...": "機台を検索...",
"Search Product": "商品を検索",
"Search products...": "商品を検索...", "Search products...": "商品を検索...",
"Search warehouses...": "倉庫を検索...", "Search warehouses...": "倉庫を検索...",
"Select a team member to handle this replenishment order": "この補充注文を担当するメンバーを選択してください",
"Select all slots": "全スロットを選択",
"Select Machine": "機台を選択", "Select Machine": "機台を選択",
"Select Machine First": "先にマシンを選択してください",
"Select Personnel": "担当者を選択",
"Select Product": "商品を選択", "Select Product": "商品を選択",
"Select Warehouse": "倉庫を選択", "Select Warehouse": "倉庫を選択",
"Serial No.": "シリアル番号", "Serial No.": "シリアル番号",
@ -123,55 +172,75 @@
"Shopping Cart": "ショッピングカート", "Shopping Cart": "ショッピングカート",
"Shopping Cart Feature": "ショッピングカート機能", "Shopping Cart Feature": "ショッピングカート機能",
"Slot": "貨道", "Slot": "貨道",
"Slots need replenishment": "補充が必要なスロット",
"Source": "発送元",
"Source Warehouse": "発送元倉庫", "Source Warehouse": "発送元倉庫",
"Start Delivery": "配送開始",
"Status": "ステータス", "Status": "ステータス",
"Status Timeline": "ステータスタイムライン",
"Status updated successfully": "ステータス更新成功",
"Stock": "在庫", "Stock": "在庫",
"Stock flow history": "在庫移動履歴",
"Stock In": "入庫", "Stock In": "入庫",
"Stock Level": "在庫量",
"Stock Levels": "在庫レベル", "Stock Levels": "在庫レベル",
"Stock Out": "出庫", "Stock Out": "出庫",
"Stock flow history": "在庫移動履歴", "Stock Rate": "在庫率",
"Stock returned to warehouse": "在庫が倉庫に返却されました",
"Stock-In Management": "入庫管理", "Stock-In Management": "入庫管理",
"Stock-in order confirmed and inventory updated": "入庫伝票が確定され、在庫が更新されました",
"Stock-in order created successfully": "入庫伝票が正常に作成されました",
"Stock-in order deleted successfully": "入庫伝票が正常に削除されました",
"Stock-In Orders": "入庫伝票", "Stock-In Orders": "入庫伝票",
"Stock-In Orders Tab": "入庫伝票", "Stock-In Orders Tab": "入庫伝票",
"System settings updated successfully.": "システム設定が正常に更新されました。", "System settings updated successfully.": "システム設定が正常に更新されました。",
"Target": "対象",
"Target Machine": "対象機台", "Target Machine": "対象機台",
"Target Warehouse": "対象倉庫", "Target Warehouse": "対象倉庫",
"Taxation System": "税務システム", "Taxation System": "税務システム",
"Time": "時間", "Time": "時間",
"To": "配送先", "To": "配送先",
"To Warehouse": "配送先倉庫", "To Warehouse": "配送先倉庫",
"Total Quantity": "合計数量",
"Total Stock": "総在庫", "Total Stock": "総在庫",
"Total Warehouses": "倉庫総数", "Total Warehouses": "倉庫総数",
"Track stock levels, stock-in orders, and movement history": "在庫レベル、入庫伝票、および移動履歴の追跡", "Track stock levels, stock-in orders, and movement history": "在庫レベル、入庫伝票、および移動履歴の追跡",
"Track stock-in orders and movement history": "入庫伝票および移動履歴の追跡", "Track stock-in orders and movement history": "入庫伝票および移動履歴の追跡",
"Transfer Audit": "転送監査", "Transfer Audit": "転送監査",
"Transfer Details": "転送伝票詳細",
"Transfer In": "転送入", "Transfer In": "転送入",
"Transfer order confirmed and inventory updated": "転送伝票が確定され、在庫が更新されました",
"Transfer order created successfully": "転送伝票が正常に作成されました",
"Transfer order deleted successfully": "転送伝票が正常に削除されました",
"Transfer Orders": "転送伝票", "Transfer Orders": "転送伝票",
"Transfer Out": "転送出", "Transfer Out": "転送出",
"Transfer Type": "転送タイプ", "Transfer Type": "転送タイプ",
"Transfers": "転送", "Transfers": "転送",
"Unassigned": "未割り当て",
"Unpublish": "配信終了", "Unpublish": "配信終了",
"Update Settings": "設定を更新",
"Update failed": "更新に失敗しました", "Update failed": "更新に失敗しました",
"Update Settings": "設定を更新",
"Video": "動画", "Video": "動画",
"View Details": "詳細を表示", "View Details": "詳細を表示",
"View Inventory": "在庫を表示", "View Inventory": "在庫を表示",
"View Slots": "貨道を表示", "View Slots": "貨道を表示",
"Visual overview of all machine locations": "全機器位置のビジュアル概要",
"Warehouse": "倉庫", "Warehouse": "倉庫",
"Warehouse created successfully.": "倉庫が正常に作成されました。",
"Warehouse deleted successfully.": "倉庫が正常に削除されました。",
"Warehouse Info": "倉庫情報", "Warehouse Info": "倉庫情報",
"Warehouse Inventory": "倉庫在庫状況", "Warehouse Inventory": "倉庫在庫状況",
"Warehouse Management": "倉庫管理", "Warehouse Management": "倉庫管理",
"Warehouse Overview": "倉庫概要", "Warehouse Overview": "倉庫概要",
"Warehouse Transfer": "倉庫転送",
"Warehouse created successfully.": "倉庫が正常に作成されました。",
"Warehouse deleted successfully.": "倉庫が正常に削除されました。",
"Warehouse purchase records": "倉庫購入記録", "Warehouse purchase records": "倉庫購入記録",
"Warehouse stock insufficient": "倉庫在庫不足",
"Warehouse stock insufficient warning": "倉庫在庫が全スロットを満たすには不足していますが、注文を作成することは可能です",
"Warehouse to Warehouse": "倉庫間転送", "Warehouse to Warehouse": "倉庫間転送",
"Warehouse Transfer": "倉庫転送",
"Warehouse updated successfully.": "倉庫が正常に更新されました。", "Warehouse updated successfully.": "倉庫が正常に更新されました。",
"Welcome Gift": "登録特典", "Welcome Gift": "登録特典",
"Welcome Gift Feature": "来店特典機能", "Welcome Gift Feature": "来店特典機能",
"Visual overview of all machine locations": "全機器位置のビジュアル概要", "Stock / Capacity": "在庫 / 上限",
"Online": "オンライン", "Warehouse Stock": "倉庫在庫",
"Offline": "オフライン", "Recalculate": "再計算"
"Error": "エラー"
} }

File diff suppressed because it is too large Load Diff

View File

@ -192,19 +192,38 @@
:class="{ 'opacity-30 pointer-events-none transition-opacity duration-300': isLoading }"> :class="{ 'opacity-30 pointer-events-none transition-opacity duration-300': isLoading }">
{{-- Filters --}} {{-- Filters --}}
<div class="flex flex-col md:flex-row md:items-center justify-between mb-8 gap-4"> <div class="flex flex-col md:flex-row md:items-center justify-between mb-8 gap-4">
<form action="{{ route('admin.warehouses.index') }}" method="GET" class="relative group" <form action="{{ route('admin.warehouses.index') }}" method="GET" class="flex items-center gap-4"
@submit.prevent="fetchPage($el.action + '?' + new URLSearchParams(new FormData($el)).toString())"> @submit.prevent="fetchPage($el.action + '?' + new URLSearchParams(new FormData($el)).toString())">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10"> <div class="relative group">
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors" <span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" <svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors"
stroke-linejoin="round"> viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
<circle cx="11" cy="11" r="8"></circle> stroke-linejoin="round">
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<input type="text" name="search" value="{{ request('search') }}"
class="py-2.5 pl-12 pr-6 block w-64 luxury-input" placeholder="{{ __('Search warehouses...') }}">
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
</div>
<button type="submit" class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 group" title="{{ __('Search') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg> </svg>
</span> </button>
<input type="text" name="search" value="{{ request('search') }}"
class="py-2.5 pl-12 pr-6 block w-64 luxury-input" placeholder="{{ __('Search warehouses...') }}"> <button type="button"
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}"> @click="
$el.closest('form').querySelectorAll('input[type=text]').forEach(i => i.value = '');
$el.closest('form').dispatchEvent(new Event('submit', { cancelable: true }));
"
class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-700 group" title="{{ __('Reset') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</form> </form>
<div <div

View File

@ -9,6 +9,10 @@
loading: false, loading: false,
showStockInModal: false, showStockInModal: false,
showConfirmModal: false, showConfirmModal: false,
showDeleteModal: false,
deleteUrl: '',
stockInWarehouseId: '',
note: '',
pendingForm: null, pendingForm: null,
items: [{ product_id: '', quantity: 1 }], items: [{ product_id: '', quantity: 1 }],
@ -130,55 +134,79 @@
}, },
async submitStockIn() { async submitStockIn() {
const form = document.getElementById('stockInForm'); console.log('submitStockIn triggered');
const warehouseId = form.querySelector('[name="warehouse_id"]')?.value;
if (!warehouseId) { const form = document.getElementById('stockInForm');
if (!form) {
console.error('Form not found');
return;
}
const formData = new FormData(form);
const warehouseId = formData.get('warehouse_id');
if (!warehouseId || warehouseId.trim() === '') {
window.showToast?.('{{ __("Please select target warehouse") }}', 'error'); window.showToast?.('{{ __("Please select target warehouse") }}', 'error');
return; return;
} }
if (this.items.length === 0) { let hasItem = false;
let allValid = true;
// 抓取所有商品選擇器和數量框
const productSelects = form.querySelectorAll('select[name^="items["][name$="][product_id]"]');
const quantityInputs = form.querySelectorAll('input[name^="items["][name$="][quantity]"]');
if (productSelects.length === 0) {
window.showToast?.('{{ __("Please add at least one item") }}', 'error'); window.showToast?.('{{ __("Please add at least one item") }}', 'error');
return; return;
} }
let hasError = false; productSelects.forEach((select, index) => {
this.items.forEach(item => { hasItem = true;
if (!item.product_id || !item.quantity || item.quantity < 1) { const pId = select.value;
hasError = true; const qty = quantityInputs[index]?.value;
if (!pId || pId.trim() === '' || pId === ' ') {
allValid = false;
window.showToast?.('{{ __("Please select product for item :num") }}'.replace(':num', index + 1), 'error');
} else if (!qty || parseInt(qty) < 1) {
allValid = false;
window.showToast?.('{{ __("Please enter valid quantity for item :num") }}'.replace(':num', index + 1), 'error');
} }
}); });
if (hasError) { if (!allValid) return;
window.showToast?.('{{ __("Please select product and valid quantity for all items") }}', 'error');
return;
}
this.loading = true; this.loading = true;
console.log('Validation passed, submitting to:', form.action);
try { try {
const formData = new FormData(form);
const response = await fetch(form.action, { const response = await fetch(form.action, {
method: 'POST', method: 'POST',
body: formData,
headers: { headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'X-Requested-With': 'XMLHttpRequest', 'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json', 'Accept': 'application/json'
'X-CSRF-TOKEN': '{{ csrf_token() }}' },
} body: formData
}); });
const result = await response.json(); const result = await response.json();
console.log('Submit result:', result);
if (result.success) { if (result.success) {
window.showToast?.(result.message, 'success');
this.showStockInModal = false; this.showStockInModal = false;
this.fetchTabData('stock-in');
// 跳轉至進貨單管理頁面 (Inbound Tab) - 恢復整頁刷新
window.location.href = '{{ route('admin.warehouses.inventory') }}?tab=stock-in';
} else { } else {
window.showToast?.(result.message || '{{ __("Validation failed") }}', 'error'); const firstError = result.errors ? Object.values(result.errors).flat()[0] : result.message;
window.showToast?.(firstError || '{{ __("Validation failed") }}', 'error');
} }
} catch (error) { } catch (error) {
console.error(error); console.error('Submit error:', error);
window.showToast?.('{{ __("System Error") }}', 'error'); window.showToast?.('{{ __("Operation failed") }}', 'error');
} finally { } finally {
this.loading = false; this.loading = false;
} }
@ -195,35 +223,42 @@
this.showConfirmModal = true; this.showConfirmModal = true;
}, },
async doConfirmStockIn() { doConfirmStockIn() {
if (!this.pendingForm) return; if (!this.pendingForm) return;
const form = this.pendingForm;
this.showConfirmModal = false; this.showConfirmModal = false;
this.loading = true; this.loading = true;
this.pendingForm.submit();
},
try { confirmDelete(url) {
const response = await fetch(form.action, { this.deleteUrl = url;
method: 'POST', this.showDeleteModal = true;
headers: { },
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'X-Requested-With': 'XMLHttpRequest', doDeleteStockIn() {
'Accept': 'application/json' if (!this.deleteUrl) return;
}, this.showDeleteModal = false;
body: new FormData(form) this.loading = true;
});
const data = await response.json(); // 建立臨時表單以執行 DELETE 請求(整頁刷新)
if (data.success || response.ok) { const form = document.createElement('form');
window.showToast?.('{{ __("Confirmed") }}', 'success'); form.method = 'POST';
this.fetchTabData('stock-in'); form.action = this.deleteUrl;
}
} catch (e) { const csrf = document.createElement('input');
console.error(e); csrf.type = 'hidden';
window.showToast?.('{{ __("Operation failed") }}', 'error'); csrf.name = '_token';
} finally { csrf.value = document.querySelector('meta[name="csrf-token"]').content;
this.loading = false;
this.pendingForm = null; const method = document.createElement('input');
} method.type = 'hidden';
method.name = '_method';
method.value = 'DELETE';
form.appendChild(csrf);
form.appendChild(method);
document.body.appendChild(form);
form.submit();
} }
}; };
} }
@ -431,33 +466,50 @@
<!-- Products Section --> <!-- Products Section -->
<div class="space-y-4 pt-4"> <div class="space-y-4 pt-4">
<div class="flex items-center justify-between px-1"> <div class="flex items-center justify-between pl-1">
<h4 class="text-sm font-black text-slate-800 dark:text-white uppercase tracking-widest">{{ __('Product List') }}</h4> <label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em]">
<button type="button" @click="addItem()" class="text-xs font-black text-cyan-600 hover:text-cyan-700 flex items-center gap-1 transition-colors uppercase tracking-widest"> {{ __('Product List') }} <span class="text-rose-500">*</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg> </label>
<button type="button" @click="addItem()"
class="text-xs font-black text-cyan-500 hover:text-cyan-400 uppercase tracking-widest flex items-center gap-1.5 transition-colors">
<svg class="w-4 h-4 stroke-[3]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{{ __('Add Product') }} {{ __('Add Product') }}
</button> </button>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
<template x-for="(item, index) in items" :key="index"> <template x-for="(item, index) in items" :key="index">
<div class="flex items-end gap-4 p-4 bg-slate-50 dark:bg-slate-800/50 rounded-2xl border border-slate-100 dark:border-slate-800 transition-all"> <div class="group flex items-center gap-3 p-3 bg-slate-50/50 dark:bg-slate-900/30 rounded-2xl border border-slate-100 dark:border-slate-800/50 hover:border-cyan-500/30 transition-all duration-300">
<div class="flex-1 space-y-2"> <div class="flex-1 min-w-[200px]">
<label class="block text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Product') }}</label>
<x-searchable-select <x-searchable-select
x-bind:name="'items['+index+'][product_id]'" x-bind:name="'items['+index+'][product_id]'"
:options="$stock_in_products ?? []" :options="$stock_in_products ?? []"
placeholder="{{ __('Select Product') }}" placeholder="{{ __('Select Product') }}"
x-on:change="item.product_id = $event.target.value"
/> />
</div> </div>
<div class="w-32 space-y-2"> <div class="w-px h-6 bg-slate-200 dark:bg-slate-800/50"></div>
<label class="block text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Quantity') }}</label> <div class="flex items-center gap-1 bg-slate-100/50 dark:bg-slate-900/50 p-1 rounded-xl border border-slate-200/50 dark:border-slate-800/50">
<input type="number" x-model="item.quantity" x-bind:name="'items['+index+'][quantity]'" min="1" <button type="button" @click="item.quantity > 1 ? item.quantity-- : null"
class="luxury-input py-2.5"> class="p-2 text-slate-400 hover:text-cyan-500 transition-colors">
<svg class="w-3.5 h-3.5 stroke-[3]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" /></svg>
</button>
<input type="number" :name="'items['+index+'][quantity]'" x-model.number="item.quantity"
min="1" required class="w-12 bg-transparent border-none p-0 text-center font-mono font-bold text-slate-800 dark:text-slate-200 focus:ring-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
placeholder="0">
<button type="button" @click="item.quantity++"
class="p-2 text-slate-400 hover:text-cyan-500 transition-colors">
<svg class="w-3.5 h-3.5 stroke-[3]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
</button>
</div> </div>
<button type="button" @click="removeItem(index)" <button type="button" @click="removeItem(index)"
class="p-2.5 mb-0.5 rounded-xl text-slate-400 hover:text-rose-500 hover:bg-rose-500/10 transition-all"> class="p-2 text-slate-300 hover:text-rose-500 transition-all transform hover:scale-110"
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg> x-show="items.length > 1">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button> </button>
</div> </div>
</template> </template>
@ -490,13 +542,13 @@
</div> </div>
<x-confirm-modal <x-confirm-modal
alpineVar="showConfirmModal" alpine-var="showConfirmModal"
confirmAction="doConfirmStockIn()" confirm-action="doConfirmStockIn()"
iconType="info" icon-type="info"
confirmColor="emerald" confirm-color="emerald"
:title="__('Confirm Stock-in Order')" :title="__('Confirm Stock-in Order')"
:message="__('Are you sure you want to confirm this stock-in order? This action will update your inventory levels.')" :message="__('Are you sure you want to confirm this stock-in order? This action will update your inventory levels.')"
:confirmText="__('Confirm')" :confirm-text="__('Confirm Stock-In')"
/> />
<!-- Order Details Slide-over (Rewritten to match index.blade.php structure) --> <!-- Order Details Slide-over (Rewritten to match index.blade.php structure) -->
@ -633,5 +685,16 @@
</div> </div>
</div> </div>
</div> </div>
<x-confirm-modal
alpine-var="showDeleteModal"
confirm-action="doDeleteStockIn()"
icon-type="danger"
confirm-color="rose"
:title="__('Confirm Deletion')"
:message="__('Are you sure you want to delete this draft order? This action cannot be undone.')"
:confirm-text="__('Delete Permanently')"
/>
</div> </div>
@endsection @endsection

View File

@ -1,139 +1,421 @@
@extends('layouts.admin') @extends('layouts.admin')
@section('content') @section('content')
<div class="space-y-3 pb-20" x-data="{ <div class="space-y-4 pb-20" x-data="{
showSlotDrawer: false, viewMode: '{{ request('machine_id') ? 'detail' : 'list' }}',
displayMode: 'table', {{-- grid or table --}}
selectedMachine: null, selectedMachine: null,
slots: [], slots: [],
loading: false, loading: false,
async init() {
const urlParams = new URLSearchParams(window.location.search);
const machineId = urlParams.get('machine_id');
if (machineId) {
await this.viewSlots({id: machineId});
}
},
async viewSlots(machine) { async viewSlots(machine) {
this.selectedMachine = machine; this.selectedMachine = machine;
this.showSlotDrawer = true; this.viewMode = 'detail';
this.loading = true; this.loading = true;
this.slots = [];
const url = new URL(window.location);
url.searchParams.set('machine_id', machine.id);
window.history.pushState({}, '', url);
try { try {
const res = await fetch(`/admin/warehouses/machine-inventory/${machine.id}/slots`); const res = await fetch(`/admin/warehouses/machine-inventory/${machine.id}/slots`);
const data = await res.json(); const data = await res.json();
this.slots = data.slots || []; if (data.success) {
} catch(e) { this.slots = []; } this.slots = data.slots || [];
this.loading = false; this.selectedMachine = data.machine;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
} catch(e) {
console.error('Fetch error:', e);
this.slots = [];
} finally {
this.loading = false;
}
},
backToList() {
this.viewMode = 'list';
this.selectedMachine = null;
this.slots = [];
const url = new URL(window.location);
url.searchParams.delete('machine_id');
window.history.pushState({}, '', url);
},
get activeSlotsCount() { return this.slots.filter(s => s.product_id != null).length; },
get emptySlotsCount() { return this.slots.filter(s => s.product_id == null).length; },
get highStockSlotsCount() {
return this.slots.filter(s => s.product_id != null && (s.stock / (s.max_stock || 1)) >= 0.5).length;
},
get totalStock() { return this.slots.reduce((acc, s) => acc + (parseInt(s.stock) || 0), 0); },
get totalCapacity() { return this.slots.reduce((acc, s) => acc + (parseInt(s.max_stock) || 10), 0); },
get overallFillRate() {
if (this.totalCapacity === 0) return 0;
return Math.round((this.totalStock / this.totalCapacity) * 100);
},
getSlotColorClass(slot) {
if (!slot.expiry_date) return 'bg-slate-50/50 dark:bg-slate-800/50 text-slate-400 border-slate-200/60 dark:border-slate-700/50';
const todayStr = new Date().toISOString().split('T')[0];
const expiryStr = slot.expiry_date;
if (expiryStr < todayStr) return 'bg-rose-50/60 dark:bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-200 dark:border-rose-500/30';
const diffDays = Math.round((new Date(expiryStr) - new Date(todayStr)) / 86400000);
if (diffDays <= 7) return 'bg-amber-50/60 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-500/30';
return 'bg-emerald-50/60 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/30';
} }
}"> }">
{{-- Header --}} {{-- Header --}}
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4"> <div class="flex items-center gap-4">
<template x-if="viewMode === 'detail'">
<button @click="backToList()"
class="p-2.5 rounded-xl bg-white dark:bg-slate-900 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-all border border-slate-200/50 dark:border-slate-700/50 shadow-sm hover:shadow-md active:scale-95">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
</button>
</template>
<div> <div>
<h1 class="text-3xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Machine Inventory Overview') }}</h1> <h1 class="text-3xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Machine Inventory Overview') }}</h1>
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Real-time slot-level inventory across all machines') }}</p> <p class="text-base font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Real-time slot-level inventory across all machines') }}</p>
</div> </div>
</div> </div>
{{-- Table --}} {{-- List Mode --}}
<div class="luxury-card rounded-3xl p-8 animate-luxury-in"> <div x-show="viewMode === 'list'" class="space-y-6 animate-luxury-in" x-cloak>
<form action="{{ route('admin.warehouses.machine-inventory') }}" method="GET" class="mb-8"> <div class="luxury-card rounded-3xl p-8">
<div class="relative group"> <div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10"><svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg></span> <form action="{{ route('admin.warehouses.machine-inventory') }}" method="GET" class="flex items-center gap-4">
<input type="text" name="search" value="{{ request('search') }}" class="py-2.5 pl-12 pr-6 block w-64 luxury-input" placeholder="{{ __('Search machines...') }}"> <div class="relative group">
</div> <span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
</form> <svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="11" cy="11" r="8"></circle>
<div class="overflow-x-auto"> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<table class="w-full text-left border-separate border-spacing-y-0"> </svg>
<thead> </span>
<tr class="bg-slate-50/50 dark:bg-slate-900/10"> <input type="text" name="search" value="{{ request('search') }}" class="py-2.5 pl-12 pr-6 block w-72 luxury-input" placeholder="{{ __('Search machines...') }}">
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Machine') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Serial No.') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Slots') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Total Stock') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Fill Rate') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($machines as $machine)
@php
$maxStock = $machine->slots->sum('max_stock') ?: 1;
$currentStock = (int)($machine->total_stock ?? 0);
$fillRate = round(($currentStock / $maxStock) * 100);
$fillColor = $fillRate >= 50 ? 'emerald' : ($fillRate >= 20 ? 'amber' : 'rose');
@endphp
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-5">
<span class="font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">{{ $machine->name }}</span>
</td>
<td class="px-6 py-5">
<span class="font-mono font-bold text-slate-500">{{ $machine->serial_no }}</span>
</td>
<td class="px-6 py-5 text-center">
<span class="font-black text-slate-800 dark:text-white">{{ $machine->total_slots ?? 0 }}</span>
</td>
<td class="px-6 py-5 text-center">
<span class="font-black text-slate-800 dark:text-white">{{ $currentStock }}</span>
</td>
<td class="px-6 py-5 text-center">
<div class="flex items-center justify-center gap-3">
<div class="w-24 h-2 bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden">
<div class="h-full bg-{{ $fillColor }}-500 rounded-full transition-all" style="width: {{ $fillRate }}%"></div>
</div>
<span class="text-xs font-black text-{{ $fillColor }}-500">{{ $fillRate }}%</span>
</div>
</td>
<td class="px-6 py-5 text-right">
<button @click="viewSlots({{ json_encode($machine->only(['id', 'name', 'serial_no'])) }})"
class="px-4 py-2 rounded-xl text-xs font-black bg-cyan-500/10 text-cyan-500 border border-cyan-500/20 hover:bg-cyan-500 hover:text-white transition-all">
{{ __('View Slots') }}
</button>
</td>
</tr>
@empty
<tr><td colspan="6" class="px-6 py-20 text-center text-slate-400 font-bold">{{ __('No machines found') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-6 py-6 border-t border-slate-50 dark:border-slate-800/50">{{ $machines->links('vendor.pagination.luxury') }}</div>
</div>
{{-- Slot Drawer --}}
<div x-show="showSlotDrawer" class="fixed inset-0 z-[100] overflow-hidden" x-cloak>
<div x-show="showSlotDrawer" x-transition class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm" @click="showSlotDrawer = false"></div>
<div x-show="showSlotDrawer" x-transition:enter="transform transition ease-in-out duration-300" x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0" x-transition:leave="transform transition ease-in-out duration-300" x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
class="fixed inset-y-0 right-0 w-full sm:max-w-lg luxury-card rounded-l-3xl shadow-2xl overflow-y-auto z-10">
<div class="p-8">
<div class="flex justify-between items-center mb-8">
<div>
<h3 class="text-xl font-black text-slate-800 dark:text-white font-display" x-text="selectedMachine?.name"></h3>
<p class="text-xs font-mono font-bold text-slate-500 mt-1" x-text="selectedMachine?.serial_no"></p>
</div> </div>
<button @click="showSlotDrawer = false" class="text-slate-400 hover:text-slate-600 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" /></svg> <button type="submit" class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 group" title="{{ __('Search') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button> </button>
<button type="button"
@click="
$el.closest('form').querySelectorAll('input[type=text]').forEach(i => i.value = '');
$el.closest('form').submit();
"
class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-700 group" title="{{ __('Reset') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</form>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-sm font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Machine') }}</th>
<th class="px-6 py-4 text-sm font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Serial No.') }}</th>
<th class="px-6 py-4 text-sm font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Slots') }}</th>
<th class="px-6 py-4 text-sm font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Total Stock') }}</th>
<th class="px-6 py-4 text-sm font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Stock Rate') }}</th>
<th class="px-6 py-4 text-sm font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($machines as $machine)
@php
$maxStock = $machine->slots->sum('max_stock') ?: 1;
$currentStock = (int)($machine->total_stock ?? 0);
$fillRate = round(($currentStock / $maxStock) * 100);
$fillColor = $fillRate >= 50 ? 'emerald' : ($fillRate >= 20 ? 'amber' : 'rose');
@endphp
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300 cursor-pointer"
@click="viewSlots({{ json_encode($machine->only(['id', 'name', 'serial_no'])) }})">
<td class="px-6 py-5">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 group-hover:bg-cyan-500 group-hover:text-white transition-all overflow-hidden shadow-sm border border-slate-200 dark:border-slate-700">
@if($machine->image_urls && isset($machine->image_urls[0]))
<img src="{{ $machine->image_urls[0] }}" class="w-full h-full object-cover">
@else
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
@endif
</div>
<span class="font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">{{ $machine->name }}</span>
</div>
</td>
<td class="px-6 py-5">
<span class="font-mono font-bold text-slate-500 uppercase tracking-widest text-sm">{{ $machine->serial_no }}</span>
</td>
<td class="px-6 py-5 text-center">
<span class="font-black text-slate-800 dark:text-white">{{ $machine->total_slots ?? 0 }}</span>
</td>
<td class="px-6 py-5 text-center">
<span class="font-black text-slate-800 dark:text-white">{{ $currentStock }}</span>
</td>
<td class="px-6 py-5 text-center">
<div class="flex items-center justify-center gap-3">
<div class="w-24 h-2 bg-slate-100 dark:bg-slate-700/50 rounded-full overflow-hidden shadow-inner">
<div class="h-full bg-{{ $fillColor }}-500 rounded-full transition-all duration-500" style="width: {{ $fillRate }}%;"></div>
</div>
<span class="text-sm font-black text-{{ $fillColor }}-500 w-8">{{ $fillRate }}%</span>
</div>
</td>
<td class="px-6 py-5 text-right">
<div class="flex items-center justify-end gap-2">
<a href="{{ route('admin.warehouses.replenishments') }}?auto_machine_id={{ $machine->id }}"
@click.stop
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20"
title="{{ __('Quick Replenish') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
</svg>
</a>
<button class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20"
title="{{ __('View Slots') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
</td>
</tr>
@empty
<tr><td colspan="6" class="px-6 py-24 text-center text-slate-400 font-bold italic">{{ __('No machines found') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-6 pt-6 border-t border-slate-50 dark:border-slate-800/50">{{ $machines->links('vendor.pagination.luxury') }}</div>
</div>
</div>
{{-- Detail Mode --}}
<div x-show="viewMode === 'detail'" class="space-y-8 animate-luxury-in" x-cloak>
<!-- Statistics Dashboard -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-stretch">
<!-- Left: Overall Progress -->
<div class="lg:col-span-5 luxury-card rounded-[2.5rem] p-10 flex items-center justify-center relative overflow-hidden group">
<div class="absolute inset-0 bg-gradient-to-br from-cyan-500/5 to-emerald-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-700"></div>
<div class="relative flex flex-col items-center">
<div class="relative w-48 h-48">
<!-- SVG Circle Progress -->
<svg class="w-full h-full -rotate-90 transform" viewBox="0 0 100 100">
<circle class="text-slate-100 dark:text-slate-700/50 stroke-current" stroke-width="10" fill="transparent" r="40" cx="50" cy="50"></circle>
<circle class="text-emerald-500 stroke-current transition-all duration-1000 ease-out" stroke-width="10" stroke-dasharray="251.2" :stroke-dashoffset="251.2 - (251.2 * Math.min(overallFillRate, 100)) / 100" stroke-linecap="round" fill="transparent" r="40" cx="50" cy="50"></circle>
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<span class="text-5xl font-black text-slate-800 dark:text-white tracking-tighter" x-text="overallFillRate + '%'"></span>
<span class="text-sm font-black text-slate-400 uppercase tracking-[0.2em] mt-1" x-text="totalStock + ' / ' + totalCapacity"></span>
</div>
</div>
<div class="mt-6 text-center">
<span class="text-base font-black text-slate-500 uppercase tracking-[0.3em]">{{ __('總庫存量') }}</span>
</div>
</div>
</div>
<!-- Right: Stats Cards -->
<div class="lg:col-span-7 grid grid-cols-1 sm:grid-cols-2 gap-6">
<!-- Machine Info Card -->
<div class="sm:col-span-2 luxury-card rounded-[2rem] p-6 border-slate-200/50 dark:border-slate-800/50 flex items-center justify-between">
<div class="flex items-center gap-6">
<div class="w-16 h-16 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 overflow-hidden">
<template x-if="selectedMachine?.image_urls && selectedMachine?.image_urls[0]">
<img :src="selectedMachine.image_urls[0]" class="w-full h-full object-cover">
</template>
<template x-if="!selectedMachine?.image_urls || !selectedMachine?.image_urls[0]">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
</template>
</div>
<div>
<h2 x-text="selectedMachine?.name" class="text-2xl font-black text-slate-800 dark:text-white tracking-tight leading-tight"></h2>
<span x-text="selectedMachine?.serial_no" class="text-sm font-mono font-bold text-slate-400 uppercase tracking-widest mt-1 block"></span>
</div>
</div>
<div class="hidden sm:flex items-center gap-4">
<div class="text-right">
<span class="text-sm font-black text-slate-400 uppercase tracking-widest block mb-1">{{ __('Location') }}</span>
<span x-text="selectedMachine?.location || '{{ __('No Location') }}'" class="text-base font-bold text-slate-600 dark:text-slate-300"></span>
</div>
<a :href="'{{ route('admin.warehouses.replenishments') }}?auto_machine_id=' + selectedMachine?.id"
class="px-4 py-2.5 rounded-xl text-xs font-black bg-cyan-500/10 text-cyan-500 border border-cyan-500/20 hover:bg-cyan-500 hover:text-white transition-all flex items-center gap-2 uppercase tracking-widest">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" /></svg>
{{ __('Quick Replenish') }}
</a>
</div>
</div> </div>
<div x-show="loading" class="py-20 text-center text-slate-400 font-bold">{{ __('Loading...') }}</div> <!-- Active Slots -->
<div class="luxury-card rounded-[2rem] p-6 border-emerald-500/10 bg-emerald-500/[0.02]">
<div class="flex items-center justify-between mb-4">
<div class="w-10 h-10 rounded-xl bg-emerald-500/10 flex items-center justify-center text-emerald-500">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div>
<span class="text-2xl font-black text-emerald-600 dark:text-emerald-400" x-text="activeSlotsCount"></span>
</div>
<span class="text-sm font-black text-slate-500 uppercase tracking-widest">{{ __('Active Slots') }}</span>
</div>
<div x-show="!loading" class="space-y-3"> <!-- Empty Slots -->
<div class="luxury-card rounded-[2rem] p-6 border-slate-500/10 bg-slate-500/[0.02]">
<div class="flex items-center justify-between mb-4">
<div class="w-10 h-10 rounded-xl bg-slate-500/10 flex items-center justify-center text-slate-500">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
</div>
<span class="text-2xl font-black text-slate-600 dark:text-slate-400" x-text="emptySlotsCount"></span>
</div>
<span class="text-sm font-black text-slate-500 uppercase tracking-widest">{{ __('Empty Slots') }}</span>
</div>
<!-- High Stock -->
<div class="luxury-card rounded-[2rem] p-6 border-cyan-500/10 bg-cyan-500/[0.02] sm:col-span-2">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-xl bg-cyan-500/10 flex items-center justify-center text-cyan-500">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>
</div>
<span class="text-sm font-black text-slate-500 uppercase tracking-widest">{{ __('Stock Level > 50%') }}</span>
</div>
<span class="text-2xl font-black text-cyan-600 dark:text-cyan-400" x-text="highStockSlotsCount"></span>
</div>
</div>
</div>
</div>
<!-- Cabinet Control & Display Toggle -->
<div class="flex items-center justify-between px-4">
<div class="flex items-center gap-6">
<button @click="displayMode = 'table'"
:class="displayMode === 'table' ? 'text-cyan-500 bg-cyan-500/10' : 'text-slate-400 hover:text-slate-600'"
class="flex items-center gap-2 px-4 py-2 rounded-xl transition-all font-black text-sm uppercase tracking-widest">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 6h16M4 10h16M4 14h16M4 18h16" /></svg>
{{ __('Table View') }}
</button>
<button @click="displayMode = 'grid'"
:class="displayMode === 'grid' ? 'text-cyan-500 bg-cyan-500/10' : 'text-slate-400 hover:text-slate-600'"
class="flex items-center gap-2 px-4 py-2 rounded-xl transition-all font-black text-sm uppercase tracking-widest">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" /></svg>
{{ __('Grid View') }}
</button>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full bg-rose-500"></span>
<span class="text-sm font-black text-slate-400 uppercase tracking-widest">{{ __('Expired') }}</span>
</div>
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full bg-amber-500"></span>
<span class="text-sm font-black text-slate-400 uppercase tracking-widest">{{ __('Low Stock') }}</span>
</div>
</div>
</div>
<!-- Grid View -->
<div x-show="displayMode === 'grid'" class="space-y-6">
<div class="luxury-card rounded-[2.5rem] p-8 border-slate-200/50 dark:border-slate-800/50 bg-white/30 dark:bg-slate-900/40 backdrop-blur-xl relative overflow-hidden min-h-[400px]">
<div x-show="loading" class="absolute inset-0 bg-white/60 dark:bg-slate-900/60 backdrop-blur-md z-20 flex items-center justify-center transition-all duration-500">
<div class="w-12 h-12 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin"></div>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-6 relative z-10" x-show="!loading">
<template x-for="slot in slots" :key="slot.id"> <template x-for="slot in slots" :key="slot.id">
<div class="p-4 bg-slate-50 dark:bg-slate-900/50 rounded-xl border border-slate-100 dark:border-slate-800"> <div :class="getSlotColorClass(slot)"
<div class="flex items-center justify-between"> class="min-h-[260px] rounded-[2.5rem] p-5 flex flex-col items-center justify-center border-2 transition-all duration-500 group relative hover:scale-105 hover:-translate-y-1 hover:shadow-xl">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-slate-200 dark:bg-slate-800 flex items-center justify-center text-xs font-black text-slate-500" x-text="slot.slot_no"></div> <div class="absolute top-4 left-5 px-3 py-1 rounded-xl bg-slate-900/5 dark:bg-white/10 backdrop-blur-md">
<div> <span class="text-sm font-black tracking-tighter text-slate-800 dark:text-white" x-text="slot.slot_no"></span>
<p class="font-extrabold text-slate-800 dark:text-slate-100 text-sm" x-text="slot.product?.name || '{{ __('Empty') }}'"></p> </div>
<p class="text-xs font-bold text-slate-400 mt-0.5" x-show="slot.expiry_date" x-text="'{{ __('Expiry') }}: ' + (slot.expiry_date || '')"></p>
</div> <div class="relative w-16 h-16 mb-4 mt-2">
<div class="absolute inset-0 rounded-2xl bg-white/20 dark:bg-slate-900/40 backdrop-blur-xl border border-white/30 overflow-hidden shadow-inner">
<template x-if="slot.product && slot.product.image_url">
<img :src="slot.product.image_url" class="w-full h-full object-cover">
</template>
</div> </div>
<div class="text-right"> </div>
<div class="flex items-center gap-1">
<span class="text-lg font-black" :class="slot.stock <= 0 ? 'text-rose-500' : (slot.stock <= (slot.max_stock * 0.2) ? 'text-amber-500' : 'text-emerald-500')" x-text="slot.stock"></span> <div class="text-center w-full">
<span class="text-xs font-bold text-slate-400">/ <span x-text="slot.max_stock"></span></span> <div class="text-base font-black truncate w-full opacity-90 tracking-tight" x-text="slot.product?.name || '{{ __('Empty') }}'"></div>
</div> <div class="flex items-baseline justify-center gap-1 mt-2">
<span class="text-2xl font-black tracking-tighter" x-text="slot.stock"></span>
<span class="text-sm font-black opacity-30">/</span>
<span class="text-sm font-bold opacity-50" x-text="slot.max_stock"></span>
</div> </div>
<div class="text-sm font-black tracking-widest opacity-40 uppercase mt-2" x-text="slot.expiry_date || '----/--/--'"></div>
</div> </div>
</div> </div>
</template> </template>
<div x-show="slots.length === 0" class="py-10 text-center text-slate-400 font-bold">{{ __('No slot data') }}</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Table View -->
<div x-show="displayMode === 'table'" class="animate-luxury-in" x-cloak>
<div class="luxury-card rounded-[2.5rem] overflow-hidden border-slate-200/50 dark:border-slate-800/50 shadow-xl">
<table class="w-full text-left">
<thead>
<tr class="bg-slate-50 dark:bg-slate-900/50">
<th class="px-8 py-5 text-sm font-black text-slate-400 uppercase tracking-[0.2em]">{{ __('Slot') }}</th>
<th class="px-8 py-5 text-sm font-black text-slate-400 uppercase tracking-[0.2em]">{{ __('Product') }}</th>
<th class="px-8 py-5 text-sm font-black text-slate-400 uppercase tracking-[0.2em]">{{ __('Barcode') }}</th>
<th class="px-8 py-5 text-sm font-black text-slate-400 uppercase tracking-[0.2em] text-center">{{ __('Stock Level') }} / {{ __('Max Stock') }}</th>
<th class="px-8 py-5 text-sm font-black text-slate-400 uppercase tracking-[0.2em] text-right">{{ __('Sale Price') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/40">
<template x-for="slot in slots" :key="slot.id">
<tr class="hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-colors">
<td class="px-8 py-6">
<span class="px-3 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-800 text-sm font-black text-slate-600 dark:text-slate-300" x-text="slot.slot_no"></span>
</td>
<td class="px-8 py-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-xl bg-slate-50 dark:bg-slate-900 border border-slate-100 dark:border-slate-800 flex items-center justify-center overflow-hidden">
<template x-if="slot.product?.image_url">
<img :src="slot.product.image_url" class="w-full h-full object-cover">
</template>
</div>
<div>
<div class="text-base font-black text-slate-800 dark:text-slate-200" x-text="slot.product?.name || '{{ __('Empty Slot') }}'"></div>
<div class="text-sm font-bold text-slate-400 uppercase tracking-widest mt-1" x-text="slot.expiry_date ? '{{ __('Expiry') }}: ' + slot.expiry_date : ''"></div>
</div>
</div>
</td>
<td class="px-8 py-6">
<span class="text-sm font-mono font-bold text-slate-400" x-text="slot.product?.barcode || '--'"></span>
</td>
<td class="px-8 py-6 text-center">
<div class="flex items-center justify-center gap-2">
<span class="text-lg font-black" :class="slot.stock <= 2 ? 'text-rose-500' : 'text-slate-700 dark:text-slate-200'" x-text="slot.stock"></span>
<span class="text-xs font-bold text-slate-300">/</span>
<span class="text-sm font-bold text-slate-400" x-text="slot.max_stock"></span>
</div>
</td>
<td class="px-8 py-6 text-right">
<span class="text-base font-black text-emerald-500" x-text="'$' + (slot.price || 0)"></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
@endsection @endsection

View File

@ -0,0 +1,26 @@
{{-- 指派人員 Modal --}}
<div x-show="showAssignModal" class="fixed inset-0 z-[200] overflow-y-auto" x-cloak>
<div class="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div x-show="showAssignModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-200" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div x-show="showAssignModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100" x-transition:leave="ease-in duration-200" class="inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white dark:bg-slate-900 rounded-3xl shadow-2xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-8 border border-slate-100 dark:border-white/10">
<div class="mb-8">
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight uppercase">{{ __('Assign Personnel') }}</h3>
<p class="text-sm font-bold text-slate-500 mt-2">{{ __('Select a team member to handle this replenishment order') }}</p>
</div>
<div class="space-y-3 mb-8">
<label class="block text-xs font-black text-slate-500 uppercase tracking-[0.15em] pl-1">{{ __('Select Personnel') }}</label>
<select x-model="assignUserId" class="luxury-input w-full px-6 py-3">
<option value="">{{ __('Select Personnel') }}</option>
@foreach($users as $u)
<option value="{{ $u->id }}">{{ $u->name }}</option>
@endforeach
</select>
</div>
<div class="flex justify-end gap-3">
<button type="button" @click="showAssignModal = false" class="btn-luxury-ghost px-6">{{ __('Cancel') }}</button>
<button type="button" @click="executeAssign()" :disabled="loading || !assignUserId" class="btn-luxury-primary px-8">{{ __('Confirm') }}</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,205 @@
{{-- 一鍵補貨 Modal --}}
<div x-show="showAutoModal" class="fixed inset-0 z-[110] overflow-y-auto" style="display: none;" x-cloak>
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div x-show="showAutoModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-200" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm" @click="showAutoModal = false"></div>
<span class="hidden sm:inline-block sm:align-middle sm:min-h-screen">&#8203;</span>
<div x-show="showAutoModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100" x-transition:leave="ease-in duration-200" class="relative inline-flex flex-col align-bottom bg-white dark:bg-slate-900 rounded-[2.5rem] text-left shadow-2xl transform sm:my-8 sm:align-middle sm:max-w-3xl w-full border border-slate-100 dark:border-slate-800 z-10 max-h-[90vh]" @click.stop>
{{-- Header --}}
<div class="px-10 py-8 pb-4 flex items-center justify-between">
<div>
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight leading-none mb-3">
<svg class="w-6 h-6 inline-block text-cyan-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" /></svg>
{{ __('Auto Replenishment') }}
</h3>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Automatically calculate replenishment needs based on machine capacity') }}</p>
</div>
<button @click="showAutoModal = false" class="p-2.5 rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-slate-600 transition-all border border-slate-100 dark:border-slate-700 shadow-sm">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div class="flex-1 overflow-y-auto px-10 py-2 custom-scrollbar">
{{-- Step 1: 選擇倉庫與機台 --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div class="space-y-3">
<label class="block text-xs font-black text-slate-500 uppercase tracking-[0.15em] pl-1">{{ __('Source Warehouse') }} <span class="text-rose-500">*</span></label>
<x-searchable-select name="auto_warehouse_id" :options="$warehouses ?? []" :placeholder="__('Select Warehouse')" class="w-full" @change="autoWarehouseId = $event.target.value; if(autoPreviewLoaded) loadAutoPreview()" />
</div>
<div class="space-y-3">
<label class="block text-xs font-black text-slate-500 uppercase tracking-[0.15em] pl-1">{{ __('Target Machine') }} <span class="text-rose-500">*</span></label>
<x-searchable-select name="auto_machine_id" :options="$machines->map(fn($m) => (object)['id' => $m->id, 'name' => $m->name . ' (' . $m->serial_no . ')'])" :placeholder="__('Select Machine')" class="w-full" @change="autoMachineId = $event.target.value; if(autoPreviewLoaded) loadAutoPreview()" />
</div>
</div>
<div class="mb-6">
<label class="block text-xs font-black text-slate-500 uppercase tracking-[0.15em] pl-1 mb-3">{{ __('Note') }}</label>
<input type="text" x-model="autoNote" class="luxury-input w-full px-6 py-3 bg-slate-50/50 dark:bg-slate-900/50" placeholder="{{ __('Optional remarks...') }}">
</div>
{{-- 載入預覽按鈕 --}}
<div x-show="!autoPreviewLoaded" class="text-center py-6">
<button type="button" @click="loadAutoPreview()" :disabled="autoLoading || !autoWarehouseId || !autoMachineId"
class="btn-luxury-primary px-10 py-3 relative">
<span :class="autoLoading ? 'opacity-0' : ''">{{ __('Calculate Replenishment') }}</span>
<template x-if="autoLoading"><div class="absolute inset-0 flex items-center justify-center"><div class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div></div></template>
</button>
</div>
{{-- 庫存不足警告 --}}
<template x-if="autoPreviewLoaded && autoHasWarning">
<div class="mb-6 p-4 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 rounded-2xl">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-amber-500 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" /></svg>
<div>
<p class="text-sm font-black text-amber-700 dark:text-amber-400">{{ __('Warehouse stock insufficient warning') }}</p>
</div>
</div>
</div>
</template>
{{-- 補貨清單預覽 --}}
<template x-if="autoPreviewLoaded && autoSlots.length > 0">
<div class="space-y-4 pb-6">
<div class="flex items-center justify-between px-1">
<h4 class="text-sm font-black text-slate-800 dark:text-white uppercase tracking-widest">{{ __('Slots need replenishment') }}</h4>
<span class="text-[10px] font-black text-cyan-500 bg-cyan-500/10 px-2 py-0.5 rounded-md uppercase" x-text="autoSlots.length + ' {{ __("Items") }}'"></span>
</div>
{{-- 桌面版Table 佈局 --}}
<div class="hidden md:block overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-4 py-3 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800">{{ __('Slot') }}</th>
<th class="px-4 py-3 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800">{{ __('Product') }}</th>
<th class="px-4 py-3 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Stock / Capacity') }}</th>
<th class="px-4 py-3 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Fill Quantity') }}</th>
<th class="px-4 py-3 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Warehouse Stock') }}</th>
<th class="w-10 border-b border-slate-100 dark:border-slate-800"></th>
</tr>
</thead>
<tbody>
<template x-for="(slot, idx) in autoSlots" :key="slot.slot_no">
<tr class="hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all border-b border-slate-50 dark:border-slate-800/30 last:border-0">
<td class="px-4 py-2.5 font-mono font-bold text-slate-700 dark:text-slate-300" x-text="slot.slot_no"></td>
<td class="px-4 py-2.5">
<div class="flex items-center gap-2">
<template x-if="slot.image_url"><img :src="slot.image_url" class="w-8 h-8 rounded-lg object-cover border border-slate-100 dark:border-slate-800"></template>
<span class="text-sm font-bold text-slate-700 dark:text-slate-200 truncate max-w-[150px]" x-text="slot.product_name"></span>
</div>
</td>
<td class="px-4 py-2.5 text-center font-mono font-bold text-slate-500 whitespace-nowrap">
<span class="text-slate-700 dark:text-slate-300" x-text="slot.current_stock"></span>
<span class="text-slate-300 dark:text-slate-600 mx-0.5">/</span>
<span class="text-slate-400" x-text="slot.max_stock"></span>
</td>
<td class="px-4 py-2.5 text-center flex justify-center">
<div class="flex items-center gap-1 bg-slate-100/50 dark:bg-slate-900/50 p-1 rounded-xl border border-slate-200/50 dark:border-slate-800/50 w-fit">
<button type="button" @click="slot.quantity > 1 ? slot.quantity-- : null" class="p-1 text-slate-400 hover:text-cyan-500 transition-colors">
<svg class="w-2.5 h-2.5 stroke-[3]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" /></svg>
</button>
<input type="number" x-model.number="slot.quantity" min="1" :max="slot.max_stock - slot.current_stock" class="w-8 bg-transparent border-none p-0 text-center font-mono font-bold text-slate-800 dark:text-slate-200 focus:ring-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none text-xs" placeholder="0">
<button type="button" @click="slot.quantity < (slot.max_stock - slot.current_stock) ? slot.quantity++ : null" class="p-1 text-slate-400 hover:text-cyan-500 transition-colors">
<svg class="w-2.5 h-2.5 stroke-[3]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
</button>
</div>
</td>
<td class="px-4 py-2.5 text-center">
<div class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-black tracking-tight"
:class="getActualFillable(idx) < slot.quantity ? 'bg-rose-500/10 text-rose-500' : 'bg-emerald-500/10 text-emerald-500'">
<span x-text="getActualFillable(idx)"></span>
</div>
</td>
<td class="px-2 py-2.5 text-center">
<button type="button" @click="removeAutoSlot(idx)" class="p-1.5 rounded-lg text-slate-400 hover:text-rose-500 hover:bg-rose-500/10 transition-all" title="{{ __('Remove') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
{{-- 手機版Card 佈局 --}}
<div class="md:hidden space-y-3">
<template x-for="(slot, idx) in autoSlots" :key="'m' + slot.slot_no">
<div class="relative bg-slate-50/50 dark:bg-slate-800/30 rounded-2xl border border-slate-100 dark:border-slate-800/50 p-4 transition-all">
{{-- 刪除按鈕 --}}
<button type="button" @click="removeAutoSlot(idx)" class="absolute top-3 right-3 p-1.5 rounded-lg text-slate-400 hover:text-rose-500 hover:bg-rose-500/10 transition-all" title="{{ __('Remove') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
{{-- 上半部:商品資訊 --}}
<div class="flex items-center gap-3 mb-3 pr-8">
<div class="flex-shrink-0 w-7 h-7 rounded-lg bg-cyan-500/10 flex items-center justify-center">
<span class="text-xs font-black text-cyan-500 font-mono" x-text="slot.slot_no"></span>
</div>
<template x-if="slot.image_url">
<img :src="slot.image_url" class="w-10 h-10 rounded-xl object-cover border border-slate-200 dark:border-slate-700 flex-shrink-0">
</template>
<span class="text-sm font-bold text-slate-700 dark:text-slate-200 truncate" x-text="slot.product_name"></span>
</div>
{{-- 下半部:數據區 --}}
<div class="grid grid-cols-3 gap-2">
{{-- 庫存 / 上限 --}}
<div class="text-center bg-white/60 dark:bg-slate-900/40 rounded-xl py-2 px-1">
<div class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Stock / Capacity') }}</div>
<div class="font-mono font-bold text-sm">
<span class="text-slate-700 dark:text-slate-300" x-text="slot.current_stock"></span>
<span class="text-slate-300 dark:text-slate-600">/</span>
<span class="text-slate-400" x-text="slot.max_stock"></span>
</div>
</div>
{{-- 補貨數量 --}}
<div class="text-center bg-white/60 dark:bg-slate-900/40 rounded-xl py-2 px-1">
<div class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Fill Quantity') }}</div>
<div class="flex items-center justify-center gap-1">
<button type="button" @click="slot.quantity > 1 ? slot.quantity-- : null" class="p-0.5 text-slate-400 hover:text-cyan-500 transition-colors">
<svg class="w-3 h-3 stroke-[3]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" /></svg>
</button>
<input type="number" x-model.number="slot.quantity" min="1" :max="slot.max_stock - slot.current_stock" class="w-8 bg-transparent border-none p-0 text-center font-mono font-bold text-sm text-slate-800 dark:text-slate-200 focus:ring-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" placeholder="0">
<button type="button" @click="slot.quantity < (slot.max_stock - slot.current_stock) ? slot.quantity++ : null" class="p-0.5 text-slate-400 hover:text-cyan-500 transition-colors">
<svg class="w-3 h-3 stroke-[3]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
</button>
</div>
</div>
{{-- 倉庫數量 --}}
<div class="text-center bg-white/60 dark:bg-slate-900/40 rounded-xl py-2 px-1">
<div class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Warehouse Stock') }}</div>
<div class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-black tracking-tight"
:class="getActualFillable(idx) < slot.quantity ? 'bg-rose-500/10 text-rose-500' : 'bg-emerald-500/10 text-emerald-500'">
<span x-text="getActualFillable(idx)"></span>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
{{-- Footer --}}
<div class="px-10 py-6 border-t border-slate-100 dark:border-slate-800/50 flex items-center justify-end gap-3">
<button type="button" @click="showAutoModal = false" class="btn-luxury-ghost px-6">{{ __('Cancel') }}</button>
<template x-if="autoPreviewLoaded">
<button type="button" @click="loadAutoPreview()" :disabled="autoLoading"
class="px-6 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 font-black text-xs uppercase tracking-widest hover:bg-slate-50 dark:hover:bg-slate-800 transition-all flex items-center gap-2">
<svg class="w-3.5 h-3.5" :class="autoLoading ? 'animate-spin' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" /></svg>
<span>{{ __('Recalculate') }}</span>
</button>
</template>
<template x-if="autoPreviewLoaded && autoSlots.length > 0">
<button type="button" @click="submitAutoReplenishment()" :disabled="autoLoading" class="btn-luxury-primary px-12 relative">
<span :class="autoLoading ? 'opacity-0' : ''">{{ __('Create') }}</span>
<template x-if="autoLoading"><div class="absolute inset-0 flex items-center justify-center"><div class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div></div></template>
</button>
</template>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,32 @@
{{-- 取消確認 Modal --}}
<div x-show="showCancelModal" class="fixed inset-0 z-[200] overflow-y-auto" x-cloak>
<div class="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div x-show="showCancelModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-200" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div x-show="showCancelModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100" x-transition:leave="ease-in duration-200" class="inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white dark:bg-slate-900 rounded-3xl shadow-2xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-8 border border-slate-100 dark:border-white/10">
<div class="sm:flex sm:items-start">
<div class="flex items-center justify-center flex-shrink-0 w-14 h-14 mx-auto bg-rose-50 dark:bg-rose-500/10 rounded-2xl sm:mx-0">
<svg class="w-8 h-8 text-rose-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" /></svg>
</div>
<div class="mt-4 text-center sm:mt-0 sm:ms-6 sm:text-left">
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight uppercase">{{ __('Cancel Order') }}</h3>
<div class="mt-3">
<p class="text-base font-bold text-slate-500 dark:text-slate-400 leading-relaxed">
{{ __('Are you sure you want to cancel this order? If the order has been prepared, stock will be returned to the warehouse.') }}
</p>
</div>
</div>
</div>
<div class="mt-8 sm:mt-10 sm:flex sm:flex-row-reverse gap-3">
<button type="button" @click="executeCancel()" :disabled="loading"
class="inline-flex justify-center w-full px-6 py-3 text-sm font-black text-white bg-rose-500 rounded-xl hover:bg-rose-600 shadow-lg shadow-rose-200 dark:shadow-none hover:scale-[1.02] active:scale-[0.98] sm:w-auto uppercase tracking-widest transition-all">
{{ __('Confirm Cancel') }}
</button>
<button type="button" @click="showCancelModal = false"
class="inline-flex justify-center w-full px-6 py-3 mt-3 text-sm font-black text-slate-700 dark:text-slate-200 bg-slate-100 dark:bg-slate-800 rounded-xl hover:bg-slate-200 dark:hover:bg-slate-700 sm:mt-0 sm:w-auto uppercase tracking-widest transition-all">
{{ __('Go Back') }}
</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,81 @@
{{-- 新建補貨單 Modal --}}
<div x-show="showReplenishmentModal" class="fixed inset-0 z-[110] overflow-y-auto" style="display: none;" x-cloak>
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div x-show="showReplenishmentModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity" @click="showReplenishmentModal = false"></div>
<span class="hidden sm:inline-block sm:align-middle sm:min-h-screen" aria-hidden="true">&#8203;</span>
<div x-show="showReplenishmentModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4 sm:scale-95" x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100 sm:scale-100" x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95" class="relative inline-flex flex-col align-bottom bg-white dark:bg-slate-900 rounded-[2.5rem] text-left shadow-2xl transform sm:my-8 sm:align-middle sm:max-w-3xl w-full border border-slate-100 dark:border-slate-800 z-10 max-h-[90vh]" @click.stop>
<div class="px-10 py-8 pb-4 flex items-center justify-between">
<div>
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight leading-none mb-3">{{ __('New Replenishment') }}</h3>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Create stock replenishment for specific machines') }}</p>
</div>
<button @click="showReplenishmentModal = false" class="p-2.5 rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-slate-600 transition-all border border-slate-100 dark:border-slate-700 shadow-sm">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div class="flex-1 overflow-y-auto px-10 py-2 custom-scrollbar overflow-x-visible">
<form id="replenishmentForm" action="{{ route('admin.warehouses.replenishments.store') }}" method="POST" class="space-y-6 pb-20">
@csrf
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-3">
<label class="block text-xs font-black text-slate-500 uppercase tracking-[0.15em] pl-1">{{ __('Source Warehouse') }} <span class="text-rose-500">*</span></label>
<x-searchable-select name="warehouse_id" :options="$warehouses ?? []" :placeholder="__('Select Warehouse')" class="w-full" required x-model="fromId" @change="fromId = $event.target.value; fetchStock()" />
</div>
<div class="space-y-3">
<label class="block text-xs font-black text-slate-500 uppercase tracking-[0.15em] pl-1">{{ __('Target Machine') }} <span class="text-rose-500">*</span></label>
<x-searchable-select name="machine_id" :options="$machines->map(fn($m) => (object)['id' => $m->id, 'name' => $m->name . ' (' . $m->serial_no . ')'])" :placeholder="__('Select Machine')" class="w-full" required />
</div>
</div>
<div class="space-y-3">
<label class="block text-xs font-black text-slate-500 uppercase tracking-[0.15em] pl-1">{{ __('Note') }}</label>
<input type="text" name="note" class="luxury-input w-full px-6 py-4 bg-slate-50/50 dark:bg-slate-900/50" placeholder="{{ __('Optional remarks...') }}">
</div>
<div class="space-y-5">
<div class="flex items-center justify-between pl-1">
<label class="text-xs font-black text-slate-500 uppercase tracking-[0.15em]">{{ __('Replenishment Items') }} <span class="text-rose-500">*</span></label>
<button type="button" @click="addItem()" class="text-xs font-black text-cyan-500 hover:text-cyan-400 uppercase tracking-widest flex items-center gap-1.5 transition-colors">
<svg class="w-4 h-4 stroke-[3]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
{{ __('Add Product') }}
</button>
</div>
<div class="space-y-3">
<template x-for="(item, index) in items" :key="index">
<div class="group flex items-center gap-3 p-3 bg-slate-50/50 dark:bg-slate-900/30 rounded-2xl border border-slate-100 dark:border-slate-800/50 hover:border-cyan-500/30 transition-all">
<div class="w-20">
<input type="number" :name="'items['+index+'][slot_no]'" x-model.number="item.slot_no" min="1" required class="luxury-input w-full px-3 py-2 text-center text-sm font-mono font-bold [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" placeholder="{{ __('Slot') }}">
</div>
<div class="w-px h-6 bg-slate-200 dark:bg-slate-800/50"></div>
<div class="flex-1 min-w-[200px] relative" :id="'product-select-wrapper-' + index" x-init="$nextTick(() => updateProductSelects())">
<div x-show="isLoadingProducts" class="absolute inset-0 bg-white/50 dark:bg-slate-900/50 flex items-center justify-center z-10 rounded-xl">
<div class="w-4 h-4 border-2 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin"></div>
</div>
</div>
<div class="w-px h-6 bg-slate-200 dark:bg-slate-800/50"></div>
<div class="flex items-center gap-1 bg-slate-100/50 dark:bg-slate-900/50 p-1 rounded-xl border border-slate-200/50 dark:border-slate-800/50">
<button type="button" @click="item.quantity > 1 ? item.quantity-- : null" class="p-1.5 text-slate-400 hover:text-cyan-500 transition-colors">
<svg class="w-3 h-3 stroke-[3]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" /></svg>
</button>
<input type="number" :name="'items['+index+'][quantity]'" x-model.number="item.quantity" min="1" required class="w-8 bg-transparent border-none p-0 text-center font-mono font-bold text-slate-800 dark:text-slate-200 focus:ring-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" placeholder="0">
<button type="button" @click="item.quantity++" class="p-1.5 text-slate-400 hover:text-cyan-500 transition-colors">
<svg class="w-3 h-3 stroke-[3]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
</button>
</div>
<button type="button" @click="removeItem(index)" class="p-2 text-slate-300 hover:text-rose-500 transition-all" x-show="items.length > 1">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
</template>
</div>
</div>
</form>
</div>
<div class="px-10 py-6 border-t border-slate-100 dark:border-slate-800/50 flex items-center justify-end gap-4">
<button type="button" @click="showReplenishmentModal = false" class="btn-luxury-ghost px-8">{{ __('Cancel') }}</button>
<button type="button" @click="submitReplenishment()" :disabled="loading" class="btn-luxury-primary px-12 relative flex items-center justify-center">
<span :class="loading ? 'opacity-0' : ''">{{ __('Create') }}</span>
<template x-if="loading"><div class="absolute inset-0 flex items-center justify-center"><svg class="animate-spin h-5 w-5 text-white" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg></div></template>
</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,131 @@
{{-- 補貨單詳情 Slide-over --}}
<div x-show="showOrderDetails" class="fixed inset-0 z-[120] overflow-hidden" style="display: none;" x-cloak>
<div class="absolute inset-0 overflow-hidden">
<div x-show="showOrderDetails" x-transition:enter="ease-in-out duration-500" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="ease-in-out duration-500" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" @click="showOrderDetails = false"></div>
<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
<div x-show="showOrderDetails" x-transition:enter="transform transition ease-in-out duration-500 sm:duration-700" x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0" x-transition:leave="transform transition ease-in-out duration-500 sm:duration-700" x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full" class="pointer-events-auto w-screen max-w-md">
<div class="flex h-full flex-col overflow-y-scroll bg-white dark:bg-slate-900 shadow-2xl border-l border-slate-100 dark:border-slate-800">
{{-- Header --}}
<div class="bg-slate-50/50 dark:bg-slate-900/50 px-6 py-8 border-b border-slate-100 dark:border-slate-800">
<div class="flex items-start justify-between">
<h2 class="text-xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Replenishment Details') }}</h2>
<button type="button" @click="showOrderDetails = false" class="rounded-full bg-white dark:bg-slate-800 p-2 text-slate-400 hover:text-slate-500 border border-slate-100 dark:border-slate-700 shadow-sm transition-all">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<template x-if="activeOrder">
<div class="mt-4 flex flex-col gap-1">
<span class="text-2xl font-black text-cyan-600 dark:text-cyan-400 font-mono tracking-tighter" x-text="activeOrder.order_no"></span>
<div class="flex items-center gap-2">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]" x-text="activeOrder.warehouse_name"></span>
<svg class="w-3 h-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M13 7l5 5m0 0l-5 5m5-5H6" /></svg>
<span class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.2em]" x-text="activeOrder.machine_name"></span>
</div>
</div>
</template>
</div>
{{-- Content --}}
<div class="relative flex-1 px-6 py-8">
<div x-show="detailsLoading" class="absolute inset-0 bg-white/60 dark:bg-slate-900/60 backdrop-blur-[2px] z-10 flex items-center justify-center">
<div class="w-10 h-10 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin"></div>
</div>
<template x-if="activeOrder">
<div class="space-y-10">
{{-- Info Grid --}}
<div class="grid grid-cols-2 gap-6 p-6 bg-slate-50/50 dark:bg-slate-800/30 rounded-[2rem] border border-slate-100 dark:border-slate-800">
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Status') }}</p>
<span class="inline-flex items-center px-2 py-0.5 rounded-lg text-[10px] font-black uppercase tracking-widest"
:class="{
'bg-amber-500/10 text-amber-500 border border-amber-500/20': activeOrder.status === 'pending',
'bg-cyan-500/10 text-cyan-500 border border-cyan-500/20': activeOrder.status === 'prepared',
'bg-indigo-500/10 text-indigo-500 border border-indigo-500/20': activeOrder.status === 'delivering',
'bg-emerald-500/10 text-emerald-500 border border-emerald-500/20': activeOrder.status === 'completed',
'bg-slate-500/10 text-slate-500 border border-slate-500/20': activeOrder.status === 'cancelled',
}"
x-text="{'pending':'{{ __("Pending") }}','prepared':'{{ __("Prepared") }}','delivering':'{{ __("Delivering") }}','completed':'{{ __("Completed") }}','cancelled':'{{ __("Cancelled") }}'}[activeOrder.status]"></span>
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Assigned To') }}</p>
<p class="text-xs font-bold" :class="activeOrder.assignee_name ? 'text-slate-700 dark:text-slate-300' : 'text-slate-400'" x-text="activeOrder.assignee_name || '{{ __("Unassigned") }}'"></p>
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Created By') }}</p>
<p class="text-xs font-bold text-slate-700 dark:text-slate-300" x-text="activeOrder.creator_name"></p>
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Created At') }}</p>
<p class="text-[11px] font-mono font-bold text-slate-600 dark:text-slate-400" x-text="activeOrder.created_at"></p>
</div>
<template x-if="activeOrder.completed_at">
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Completed At') }}</p>
<p class="text-[11px] font-mono font-bold text-emerald-600 dark:text-emerald-400" x-text="activeOrder.completed_at"></p>
</div>
</template>
<template x-if="activeOrder.note">
<div class="col-span-2 space-y-1 pt-2 border-t border-slate-200/50 dark:border-slate-700/50">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Note') }}</p>
<p class="text-xs font-bold text-slate-600 dark:text-slate-400 italic" x-text="activeOrder.note"></p>
</div>
</template>
</div>
{{-- Action Buttons --}}
<template x-if="activeOrder.status !== 'completed' && activeOrder.status !== 'cancelled'">
<div class="flex flex-wrap gap-2">
<button type="button" @click="openAssignModal(activeOrder.id)" class="px-4 py-2 rounded-xl text-xs font-black bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all border border-slate-200 dark:border-slate-700">
{{ __('Assign Personnel') }}
</button>
<template x-if="activeOrder.status === 'pending'">
<button type="button" @click="advanceStatus(activeOrder.id, 'prepared')" class="px-4 py-2 rounded-xl text-xs font-black bg-cyan-500/10 text-cyan-500 border border-cyan-500/20 hover:bg-cyan-500 hover:text-white transition-all">{{ __('Confirm Prepare') }}</button>
</template>
<template x-if="activeOrder.status === 'prepared'">
<button type="button" @click="advanceStatus(activeOrder.id, 'delivering')" class="px-4 py-2 rounded-xl text-xs font-black bg-indigo-500/10 text-indigo-500 border border-indigo-500/20 hover:bg-indigo-500 hover:text-white transition-all">{{ __('Start Delivery') }}</button>
</template>
<template x-if="activeOrder.status === 'delivering'">
<button type="button" @click="advanceStatus(activeOrder.id, 'completed')" class="px-4 py-2 rounded-xl text-xs font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 hover:bg-emerald-500 hover:text-white transition-all">{{ __('Confirm Complete') }}</button>
</template>
<button type="button" @click="confirmCancel(activeOrder.id)" class="px-4 py-2 rounded-xl text-xs font-black bg-rose-500/10 text-rose-500 border border-rose-500/20 hover:bg-rose-500 hover:text-white transition-all">{{ __('Cancel Order') }}</button>
</div>
</template>
{{-- Items List --}}
<div class="space-y-5">
<div class="flex items-center justify-between px-1">
<h3 class="text-sm font-black text-slate-800 dark:text-white uppercase tracking-widest">{{ __('Replenishment Items') }}</h3>
<span class="text-[10px] font-black text-cyan-500 bg-cyan-500/10 px-2 py-0.5 rounded-md uppercase" x-text="activeItems.length + ' {{ __("Items") }}'"></span>
</div>
<div class="space-y-3">
<template x-for="(item, idx) in activeItems" :key="idx">
<div class="group flex items-center gap-4 p-4 bg-white dark:bg-slate-900 rounded-2xl border border-slate-100 dark:border-slate-800 hover:border-cyan-500/30 transition-all">
<div class="w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center font-mono font-bold text-slate-500 border border-slate-200 dark:border-slate-700" x-text="item.slot_no"></div>
<div class="w-12 h-12 rounded-xl bg-slate-50 dark:bg-slate-800 flex items-center justify-center overflow-hidden border border-slate-100 dark:border-slate-800 group-hover:scale-105 transition-transform">
<template x-if="item.image_url"><img :src="item.image_url" class="w-full h-full object-cover"></template>
<template x-if="!item.image_url"><svg class="w-6 h-6 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg></template>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-black text-slate-800 dark:text-slate-100 truncate tracking-tight" x-text="item.product_name"></p>
<template x-if="item.current_stock !== null && item.max_stock">
<p class="text-[10px] font-bold text-slate-400 mt-0.5">
{{ __('Stock') }}: <span class="text-slate-600 dark:text-slate-300 font-mono" x-text="item.current_stock"></span>
/ <span class="font-mono" x-text="item.max_stock"></span>
</p>
</template>
</div>
<div class="text-right">
<p class="text-base font-black text-cyan-600 dark:text-cyan-400 font-mono tracking-tighter" x-text="'x' + item.quantity"></p>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -10,7 +10,6 @@
:options="$warehouses" :options="$warehouses"
:selected="request('warehouse_id')" :selected="request('warehouse_id')"
:placeholder="__('All Warehouses')" :placeholder="__('All Warehouses')"
x-on:change="handleFilterSubmit('movements')"
/> />
</div> </div>
@ -19,7 +18,6 @@
name="type" name="type"
:selected="request('type')" :selected="request('type')"
:placeholder="__('All Types')" :placeholder="__('All Types')"
x-on:change="handleFilterSubmit('movements')"
> >
@foreach(\App\Models\Warehouse\StockMovement::TYPE_LABELS as $key => $label) @foreach(\App\Models\Warehouse\StockMovement::TYPE_LABELS as $key => $label)
<option value="{{ $key }}" {{ request('type') === $key ? 'selected' : '' }} data-title="{{ __($label) }}"> <option value="{{ $key }}" {{ request('type') === $key ? 'selected' : '' }} data-title="{{ __($label) }}">
@ -45,11 +43,9 @@
if (selectedDates.length === 2) { if (selectedDates.length === 2) {
$refs.startDate.value = instance.formatDate(selectedDates[0], 'Y-m-d H:i'); $refs.startDate.value = instance.formatDate(selectedDates[0], 'Y-m-d H:i');
$refs.endDate.value = instance.formatDate(selectedDates[1], 'Y-m-d H:i'); $refs.endDate.value = instance.formatDate(selectedDates[1], 'Y-m-d H:i');
handleFilterSubmit('movements');
} else if (selectedDates.length === 0) { } else if (selectedDates.length === 0) {
$refs.startDate.value = ''; $refs.startDate.value = '';
$refs.endDate.value = ''; $refs.endDate.value = '';
handleFilterSubmit('movements');
} }
} }
})"> })">
@ -62,6 +58,30 @@
value="{{ request('start_date') && request('end_date') ? request('start_date') . ' 至 ' . request('end_date') : (request('start_date') ?: '') }}" value="{{ request('start_date') && request('end_date') ? request('start_date') . ' 至 ' . request('end_date') : (request('start_date') ?: '') }}"
placeholder="{{ __('Select Date Range') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-full cursor-pointer"> placeholder="{{ __('Select Date Range') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-full cursor-pointer">
</div> </div>
<div class="flex items-center gap-4">
<button type="submit" class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 group" title="{{ __('Search') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<button type="button"
@click="
$el.closest('form').querySelectorAll('input[type=text], input[type=hidden]:not([name=tab])').forEach(i => i.value = '');
$el.closest('form').querySelectorAll('select').forEach(s => {
s.value = ' ';
const instance = window.HSSelect.getInstance(s);
if (instance) instance.setValue(' ');
});
handleFilterSubmit('movements');
"
class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-700 group" title="{{ __('Reset') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div> </div>
</form> </form>
@ -89,7 +109,7 @@
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors"> <span class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">
{{ $mv->product?->name }} {{ $mv->product?->localized_name }}
</span> </span>
<div class="flex items-center gap-2 mt-0.5"> <div class="flex items-center gap-2 mt-0.5">
<span class="text-xs font-bold text-slate-500 dark:text-slate-400 tracking-wide"> <span class="text-xs font-bold text-slate-500 dark:text-slate-400 tracking-wide">

View File

@ -34,11 +34,9 @@
if (selectedDates.length === 2) { if (selectedDates.length === 2) {
$refs.startDate.value = instance.formatDate(selectedDates[0], 'Y-m-d H:i'); $refs.startDate.value = instance.formatDate(selectedDates[0], 'Y-m-d H:i');
$refs.endDate.value = instance.formatDate(selectedDates[1], 'Y-m-d H:i'); $refs.endDate.value = instance.formatDate(selectedDates[1], 'Y-m-d H:i');
handleFilterSubmit('stock-in');
} else if (selectedDates.length === 0) { } else if (selectedDates.length === 0) {
$refs.startDate.value = ''; $refs.startDate.value = '';
$refs.endDate.value = ''; $refs.endDate.value = '';
handleFilterSubmit('stock-in');
} }
} }
})"> })">
@ -51,6 +49,28 @@
value="{{ request('start_date') && request('end_date') ? request('start_date') . ' 至 ' . request('end_date') : (request('start_date') ?: '') }}" value="{{ request('start_date') && request('end_date') ? request('start_date') . ' 至 ' . request('end_date') : (request('start_date') ?: '') }}"
placeholder="{{ __('Select Date Range') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-full cursor-pointer"> placeholder="{{ __('Select Date Range') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-full cursor-pointer">
</div> </div>
<button type="submit" class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 group" title="{{ __('Search') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<button type="button"
@click="
$el.closest('form').querySelectorAll('input[type=text], input[type=hidden]:not([name=tab])').forEach(i => i.value = '');
$el.closest('form').querySelectorAll('select').forEach(s => {
s.value = ' ';
const instance = window.HSSelect.getInstance(s);
if (instance) instance.setValue(' ');
});
handleFilterSubmit('stock-in');
"
class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-700 group" title="{{ __('Reset') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div> </div>
@if(isset($stock_in_status_options)) @if(isset($stock_in_status_options))
@ -83,6 +103,9 @@
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center"> <th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Status') }} {{ __('Status') }}
</th> </th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Items') }}
</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center"> <th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Date') }} {{ __('Date') }}
</th> </th>
@ -125,12 +148,19 @@
<span class="inline-flex items-center px-3 py-1.5 rounded-xl text-[10px] font-black bg-amber-500/10 text-amber-500 border border-amber-500/20 uppercase tracking-widest shadow-sm shadow-amber-500/5"> <span class="inline-flex items-center px-3 py-1.5 rounded-xl text-[10px] font-black bg-amber-500/10 text-amber-500 border border-amber-500/20 uppercase tracking-widest shadow-sm shadow-amber-500/5">
{{ __('Draft') }} {{ __('Draft') }}
</span> </span>
@elseif($order->status === 'cancelled')
<span class="inline-flex items-center px-3 py-1.5 rounded-xl text-[10px] font-black bg-slate-500/10 text-slate-500 border border-slate-500/20 uppercase tracking-widest shadow-sm">
{{ __('Cancelled') }}
</span>
@else @else
<span class="inline-flex items-center px-3 py-1.5 rounded-xl text-[10px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 uppercase tracking-widest shadow-sm shadow-emerald-500/5"> <span class="inline-flex items-center px-3 py-1.5 rounded-xl text-[10px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 uppercase tracking-widest shadow-sm shadow-emerald-500/5">
{{ __('Completed') }} {{ __('Completed') }}
</span> </span>
@endif @endif
</td> </td>
<td class="px-6 py-5 text-center whitespace-nowrap">
<span class="font-black text-slate-800 dark:text-white">{{ $order->items_count }}</span>
</td>
<td class="px-6 py-5 text-center whitespace-nowrap"> <td class="px-6 py-5 text-center whitespace-nowrap">
<span class="text-xs font-mono font-black text-slate-500 dark:text-slate-400 tracking-widest"> <span class="text-xs font-mono font-black text-slate-500 dark:text-slate-400 tracking-widest">
{{ $order->created_at->format('Y-m-d H:i:s') }} {{ $order->created_at->format('Y-m-d H:i:s') }}
@ -148,8 +178,9 @@
</button> </button>
@if($order->status === 'draft') @if($order->status === 'draft')
{{-- Confirm Button --}}
<form action="{{ route('admin.warehouses.inventory.stock-in.confirm', $order) }}" method="POST" class="inline" @submit.prevent="confirmStockIn($el)"> <form action="{{ route('admin.warehouses.inventory.stock-in.confirm', $order) }}" method="POST" class="inline" @submit.prevent="confirmStockIn($el)">
@csrf @csrf @method('PATCH')
<button type="submit" <button type="submit"
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-emerald-500 hover:bg-emerald-500/5 transition-all border border-transparent hover:border-emerald-500/20" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-emerald-500 hover:bg-emerald-500/5 transition-all border border-transparent hover:border-emerald-500/20"
title="{{ __('Confirm Stock-In') }}"> title="{{ __('Confirm Stock-In') }}">
@ -158,13 +189,22 @@
</svg> </svg>
</button> </button>
</form> </form>
{{-- Delete/Cancel Button --}}
<button type="button" @click="confirmDelete('{{ route('admin.warehouses.inventory.stock-in.destroy', $order) }}')"
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 transition-all border border-transparent hover:border-rose-500/20"
title="{{ __('Delete Draft') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
@endif @endif
</div> </div>
</td> </td>
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="5" class="px-6 py-20 text-center"> <td colspan="6" class="px-6 py-20 text-center">
<div class="flex flex-col items-center justify-center gap-3"> <div class="flex flex-col items-center justify-center gap-3">
<div class="p-4 rounded-3xl bg-slate-50 dark:bg-slate-800/50"> <div class="p-4 rounded-3xl bg-slate-50 dark:bg-slate-800/50">
<svg class="w-8 h-8 text-slate-300 dark:text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-8 h-8 text-slate-300 dark:text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -181,6 +221,6 @@
</div> </div>
<div class="mt-8 py-6 border-t border-slate-50 dark:border-slate-800/50"> <div class="mt-8 py-6 border-t border-slate-50 dark:border-slate-800/50">
{{ $orders->links('vendor.pagination.luxury') }} {{ $orders->links('vendor.pagination.luxury', ['ajax_navigate_event' => 'ajax:navigate:stock-in']) }}
</div> </div>
</div> </div>

View File

@ -1,7 +1,6 @@
<div class="luxury-card rounded-3xl p-8 animate-luxury-in"> <div class="luxury-card rounded-3xl p-8 animate-luxury-in">
<form action="{{ route('admin.warehouses.inventory') }}" method="GET" class="flex flex-col md:flex-row md:items-center gap-4 mb-8" <form action="{{ route('admin.warehouses.inventory') }}" method="GET" class="flex flex-col md:flex-row md:items-center gap-4 mb-8"
@submit.prevent="handleFilterSubmit('stock')" @submit.prevent="handleFilterSubmit('stock')">
@multi-select-changed.window="handleFilterSubmit('stock')">
<input type="hidden" name="tab" value="stock"> <input type="hidden" name="tab" value="stock">
<div class="relative group"> <div class="relative group">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10"><svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg></span> <span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10"><svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg></span>
@ -20,6 +19,26 @@
:placeholder="__('Filter by Warehouse Presence')" :placeholder="__('Filter by Warehouse Presence')"
/> />
</div> </div>
<div class="flex items-center gap-4">
<button type="submit" class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 group" title="{{ __('Search') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<button type="button"
@click="
$el.closest('form').querySelectorAll('input[type=text]').forEach(i => i.value = '');
$el.closest('form').dispatchEvent(new CustomEvent('multi-select-reset', { bubbles: true }));
handleFilterSubmit('stock');
"
class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-700 group" title="{{ __('Reset') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</form> </form>
<div class="overflow-x-auto -mx-8 px-8 custom-scrollbar"> <div class="overflow-x-auto -mx-8 px-8 custom-scrollbar">

View File

@ -0,0 +1,110 @@
<div id="replenishments-table-container">
<div class="overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Order No.') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Machine') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Warehouse') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Items') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Assigned To') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Created By') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($orders as $order)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
{{-- 訂單編號 --}}
<td class="px-6 py-3.5">
<div class="flex flex-col cursor-pointer" @click="openOrderDetails('{{ $order->id }}')">
<span class="font-mono font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 transition-colors">{{ $order->order_no }}</span>
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">{{ $order->created_at->format('Y-m-d H:i:s') }}</span>
</div>
</td>
{{-- 機台 --}}
<td class="px-6 py-3.5">
<div class="flex flex-col">
<span class="font-bold text-slate-700 dark:text-slate-200">{{ $order->machine?->name }}</span>
<span class="text-xs font-mono text-slate-400">{{ $order->machine?->serial_no }}</span>
</div>
</td>
{{-- 倉庫 --}}
<td class="px-6 py-3.5"><span class="font-bold text-slate-600 dark:text-slate-300">{{ $order->warehouse?->name }}</span></td>
{{-- 項目數 --}}
<td class="px-6 py-3.5 text-center"><span class="font-black text-slate-800 dark:text-white">{{ $order->items_count }}</span></td>
{{-- 狀態標籤 --}}
<td class="px-6 py-3.5 text-center">
@php
$colors = ['pending' => 'amber', 'prepared' => 'cyan', 'delivering' => 'indigo', 'completed' => 'emerald', 'cancelled' => 'slate'];
$sColor = $colors[$order->status] ?? 'cyan';
@endphp
<span class="inline-flex items-center px-3 py-1.5 rounded-xl text-[10px] font-black bg-{{ $sColor }}-500/10 text-{{ $sColor }}-500 border border-{{ $sColor }}-500/20 tracking-widest uppercase">{{ __(ucfirst($order->status)) }}</span>
</td>
{{-- 指派人員 --}}
<td class="px-6 py-3.5">
<span class="text-sm font-bold {{ $order->assignee ? 'text-slate-700 dark:text-slate-200' : 'text-slate-400' }}">
{{ $order->assignee?->name ?? __('Unassigned') }}
</span>
</td>
{{-- 建立人 --}}
<td class="px-6 py-3.5"><span class="text-sm font-bold text-slate-500">{{ $order->creator?->name ?? '-' }}</span></td>
{{-- 操作按鈕 --}}
<td class="px-6 py-3.5 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
{{-- 查看詳情 --}}
<button type="button" @click="openOrderDetails('{{ $order->id }}')"
class="p-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-50 dark:hover:bg-cyan-500/10 transition-all border border-slate-100 dark:border-slate-700 shadow-sm group/btn"
title="{{ __('View Details') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.644C3.399 8.049 7.21 5 12 5c4.79 0 8.601 3.049 9.964 6.678.045.133.045.278 0 .412C20.601 15.951 16.79 19 12 19c-4.79 0-8.601-3.049-9.964-6.678z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
{{-- 狀態推進按鈕(依當前狀態顯示) --}}
@if($order->status === 'pending')
<button type="button"
@click.stop="advanceStatus('{{ $order->id }}', 'prepared')"
class="px-4 py-2 rounded-xl text-xs font-black bg-cyan-500/10 text-cyan-500 border border-cyan-500/20 hover:bg-cyan-500 hover:text-white transition-all shadow-sm">
{{ __('Confirm Prepare') }}
</button>
@elseif($order->status === 'prepared')
<button type="button"
@click.stop="advanceStatus('{{ $order->id }}', 'delivering')"
class="px-4 py-2 rounded-xl text-xs font-black bg-indigo-500/10 text-indigo-500 border border-indigo-500/20 hover:bg-indigo-500 hover:text-white transition-all shadow-sm">
{{ __('Start Delivery') }}
</button>
@elseif($order->status === 'delivering')
<button type="button"
@click.stop="advanceStatus('{{ $order->id }}', 'completed')"
class="px-4 py-2 rounded-xl text-xs font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 hover:bg-emerald-500 hover:text-white transition-all shadow-sm">
{{ __('Confirm Complete') }}
</button>
@endif
{{-- 取消按鈕(非完成/非取消狀態顯示) --}}
@if(in_array($order->status, ['pending', 'prepared', 'delivering']))
<button type="button"
@click.stop="confirmCancel('{{ $order->id }}')"
class="p-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-500/10 transition-all border border-slate-100 dark:border-slate-700 shadow-sm"
title="{{ __('Cancel Order') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@endif
</div>
</td>
</tr>
@empty
<tr><td colspan="8" class="px-6 py-20 text-center text-slate-400 font-bold">{{ __('No replenishment orders found') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-6 py-6 border-t border-slate-50 dark:border-slate-800/50">
{{ $orders->links('vendor.pagination.luxury', ['ajax_navigate_event' => 'ajax:navigate:replenishments']) }}
</div>
</div>

View File

@ -0,0 +1,94 @@
<div id="transfers-table-container">
<div class="overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Order No.') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Type') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('From') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('To') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Items') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($orders as $order)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-5 cursor-pointer" @click="openOrderDetails('{{ $order->id }}')">
<div class="flex items-center gap-x-4">
<div class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<div class="flex flex-col group/no">
<span class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover/no:text-cyan-600 dark:group-hover/no:text-cyan-400 transition-colors">
{{ $order->order_no }}
</span>
<div class="flex items-center gap-1.5 mt-1">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{{ $order->created_at->format('Y-m-d H:i:s') }}</span>
</div>
</div>
</div>
</td>
<td class="px-6 py-3.5">
@if($order->type === 'warehouse_to_warehouse')
<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-[10px] font-black bg-indigo-500/10 text-indigo-500 border border-indigo-500/20 uppercase tracking-widest">{{ __('W2W') }}</span>
@else
<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-[10px] font-black bg-amber-500/10 text-amber-500 border border-amber-500/20 uppercase tracking-widest">{{ __('M2W') }}</span>
@endif
</td>
<td class="px-6 py-3.5 font-bold text-slate-600 dark:text-slate-300">
{{ $order->type === 'warehouse_to_warehouse' ? ($order->fromWarehouse?->name ?? '-') : ($order->fromMachine?->name ?? '-') }}
</td>
<td class="px-6 py-3.5 font-bold text-slate-600 dark:text-slate-300">{{ $order->toWarehouse?->name ?? '-' }}</td>
<td class="px-6 py-3.5 text-center font-black text-slate-800 dark:text-white">{{ $order->items_count }}</td>
<td class="px-6 py-3.5 text-center">
@php
$colors = ['draft' => 'amber', 'completed' => 'emerald', 'cancelled' => 'slate'];
$sColor = $colors[$order->status] ?? 'cyan';
@endphp
<span class="inline-flex items-center px-3 py-1.5 rounded-xl text-[10px] font-black bg-{{ $sColor }}-500/10 text-{{ $sColor }}-500 border border-{{ $sColor }}-500/20 tracking-widest uppercase">{{ __(ucfirst($order->status)) }}</span>
</td>
<td class="px-6 py-3.5 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<button type="button" @click.stop="openOrderDetails('{{ $order->id }}')"
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20"
title="{{ __('View Details') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</button>
@if($order->status === 'draft')
<button type="button" @click.stop="confirmTransfer('{{ route('admin.warehouses.transfers.confirm', $order->id) }}')"
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-emerald-500 hover:bg-emerald-500/5 transition-all border border-transparent hover:border-emerald-500/20"
title="{{ __('Confirm') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</button>
<button type="button" @click.stop="confirmDelete('{{ route('admin.warehouses.transfers.destroy', $order) }}')"
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 transition-all border border-transparent hover:border-rose-500/20"
title="{{ __('Delete') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
@endif
</div>
</td>
</tr>
@empty
<tr><td colspan="7" class="px-6 py-20 text-center text-slate-400 font-bold uppercase tracking-widest">{{ __('No transfer orders found') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-6 py-6 border-t border-slate-50 dark:border-slate-800/50">
{{ $orders->links('vendor.pagination.luxury', ['ajax_navigate_event' => 'ajax:navigate:transfers']) }}
</div>
</div>

View File

@ -4,152 +4,357 @@
<script> <script>
function replenishmentManager() { function replenishmentManager() {
return { return {
showModal: false, showReplenishmentModal: false,
showAutoModal: false,
showCancelModal: false,
showAssignModal: false,
fromId: '', fromId: '',
availableProducts: [], availableProducts: [],
allProducts: @json(\App\Models\Product\Product::orderBy('name')->get(['id', 'name'])), allProducts: @json($products ?? []),
isLoadingProducts: false, isLoadingProducts: false,
items: [{ product_id: '', slot_no: '', quantity: 1 }], items: [{ product_id: '', slot_no: '', quantity: 1, current_stock: 0, max_stock: 0 }],
loading: false, loading: false,
cancelOrderId: null,
// Details Panel State assignOrderId: null,
showDetailsPanel: false, assignUserId: '',
// Auto replenishment
autoWarehouseId: '',
autoMachineId: '',
autoNote: '',
autoSlots: [],
autoHasWarning: false,
autoInsufficient: {},
autoWarehouseStocks: {},
autoPreviewLoaded: false,
autoLoading: false,
// Details Panel
showOrderDetails: false,
detailsLoading: false, detailsLoading: false,
selectedOrder: null, activeOrder: null,
orderItems: [], activeItems: [],
async openDetails(id) { init() {
this.showDetailsPanel = true; // 偵測 URL 參數自動開啟一鍵補貨 Modal
this.detailsLoading = true; const params = new URLSearchParams(window.location.search);
this.selectedOrder = null; const autoMachineId = params.get('auto_machine_id');
this.orderItems = []; if (autoMachineId) {
try {
const response = await fetch(`{{ url('/admin/warehouses/replenishments') }}/${id}/details`, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
const data = await response.json();
if (data.success) {
this.selectedOrder = data.order;
this.orderItems = data.items;
} else {
throw new Error(data.message || 'Failed to load details');
}
} catch (e) {
console.error('openDetails error:', e);
window.showToast?.('{{ __("Failed to load details") }}', 'error');
} finally {
this.detailsLoading = false;
this.$nextTick(() => { this.$nextTick(() => {
if (window.HSStaticMethods) window.HSStaticMethods.autoInit(); this.autoMachineId = autoMachineId;
this.showAutoModal = true;
this.autoPreviewLoaded = false;
this.autoSlots = [];
this.autoNote = '';
// 同步 HSSelect UI
this.$nextTick(() => {
const select = document.querySelector('[name="auto_machine_id"]');
if (select && window.HSSelect) {
const inst = window.HSSelect.getInstance(select);
if (inst) inst.setValue(autoMachineId);
}
});
}); });
// 清除 URL 參數避免重複觸發
const url = new URL(window.location);
url.searchParams.delete('auto_machine_id');
window.history.replaceState({}, '', url);
} }
}, },
openCreateModal() { async openOrderDetails(id) {
this.items = [{ product_id: '', slot_no: '', quantity: 1 }]; this.showOrderDetails = true;
this.showModal = true; this.detailsLoading = true;
this.$nextTick(() => { this.activeOrder = null;
if (window.HSStaticMethods) window.HSStaticMethods.autoInit('select'); this.activeItems = [];
}); try {
const res = await fetch(`{{ url('/admin/warehouses/replenishments') }}/${id}/details`, {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }
});
const data = await res.json();
if (data.success) { this.activeOrder = data.order; this.activeItems = data.items; }
} catch (e) { console.error(e); window.showToast?.('{{ __("Failed to load details") }}', 'error'); }
finally { this.detailsLoading = false; }
}, },
addItem() { openCreateModal() {
this.items.push({ product_id: '', slot_no: '', quantity: 1 }); this.items = [{ product_id: '', slot_no: '', quantity: 1, current_stock: 0, max_stock: 0 }];
this.$nextTick(() => { this.showReplenishmentModal = true;
if (window.HSStaticMethods) window.HSStaticMethods.autoInit('select'); this.$nextTick(() => { if (window.HSStaticMethods) window.HSStaticMethods.autoInit('select'); });
}); },
addItem() {
this.items.push({ product_id: '', slot_no: '', quantity: 1, current_stock: 0, max_stock: 0 });
this.$nextTick(() => this.updateProductSelects());
}, },
removeItem(i) { if (this.items.length > 1) this.items.splice(i, 1); }, removeItem(i) { if (this.items.length > 1) this.items.splice(i, 1); },
async fetchStock() { async fetchStock() {
this.availableProducts = []; this.availableProducts = [];
if (!this.fromId || this.fromId.trim() === '') return; if (!this.fromId) { this.$nextTick(() => this.updateProductSelects()); return; }
this.isLoadingProducts = true; this.isLoadingProducts = true;
try { try {
const res = await fetch('{{ route('admin.warehouses.ajax.stock') }}?warehouse_id=' + this.fromId); const res = await fetch('{{ route("admin.warehouses.ajax.stock") }}?warehouse_id=' + this.fromId);
const json = await res.json(); const json = await res.json();
if (json.success) { if (json.success) { this.availableProducts = json.data; this.$nextTick(() => this.updateProductSelects()); }
this.availableProducts = json.data; } catch (e) { console.error(e); }
finally { this.isLoadingProducts = false; }
},
updateProductSelects() {
this.items.forEach((item, index) => {
const wrapper = document.getElementById(`product-select-wrapper-${index}`);
if (!wrapper) return;
const oldSelect = wrapper.querySelector('select');
if (oldSelect && window.HSSelect?.getInstance(oldSelect)) {
try { window.HSSelect.getInstance(oldSelect).destroy(); } catch (e) {}
} }
} catch (e) { wrapper.innerHTML = '';
console.error(e); const selectEl = document.createElement('select');
} finally { selectEl.id = `product-select-${index}-${Date.now()}`;
this.isLoadingProducts = false; selectEl.name = `items[${index}][product_id]`;
this.$nextTick(() => { selectEl.required = true;
if (window.HSStaticMethods) window.HSStaticMethods.autoInit('select'); selectEl.className = 'hidden';
const products = this.fromId ? this.availableProducts : this.allProducts;
const placeholderOpt = document.createElement('option');
placeholderOpt.value = '';
placeholderOpt.textContent = '{{ __("Select Product") }}';
selectEl.appendChild(placeholderOpt);
products.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id;
const stockText = p.quantity !== undefined ? ` (${'{{ __("Stock") }}'}: ${p.quantity})` : '';
opt.textContent = p.name + stockText;
opt.setAttribute('data-title', p.name + stockText);
if (item.product_id == p.id) opt.selected = true;
selectEl.appendChild(opt);
}); });
} selectEl.setAttribute('data-hs-select', JSON.stringify({
"placeholder": "{{ __('Select Product') }}",
"toggleClasses": "hs-select-toggle luxury-select-toggle w-full text-left",
"toggleTemplate": "<button type=\"button\"><span class=\"text-slate-800 dark:text-slate-200\" data-title></span><div class=\"ms-auto\"><svg class=\"size-4 text-slate-400\" xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m6 9 6 6 6-6\"/></svg></div></button>",
"dropdownClasses": "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-2xl mt-2 z-[150] max-h-48 overflow-y-auto custom-scrollbar-thin",
"optionClasses": "hs-select-option py-2 px-3 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-cyan-500/10 rounded-lg",
"hasSearch": true, "searchPlaceholder": "{{ __('Search Product') }}",
"searchClasses": "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200",
"searchWrapperClasses": "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10",
"strategy": "fixed"
}));
wrapper.appendChild(selectEl);
selectEl.addEventListener('change', (e) => { item.product_id = e.target.value; });
if (window.HSStaticMethods) window.HSStaticMethods.autoInit('select');
});
}, },
async submitReplenishment() { async submitReplenishment() {
const form = document.getElementById('replenishmentForm'); const form = document.getElementById('replenishmentForm');
const warehouseId = form.querySelector('[name="warehouse_id"]')?.value; const warehouseId = this.fromId;
const machineId = form.querySelector('[name="machine_id"]')?.value; const machineId = form.querySelector('[name="machine_id"]')?.value;
if (!warehouseId) { if (!warehouseId || warehouseId === ' ') {
window.showToast?.('{{ __("Please select source warehouse") }}', 'error'); window.showToast?.('{{ __("Please select source warehouse") }}', 'error'); return;
return;
} }
if (!machineId) { if (!machineId || machineId === ' ') {
window.showToast?.('{{ __("Please select target machine") }}', 'error'); window.showToast?.('{{ __("Please select target machine") }}', 'error'); return;
return;
} }
if (this.items.length === 0) {
if (this.items.length === 0) { window.showToast?.('{{ __("Please add at least one item") }}', 'error'); return;
window.showToast?.('{{ __("Please add at least one item") }}', 'error');
return;
} }
let hasError = false; for (let i = 0; i < this.items.length; i++) {
this.items.forEach(item => { if (!this.items[i].product_id) {
if (!item.slot_no || !item.product_id || !item.quantity || item.quantity < 1) { window.showToast?.('{{ __("Please select product for item :num") }}'.replace(':num', i+1), 'error'); return;
hasError = true; }
if (!this.items[i].quantity || this.items[i].quantity < 1) {
window.showToast?.('{{ __("Please enter valid quantity for item :num") }}'.replace(':num', i+1), 'error'); return;
} }
});
if (hasError) {
window.showToast?.('{{ __("Please select slot, product and valid quantity for all items") }}', 'error');
return;
} }
this.loading = true; this.loading = true;
try { try {
const formData = new FormData(form); const formData = new FormData(form);
const response = await fetch(form.action, { const response = await fetch(form.action, {
method: 'POST', method: 'POST', body: formData,
body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
}); });
const result = await response.json(); const result = await response.json();
if (result.success) { if (response.ok && result.success) {
this.showReplenishmentModal = false;
window.showToast?.(result.message, 'success'); window.showToast?.(result.message, 'success');
this.showModal = false; this.fetchTabData();
window.location.reload(); } else if (response.status === 422) {
} else { const firstError = Object.values(result.errors || {})[0]?.[0] || '{{ __("Validation failed") }}';
window.showToast?.(result.message || '{{ __("Validation failed") }}', 'error'); window.showToast?.(firstError, 'error');
} } else { window.showToast?.(result.message || '{{ __("System error") }}', 'error'); }
} catch (error) { } catch (e) { console.error(e); window.showToast?.('{{ __("System Error") }}', 'error'); }
console.error(error); finally { this.loading = false; }
window.showToast?.('{{ __("System Error") }}', 'error'); },
} finally {
this.loading = false; // 一鍵補貨預覽
async loadAutoPreview() {
if (!this.autoWarehouseId || this.autoWarehouseId === ' ' || !this.autoMachineId || this.autoMachineId === ' ') {
window.showToast?.('{{ __("Please select warehouse and machine") }}', 'error'); return;
} }
this.autoLoading = true;
try {
const res = await fetch('{{ route("admin.warehouses.replenishments.auto") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ warehouse_id: this.autoWarehouseId, machine_id: this.autoMachineId })
});
const data = await res.json();
if (data.success && data.preview) {
this.autoSlots = data.slots;
this.autoHasWarning = data.has_warning;
this.autoInsufficient = data.insufficient || {};
this.autoWarehouseStocks = data.warehouse_stocks || {};
this.autoPreviewLoaded = true;
} else {
window.showToast?.(data.message || '{{ __("Failed to load preview") }}', 'error');
}
} catch (e) { console.error(e); window.showToast?.('{{ __("System Error") }}', 'error'); }
finally { this.autoLoading = false; }
},
getActualFillable(index) {
const slot = this.autoSlots[index];
if (!slot) return 0;
let available = this.autoWarehouseStocks[slot.product_id] || 0;
for (let i = 0; i < index; i++) {
if (this.autoSlots[i].product_id === slot.product_id) {
available -= this.autoSlots[i].quantity;
}
}
return Math.max(0, Math.min(slot.quantity, available));
},
removeAutoSlot(idx) {
this.autoSlots.splice(idx, 1);
},
async submitAutoReplenishment() {
if (!this.autoSlots || this.autoSlots.length === 0) {
window.showToast?.('{{ __("No items to replenish") }}', 'error'); return;
}
this.autoLoading = true;
const items = this.autoSlots.map(s => ({
slot_no: s.slot_no,
product_id: s.product_id,
quantity: s.quantity,
current_stock: s.current_stock,
max_stock: s.max_stock,
}));
try {
const res = await fetch('{{ route("admin.warehouses.replenishments.auto") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({
warehouse_id: this.autoWarehouseId,
machine_id: this.autoMachineId,
note: this.autoNote,
items
})
});
const data = await res.json();
if (data.success) {
this.showAutoModal = false;
this.autoPreviewLoaded = false;
this.autoSlots = [];
window.showToast?.(data.message, 'success');
this.fetchTabData();
} else {
window.showToast?.(data.message || '{{ __("Creation failed") }}', 'error');
}
} catch (e) { console.error(e); window.showToast?.('{{ __("System Error") }}', 'error'); }
finally { this.autoLoading = false; }
},
// 狀態推進
async advanceStatus(orderId, newStatus) {
const labels = { prepared: '{{ __("Confirm Prepare") }}', delivering: '{{ __("Start Delivery") }}', completed: '{{ __("Confirm Complete") }}' };
if (!confirm(labels[newStatus] + '?')) return;
this.loading = true;
try {
const res = await fetch(`{{ url('/admin/warehouses/replenishments') }}/${orderId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ status: newStatus })
});
const data = await res.json();
if (data.success) { window.showToast?.(data.message, 'success'); this.fetchTabData(); }
else { window.showToast?.(data.message || '{{ __("Operation failed") }}', 'error'); }
} catch (e) { console.error(e); window.showToast?.('{{ __("System Error") }}', 'error'); }
finally { this.loading = false; }
},
// 取消
confirmCancel(orderId) { this.cancelOrderId = orderId; this.showCancelModal = true; },
async executeCancel() {
this.loading = true;
try {
const res = await fetch(`{{ url('/admin/warehouses/replenishments') }}/${this.cancelOrderId}/cancel`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
});
const data = await res.json();
if (data.success) {
this.showCancelModal = false;
let msg = data.message;
if (data.stock_returned) msg += ' — {{ __("Stock returned to warehouse") }}';
window.showToast?.(msg, 'success');
this.fetchTabData();
} else { window.showToast?.(data.message, 'error'); }
} catch (e) { console.error(e); window.showToast?.('{{ __("System Error") }}', 'error'); }
finally { this.loading = false; }
},
// 指派
openAssignModal(orderId) { this.assignOrderId = orderId; this.assignUserId = ''; this.showAssignModal = true; },
async executeAssign() {
if (!this.assignUserId) { window.showToast?.('{{ __("Select Personnel") }}', 'error'); return; }
this.loading = true;
try {
const res = await fetch(`{{ url('/admin/warehouses/replenishments') }}/${this.assignOrderId}/assign`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ assigned_to: this.assignUserId })
});
const data = await res.json();
if (data.success) { this.showAssignModal = false; window.showToast?.(data.message, 'success'); this.fetchTabData(); }
else { window.showToast?.(data.message, 'error'); }
} catch (e) { console.error(e); }
finally { this.loading = false; }
},
async fetchTabData(url = null) {
this.loading = true;
const container = document.getElementById('replenishments-table-container');
if (!url) {
const form = document.querySelector('form[action="{{ route('admin.warehouses.replenishments') }}"]');
url = `${form.action}?${new URLSearchParams(new FormData(form)).toString()}`;
}
try {
const res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } });
const data = await res.json();
if (data.success && container) {
container.outerHTML = data.html;
this.$nextTick(() => { if (window.HSStaticMethods) window.HSStaticMethods.autoInit(); });
window.history.pushState({}, '', new URL(url, window.location.origin).toString());
}
} catch (e) { console.error(e); window.showToast?.('{{ __("Loading failed") }}', 'error'); }
finally { this.loading = false; }
} }
} }
} }
</script> </script>
<div class="space-y-3 pb-10" x-data="replenishmentManager()" @open-details.window="openDetails($event.detail.id)"> <div class="space-y-3 pb-10" x-data="replenishmentManager()"
@open-details.window="openOrderDetails($event.detail.id)"
@ajax:navigate:replenishments.window.prevent="fetchTabData($event.detail.url)">
{{-- Header --}} {{-- Header --}}
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4"> <div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
@ -157,390 +362,66 @@
<h1 class="text-3xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Machine Replenishment') }}</h1> <h1 class="text-3xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Machine Replenishment') }}</h1>
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Create and manage replenishment orders from warehouse to machines') }}</p> <p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Create and manage replenishment orders from warehouse to machines') }}</p>
</div> </div>
<button type="button" @click="openCreateModal()" class="btn-luxury-primary"> <div class="flex items-center gap-3">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg> <button type="button" @click="
<span>{{ __('New Replenishment') }}</span> autoWarehouseId = '';
</button> autoMachineId = '';
autoNote = '';
autoPreviewLoaded = false;
autoSlots = [];
showAutoModal = true;
$nextTick(() => {
document.querySelectorAll('[name^=\'auto_\']').forEach(s => {
if (window.HSSelect) {
const inst = window.HSSelect.getInstance(s);
if (inst) inst.setValue(' ');
}
});
});
" class="btn-luxury-ghost border-cyan-500/30 text-cyan-600 hover:bg-cyan-50 dark:hover:bg-cyan-500/10">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" /></svg>
<span>{{ __('Auto Replenishment') }}</span>
</button>
<button type="button" @click="openCreateModal()" class="btn-luxury-primary">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
<span>{{ __('New Replenishment') }}</span>
</button>
</div>
</div> </div>
{{-- Filters --}} {{-- Filters --}}
<div class="luxury-card rounded-3xl p-8 animate-luxury-in"> <div class="luxury-card rounded-3xl p-8 animate-luxury-in">
<form action="{{ route('admin.warehouses.replenishments') }}" method="GET" class="mb-8"> <form id="replenishment-filter-form" action="{{ route('admin.warehouses.replenishments') }}" method="GET" class="flex flex-col md:flex-row md:items-center gap-4 mb-8">
<div class="flex items-center p-1 bg-slate-100/50 dark:bg-slate-900/50 backdrop-blur-md rounded-2xl border border-slate-200/50 dark:border-slate-700/50 w-fit"> <div class="relative group flex-1 md:max-w-[240px]">
<a href="{{ route('admin.warehouses.replenishments') }}" <span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
class="px-5 py-2 text-xs font-black tracking-widest uppercase transition-all duration-300 rounded-xl {{ !request()->filled('status') ? 'bg-white dark:bg-slate-800 text-cyan-600 shadow-lg shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600' }}"> <svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
{{ __('All') }} </span>
</a> <input type="text" name="search_order" value="{{ request('search_order') }}" @keydown.enter.prevent="fetchTabData()" class="py-2.5 pl-12 pr-6 block w-full luxury-input" placeholder="{{ __('Search order number...') }}">
@foreach(['pending' => 'Pending', 'prepared' => 'Prepared', 'delivering' => 'Delivering', 'completed' => 'Completed'] as $key => $label)
<a href="{{ route('admin.warehouses.replenishments', ['status' => $key]) }}"
class="px-5 py-2 text-xs font-black tracking-widest uppercase transition-all duration-300 rounded-xl {{ request('status') === $key ? 'bg-white dark:bg-slate-800 text-cyan-600 shadow-lg shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600' }}">
{{ __($label) }}
</a>
@endforeach
</div> </div>
<div class="flex-1 md:max-w-[180px]">
<x-searchable-select name="status" :selected="request('status')" :placeholder="__('All Statuses')">
<option value="" data-title="{{ __('All Statuses') }}">{{ __('All Statuses') }}</option>
<option value="pending" {{ request('status') === 'pending' ? 'selected' : '' }} data-title="{{ __('Pending') }}">{{ __('Pending') }}</option>
<option value="prepared" {{ request('status') === 'prepared' ? 'selected' : '' }} data-title="{{ __('Prepared') }}">{{ __('Prepared') }}</option>
<option value="delivering" {{ request('status') === 'delivering' ? 'selected' : '' }} data-title="{{ __('Delivering') }}">{{ __('Delivering') }}</option>
<option value="completed" {{ request('status') === 'completed' ? 'selected' : '' }} data-title="{{ __('Completed') }}">{{ __('Completed') }}</option>
<option value="cancelled" {{ request('status') === 'cancelled' ? 'selected' : '' }} data-title="{{ __('Cancelled') }}">{{ __('Cancelled') }}</option>
</x-searchable-select>
</div>
<button type="button" @click="fetchTabData()" class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25" title="{{ __('Search') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</button>
<button type="button" @click="$el.closest('form').querySelectorAll('input[type=text]').forEach(i => i.value = ''); $el.closest('form').querySelectorAll('select').forEach(s => { s.value = ' '; const inst = window.HSSelect?.getInstance(s); if(inst) inst.setValue(' '); }); fetchTabData();" class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-700" title="{{ __('Reset') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
</button>
</form> </form>
@include('admin.warehouses.partials.table-replenishments')
<div class="overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Order No.') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Machine') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Warehouse') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Items') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Created By') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($orders as $order)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-3.5">
<div class="flex flex-col cursor-pointer" @click="openDetails('{{ $order->id }}')">
<span class="font-mono font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 transition-colors">{{ $order->order_no }}</span>
</div>
</td>
<td class="px-6 py-3.5">
<div class="flex flex-col">
<span class="font-bold text-slate-700 dark:text-slate-200">{{ $order->machine?->name }}</span>
<span class="text-xs font-mono text-slate-400">{{ $order->machine?->serial_no }}</span>
</div>
</td>
<td class="px-6 py-3.5"><span class="font-bold text-slate-600 dark:text-slate-300">{{ $order->warehouse?->name }}</span></td>
<td class="px-6 py-3.5 text-center"><span class="font-black text-slate-800 dark:text-white">{{ $order->items_count }}</span></td>
<td class="px-6 py-3.5 text-center">
@php
$colors = ['pending' => 'amber', 'prepared' => 'cyan', 'delivering' => 'indigo', 'completed' => 'emerald', 'cancelled' => 'slate'];
$sColor = $colors[$order->status] ?? 'cyan';
@endphp
<span class="inline-flex items-center px-3 py-1.5 rounded-xl text-[10px] font-black bg-{{ $sColor }}-500/10 text-{{ $sColor }}-500 border border-{{ $sColor }}-500/20 tracking-widest uppercase">{{ __(ucfirst($order->status)) }}</span>
</td>
<td class="px-6 py-3.5"><span class="text-sm font-bold text-slate-500">{{ $order->creator?->name ?? '-' }}</span></td>
<td class="px-6 py-3.5 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<button type="button" @click="openDetails('{{ $order->id }}')"
class="p-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20"
title="{{ __('View Details') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.644C3.399 8.049 7.21 5 12 5c4.79 0 8.601 3.049 9.964 6.678.045.133.045.278 0 .412C20.601 15.951 16.79 19 12 19c-4.79 0-8.601-3.049-9.964-6.678z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
@if(in_array($order->status, ['pending', 'prepared', 'delivering']))
<form action="{{ route('admin.warehouses.replenishments.confirm', $order->id) }}" method="POST" class="inline" onsubmit="return confirm('{{ __('Confirm replenishment completed?') }}')">
@csrf @method('PATCH')
<button type="submit" class="px-4 py-2 rounded-xl text-xs font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 hover:bg-emerald-500 hover:text-white transition-all">{{ __('Complete') }}</button>
</form>
@endif
</div>
</td>
</tr>
@empty
<tr><td colspan="7" class="px-6 py-20 text-center text-slate-400 font-bold">{{ __('No replenishment orders found') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-6 py-6 border-t border-slate-50 dark:border-slate-800/50">{{ $orders->links('vendor.pagination.luxury') }}</div>
</div> </div>
{{-- New Replenishment Modal --}} @include('admin.warehouses.partials.modal-replenishment-create')
<div x-show="showModal" class="fixed inset-0 z-[110] overflow-y-auto" style="display: none;" role="dialog" aria-modal="true" x-cloak> @include('admin.warehouses.partials.modal-replenishment-auto')
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0"> @include('admin.warehouses.partials.slideover-replenishment-details')
<!-- Background Backdrop --> @include('admin.warehouses.partials.modal-replenishment-cancel')
<div x-show="showModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" @include('admin.warehouses.partials.modal-replenishment-assign')
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
@click="showModal = false"></div>
<span class="hidden sm:inline-block sm:align-middle sm:min-h-screen" aria-hidden="true">&#8203;</span>
<!-- Modal Panel -->
<div x-show="showModal"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="relative inline-flex flex-col align-bottom bg-white dark:bg-slate-900 rounded-[2.5rem] text-left shadow-2xl transform sm:my-8 sm:align-middle sm:max-w-3xl w-full border border-slate-100 dark:border-slate-800 z-10 max-h-[90vh]"
@click.stop>
<!-- Modal Header -->
<div class="px-10 py-8 pb-4 flex items-center justify-between">
<div>
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight leading-none mb-3">
{{ __('New Replenishment') }}
</h3>
<p class="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">
{{ __('Create and manage replenishment orders from warehouse to machines') }}
</p>
</div>
<button @click="showModal = false"
class="p-2.5 rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-all border border-slate-100 dark:border-slate-700 shadow-sm">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto px-10 py-2 custom-scrollbar overflow-x-visible">
<form id="replenishmentForm" action="{{ route('admin.warehouses.replenishments.store') }}" method="POST" class="space-y-6 pb-20">
@csrf
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-3">
<label class="block text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] pl-1">
{{ __('Source Warehouse') }} <span class="text-rose-500">*</span>
</label>
<x-searchable-select
name="warehouse_id"
:options="$warehouses ?? []"
:placeholder="__('Select Warehouse')"
class="w-full"
required
x-model="fromId"
@change="fetchStock()"
/>
</div>
<div class="space-y-3">
<label class="block text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] pl-1">
{{ __('Target Machine') }} <span class="text-rose-500">*</span>
</label>
<x-searchable-select
name="machine_id"
:options="$machines->map(fn($m) => (object)['id' => $m->id, 'name' => $m->name . ' (' . $m->serial_no . ')'])"
:placeholder="__('Select Machine')"
class="w-full"
required
/>
</div>
</div>
<div class="space-y-3">
<label class="block text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] pl-1">
{{ __('Note') }}
</label>
<input type="text" name="note" class="luxury-input w-full px-6 py-4 bg-slate-50/50 dark:bg-slate-900/50"
placeholder="{{ __('Optional remarks...') }}">
</div>
{{-- Items --}}
<div class="space-y-5">
<div class="flex items-center justify-between pl-1">
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em]">
{{ __('Replenishment Items') }} <span class="text-rose-500">*</span>
</label>
<button type="button" @click="addItem()"
class="text-xs font-black text-cyan-500 hover:text-cyan-400 uppercase tracking-widest flex items-center gap-1.5 transition-colors">
<svg class="w-4 h-4 stroke-[3]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{{ __('Add Product') }}
</button>
</div>
<div class="space-y-3">
<template x-for="(item, index) in items" :key="index">
<div class="group flex items-center gap-3 p-3 bg-slate-50/50 dark:bg-slate-900/30 rounded-2xl border border-slate-100 dark:border-slate-800/50 hover:border-cyan-500/30 transition-all duration-300">
<div class="w-20">
<input type="number" :name="'items['+index+'][slot_no]'" x-model="item.slot_no" min="1" required
class="luxury-input w-full px-3 py-2 text-center text-sm font-mono font-bold" placeholder="{{ __('Slot') }}">
</div>
<div class="w-px h-6 bg-slate-200 dark:bg-slate-800/50"></div>
<div class="flex-1 min-w-[200px]" :key="'select-wrapper-' + index + '-' + (fromId ? 'filtered-' + fromId + '-' + availableProducts.length : 'all')">
<select :id="'product-select-' + index + '-' + Date.now()" :name="'items['+index+'][product_id]'" x-model="item.product_id" required
data-hs-select='{"placeholder": "{{ __('Select Product') }}","toggleClasses": "hs-select-toggle luxury-select-toggle w-full text-left","toggleTemplate": "<button type=\"button\"><span class=\"text-slate-800 dark:text-slate-200\" data-title></span><div class=\"ms-auto\"><svg class=\"size-4 text-slate-400\" xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m6 9 6 6 6-6\"/></svg></div></button>","dropdownClasses": "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.3)] mt-2 z-[150] max-h-72 overflow-y-auto custom-scrollbar-thin","optionClasses": "hs-select-option py-2 px-3 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-cyan-500/10 dark:hover:text-cyan-400 rounded-lg","hasSearch": true,"searchPlaceholder": "{{ __('Search Product') }}","searchClasses": "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200","searchWrapperClasses": "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10","strategy": "fixed"}' class="hidden">
<option value="">{{ __('Select Product') }}</option>
<template x-for="p in (fromId ? availableProducts : allProducts)" :key="p.id">
<option :value="p.id"
x-text="p.name + (p.quantity !== undefined ? ' (' + '{{ __('Stock') }}: ' + p.quantity + ')' : '')"
:data-title="p.name + (p.quantity !== undefined ? ' (' + '{{ __('Stock') }}: ' + p.quantity + ')' : '')">
</option>
</template>
</select>
</div>
<div class="w-px h-6 bg-slate-200 dark:bg-slate-800/50"></div>
<div class="w-20">
<input type="number" :name="'items['+index+'][quantity]'" x-model="item.quantity" min="1" required
class="luxury-input w-full px-3 py-2 text-center text-sm font-mono font-bold" placeholder="{{ __('Qty') }}">
</div>
<button type="button" @click="removeItem(index)"
class="p-2 text-slate-300 hover:text-rose-500 transition-all transform hover:scale-110"
x-show="items.length > 1">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
</template>
</div>
</div>
</form>
</div>
<!-- Modal Footer -->
<div class="px-10 py-6 border-t border-slate-100 dark:border-slate-800/50 flex items-center justify-end gap-4">
<button type="button" @click="showModal = false" class="btn-luxury-ghost px-8">
{{ __('Cancel') }}
</button>
<button type="button" @click="submitReplenishment()"
:disabled="loading"
class="btn-luxury-primary px-12 relative flex items-center justify-center">
<span :class="loading ? 'opacity-0' : ''">{{ __('Create') }}</span>
<template x-if="loading">
<div class="absolute inset-0 flex items-center justify-center">
<svg class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</template>
</button>
</div>
</div>
</div>
</div>
<!-- Details Slide-over -->
<div x-show="showDetailsPanel"
class="fixed inset-0 z-[120] overflow-hidden"
style="display: none;"
x-cloak>
<div class="absolute inset-0 overflow-hidden">
<!-- Backdrop -->
<div x-show="showDetailsPanel"
x-transition:enter="ease-in-out duration-500" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in-out duration-500" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
@click="showDetailsPanel = false"></div>
<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
<div x-show="showDetailsPanel"
x-transition:enter="transform transition ease-in-out duration-500 sm:duration-700" x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
x-transition:leave="transform transition ease-in-out duration-500 sm:duration-700" x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
class="pointer-events-auto w-screen max-w-md">
<div class="flex h-full flex-col overflow-y-scroll bg-white dark:bg-slate-900 shadow-2xl border-l border-slate-100 dark:border-slate-800">
<!-- Header -->
<div class="bg-slate-50/50 dark:bg-slate-900/50 px-6 py-8 border-b border-slate-100 dark:border-slate-800">
<div class="flex items-start justify-between">
<h2 class="text-xl font-black text-slate-800 dark:text-white font-display tracking-tight leading-none">
{{ __('Replenishment Details') }}
</h2>
<div class="ml-3 flex h-7 items-center">
<button type="button" @click="showDetailsPanel = false"
class="rounded-full bg-white dark:bg-slate-800 p-2 text-slate-400 hover:text-slate-500 dark:hover:text-slate-300 focus:outline-none border border-slate-100 dark:border-slate-700 shadow-sm transition-all">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="mt-4">
<template x-if="selectedOrder">
<div class="flex flex-col gap-1">
<span class="text-2xl font-black text-cyan-600 dark:text-cyan-400 font-mono tracking-tighter" x-text="selectedOrder.order_no"></span>
<div class="flex items-center gap-2">
<span class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em]" x-text="selectedOrder.warehouse_name"></span>
<svg class="w-3 h-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M13 7l5 5m0 0l-5 5m5-5H6" /></svg>
<span class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.2em]" x-text="selectedOrder.machine_name"></span>
</div>
</div>
</template>
</div>
</div>
<!-- Content -->
<div class="relative flex-1 px-6 py-8">
<!-- Loading Spinner -->
<div x-show="detailsLoading" class="absolute inset-0 bg-white/60 dark:bg-slate-900/60 backdrop-blur-[2px] z-10 flex items-center justify-center">
<div class="w-10 h-10 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin"></div>
</div>
<template x-if="selectedOrder">
<div class="space-y-10">
<!-- Info Grid -->
<div class="grid grid-cols-2 gap-6 p-6 bg-slate-50/50 dark:bg-slate-800/30 rounded-[2rem] border border-slate-100 dark:border-slate-800">
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('Status') }}</p>
<div>
@php
$statusMap = [
'pending' => ['color' => 'amber', 'label' => __('Pending')],
'prepared' => ['color' => 'cyan', 'label' => __('Prepared')],
'delivering' => ['color' => 'indigo', 'label' => __('Delivering')],
'completed' => ['color' => 'emerald', 'label' => __('Completed')],
'cancelled' => ['color' => 'slate', 'label' => __('Cancelled')],
];
@endphp
<template x-for="(config, status) in {{ json_encode($statusMap) }}" :key="status">
<template x-if="selectedOrder.status === status">
<span :class="'inline-flex items-center px-2 py-0.5 rounded-lg text-[10px] font-black bg-' + config.color + '-500/10 text-' + config.color + '-500 border border-' + config.color + '-500/20 uppercase tracking-widest'" x-text="config.label"></span>
</template>
</template>
</div>
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('Created By') }}</p>
<p class="text-xs font-bold text-slate-700 dark:text-slate-300" x-text="selectedOrder.creator_name"></p>
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('Created At') }}</p>
<p class="text-[11px] font-mono font-bold text-slate-600 dark:text-slate-400" x-text="selectedOrder.created_at"></p>
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('Completed At') }}</p>
<p class="text-[11px] font-mono font-bold text-slate-600 dark:text-slate-400" x-text="selectedOrder.completed_at || '-'"></p>
</div>
<template x-if="selectedOrder.note">
<div class="col-span-2 space-y-1 pt-2 border-t border-slate-200/50 dark:border-slate-700/50">
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('Note') }}</p>
<p class="text-xs font-bold text-slate-600 dark:text-slate-400 italic" x-text="selectedOrder.note"></p>
</div>
</template>
</div>
<!-- Items List -->
<div class="space-y-5">
<div class="flex items-center justify-between px-1">
<h3 class="text-sm font-black text-slate-800 dark:text-white uppercase tracking-widest">{{ __('Replenishment Items') }}</h3>
<span class="text-[10px] font-black text-cyan-500 bg-cyan-500/10 px-2 py-0.5 rounded-md uppercase" x-text="orderItems.length + ' {{ __('Items') }}'"></span>
</div>
<div class="space-y-3">
<template x-for="(item, idx) in orderItems" :key="idx">
<div class="group flex items-center gap-4 p-4 bg-white dark:bg-slate-900 rounded-2xl border border-slate-100 dark:border-slate-800 hover:border-cyan-500/30 transition-all duration-300">
<div class="w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center font-mono font-bold text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700" x-text="item.slot_no"></div>
<div class="w-12 h-12 rounded-xl bg-slate-50 dark:bg-slate-800 flex items-center justify-center overflow-hidden border border-slate-100 dark:border-slate-800 group-hover:scale-105 transition-transform">
<template x-if="item.image_url">
<img :src="item.image_url" class="w-full h-full object-cover">
</template>
<template x-if="!item.image_url">
<svg class="w-6 h-6 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</template>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-black text-slate-800 dark:text-slate-100 truncate tracking-tight" x-text="item.product_name"></p>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5">{{ __('Product ID') }}: <span class="text-slate-500 dark:text-slate-400" x-text="item.product_id"></span></p>
</div>
<div class="text-right">
<p class="text-base font-black text-cyan-600 dark:text-cyan-400 font-mono tracking-tighter" x-text="'x' + item.quantity"></p>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
@endsection @endsection

View File

@ -4,7 +4,7 @@
<script> <script>
function transferManager() { function transferManager() {
return { return {
showModal: false, showTransferModal: false,
transferType: 'warehouse_to_warehouse', transferType: 'warehouse_to_warehouse',
fromId: '', fromId: '',
availableProducts: [], availableProducts: [],
@ -14,53 +14,129 @@
loading: false, loading: false,
// Details Panel State // Details Panel State
showDetailsPanel: false, showOrderDetails: false,
selectedOrder: null, activeOrder: null,
orderItems: [], activeItems: [],
loadingDetails: false, detailsLoading: false,
// Delete State
showDeleteModal: false,
deleteUrl: '',
// Confirm State
showConfirmModal: false,
pendingTransferUrl: '',
openCreateModal() { openCreateModal() {
this.items = [{ product_id: '', quantity: 1 }]; this.items = [{ product_id: '', quantity: 1 }];
this.showModal = true; this.showTransferModal = true;
this.$nextTick(() => { this.$nextTick(() => {
if (window.HSStaticMethods) window.HSStaticMethods.autoInit('select'); if (window.HSStaticMethods) window.HSStaticMethods.autoInit('select');
}); });
}, },
async openDetails(id) { async openOrderDetails(id) {
this.loadingDetails = true; console.log('openOrderDetails triggered for ID:', id);
this.showDetailsPanel = true; this.showOrderDetails = true;
this.selectedOrder = null; this.detailsLoading = true;
this.orderItems = []; this.activeOrder = null;
this.activeItems = [];
try { try {
const response = await fetch(`/admin/warehouses/transfers/${id}/details`); const response = await fetch(`/admin/warehouses/transfers/${id}/details`, {
const result = await response.json(); headers: {
if (result.success) { 'X-Requested-With': 'XMLHttpRequest',
this.selectedOrder = result.order; 'Accept': 'application/json'
this.orderItems = result.items; }
}
} catch (error) {
console.error('Fetch error:', error);
} finally {
this.loadingDetails = false;
this.$nextTick(() => {
if (window.HSStaticMethods) window.HSStaticMethods.autoInit();
}); });
const data = await response.json();
if (data.success) {
this.activeOrder = data.order;
this.activeItems = data.items;
} else {
throw new Error(data.message || 'Failed to load details');
}
} catch (e) {
console.error('openOrderDetails error:', e);
window.showToast?.('{{ __("Failed to load details") }}', 'error');
} finally {
this.detailsLoading = false;
}
},
confirmTransfer(url) {
this.confirmUrl = url;
this.showConfirmModal = true;
},
confirmDelete(url) {
this.deleteUrl = url;
this.showDeleteModal = true;
},
async fetchTabData(url = null) {
this.loading = true;
const container = document.getElementById('transfers-table-container');
let targetUrl = url;
if (!targetUrl) {
const form = document.getElementById('transfer-filter-form');
const formData = new FormData(form);
const params = new URLSearchParams(formData);
targetUrl = `${form.action}?${params.toString()}`;
} else {
try {
const urlObj = new URL(targetUrl, window.location.origin);
const perPage = urlObj.searchParams.get('per_page');
if (perPage) {
const hiddenInput = document.querySelector('#transfer-filter-form input[name="per_page"]');
if (hiddenInput) hiddenInput.value = perPage;
}
} catch (e) {
console.error('URL sync error:', e);
}
}
try {
const response = await fetch(targetUrl, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
const data = await response.json();
if (data.success && container) {
container.outerHTML = data.html;
// Re-init HSSelect or other Preline components if needed
this.$nextTick(() => {
if (window.HSStaticMethods) window.HSStaticMethods.autoInit();
});
// Update URL without reload
window.history.pushState({}, '', targetUrl);
}
} catch (e) {
console.error(e);
window.showToast?.('{{ __("Loading failed") }}', 'error');
} finally {
this.loading = false;
} }
}, },
addItem() { addItem() {
this.items.push({ product_id: '', quantity: 1 }); this.items.push({ product_id: '', quantity: 1 });
this.$nextTick(() => { this.$nextTick(() => {
if (window.HSStaticMethods) window.HSStaticMethods.autoInit('select'); this.updateProductSelects();
}); });
}, },
removeItem(i) { if (this.items.length > 1) this.items.splice(i, 1); }, removeItem(i) { if (this.items.length > 1) this.items.splice(i, 1); },
async fetchStock() { async fetchStock() {
this.availableProducts = []; this.availableProducts = [];
if (!this.fromId || this.fromId.trim() === '') return; if (!this.fromId || this.fromId.trim() === '') {
this.$nextTick(() => this.updateProductSelects());
return;
}
this.isLoadingProducts = true; this.isLoadingProducts = true;
try { try {
@ -71,33 +147,96 @@
const json = await res.json(); const json = await res.json();
if (json.success) { if (json.success) {
this.availableProducts = json.data; this.availableProducts = json.data;
this.$nextTick(() => this.updateProductSelects());
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally { } finally {
this.isLoadingProducts = false; this.isLoadingProducts = false;
this.$nextTick(() => {
if (window.HSStaticMethods) window.HSStaticMethods.autoInit('select');
});
} }
}, },
updateProductSelects() {
this.items.forEach((item, index) => {
const wrapper = document.getElementById(`product-select-wrapper-${index}`);
if (!wrapper) return;
// 銷毀舊實例
const oldSelect = wrapper.querySelector('select');
if (oldSelect && window.HSSelect && window.HSSelect.getInstance(oldSelect)) {
try { window.HSSelect.getInstance(oldSelect).destroy(); } catch (e) {}
}
wrapper.innerHTML = '';
// 構建新 Select
const selectEl = document.createElement('select');
selectEl.id = `product-select-${index}-${Date.now()}`;
selectEl.name = `items[${index}][product_id]`;
selectEl.required = true;
selectEl.className = 'hidden';
const products = this.fromId ? this.availableProducts : this.allProducts;
// Placeholder
const placeholderOpt = document.createElement('option');
placeholderOpt.value = '';
placeholderOpt.textContent = '{{ __("Select Product") }}';
selectEl.appendChild(placeholderOpt);
products.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id;
const stockText = p.quantity !== undefined ? ` (${'{{ __("Stock") }}'}: ${p.quantity})` : '';
opt.textContent = p.name + stockText;
opt.setAttribute('data-title', p.name + stockText);
if (item.product_id == p.id) opt.selected = true;
selectEl.appendChild(opt);
});
// Preline Config
const config = {
"placeholder": "{{ __('Select Product') }}",
"toggleClasses": "hs-select-toggle luxury-select-toggle w-full text-left",
"toggleTemplate": "<button type=\"button\"><span class=\"text-slate-800 dark:text-slate-200\" data-title></span><div class=\"ms-auto\"><svg class=\"size-4 text-slate-400\" xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m6 9 6 6 6-6\"/></svg></div></button>",
"dropdownClasses": "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-2xl mt-2 z-[150] max-h-48 overflow-y-auto custom-scrollbar-thin",
"optionClasses": "hs-select-option py-2 px-3 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-cyan-500/10 dark:hover:text-cyan-400 rounded-lg",
"hasSearch": true,
"searchPlaceholder": "{{ __('Search Product') }}",
"searchClasses": "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200",
"searchWrapperClasses": "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10",
"strategy": "fixed"
};
selectEl.setAttribute('data-hs-select', JSON.stringify(config));
wrapper.appendChild(selectEl);
selectEl.addEventListener('change', (e) => {
item.product_id = e.target.value;
});
// Init Preline
if (window.HSStaticMethods && window.HSStaticMethods.autoInit) {
window.HSStaticMethods.autoInit('select');
}
});
},
resetFrom() { resetFrom() {
this.fromId = ''; this.fromId = '';
this.availableProducts = []; this.availableProducts = [];
this.$nextTick(() => { this.items.forEach(item => item.product_id = ''); // 清除已選商品
if (window.HSStaticMethods) window.HSStaticMethods.autoInit('select'); this.$nextTick(() => this.updateProductSelects());
});
}, },
async submitTransfer() { async submitTransfer() {
const form = document.getElementById('transferForm'); const form = document.getElementById('transferForm');
const type = this.transferType; const type = this.transferType;
const fromId = form.querySelector(type === 'warehouse_to_warehouse' ? '[name="from_warehouse_id"]' : '[name="from_machine_id"]')?.value; const fromId = this.fromId; // 使用 Alpine 變數更直接
const toId = form.querySelector('[name="to_warehouse_id"]')?.value; const toId = form.querySelector('[name="to_warehouse_id"]')?.value;
// 前端必填檢查
if (!fromId) { if (!fromId) {
window.showToast?.('{{ __("Please select source") }}', 'error'); const label = type === 'warehouse_to_warehouse' ? '{{ __("Source Warehouse") }}' : '{{ __("Source Machine") }}';
window.showToast?.('{{ __("Please select") }} ' + label, 'error');
return; return;
} }
if (!toId) { if (!toId) {
@ -114,15 +253,22 @@
return; return;
} }
let hasError = false; // 檢查每一列商品與數量
this.items.forEach(item => { let itemError = null;
if (!item.product_id || !item.quantity || item.quantity < 1) { for (let i = 0; i < this.items.length; i++) {
hasError = true; const item = this.items[i];
if (!item.product_id) {
itemError = '{{ __("Please select product for item :num") }}'.replace(':num', i + 1);
break;
} }
}); if (!item.quantity || item.quantity < 1) {
itemError = '{{ __("Please enter valid quantity for item :num") }}'.replace(':num', i + 1);
break;
}
}
if (hasError) { if (itemError) {
window.showToast?.('{{ __("Please select product and valid quantity for all items") }}', 'error'); window.showToast?.(itemError, 'error');
return; return;
} }
@ -140,16 +286,24 @@
}); });
const result = await response.json(); const result = await response.json();
if (result.success) { if (response.ok) {
window.showToast?.(result.message, 'success'); if (result.success) {
this.showModal = false; this.showTransferModal = false;
window.location.reload(); window.location.reload();
} else {
window.showToast?.(result.message || '{{ __("Creation failed") }}', 'error');
}
} else if (response.status === 422) {
// Laravel 驗證錯誤
const errors = result.errors || {};
const firstError = Object.values(errors)[0]?.[0] || '{{ __("Validation failed") }}';
window.showToast?.(firstError, 'error');
} else { } else {
window.showToast?.(result.message || '{{ __("Validation failed") }}', 'error'); window.showToast?.(result.message || '{{ __("System error, please try again later") }}', 'error');
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
window.showToast?.('{{ __("System Error") }}', 'error'); window.showToast?.('{{ __("Connection error or system failure") }}', 'error');
} finally { } finally {
this.loading = false; this.loading = false;
} }
@ -158,7 +312,10 @@
} }
</script> </script>
<div class="space-y-3 pb-10" x-data="transferManager()"> <div class="space-y-3 pb-10" x-data="transferManager()"
@open-details.window="openOrderDetails($event.detail.id)"
@ajax:filter.window="fetchTabData()"
@ajax:navigate:transfers.window.prevent="fetchTabData($event.detail.url)">
{{-- Header --}} {{-- Header --}}
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4"> <div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
@ -173,15 +330,50 @@
</div> </div>
{{-- Table --}} {{-- Table --}}
<div class="luxury-card rounded-3xl p-8 animate-luxury-in"> <div class="luxury-card rounded-3xl p-8 animate-luxury-in relative min-h-[400px]">
<form action="{{ route('admin.warehouses.transfers') }}" method="GET" class="flex flex-col md:flex-row md:items-center gap-4 mb-8"> <!-- Loading Overlay -->
<div x-show="loading" x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 z-20 bg-white/40 dark:bg-slate-900/40 backdrop-blur-[1px] flex flex-col items-center justify-center rounded-3xl"
x-cloak>
<div class="relative w-16 h-16 mb-4 flex items-center justify-center">
<div class="absolute inset-0 rounded-full border-2 border-transparent border-t-cyan-500 border-r-cyan-500/30 animate-spin"></div>
<div class="absolute inset-2 rounded-full border border-cyan-500/10 animate-spin" style="animation-duration: 3s; direction: reverse;"></div>
<div class="relative w-8 h-8 flex items-center justify-center">
<svg class="w-6 h-6 text-cyan-500 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
</div>
</div>
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.4em] animate-pulse">{{ __('Loading Data') }}...</p>
</div>
<form id="transfer-filter-form" action="{{ route('admin.warehouses.transfers') }}" method="GET" class="flex flex-col md:flex-row md:items-center gap-4 mb-8">
<input type="hidden" name="per_page" value="{{ request('per_page', 15) }}">
<div class="relative group flex-1 md:max-w-[240px]">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<input type="text" name="search_order" value="{{ request('search_order') }}"
@keydown.enter.prevent="$dispatch('ajax:filter')"
class="py-2.5 pl-12 pr-6 block w-full luxury-input" placeholder="{{ __('Search order number...') }}">
</div>
<div class="flex-1 md:max-w-[220px]"> <div class="flex-1 md:max-w-[220px]">
<x-searchable-select <x-searchable-select
name="type" name="type"
:selected="request('type')" :selected="request('type')"
:placeholder="__('All Types')" :placeholder="__('All Types')"
onchange="this.form.submit()" onchange="this.dispatchEvent(new CustomEvent('ajax:filter', { bubbles: true }))"
> >
<option value="" data-title="{{ __('All Types') }}">{{ __('All Types') }}</option>
<option value="warehouse_to_warehouse" {{ request('type') === 'warehouse_to_warehouse' ? 'selected' : '' }} data-title="{{ __('Warehouse to Warehouse') }}">{{ __('Warehouse to Warehouse') }}</option> <option value="warehouse_to_warehouse" {{ request('type') === 'warehouse_to_warehouse' ? 'selected' : '' }} data-title="{{ __('Warehouse to Warehouse') }}">{{ __('Warehouse to Warehouse') }}</option>
<option value="machine_to_warehouse" {{ request('type') === 'machine_to_warehouse' ? 'selected' : '' }} data-title="{{ __('Machine to Warehouse') }}">{{ __('Machine to Warehouse') }}</option> <option value="machine_to_warehouse" {{ request('type') === 'machine_to_warehouse' ? 'selected' : '' }} data-title="{{ __('Machine to Warehouse') }}">{{ __('Machine to Warehouse') }}</option>
</x-searchable-select> </x-searchable-select>
@ -191,89 +383,89 @@
name="status" name="status"
:selected="request('status')" :selected="request('status')"
:placeholder="__('All Statuses')" :placeholder="__('All Statuses')"
onchange="this.form.submit()" onchange="this.dispatchEvent(new CustomEvent('ajax:filter', { bubbles: true }))"
> >
<option value="" data-title="{{ __('All Statuses') }}">{{ __('All Statuses') }}</option>
<option value="draft" {{ request('status') === 'draft' ? 'selected' : '' }} data-title="{{ __('Draft') }}">{{ __('Draft') }}</option> <option value="draft" {{ request('status') === 'draft' ? 'selected' : '' }} data-title="{{ __('Draft') }}">{{ __('Draft') }}</option>
<option value="completed" {{ request('status') === 'completed' ? 'selected' : '' }} data-title="{{ __('Completed') }}">{{ __('Completed') }}</option> <option value="completed" {{ request('status') === 'completed' ? 'selected' : '' }} data-title="{{ __('Completed') }}">{{ __('Completed') }}</option>
<option value="cancelled" {{ request('status') === 'cancelled' ? 'selected' : '' }} data-title="{{ __('Cancelled') }}">{{ __('Cancelled') }}</option> <option value="cancelled" {{ request('status') === 'cancelled' ? 'selected' : '' }} data-title="{{ __('Cancelled') }}">{{ __('Cancelled') }}</option>
</x-searchable-select> </x-searchable-select>
</div> </div>
{{-- Date Range --}}
<div class="flex-1 md:max-w-[320px]">
<div class="relative group"
x-data="{ fp: null }"
x-init="fp = flatpickr($refs.dateRange, {
mode: 'range',
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
defaultHour: 0,
defaultMinute: 0,
locale: 'zh_tw',
defaultDate: '{{ request('start_date') }}' && '{{ request('end_date') }}' ? ['{{ request('start_date') }}', '{{ request('end_date') }}'] : [],
onChange: function(selectedDates, dateStr, instance) {
if (selectedDates.length === 2) {
$refs.startDate.value = instance.formatDate(selectedDates[0], 'Y-m-d H:i');
$refs.endDate.value = instance.formatDate(selectedDates[1], 'Y-m-d H:i');
} else if (selectedDates.length === 0) {
$refs.startDate.value = '';
$refs.endDate.value = '';
}
}
})">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10 text-slate-400 group-focus-within:text-cyan-500 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</span>
<input type="hidden" name="start_date" x-ref="startDate" value="{{ request('start_date') }}">
<input type="hidden" name="end_date" x-ref="endDate" value="{{ request('end_date') }}">
<input type="text" x-ref="dateRange"
value="{{ request('start_date') && request('end_date') ? request('start_date') . ' 至 ' . request('end_date') : (request('start_date') ?: '') }}"
placeholder="{{ __('Select Date Range') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-full cursor-pointer">
</div>
</div>
<button type="button" @click="fetchTabData()" class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 group" title="{{ __('Search') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<button type="button"
@click="
$el.closest('form').querySelectorAll('input[type=text], input[type=hidden]:not([name=per_page])').forEach(i => i.value = '');
$el.closest('form').querySelectorAll('select').forEach(s => {
s.value = ' ';
const instance = window.HSSelect.getInstance(s);
if (instance) instance.setValue(' ');
});
fetchTabData();
"
class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-700 group" title="{{ __('Reset') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</form> </form>
<div class="overflow-x-auto"> @include('admin.warehouses.partials.table-transfers')
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Order No.') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Type') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('From') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('To') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Items') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($orders as $order)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-3.5"><span class="font-mono font-extrabold text-slate-800 dark:text-slate-100">{{ $order->order_no }}</span></td>
<td class="px-6 py-5 text-center">
@if($order->type === 'warehouse_to_warehouse')
<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-[10px] font-black bg-indigo-500/10 text-indigo-500 border border-indigo-500/20 tracking-widest uppercase">W2W</span>
@else
<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-[10px] font-black bg-amber-500/10 text-amber-500 border border-amber-500/20 tracking-widest uppercase">M2W</span>
@endif
</td>
<td class="px-6 py-3.5 font-bold text-slate-600 dark:text-slate-300">
{{ $order->type === 'warehouse_to_warehouse' ? ($order->fromWarehouse?->name ?? '-') : ($order->fromMachine?->name ?? '-') }}
</td>
<td class="px-6 py-3.5 font-bold text-slate-600 dark:text-slate-300">{{ $order->toWarehouse?->name ?? '-' }}</td>
<td class="px-6 py-3.5 text-center font-black text-slate-800 dark:text-white">{{ $order->items->count() }}</td>
<td class="px-6 py-3.5 text-center">
@php
$colors = ['draft' => 'amber', 'completed' => 'emerald', 'cancelled' => 'slate'];
$sColor = $colors[$order->status] ?? 'cyan';
@endphp
<span class="inline-flex items-center px-3 py-1.5 rounded-xl text-[10px] font-black bg-{{ $sColor }}-500/10 text-{{ $sColor }}-500 border border-{{ $sColor }}-500/20 tracking-widest uppercase">{{ __(ucfirst($order->status)) }}</span>
</td>
<td class="px-6 py-3.5 text-right flex items-center justify-end gap-2">
<button type="button" @click="openDetails({{ $order->id }})" class="p-2 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 transition-all border border-slate-100 dark:border-slate-700" title="{{ __('View Details') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12.a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>
</button>
@if($order->status === 'draft')
<form action="{{ route('admin.warehouses.transfers.confirm', $order->id) }}" method="POST" class="inline" onsubmit="return confirm('{{ __('Confirm this transfer?') }}')">
@csrf @method('PATCH')
<button type="submit" class="px-4 py-2 rounded-xl text-xs font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 hover:bg-emerald-500 hover:text-white transition-all">{{ __('Confirm') }}</button>
</form>
@else
<span class="text-xs font-bold text-slate-400">{{ $order->completed_at?->format('Y-m-d H:i') }}</span>
@endif
</td>
</tr>
@empty
<tr><td colspan="7" class="px-6 py-20 text-center text-slate-400 font-bold">{{ __('No transfer orders found') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-6 py-6 border-t border-slate-50 dark:border-slate-800/50">{{ $orders->links('vendor.pagination.luxury') }}</div>
</div> </div>
{{-- New Transfer Modal --}} {{-- New Transfer Modal --}}
<div x-show="showModal" class="fixed inset-0 z-[110] overflow-y-auto" style="display: none;" role="dialog" aria-modal="true" x-cloak> <div x-show="showTransferModal" class="fixed inset-0 z-[110] overflow-y-auto" style="display: none;" role="dialog" aria-modal="true" x-cloak>
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0"> <div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<!-- Background Backdrop --> <!-- Background Backdrop -->
<div x-show="showModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" <div x-show="showTransferModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-200" x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
@click="showModal = false"></div> @click="showTransferModal = false"></div>
<span class="hidden sm:inline-block sm:align-middle sm:min-h-screen" aria-hidden="true">&#8203;</span> <span class="hidden sm:inline-block sm:align-middle sm:min-h-screen" aria-hidden="true">&#8203;</span>
<!-- Modal Panel --> <!-- Modal Panel -->
<div x-show="showModal" <div x-show="showTransferModal"
x-transition:enter="ease-out duration-300" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
@ -293,7 +485,7 @@
{{ __('Create stock transfers between warehouses and machine returns') }} {{ __('Create stock transfers between warehouses and machine returns') }}
</p> </p>
</div> </div>
<button @click="showModal = false" <button @click="showTransferModal = false"
class="p-2.5 rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-all border border-slate-100 dark:border-slate-700 shadow-sm"> class="p-2.5 rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-all border border-slate-100 dark:border-slate-700 shadow-sm">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
@ -337,7 +529,7 @@
:placeholder="__('Select Warehouse')" :placeholder="__('Select Warehouse')"
class="w-full" class="w-full"
x-model="fromId" x-model="fromId"
@change="fetchStock()" @change="fromId = $event.target.value; fetchStock()"
/> />
</div> </div>
<div x-show="transferType === 'machine_to_warehouse'" class="space-y-3"> <div x-show="transferType === 'machine_to_warehouse'" class="space-y-3">
@ -350,7 +542,7 @@
:placeholder="__('Select Machine')" :placeholder="__('Select Machine')"
class="w-full" class="w-full"
x-model="fromId" x-model="fromId"
@change="fetchStock()" @change="fromId = $event.target.value; fetchStock()"
/> />
</div> </div>
@ -395,17 +587,13 @@
<div class="space-y-3"> <div class="space-y-3">
<template x-for="(item, index) in items" :key="index"> <template x-for="(item, index) in items" :key="index">
<div class="group flex items-center gap-3 p-3 bg-slate-50/50 dark:bg-slate-900/30 rounded-2xl border border-slate-100 dark:border-slate-800/50 hover:border-cyan-500/30 transition-all duration-300"> <div class="group flex items-center gap-3 p-3 bg-slate-50/50 dark:bg-slate-900/30 rounded-2xl border border-slate-100 dark:border-slate-800/50 hover:border-cyan-500/30 transition-all duration-300">
<div class="flex-1 min-w-[200px]" :key="'select-wrapper-' + index + '-' + (fromId ? 'filtered-' + fromId + '-' + availableProducts.length : 'all')"> <div class="flex-1 min-w-[200px] relative"
<select :id="'product-select-' + index + '-' + Date.now()" :name="'items['+index+'][product_id]'" x-model="item.product_id" required :id="'product-select-wrapper-' + index"
data-hs-select='{"placeholder": "{{ __('Select Product') }}","toggleClasses": "hs-select-toggle luxury-select-toggle w-full text-left","toggleTemplate": "<button type=\"button\"><span class=\"text-slate-800 dark:text-slate-200\" data-title></span><div class=\"ms-auto\"><svg class=\"size-4 text-slate-400\" xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m6 9 6 6 6-6\"/></svg></div></button>","dropdownClasses": "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-2xl mt-2 z-[150] max-h-48 overflow-y-auto custom-scrollbar-thin","optionClasses": "hs-select-option py-2 px-3 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-cyan-500/10 dark:hover:text-cyan-400 rounded-lg","hasSearch": true,"searchPlaceholder": "{{ __('Search Product') }}","searchClasses": "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200","searchWrapperClasses": "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10","strategy": "fixed"}' class="hidden"> x-init="$nextTick(() => updateProductSelects())">
<option value="">{{ __('Select Product') }}</option> <!-- 動態渲染商品 Select -->
<template x-for="p in (fromId ? availableProducts : allProducts)" :key="p.id"> <div x-show="isLoadingProducts" class="absolute inset-0 bg-white/50 dark:bg-slate-900/50 flex items-center justify-center z-10 rounded-xl">
<option :value="p.id" <div class="w-4 h-4 border-2 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin"></div>
x-text="p.name + (p.quantity !== undefined ? ' (' + '{{ __('Stock') }}: ' + p.quantity + ')' : '')" </div>
:data-title="p.name + (p.quantity !== undefined ? ' (' + '{{ __('Stock') }}: ' + p.quantity + ')' : '')">
</option>
</template>
</select>
</div> </div>
<div class="w-px h-6 bg-slate-200 dark:bg-slate-800/50"></div> <div class="w-px h-6 bg-slate-200 dark:bg-slate-800/50"></div>
<div class="flex items-center gap-1 bg-slate-100/50 dark:bg-slate-900/50 p-1 rounded-xl border border-slate-200/50 dark:border-slate-800/50"> <div class="flex items-center gap-1 bg-slate-100/50 dark:bg-slate-900/50 p-1 rounded-xl border border-slate-200/50 dark:border-slate-800/50">
@ -437,7 +625,7 @@
<!-- Modal Footer --> <!-- Modal Footer -->
<div class="px-10 py-6 border-t border-slate-100 dark:border-slate-800/50 flex items-center justify-end gap-4"> <div class="px-10 py-6 border-t border-slate-100 dark:border-slate-800/50 flex items-center justify-end gap-4">
<button type="button" @click="showModal = false" class="btn-luxury-ghost px-8"> <button type="button" @click="showTransferModal = false" class="btn-luxury-ghost px-8">
{{ __('Cancel') }} {{ __('Cancel') }}
</button> </button>
<button type="button" @click="submitTransfer()" <button type="button" @click="submitTransfer()"
@ -456,142 +644,290 @@
</div> </div>
</div> </div>
</div> </div>
{{-- Details Offcanvas --}} </div>
<div x-show="showDetailsPanel" {{-- Details Slide-over --}}
class="fixed inset-0 z-[120] overflow-hidden" <div x-show="showOrderDetails" class="fixed inset-0 z-[120] overflow-hidden" style="display: none;" x-cloak>
style="display: none;"
x-cloak>
<div class="absolute inset-0 overflow-hidden"> <div class="absolute inset-0 overflow-hidden">
<div class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm transition-opacity" <!-- Backdrop -->
@click="showDetailsPanel = false" <div x-show="showOrderDetails"
x-show="showDetailsPanel" x-transition:enter="ease-in-out duration-500" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:enter="ease-in-out duration-500" x-transition:leave="ease-in-out duration-500" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
x-transition:enter-start="opacity-0" class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
x-transition:enter-end="opacity-100" @click="showOrderDetails = false"></div>
x-transition:leave="ease-in-out duration-500"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"></div>
<div class="fixed inset-y-0 right-0 flex max-w-full pl-10"> <div class="fixed inset-y-0 right-0 max-w-full flex">
<div class="w-screen max-w-md transform transition duration-500 ease-in-out sm:max-w-lg" <div x-show="showOrderDetails"
x-show="showDetailsPanel" x-transition:enter="transform transition ease-in-out duration-500 sm:duration-700" x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
x-transition:enter="transform transition ease-in-out duration-500 sm:duration-700" x-transition:leave="transform transition ease-in-out duration-500 sm:duration-700" x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
x-transition:enter-start="translate-x-full" class="w-screen max-w-2xl">
x-transition:enter-end="translate-x-0"
x-transition:leave="transform transition ease-in-out duration-500 sm:duration-700"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full">
<div class="flex h-full flex-col bg-white dark:bg-slate-900 shadow-2xl border-l border-slate-100 dark:border-slate-800"> <div class="h-full flex flex-col bg-white dark:bg-slate-900 shadow-2xl border-l border-slate-100 dark:border-slate-800">
<!-- Header --> <!-- Header -->
<div class="px-8 py-8 bg-slate-50/50 dark:bg-slate-900/50 border-b border-slate-100 dark:border-slate-800"> <div class="px-5 py-6 sm:px-8 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between gap-4">
<div> <div class="min-w-0 flex-1">
<h2 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight leading-none mb-2" x-text="selectedOrder ? selectedOrder.order_no : '{{ __('Loading...') }}'"></h2> <h2 class="text-xl sm:text-2xl font-black text-slate-800 dark:text-white font-display flex items-center gap-2 sm:gap-3">
<p class="text-xs font-bold text-slate-400 uppercase tracking-[0.2em]">{{ __('Transfer Details') }}</p> <svg class="w-5 h-5 sm:w-6 sm:h-6 text-cyan-500 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34" />
<path d="M11 9.66V7a2 2 0 0 0-2-2H5.66" />
<path d="m20 21-5-5" />
<path d="m15 21 5-5" />
<path d="M12 13H7" />
<path d="M12 17H7" />
<path d="M17 12V7a2 2 0 0 0-2-2h-5" />
</svg>
<span class="truncate">{{ __('Transfer Details') }}</span>
</h2>
<template x-if="activeOrder">
<div class="mt-2 flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 font-bold uppercase tracking-widest">
<span x-text="activeOrder.order_no" class="text-cyan-600 dark:text-cyan-400 font-mono tracking-tighter text-lg"></span>
</div>
</template>
</div> </div>
<button @click="showDetailsPanel = false" class="p-2 rounded-full hover:bg-white dark:hover:bg-slate-800 text-slate-400 transition-all"> <button type="button" @click="showOrderDetails = false"
<svg class="w-6 h-6 stroke-[2]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg> class="bg-white dark:bg-slate-800 rounded-full p-2 text-slate-400 hover:text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 focus:outline-none transition duration-300 shadow-sm border border-slate-200 dark:border-slate-700">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button> </button>
</div> </div>
</div> </div>
<!-- Content --> <!-- Content -->
<div class="flex-1 overflow-y-auto custom-scrollbar p-8"> <div class="flex-1 overflow-y-auto p-6 sm:p-8">
<template x-if="loadingDetails"> <div class="relative min-h-[200px]">
<div class="flex flex-col items-center justify-center h-64 space-y-4"> <!-- Loading State -->
<div class="w-12 h-12 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin"></div> <div x-show="detailsLoading"
<p class="text-sm font-bold text-slate-400 animate-pulse">{{ __('Fetching data...') }}</p> class="absolute inset-0 z-50 flex flex-col items-center justify-center bg-white/40 dark:bg-slate-900/40 backdrop-blur-[1px]">
<div class="w-10 h-10 border-2 border-cyan-500 border-t-transparent rounded-full animate-spin"></div>
</div> </div>
</template>
<template x-if="!loadingDetails && selectedOrder"> <template x-if="activeOrder">
<div class="space-y-8 animate-luxury-in"> <div class="space-y-10">
<!-- Info Cards --> <!-- Transfer Flow -->
<div class="grid grid-cols-2 gap-4"> <div class="flex items-center gap-4 justify-center">
<div class="p-4 rounded-2xl bg-slate-50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-800"> <div class="flex-1 text-center p-6 bg-slate-50 dark:bg-slate-800/50 rounded-3xl border border-slate-100 dark:border-slate-800 shadow-sm transition-all duration-300 hover:shadow-md">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Status') }}</p> <p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2">{{ __('Source') }}</p>
<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-[10px] font-black tracking-widest uppercase" <p class="text-sm font-black text-slate-800 dark:text-white" x-text="activeOrder.from_name"></p>
:class="{
'bg-amber-500/10 text-amber-500 border border-amber-500/20': selectedOrder.status === 'draft',
'bg-emerald-500/10 text-emerald-500 border border-emerald-500/20': selectedOrder.status === 'completed',
'bg-slate-500/10 text-slate-500 border border-slate-500/20': selectedOrder.status === 'cancelled'
}"
x-text="selectedOrder.status"></span>
</div>
<div class="p-4 rounded-2xl bg-slate-50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-800">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Type') }}</p>
<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-[10px] font-black tracking-widest uppercase"
:class="selectedOrder.type === 'warehouse_to_warehouse' ? 'bg-indigo-500/10 text-indigo-500' : 'bg-amber-500/10 text-amber-500'"
x-text="selectedOrder.type === 'warehouse_to_warehouse' ? 'W2W' : 'M2W'"></span>
</div>
</div>
<div class="space-y-4">
<div class="flex items-center gap-4">
<div class="flex-1 p-4 rounded-2xl bg-slate-50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-800">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('From') }}</p>
<p class="font-bold text-slate-800 dark:text-white" x-text="selectedOrder.from_name || '-'"></p>
</div> </div>
<div class="flex-shrink-0 text-slate-300"> <div class="flex-shrink-0 w-12 h-12 flex items-center justify-center bg-cyan-500 text-white rounded-2xl shadow-lg shadow-cyan-500/20 animate-pulse">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" /></svg> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</div> </div>
<div class="flex-1 p-4 rounded-2xl bg-slate-50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-800"> <div class="flex-1 text-center p-6 bg-slate-50 dark:bg-slate-800/50 rounded-3xl border border-slate-100 dark:border-slate-800 shadow-sm transition-all duration-300 hover:shadow-md">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('To') }}</p> <p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2">{{ __('Target') }}</p>
<p class="font-bold text-slate-800 dark:text-white" x-text="selectedOrder.to_warehouse_name || '-'"></p> <p class="text-sm font-black text-slate-800 dark:text-white" x-text="activeOrder.to_name"></p>
</div> </div>
</div> </div>
</div>
<div class="space-y-3"> <!-- Info Grid -->
<h4 class="text-xs font-black text-slate-400 uppercase tracking-[0.2em] pl-1">{{ __('Order Items') }}</h4> <div class="grid grid-cols-2 gap-8 p-8 bg-slate-50/50 dark:bg-slate-800/30 rounded-[2rem] border border-slate-100 dark:border-slate-800">
<div class="space-y-2"> <div class="space-y-2">
<template x-for="item in orderItems" :key="item.product_id"> <p class="text-xs font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('Status') }}</p>
<div class="flex items-center gap-4 p-4 bg-slate-50 dark:bg-slate-800/30 rounded-2xl border border-slate-100 dark:border-slate-800/50"> <div>
<div class="w-12 h-12 rounded-xl bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 p-1 overflow-hidden flex-shrink-0"> <span class="inline-flex items-center px-2 py-0.5 rounded-lg text-xs font-black tracking-widest uppercase border"
<img :src="item.image_url || '/images/placeholder-product.png'" class="w-full h-full object-cover rounded-lg" alt=""> :class="{
</div> 'bg-amber-500/10 text-amber-500 border-amber-500/20': activeOrder.status === 'draft',
<div class="flex-1 min-w-0"> 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20': activeOrder.status === 'completed',
<p class="text-sm font-bold text-slate-800 dark:text-slate-200 truncate" x-text="item.product_name"></p> 'bg-slate-500/10 text-slate-500 border-slate-500/20': activeOrder.status === 'cancelled'
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest" x-text="'ID: ' + item.product_id"></p> }"
</div> x-text="{
<div class="text-right"> 'draft': '{{ __('Draft') }}',
<p class="text-sm font-black text-slate-800 dark:text-white" x-text="'x' + item.quantity"></p> 'completed': '{{ __('Completed') }}',
</div> 'cancelled': '{{ __('Cancelled') }}'
}[activeOrder.status] || activeOrder.status"></span>
</div>
</div>
<div class="space-y-2">
<p class="text-xs font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('Transfer Type') }}</p>
<div>
<span class="inline-flex items-center px-2 py-0.5 rounded-lg text-xs font-black tracking-widest uppercase border border-slate-200 dark:border-slate-700 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300"
x-text="{
'warehouse_to_warehouse': '{{ __('Warehouse to Warehouse') }}',
'machine_to_warehouse': '{{ __('Machine to Warehouse') }}'
}[activeOrder.type] || activeOrder.type"></span>
</div>
</div>
<div class="space-y-2">
<p class="text-xs font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('Created By') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300" x-text="activeOrder.creator_name"></p>
</div>
<div class="space-y-2">
<p class="text-xs font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('Created At') }}</p>
<p class="text-sm font-mono font-bold text-slate-600 dark:text-slate-400" x-text="activeOrder.created_at"></p>
</div>
<div class="space-y-2">
<p class="text-xs font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('Completed At') }}</p>
<p class="text-sm font-mono font-bold text-slate-600 dark:text-slate-400" x-text="activeOrder.completed_at || '-'"></p>
</div>
<template x-if="activeOrder.note">
<div class="col-span-2 space-y-1 pt-2 border-t border-slate-200/50 dark:border-slate-700/50">
<p class="text-xs font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('Note') }}</p>
<p class="text-xs font-bold text-slate-600 dark:text-slate-400 italic" x-text="activeOrder.note"></p>
</div> </div>
</template> </template>
</div> </div>
</div>
<div class="p-6 rounded-3xl bg-slate-50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-800 space-y-4"> <!-- Items List -->
<div class="flex justify-between items-center"> <div class="space-y-5">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Created By') }}</p> <div class="flex items-center justify-between px-1">
<p class="text-xs font-bold text-slate-600 dark:text-slate-300" x-text="selectedOrder.creator_name"></p> <h3 class="text-sm font-black text-slate-800 dark:text-white uppercase tracking-widest">{{ __('Product Details') }}</h3>
</div> <span class="text-[10px] font-black text-cyan-500 bg-cyan-500/10 px-2 py-0.5 rounded-md uppercase" x-text="activeItems.length + ' {{ __('Items') }}'"></span>
<div class="flex justify-between items-center">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Created At') }}</p>
<p class="text-xs font-bold text-slate-600 dark:text-slate-300" x-text="selectedOrder.created_at"></p>
</div>
<template x-if="selectedOrder.completed_at">
<div class="flex justify-between items-center pt-2 border-t border-slate-200 dark:border-slate-700">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Completed At') }}</p>
<p class="text-xs font-bold text-emerald-500" x-text="selectedOrder.completed_at"></p>
</div> </div>
</template>
<template x-if="selectedOrder.note"> <div class="space-y-3">
<div class="pt-2 border-t border-slate-200 dark:border-slate-700"> <template x-for="(item, idx) in activeItems" :key="idx">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Note') }}</p> <div class="luxury-card p-4 rounded-2xl flex items-center gap-4 group/item transition-all hover:border-cyan-500/30">
<p class="text-xs font-bold text-slate-600 dark:text-slate-300 italic" x-text="selectedOrder.note"></p> <div class="w-16 h-16 rounded-xl bg-slate-100 dark:bg-slate-800 flex-shrink-0 overflow-hidden border border-slate-200 dark:border-slate-700 group-hover:scale-105 transition-transform">
<template x-if="item.image_url">
<img :src="item.image_url" class="w-full h-full object-cover">
</template>
<template x-if="!item.image_url">
<div class="w-full h-full flex items-center justify-center text-slate-400">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
</template>
</div>
<div class="flex-1 min-w-0">
<p class="text-base font-extrabold text-slate-800 dark:text-white truncate" x-text="item.product_name"></p>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs font-black text-slate-400 uppercase tracking-widest">{{ __('Product ID') }}:</span>
<span class="text-xs font-mono font-bold text-cyan-600 dark:text-cyan-400" x-text="item.product_id"></span>
</div>
</div>
<div class="text-right">
<p class="text-2xl font-black text-cyan-600 dark:text-cyan-400 font-mono tracking-tighter" x-text="'x' + item.quantity"></p>
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Quantity') }}</p>
</div>
</div>
</template>
</div> </div>
</template> </div>
</div> </div>
</div> </template>
</template> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{{-- Confirmation Modal --}}
<template x-teleport="body">
<div x-show="showConfirmModal" class="fixed inset-0 z-[200] overflow-y-auto" x-cloak>
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div x-show="showConfirmModal" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm"
@click="showConfirmModal = false"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div x-show="showConfirmModal" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white dark:bg-slate-900 rounded-3xl shadow-2xl sm:my-8 sm:align-middle sm:max-w-md sm:w-full sm:p-8 border border-slate-100 dark:border-slate-800">
<div class="sm:flex sm:items-start text-center sm:text-left">
<div class="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-emerald-100 dark:bg-emerald-500/10 rounded-2xl sm:mx-0 sm:h-12 sm:w-12 text-emerald-600 dark:text-emerald-400">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="mt-3 sm:mt-0 sm:ml-6">
<h3 class="text-xl font-black text-slate-800 dark:text-white leading-6 tracking-tight font-display uppercase">
{{ __('Confirm Transfer Order') }}
</h3>
<div class="mt-4">
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 leading-relaxed">
{{ __('Are you sure you want to confirm this transfer? This will deduct stock from source and add to target.') }}
</p>
</div>
</div>
</div>
<div class="mt-8 sm:mt-10 sm:flex sm:flex-row-reverse gap-3">
<form :action="confirmUrl" method="POST" class="inline">
@csrf
@method('PATCH')
<button type="submit"
class="inline-flex justify-center w-full px-6 py-3 text-sm font-black text-white transition-all bg-emerald-500 rounded-xl hover:bg-emerald-600 shadow-lg shadow-emerald-200 dark:shadow-none hover:scale-[1.02] active:scale-[0.98] sm:w-auto uppercase tracking-widest font-display">
{{ __('Confirm Transfer') }}
</button>
</form>
<button type="button" @click="showConfirmModal = false"
class="inline-flex justify-center w-full px-6 py-3 mt-3 text-sm font-black text-slate-700 dark:text-slate-200 transition-all bg-slate-100 dark:bg-slate-800 rounded-xl hover:bg-slate-200 dark:hover:bg-slate-700 sm:mt-0 sm:w-auto uppercase tracking-widest font-display">
{{ __('Cancel') }}
</button>
</div>
</div>
</div>
</div>
</template>
{{-- Delete Confirmation Modal --}}
<template x-teleport="body">
<div x-show="showDeleteModal" class="fixed inset-0 z-[200] overflow-y-auto" x-cloak>
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div x-show="showDeleteModal" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm"
@click="showDeleteModal = false"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div x-show="showDeleteModal" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white dark:bg-slate-900 rounded-3xl shadow-2xl sm:my-8 sm:align-middle sm:max-w-md sm:w-full sm:p-8 border border-slate-100 dark:border-slate-800">
<div class="sm:flex sm:items-start text-center sm:text-left">
<div class="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-amber-100 dark:bg-amber-500/10 rounded-2xl sm:mx-0 sm:h-12 sm:w-12 text-amber-600 dark:text-amber-400">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<div class="mt-3 sm:mt-0 sm:ml-6">
<h3 class="text-xl font-black text-slate-800 dark:text-white leading-6 tracking-tight font-display uppercase">
{{ __('Confirm Deletion') }}
</h3>
<div class="mt-4">
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 leading-relaxed">
{{ __('Are you sure you want to delete this draft order? This action cannot be undone.') }}
</p>
</div>
</div>
</div>
<div class="mt-8 sm:mt-10 sm:flex sm:flex-row-reverse gap-3">
<form :action="deleteUrl" method="POST" class="inline">
@csrf
@method('DELETE')
<button type="submit"
class="inline-flex justify-center w-full px-6 py-3 text-sm font-black text-white transition-all bg-rose-500 rounded-xl hover:bg-rose-600 shadow-lg shadow-rose-200 dark:shadow-none hover:scale-[1.02] active:scale-[0.98] sm:w-auto uppercase tracking-widest font-display">
{{ __('Delete Permanently') }}
</button>
</form>
<button type="button" @click="showDeleteModal = false"
class="inline-flex justify-center w-full px-6 py-3 mt-3 text-sm font-black text-slate-700 dark:text-slate-200 transition-all bg-slate-100 dark:bg-slate-800 rounded-xl hover:bg-slate-200 dark:hover:bg-slate-700 sm:mt-0 sm:w-auto uppercase tracking-widest font-display">
{{ __('Cancel') }}
</button>
</div>
</div>
</div>
</div>
</template>
</div> </div>
@endsection @endsection

View File

@ -95,6 +95,7 @@
}" }"
@click.outside="open = false" @click.outside="open = false"
@keydown.escape.window="open = false" @keydown.escape.window="open = false"
@multi-select-reset.window="clear()"
{{ $attributes->except(['class', 'options', 'selected', 'placeholder', 'id', 'name', 'hasSearch', 'no-auto-submit']) }}> {{ $attributes->except(['class', 'options', 'selected', 'placeholder', 'id', 'name', 'hasSearch', 'no-auto-submit']) }}>
{{-- 隱藏 input 送出表單用 --}} {{-- 隱藏 input 送出表單用 --}}

View File

@ -24,6 +24,9 @@ x-init="
window.dispatchEvent(new CustomEvent('toast', { detail: { message, type } })); window.dispatchEvent(new CustomEvent('toast', { detail: { message, type } }));
} }
}); });
window.showToast = (message, type = 'success') => {
window.Alpine.store('toast').show(message, type);
};
@if(session('success')) add('{{ addslashes(session('success')) }}', 'success'); @endif @if(session('success')) add('{{ addslashes(session('success')) }}', 'success'); @endif
@if(session('error')) add('{{ addslashes(session('error')) }}', 'error'); @endif @if(session('error')) add('{{ addslashes(session('error')) }}', 'error'); @endif
@foreach($allErrors as $error) add('{{ addslashes($error) }}', 'error'); @endforeach @foreach($allErrors as $error) add('{{ addslashes($error) }}', 'error'); @endforeach

View File

@ -9,8 +9,9 @@
@php @php
$currentLimit = $paginator->perPage(); $currentLimit = $paginator->perPage();
$limits = [10, 25, 50, 100]; $limits = [10, 25, 50, 100];
$event = $ajax_navigate_event ?? 'ajax:navigate';
@endphp @endphp
<select onchange="{ const params = new URLSearchParams(window.location.search); params.set('per_page', this.value); params.delete('page'); const url = window.location.pathname + '?' + params.toString(); const evt = new CustomEvent('ajax:navigate', { detail: { url: url }, bubbles: true, cancelable: true }); if (this.dispatchEvent(evt)) { window.location.href = url; } }" <select onchange="{ const params = new URLSearchParams(window.location.search); params.set('per_page', this.value); params.delete('page'); const url = window.location.pathname + '?' + params.toString(); const evt = new CustomEvent('{{ $event }}', { detail: { url: url }, bubbles: true, cancelable: true }); if (this.dispatchEvent(evt)) { window.location.href = url; } }"
class="h-7 pl-2 pr-7 rounded-lg bg-white dark:bg-slate-800 border-none text-[11px] font-black text-slate-600 dark:text-slate-300 appearance-none focus:ring-4 focus:ring-cyan-500/10 outline-none transition-all cursor-pointer shadow-sm leading-none py-0 !bg-none"> class="h-7 pl-2 pr-7 rounded-lg bg-white dark:bg-slate-800 border-none text-[11px] font-black text-slate-600 dark:text-slate-300 appearance-none focus:ring-4 focus:ring-cyan-500/10 outline-none transition-all cursor-pointer shadow-sm leading-none py-0 !bg-none">
@foreach($limits as $l) @foreach($limits as $l)
<option value="{{ $l }}" {{ $currentLimit == $l ? 'selected' : '' }}>{{ $l }}</option> <option value="{{ $l }}" {{ $currentLimit == $l ? 'selected' : '' }}>{{ $l }}</option>
@ -44,7 +45,7 @@
</span> </span>
@else @else
<a href="{{ $paginator->previousPageUrl() }}" <a href="{{ $paginator->previousPageUrl() }}"
onclick="{ event.preventDefault(); const url = this.href; const evt = new CustomEvent('ajax:navigate', { detail: { url: url }, bubbles: true, cancelable: true }); if (this.dispatchEvent(evt)) { window.location.href = url; } }" onclick="{ event.preventDefault(); const url = this.href; const evt = new CustomEvent('{{ $event }}', { detail: { url: url }, bubbles: true, cancelable: true }); if (this.dispatchEvent(evt)) { window.location.href = url; } }"
class="h-9 inline-flex items-center gap-x-1.5 sm:gap-x-2 px-3 sm:px-4 rounded-xl text-[10px] sm:text-xs font-black bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-300 border border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800 transition-all shadow-sm group"> class="h-9 inline-flex items-center gap-x-1.5 sm:gap-x-2 px-3 sm:px-4 rounded-xl text-[10px] sm:text-xs font-black bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-300 border border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800 transition-all shadow-sm group">
<svg class="size-3.5 sm:size-4 text-cyan-500 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M15 19l-7-7 7-7"/></svg> <svg class="size-3.5 sm:size-4 text-cyan-500 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M15 19l-7-7 7-7"/></svg>
<span class="hidden xxs:inline">{{ __('Previous') }}</span> <span class="hidden xxs:inline">{{ __('Previous') }}</span>
@ -53,7 +54,7 @@
{{-- Unified Quick Jump Selection (Desktop & Mobile) --}} {{-- Unified Quick Jump Selection (Desktop & Mobile) --}}
<div class="relative group"> <div class="relative group">
<select onchange="{ const url = this.value; const evt = new CustomEvent('ajax:navigate', { detail: { url: url }, bubbles: true, cancelable: true }); if (this.dispatchEvent(evt)) { window.location.href = url; } }" <select onchange="{ const url = this.value; const evt = new CustomEvent('{{ $event }}', { detail: { url: url }, bubbles: true, cancelable: true }); if (this.dispatchEvent(evt)) { window.location.href = url; } }"
class="h-9 pl-4 pr-10 rounded-xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-[11px] sm:text-xs font-black text-slate-600 dark:text-slate-300 appearance-none focus:ring-4 focus:ring-cyan-500/10 focus:border-cyan-500 outline-none transition-all cursor-pointer shadow-sm hover:border-slate-300 dark:hover:border-slate-600 !bg-none {{ $paginator->lastPage() <= 1 ? 'opacity-50 cursor-not-allowed pointer-events-none' : '' }}" class="h-9 pl-4 pr-10 rounded-xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-[11px] sm:text-xs font-black text-slate-600 dark:text-slate-300 appearance-none focus:ring-4 focus:ring-cyan-500/10 focus:border-cyan-500 outline-none transition-all cursor-pointer shadow-sm hover:border-slate-300 dark:hover:border-slate-600 !bg-none {{ $paginator->lastPage() <= 1 ? 'opacity-50 cursor-not-allowed pointer-events-none' : '' }}"
{{ $paginator->lastPage() <= 1 ? 'disabled' : '' }}> {{ $paginator->lastPage() <= 1 ? 'disabled' : '' }}>
@for ($i = 1; $i <= $paginator->lastPage(); $i++) @for ($i = 1; $i <= $paginator->lastPage(); $i++)
@ -70,7 +71,7 @@
{{-- Next Page Link --}} {{-- Next Page Link --}}
@if ($paginator->hasMorePages()) @if ($paginator->hasMorePages())
<a href="{{ $paginator->nextPageUrl() }}" <a href="{{ $paginator->nextPageUrl() }}"
onclick="{ event.preventDefault(); const url = this.href; const evt = new CustomEvent('ajax:navigate', { detail: { url: url }, bubbles: true, cancelable: true }); if (this.dispatchEvent(evt)) { window.location.href = url; } }" onclick="{ event.preventDefault(); const url = this.href; const evt = new CustomEvent('{{ $event }}', { detail: { url: url }, bubbles: true, cancelable: true }); if (this.dispatchEvent(evt)) { window.location.href = url; } }"
class="h-9 inline-flex items-center gap-x-1.5 sm:gap-x-2 px-3 sm:px-4 rounded-xl text-[10px] sm:text-xs font-black bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-300 border border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800 transition-all shadow-sm group"> class="h-9 inline-flex items-center gap-x-1.5 sm:gap-x-2 px-3 sm:px-4 rounded-xl text-[10px] sm:text-xs font-black bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-300 border border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800 transition-all shadow-sm group">
<span class="hidden xxs:inline">{{ __('Next') }}</span> <span class="hidden xxs:inline">{{ __('Next') }}</span>
<svg class="size-3.5 sm:size-4 text-cyan-500 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M9 5l7 7-7 7"/></svg> <svg class="size-3.5 sm:size-4 text-cyan-500 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M9 5l7 7-7 7"/></svg>

View File

@ -84,13 +84,15 @@ Route::middleware(['auth', 'verified', 'tenant.access'])->prefix('admin')->name(
Route::get('/inventory', [App\Http\Controllers\Admin\WarehouseController::class, 'inventory'])->name('inventory'); Route::get('/inventory', [App\Http\Controllers\Admin\WarehouseController::class, 'inventory'])->name('inventory');
Route::post('/inventory/stock-in', [App\Http\Controllers\Admin\WarehouseController::class, 'storeStockIn'])->name('inventory.stock-in.store'); Route::post('/inventory/stock-in', [App\Http\Controllers\Admin\WarehouseController::class, 'storeStockIn'])->name('inventory.stock-in.store');
Route::patch('/inventory/stock-in/{stockInOrder}/confirm', [App\Http\Controllers\Admin\WarehouseController::class, 'confirmStockIn'])->name('inventory.stock-in.confirm'); Route::patch('/inventory/stock-in/{stockInOrder}/confirm', [App\Http\Controllers\Admin\WarehouseController::class, 'confirmStockIn'])->name('inventory.stock-in.confirm');
Route::delete('/inventory/stock-in/{stockInOrder}', [App\Http\Controllers\Admin\WarehouseController::class, 'destroyStockIn'])->name('inventory.stock-in.destroy');
Route::get('/inventory/stock-in/{order}/details', [App\Http\Controllers\Admin\WarehouseController::class, 'stockInOrderDetails'])->name('inventory.stock-in.details'); Route::get('/inventory/stock-in/{order}/details', [App\Http\Controllers\Admin\WarehouseController::class, 'stockInOrderDetails'])->name('inventory.stock-in.details');
// 模組 3調撥單 // 模組 3調撥單
Route::get('/transfers', [App\Http\Controllers\Admin\WarehouseController::class, 'transfers'])->name('transfers'); Route::get('/transfers', [App\Http\Controllers\Admin\WarehouseController::class, 'transfers'])->name('transfers');
Route::post('/transfers', [App\Http\Controllers\Admin\WarehouseController::class, 'storeTransfer'])->name('transfers.store'); Route::post('/transfers', [App\Http\Controllers\Admin\WarehouseController::class, 'storeTransfer'])->name('transfers.store');
Route::patch('/transfers/{transferOrder}/confirm', [App\Http\Controllers\Admin\WarehouseController::class, 'confirmTransfer'])->name('transfers.confirm'); Route::patch('/transfers/{transferOrder}/confirm', [App\Http\Controllers\Admin\WarehouseController::class, 'confirmTransfer'])->name('transfers.confirm');
Route::get('/transfers/{order}/details', [App\Http\Controllers\Admin\WarehouseController::class, 'transferOrderDetails'])->name('transfers.details'); Route::delete('/transfers/{transferOrder}', [App\Http\Controllers\Admin\WarehouseController::class, 'destroyTransfer'])->name('transfers.destroy');
Route::get('/transfers/{id}/details', [App\Http\Controllers\Admin\WarehouseController::class, 'transferOrderDetails'])->name('transfers.details');
// 模組 4機台庫存總覽 // 模組 4機台庫存總覽
Route::get('/machine-inventory', [App\Http\Controllers\Admin\WarehouseController::class, 'machineInventory'])->name('machine-inventory'); Route::get('/machine-inventory', [App\Http\Controllers\Admin\WarehouseController::class, 'machineInventory'])->name('machine-inventory');
@ -99,7 +101,11 @@ Route::middleware(['auth', 'verified', 'tenant.access'])->prefix('admin')->name(
// 模組 5機台補貨 // 模組 5機台補貨
Route::get('/replenishments', [App\Http\Controllers\Admin\WarehouseController::class, 'replenishments'])->name('replenishments'); Route::get('/replenishments', [App\Http\Controllers\Admin\WarehouseController::class, 'replenishments'])->name('replenishments');
Route::post('/replenishments', [App\Http\Controllers\Admin\WarehouseController::class, 'storeReplenishment'])->name('replenishments.store'); Route::post('/replenishments', [App\Http\Controllers\Admin\WarehouseController::class, 'storeReplenishment'])->name('replenishments.store');
Route::patch('/replenishments/{replenishmentOrder}/confirm', [App\Http\Controllers\Admin\WarehouseController::class, 'confirmReplenishment'])->name('replenishments.confirm'); Route::post('/replenishments/auto', [App\Http\Controllers\Admin\WarehouseController::class, 'autoReplenishment'])->name('replenishments.auto');
Route::patch('/replenishments/{replenishmentOrder}/status', [App\Http\Controllers\Admin\WarehouseController::class, 'updateReplenishmentStatus'])->name('replenishments.status');
Route::patch('/replenishments/{replenishmentOrder}/cancel', [App\Http\Controllers\Admin\WarehouseController::class, 'cancelReplenishment'])->name('replenishments.cancel');
Route::patch('/replenishments/{replenishmentOrder}/assign', [App\Http\Controllers\Admin\WarehouseController::class, 'assignReplenishment'])->name('replenishments.assign');
Route::get('/replenishments/machine-slots/{machine}', [App\Http\Controllers\Admin\WarehouseController::class, 'getMachineSlotsForReplenishment'])->name('replenishments.machine-slots');
Route::get('/replenishments/{order}/details', [App\Http\Controllers\Admin\WarehouseController::class, 'replenishmentOrderDetails'])->name('replenishments.details'); Route::get('/replenishments/{order}/details', [App\Http\Controllers\Admin\WarehouseController::class, 'replenishmentOrderDetails'])->name('replenishments.details');
// AJAX 庫存查詢 // AJAX 庫存查詢