[FEAT] 實作「同步到所有機台」功能與商品資料同步機制
1. 在商品管理介面新增「同步到所有機台」按鈕與 Alpine.js 確認彈窗。 2. 實作後端 syncToAllMachines 控制器邏輯,並加入同一公司 1 分鐘內禁止重複發送的限制。 3. 建立 SyncProductsToMachinesJob 與 SendProductSyncCommandJob 非同步隊列任務,支援舊指令覆蓋機制。 4. 補全繁體中文、英文與日文的多語系翻譯字串。 5. 更新遠端指令日誌視圖,支援展示商品同步指令及其備註。 6. 更新 MQTT 通訊規範與 API 技術規格文檔,納入 update_products 指令。 7. 整合 update_ads 與 update_products 指令至 config/api-docs.php 的系統指令規格中。
This commit is contained in:
parent
2c8a2de81c
commit
9ff9abef89
@ -185,35 +185,6 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
|
||||
---
|
||||
|
||||
### 3.8 B017: 貨道庫存同步 (Slot Synchronization)
|
||||
用於機台端**主動**向遠端後台拉取目前所有貨道的最新庫存、效期與配置狀態。通常發生於機台開機初始化、補貨/維護模式結束,或是機台需要確保本地端庫存資料與雲端一致時主動請求。
|
||||
|
||||
- **URL**: GET /api/v1/app/machine/reload_msg/B017
|
||||
- **Authentication**: Bearer Token (Header)
|
||||
- **Request Body:** 無 (由 Token 自動識別機台)
|
||||
|
||||
- **Response Body:**
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| success | Boolean | 請求是否成功 | true |
|
||||
| code | Integer | 200 | 200 |
|
||||
| data | Array | 貨道數據陣列 (依 slot_no 排序) | 見下表 |
|
||||
|
||||
**data 陣列內部欄位:**
|
||||
|
||||
| 欄位 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| tid | String | 貨道編號 | "1" |
|
||||
| num | Integer | 當前庫存數量 | 10 |
|
||||
| expiry_date | String | 商品效期 | "2026-12-31" |
|
||||
| batch_no | String | 批號 | "B202604" |
|
||||
| product_id | Integer | 商品 ID | 1 |
|
||||
| capacity | Integer | 貨道容量上限 | 15 |
|
||||
| status | String | 貨道狀態 ("1": 啟用, "0": 停用) | "1" |
|
||||
|
||||
---
|
||||
|
||||
### 3.9 B660: 取貨碼驗證 (Pickup Code)
|
||||
處理機台端的 8 位數代碼取貨流程。
|
||||
|
||||
@ -304,6 +275,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
| B601 | 發票紀錄 | `machine/{serial_no}/transaction` | `action: invoice` |
|
||||
| B602 | 出貨結果 | `machine/{serial_no}/transaction` | `action: dispense` |
|
||||
| B600+ | 交易完成(統整) | `machine/{serial_no}/transaction` | `action: finalize` |
|
||||
| [New] | 商品同步指令 | `machine/{serial_no}/command` | `command: update_products` |
|
||||
| B055 (PUT) | 遠端出貨回報 | `machine/{serial_no}/command/ack` | GET 查詢路由保留 |
|
||||
|
||||
---
|
||||
|
||||
@ -280,6 +280,7 @@ Mqtt3Client client = MqttClient.builder()
|
||||
- `update_inventory`: 遠端即時修改庫存。
|
||||
- `dispense`: 遠端出貨指令。
|
||||
- `update_ads`: 遠端廣告同步指令。
|
||||
- `update_products`: 遠端商品資料同步指令(觸發機台調用 B012)。
|
||||
|
||||
**`update_inventory` Payload 範例:**
|
||||
```json
|
||||
@ -295,6 +296,17 @@ Mqtt3Client client = MqttClient.builder()
|
||||
}
|
||||
```
|
||||
|
||||
**`update_products` Payload 範例:**
|
||||
```json
|
||||
{
|
||||
"command": "update_products",
|
||||
"command_id": "123",
|
||||
"payload": {
|
||||
"remark": "手動同步商品資料"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 指令回覆 (Command ACK) - `machine/{serial_no}/command/ack`
|
||||
|
||||
機台執行完雲端下發的指令後,透過此 Topic 回報執行結果。雲端收到後會根據 `command_id` 更新操作紀錄狀態,若結果為 `failed` 且為庫存指令,將觸發自動回滾 (Rollback)。
|
||||
|
||||
@ -434,4 +434,53 @@ class ProductController extends Controller
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步商品目錄至全公司機台 (Phase 2)
|
||||
*/
|
||||
public function syncToAllMachines(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
// 1. 決定目標公司 ID
|
||||
$companyId = $user->isSystemAdmin()
|
||||
? $request->input('company_id')
|
||||
: $user->company_id;
|
||||
|
||||
if (!$companyId) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('Please select a company.')
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 3. 頻率限制:1 分鐘內不可重複對同一家公司發送同步指令
|
||||
$machineIds = \App\Models\Machine\Machine::where('company_id', $companyId)->pluck('id');
|
||||
$recentCommand = \App\Models\Machine\RemoteCommand::whereIn('machine_id', $machineIds)
|
||||
->where('command_type', 'update_products')
|
||||
->where('created_at', '>=', now()->subMinute())
|
||||
->whereIn('status', ['pending', 'sent'])
|
||||
->exists();
|
||||
|
||||
if ($recentCommand) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('A sync command was recently sent. Please wait 1 minute.')
|
||||
], 422);
|
||||
}
|
||||
|
||||
$remark = $request->input('remark') ?: __('Manual Sync Products');
|
||||
|
||||
// 2. 派遣延遲廣播 Job
|
||||
\App\Jobs\Product\SyncProductsToMachinesJob::dispatch(
|
||||
(int) $companyId,
|
||||
$remark,
|
||||
$user->id
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Batch sync command has been queued. Machines will be updated sequentially.')
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
76
app/Jobs/Product/SendProductSyncCommandJob.php
Normal file
76
app/Jobs/Product/SendProductSyncCommandJob.php
Normal file
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Product;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\RemoteCommand;
|
||||
use App\Services\Machine\MqttService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SendProductSyncCommandJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @param int $machineId
|
||||
* @param string|null $remark
|
||||
* @param int|null $userId
|
||||
*/
|
||||
public function __construct(
|
||||
protected int $machineId,
|
||||
protected ?string $remark = null,
|
||||
protected ?int $userId = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @param MqttService $mqttService
|
||||
* @return void
|
||||
*/
|
||||
public function handle(MqttService $mqttService): void
|
||||
{
|
||||
$machine = Machine::find($this->machineId);
|
||||
|
||||
if (!$machine) {
|
||||
Log::error("SendProductSyncCommandJob: Machine not found", ['id' => $this->machineId]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 覆蓋任何該機台尚在「待處理」狀態的商品同步指令
|
||||
RemoteCommand::where('machine_id', $machine->id)
|
||||
->where('command_type', 'update_products')
|
||||
->where('status', 'pending')
|
||||
->update([
|
||||
'status' => 'superseded',
|
||||
'note' => 'Superseded by new command'
|
||||
]);
|
||||
|
||||
$command = RemoteCommand::create([
|
||||
'machine_id' => $machine->id,
|
||||
'user_id' => $this->userId,
|
||||
'command_type' => 'update_products',
|
||||
'payload' => [], // No extra payload needed for B012 trigger
|
||||
'status' => 'pending',
|
||||
'remark' => $this->remark ?: __('Batch Product Sync'),
|
||||
]);
|
||||
|
||||
$success = $mqttService->pushCommand(
|
||||
$machine->serial_no,
|
||||
'update_products',
|
||||
[],
|
||||
(string) $command->id
|
||||
);
|
||||
|
||||
if (!$success) {
|
||||
$command->update(['status' => 'failed', 'note' => 'MQTT push failed']);
|
||||
}
|
||||
}
|
||||
}
|
||||
52
app/Jobs/Product/SyncProductsToMachinesJob.php
Normal file
52
app/Jobs/Product/SyncProductsToMachinesJob.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Product;
|
||||
|
||||
use App\Models\System\Company;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class SyncProductsToMachinesJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @param int $companyId
|
||||
* @param string|null $remark
|
||||
* @param int|null $userId
|
||||
*/
|
||||
public function __construct(
|
||||
protected int $companyId,
|
||||
protected ?string $remark = null,
|
||||
protected ?int $userId = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$company = Company::with('machines')->find($this->companyId);
|
||||
|
||||
if (!$company || $company->machines->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$delayMs = 100; // 100ms per machine
|
||||
|
||||
foreach ($company->machines as $index => $machine) {
|
||||
// Calculate delay to avoid thundering herd (100ms, 200ms, 300ms...)
|
||||
$currentDelay = ($index * $delayMs) / 1000;
|
||||
|
||||
SendProductSyncCommandJob::dispatch($machine->id, $this->remark, $this->userId)
|
||||
->delay(now()->addSeconds($currentDelay));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -673,24 +673,6 @@ return [
|
||||
]
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => '指令下發:廣告同步 (Update Ads)',
|
||||
'slug' => 'mqtt-command-update-ads',
|
||||
'action' => 'SUB',
|
||||
'topic' => 'machine/{serial_no}/command',
|
||||
'qos' => 1,
|
||||
'description' => '雲端主動下發「廣告同步」指令。指示機台重新拉取 B005 廣告清單。',
|
||||
'payload_parameters' => [
|
||||
'command' => ['type' => 'string', 'description' => '固定為 "update_ads"'],
|
||||
'command_id' => ['type' => 'string', 'description' => '指令唯一 ID'],
|
||||
'payload' => ['type' => 'object', 'description' => '空物件 (無額外參數)'],
|
||||
],
|
||||
'payload_example' => [
|
||||
'command' => 'update_ads',
|
||||
'command_id' => '1122334456',
|
||||
'payload' => new \stdClass()
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => '指令下發:遠端找零 (Change)',
|
||||
'slug' => 'mqtt-command-change',
|
||||
@ -720,7 +702,7 @@ return [
|
||||
'qos' => 1,
|
||||
'description' => '雲端主動下發的系統級別控制指令。包含重啟、設備鎖定解鎖、購物車結帳等無額外參數的指令。',
|
||||
'payload_parameters' => [
|
||||
'command' => ['type' => 'string', 'description' => '系統指令類型。支援:reboot (重啟機台), reboot_card (重啟刷卡機), checkout (購物車結帳), lock (設備鎖定), unlock (設備解鎖)'],
|
||||
'command' => ['type' => 'string', 'description' => '系統指令類型。支援:reboot (重啟機台), reboot_card (重啟刷卡機), checkout (購物車結帳), lock (設備鎖定), unlock (設備解鎖), update_ads (廣告同步), update_products (商品資料同步)'],
|
||||
'command_id' => ['type' => 'string', 'description' => '指令唯一 ID'],
|
||||
'payload' => ['type' => 'object', 'description' => '空物件 (無額外參數)'],
|
||||
],
|
||||
|
||||
94
lang/en.json
94
lang/en.json
@ -168,9 +168,9 @@
|
||||
"Audit Permissions": "Audit Permissions",
|
||||
"Authorization updated successfully": "Authorization updated successfully",
|
||||
"Authorize": "Authorize",
|
||||
"Authorize Btn": "Authorize Btn",
|
||||
"Authorize Btn": "Authorize",
|
||||
"Authorized Accounts": "Authorized Accounts",
|
||||
"Authorized Accounts Tab": "Authorized Accounts Tab",
|
||||
"Authorized Accounts Tab": "Authorized Accounts",
|
||||
"Authorized Machines": "Authorized Machines",
|
||||
"Authorized Machines Management": "Authorized Machines Management",
|
||||
"Authorized Status": "Authorized Status",
|
||||
@ -1592,6 +1592,7 @@
|
||||
"This is a system administrator role. Its name is locked to ensure system stability.": "This is a system administrator role. Its name is locked to ensure system stability.",
|
||||
"This role belongs to another company and cannot be assigned.": "This role belongs to another company and cannot be assigned.",
|
||||
"This slot has a pending command. Please wait.": "This slot has a pending command. Please wait.",
|
||||
"This machine has a pending command. Please wait.": "This machine has a pending command. Please wait.",
|
||||
"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 is syncing with the machine. Please wait.": "This slot is syncing with the machine. Please wait.",
|
||||
"Ticket": "Ticket",
|
||||
@ -1777,33 +1778,33 @@
|
||||
"Your recent account activity": "Your recent account activity",
|
||||
"accounts": "Account Management",
|
||||
"admin": "Admin",
|
||||
"analysis": "Analysis Management",
|
||||
"analysis": "Data Analysis",
|
||||
"and :count other items": "and :count other items",
|
||||
"app": "APP Management",
|
||||
"app": "APP Maintenance",
|
||||
"audit": "Audit Management",
|
||||
"basic-settings": "Basic Settings",
|
||||
"basic.machines": "Machine Settings",
|
||||
"basic.payment-configs": "Customer Payment Configs",
|
||||
"basic.payment-configs": "Customer Payment Config",
|
||||
"by": "by",
|
||||
"cancel": "Cancel",
|
||||
"cancel": "cancel",
|
||||
"command.change": "Change",
|
||||
"command.checkout": "Checkout",
|
||||
"command.dispense": "Dispense",
|
||||
"command.lock": "Lock Machine",
|
||||
"command.lock": "Lock",
|
||||
"command.reboot": "Reboot",
|
||||
"command.reboot_card": "Reboot Card Reader",
|
||||
"command.reload_stock": "Sync Stock",
|
||||
"command.unlock": "Unlock",
|
||||
"companies": "Customer Management",
|
||||
"completed": "Pickup Completed",
|
||||
"consume": "Pickup Success",
|
||||
"consume": "Pickup Successful",
|
||||
"consume_failed": "Pickup Failed",
|
||||
"consumed": "Verification Completed",
|
||||
"consumed": "Consumed",
|
||||
"create": "Create",
|
||||
"data-config": "Data Configuration",
|
||||
"data-config.sub-account-roles": "Sub Account Roles",
|
||||
"data-config.sub-accounts": "Sub Account Management",
|
||||
"disabled": "Disabled",
|
||||
"data-config.sub-accounts": "Sub Accounts",
|
||||
"disabled": "disabled",
|
||||
"e.g. 500ml / 300g": "e.g. 500ml / 300g",
|
||||
"e.g. John Doe": "e.g. John Doe",
|
||||
"e.g. Main Warehouse": "e.g. Main Warehouse",
|
||||
@ -1818,40 +1819,40 @@
|
||||
"e.g., John Doe": "e.g., John Doe",
|
||||
"e.g., S001": "e.g., S001",
|
||||
"e.g., Taipei Station": "e.g., Taipei Station",
|
||||
"e.g., お飲み物": "e.g., お飲み物",
|
||||
"enabled": "Enabled",
|
||||
"failed": "Failed",
|
||||
"e.g., お飲み物": "e.g., Drinks",
|
||||
"enabled": "enabled",
|
||||
"failed": "failed",
|
||||
"files selected": "files selected",
|
||||
"hours ago": "hours ago",
|
||||
"image": "Image",
|
||||
"image": "image",
|
||||
"items": "items",
|
||||
"john@example.com": "john@example.com",
|
||||
"line": "Line Management",
|
||||
"line": "LINE Config",
|
||||
"log.command.failed": "Remote command [:type] execution failed",
|
||||
"log.command.success": "Remote command [:type] execution succeeded",
|
||||
"log.passcode.verify_success_note": "Maintenance staff verified pass code [:code] at machine [:machine_sn]",
|
||||
"log.command.success": "Remote command [:type] execution successful",
|
||||
"log.passcode.verify_success_note": "Maintenance staff verified passcode [:code] on machine [:machine_sn]",
|
||||
"log.pickup.consume_success": "[Pickup Code] Consumed successfully: :code, Slot: :slot",
|
||||
"log.pickup.consume_success_note": "Machine [:machine_sn] consumed pickup code. Slot :slot: :old -> :new",
|
||||
"log.pickup.dispense_failed": "[Pickup Code] Machine reported dispense failed: :code",
|
||||
"log.pickup.dispense_failed_note": "Machine [:machine_sn] reported pickup code [:code] dispense failed",
|
||||
"log.pickup.verify_success": "[Pickup Code] Verified successfully, Code: :code",
|
||||
"log.pickup.dispense_failed_note": "Machine [:machine_sn] reported dispense failed for pickup code [:code]",
|
||||
"log.pickup.verify_success": "[Pickup Code] Verification successful, Code: :code",
|
||||
"log.pickup.verify_success_note": "Machine [:machine_sn] verified pickup code [:code]",
|
||||
"machines": "Machine Management",
|
||||
"members": "Member Management",
|
||||
"menu.analysis": "Data Analysis",
|
||||
"menu.app": "APP Operations",
|
||||
"menu.app": "APP Maintenance",
|
||||
"menu.audit": "Audit Management",
|
||||
"menu.basic": "Basic Settings",
|
||||
"menu.basic.machines": "Machine Settings",
|
||||
"menu.basic.payment-configs": "Customer Payment Configs",
|
||||
"menu.basic.payment-configs": "Customer Payment Config",
|
||||
"menu.data-config": "Data Configuration",
|
||||
"menu.data-config.admin-products": "Product Status",
|
||||
"menu.data-config.advertisements": "Advertisement Management",
|
||||
"menu.data-config.badges": "Staff ID Management",
|
||||
"menu.data-config.points": "Points Settings",
|
||||
"menu.data-config.points": "Point Settings",
|
||||
"menu.data-config.products": "Product Management",
|
||||
"menu.data-config.sub-accounts": "Sub Accounts",
|
||||
"menu.line": "LINE Configuration",
|
||||
"menu.line": "LINE Config",
|
||||
"menu.machines": "Machine Management",
|
||||
"menu.machines.list": "Machine List",
|
||||
"menu.machines.maintenance": "Maintenance Records",
|
||||
@ -1862,7 +1863,7 @@
|
||||
"menu.permissions": "Permission Management",
|
||||
"menu.permissions.accounts": "Account Management",
|
||||
"menu.permissions.companies": "Customer Management",
|
||||
"menu.permissions.roles": "Role Management",
|
||||
"menu.permissions.roles": "Role Permissions",
|
||||
"menu.remote": "Remote Commands",
|
||||
"menu.remote.commands": "Command Center",
|
||||
"menu.remote.stock": "Remote Expiry & Stock",
|
||||
@ -1871,7 +1872,7 @@
|
||||
"menu.sales.orders": "Purchase Orders",
|
||||
"menu.sales.pass-codes": "Pass Codes",
|
||||
"menu.sales.pickup-codes": "Pickup Codes",
|
||||
"menu.sales.promotions": "Promotional Periods",
|
||||
"menu.sales.promotions": "Promotions",
|
||||
"menu.sales.records": "Sales Records",
|
||||
"menu.sales.store-gifts": "Store Gifts",
|
||||
"menu.special-permission": "Special Permissions",
|
||||
@ -1882,15 +1883,15 @@
|
||||
"menu.warehouses.inventory": "Inventory Management",
|
||||
"menu.warehouses.machine-inventory": "Machine Inventory Overview",
|
||||
"menu.warehouses.overview": "Warehouse Overview",
|
||||
"menu.warehouses.replenishments": "Machine Replenishment Orders",
|
||||
"menu.warehouses.transfers": "Transfer Order Management",
|
||||
"menu.warehouses.replenishments": "Machine Replenishment",
|
||||
"menu.warehouses.transfers": "Transfer Orders",
|
||||
"min": "min",
|
||||
"mins ago": "mins ago",
|
||||
"movement.note.initial_sync_report": "B009 Initial sync slot stock (New slot: :slot_no, Initial stock: :new)",
|
||||
"movement.note.manual_adjustment": "Manual stock adjustment in backend (Old: :old -> New: :new)",
|
||||
"movement.note.product_changed_and_adjusted": "B009 Slot product changed and stock adjusted (Stock :old -> :new)",
|
||||
"movement.note.replenishment_correction": "B009 Inventory correction (Old: :old -> New: :new)",
|
||||
"movement.note.slot_decommissioned_by_b009": "B009 Full sync: Slot :slot_no not in report, stock :old cleared and removed",
|
||||
"movement.note.initial_sync_report": "B009 Initial sync slot inventory (New slot: :slot_no, Initial stock: :new)",
|
||||
"movement.note.manual_adjustment": "Backend manual adjustment of slot inventory (Old: :old -> New: :new)",
|
||||
"movement.note.product_changed_and_adjusted": "B009 Slot product changed and inventory adjusted (Stock :old -> :new)",
|
||||
"movement.note.replenishment_correction": "B009 Inventory report correction (Old: :old -> New: :new)",
|
||||
"movement.note.slot_decommissioned_by_b009": "B009 Full sync: Slot :slot_no is not in the report list, stock :old reset to zero and removed",
|
||||
"movement.note.remote_dispense_queued": "Remote dispense command (B055), Command ID: :id",
|
||||
"movement.type.adjustment": "Inventory Adjustment",
|
||||
"movement.type.decommission": "B009 Full Sync Removal",
|
||||
@ -1900,11 +1901,11 @@
|
||||
"movement.type.rollback": "Dispense Failed Rollback",
|
||||
"of": "of",
|
||||
"of items": "items",
|
||||
"pending": "Pending Pickup",
|
||||
"pending": "Pending",
|
||||
"permissions": "Permission Settings",
|
||||
"permissions.accounts": "Account Management",
|
||||
"permissions.companies": "Customer Management",
|
||||
"permissions.roles": "Role Management",
|
||||
"permissions.roles": "Role Permissions",
|
||||
"remote": "Remote Management",
|
||||
"reservation": "Reservation System",
|
||||
"roles": "Roles",
|
||||
@ -1914,23 +1915,23 @@
|
||||
"set": "Set",
|
||||
"special-permission": "Special Permissions",
|
||||
"standby": "Standby Ad",
|
||||
"success": "Success",
|
||||
"success": "success",
|
||||
"superseded": "Superseded",
|
||||
"timeout": "Timeout",
|
||||
"super-admin": "Super Admin",
|
||||
"to": "to",
|
||||
"update": "Update",
|
||||
"used": "Used",
|
||||
"user": "General User",
|
||||
"user": "User",
|
||||
"vending": "Vending Page",
|
||||
"Verified Only": "Verified Only",
|
||||
"verified": "Verified Successfully",
|
||||
"verified": "Verified",
|
||||
"verify_success": "Verify Success",
|
||||
"video": "Video",
|
||||
"visit_gift": "Visit Gift",
|
||||
"vs Yesterday": "vs Yesterday",
|
||||
"warehouses": "Warehouse Management",
|
||||
"待填寫": "To be filled",
|
||||
"待填寫": "Pending",
|
||||
"Sales Today": "Sales Today",
|
||||
"Revenue": "Revenue",
|
||||
"Errors": "Errors",
|
||||
@ -1945,7 +1946,20 @@
|
||||
"Real-time fleet status and revenue monitoring": "Real-time fleet status and revenue monitoring",
|
||||
"[StaffCard] Verification failed: :uid": "[StaffCard] Verification failed: :uid",
|
||||
"[PickupCode] Verification failed: :code": "[PickupCode] Verification failed: :code",
|
||||
"Sync to Machine": "Sync to Machine",
|
||||
"Manual Sync Ads": "Manual Sync Ads",
|
||||
"Sync command sent successfully.": "Sync command sent successfully.",
|
||||
"Failed to send sync command.": "Failed to send sync command.",
|
||||
"Sync Ads": "Sync Ads",
|
||||
"Uploading...": "Uploading...",
|
||||
"Upload failed, please try again": "Upload failed, please try again"
|
||||
"Upload failed, please try again": "Upload failed, please try again",
|
||||
"Sync to All Machines": "Sync to All Machines",
|
||||
"Sync Products": "Sync Products",
|
||||
"Reason for this sync...": "Reason for this sync...",
|
||||
"Select Target Company": "Select Target Company",
|
||||
"Search Company...": "Search Company...",
|
||||
"Manual Sync Products": "Manual Sync Products",
|
||||
"Please select a company.": "Please select a company.",
|
||||
"Batch sync command has been queued. Machines will be updated sequentially.": "Batch sync command has been queued. Machines will be updated sequentially.",
|
||||
"A sync command was recently sent. Please wait 1 minute.": "A sync command was recently sent. Please wait 1 minute."
|
||||
}
|
||||
1807
lang/ja.json
1807
lang/ja.json
File diff suppressed because it is too large
Load Diff
@ -1952,5 +1952,14 @@
|
||||
"Failed to send sync command.": "無法送出同步指令",
|
||||
"Sync Ads": "同步廣告",
|
||||
"Uploading...": "上傳中...",
|
||||
"Upload failed, please try again": "上傳失敗,請重試"
|
||||
"Upload failed, please try again": "上傳失敗,請重試",
|
||||
"Sync to All Machines": "同步到所有機台",
|
||||
"Sync Products": "同步商品資料",
|
||||
"Reason for this sync...": "此次同步的原因...",
|
||||
"Select Target Company": "選擇目標公司",
|
||||
"Search Company...": "搜尋公司...",
|
||||
"Manual Sync Products": "手動同步商品",
|
||||
"Please select a company.": "請選擇公司",
|
||||
"Batch sync command has been queued. Machines will be updated sequentially.": "批次同步指令已進入隊列,機台將依序進行更新",
|
||||
"A sync command was recently sent. Please wait 1 minute.": "近期已發送過同步指令,請等待 1 分鐘後再試"
|
||||
}
|
||||
@ -25,13 +25,22 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
<x-page-header :title="__('Product Management')"
|
||||
:subtitle="__('Manage your catalog, prices, and multilingual details.')">
|
||||
<template x-if="activeTab === 'products'">
|
||||
<a href="{{ route($baseRoute . '.create') }}"
|
||||
class="btn-luxury-primary whitespace-nowrap flex items-center gap-2 transition-all duration-300">
|
||||
<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 class="text-sm sm:text-base">{{ __('Add Product') }}</span>
|
||||
</a>
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<button @click="syncToAllMachines()"
|
||||
class="btn-luxury-ghost group whitespace-nowrap flex items-center gap-2 transition-all duration-300 py-2 sm:py-2.5 px-4 sm:px-6 rounded-2xl border-slate-200 dark:border-white/10 hover:border-cyan-500/50 hover:bg-cyan-500/5">
|
||||
<svg class="w-4 h-4 text-cyan-500 group-hover:rotate-180 transition-transform duration-700" 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 class="text-sm font-black tracking-tight uppercase">{{ __('Sync to All Machines') }}</span>
|
||||
</button>
|
||||
<a href="{{ route($baseRoute . '.create') }}"
|
||||
class="btn-luxury-primary whitespace-nowrap flex items-center gap-2 transition-all duration-300 shadow-lg shadow-emerald-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="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<span class="text-sm sm:text-base font-black tracking-tight uppercase">{{ __('Add Product') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="activeTab === 'categories'">
|
||||
<button @click="openCategoryModal()" type="button"
|
||||
@ -459,6 +468,98 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Confirmation Modal (Mirroring Remote Control) -->
|
||||
<template x-teleport="body">
|
||||
<div x-show="confirmModal.show" class="fixed inset-0 z-[120] overflow-y-auto" x-cloak>
|
||||
<div class="flex min-h-screen items-center justify-center p-4 text-center sm:p-0">
|
||||
<!-- Background Backdrop -->
|
||||
<div x-show="confirmModal.show" 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="confirmModal.show = false"></div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div x-show="confirmModal.show" 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 transform overflow-hidden rounded-[2.5rem] bg-white dark:bg-slate-900 p-8 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-lg border border-slate-200 dark:border-slate-800">
|
||||
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<div
|
||||
class="w-14 h-14 rounded-2xl bg-amber-500/10 flex items-center justify-center text-amber-500 border border-amber-500/20">
|
||||
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<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>
|
||||
</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-sm font-black text-slate-800 dark:text-slate-200"
|
||||
x-text="getCommandName(confirmModal.type)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-1 pt-3 border-t border-slate-200/50 dark:border-slate-800/50">
|
||||
<span class="text-[10px] font-black text-cyan-500 uppercase tracking-widest">{{ __('Target') }}</span>
|
||||
<span class="text-sm font-black text-slate-800 dark:text-slate-200"
|
||||
x-text="confirmModal.company_id ? (companies.find(c => c.id == confirmModal.company_id)?.name || '{{ __('All Machines') }}') : '{{ __('All Machines') }}'"></span>
|
||||
</div>
|
||||
|
||||
@if(auth()->user()->isSystemAdmin())
|
||||
<div class="space-y-2 px-1 pt-3 border-t border-slate-200/50 dark:border-slate-800/50">
|
||||
<label class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.2em] ml-1">
|
||||
{{ __('Select Target Company') }}
|
||||
</label>
|
||||
<div id="sync_company_select_wrapper" class="relative">
|
||||
<!-- Searchable Select will be injected here -->
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="space-y-2 px-1 pt-3 border-t border-slate-200/50 dark:border-slate-800/50">
|
||||
<label
|
||||
class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.2em] ml-1">{{
|
||||
__('Operation Note') }}</label>
|
||||
<textarea x-model="confirmModal.note"
|
||||
class="luxury-input w-full min-h-[100px] text-sm py-3 px-4 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 focus:border-cyan-500/50"
|
||||
placeholder="{{ __('Reason for this sync...') }}"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<button @click="confirmModal.show = false"
|
||||
class="flex-1 px-6 py-4 rounded-2xl bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 text-xs font-black uppercase tracking-widest hover:bg-slate-200 dark:hover:bg-slate-700 transition-all">
|
||||
{{ __('Cancel') }}
|
||||
</button>
|
||||
<button @click="executeSyncToAll()" :disabled="loading"
|
||||
class="flex-1 px-6 py-4 rounded-2xl bg-cyan-600 text-white text-xs font-black uppercase tracking-widest hover:bg-cyan-500 shadow-lg shadow-cyan-500/20 active:scale-[0.98] transition-all disabled:opacity-50">
|
||||
<span x-show="!loading">{{ __('Execute') }}</span>
|
||||
<span x-show="loading" class="flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-4 w-4 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>
|
||||
{{ __('Processing...') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@ -472,6 +573,12 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
isStatusConfirmOpen: false,
|
||||
isDeleteConfirmOpen: false,
|
||||
isCategoryModalOpen: false,
|
||||
confirmModal: {
|
||||
show: false,
|
||||
type: 'sync_products',
|
||||
note: '',
|
||||
company_id: ''
|
||||
},
|
||||
activeTab: '{{ request("tab", "products") }}',
|
||||
tabLoading: null,
|
||||
loading: false,
|
||||
@ -490,11 +597,26 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
init() {
|
||||
this.categories = JSON.parse(this.$el.dataset.categories || '[]');
|
||||
this.companies = @js($companies);
|
||||
this.translations = @js([
|
||||
'Sync Products' => __('Sync Products'),
|
||||
'Connection error' => __('Connection error'),
|
||||
'Failed to send command' => __('Failed to send command'),
|
||||
'Select Target Company' => __('Select Target Company'),
|
||||
'Search Company...' => __('Search Company...'),
|
||||
]);
|
||||
|
||||
// Initial binding
|
||||
this.bindPaginationLinks('#tab-products-container', 'products');
|
||||
this.bindPaginationLinks('#tab-categories-container', 'categories');
|
||||
|
||||
this.$watch('confirmModal.show', (value) => {
|
||||
if (value && document.getElementById('sync_company_select_wrapper')) {
|
||||
this.$nextTick(() => {
|
||||
this.updateSyncCompanySelect();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for category modal updates
|
||||
this.$watch('isCategoryModalOpen', (value) => {
|
||||
if (value && document.getElementById('category_company_select_wrapper')) {
|
||||
@ -788,6 +910,108 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString();
|
||||
},
|
||||
|
||||
syncToAllMachines() {
|
||||
this.confirmModal.note = '';
|
||||
this.confirmModal.company_id = '{{ auth()->user()->company_id }}';
|
||||
this.confirmModal.show = true;
|
||||
},
|
||||
|
||||
updateSyncCompanySelect() {
|
||||
const wrapper = document.getElementById('sync_company_select_wrapper');
|
||||
if (!wrapper) return;
|
||||
wrapper.innerHTML = '';
|
||||
|
||||
const selectEl = document.createElement('select');
|
||||
selectEl.name = 'sync_company_id';
|
||||
const uniqueId = 'sync-company-' + Date.now();
|
||||
selectEl.id = uniqueId;
|
||||
selectEl.className = 'hidden';
|
||||
|
||||
const config = {
|
||||
"placeholder": this.translations['Select Target Company'],
|
||||
"hasSearch": true,
|
||||
"searchPlaceholder": this.translations['Search Company...'],
|
||||
"isHidePlaceholder": false,
|
||||
"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 placeholder:text-slate-400 dark:placeholder:text-slate-500",
|
||||
"searchWrapperClasses": "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10",
|
||||
"toggleClasses": "hs-select-toggle luxury-select-toggle",
|
||||
"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-[100] animate-luxury-in",
|
||||
"optionClasses": "hs-select-option py-2.5 px-3 mb-0.5 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 flex items-center justify-between transition-all duration-300",
|
||||
"optionTemplate": '<div class="flex items-center justify-between w-full"><span data-title></span><span class="hs-select-active-indicator hidden text-cyan-500"><svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></span></div>'
|
||||
};
|
||||
|
||||
selectEl.setAttribute('data-hs-select', JSON.stringify(config));
|
||||
|
||||
this.companies.forEach((company, index) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = company.id;
|
||||
opt.textContent = company.name;
|
||||
opt.setAttribute('data-title', company.name);
|
||||
|
||||
// 如果目前尚未選擇公司 (例如系統管理員初次點開),則自動預設選中第一個
|
||||
if (!this.confirmModal.company_id && index === 0) {
|
||||
this.confirmModal.company_id = company.id;
|
||||
opt.selected = true;
|
||||
} else if (String(company.id) === String(this.confirmModal.company_id)) {
|
||||
opt.selected = true;
|
||||
}
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
|
||||
selectEl.addEventListener('change', (e) => {
|
||||
this.confirmModal.company_id = e.target.value;
|
||||
});
|
||||
|
||||
wrapper.appendChild(selectEl);
|
||||
|
||||
// Initialize Preline Select
|
||||
this.$nextTick(() => {
|
||||
if (window.HSStaticMethods && window.HSStaticMethods.autoInit) {
|
||||
window.HSStaticMethods.autoInit();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getCommandName(type) {
|
||||
const names = {
|
||||
'sync_products': this.translations['Sync Products']
|
||||
};
|
||||
return names[type] || type;
|
||||
},
|
||||
|
||||
async executeSyncToAll() {
|
||||
this.loading = true;
|
||||
this.confirmModal.show = false;
|
||||
try {
|
||||
const response = await fetch('{{ route($baseRoute . ".sync-all") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
remark: this.confirmModal.note,
|
||||
company_id: this.confirmModal.company_id
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (window.showToast) window.showToast(data.message, 'success');
|
||||
this.confirmModal.note = '';
|
||||
} else {
|
||||
if (window.showToast) window.showToast(data.message || this.translations['Failed to send command'], 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sync error:', error);
|
||||
if (window.showToast) window.showToast(this.translations['Connection error'], 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
@ -42,6 +42,7 @@
|
||||
'Remote Dispense' => __('Remote Dispense'),
|
||||
'Adjust Stock & Expiry' => __('Adjust Stock & Expiry'),
|
||||
'Sync Ads' => __('Sync Ads'),
|
||||
'Sync Products' => __('Sync Products'),
|
||||
'Pending' => __('Pending'),
|
||||
'Sent' => __('Sent'),
|
||||
'Success' => __('Success'),
|
||||
@ -443,7 +444,8 @@
|
||||
'change': this.translations['Remote Change'],
|
||||
'dispense': this.translations['Remote Dispense'],
|
||||
'reload_stock': this.translations['Adjust Stock & Expiry'],
|
||||
'update_ads': this.translations['Sync Ads']
|
||||
'update_ads': this.translations['Sync Ads'],
|
||||
'update_products': this.translations['Sync Products']
|
||||
};
|
||||
return names[type] || type;
|
||||
},
|
||||
|
||||
@ -84,6 +84,7 @@
|
||||
'change' => __('Remote Change'),
|
||||
'dispense' => __('Remote Dispense'),
|
||||
'update_ads' => __('Sync Ads'),
|
||||
'update_products' => __('Sync Products'),
|
||||
]" :selected="request('command_type')" :placeholder="__('All Command Types')"
|
||||
:hasSearch="false"
|
||||
@change="searchInTab('history')" />
|
||||
@ -209,7 +210,7 @@
|
||||
<td class="px-6 py-6">
|
||||
<div class="flex flex-col min-w-[200px]">
|
||||
@if($item->remark)
|
||||
<span class="text-sm font-bold text-slate-600 dark:text-slate-300 break-words leading-tight">{{ $item->remark }}</span>
|
||||
<span class="text-sm font-bold text-slate-600 dark:text-slate-300 break-words leading-tight">{{ __($item->remark) }}</span>
|
||||
@endif
|
||||
|
||||
@if($item->note)
|
||||
@ -330,7 +331,7 @@
|
||||
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Operation Note') }}</p>
|
||||
<div class="flex flex-col gap-1.5 p-3 rounded-2xl bg-slate-50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-800/50">
|
||||
@if($item->remark)
|
||||
<span class="text-sm font-bold text-slate-700 dark:text-slate-200 leading-tight">{{ $item->remark }}</span>
|
||||
<span class="text-sm font-bold text-slate-700 dark:text-slate-200 leading-tight">{{ __($item->remark) }}</span>
|
||||
@endif
|
||||
@if($item->note)
|
||||
<div class="flex items-center gap-1.5 opacity-60">
|
||||
|
||||
@ -163,6 +163,7 @@ Route::middleware(['auth', 'verified', 'tenant.access'])->prefix('admin')->name(
|
||||
Route::middleware('can:menu.data-config.products')->group(function () {
|
||||
Route::resource('products', App\Http\Controllers\Admin\ProductController::class)->except(['show']);
|
||||
Route::patch('/products/{id}/toggle-status', [App\Http\Controllers\Admin\ProductController::class, 'toggleStatus'])->name('products.status.toggle');
|
||||
Route::post('/products/sync-all', [App\Http\Controllers\Admin\ProductController::class, 'syncToAllMachines'])->name('products.sync-all');
|
||||
Route::resource('product-categories', App\Http\Controllers\Admin\ProductCategoryController::class)->except(['show', 'create', 'edit']);
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user