star-cloud/resources/views/admin/warehouses/replenishments.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

428 lines
25 KiB
PHP

@extends('layouts.admin')
@section('content')
<script>
function replenishmentManager() {
return {
showReplenishmentModal: false,
showAutoModal: false,
showCancelModal: false,
showAssignModal: false,
fromId: '',
availableProducts: [],
allProducts: @json($products ?? []),
isLoadingProducts: false,
items: [{ product_id: '', slot_no: '', quantity: 1, current_stock: 0, max_stock: 0 }],
loading: false,
cancelOrderId: null,
assignOrderId: null,
assignUserId: '',
// Auto replenishment
autoWarehouseId: '',
autoMachineId: '',
autoNote: '',
autoSlots: [],
autoHasWarning: false,
autoInsufficient: {},
autoWarehouseStocks: {},
autoPreviewLoaded: false,
autoLoading: false,
// Details Panel
showOrderDetails: false,
detailsLoading: false,
activeOrder: null,
activeItems: [],
init() {
// 偵測 URL 參數自動開啟一鍵補貨 Modal
const params = new URLSearchParams(window.location.search);
const autoMachineId = params.get('auto_machine_id');
if (autoMachineId) {
this.$nextTick(() => {
this.autoMachineId = autoMachineId;
this.showAutoModal = true;
this.autoPreviewLoaded = false;
this.autoSlots = [];
this.autoNote = '';
// 同步 HSSelect UI
this.$nextTick(() => {
const select = document.querySelector('[name="auto_machine_id"]');
if (select && window.HSSelect) {
const inst = window.HSSelect.getInstance(select);
if (inst) inst.setValue(autoMachineId);
}
});
});
// 清除 URL 參數避免重複觸發
const url = new URL(window.location);
url.searchParams.delete('auto_machine_id');
window.history.replaceState({}, '', url);
}
},
async openOrderDetails(id) {
this.showOrderDetails = true;
this.detailsLoading = true;
this.activeOrder = null;
this.activeItems = [];
try {
const res = await fetch(`{{ url('/admin/warehouses/replenishments') }}/${id}/details`, {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }
});
const data = await res.json();
if (data.success) { this.activeOrder = data.order; this.activeItems = data.items; }
} catch (e) { console.error(e); window.showToast?.('{{ __("Failed to load details") }}', 'error'); }
finally { this.detailsLoading = false; }
},
openCreateModal() {
this.items = [{ product_id: '', slot_no: '', quantity: 1, current_stock: 0, max_stock: 0 }];
this.showReplenishmentModal = true;
this.$nextTick(() => { if (window.HSStaticMethods) window.HSStaticMethods.autoInit('select'); });
},
addItem() {
this.items.push({ product_id: '', slot_no: '', quantity: 1, current_stock: 0, max_stock: 0 });
this.$nextTick(() => this.updateProductSelects());
},
removeItem(i) { if (this.items.length > 1) this.items.splice(i, 1); },
async fetchStock() {
this.availableProducts = [];
if (!this.fromId) { this.$nextTick(() => this.updateProductSelects()); return; }
this.isLoadingProducts = true;
try {
const res = await fetch('{{ route("admin.warehouses.ajax.stock") }}?warehouse_id=' + this.fromId);
const json = await res.json();
if (json.success) { this.availableProducts = json.data; this.$nextTick(() => this.updateProductSelects()); }
} catch (e) { console.error(e); }
finally { this.isLoadingProducts = false; }
},
updateProductSelects() {
this.items.forEach((item, index) => {
const wrapper = document.getElementById(`product-select-wrapper-${index}`);
if (!wrapper) return;
const oldSelect = wrapper.querySelector('select');
if (oldSelect && window.HSSelect?.getInstance(oldSelect)) {
try { window.HSSelect.getInstance(oldSelect).destroy(); } catch (e) {}
}
wrapper.innerHTML = '';
const selectEl = document.createElement('select');
selectEl.id = `product-select-${index}-${Date.now()}`;
selectEl.name = `items[${index}][product_id]`;
selectEl.required = true;
selectEl.className = 'hidden';
const products = this.fromId ? this.availableProducts : this.allProducts;
const placeholderOpt = document.createElement('option');
placeholderOpt.value = '';
placeholderOpt.textContent = '{{ __("Select Product") }}';
selectEl.appendChild(placeholderOpt);
products.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id;
const stockText = p.quantity !== undefined ? ` (${'{{ __("Stock") }}'}: ${p.quantity})` : '';
opt.textContent = p.name + stockText;
opt.setAttribute('data-title', p.name + stockText);
if (item.product_id == p.id) opt.selected = true;
selectEl.appendChild(opt);
});
selectEl.setAttribute('data-hs-select', JSON.stringify({
"placeholder": "{{ __('Select Product') }}",
"toggleClasses": "hs-select-toggle luxury-select-toggle w-full text-left",
"toggleTemplate": "<button type=\"button\"><span class=\"text-slate-800 dark:text-slate-200\" data-title></span><div class=\"ms-auto\"><svg class=\"size-4 text-slate-400\" 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>",
"dropdownClasses": "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-2xl mt-2 z-[150] max-h-48 overflow-y-auto custom-scrollbar-thin",
"optionClasses": "hs-select-option py-2 px-3 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-cyan-500/10 rounded-lg",
"hasSearch": true, "searchPlaceholder": "{{ __('Search Product') }}",
"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",
"searchWrapperClasses": "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10",
"strategy": "fixed"
}));
wrapper.appendChild(selectEl);
selectEl.addEventListener('change', (e) => { item.product_id = e.target.value; });
if (window.HSStaticMethods) window.HSStaticMethods.autoInit('select');
});
},
async submitReplenishment() {
const form = document.getElementById('replenishmentForm');
const warehouseId = this.fromId;
const machineId = form.querySelector('[name="machine_id"]')?.value;
if (!warehouseId || warehouseId === ' ') {
window.showToast?.('{{ __("Please select source warehouse") }}', 'error'); return;
}
if (!machineId || machineId === ' ') {
window.showToast?.('{{ __("Please select target machine") }}', 'error'); return;
}
if (this.items.length === 0) {
window.showToast?.('{{ __("Please add at least one item") }}', 'error'); return;
}
for (let i = 0; i < this.items.length; i++) {
if (!this.items[i].product_id) {
window.showToast?.('{{ __("Please select product for item :num") }}'.replace(':num', i+1), 'error'); return;
}
if (!this.items[i].quantity || this.items[i].quantity < 1) {
window.showToast?.('{{ __("Please enter valid quantity for item :num") }}'.replace(':num', i+1), 'error'); return;
}
}
this.loading = true;
try {
const formData = new FormData(form);
const response = await fetch(form.action, {
method: 'POST', body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
});
const result = await response.json();
if (response.ok && result.success) {
this.showReplenishmentModal = false;
window.showToast?.(result.message, 'success');
this.fetchTabData();
} else if (response.status === 422) {
const firstError = Object.values(result.errors || {})[0]?.[0] || '{{ __("Validation failed") }}';
window.showToast?.(firstError, 'error');
} else { window.showToast?.(result.message || '{{ __("System error") }}', 'error'); }
} catch (e) { console.error(e); window.showToast?.('{{ __("System Error") }}', 'error'); }
finally { this.loading = false; }
},
// 一鍵補貨預覽
async loadAutoPreview() {
if (!this.autoWarehouseId || this.autoWarehouseId === ' ' || !this.autoMachineId || this.autoMachineId === ' ') {
window.showToast?.('{{ __("Please select warehouse and machine") }}', 'error'); return;
}
this.autoLoading = true;
try {
const res = await fetch('{{ route("admin.warehouses.replenishments.auto") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ warehouse_id: this.autoWarehouseId, machine_id: this.autoMachineId })
});
const data = await res.json();
if (data.success && data.preview) {
this.autoSlots = data.slots;
this.autoHasWarning = data.has_warning;
this.autoInsufficient = data.insufficient || {};
this.autoWarehouseStocks = data.warehouse_stocks || {};
this.autoPreviewLoaded = true;
} else {
window.showToast?.(data.message || '{{ __("Failed to load preview") }}', 'error');
}
} catch (e) { console.error(e); window.showToast?.('{{ __("System Error") }}', 'error'); }
finally { this.autoLoading = false; }
},
getActualFillable(index) {
const slot = this.autoSlots[index];
if (!slot) return 0;
let available = this.autoWarehouseStocks[slot.product_id] || 0;
for (let i = 0; i < index; i++) {
if (this.autoSlots[i].product_id === slot.product_id) {
available -= this.autoSlots[i].quantity;
}
}
return Math.max(0, Math.min(slot.quantity, available));
},
removeAutoSlot(idx) {
this.autoSlots.splice(idx, 1);
},
async submitAutoReplenishment() {
if (!this.autoSlots || this.autoSlots.length === 0) {
window.showToast?.('{{ __("No items to replenish") }}', 'error'); return;
}
this.autoLoading = true;
const items = this.autoSlots.map(s => ({
slot_no: s.slot_no,
product_id: s.product_id,
quantity: s.quantity,
current_stock: s.current_stock,
max_stock: s.max_stock,
}));
try {
const res = await fetch('{{ route("admin.warehouses.replenishments.auto") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({
warehouse_id: this.autoWarehouseId,
machine_id: this.autoMachineId,
note: this.autoNote,
items
})
});
const data = await res.json();
if (data.success) {
this.showAutoModal = false;
this.autoPreviewLoaded = false;
this.autoSlots = [];
window.showToast?.(data.message, 'success');
this.fetchTabData();
} else {
window.showToast?.(data.message || '{{ __("Creation failed") }}', 'error');
}
} catch (e) { console.error(e); window.showToast?.('{{ __("System Error") }}', 'error'); }
finally { this.autoLoading = false; }
},
// 狀態推進
async advanceStatus(orderId, newStatus) {
const labels = { prepared: '{{ __("Confirm Prepare") }}', delivering: '{{ __("Start Delivery") }}', completed: '{{ __("Confirm Complete") }}' };
if (!confirm(labels[newStatus] + '?')) return;
this.loading = true;
try {
const res = await fetch(`{{ url('/admin/warehouses/replenishments') }}/${orderId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ status: newStatus })
});
const data = await res.json();
if (data.success) { window.showToast?.(data.message, 'success'); this.fetchTabData(); }
else { window.showToast?.(data.message || '{{ __("Operation failed") }}', 'error'); }
} catch (e) { console.error(e); window.showToast?.('{{ __("System Error") }}', 'error'); }
finally { this.loading = false; }
},
// 取消
confirmCancel(orderId) { this.cancelOrderId = orderId; this.showCancelModal = true; },
async executeCancel() {
this.loading = true;
try {
const res = await fetch(`{{ url('/admin/warehouses/replenishments') }}/${this.cancelOrderId}/cancel`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
});
const data = await res.json();
if (data.success) {
this.showCancelModal = false;
let msg = data.message;
if (data.stock_returned) msg += ' — {{ __("Stock returned to warehouse") }}';
window.showToast?.(msg, 'success');
this.fetchTabData();
} else { window.showToast?.(data.message, 'error'); }
} catch (e) { console.error(e); window.showToast?.('{{ __("System Error") }}', 'error'); }
finally { this.loading = false; }
},
// 指派
openAssignModal(orderId) { this.assignOrderId = orderId; this.assignUserId = ''; this.showAssignModal = true; },
async executeAssign() {
if (!this.assignUserId) { window.showToast?.('{{ __("Select Personnel") }}', 'error'); return; }
this.loading = true;
try {
const res = await fetch(`{{ url('/admin/warehouses/replenishments') }}/${this.assignOrderId}/assign`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ assigned_to: this.assignUserId })
});
const data = await res.json();
if (data.success) { this.showAssignModal = false; window.showToast?.(data.message, 'success'); this.fetchTabData(); }
else { window.showToast?.(data.message, 'error'); }
} catch (e) { console.error(e); }
finally { this.loading = false; }
},
async fetchTabData(url = null) {
this.loading = true;
const container = document.getElementById('replenishments-table-container');
if (!url) {
const form = document.querySelector('form[action="{{ route('admin.warehouses.replenishments') }}"]');
url = `${form.action}?${new URLSearchParams(new FormData(form)).toString()}`;
}
try {
const res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } });
const data = await res.json();
if (data.success && container) {
container.outerHTML = data.html;
this.$nextTick(() => { if (window.HSStaticMethods) window.HSStaticMethods.autoInit(); });
window.history.pushState({}, '', new URL(url, window.location.origin).toString());
}
} catch (e) { console.error(e); window.showToast?.('{{ __("Loading failed") }}', 'error'); }
finally { this.loading = false; }
}
}
}
</script>
<div class="space-y-3 pb-10" x-data="replenishmentManager()"
@open-details.window="openOrderDetails($event.detail.id)"
@ajax:navigate:replenishments.window.prevent="fetchTabData($event.detail.url)">
{{-- Header --}}
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Machine Replenishment') }}</h1>
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Create and manage replenishment orders from warehouse to machines') }}</p>
</div>
<div class="flex items-center gap-3">
<button type="button" @click="
autoWarehouseId = '';
autoMachineId = '';
autoNote = '';
autoPreviewLoaded = false;
autoSlots = [];
showAutoModal = true;
$nextTick(() => {
document.querySelectorAll('[name^=\'auto_\']').forEach(s => {
if (window.HSSelect) {
const inst = window.HSSelect.getInstance(s);
if (inst) inst.setValue(' ');
}
});
});
" class="btn-luxury-ghost border-cyan-500/30 text-cyan-600 hover:bg-cyan-50 dark:hover:bg-cyan-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="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" /></svg>
<span>{{ __('Auto Replenishment') }}</span>
</button>
<button type="button" @click="openCreateModal()" class="btn-luxury-primary">
<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>{{ __('New Replenishment') }}</span>
</button>
</div>
</div>
{{-- Filters --}}
<div class="luxury-card rounded-3xl p-8 animate-luxury-in">
<form id="replenishment-filter-form" action="{{ route('admin.warehouses.replenishments') }}" method="GET" class="flex flex-col md:flex-row md:items-center gap-4 mb-8">
<div class="relative group flex-1 md:max-w-[240px]">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
</span>
<input type="text" name="search_order" value="{{ request('search_order') }}" @keydown.enter.prevent="fetchTabData()" class="py-2.5 pl-12 pr-6 block w-full luxury-input" placeholder="{{ __('Search order number...') }}">
</div>
<div class="flex-1 md:max-w-[180px]">
<x-searchable-select name="status" :selected="request('status')" :placeholder="__('All Statuses')">
<option value="" data-title="{{ __('All Statuses') }}">{{ __('All Statuses') }}</option>
<option value="pending" {{ request('status') === 'pending' ? 'selected' : '' }} data-title="{{ __('Pending') }}">{{ __('Pending') }}</option>
<option value="prepared" {{ request('status') === 'prepared' ? 'selected' : '' }} data-title="{{ __('Prepared') }}">{{ __('Prepared') }}</option>
<option value="delivering" {{ request('status') === 'delivering' ? 'selected' : '' }} data-title="{{ __('Delivering') }}">{{ __('Delivering') }}</option>
<option value="completed" {{ request('status') === 'completed' ? 'selected' : '' }} data-title="{{ __('Completed') }}">{{ __('Completed') }}</option>
<option value="cancelled" {{ request('status') === 'cancelled' ? 'selected' : '' }} data-title="{{ __('Cancelled') }}">{{ __('Cancelled') }}</option>
</x-searchable-select>
</div>
<button type="button" @click="fetchTabData()" class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25" title="{{ __('Search') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</button>
<button type="button" @click="$el.closest('form').querySelectorAll('input[type=text]').forEach(i => i.value = ''); $el.closest('form').querySelectorAll('select').forEach(s => { s.value = ' '; const inst = window.HSSelect?.getInstance(s); if(inst) inst.setValue(' '); }); fetchTabData();" class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-700" title="{{ __('Reset') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
</button>
</form>
@include('admin.warehouses.partials.table-replenishments')
</div>
@include('admin.warehouses.partials.modal-replenishment-create')
@include('admin.warehouses.partials.modal-replenishment-auto')
@include('admin.warehouses.partials.slideover-replenishment-details')
@include('admin.warehouses.partials.modal-replenishment-cancel')
@include('admin.warehouses.partials.modal-replenishment-assign')
</div>
@endsection