star-cloud/resources/views/admin/sales/index.blade.php
sky121113 4766ec6939 [FEAT] 電子發票作廢權限控管、交易訂單與發票備註、作廢發票差異顯示
1. 發票作廢改為僅限平台系統管理員:voidInvoice() 加 isSystemAdmin() 403 守衛,訂單詳情面板與 tab-invoices 手機卡片作廢按鈕同步加 isSystemAdmin 顯示條件(列印鈕維持所有人可用)。
2. 新增 orders、invoices 各自獨立的 remark 欄位(migration + Model fillable),供管理者編輯內部備註。
3. 新增 updateOrderRemark / updateInvoiceRemark 端點與 PATCH 路由,AJAX 回傳 JSON、租戶隔離沿用 TenantScoped。
4. 訂單詳情面板加入訂單與發票備註的 Alpine 內嵌編輯(fetch + toast,不重整頁面),所有可進銷售中心者皆可編輯。
5. confirm-modal 加可選 slot;作廢確認視窗加備註輸入框,submitInvoiceAction() 送出前注入隱藏 remark,寫入 invoice.remark(不影響送綠界的 void_reason)。
6. 訂單列表(桌面與手機)已作廢發票改以灰色刪除線加「已作廢」標記顯示,index eager load 補載 invoice.status。
7. 新增 5 組三語系 key(Voided、Enter remark (optional)、Remark updated、No remark、Only system administrators can void invoices),zh_TW/en/ja 對齊排序至 2572 鍵。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:48:04 +08:00

379 lines
16 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.

