[RELEASE] demo -> main: 取貨碼/通行碼批次新增(≤1000)+CSV下載+角色數量分級+cache表+取貨頁/下載修正

This commit is contained in:
terrylee 2026-06-29 15:40:43 +08:00
commit 6ed11bf858
14 changed files with 486 additions and 12 deletions

View File

@ -19,6 +19,7 @@ use App\Models\Machine\Machine;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use App\Models\System\SystemOperationLog;
@ -373,6 +374,11 @@ class SalesController extends Controller
'tab' => $tab,
];
// 批次產生數量上限/快捷選項依角色分級:平台管理員 100010/50/100/1000、客戶端 2010/20
$isSystemAdmin = Auth::user()->isSystemAdmin();
$data['maxBatchQuantity'] = $isSystemAdmin ? 1000 : 20;
$data['batchQuickOptions'] = $isSystemAdmin ? [10, 50, 100, 1000] : [10, 20];
// 1. 取貨碼列表 (list)
if (!$isAjax || $tab === 'list') {
// 僅顯示目前帳號可存取機台的取貨碼(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope
@ -474,6 +480,10 @@ class SalesController extends Controller
*/
public function storePickupCode(Request $request)
{
// 批次數量上限依角色分級:平台管理員最多 1000 筆;客戶端(租戶)最多 20 筆。
// 後端強制把關,避免前端被繞過送出大量產碼請求。
$maxQuantity = Auth::user()->isSystemAdmin() ? 1000 : 20;
$validated = $request->validate([
'machine_id' => 'required|exists:machines,id',
// 取貨碼改綁「商品」:新流程傳 product_id舊的綁「貨道」流程仍可傳 slot_no向後相容
@ -483,9 +493,11 @@ class SalesController extends Controller
'expires_hours' => 'nullable|integer|min:1|max:720',
'expires_at' => 'nullable|date|after:now',
'custom_code' => 'nullable|string|min:4|max:12',
'quantity' => 'nullable|integer|min:1|max:' . $maxQuantity,
]);
$machine = Machine::findOrFail($validated['machine_id']);
$quantity = (int) ($validated['quantity'] ?? 1);
// 綁商品時驗證該商品屬於此機台所屬公司(不要求機台當下有貨道;機台未上架仍可產碼,
// 由前端提示、刷碼時若無貨道再由 B660 擋下)。
@ -502,6 +514,66 @@ class SalesController extends Controller
$expiresAt = $request->filled('expires_at')
? Carbon::parse($validated['expires_at'])
: now()->addHours((int) ($request->expires_hours ?? 24));
$usageLimit = $validated['usage_limit'] ?? 1;
// 批次產生(>1 筆):忽略自訂碼,系統自動產生隨機唯一碼,並以 batch_no 標記同一批。
// 沿用單筆的綁定方式(優先 product_id相容舊的 slot_no
if ($quantity > 1) {
$batchNo = 'PUB' . now()->format('YmdHis') . strtoupper(Str::random(4));
$codes = $this->generateUniqueCodeBatch(PickupCode::class, $machine->id, $quantity);
$nowTs = now();
$rows = [];
foreach ($codes as $code) {
$rows[] = [
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'product_id' => $validated['product_id'] ?? null,
'slot_no' => $validated['slot_no'] ?? null,
'code' => $code,
'slug' => Str::random(16),
'batch_no' => $batchNo,
'status' => 'active',
'usage_limit' => $usageLimit,
'usage_count' => 0,
'expires_at' => $expiresAt,
'created_by' => Auth::id(),
'created_at' => $nowTs,
'updated_at' => $nowTs,
];
}
DB::transaction(function () use ($rows) {
foreach (array_chunk($rows, 500) as $chunk) {
PickupCode::insert($chunk);
}
});
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'pickup_code',
'action' => 'batch_create',
'target_id' => null,
'target_type' => PickupCode::class,
'new_values' => [
'batch_no' => $batchNo,
'count' => count($rows),
'machine_id' => $machine->id,
'product_id' => $validated['product_id'] ?? null,
'slot_no' => $validated['slot_no'] ?? null,
],
]);
return back()
->with('success', __(':count pickup codes generated', ['count' => count($rows)]))
->with('code_batch', [
'type' => 'pickup',
'batch_no' => $batchNo,
'count' => count($rows),
'download_url' => route('admin.sales.pickup-codes.batch-download', $batchNo),
]);
}
$pickupCode = PickupCode::create([
'machine_id' => $validated['machine_id'],
@ -641,6 +713,11 @@ class SalesController extends Controller
'tab' => $tab,
];
// 批次產生數量上限/快捷選項依角色分級:平台管理員 100010/50/100/1000、客戶端 2010/20
$isSystemAdmin = Auth::user()->isSystemAdmin();
$data['maxBatchQuantity'] = $isSystemAdmin ? 1000 : 20;
$data['batchQuickOptions'] = $isSystemAdmin ? [10, 50, 100, 1000] : [10, 20];
// 1. 通行碼列表 (list)
if (!$isAjax || $tab === 'list') {
// 僅顯示目前帳號可存取機台的通行碼(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope
@ -750,20 +827,79 @@ class SalesController extends Controller
*/
public function storePassCode(Request $request)
{
// 批次數量上限依角色分級:平台管理員最多 1000 筆;客戶端(租戶)最多 20 筆(後端強制把關)。
$maxQuantity = Auth::user()->isSystemAdmin() ? 1000 : 20;
$validated = $request->validate([
'machine_id' => 'required|exists:machines,id',
'name' => 'required|string|max:50',
'expires_days' => 'nullable|integer|min:0',
'custom_code' => 'required|string|min:4|max:12',
'custom_code' => 'nullable|string|min:4|max:12',
'quantity' => 'nullable|integer|min:1|max:' . $maxQuantity,
]);
$machine = Machine::findOrFail($validated['machine_id']);
$quantity = (int) ($validated['quantity'] ?? 1);
$expiresAt = $request->expires_days ? now()->addDays((int) $request->expires_days) : null;
// 批次產生(>1 筆):忽略自訂碼,系統自動產生隨機唯一碼,並以 batch_no 標記同一批。
if ($quantity > 1) {
$batchNo = 'PSB' . now()->format('YmdHis') . strtoupper(Str::random(4));
$codes = $this->generateUniqueCodeBatch(PassCode::class, $machine->id, $quantity);
$nowTs = now();
$rows = [];
foreach ($codes as $i => $code) {
$rows[] = [
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'name' => $validated['name'] . ' #' . ($i + 1),
'code' => $code,
'slug' => Str::random(16),
'batch_no' => $batchNo,
'expires_at' => $expiresAt,
'status' => 'active',
'created_by' => Auth::id(),
'created_at' => $nowTs,
'updated_at' => $nowTs,
];
}
DB::transaction(function () use ($rows) {
foreach (array_chunk($rows, 500) as $chunk) {
PassCode::insert($chunk);
}
});
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'pass_code',
'action' => 'batch_create',
'target_id' => null,
'target_type' => PassCode::class,
'new_values' => [
'batch_no' => $batchNo,
'count' => count($rows),
'machine_id' => $machine->id,
'name' => $validated['name'],
],
]);
return back()
->with('success', __(':count pass codes created', ['count' => count($rows)]))
->with('code_batch', [
'type' => 'pass',
'batch_no' => $batchNo,
'count' => count($rows),
'download_url' => route('admin.sales.pass-codes.batch-download', $batchNo),
]);
}
$passCode = PassCode::create([
'machine_id' => $validated['machine_id'],
'name' => $validated['name'] ?? 'Manual Generate',
'code' => $validated['custom_code'] ?? PassCode::generateUniqueCode($validated['machine_id']),
'code' => $request->custom_code ?: PassCode::generateUniqueCode($validated['machine_id']),
'expires_at' => $expiresAt,
'status' => 'active',
'company_id' => $machine->company_id,
@ -783,6 +919,125 @@ class SalesController extends Controller
return back()->with('success', __('Pass code created: :code', ['code' => $passCode->code]));
}
/**
* 批次產生指定數量的唯一 8 位數字碼(避開該機台目前有效的碼,並去除同批重複)
*
* @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass PickupCode::class PassCode::class
* @return string[]
*/
private function generateUniqueCodeBatch(string $modelClass, int $machineId, int $quantity): array
{
// 載入該機台目前仍有效的碼,避免與既有碼衝突
$existing = array_flip(
$modelClass::where('machine_id', $machineId)
->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->pluck('code')
->all()
);
$codes = [];
$seen = [];
while (count($codes) < $quantity) {
$code = str_pad((string) random_int(0, 99999999), 8, '0', STR_PAD_LEFT);
if (isset($existing[$code]) || isset($seen[$code])) {
continue;
}
$seen[$code] = true;
$codes[] = $code;
}
return $codes;
}
/**
* 下載某一批取貨碼清單 (CSV)
*/
public function downloadPickupCodeBatch(string $batchNo)
{
$codes = PickupCode::with(['machine:id,name,serial_no', 'product:id,name'])
->where('batch_no', $batchNo)
->orderBy('id')
->get();
abort_if($codes->isEmpty(), 404);
$header = [
__('Pickup Code'),
__('Target Machine'),
__('Select Product'),
__('Select Slot'),
__('Usage Limit'),
__('Expires At'),
__('Link'),
__('Created At'),
];
return response()->streamDownload(function () use ($codes, $header) {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF"); // UTF-8 BOMExcel 開啟才不會亂碼
fputcsv($out, $header);
foreach ($codes as $c) {
fputcsv($out, [
$c->code,
optional($c->machine)->name . ' (' . optional($c->machine)->serial_no . ')',
optional($c->product)->name ?? '',
$c->slot_no ?? '',
$c->usage_limit,
optional($c->expires_at)->format('Y-m-d H:i') ?? __('Permanent'),
$c->ticket_url,
optional($c->created_at)->format('Y-m-d H:i'),
]);
}
fclose($out);
}, 'pickup-codes-' . $batchNo . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
/**
* 下載某一批通行碼清單 (CSV)
*/
public function downloadPassCodeBatch(string $batchNo)
{
$codes = PassCode::with('machine:id,name,serial_no')
->where('batch_no', $batchNo)
->orderBy('id')
->get();
abort_if($codes->isEmpty(), 404);
$header = [
__('Pass Code'),
__('Description / Name'),
__('Target Machine'),
__('Expires At'),
__('Link'),
__('Created At'),
];
return response()->streamDownload(function () use ($codes, $header) {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, $header);
foreach ($codes as $c) {
fputcsv($out, [
$c->code,
$c->name,
optional($c->machine)->name . ' (' . optional($c->machine)->serial_no . ')',
optional($c->expires_at)->format('Y-m-d H:i') ?? __('Permanent'),
$c->ticket_url,
optional($c->created_at)->format('Y-m-d H:i'),
]);
}
fclose($out);
}, 'pass-codes-' . $batchNo . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
/**
* 更新通行碼
*/

View File

@ -13,7 +13,7 @@ class PickupController extends Controller
*/
public function show($slug)
{
$pickupCode = PickupCode::with(['machine.slots.product'])
$pickupCode = PickupCode::with(['machine.slots.product', 'product'])
->where(function($query) use ($slug) {
$query->where('slug', $slug)
->orWhere('code', $slug); // 相容舊版或測試

View File

@ -18,6 +18,7 @@ class PassCode extends Model
'name',
'code',
'slug',
'batch_no',
'expires_at',
'status',
'created_by',

View File

@ -19,6 +19,7 @@ class PickupCode extends Model
'slot_no',
'code',
'slug',
'batch_no',
'status',
'expires_at',
'used_at',

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 建立 Laravel 標準 cache / cache_locks 表。
* 對應 dev 的修正 55cf6b8CACHE_STORE=database 須有這兩張表,
* 否則 ProcessHeartbeat 等的 Cache::put 會因缺表而失敗。
*/
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasTable('cache')) {
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
}
if (!Schema::hasTable('cache_locks')) {
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
}
}
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 取貨碼 / 通行碼 批次新增:以 batch_no 標記同一批產生的碼,供後台批次下載清單使用。
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('pickup_codes', function (Blueprint $table) {
$table->string('batch_no', 32)->nullable()->after('slug')->index();
});
Schema::table('pass_codes', function (Blueprint $table) {
$table->string('batch_no', 32)->nullable()->after('slug')->index();
});
}
public function down(): void
{
Schema::table('pickup_codes', function (Blueprint $table) {
$table->dropIndex(['batch_no']);
$table->dropColumn('batch_no');
});
Schema::table('pass_codes', function (Blueprint $table) {
$table->dropIndex(['batch_no']);
$table->dropColumn('batch_no');
});
}
};

View File

@ -1,4 +1,12 @@
{
"Search Product...": "Search Product...",
"Generate Quantity": "Generate Quantity",
"Batch mode: codes are auto-generated and the custom code is ignored.": "Batch mode: codes are auto-generated and the custom code is ignored.",
"Batch generated successfully": "Batch generated successfully",
"codes": "codes",
"Download List (CSV)": "Download List (CSV)",
":count pickup codes generated": ":count pickup codes generated",
":count pass codes created": ":count pass codes created",
"(deducted by issued pickup orders)": "(deducted by issued pickup orders)",
"-- Select Machine --": "-- Select Machine --",
"10s": "10s",

View File

@ -1,4 +1,12 @@
{
"Search Product...": "商品を検索...",
"Generate Quantity": "生成数量",
"Batch mode: codes are auto-generated and the custom code is ignored.": "バッチモード:コードは自動生成され、カスタムコードは無視されます。",
"Batch generated successfully": "バッチ生成が完了しました",
"codes": "件",
"Download List (CSV)": "リストをダウンロード (CSV)",
":count pickup codes generated": ":count 件の受取コードを生成しました",
":count pass codes created": ":count 件の通行コードを作成しました",
"(deducted by issued pickup orders)": "(発行済み受取注文の予約分を差し引き済み)",
"-- Select Machine --": "-- 機器を選択 --",
"10s": "10秒",

View File

@ -1,4 +1,12 @@
{
"Search Product...": "搜尋商品...",
"Generate Quantity": "產生數量",
"Batch mode: codes are auto-generated and the custom code is ignored.": "批次模式:系統自動產生隨機碼,將忽略自訂碼。",
"Batch generated successfully": "批次產生成功",
"codes": "筆",
"Download List (CSV)": "下載清單 (CSV)",
":count pickup codes generated": "已產生 :count 筆取貨碼",
":count pass codes created": "已建立 :count 筆通行碼",
"(deducted by issued pickup orders)": "(已扣除已生成領藥單的預留量)",
"-- Select Machine --": "-- 選擇機台 --",
"10s": "10秒",

View File

@ -0,0 +1,29 @@
@if(session('code_batch'))
@php($batch = session('code_batch'))
<div class="luxury-card rounded-3xl p-6 border border-emerald-500/30 bg-emerald-500/5 flex flex-col sm:flex-row sm:items-center gap-4 animate-luxury-in">
<div class="flex items-center gap-3 flex-1">
<div class="w-11 h-11 rounded-2xl bg-emerald-500/15 text-emerald-500 flex items-center justify-center shrink-0">
<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="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<p class="text-sm font-black text-slate-800 dark:text-white">{{ __('Batch generated successfully') }}</p>
<p class="text-xs font-bold text-slate-500">
{{ __('Batch No') }}: <span class="font-mono text-cyan-600 dark:text-cyan-400">{{ $batch['batch_no'] }}</span>
· {{ $batch['count'] }} {{ __('codes') }}
</p>
</div>
</div>
{{-- download 屬性讓全域連結攔截器layouts/admin跳過「導航載入遮罩」
否則檔案下載不會換頁、遮罩不會關閉,畫面會卡在模糊狀態。 --}}
<a href="{{ $batch['download_url'] }}" download
class="btn-luxury-primary whitespace-nowrap flex items-center justify-center gap-2">
<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="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M7.5 12l4.5 4.5m0 0l4.5-4.5M12 3v13.5" />
</svg>
{{ __('Download List (CSV)') }}
</a>
</div>
@endif

View File

@ -12,6 +12,9 @@
passCodeName: '',
expiresDays: 7,
customCode: '',
quantity: 1,
maxBatchQty: {{ (int) ($maxBatchQuantity ?? 20) }},
batchQuickOptions: @js($batchQuickOptions ?? [10, 20]),
status: '{{ request('status', '') }}',
showQrModal: false,
activeQrCode: '',
@ -145,7 +148,7 @@
window.showToast('{{ __('Please enter a description') }}', 'error');
return;
}
if (!this.customCode.trim()) {
if (parseInt(this.quantity) <= 1 && !this.customCode.trim()) {
window.showToast('{{ __('Please enter or generate a pass code') }}', 'error');
return;
}
@ -194,12 +197,15 @@
this.selectedMachine = '';
this.passCodeName = '';
this.expiresDays = 7;
this.quantity = 1;
this.generateRandomCode();
this.syncSelect('modal-pass-machine', '');
}
});
}
} " @ajax:navigate.window.prevent="fetchTabData(activeTab, $event.detail.url)">
@include('admin.sales.partials.batch-result-banner')
{{-- Page Header --}}
<x-page-header :title="__('Pass Codes')"
:subtitle="__('Manage multi-use authorization codes for testing and maintenance')">
@ -302,6 +308,42 @@
</div>
</div>
{{-- Quantity (Batch Generate) --}}
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Generate Quantity') }} (1-{{ $maxBatchQuantity ?? 20 }})
</label>
<div class="flex items-center gap-3">
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden flex-1 group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="quantity = Math.max(1, parseInt(quantity || 1) - 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<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 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<input type="number" name="quantity" x-model="quantity" min="1" :max="maxBatchQty"
@input="if (parseInt(quantity) > maxBatchQty) quantity = maxBatchQty"
class="flex-1 min-w-0 bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 p-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="quantity = Math.min(maxBatchQty, parseInt(quantity || 1) + 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<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="M12 5v14M5 12h14"/></svg>
</button>
</div>
<div class="flex items-center gap-1.5">
<template x-for="val in batchQuickOptions" :key="val">
<button type="button" @click="quantity = val"
class="px-2 h-10 rounded-xl border border-slate-200 dark:border-slate-700 flex items-center justify-center text-[11px] font-black transition-all shrink-0"
:class="quantity == val ? 'bg-cyan-500 text-white border-cyan-500 shadow-lg shadow-cyan-500/20' : 'bg-white dark:bg-slate-800 text-slate-500 hover:border-cyan-500/50'">
<span x-text="val"></span>
</button>
</template>
</div>
</div>
<p class="text-[10px] font-bold text-amber-500 pl-1" x-show="parseInt(quantity) > 1" x-cloak>
{{ __('Batch mode: codes are auto-generated and the custom code is ignored.') }}
</p>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
@ -317,8 +359,10 @@
</button>
</div>
<input type="text" name="custom_code" x-model="customCode"
:disabled="parseInt(quantity) > 1" :required="parseInt(quantity) <= 1"
:class="parseInt(quantity) > 1 ? 'opacity-40 cursor-not-allowed' : ''"
class="luxury-input w-full py-4 font-mono text-2xl tracking-[0.3em] text-center text-cyan-600 dark:text-cyan-400 bg-cyan-50/30 dark:bg-cyan-500/5"
maxlength="12" required>
maxlength="12">
</div>
<div class="space-y-4">

View File

@ -2,9 +2,9 @@
@php
$slotSelectConfig = [
"placeholder" => __("Select Slot"),
"placeholder" => __("Select Product"),
"hasSearch" => true,
"searchPlaceholder" => __("Search Slot..."),
"searchPlaceholder" => __("Search Product..."),
"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
@ -37,7 +37,7 @@ transition-all duration-300",
<div class="space-y-2 pb-20" x-data="pickupCodeManager(@js($slotSelectConfig))"
@ajax:navigate.window.prevent="fetchTabData(activeTab, $event.detail.url)">
@include('admin.sales.partials.batch-result-banner')
{{-- Page Header --}}
<x-page-header :title="__('Pickup Codes')"
@ -295,6 +295,41 @@ transition-all duration-300",
</div>
</div>
{{-- Quantity (Batch Generate) 獨立整列,避免與使用次數上限擠在同一列重疊。上限/快捷選項依角色分級 --}}
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
__('Generate Quantity') }} (1-{{ $maxBatchQuantity ?? 20 }})</label>
<div class="flex items-center gap-3">
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden flex-1 group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="quantity = Math.max(1, parseInt(quantity || 1) - 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<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 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<input type="number" name="quantity" x-model="quantity" min="1" :max="maxBatchQty"
@input="if (parseInt(quantity) > maxBatchQty) quantity = maxBatchQty"
class="flex-1 min-w-0 bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 p-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="quantity = Math.min(maxBatchQty, parseInt(quantity || 1) + 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<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="M12 5v14M5 12h14"/></svg>
</button>
</div>
<div class="flex items-center gap-1.5">
<template x-for="val in batchQuickOptions" :key="val">
<button type="button" @click="quantity = val"
class="px-2 h-10 rounded-xl border border-slate-200 dark:border-slate-700 flex items-center justify-center text-[11px] font-black transition-all shrink-0"
:class="quantity == val ? 'bg-cyan-500 text-white border-cyan-500 shadow-lg shadow-cyan-500/20' : 'bg-white dark:bg-slate-800 text-slate-500 hover:border-cyan-500/50'">
<span x-text="val"></span>
</button>
</template>
</div>
</div>
<p class="text-[10px] font-bold text-amber-500 pl-1" x-show="parseInt(quantity) > 1" x-cloak>
{{ __('Batch mode: codes are auto-generated and the custom code is ignored.') }}
</p>
</div>
{{-- Pickup Code Preview Section --}}
<div class="space-y-2">
<div class="flex items-center justify-between mb-1">
@ -311,8 +346,10 @@ transition-all duration-300",
</button>
</div>
<input type="text" name="custom_code" x-model="customCode"
:disabled="parseInt(quantity) > 1" :required="parseInt(quantity) <= 1"
:class="parseInt(quantity) > 1 ? 'opacity-40 cursor-not-allowed' : ''"
class="luxury-input w-full py-4 font-mono text-2xl tracking-[0.3em] text-center text-cyan-600 dark:text-cyan-400 bg-cyan-50/30 dark:bg-cyan-500/5"
maxlength="12" required>
maxlength="12">
</div>
{{-- Validity Period Section (Flexible) --}}
@ -504,6 +541,9 @@ transition-all duration-300",
expiryMode: 'hours',
customExpiry: '',
usageLimit: 1,
quantity: 1,
maxBatchQty: {{ (int) ($maxBatchQuantity ?? 20) }},
batchQuickOptions: @js($batchQuickOptions ?? [10, 20]),
showQrModal: false,
activeQrCode: '',
activeTicketUrl: '',
@ -801,6 +841,7 @@ transition-all duration-300",
this.selectedProduct = '';
this.productNotOnMachine = false;
this.usageLimit = 1;
this.quantity = 1;
this.expiryMode = 'hours';
this.expiresHours = 24;
const tomorrow = new Date();

View File

@ -15,8 +15,12 @@
</head>
<body class="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-start pt-8 pb-12 px-6 antialiased">
@php
$slot = $pickupCode->machine->slots->where('slot_no', $pickupCode->slot_no)->first();
$product = $slot?->product;
// 取貨碼可能綁「商品」(product_idslot_no 為 null貨道刷碼當下挑) 或綁「貨道」(舊版)。
// 優先用直接綁定的商品;否則回退用 slot_no 對應貨道的商品。
$slot = $pickupCode->slot_no
? $pickupCode->machine->slots->where('slot_no', $pickupCode->slot_no)->first()
: null;
$product = $pickupCode->product ?? $slot?->product;
@endphp
<div class="w-full max-w-sm animate-luxury-in">
@ -53,7 +57,7 @@
<div class="flex items-center justify-between">
<div class="text-center flex-1">
<span class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Slot') }}</span>
<span class="text-2xl font-black text-slate-800 dark:text-white font-display">{{ $pickupCode->slot_no }}</span>
<span class="text-2xl font-black text-slate-800 dark:text-white font-display">{{ $pickupCode->slot_no ?: '—' }}</span>
</div>
<div class="w-px h-8 bg-slate-100 dark:bg-slate-800"></div>
<div class="text-center flex-1">

View File

@ -167,12 +167,14 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
Route::post('/pickup-codes', [App\Http\Controllers\Admin\SalesController::class, 'storePickupCode'])->name('pickup-codes.store');
Route::patch('/pickup-codes/{pickupCode}', [App\Http\Controllers\Admin\SalesController::class, 'updatePickupCode'])->name('pickup-codes.update');
Route::delete('/pickup-codes/{pickupCode}', [App\Http\Controllers\Admin\SalesController::class, 'destroyPickupCode'])->name('pickup-codes.destroy');
Route::get('/pickup-codes/batch/{batchNo}/download', [App\Http\Controllers\Admin\SalesController::class, 'downloadPickupCodeBatch'])->name('pickup-codes.batch-download');
// 通行碼
Route::get('/pass-codes', [App\Http\Controllers\Admin\SalesController::class, 'passCodes'])->name('pass-codes');
Route::post('/pass-codes', [App\Http\Controllers\Admin\SalesController::class, 'storePassCode'])->name('pass-codes.store');
Route::patch('/pass-codes/{passCode}', [App\Http\Controllers\Admin\SalesController::class, 'updatePassCode'])->name('pass-codes.update');
Route::delete('/pass-codes/{passCode}', [App\Http\Controllers\Admin\SalesController::class, 'destroyPassCode'])->name('pass-codes.destroy');
Route::get('/pass-codes/batch/{batchNo}/download', [App\Http\Controllers\Admin\SalesController::class, 'downloadPassCodeBatch'])->name('pass-codes.batch-download');
Route::get('/orders', [App\Http\Controllers\Admin\SalesController::class, 'orders'])->name('orders');
Route::get('/promotions', [App\Http\Controllers\Admin\SalesController::class, 'promotions'])->name('promotions');