[FEAT] 優化廣告與機台管理介面及效能
1. [廣告管理] 修復編輯素材時刪除按鈕顯示邏輯並優化預覽功能。 2. [廣告管理] 修正請求回傳格式為 JSON,解決 AJAX 解析錯誤。 3. [機台管理] 實作 Alpine.js 無感頁籤切換(機台列表與效期管理)。 4. [機台管理] 移除冗餘返回按鈕,改為動態標題與頁籤重設邏輯。 5. [機台管理] 統一後端查詢,減少切換分頁時的延遲感。 6. [商品管理] 支援商品圖片 WebP 自動轉換,並調整上傳大小限制 (10MB)。 7. [UI] 修正多個管理模組的 JS 時序競爭與 Preline HSSelect 重置問題。
This commit is contained in:
@@ -6,11 +6,14 @@ use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MachineAdvertisement;
|
||||
use App\Models\System\Advertisement;
|
||||
use App\Models\System\Company;
|
||||
use App\Traits\ImageHandler;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class AdvertisementController extends AdminController
|
||||
{
|
||||
use ImageHandler;
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
@@ -48,13 +51,19 @@ class AdvertisementController extends AdminController
|
||||
'required',
|
||||
'file',
|
||||
'mimes:jpeg,png,jpg,gif,webp,mp4,mov,avi',
|
||||
$request->type === 'image' ? 'max:5120' : 'max:51200', // Image 5MB, Video 50MB
|
||||
$request->type === 'image' ? 'max:10240' : 'max:51200', // Image 10MB, Video 50MB
|
||||
],
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
]);
|
||||
|
||||
$user = auth()->user();
|
||||
$path = $request->file('file')->store('ads', 'public');
|
||||
$file = $request->file('file');
|
||||
|
||||
if ($request->type === 'image') {
|
||||
$path = $this->storeAsWebp($file, 'ads');
|
||||
} else {
|
||||
$path = $file->store('ads', 'public');
|
||||
}
|
||||
|
||||
if ($user->isSystemAdmin()) {
|
||||
$companyId = $request->filled('company_id') ? $request->company_id : null;
|
||||
@@ -71,6 +80,14 @@ class AdvertisementController extends AdminController
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Advertisement created successfully.'),
|
||||
'data' => $advertisement
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Advertisement created successfully.'));
|
||||
}
|
||||
|
||||
@@ -88,7 +105,7 @@ class AdvertisementController extends AdminController
|
||||
$rules['file'] = [
|
||||
'file',
|
||||
'mimes:jpeg,png,jpg,gif,webp,mp4,mov,avi',
|
||||
$request->type === 'image' ? 'max:5120' : 'max:51200',
|
||||
$request->type === 'image' ? 'max:10240' : 'max:51200',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -104,16 +121,32 @@ class AdvertisementController extends AdminController
|
||||
|
||||
if ($request->hasFile('file')) {
|
||||
// 刪除舊檔案
|
||||
// 處理 URL 可能包含 storage 或原始路徑的情況
|
||||
$oldPath = str_replace(Storage::disk('public')->url(''), '', $advertisement->url);
|
||||
// 去除開頭可能的斜線
|
||||
$oldPath = ltrim($oldPath, '/');
|
||||
Storage::disk('public')->delete($oldPath);
|
||||
|
||||
// 存入新檔案
|
||||
$path = $request->file('file')->store('ads', 'public');
|
||||
$file = $request->file('file');
|
||||
if ($request->type === 'image') {
|
||||
$path = $this->storeAsWebp($file, 'ads');
|
||||
} else {
|
||||
$path = $file->store('ads', 'public');
|
||||
}
|
||||
$data['url'] = Storage::disk('public')->url($path);
|
||||
}
|
||||
|
||||
$advertisement->update($data);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Advertisement updated successfully.'),
|
||||
'data' => $advertisement
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Advertisement updated successfully.'));
|
||||
}
|
||||
|
||||
@@ -129,6 +162,13 @@ class AdvertisementController extends AdminController
|
||||
Storage::disk('public')->delete($path);
|
||||
|
||||
$advertisement->delete();
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Advertisement deleted successfully.')
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Advertisement deleted successfully.'));
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,12 @@ class MachinePhotoController extends Controller
|
||||
'files' => $request->allFiles()
|
||||
]);
|
||||
|
||||
$request->validate([
|
||||
'machine_image_0' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
|
||||
'machine_image_1' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
|
||||
'machine_image_2' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
|
||||
]);
|
||||
|
||||
try {
|
||||
$images = $machine->images ?? [];
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ class MachineSettingController extends AdminController
|
||||
'machine_model_id' => 'required|exists:machine_models,id',
|
||||
'payment_config_id' => 'nullable|exists:payment_configs,id',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'images.*' => 'image|mimes:jpeg,png,jpg,gif|max:2048',
|
||||
'images.*' => 'image|mimes:jpeg,png,jpg,gif,webp|max:10240', // Increase to 10MB
|
||||
]);
|
||||
|
||||
$imagePaths = [];
|
||||
@@ -163,6 +163,12 @@ class MachineSettingController extends AdminController
|
||||
'machine_model_id' => 'required|exists:machine_models,id',
|
||||
'payment_config_id' => 'nullable|exists:payment_configs,id',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'image_0' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
|
||||
'image_1' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
|
||||
'image_2' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
|
||||
'remove_image_0' => 'nullable|boolean',
|
||||
'remove_image_1' => 'nullable|boolean',
|
||||
'remove_image_2' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
// 僅限系統管理員可修改公司
|
||||
@@ -178,35 +184,48 @@ class MachineSettingController extends AdminController
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$machine->update(array_merge($validated, [
|
||||
// 排除虛擬欄位 (圖片上傳、移除標記),這些欄位不在資料表內
|
||||
$dataToUpdate = \Illuminate\Support\Arr::except($validated, [
|
||||
'image_0', 'image_1', 'image_2',
|
||||
'remove_image_0', 'remove_image_1', 'remove_image_2'
|
||||
]);
|
||||
|
||||
$machine->update(array_merge($dataToUpdate, [
|
||||
'updater_id' => auth()->id(),
|
||||
]));
|
||||
|
||||
// 處理圖片更新 (支援 3 個獨立槽位)
|
||||
if ($request->hasFile('images')) {
|
||||
// 處理圖片更新 (支援 3 個獨立槽位: image_0, image_1, image_2)
|
||||
$currentImages = $machine->images ?? [];
|
||||
$newImages = $request->file('images');
|
||||
$updated = false;
|
||||
|
||||
foreach ($newImages as $index => $file) {
|
||||
// 限制 3 個槽位 (0, 1, 2)
|
||||
if ($index < 0 || $index > 2) continue;
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$inputName = "image_$i";
|
||||
$removeName = "remove_image_$i";
|
||||
|
||||
// 刪除該槽位的舊圖
|
||||
if (isset($currentImages[$index]) && !empty($currentImages[$index])) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($currentImages[$index]);
|
||||
// 如果有新圖片上傳
|
||||
if ($request->hasFile($inputName)) {
|
||||
// 刪除舊圖
|
||||
if (isset($currentImages[$i]) && !empty($currentImages[$i])) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($currentImages[$i]);
|
||||
}
|
||||
|
||||
// 處理並儲存新圖
|
||||
$currentImages[$index] = $this->storeAsWebp($file, 'machines');
|
||||
// 儲存新圖
|
||||
$currentImages[$i] = $this->storeAsWebp($request->file($inputName), 'machines');
|
||||
$updated = true;
|
||||
}
|
||||
// 否則,如果有刪除標記
|
||||
elseif ($request->input($removeName) === '1') {
|
||||
if (isset($currentImages[$i]) && !empty($currentImages[$i])) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($currentImages[$i]);
|
||||
unset($currentImages[$i]);
|
||||
$updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($updated) {
|
||||
ksort($currentImages);
|
||||
$machine->update(['images' => array_values($currentImages)]);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('admin.basic-settings.machines.index')
|
||||
->with('success', __('Machine settings updated successfully.'));
|
||||
|
||||
@@ -8,9 +8,6 @@ use Illuminate\View\View;
|
||||
|
||||
class MachineController extends AdminController
|
||||
{
|
||||
/**
|
||||
* 顯示所有機台列表或效期管理
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$tab = $request->input('tab', 'list');
|
||||
@@ -26,17 +23,7 @@ class MachineController extends AdminController
|
||||
});
|
||||
}
|
||||
|
||||
if ($tab === 'list') {
|
||||
$machines = $query->when($request->status, function ($query, $status) {
|
||||
return $query->where('status', $status);
|
||||
})
|
||||
->orderBy("last_heartbeat_at", "desc")->orderBy("id", "desc")
|
||||
->paginate($per_page)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.machines.index', compact('machines', 'tab'));
|
||||
} else {
|
||||
// 效期管理模式:獲取機台及其貨道統計
|
||||
// 統一預加載貨道統計資料 (無論在哪一個頁籤)
|
||||
$machines = $query->withCount(['slots as total_slots'])
|
||||
->withCount(['slots as expired_count' => function ($q) {
|
||||
$q->where('expiry_date', '<', now()->toDateString());
|
||||
@@ -47,13 +34,17 @@ class MachineController extends AdminController
|
||||
->withCount(['slots as warning_count' => function ($q) {
|
||||
$q->whereBetween('expiry_date', [now()->toDateString(), now()->addDays(7)->toDateString()]);
|
||||
}])
|
||||
->orderBy("last_heartbeat_at", "desc")->orderBy("id", "desc")
|
||||
// 只有在機台列表且有狀態篩選時才套用狀態過濾
|
||||
->when($request->status && $tab === 'list', function ($q, $status) {
|
||||
return $q->where('status', $status);
|
||||
})
|
||||
->orderBy("last_heartbeat_at", "desc")
|
||||
->orderBy("id", "desc")
|
||||
->paginate($per_page)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.machines.index', compact('machines', 'tab'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示特定機台的日誌與詳細資訊
|
||||
|
||||
@@ -126,7 +126,7 @@ class ProductController extends Controller
|
||||
'metadata' => 'nullable|array',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',
|
||||
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240', // Increase to 10MB
|
||||
]);
|
||||
|
||||
try {
|
||||
@@ -216,7 +216,7 @@ class ProductController extends Controller
|
||||
'member_price' => 'required|numeric|min:0',
|
||||
'metadata' => 'nullable|array',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',
|
||||
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240', // Increase to 10MB
|
||||
'remove_image' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
|
||||
@@ -378,12 +378,14 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
|
||||
adFormMode: 'add',
|
||||
fileName: '',
|
||||
mediaPreview: null,
|
||||
adForm: {
|
||||
id: null,
|
||||
name: '',
|
||||
type: 'image',
|
||||
duration: 15,
|
||||
is_active: true
|
||||
is_active: true,
|
||||
url: ''
|
||||
},
|
||||
|
||||
// Assign Modal
|
||||
@@ -522,8 +524,83 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
|
||||
handleFileChange(e) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
if (!file) return;
|
||||
|
||||
// Validation
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const maxSize = isVideo ? 50 * 1024 * 1024 : 10 * 1024 * 1024; // 50MB for video, 10MB for image
|
||||
|
||||
if (file.size > maxSize) {
|
||||
window.showToast?.(`{{ __("File is too large") }} (${isVideo ? '50MB' : '10MB'} MAX)`, 'error');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this.fileName = file.name;
|
||||
|
||||
// Set form type based on file
|
||||
if (isVideo) this.adForm.type = 'video';
|
||||
else if (isImage) this.adForm.type = 'image';
|
||||
|
||||
// Local Preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
this.mediaPreview = event.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Update Select UI
|
||||
this.$nextTick(() => {
|
||||
window.HSSelect.getInstance('#ad_type_select')?.setValue(this.adForm.type);
|
||||
});
|
||||
},
|
||||
|
||||
removeMedia() {
|
||||
this.fileName = '';
|
||||
this.mediaPreview = null;
|
||||
if (this.$refs.fileInput) this.$refs.fileInput.value = '';
|
||||
// If editing, we still keep the original url in adForm.url but the UI shows "empty"
|
||||
},
|
||||
|
||||
async submitAdForm() {
|
||||
const form = this.$refs.adFormEl;
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Basic validation
|
||||
if (!this.adForm.name) {
|
||||
window.showToast?.('{{ __("Please enter a name") }}', 'error');
|
||||
return;
|
||||
}
|
||||
if (this.adFormMode === 'add' && !this.fileName) {
|
||||
window.showToast?.('{{ __("Please select a file") }}', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = this.adFormMode === 'add' ? this.urls.store : this.urls.update.replace(':id', this.adForm.id);
|
||||
if (this.adFormMode === 'edit') formData.append('_method', 'PUT');
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
window.showToast?.(result.message, 'success');
|
||||
this.isAdModalOpen = false;
|
||||
location.reload(); // Reload to refresh the list
|
||||
} else {
|
||||
window.showToast?.(result.message || 'Error', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to submit ad', e);
|
||||
window.showToast?.('System Error', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -533,10 +610,18 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify(this.assignForm)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Server error response:', errorText);
|
||||
throw new Error(`Server responded with ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.isAssignModalOpen = false;
|
||||
@@ -600,13 +685,17 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
|
||||
openAddModal() {
|
||||
this.adFormMode = 'add';
|
||||
this.adForm = { id: null, company_id: '', name: '', type: 'image', duration: 15, is_active: true };
|
||||
this.adForm = { id: null, company_id: '', name: '', type: 'image', duration: 15, is_active: true, url: '' };
|
||||
this.fileName = '';
|
||||
this.mediaPreview = null;
|
||||
this.isAdModalOpen = true;
|
||||
},
|
||||
|
||||
openEditModal(ad) {
|
||||
this.adFormMode = 'edit';
|
||||
this.adForm = { ...ad };
|
||||
this.fileName = '';
|
||||
this.mediaPreview = ad.url; // Use existing URL as preview
|
||||
this.isAdModalOpen = true;
|
||||
},
|
||||
|
||||
@@ -678,6 +767,11 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
|
||||
wrapper.appendChild(selectEl);
|
||||
|
||||
// Set the initial value after appending but before autoInit
|
||||
if (this.assignForm.advertisement_id) {
|
||||
selectEl.value = this.assignForm.advertisement_id;
|
||||
}
|
||||
|
||||
selectEl.addEventListener('change', (e) => {
|
||||
this.assignForm.advertisement_id = e.target.value;
|
||||
});
|
||||
@@ -685,6 +779,13 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
this.$nextTick(() => {
|
||||
if (window.HSStaticMethods && window.HSStaticMethods.autoInit) {
|
||||
window.HSStaticMethods.autoInit(['select']);
|
||||
|
||||
// If we have a value, ensure the Preline instance reflects it
|
||||
if (this.assignForm.advertisement_id) {
|
||||
setTimeout(() => {
|
||||
window.HSSelect.getInstance(selectEl)?.setValue(this.assignForm.advertisement_id);
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -8,11 +8,10 @@
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0">
|
||||
|
||||
<div class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm"></div>
|
||||
<div class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm" @click="isAdModalOpen = false"></div>
|
||||
|
||||
<div class="flex items-center justify-center min-h-screen p-4 sm:p-0">
|
||||
<div class="relative inline-block px-8 py-10 text-left align-bottom transition-all transform luxury-card rounded-3xl dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-xl sm:w-full overflow-visible animate-luxury-in"
|
||||
@click.away="isAdModalOpen = false">
|
||||
<div class="relative inline-block px-8 py-10 text-left align-bottom transition-all transform luxury-card rounded-3xl dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-xl sm:w-full overflow-visible animate-luxury-in">
|
||||
|
||||
<!-- Modal Header -->
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
@@ -25,7 +24,8 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form :action="adFormMode === 'add' ? urls.store : urls.update.replace(':id', adForm.id)"
|
||||
<form x-ref="adFormEl"
|
||||
:action="adFormMode === 'add' ? urls.store : urls.update.replace(':id', adForm.id)"
|
||||
method="POST"
|
||||
enctype="multipart/form-data"
|
||||
@submit.prevent="submitAdForm"
|
||||
@@ -73,7 +73,7 @@
|
||||
:has-search="false"
|
||||
:selected="null"
|
||||
:options="['image' => __('image'), 'video' => __('video')]"
|
||||
@change="adForm.type = $event.target.value; fileName = ''; document.querySelector('input[name=file]').value = ''"
|
||||
@change="adForm.type = $event.target.value; removeMedia();"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Upload -->
|
||||
<!-- File Upload (Luxury UI Pattern) -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
|
||||
<span x-text="adForm.type === 'image' ? '{{ __("Upload Image") }}' : '{{ __("Upload Video") }}'"></span>
|
||||
@@ -102,25 +102,60 @@
|
||||
<span class="text-rose-500 ml-0.5">*</span>
|
||||
</template>
|
||||
</label>
|
||||
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-0 bg-gradient-to-tr from-cyan-500/5 to-indigo-500/5 rounded-2xl border-2 border-dashed border-slate-200 dark:border-white/10 group-hover:border-cyan-500/30 transition-all"></div>
|
||||
<label class="relative flex flex-col items-center justify-center p-10 cursor-pointer">
|
||||
<svg class="size-8 text-slate-300 group-hover:text-cyan-500 transition-colors mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
|
||||
<span class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest group-hover:text-cyan-500 transition-colors">{{ __('Click to upload') }}</span>
|
||||
<span class="text-[10px] font-bold text-slate-400 mt-1 uppercase" x-text="adForm.type === 'image' ? 'JPG, PNG, WEBP (' + '{{ __('Max 5MB') }}' + ')' : 'MP4, MOV (' + '{{ __('Max 50MB') }}' + ')'"></span>
|
||||
<input type="file" name="file"
|
||||
<template x-if="!mediaPreview">
|
||||
<div @click="$refs.fileInput.click()" class="aspect-video rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 flex flex-col items-center justify-center gap-4 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800/80 hover:border-cyan-500/50 transition-all duration-300 group">
|
||||
<div class="p-4 rounded-2xl bg-white dark:bg-slate-800 shadow-sm border border-slate-100 dark:border-slate-700 group-hover:scale-110 group-hover:text-cyan-500 transition-all duration-300">
|
||||
<svg class="size-8 text-slate-400 dark:text-slate-500 group-hover:text-cyan-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
|
||||
</div>
|
||||
<div class="text-center px-4">
|
||||
<p class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-tighter">{{ __('Click to upload') }}</p>
|
||||
<p class="text-[10px] font-bold text-slate-400 mt-1 uppercase" x-text="adForm.type === 'image' ? 'JPG, PNG, WEBP (Max 10MB)' : 'MP4, MOV (Max 50MB)'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="mediaPreview">
|
||||
<div class="relative aspect-video rounded-3xl overflow-hidden border border-slate-200 dark:border-slate-800 shadow-xl group/media">
|
||||
<template x-if="adForm.type === 'image'">
|
||||
<img :src="mediaPreview" class="w-full h-full object-cover">
|
||||
</template>
|
||||
<template x-if="adForm.type === 'video'">
|
||||
<div class="w-full h-full bg-slate-900 flex items-center justify-center">
|
||||
<video :src="mediaPreview" class="max-w-full max-h-full object-contain" muted></video>
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div class="p-4 rounded-full bg-block/20 backdrop-blur-sm border border-white/10">
|
||||
<svg class="size-8 text-white/50" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5.14v14l11-7-11-7z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="absolute inset-0 bg-slate-950/40 opacity-0 group-hover/media:opacity-100 transition-opacity duration-300 flex items-center justify-center gap-3">
|
||||
<button type="button" @click="$refs.fileInput.click()" class="p-3 rounded-2xl bg-white text-slate-800 hover:bg-cyan-500 hover:text-white transition-all duration-300 shadow-lg">
|
||||
<svg class="size-5" 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 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" /></svg>
|
||||
</button>
|
||||
<button type="button" @click="openPreview(adFormMode === 'edit' ? (fileName ? {url: mediaPreview, type: adForm.type, name: fileName} : adForm) : {url: mediaPreview, type: adForm.type, name: fileName})" class="p-3 rounded-2xl bg-white text-slate-800 hover:bg-emerald-500 hover:text-white transition-all duration-300 shadow-lg">
|
||||
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
</button>
|
||||
<button type="button" @click="removeMedia" class="p-3 rounded-2xl bg-white text-slate-800 hover:bg-rose-500 hover:text-white transition-all duration-300 shadow-lg" x-show="mediaPreview">
|
||||
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<input type="file" name="file" x-ref="fileInput"
|
||||
:accept="adForm.type === 'image' ? 'image/jpeg,image/png,image/gif,image/webp' : 'video/mp4,video/quicktime,video/x-msvideo'"
|
||||
class="hidden" @change="handleFileChange">
|
||||
</label>
|
||||
</div>
|
||||
<!-- Preview Filename -->
|
||||
<p class="text-[10px] font-bold text-cyan-600 dark:text-cyan-400 italic px-2" x-show="fileName" x-text="fileName"></p>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="flex items-center gap-3 px-2">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" name="is_active" x-model="adForm.is_active" value="1" class="sr-only peer">
|
||||
<input type="checkbox" name="is_active" x-model="adForm.is_active" :value="adForm.is_active ? '1' : '0'" class="sr-only peer">
|
||||
<div class="w-11 h-6 bg-slate-200 dark:bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-cyan-500"></div>
|
||||
</label>
|
||||
<span class="text-sm font-black text-slate-700 dark:text-slate-200 tracking-tight">{{ __('Active Status') }}</span>
|
||||
@@ -132,63 +167,11 @@
|
||||
{{ __('Cancel') }}
|
||||
</button>
|
||||
<button type="submit" class="btn-luxury-primary px-10 py-3">
|
||||
{{ __('Save Material') }}
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /></svg>
|
||||
<span>{{ __('Save Material') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function handleFileChange(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const type = this.adForm.type;
|
||||
const maxSize = type === 'image' ? 5 * 1024 * 1024 : 50 * 1024 * 1024;
|
||||
|
||||
// Check size
|
||||
if (file.size > maxSize) {
|
||||
window.showToast?.('error', '{{ __("File is too large") }} (Max: ' + (maxSize / 1024 / 1024) + 'MB)');
|
||||
e.target.value = '';
|
||||
this.fileName = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check extension/mime
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
|
||||
if (type === 'image' && !isImage) {
|
||||
window.showToast?.('error', '{{ __("Please upload an image file") }}');
|
||||
e.target.value = '';
|
||||
this.fileName = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'video' && !isVideo) {
|
||||
window.showToast?.('error', '{{ __("Please upload a video file") }}');
|
||||
e.target.value = '';
|
||||
this.fileName = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this.fileName = file.name;
|
||||
}
|
||||
|
||||
function submitAdForm(e) {
|
||||
// Final check before submission
|
||||
if (!this.adForm.name || !this.adForm.type || !this.adForm.duration) {
|
||||
window.showToast?.('error', '{{ __("Please fill in all required fields") }}');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.adFormMode === 'add' && !this.fileName) {
|
||||
window.showToast?.('error', '{{ __("Please upload a material file") }}');
|
||||
return;
|
||||
}
|
||||
|
||||
e.target.submit();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -8,11 +8,10 @@
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0">
|
||||
|
||||
<div class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm"></div>
|
||||
<div class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm" @click="isAssignModalOpen = false"></div>
|
||||
|
||||
<div class="flex items-center justify-center min-h-screen p-4 sm:p-0">
|
||||
<div class="relative inline-block px-8 py-10 text-left align-bottom transition-all transform luxury-card rounded-3xl dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full overflow-visible animate-luxury-in"
|
||||
@click.away="isAssignModalOpen = false">
|
||||
<div class="relative inline-block px-8 py-10 text-left align-bottom transition-all transform luxury-card rounded-3xl dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full overflow-visible animate-luxury-in">
|
||||
|
||||
<!-- Modal Header -->
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
|
||||
@@ -34,7 +34,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="edit-form" action="{{ route('admin.basic-settings.machines.update', $machine) }}" method="POST" enctype="multipart/form-data" class="space-y-6">
|
||||
<form id="edit-form" x-data="{
|
||||
imagePreviews: [
|
||||
{{ isset($machine->images[0]) ? "'" . Storage::disk('public')->url($machine->images[0]) . "'" : 'null' }},
|
||||
{{ isset($machine->images[1]) ? "'" . Storage::disk('public')->url($machine->images[1]) . "'" : 'null' }},
|
||||
{{ isset($machine->images[2]) ? "'" . Storage::disk('public')->url($machine->images[2]) . "'" : 'null' }}
|
||||
],
|
||||
removeImages: [false, false, false],
|
||||
handleImageUpload(event, index) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.imagePreviews[index] = e.target.result;
|
||||
this.removeImages[index] = false;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
removeImage(index) {
|
||||
this.imagePreviews[index] = null;
|
||||
this.removeImages[index] = true;
|
||||
if (this.$refs['imageInput_' + index]) this.$refs['imageInput_' + index].value = '';
|
||||
}
|
||||
}" action="{{ route('admin.basic-settings.machines.update', $machine) }}" method="POST" enctype="multipart/form-data" class="space-y-6">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
@@ -58,7 +80,9 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="flex flex-col lg:flex-row gap-8 items-start">
|
||||
<!-- Main Content Area -->
|
||||
<div class="flex-1 space-y-8 order-2 lg:order-1 capitalize">
|
||||
<!-- Left: Basic info & Hardware -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Basic Information -->
|
||||
@@ -196,8 +220,71 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: System & Payment -->
|
||||
<!-- Right Column: Images & Primary System Settings -->
|
||||
<div class="w-full lg:w-96 space-y-8 order-1 lg:order-2 lg:sticky top-24">
|
||||
<!-- Machine Images -->
|
||||
<div class="luxury-card rounded-[2.5rem] p-8 animate-luxury-in">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="w-10 h-10 rounded-xl bg-cyan-500/10 flex items-center justify-center text-cyan-500">
|
||||
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-black text-slate-800 dark:text-white tracking-tight">{{ __('Machine Photos') }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
@for($i = 0; $i < 3; $i++)
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between px-1 text-xs font-black text-slate-400 uppercase tracking-widest">
|
||||
<span>{{ __('Photo Slot') }} {{ $i + 1 }}</span>
|
||||
<template x-if="imagePreviews[{{ $i }}]">
|
||||
<span class="text-emerald-500 flex items-center gap-1 lowercase tracking-normal font-bold">
|
||||
<svg class="size-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="m4.5 12.75 6 6 9-13.5"/></svg>
|
||||
{{ __('set') }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="relative group">
|
||||
<input type="hidden" name="remove_image_{{ $i }}" :value="removeImages[{{ $i }}] ? '1' : '0'">
|
||||
|
||||
<template x-if="!imagePreviews[{{ $i }}]">
|
||||
<div @click="$refs.imageInput_{{ $i }}.click()" class="aspect-video rounded-2xl border-2 border-dashed border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 flex flex-col items-center justify-center gap-3 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800/80 hover:border-cyan-500/50 transition-all duration-300 group/upload">
|
||||
<div class="p-2.5 rounded-xl bg-white dark:bg-slate-800 shadow-sm border border-slate-100 dark:border-slate-700 group-hover/upload:scale-110 group-hover/upload:text-cyan-500 transition-all duration-300 text-slate-400">
|
||||
<svg class="size-5" 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>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-tighter">{{ __('Click to upload') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="imagePreviews[{{ $i }}]">
|
||||
<div class="relative aspect-video rounded-2xl overflow-hidden border border-slate-200 dark:border-slate-800 shadow-lg group/image">
|
||||
<img :src="imagePreviews[{{ $i }}]" class="w-full h-full object-cover">
|
||||
<div class="absolute inset-0 bg-slate-950/40 opacity-0 group-hover/image:opacity-100 transition-opacity duration-300 flex items-center justify-center gap-3">
|
||||
<button type="button" @click="$refs.imageInput_{{ $i }}.click()" class="p-2.5 rounded-xl bg-white text-slate-800 hover:bg-cyan-500 hover:text-white transition-all duration-300 shadow-lg">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" /></svg>
|
||||
</button>
|
||||
<button type="button" @click="removeImage({{ $i }})" class="p-2.5 rounded-xl bg-white text-slate-800 hover:bg-rose-500 hover:text-white transition-all duration-300 shadow-lg">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<input type="file" name="image_{{ $i }}" x-ref="imageInput_{{ $i }}" class="hidden" accept="image/*" @change="handleImageUpload($event, {{ $i }})">
|
||||
</div>
|
||||
</div>
|
||||
@endfor
|
||||
|
||||
<div class="p-4 rounded-2xl bg-slate-50 dark:bg-slate-900/50 border border-slate-100 dark:border-white/5">
|
||||
<p class="text-[10px] font-bold text-slate-400 leading-relaxed uppercase tracking-widest text-center">
|
||||
{{ __('PNG, JPG, WEBP up to 10MB') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative z-20" style="animation-delay: 200ms">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="w-10 h-10 rounded-xl bg-emerald-500/10 flex items-center justify-center text-emerald-500">
|
||||
|
||||
@@ -707,6 +707,9 @@
|
||||
<p
|
||||
class="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest">
|
||||
{{ __('Click to upload') }}</p>
|
||||
<p class="text-[10px] font-bold text-slate-400 dark:text-slate-500 mt-1">
|
||||
{{ __('PNG, JPG, WEBP up to 10MB') }} ({{ __('Max 3') }})
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="selectedFileCount > 0">
|
||||
@@ -940,8 +943,7 @@
|
||||
</div>
|
||||
<p
|
||||
class="text-xs font-bold text-amber-700 dark:text-amber-300 leading-relaxed text-left flex-1">
|
||||
{{ __('Optimized for display. Supported formats: JPG, PNG, WebP.')
|
||||
}}
|
||||
{{ __('Optimized for display. Supported formats: JPG, PNG, WebP (Max 10MB).') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,18 +135,9 @@ window.machineApp = function(initialTab) {
|
||||
<!-- Top Header & Actions -->
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<template x-if="tab === 'expiry' && viewMode === 'cabinet'">
|
||||
<button @click="viewMode = 'fleet'; selectedMachine = null"
|
||||
class="p-2 rounded-xl bg-white dark:bg-slate-800 text-slate-500 hover:bg-slate-50 dark:hover:bg-slate-700 transition-all border border-slate-200 dark:border-slate-700 shadow-sm">
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
<div>
|
||||
<h1
|
||||
<h1 x-text="tab === 'list' ? '{{ __('Machine List') }}' : '{{ __('Expiry Management') }}'"
|
||||
class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display transition-all duration-300">
|
||||
{{ __('Machine List') }}
|
||||
</h1>
|
||||
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">
|
||||
{{ __('Manage your machine fleet and operational data') }}
|
||||
@@ -158,19 +149,20 @@ window.machineApp = function(initialTab) {
|
||||
<!-- Tabs Switcher (Standard Position) -->
|
||||
<div
|
||||
class="flex items-center gap-1 p-1.5 bg-slate-100 dark:bg-slate-900/50 rounded-2xl w-fit border border-slate-200/50 dark:border-slate-800/50">
|
||||
<a href="{{ route('admin.machines.index', ['tab' => 'list']) }}"
|
||||
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all {{ $tab === 'list' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200' }}">
|
||||
<button @click="tab = 'list'; viewMode = 'fleet'; selectedMachine = null; window.history.replaceState(null, '', '?tab=list' + (new URLSearchParams(window.location.search).get('search') ? '&search=' + new URLSearchParams(window.location.search).get('search') : ''))"
|
||||
:class="tab === 'list' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200'"
|
||||
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all">
|
||||
{{ __('Machine List') }}
|
||||
</a>
|
||||
<a href="{{ route('admin.machines.index', ['tab' => 'expiry']) }}"
|
||||
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all {{ $tab === 'expiry' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200' }}">
|
||||
</button>
|
||||
<button @click="tab = 'expiry'; viewMode = 'fleet'; selectedMachine = null; window.history.replaceState(null, '', '?tab=expiry' + (new URLSearchParams(window.location.search).get('search') ? '&search=' + new URLSearchParams(window.location.search).get('search') : ''))"
|
||||
:class="tab === 'expiry' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200'"
|
||||
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all">
|
||||
{{ __('Expiry Management') }}
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if($tab === 'list')
|
||||
<!-- Main Card (Machine List Tab) -->
|
||||
<div class="luxury-card rounded-3xl p-8 animate-luxury-in overflow-hidden mt-6">
|
||||
<div x-show="tab === 'list'" class="luxury-card rounded-3xl p-8 animate-luxury-in overflow-hidden mt-6">
|
||||
<!-- Filters Area -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<form method="GET" action="{{ route('admin.machines.index') }}" class="relative group">
|
||||
@@ -340,7 +332,8 @@ window.machineApp = function(initialTab) {
|
||||
{{ $machines->appends(request()->query())->links('vendor.pagination.luxury') }}
|
||||
</div>
|
||||
</div>
|
||||
@elseif($tab === 'expiry')
|
||||
|
||||
<div x-show="tab === 'expiry'">
|
||||
<!-- Expiry Management Tab Content -->
|
||||
<div class="animate-luxury-in mt-6">
|
||||
<!-- viewMode: fleet (機台列表概覽) -->
|
||||
@@ -575,7 +568,7 @@ window.machineApp = function(initialTab) {
|
||||
{{ $machines->appends(request()->query())->links('vendor.pagination.luxury') }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Offcanvas Log Panel -->
|
||||
<div x-show="showLogPanel" class="fixed inset-0 z-[100] overflow-hidden" style="display: none;"
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-tighter">{{ __('Click to upload') }}</p>
|
||||
<p class="text-[10px] font-bold text-slate-400 dark:text-slate-500 mt-1">{{ __('PNG, JPG up to 2MB') }}</p>
|
||||
<p class="text-[10px] font-bold text-slate-400 dark:text-slate-500 mt-1">{{ __('PNG, JPG, WEBP up to 10MB') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-tighter">{{ __('Click to upload') }}</p>
|
||||
<p class="text-[10px] font-bold text-slate-400 dark:text-slate-500 mt-1">{{ __('PNG, JPG up to 2MB') }}</p>
|
||||
<p class="text-[10px] font-bold text-slate-400 dark:text-slate-500 mt-1">{{ __('PNG, JPG, WEBP up to 10MB') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -110,9 +110,9 @@ $roleSelectConfig = [
|
||||
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
||||
@forelse($products as $product)
|
||||
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
||||
<td class="px-6 py-6">
|
||||
<td class="px-6 py-6 cursor-pointer group/info" @click="viewProductDetail(@js($product))" title="{{ __('View Details') }}">
|
||||
<div class="flex items-center gap-x-4">
|
||||
<div class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500 shadow-sm group-hover:shadow-cyan-500/50 transition-all duration-300 overflow-hidden">
|
||||
<div class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover/info:bg-cyan-500 group-hover/info:text-white group-hover/info:border-cyan-500 shadow-sm group-hover/info:shadow-cyan-500/50 transition-all duration-300 overflow-hidden">
|
||||
@if($product->image_url)
|
||||
<img src="{{ $product->image_url }}" class="w-full h-full object-cover">
|
||||
@else
|
||||
@@ -120,12 +120,12 @@ $roleSelectConfig = [
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">{{ $product->localized_name }}</span>
|
||||
<span class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover/info:text-cyan-600 dark:group-hover/info:text-cyan-400 transition-colors">{{ $product->localized_name }}</span>
|
||||
<div class="flex flex-wrap items-center gap-1.5 mt-1">
|
||||
@php
|
||||
$catName = $product->category->localized_name ?? __('Uncategorized');
|
||||
@endphp
|
||||
<span class="text-[10px] font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest bg-slate-100 dark:bg-slate-800 px-1.5 py-0.5 rounded transition-colors group-hover:text-slate-600 dark:group-hover:text-slate-300">{{ $catName }}</span>
|
||||
<span class="text-[10px] font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest bg-slate-100 dark:bg-slate-800 px-1.5 py-0.5 rounded transition-colors group-hover/info:text-slate-600 dark:group-hover/info:text-slate-300">{{ $catName }}</span>
|
||||
@if(($companySettings['enable_material_code'] ?? false) && isset($product->metadata['material_code']))
|
||||
<span class="text-[10px] font-bold text-emerald-500/80 uppercase tracking-widest bg-emerald-500/10 px-1.5 py-0.5 rounded border border-emerald-500/20">#{{ $product->metadata['material_code'] }}</span>
|
||||
@endif
|
||||
|
||||
Reference in New Issue
Block a user