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

178 lines
8.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{--
多選下拉組件 (Multi-Select Dropdown)
Alpine.js 實作,不依賴 Preline HSSelect避免 tags 模式衝突。
使用方式:
<x-multi-select
name="warehouse_ids"
:options="$warehouses"
:selected="request('warehouse_ids', [])"
:placeholder="__('Filter by Warehouse')"
/>
Props
- name : 表單欄位名稱,會自動加上 [] (string)
- options : 選項列表,支援 Collection<{id, name}> associative array (iterable)
- selected : 已選值陣列 (array)
- placeholder : 未選擇時的提示文字 (string)
- id : 元素 ID (string, optional)
- hasSearch : 是否顯示搜尋框 (bool, default: true)
事件:
- 選項變更後會觸發外層 form submit 事件400ms debounce
- 如不需自動提交,加上 no-auto-submit 屬性
--}}
@props([
'name' => null,
'options' => [],
'selected' => [],
'placeholder' => null,
'id' => null,
'hasSearch' => true,
])
@php
$id = $id ?? $name ?? 'multi-select-' . uniqid();
$options = is_iterable($options) ? $options : [];
$placeholder = $placeholder ?: __('Select...');
$autoSubmit = !$attributes->has('no-auto-submit');
// 將選項統一為 [{id, name}] 格式
$normalizedOptions = collect($options)->map(function ($item, $key) {
if (is_object($item)) {
return ['id' => $item->id ?? $item->value ?? $key, 'name' => $item->name ?? $item->label ?? $item->title ?? $key];
}
return ['id' => $key, 'name' => $item];
})->values()->toArray();
// 確保 selected 是陣列
$selectedArray = is_array($selected) ? array_map('intval', $selected) : [];
@endphp
<div id="{{ $id }}"
class="relative {{ $attributes->get('class', '') }}"
x-data="{
open: false,
search: '',
selected: @js($selectedArray),
options: @js($normalizedOptions),
autoSubmit: @js($autoSubmit),
get filtered() {
if (!this.search) return this.options;
const s = this.search.toLowerCase();
return this.options.filter(o => o.name.toString().toLowerCase().includes(s));
},
isSelected(id) { return this.selected.includes(id); },
toggle(id) {
const i = this.selected.indexOf(id);
if (i === -1) { this.selected.push(id); } else { this.selected.splice(i, 1); }
this.onChanged();
},
remove(id) {
this.selected = this.selected.filter(v => v !== id);
this.onChanged();
},
selectAll() {
this.selected = this.options.map(o => o.id);
this.onChanged();
},
clear() {
this.selected = [];
this.onChanged();
},
onChanged() {
if (!this.autoSubmit) return;
this.$nextTick(() => {
window.dispatchEvent(new CustomEvent('multi-select-changed'));
});
},
getName(id) {
const o = this.options.find(o => o.id === id);
return o ? o.name : id;
}
}"
@click.outside="open = false"
@keydown.escape.window="open = false"
@multi-select-reset.window="clear()"
{{ $attributes->except(['class', 'options', 'selected', 'placeholder', 'id', 'name', 'hasSearch', 'no-auto-submit']) }}>
{{-- 隱藏 input 送出表單用 --}}
<template x-for="id in selected" :key="id">
<input type="hidden" name="{{ $name }}[]" :value="id">
</template>
{{-- Toggle 按鈕 --}}
<button type="button" @click="open = !open"
class="luxury-input w-full pr-10 cursor-pointer text-start flex flex-wrap items-center gap-1.5 relative min-h-[46px]">
{{-- 未選擇時的 placeholder --}}
<span x-show="selected.length === 0" class="text-slate-400 dark:text-slate-500 font-bold text-sm">
{{ $placeholder }}
</span>
{{-- 選擇後的 tags --}}
<template x-for="id in selected" :key="'tag-'+id">
<span class="inline-flex items-center gap-1 bg-cyan-500/10 dark:bg-cyan-500/20 text-cyan-700 dark:text-cyan-300 rounded-lg px-2.5 py-1 text-xs font-bold border border-cyan-500/20 dark:border-cyan-500/30">
<span x-text="getName(id)"></span>
<button type="button" @click.stop="remove(id)" class="hover:text-cyan-500 transition-colors">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</span>
</template>
{{-- 下拉箭頭 --}}
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<svg class="size-4 text-slate-400 transition-transform duration-300" :class="open && 'rotate-180'" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
</button>
{{-- 下拉選單 --}}
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute z-[150] mt-2 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)] overflow-hidden"
x-cloak>
{{-- 搜尋框 --}}
@if($hasSearch)
<div class="sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10">
<input type="text" x-model="search"
class="block w-full 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"
placeholder="{{ __('Search...') }}" @click.stop>
</div>
@endif
{{-- 全選 / 清除 --}}
<div class="flex items-center justify-between px-3 py-1.5 border-b border-slate-100 dark:border-slate-800">
<button type="button" @click.stop="selectAll()"
class="text-[11px] font-bold text-cyan-600 dark:text-cyan-400 hover:underline">{{ __('Select All') }}</button>
<button type="button" @click.stop="clear()"
class="text-[11px] font-bold text-slate-400 hover:text-rose-500 hover:underline transition-colors">{{ __('Clear') }}</button>
</div>
{{-- 選項列表 --}}
<div class="max-h-60 overflow-y-auto p-1.5">
<template x-for="o in filtered" :key="o.id">
<label @click.stop class="flex items-center gap-3 py-2.5 px-3 rounded-lg cursor-pointer hover:bg-slate-50 dark:hover:bg-cyan-500/10 transition-all duration-200 mb-0.5"
:class="isSelected(o.id) && 'bg-cyan-50 dark:bg-cyan-500/5'">
<input type="checkbox" :checked="isSelected(o.id)" @change="toggle(o.id)"
class="rounded border-slate-300 dark:border-slate-600 text-cyan-500 focus:ring-cyan-500 dark:bg-slate-800 cursor-pointer">
<span class="text-sm font-bold text-slate-700 dark:text-slate-300" x-text="o.name"></span>
<svg x-show="isSelected(o.id)" class="w-4 h-4 text-cyan-500 ml-auto shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
</label>
</template>
{{-- 無搜尋結果 --}}
<div x-show="filtered.length === 0" class="py-6 text-center text-sm text-slate-400 font-bold">
{{ __('No results found') }}
</div>
</div>
</div>
</div>