[FEAT] 完善遠端出貨與庫存異動的併發控制與視覺回饋

1. 修正 RemoteController 中的 MachineService 注入問題,解決遠端出貨指令失效的 500 錯誤。
2. 在 MachineService 實作貨道鎖定機制 (lockSlot/unlockSlot),防止重複扣庫存導致的數據不一致。
3. 採用樂觀扣除模式,發送指令時即更新預扣庫存,並在 ProcessCommandAck 實作失敗自動 Rollback。
4. 優化 UI 互動:在操作紀錄詳情顯示庫存變化 (如 6 -> 5),且針對執行失敗的指令加上刪除線樣式。
5. 完成指令詳情、操作紀錄與庫存歷史的語系對照表同步 (zh_TW, en, ja)。
This commit is contained in:
sky121113 2026-04-22 14:59:28 +08:00
parent a8b150cadc
commit 9114269a7a
13 changed files with 290 additions and 1328 deletions

View File

@ -21,7 +21,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
- **Header**`Authorization: Bearer <user_token>`。
### 2.2 機台通訊認證 (Machine Authentication)
- **適用 API**B012, B013, B600 等後續通訊。
- **適用 API**B012, B009 等後續通訊。
- **方式**:使用機台專屬之 **api_token**
- **Header**`Authorization: Bearer <api_token>`。
@ -80,18 +80,14 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
### 3.3 B009: 貨道庫存即時回報 (Supplementary Report)
當維修或補貨人員在機台端完成操作後,將目前的貨道實體狀態同步回雲端。
#### B009 權限驗證邏輯 (RBAC Compliance)
系統會依據 account 欄位進行三層式權限核查:
1. **系統層 (System Admin)**:當 company_id 為 null 時,具備全局管理權限,直接放行。
2. **公司層 (Company Admin)**:當 is_admin 為 true 時,檢查機台的 company_id 是否與該帳號一致。
3. **人員層 (Operator/User)**:當帳號僅為一般人員時,檢查 machine_user 授權表,確認該帳號有被分配至此機台。
- **URL**: PUT /api/v1/app/products/supplementary/B009
- **Request Body:**
- **Authentication**: Bearer Token (Header)
- **Request Body:** 無 (由 Token 自動識別機台)
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| account | String | 是 | 操作人員帳號 | 0999123456 |
| data | Array | 是 | 貨道數據陣列 | [{"tid":"1", "t060v00":"1", "num":"10"}] |
- **data 陣列內部欄位:**
@ -152,24 +148,12 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
### 3.6 B013: [DEPRECATED / REMOVED] 機台故障與異常狀態上報
> [!WARNING]
> **此 REST API 已廢棄並移除**。機台異常上報已全面遷移至 **MQTT** 即時通訊架構。
> 請參閱 [MQTT 通訊規範 - B013 異常上報](file:///home/mama/projects/star-cloud/.agents/skills/mqtt-communication-specs/SKILL.md#32-異常與錯誤上報-b013) 進行串接。
> 詳細錯誤代碼請參閱:[MQTT 通訊規範 - B013 硬體代碼對照表](file:///home/mama/projects/star-cloud/.agents/skills/mqtt-communication-specs/SKILL.md#32-異常與錯誤上報-b013)
### 3.12 B010: [DEPRECATED / REMOVED] 心跳上報與狀態同步
> [!WARNING]
> **此 REST API 已廢棄並移除**。機台心跳上報與指令下發已全面遷移至 **MQTT** 架構。
> 請參閱 [MQTT 通訊規範 - B010 心跳上報](file:///home/mama/projects/star-cloud/.agents/skills/mqtt-communication-specs/SKILL.md#31-心跳上報-b010) 進行串接。
#### B013 硬體代碼對照表 (由 MachineService 自動翻譯)
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
| :--- | :--- | :--- | :--- |
| **0402** | Dispense successful | info | 出貨成功 |
| **0403** | Slot jammed | error | **貨道卡貨** (重大異常) |
| **0202** | Product empty | warning | 貨道缺貨 |
| **0412** | Elevator rise error | error | 昇降機上升異常 |
| **0415** | Pickup door error | error | 取貨門異常 |
| **5402** | Pickup door not closed | warning | **取貨門未關** (警告) |
| **5403** | Elevator failure | error | 昇降系統故障 |
> 詳細格式請參閱:[MQTT 通訊規範 - B010 心跳上報](file:///home/mama/projects/star-cloud/.agents/skills/mqtt-communication-specs/SKILL.md#31-心跳上報-b010)
---

View File

@ -136,7 +136,19 @@ Mqtt3Client client = MqttClient.builder()
"error_code": "0403"
}
```
*(註:`error_code` 應遵循 `MachineService::ERROR_CODE_MAP` 定義,雲端將自動轉換為對應語言標籤。`tid` 為貨道或任務 ID。)*
#### B013 硬體代碼對照表 (由 MachineService 自動翻譯)
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
| :--- | :--- | :--- | :--- |
| **0402** | Dispense successful | info | 出貨成功 |
| **0403** | Slot jammed | error | **貨道卡貨** (重大異常) |
| **0202** | Product empty | warning | 貨道缺貨 |
| **0412** | Elevator rise error | error | 昇降機上升異常 |
| **0415** | Pickup door error | error | 取貨門異常 |
| **5402** | Pickup door not closed | warning | **取貨門未關** (警告) |
| **5403** | Elevator failure | error | 昇降系統故障 |
*(註:`error_code` 應遵循此表定義,雲端將根據 `i18n` 自動轉換。`tid` 為貨道或任務 ID。)*
### 3.3 交易生命週期上報 (Transaction) - `machine/{serial_no}/transaction`

View File

@ -133,7 +133,24 @@ class MachineController extends AdminController
*/
public function slotsAjax(Machine $machine)
{
$slots = $machine->slots()->with('product:id,name,image_url')->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')->get();
$slots = $machine->slots()
->with('product:id,name,image_url')
->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')
->get();
// 取得該機台目前所有等待中的庫存更新指令 (Pending Commands)
$pendingSlotNos = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'reload_stock')
->where('status', 'pending')
->get()
->pluck('payload.slot_no')
->flatten()
->toArray();
// 將待處理狀態注入貨道物件
$slots->each(function ($slot) use ($pendingSlotNos) {
$slot->is_pending = in_array((int)$slot->slot_no, $pendingSlotNos);
});
return response()->json([
'success' => true,
@ -154,14 +171,21 @@ class MachineController extends AdminController
'batch_no' => 'nullable|string|max:50',
]);
$this->machineService->updateSlot($machine, $validated, auth()->id());
try {
$this->machineService->updateSlot($machine, $validated, auth()->id());
session()->flash('success', __('Slot updated successfully.'));
session()->flash('success', __('Slot updated successfully.'));
return response()->json([
'success' => true,
'message' => __('Slot updated successfully.')
]);
return response()->json([
'success' => true,
'message' => __('Slot updated successfully.')
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 422);
}
}
/**

View File

@ -7,15 +7,18 @@ use Illuminate\Http\Request;
use App\Models\Machine\Machine;
use App\Models\Machine\RemoteCommand;
use App\Services\Machine\MqttService;
use App\Services\Machine\MachineService;
use Illuminate\Support\Facades\Auth;
class RemoteController extends Controller
{
protected $mqttService;
protected $machineService;
public function __construct(MqttService $mqttService)
public function __construct(MqttService $mqttService, MachineService $machineService)
{
$this->mqttService = $mqttService;
$this->machineService = $machineService;
}
/**
@ -138,41 +141,47 @@ class RemoteController extends Controller
'note' => 'nullable|string|max:255',
]);
$payload = [];
if ($validated['command_type'] === 'change') {
$payload['amount'] = $validated['amount'];
} elseif ($validated['command_type'] === 'dispense') {
$payload['slot_no'] = $validated['slot_no'];
}
if ($validated['command_type'] === 'dispense') {
$this->machineService->dispatchDispense(
Machine::findOrFail($validated['machine_id']),
$validated['slot_no'],
auth()->id()
);
} else {
$payload = [];
if ($validated['command_type'] === 'change') {
$payload['amount'] = $validated['amount'];
}
// 指令去重:將同機台、同類型的 pending 指令標記為「已取代」
RemoteCommand::where('machine_id', $validated['machine_id'])
->where('command_type', $validated['command_type'])
->where('status', 'pending')
->update([
'status' => 'superseded',
'note' => __('Superseded by new command'),
'executed_at' => now(),
// 指令去重:將同機台、同類期的 pending 指令標記為「已取代」
RemoteCommand::where('machine_id', $validated['machine_id'])
->where('command_type', $validated['command_type'])
->where('status', 'pending')
->update([
'status' => 'superseded',
'note' => __('Superseded by new command'),
'executed_at' => now(),
]);
$command = RemoteCommand::create([
'machine_id' => $validated['machine_id'],
'user_id' => auth()->id(),
'command_type' => $validated['command_type'],
'payload' => $payload,
'status' => 'pending',
'note' => $validated['note'] ?? null,
]);
$command = RemoteCommand::create([
'machine_id' => $validated['machine_id'],
'user_id' => auth()->id(),
'command_type' => $validated['command_type'],
'payload' => $payload,
'status' => 'pending',
'note' => $validated['note'] ?? null,
]);
// 【新增】同步推播 MQTT 指令
$machine = Machine::find($validated['machine_id']);
if ($machine) {
$this->mqttService->pushCommand(
$machine->serial_no,
$command->command_type,
$command->payload,
$command->id
);
// 推播 MQTT 指令
$machine = Machine::find($validated['machine_id']);
if ($machine) {
$this->mqttService->pushCommand(
$machine->serial_no,
$command->command_type,
$command->payload,
(string) $command->id
);
}
}
session()->flash('success', __('Command has been queued successfully.'));

View File

@ -88,26 +88,18 @@ class ProcessCommandAck implements ShouldQueue
$machine = $command->machine;
if ($command->command_type === 'dispense') {
// 若成功,連動更新貨道庫存
if ($status === 'success' && isset($command->payload['slot_no'])) {
$slotNo = $command->payload['slot_no'];
$slot = $machine->slots()->where('slot_no', $slotNo)->first();
if ($slot) {
$oldStock = $slot->stock;
// 若 APP 回傳的庫存大於 0 則使用回傳值,否則執行扣減
$newStock = (int) ($stockReported ?? 0);
if ($newStock <= 0 && $oldStock > 0) {
$newStock = $oldStock - 1;
$slotNo = $command->payload['slot_no'] ?? null;
$slot = $machine->slots()->where('slot_no', $slotNo)->first();
if ($slot) {
if ($status === 'success') {
// 若出貨成功,且 APP 回報了最新的庫存值,則以 APP 為準進行校準
// 否則維持預扣後的狀態 (不需要額外動作,因為下發時已扣除)
if ($stockReported !== null) {
$slot->update(['stock' => (int)$stockReported]);
$payloadUpdates['new_stock'] = (int)$stockReported;
}
$finalStock = max(0, $newStock);
$slot->update(['stock' => $finalStock]);
// 紀錄庫存變化供前端顯示
$payloadUpdates['old_stock'] = $oldStock;
$payloadUpdates['new_stock'] = $finalStock;
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
@ -115,9 +107,23 @@ class ProcessCommandAck implements ShouldQueue
'info',
['slot' => $slotNo]
);
} else {
// 若出貨失敗,執行回滾 (Rollback):將預扣的庫存加回去
$oldStock = $command->payload['old_stock'] ?? $slot->stock;
$slot->update(['stock' => $oldStock]);
$payloadUpdates['rolled_back'] = true;
$payloadUpdates['new_stock'] = $oldStock;
Log::warning('MQTT Dispense failed, rolled back inventory', [
'serial_no' => $this->serialNo,
'slot_no' => $slotNo,
'rolled_back_to' => $oldStock
]);
}
}
} elseif ($command->command_type === 'reload_stock') {
}
elseif ($command->command_type === 'reload_stock') {
// 若同步失敗,自動 Rollback 回滾庫存
if ($status === 'failed' && isset($command->payload['slot_no'], $command->payload['old'])) {
$slotNo = $command->payload['slot_no'];

View File

@ -203,46 +203,49 @@ class MachineService
*/
public function updateSlot(Machine $machine, array $data, ?int $userId = null): void
{
$command = DB::transaction(function () use ($machine, $data, $userId) {
DB::transaction(function () use ($machine, $data, $userId) {
$slotNo = $data['slot_no'];
$stock = $data['stock'] ?? null;
$expiryDate = $data['expiry_date'] ?? null;
$batchNo = $data['batch_no'] ?? null;
$slot = $machine->slots()->where('slot_no', $slotNo)->firstOrFail();
$slot = $machine->slots()->where('slot_no', $slotNo)->lockForUpdate()->firstOrFail();
// 紀錄舊數據以供 Payload 使用
// 1. 併行檢查:確保同一個貨道在同一時間只有一個指令在執行中 (Pending)
// 包含 reload_stock 與 dispense 兩種會異動庫存的類型
$isPending = RemoteCommand::where('machine_id', $machine->id)
->whereIn('command_type', ['reload_stock', 'dispense'])
->where('status', 'pending')
->whereJsonContains('payload->slot_no', (string)$slotNo)
->exists();
if ($isPending) {
throw new \Exception(__('This slot has a pending update. Please wait for the previous command to complete.'));
}
// 紀錄舊數據以供回滾使用
$oldData = [
'stock' => $slot->stock,
'expiry_date' => $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null,
'batch_no' => $slot->batch_no,
];
// 2. 執行樂觀更新 (Optimistic Update)
$updateData = [
'expiry_date' => $expiryDate,
'batch_no' => $batchNo,
];
if ($stock !== null) $updateData['stock'] = (int)$stock;
$slot->update($updateData);
// 指令去重:將該機台所有尚未領取的舊庫存同步指令標記為「已取代」
RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'reload_stock')
->where('status', 'pending')
->update([
'status' => 'superseded',
'note' => __('Superseded by new adjustment'),
'executed_at' => now(),
]);
// 建立遠端指令紀錄 (Unified Command Concept)
return RemoteCommand::create([
// 3. 建立遠端指令紀錄
$command = RemoteCommand::create([
'machine_id' => $machine->id,
'user_id' => $userId,
'command_type' => 'reload_stock',
'status' => 'pending',
'payload' => [
'slot_no' => $slotNo,
'slot_no' => (string)$slotNo,
'old' => $oldData,
'new' => [
'stock' => $stock !== null ? (int)$stock : $oldData['stock'],
@ -251,26 +254,80 @@ class MachineService
]
]
]);
});
// 交易成功後,推播 MQTT 遠端指令
$mqttPayload = [
'data' => [
[
'slot_no' => $command->payload['slot_no'],
'stock' => $command->payload['new']['stock'],
'expiry_date' => $command->payload['new']['expiry_date'],
'batch_no' => $command->payload['new']['batch_no'] ?? null,
// 4. 推播 MQTT
$mqttPayload = [
'data' => [
[
'slot_no' => $command->payload['slot_no'],
'stock' => $command->payload['new']['stock'],
'expiry_date' => $command->payload['new']['expiry_date'],
'batch_no' => $command->payload['new']['batch_no'] ?? null,
]
]
]
];
];
app(\App\Services\Machine\MqttService::class)->pushCommand(
$machine->serial_no,
'update_inventory',
$mqttPayload,
(string) $command->id
);
app(\App\Services\Machine\MqttService::class)->pushCommand(
$machine->serial_no,
'update_inventory',
$mqttPayload,
(string) $command->id
);
});
}
/**
* 遠端出貨指令下發 (含樂觀扣庫存與鎖定)
*/
public function dispatchDispense(Machine $machine, string $slotNo, ?int $userId = null): RemoteCommand
{
return DB::transaction(function () use ($machine, $slotNo, $userId) {
$slot = $machine->slots()->where('slot_no', $slotNo)->lockForUpdate()->firstOrFail();
// 併行檢查
$isPending = RemoteCommand::where('machine_id', $machine->id)
->whereIn('command_type', ['reload_stock', 'dispense'])
->where('status', 'pending')
->whereJsonContains('payload->slot_no', (string)$slotNo)
->exists();
if ($isPending) {
throw new \Exception(__('This slot has a pending command. Please wait.'));
}
if ($slot->stock <= 0) {
throw new \Exception(__('Out of stock.'));
}
$oldStock = $slot->stock;
$newStock = $oldStock - 1;
// 1. 執行樂觀扣薪
$slot->update(['stock' => $newStock]);
// 2. 建立指令紀錄
$command = RemoteCommand::create([
'machine_id' => $machine->id,
'user_id' => $userId,
'command_type' => 'dispense',
'status' => 'pending',
'payload' => [
'slot_no' => (string)$slotNo,
'old_stock' => $oldStock,
'new_stock' => $newStock,
]
]);
// 3. 推播 MQTT
app(\App\Services\Machine\MqttService::class)->pushCommand(
$machine->serial_no,
'dispense',
$command->payload,
(string) $command->id
);
return $command;
});
}
/**

View File

@ -815,7 +815,7 @@
"Sale Price": "Sale Price",
"Sales": "Sales",
"sales": "sales",
"Sales Activity": "銷售活動",
"Sales Activity": "Sales Activity",
"Sales Management": "Sales Management",
"Sales Permissions": "Sales Permissions",
"Sales Records": "Sales Records",
@ -823,10 +823,10 @@
"Save Changes": "Save Changes",
"Save Config": "Save Config",
"Save Material": "Save Material",
"Save Permissions": "儲存權限",
"Save Permissions": "Save Permissions",
"Saved.": "Saved.",
"Saving...": "儲存中...",
"Scale level and access control": "層級與存取控制",
"Saving...": "Saving...",
"Scale level and access control": "Scale level and access control",
"Scan this code to quickly access the maintenance form for this device.": "Scan this code to quickly access the maintenance form for this device.",
"Search accounts...": "Search accounts...",
"Search by name or S/N...": "Search by name or S/N...",
@ -1178,5 +1178,15 @@
"Unauthorized: Account not found": "Unauthorized: Account not found",
"Unauthorized: Account not authorized for this machine": "Unauthorized: Account not authorized for this machine",
"Slot report synchronized success": "Slot report synchronized success",
"Error: :message (:code)": "Error: :message (:code)"
"Error: :message (:code)": "Error: :message (:code)",
"Syncing": "Syncing",
"This slot is syncing with the machine. Please wait.": "This slot is syncing with the machine. Please wait.",
"Loading Data": "Loading Data",
"All Status": "All Status",
"CASH": "CASH",
"ITEM": "ITEM",
"This slot has a pending update. Please wait for the previous command to complete.": "This slot has a pending update. Please wait for the previous command to complete.",
"This slot has a pending command. Please wait.": "This slot has a pending command. Please wait.",
"Out of stock.": "Out of stock.",
"Save error:": "Save error:"
}

File diff suppressed because it is too large Load Diff

View File

@ -433,7 +433,7 @@
"LINE_MERCHANT_ID": "LINE Pay 商店代號",
"LIVE": "實時",
"Live Fleet Updates": "即時數據更新",
"Loading Cabinet...": "正在載入貨道...",
"Loading Cabinet...": "正在載入機台貨盤...",
"Loading machines...": "正在載入機台...",
"Loading...": "載入中...",
"Location": "位置",
@ -619,7 +619,7 @@
"No matching machines": "查無匹配機台",
"No materials available": "沒有可用的素材",
"No permissions": "無權限項目",
"No records found": "查無操作紀錄",
"No records found": "未找到相關紀錄",
"No roles available": "目前沒有角色資料。",
"No roles found.": "找不到角色資料。",
"No slots found": "未找到貨道資訊",
@ -698,7 +698,7 @@
"Photo Slot": "照片欄位",
"PI_MERCHANT_ID": "Pi 拍錢包 商店代號",
"Picked up": "領取",
"Picked up Time": "取時間",
"Picked up Time": "取時間",
"Pickup Code": "取貨碼",
"Pickup Codes": "取貨碼",
"Playback Order": "播放順序",
@ -998,7 +998,7 @@
"Spring": "彈簧",
"Transfer Audit": "調撥單",
"Transfers": "調撥單",
"Trigger": "觸發",
"Trigger": "執行",
"Trigger Dispense": "觸發出貨",
"Trigger Remote Dispense": "觸發遠端出貨",
"Tutorial Page": "教學頁",
@ -1188,5 +1188,14 @@
"Unauthorized: Account not found": "授權失敗:找不到帳號",
"Unauthorized: Account not authorized for this machine": "授權失敗:此帳號無存取該機台的權限",
"Slot report synchronized success": "貨道狀態同步成功",
"Error: :message (:code)": "錯誤::message (:code)"
"Error: :message (:code)": "錯誤::message (:code)",
"Syncing": "同步中",
"This slot is syncing with the machine. Please wait.": "此貨道正在與機台同步中,請稍候。",
"Loading Data": "載入資料中",
"CASH": "CASH",
"ITEM": "ITEM",
"This slot has a pending update. Please wait for the previous command to complete.": "此貨道已有更新指令在執行中,請等候前一個指令完成。",
"This slot has a pending command. Please wait.": "此貨道已有指令正在執行,請稍後。",
"Out of stock.": "庫存不足。",
"Save error:": "儲存錯誤:"
}

View File

@ -56,6 +56,7 @@
'Amount' => __('Amount'),
'Command error:' => __('Command error:'),
'Command has been queued successfully.' => __('Command has been queued successfully.'),
'Remote Settlement' => __('Remote Settlement'),
'Just now' => __('Just now'),
'mins ago' => __('mins ago'),
'hours ago' => __('hours ago'),
@ -69,8 +70,10 @@
async init() {
// Watch for machine data changes to rebuild slot select
this.$watch('selectedMachine.slots', () => {
this.$nextTick(() => this.updateSlotSelect());
this.$watch('selectedMachine', (val) => {
if (val && val.slots) {
this.$nextTick(() => this.updateSlotSelect());
}
});
// 首次載入時綁定分頁連結
@ -376,8 +379,12 @@
body: formData
});
if (res.ok) {
const data = await res.json();
if (res.ok && data.success) {
window.location.href = this.appConfig.indexUrl;
} else {
throw new Error(data.message || 'Unknown error');
}
} catch (e) {
window.dispatchEvent(new CustomEvent('toast', {
@ -424,7 +431,7 @@
const names = {
'reboot': this.translations['Machine Reboot'],
'reboot_card': this.translations['Card Reader Reboot'],
'checkout': this.translations['Remote Reboot'],
'checkout': this.translations['Remote Settlement'],
'lock': this.translations['Lock Page Lock'],
'unlock': this.translations['Lock Page Unlock'],
'change': this.translations['Remote Change'],
@ -452,7 +459,8 @@
translateNote(note) {
if (!note) return '';
const translations = {
'Superseded by new adjustment': this.translations['Superseded by new adjustment']
'Superseded by new adjustment': this.translations['Superseded by new adjustment'],
'Superseded by new command': this.translations['Superseded by new command']
};
return translations[note] || note;
},
@ -485,7 +493,7 @@
if (item.command_type === 'dispense' && item.payload) {
let details = `${this.translations['Slot']} ${item.payload.slot_no}`;
if (item.status === 'success' && item.payload.old_stock !== undefined) {
if (item.payload.old_stock !== undefined && item.payload.new_stock !== undefined) {
details += `: ${this.translations['Stock']} ${item.payload.old_stock} → ${item.payload.new_stock}`;
}
return details;
@ -752,7 +760,7 @@
</h3>
<div
class="text-[40px] font-black text-slate-200 dark:text-slate-800 leading-none select-none tracking-tighter">
CASH</div>
{{ __('CASH') }}</div>
</div>
<div class="space-y-6">
<div class="relative group">
@ -799,7 +807,7 @@
</h3>
<div
class="text-[40px] font-black text-slate-200 dark:text-slate-800 leading-none select-none tracking-tighter">
ITEM</div>
{{ __('ITEM') }}</div>
</div>
<div class="space-y-6">
<div class="space-y-2">
@ -876,6 +884,7 @@
</div>
<template x-if="getPayloadDetails(cmd)">
<div class="px-3 py-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-sm font-bold text-cyan-600 dark:text-cyan-400 break-words leading-relaxed"
:class="cmd.status === 'failed' ? 'line-through opacity-60' : ''"
x-text="getPayloadDetails(cmd)">
</div>
</template>
@ -892,8 +901,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div class="text-[10px] font-black uppercase tracking-widest">{{ __('No command
history') }}</div>
<div class="text-[10px] font-black uppercase tracking-widest">{{ __('No command history') }}</div>
</div>
</template>
</div>
@ -937,16 +945,14 @@
<div>
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight uppercase">{{
__('Command Confirmation') }}</h3>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mt-0.5">{{ __('Please
confirm the details below') }}</p>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mt-0.5">{{ __('Please confirm the details below') }}</p>
</div>
</div>
<div
class="space-y-4 bg-slate-50 dark:bg-slate-950/50 p-6 rounded-3xl border border-slate-100 dark:border-slate-800/50 mb-8">
<div class="flex justify-between items-center px-1">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Command
Type') }}</span>
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Command Type') }}</span>
<span class="text-sm font-black text-slate-800 dark:text-slate-200"
x-text="getCommandName(confirmModal.type)"></span>
</div>

View File

@ -69,7 +69,7 @@
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') ?: '') }}"
value="{{ request('start_date') && request('end_date') ? request('start_date') . ' ' . __('to') . ' ' . 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>
@ -197,6 +197,7 @@
<div class="flex flex-col gap-0.5 mt-1">
<span x-show="getPayloadDetails({{ Js::from($item) }})"
class="text-[11px] font-bold text-cyan-600 dark:text-cyan-400/80 bg-cyan-500/5 px-2 py-0.5 rounded-md border border-cyan-500/10 w-fit"
:class="{'line-through opacity-60': {{ Js::from($item->status) }} === 'failed'}"
x-text="getPayloadDetails({{ Js::from($item) }})"></span>
@if($item->note)
<span class="text-[10px] text-slate-400 italic pl-1"

View File

@ -133,7 +133,10 @@
<div class="flex flex-col min-w-[200px]">
<span class="text-sm font-black text-slate-700 dark:text-slate-300 tracking-tight" x-text="getCommandName(@js($item->command_type))"></span>
<div class="flex flex-col gap-0.5 mt-1">
<span x-show="getPayloadDetails(@js($item))" class="text-[11px] font-bold text-cyan-600 dark:text-cyan-400/80 bg-cyan-500/5 px-2 py-0.5 rounded-md border border-cyan-500/10 w-fit" x-text="getPayloadDetails(@js($item))"></span>
<span x-show="getPayloadDetails(@js($item))"
class="text-[11px] font-bold text-cyan-600 dark:text-cyan-400/80 bg-cyan-500/5 px-2 py-0.5 rounded-md border border-cyan-500/10 w-fit"
:class="{'line-through opacity-60': @js($item->status) === 'failed'}"
x-text="getPayloadDetails(@js($item))"></span>
@if($item->note)
<span class="text-[10px] text-slate-400 italic pl-1" x-text="translateNote(@js($item->note))"></span>
@endif

View File

@ -190,6 +190,15 @@
},
openEdit(slot) {
if (slot.is_pending) {
window.dispatchEvent(new CustomEvent('toast', {
detail: {
message: '{{ __("This slot is syncing with the machine. Please wait.") }}',
type: 'warning'
}
}));
return;
}
this.selectedSlot = slot;
this.formData = {
stock: slot.stock || 0,
@ -214,10 +223,9 @@
const data = await res.json();
if (data.success) {
this.showEditModal = false;
// Redirect instantly to history tab.
// The success toast will be handled by the session() flash in the controller.
window.location.href = "{{ route('admin.remote.stock') }}";
} else {
throw new Error(data.message || 'Unknown error');
}
} catch (e) {
window.dispatchEvent(new CustomEvent('toast', {
@ -261,7 +269,7 @@
const names = {
'reboot': {{ Js::from(__('Machine Reboot')) }},
'reboot_card': {{ Js::from(__('Card Reader Reboot')) }},
'checkout': {{ Js::from(__('Remote Reboot')) }},
'checkout': {{ Js::from(__('Remote Settlement')) }},
'lock': {{ Js::from(__('Lock Page Lock')) }},
'unlock': {{ Js::from(__('Lock Page Unlock')) }},
'change': {{ Js::from(__('Remote Change')) }},
@ -331,7 +339,8 @@
translateNote(note) {
if (!note) return '';
const translations = {
'Superseded by new adjustment': {{ Js::from(__('Superseded by new adjustment')) }}
'Superseded by new adjustment': {{ Js::from(__('Superseded by new adjustment')) }},
'Superseded by new command': {{ Js::from(__('Superseded by new command')) }}
};
return translations[note] || note;
}
@ -583,8 +592,18 @@
<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">
<div @click="openEdit(slot)" :class="getSlotColorClass(slot)"
class="min-h-[300px] rounded-[2.5rem] p-5 flex flex-col items-center justify-center border-2 transition-all duration-500 cursor-pointer group hover:scale-[1.08] hover:-translate-y-3 hover:shadow-2xl active:scale-[0.98] relative">
<div @click="openEdit(slot)" :class="[getSlotColorClass(slot), slot.is_pending ? 'opacity-50 cursor-not-allowed grayscale-[0.5]' : 'cursor-pointer hover:scale-[1.08] hover:-translate-y-3 hover:shadow-2xl active:scale-[0.98]']"
class="min-h-[300px] rounded-[2.5rem] p-5 flex flex-col items-center justify-center border-2 transition-all duration-500 group relative">
<!-- Pending Overlay -->
<template x-if="slot.is_pending">
<div class="absolute inset-0 z-30 flex items-center justify-center bg-white/10 dark:bg-slate-900/10 backdrop-blur-[1px] rounded-[2.5rem]">
<div class="flex flex-col items-center gap-2">
<div class="w-8 h-8 border-2 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin"></div>
<span class="text-[8px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-widest">{{ __('Syncing') }}</span>
</div>
</div>
</template>
<!-- Slot Header (Pinned to top) -->
<div class="absolute top-4 left-5 right-5 flex justify-between items-center z-20">