@extends('layouts.admin')
@section('content')
<div class="space-y-2 pb-20" x-data="salesCenter()"
@ajax:navigate.window.prevent="fetchTabData(activeTab, $event.detail.url)">
{{-- Page Header --}}
<x-page-header :title="$title" :subtitle="$description" />
{{-- Tab Navigation --}}
<x-tab-nav model="activeTab">
<x-tab-nav-item value="orders" :label="__('Transaction Orders')" model="activeTab" />
<x-tab-nav-item value="invoices" :label="__('Electronic Invoices')" model="activeTab" />
<x-tab-nav-item value="dispense" :label="__('Dispense Records')" model="activeTab" />
</x-tab-nav>
{{-- Tab Contents --}}
<div class="mt-6">
<div class="relative min-h-[400px]">
{{-- Orders Tab --}}
<div x-show="activeTab === 'orders'" class="relative"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0">
<div class="luxury-card rounded-3xl p-8 relative overflow-hidden">
<x-luxury-spinner show="tabLoading === 'orders'" />
<div id="tab-orders-container" :class="{ 'opacity-30 pointer-events-none transition-opacity duration-300': tabLoading === 'orders' }">
@include('admin.sales.partials.tab-orders')
</div>
</div>
</div>
{{-- Invoices Tab --}}
<div x-show="activeTab === 'invoices'" class="relative"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0" x-cloak>
<div class="luxury-card rounded-3xl p-8 relative overflow-hidden">
<x-luxury-spinner show="tabLoading === 'invoices'" />
<div id="tab-invoices-container" :class="{ 'opacity-30 pointer-events-none transition-opacity duration-300': tabLoading === 'invoices' }">
@include('admin.sales.partials.tab-invoices')
</div>
</div>
</div>
{{-- Dispense Records Tab --}}
<div x-show="activeTab === 'dispense'" class="relative"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0" x-cloak>
<div class="luxury-card rounded-3xl p-8 relative overflow-hidden">
<x-luxury-spinner show="tabLoading === 'dispense'" />
<div id="tab-dispense-container" :class="{ 'opacity-30 pointer-events-none transition-opacity duration-300': tabLoading === 'dispense' }">
@include('admin.sales.partials.tab-dispense')
</div>
</div>
</div>
</div>
</div>
{{-- Slide-over Panel (Order Details) --}}
<div
class="fixed inset-0 z-[100] overflow-hidden"
x-show="showPanel"
x-cloak
@keydown.window.escape="showPanel = false"
>
<div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
x-show="showPanel"
x-transition:enter="ease-out duration-500"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-500"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="showPanel = false"
></div>
<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
<div
class="pointer-events-auto w-screen max-w-2xl transform transition ease-in-out duration-500 sm:duration-700"
x-show="showPanel"
x-transition:enter="transform transition ease-in-out duration-500 sm:duration-700"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transform transition ease-in-out duration-500 sm:duration-700"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full"
>
<div class="flex h-full flex-col overflow-y-scroll bg-white dark:bg-slate-900 shadow-2xl border-l border-slate-100 dark:border-slate-800">
<div id="order-detail-content">
{{-- AJAX injected content --}}
</div>
</div>
</div>
</div>
</div>
{{-- 發票作廢 / 補開 確認框(自製 UI取代瀏覽器原生 confirm --}}
<x-confirm-modal alpineVar="showVoidModal" confirmAction="submitInvoiceAction()" :title="__('Void Invoice')"
:message="__('Void this invoice?')" :confirmText="__('Void Invoice')" :cancelText="__('Cancel')"
iconType="danger" confirmColor="rose">
<div>
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-2">{{ __('Remark') }}</label>
<textarea x-model="voidRemark" rows="2" maxlength="1000"
placeholder="{{ __('Enter remark (optional)') }}"
class="luxury-input w-full text-sm py-2.5 px-4 resize-none"></textarea>
</div>
</x-confirm-modal>
<x-confirm-modal alpineVar="showReissueModal" confirmAction="submitInvoiceAction()" :title="__('Re-issue')"
:message="__('Re-issue this invoice?')" :confirmText="__('Re-issue')" :cancelText="__('Cancel')"
iconType="info" confirmColor="sky" />
</div>
@endsection
@section('scripts')
<script>
function salesCenter() {
return {
showPanel: false,
activeTab: '{{ $tab }}',
tabLoading: null,
showVoidModal: false,
showReissueModal: false,
pendingInvoiceForm: '',
voidRemark: '',
confirmVoidInvoice(formId) {
this.pendingInvoiceForm = formId;
this.voidRemark = '';
this.showVoidModal = true;
},
confirmReissueInvoice(formId) {
this.pendingInvoiceForm = formId;
this.showReissueModal = true;
},
submitInvoiceAction() {
const form = this.pendingInvoiceForm ? document.getElementById(this.pendingInvoiceForm) : null;
if (form) {
// 作廢時附帶管理者備註(注入隱藏 input後端寫入 invoice.remark
if (this.showVoidModal) {
let input = form.querySelector('input[name="remark"]');
if (!input) {
input = document.createElement('input');
input.type = 'hidden';
input.name = 'remark';
form.appendChild(input);
}
input.value = this.voidRemark || '';
}
form.submit();
}
this.showVoidModal = false;
this.showReissueModal = false;
},
init() {
const params = new URLSearchParams(window.location.search);
const urlTab = params.get('tab');
if (urlTab && ['orders', 'invoices', 'dispense'].includes(urlTab)) {
this.activeTab = urlTab;
}
// 監聽頁籤切換以更新網址列 (不觸發重整)
this.$watch('activeTab', (newTab) => {
const url = new URL(window.location.origin + window.location.pathname);
url.searchParams.set('tab', newTab);
window.history.pushState({}, '', url);
});
},
switchTab(tab, url = null) {
if (this.activeTab === tab && !url) return;
this.activeTab = tab;
// 如果有提供 URL (例如點擊跳轉),則使用 AJAX 載入新內容
if (url) {
this.fetchTabData(tab, url);
return;
}
},
async fetchTabData(tab, url = null) {
if (this.tabLoading === tab) return;
this.tabLoading = tab;
const container = document.getElementById('tab-' + tab + '-container');
if (!url) {
// 從表單建構 URL
let params = new URLSearchParams();
params.set('tab', tab);
if (container) {
const form = container.querySelector('form');
if (form) {
const formData = new FormData(form);
formData.forEach((value, key) => {
if (value && value.trim() !== '') params.append(key, value);
});
}
}
url = `${window.location.pathname}?${params.toString()}`;
}
try {
const response = await fetch(url, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
const data = await response.json();
if (data.success && data.html) {
if (container) container.innerHTML = data.html;
// 更新網址列但不重整頁面
window.history.pushState({}, '', url);
// 重新初始化 Preline 組件
this.$nextTick(() => {
if (window.HSStaticMethods) window.HSStaticMethods.autoInit();
});
}
} catch (err) {
console.error('Failed to fetch tab data:', err);
} finally {
this.tabLoading = null;
}
},
openDetail(orderId) {
this.showPanel = true;
document.getElementById('order-detail-content').innerHTML = `<div class="flex items-center justify-center h-full py-20"><x-luxury-spinner show="true" /></div>`;
fetch(`/admin/sales/${orderId}`, {
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(res => res.json())
.then(data => {
if (data.success) {
document.getElementById('order-detail-content').innerHTML = data.html;
}
})
.catch(err => {
console.error('Failed to load order detail:', err);
this.showPanel = false;
});
},
exportData(type) {
const container = document.getElementById('tab-' + this.activeTab + '-container');
let params = new URLSearchParams();
params.set('tab', this.activeTab);
params.set('export', type);
if (container) {
const form = container.querySelector('form');
if (form) {
const formData = new FormData(form);
formData.forEach((value, key) => {
if (value && value.trim() !== '') {
if (key !== 'tab' && key !== 'export') {
params.append(key, value);
}
}
});
}
}
// 使用隱藏的 iframe 進行下載,避免觸發主視窗的 beforeunload 事件,
// 從而防止全域的頂部進度條 (top-loading-bar) 被觸發並卡在 95% 的位置。
let iframe = document.getElementById('export-download-iframe');
if (!iframe) {
iframe = document.createElement('iframe');
iframe.id = 'export-download-iframe';
iframe.style.display = 'none';
document.body.appendChild(iframe);
}
iframe.src = `${window.location.pathname}?${params.toString()}`;
// 顯示成功提示訊息,與商品報表保持一致
if (type === 'csv') {
window.showToast?.('CSV 報表導出成功', 'success');
} else if (type === 'excel') {
window.showToast?.('Excel 報表導出成功', 'success');
}
},
copyTableData() {
const container = document.getElementById('tab-' + this.activeTab + '-container');
if (!container) return;
const table = container.querySelector('table');
if (!table) return;
let headers = [];
table.querySelectorAll('thead th').forEach((th, idx, arr) => {
if (idx < arr.length - 1) {
headers.push(th.innerText.trim().replace(/\s+/g, ' '));
}
});
let rows = [headers.join('\t')];
table.querySelectorAll('tbody tr').forEach(tr => {
if (tr.querySelector('td.colspan') || tr.querySelector('td[colspan]')) return;
let cells = [];
tr.querySelectorAll('td').forEach((td, idx, arr) => {
if (idx < arr.length - 1) {
let text = td.innerText.trim().replace(/\r?\n/g, ' ').replace(/\s+/g, ' ');
cells.push(text);
}
});
if (cells.length > 0) {
rows.push(cells.join('\t'));
}
});
navigator.clipboard.writeText(rows.join('\n')).then(() => {
window.showToast?.('已成功將明細複製至剪貼簿', 'success');
}).catch(err => {
console.error('Clipboard copy failed:', err);
window.showToast?.('複製失敗,瀏覽器權限可能不足', 'error');
});
},
printTable() {
const container = document.getElementById('tab-' + this.activeTab + '-container');
if (!container) return;
const table = container.querySelector('table');
if (!table) return;
const clonedTable = table.cloneNode(true);
// 移除所有 SVG 圖標,防止列印或 PDF 輸出時因為沒有載入 Tailwind 樣式而顯示巨大黑色區塊
clonedTable.querySelectorAll('svg').forEach(svg => svg.remove());
clonedTable.querySelectorAll('tr').forEach(tr => {
const cells = tr.querySelectorAll('th, td');
if (cells.length > 0) {
cells[cells.length - 1].remove();
}
});
let tabTitle = '';
if (this.activeTab === 'orders') tabTitle = '交易紀錄';
else if (this.activeTab === 'invoices') tabTitle = '電子發票';
else if (this.activeTab === 'dispense') tabTitle = '遠端出貨紀錄';
const printWindow = window.open('', '_blank');
let html = `<html><head><title>${tabTitle}</title>`;
html += `<style>
body { font-family: system-ui, -apple-system, sans-serif; padding: 30px; color: #1e293b; }
h1 { font-size: 24px; font-weight: 800; margin-bottom: 5px; }
.subtitle { font-size: 13px; font-weight: 600; color: #64748b; margin-bottom: 25px; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 12px; }
th, td { border: 1px solid #cbd5e1; padding: 10px 12px; text-align: left; }
th { background-color: #f1f5f9; font-weight: bold; color: #334155; }
@media print {
body { padding: 0; }
}
</style></head><body>`;
html += `<h1>${tabTitle}</h1>`;
html += `<div class="subtitle">列印時間:${new Date().toLocaleString()}</div>`;
html += clonedTable.outerHTML;
html += `</body></html>`;
printWindow.document.write(html);
printWindow.document.close();
printWindow.focus();
setTimeout(() => {
printWindow.print();
printWindow.close();
}, 250);
}
}
}
</script>
@endsection