[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>
This commit is contained in:
sky121113 2026-06-18 10:48:04 +08:00
parent bfaf0e7317
commit 4766ec6939
13 changed files with 237 additions and 13 deletions

View File

@ -66,8 +66,8 @@ class SalesController extends Controller
// 1. 建立基本查詢 (套用共用過濾器:機台、日期) // 1. 建立基本查詢 (套用共用過濾器:機台、日期)
$ordersQuery = Order::with([ $ordersQuery = Order::with([
'machine:id,name,serial_no', 'machine:id,name,serial_no',
'invoice:id,order_id,invoice_no', 'invoice:id,order_id,invoice_no,status',
'items', 'items',
'staffCardLog:id,order_id,staff_card_id', 'staffCardLog:id,order_id,staff_card_id',
'staffCardLog.staffCard:id,employee_id,name,card_uid' 'staffCardLog.staffCard:id,employee_id,name,card_uid'
]); ]);
@ -1261,6 +1261,11 @@ class SalesController extends Controller
/** 作廢Invalid用於「已開票未出貨」等情況。 */ /** 作廢Invalid用於「已開票未出貨」等情況。 */
public function voidInvoice(Request $request, Invoice $invoice, \App\Services\Invoice\EcpayInvoiceService $ecpay) public function voidInvoice(Request $request, Invoice $invoice, \App\Services\Invoice\EcpayInvoiceService $ecpay)
{ {
// 權限守衛:發票作廢僅限「平台系統管理員」,租戶帳號(含其管理員)一律不得作廢。
// 真正的閘門在後端,前端僅隱藏按鈕無法防止偽造 POST。
if (!Auth::user()->isSystemAdmin()) {
abort(403, __('Only system administrators can void invoices'));
}
// 伺服器端狀態守衛:僅 issued 可作廢(防把已作廢/未開立的發票再作廢) // 伺服器端狀態守衛:僅 issued 可作廢(防把已作廢/未開立的發票再作廢)
if ($invoice->status !== Invoice::STATUS_ISSUED) { if ($invoice->status !== Invoice::STATUS_ISSUED) {
return back()->with('error', __('Only issued invoices can be voided')); return back()->with('error', __('Only issued invoices can be voided'));
@ -1268,7 +1273,9 @@ class SalesController extends Controller
if (empty($invoice->invoice_no)) { if (empty($invoice->invoice_no)) {
return back()->with('error', __('No invoice issued, cannot void')); return back()->with('error', __('No invoice issued, cannot void'));
} }
$reason = $request->input('reason', '未出貨作廢'); // 送綠界的作廢原因維持固定值(法定欄位、上限 20 字);管理者於視窗輸入的備註僅存內部 remark。
$reason = '未出貨作廢';
$remark = trim((string) $request->input('remark', ''));
$date = $invoice->invoice_date $date = $invoice->invoice_date
? Carbon::parse($invoice->invoice_date)->format('Y-m-d') ? Carbon::parse($invoice->invoice_date)->format('Y-m-d')
: ''; : '';
@ -1283,12 +1290,41 @@ class SalesController extends Controller
'status' => Invoice::STATUS_VOID, 'status' => Invoice::STATUS_VOID,
'voided_at' => now(), 'voided_at' => now(),
'void_reason' => $reason, 'void_reason' => $reason,
'remark' => $remark !== '' ? $remark : $invoice->remark,
]); ]);
return back()->with('success', __('Void success')); return back()->with('success', __('Void success'));
} }
return back()->with('error', __('Void failed: ') . ($result['RtnMsg'] ?? '')); return back()->with('error', __('Void failed: ') . ($result['RtnMsg'] ?? ''));
} }
/** 更新訂單備註(管理者內部註記)。租戶隔離由 TenantScoped + route-model binding 自動處理。 */
public function updateOrderRemark(Request $request, Order $order)
{
$data = $request->validate([
'remark' => ['nullable', 'string', 'max:1000'],
]);
$order->update(['remark' => $data['remark'] ?? null]);
if ($request->expectsJson()) {
return response()->json(['success' => true, 'message' => __('Remark updated')]);
}
return back()->with('success', __('Remark updated'));
}
/** 更新發票備註(管理者內部註記,與送綠界的 void_reason 無關)。 */
public function updateInvoiceRemark(Request $request, Invoice $invoice)
{
$data = $request->validate([
'remark' => ['nullable', 'string', 'max:1000'],
]);
$invoice->update(['remark' => $data['remark'] ?? null]);
if ($request->expectsJson()) {
return response()->json(['success' => true, 'message' => __('Remark updated')]);
}
return back()->with('success', __('Remark updated'));
}
private function safeDate($raw): ?string private function safeDate($raw): ?string
{ {
if (empty($raw)) { if (empty($raw)) {

View File

@ -30,6 +30,7 @@ class Invoice extends Model
'retry_count', 'retry_count',
'voided_at', 'voided_at',
'void_reason', 'void_reason',
'remark',
'random_number', 'random_number',
'love_code', 'love_code',
'rtn_code', 'rtn_code',

View File

@ -63,6 +63,7 @@ class Order extends Model
'status', 'status',
'metadata', 'metadata',
'delivery_status', 'delivery_status',
'remark',
]; ];
protected $casts = [ protected $casts = [

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 為交易訂單與電子發票各自新增「管理者備註」欄位。
* - orders.remark訂單內部備註出貨客訴等
* - invoices.remark發票內部備註作廢視窗亦寫入此欄不影響送綠界的 void_reason
* 兩欄皆 nullable對既有資料零影響。
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->text('remark')->nullable()->after('metadata');
});
Schema::table('invoices', function (Blueprint $table) {
$table->text('remark')->nullable()->after('void_reason');
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropColumn('remark');
});
Schema::table('invoices', function (Blueprint $table) {
$table->dropColumn('remark');
});
}
};

View File

@ -700,6 +700,7 @@
"Enter machine name": "Enter machine name", "Enter machine name": "Enter machine name",
"Enter machine name...": "Enter machine name...", "Enter machine name...": "Enter machine name...",
"Enter model name": "Enter model name", "Enter model name": "Enter model name",
"Enter remark (optional)": "Enter remark (optional)",
"Enter role name": "Enter role name", "Enter role name": "Enter role name",
"Enter serial number": "Enter serial number", "Enter serial number": "Enter serial number",
"Enter your password to confirm": "Enter your password to confirm", "Enter your password to confirm": "Enter your password to confirm",
@ -1321,6 +1322,7 @@
"No products found matching your criteria.": "No products found matching your criteria.", "No products found matching your criteria.": "No products found matching your criteria.",
"No records found": "No records found", "No records found": "No records found",
"No related detail found": "No related detail found", "No related detail found": "No related detail found",
"No remark": "No remark",
"No replenishment orders found": "No replenishment orders found", "No replenishment orders found": "No replenishment orders found",
"No results found": "No results found", "No results found": "No results found",
"No roles available": "No roles available", "No roles available": "No roles available",
@ -1394,6 +1396,7 @@
"Only machines under the same company can be cloned for security.": "Only machines under the same company can be cloned for security.", "Only machines under the same company can be cloned for security.": "Only machines under the same company can be cloned for security.",
"Only pending/failed invoices can be queried": "Only pending/failed invoices can be queried", "Only pending/failed invoices can be queried": "Only pending/failed invoices can be queried",
"Only pending/failed invoices can be re-issued": "Only pending/failed invoices can be re-issued", "Only pending/failed invoices can be re-issued": "Only pending/failed invoices can be re-issued",
"Only system administrators can void invoices": "Only system administrators can void invoices",
"Only system roles can be assigned to platform administrative accounts.": "Only system roles can be assigned to platform administrative accounts.", "Only system roles can be assigned to platform administrative accounts.": "Only system roles can be assigned to platform administrative accounts.",
"Operation Logs": "Operation Logs", "Operation Logs": "Operation Logs",
"Operation Note": "Operation Note", "Operation Note": "Operation Note",
@ -1699,6 +1702,7 @@
"Register a new firmware version for OTA deployment": "Register a new firmware version for OTA deployment", "Register a new firmware version for OTA deployment": "Register a new firmware version for OTA deployment",
"Release Notes": "Release Notes", "Release Notes": "Release Notes",
"Remark": "Remark", "Remark": "Remark",
"Remark updated": "Remark updated",
"Remote Change": "Remote Change", "Remote Change": "Remote Change",
"Remote Checkout": "Remote Checkout", "Remote Checkout": "Remote Checkout",
"Remote Command Center": "Remote Command Center", "Remote Command Center": "Remote Command Center",
@ -2298,6 +2302,7 @@
"Void failed: ": "Void failed: ", "Void failed: ": "Void failed: ",
"Void success": "Void success", "Void success": "Void success",
"Void this invoice?": "Void this invoice?", "Void this invoice?": "Void this invoice?",
"Voided": "Voided",
"W2W": "W2W", "W2W": "W2W",
"Waiting": "Waiting", "Waiting": "Waiting",
"Waiting for Payment": "Waiting for Payment", "Waiting for Payment": "Waiting for Payment",

View File

@ -700,6 +700,7 @@
"Enter machine name": "機器名を入力してください", "Enter machine name": "機器名を入力してください",
"Enter machine name...": "機器名を入力...", "Enter machine name...": "機器名を入力...",
"Enter model name": "モデル名を入力してください", "Enter model name": "モデル名を入力してください",
"Enter remark (optional)": "備考を入力(任意)",
"Enter role name": "ロール名を入力してください", "Enter role name": "ロール名を入力してください",
"Enter serial number": "シリアル番号を入力してください", "Enter serial number": "シリアル番号を入力してください",
"Enter your password to confirm": "確認のためパスワードを入力してください", "Enter your password to confirm": "確認のためパスワードを入力してください",
@ -1321,6 +1322,7 @@
"No products found matching your criteria.": "条件に一致する商品が見つかりません。", "No products found matching your criteria.": "条件に一致する商品が見つかりません。",
"No records found": "記録が見つかりません", "No records found": "記録が見つかりません",
"No related detail found": "関連する詳細が見つかりません", "No related detail found": "関連する詳細が見つかりません",
"No remark": "備考なし",
"No replenishment orders found": "補充伝票が見つかりません", "No replenishment orders found": "補充伝票が見つかりません",
"No results found": "結果が見つかりません", "No results found": "結果が見つかりません",
"No roles available": "利用可能なロールがありません。", "No roles available": "利用可能なロールがありません。",
@ -1394,6 +1396,7 @@
"Only machines under the same company can be cloned for security.": "セキュリティのため、同じ会社の機器設定のみコピーできます。", "Only machines under the same company can be cloned for security.": "セキュリティのため、同じ会社の機器設定のみコピーできます。",
"Only pending/failed invoices can be queried": "Only pending/failed invoices can be queried", "Only pending/failed invoices can be queried": "Only pending/failed invoices can be queried",
"Only pending/failed invoices can be re-issued": "Only pending/failed invoices can be re-issued", "Only pending/failed invoices can be re-issued": "Only pending/failed invoices can be re-issued",
"Only system administrators can void invoices": "システム管理者のみが請求書を無効化できます",
"Only system roles can be assigned to platform administrative accounts.": "プラットフォーム管理アカウントにはシステムロールのみを割り当てることができます。", "Only system roles can be assigned to platform administrative accounts.": "プラットフォーム管理アカウントにはシステムロールのみを割り当てることができます。",
"Operation Logs": "Operation Logs", "Operation Logs": "Operation Logs",
"Operation Note": "操作メモ", "Operation Note": "操作メモ",
@ -1699,6 +1702,7 @@
"Register a new firmware version for OTA deployment": "Register a new firmware version for OTA deployment", "Register a new firmware version for OTA deployment": "Register a new firmware version for OTA deployment",
"Release Notes": "Release Notes", "Release Notes": "Release Notes",
"Remark": "備考", "Remark": "備考",
"Remark updated": "備考を更新しました",
"Remote Change": "遠隔お釣り", "Remote Change": "遠隔お釣り",
"Remote Checkout": "遠隔決済", "Remote Checkout": "遠隔決済",
"Remote Command Center": "遠隔コマンドセンター", "Remote Command Center": "遠隔コマンドセンター",
@ -2298,6 +2302,7 @@
"Void failed: ": "Void failed: ", "Void failed: ": "Void failed: ",
"Void success": "Void success", "Void success": "Void success",
"Void this invoice?": "Void this invoice?", "Void this invoice?": "Void this invoice?",
"Voided": "無効化済み",
"W2W": "倉庫間", "W2W": "倉庫間",
"Waiting": "待機中", "Waiting": "待機中",
"Waiting for Payment": "支払待機中", "Waiting for Payment": "支払待機中",

View File

@ -700,6 +700,7 @@
"Enter machine name": "請輸入機台名稱", "Enter machine name": "請輸入機台名稱",
"Enter machine name...": "輸入機台名稱...", "Enter machine name...": "輸入機台名稱...",
"Enter model name": "請輸入型號名稱", "Enter model name": "請輸入型號名稱",
"Enter remark (optional)": "輸入備註(選填)",
"Enter role name": "請輸入角色名稱", "Enter role name": "請輸入角色名稱",
"Enter serial number": "請輸入機台序號", "Enter serial number": "請輸入機台序號",
"Enter your password to confirm": "請輸入您的密碼以確認", "Enter your password to confirm": "請輸入您的密碼以確認",
@ -1321,6 +1322,7 @@
"No products found matching your criteria.": "找不到符合條件的商品。", "No products found matching your criteria.": "找不到符合條件的商品。",
"No records found": "未找到相關紀錄", "No records found": "未找到相關紀錄",
"No related detail found": "無關聯詳細資料", "No related detail found": "無關聯詳細資料",
"No remark": "尚無備註",
"No replenishment orders found": "查無補貨單紀錄", "No replenishment orders found": "查無補貨單紀錄",
"No results found": "查無搜尋結果", "No results found": "查無搜尋結果",
"No roles available": "目前沒有角色資料。", "No roles available": "目前沒有角色資料。",
@ -1394,6 +1396,7 @@
"Only machines under the same company can be cloned for security.": "基於安全性,僅限複製同公司旗下的機台設定。", "Only machines under the same company can be cloned for security.": "基於安全性,僅限複製同公司旗下的機台設定。",
"Only pending/failed invoices can be queried": "僅待開立/失敗的發票可查詢", "Only pending/failed invoices can be queried": "僅待開立/失敗的發票可查詢",
"Only pending/failed invoices can be re-issued": "僅待開立/失敗的發票可補開", "Only pending/failed invoices can be re-issued": "僅待開立/失敗的發票可補開",
"Only system administrators can void invoices": "僅系統管理員可作廢發票",
"Only system roles can be assigned to platform administrative accounts.": "僅系統角色可指派給平台管理帳號。", "Only system roles can be assigned to platform administrative accounts.": "僅系統角色可指派給平台管理帳號。",
"Operation Logs": "操作紀錄", "Operation Logs": "操作紀錄",
"Operation Note": "操作備註", "Operation Note": "操作備註",
@ -1699,6 +1702,7 @@
"Register a new firmware version for OTA deployment": "註冊新韌體版本以進行 OTA 部署", "Register a new firmware version for OTA deployment": "註冊新韌體版本以進行 OTA 部署",
"Release Notes": "更新說明", "Release Notes": "更新說明",
"Remark": "備註", "Remark": "備註",
"Remark updated": "備註已更新",
"Remote Change": "遠端找零", "Remote Change": "遠端找零",
"Remote Checkout": "遠端結帳", "Remote Checkout": "遠端結帳",
"Remote Command Center": "遠端指令中心", "Remote Command Center": "遠端指令中心",
@ -2298,6 +2302,7 @@
"Void failed: ": "作廢失敗:", "Void failed: ": "作廢失敗:",
"Void success": "作廢成功", "Void success": "作廢成功",
"Void this invoice?": "確定要作廢這張發票嗎?", "Void this invoice?": "確定要作廢這張發票嗎?",
"Voided": "已作廢",
"W2W": "倉對倉", "W2W": "倉對倉",
"Waiting": "等待中", "Waiting": "等待中",
"Waiting for Payment": "等待付款", "Waiting for Payment": "等待付款",

View File

@ -96,7 +96,14 @@
{{-- 發票作廢 / 補開 確認框(自製 UI取代瀏覽器原生 confirm --}} {{-- 發票作廢 / 補開 確認框(自製 UI取代瀏覽器原生 confirm --}}
<x-confirm-modal alpineVar="showVoidModal" confirmAction="submitInvoiceAction()" :title="__('Void Invoice')" <x-confirm-modal alpineVar="showVoidModal" confirmAction="submitInvoiceAction()" :title="__('Void Invoice')"
:message="__('Void this invoice?')" :confirmText="__('Void Invoice')" :cancelText="__('Cancel')" :message="__('Void this invoice?')" :confirmText="__('Void Invoice')" :cancelText="__('Cancel')"
iconType="danger" confirmColor="rose" /> 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')" <x-confirm-modal alpineVar="showReissueModal" confirmAction="submitInvoiceAction()" :title="__('Re-issue')"
:message="__('Re-issue this invoice?')" :confirmText="__('Re-issue')" :cancelText="__('Cancel')" :message="__('Re-issue this invoice?')" :confirmText="__('Re-issue')" :cancelText="__('Cancel')"
iconType="info" confirmColor="sky" /> iconType="info" confirmColor="sky" />
@ -113,9 +120,11 @@ function salesCenter() {
showVoidModal: false, showVoidModal: false,
showReissueModal: false, showReissueModal: false,
pendingInvoiceForm: '', pendingInvoiceForm: '',
voidRemark: '',
confirmVoidInvoice(formId) { confirmVoidInvoice(formId) {
this.pendingInvoiceForm = formId; this.pendingInvoiceForm = formId;
this.voidRemark = '';
this.showVoidModal = true; this.showVoidModal = true;
}, },
@ -125,9 +134,20 @@ function salesCenter() {
}, },
submitInvoiceAction() { submitInvoiceAction() {
if (this.pendingInvoiceForm) { const form = this.pendingInvoiceForm ? document.getElementById(this.pendingInvoiceForm) : null;
const form = document.getElementById(this.pendingInvoiceForm); if (form) {
if (form) form.submit(); // 作廢時附帶管理者備註(注入隱藏 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.showVoidModal = false;
this.showReissueModal = false; this.showReissueModal = false;

View File

@ -102,6 +102,56 @@
</div> </div>
</section> </section>
{{-- 管理者備註(訂單內部註記) --}}
<section class="space-y-4"
x-data="{
editing: false,
saving: false,
value: @js($order->remark ?? ''),
original: @js($order->remark ?? ''),
cancel() { this.value = this.original; this.editing = false; },
save() {
this.saving = true;
fetch(@js(route('admin.sales.orders.remark', $order)), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': @js(csrf_token()), 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify({ remark: this.value })
}).then(r => { if (!r.ok) throw 0; return r.json(); })
.then(d => { this.original = this.value; this.editing = false; window.showToast?.((d && d.message) || @js(__('Remark updated')), 'success'); })
.catch(() => window.showToast?.(@js(__('Operation failed')), 'error'))
.finally(() => this.saving = false);
}
}">
<div class="flex items-center justify-between gap-2 px-1">
<div class="flex items-center gap-2">
<div class="w-1.5 h-4 bg-cyan-500 rounded-full"></div>
<h3 class="text-sm font-black text-slate-800 dark:text-white uppercase tracking-widest">{{ __('Remark') }}</h3>
</div>
<button type="button" x-show="!editing" @click="editing = true"
class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-widest hover:underline">{{ __('Edit') }}</button>
</div>
<div class="p-6 bg-slate-50/50 dark:bg-slate-800/30 rounded-[2rem] border border-slate-100 dark:border-slate-800">
<template x-if="!editing">
<p class="text-sm font-bold whitespace-pre-wrap break-words"
:class="value ? 'text-slate-600 dark:text-slate-300' : 'text-slate-300 dark:text-slate-600'"
x-text="value || @js(__('No remark'))"></p>
</template>
<template x-if="editing">
<div class="space-y-3">
<textarea x-model="value" rows="3" maxlength="1000"
placeholder="{{ __('Enter remark (optional)') }}"
class="luxury-input w-full text-sm py-2.5 px-4 resize-none"></textarea>
<div class="flex items-center justify-end gap-2">
<button type="button" @click="cancel()" :disabled="saving"
class="px-5 py-2 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-black text-xs uppercase tracking-widest disabled:opacity-60">{{ __('Cancel') }}</button>
<button type="button" @click="save()" :disabled="saving"
class="px-5 py-2 rounded-xl bg-cyan-500 hover:bg-cyan-600 text-white font-black text-xs uppercase tracking-widest disabled:opacity-60">{{ __('Save') }}</button>
</div>
</div>
</template>
</div>
</section>
{{-- 員工卡資訊 --}} {{-- 員工卡資訊 --}}
@if($order->payment_type == 41 && $order->staffCardLog && $order->staffCardLog->staffCard) @if($order->payment_type == 41 && $order->staffCardLog && $order->staffCardLog->staffCard)
<section class="space-y-4"> <section class="space-y-4">
@ -322,7 +372,8 @@
@else @else
<span class="text-[11px] font-bold text-slate-400 dark:text-slate-500 px-2">{{ __('Donation invoices cannot be printed') }}</span> <span class="text-[11px] font-bold text-slate-400 dark:text-slate-500 px-2">{{ __('Donation invoices cannot be printed') }}</span>
@endif @endif
{{-- 作廢:使用自製確認框 UI不用瀏覽器原生 confirm --}} {{-- 作廢:僅平台系統管理員可見可用(後端亦有 403 守衛)。使用自製確認框 UI --}}
@if(auth()->user()->isSystemAdmin())
<form id="invoice-void-form-{{ $order->invoice->id }}" method="POST" action="{{ route('admin.sales.invoices.void', $order->invoice) }}" class="hidden"> <form id="invoice-void-form-{{ $order->invoice->id }}" method="POST" action="{{ route('admin.sales.invoices.void', $order->invoice) }}" class="hidden">
@csrf @csrf
</form> </form>
@ -330,9 +381,55 @@
class="flex-1 sm:flex-none px-6 py-3 rounded-xl bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-bold text-xs border border-slate-200 dark:border-slate-700 hover:bg-rose-500 hover:text-white hover:border-rose-500 transition-all duration-300 shadow-sm"> class="flex-1 sm:flex-none px-6 py-3 rounded-xl bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-bold text-xs border border-slate-200 dark:border-slate-700 hover:bg-rose-500 hover:text-white hover:border-rose-500 transition-all duration-300 shadow-sm">
{{ __('Void Invoice') }} {{ __('Void Invoice') }}
</button> </button>
@endif
</div> </div>
@endif @endif
</div> </div>
{{-- 發票備註(管理者內部註記;作廢視窗輸入的備註也會顯示於此) --}}
<div class="relative mt-6 pt-6 border-t border-indigo-100/70 dark:border-indigo-500/20"
x-data="{
editing: false,
saving: false,
value: @js($order->invoice->remark ?? ''),
original: @js($order->invoice->remark ?? ''),
cancel() { this.value = this.original; this.editing = false; },
save() {
this.saving = true;
fetch(@js(route('admin.sales.invoices.remark', $order->invoice)), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': @js(csrf_token()), 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify({ remark: this.value })
}).then(r => { if (!r.ok) throw 0; return r.json(); })
.then(d => { this.original = this.value; this.editing = false; window.showToast?.((d && d.message) || @js(__('Remark updated')), 'success'); })
.catch(() => window.showToast?.(@js(__('Operation failed')), 'error'))
.finally(() => this.saving = false);
}
}">
<div class="flex items-center justify-between gap-2 mb-2">
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('Remark') }}</p>
<button type="button" x-show="!editing" @click="editing = true"
class="text-[10px] font-black text-indigo-500 dark:text-indigo-400 uppercase tracking-widest hover:underline">{{ __('Edit') }}</button>
</div>
<template x-if="!editing">
<p class="text-sm font-bold whitespace-pre-wrap break-words"
:class="value ? 'text-slate-600 dark:text-slate-300' : 'text-slate-300 dark:text-slate-600'"
x-text="value || @js(__('No remark'))"></p>
</template>
<template x-if="editing">
<div class="space-y-3">
<textarea x-model="value" rows="3" maxlength="1000"
placeholder="{{ __('Enter remark (optional)') }}"
class="luxury-input w-full text-sm py-2.5 px-4 resize-none"></textarea>
<div class="flex items-center justify-end gap-2">
<button type="button" @click="cancel()" :disabled="saving"
class="px-5 py-2 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-black text-xs uppercase tracking-widest disabled:opacity-60">{{ __('Cancel') }}</button>
<button type="button" @click="save()" :disabled="saving"
class="px-5 py-2 rounded-xl bg-indigo-500 hover:bg-indigo-600 text-white font-black text-xs uppercase tracking-widest disabled:opacity-60">{{ __('Save') }}</button>
</div>
</div>
</template>
</div>
</div> </div>
</section> </section>
@endif @endif

View File

@ -394,7 +394,7 @@
</form> </form>
</div> </div>
@endif @endif
@if($st === 'issued') @if($st === 'issued' && auth()->user()->isSystemAdmin())
<form method="POST" action="{{ route('admin.sales.invoices.void', $invoice) }}" <form method="POST" action="{{ route('admin.sales.invoices.void', $invoice) }}"
onsubmit="return confirm('{{ __('Void this invoice?') }}')"> onsubmit="return confirm('{{ __('Void this invoice?') }}')">
@csrf @csrf

View File

@ -289,16 +289,20 @@
$targetUrl = route('admin.sales.index', ['tab' => 'invoices', 'search' => $invoiceSearchValue]); $targetUrl = route('admin.sales.index', ['tab' => 'invoices', 'search' => $invoiceSearchValue]);
@endphp @endphp
@if($order->invoice) @if($order->invoice)
@php $invVoided = $order->invoice->status === 'void'; @endphp
<div @click.stop="switchTab('invoices', '{{ $targetUrl }}')" <div @click.stop="switchTab('invoices', '{{ $targetUrl }}')"
class="flex items-center gap-1.5 text-sm font-extrabold text-slate-600 dark:text-slate-400 cursor-pointer hover:text-cyan-500 transition-colors group/invoice" class="flex items-center gap-1.5 text-sm font-extrabold cursor-pointer hover:text-cyan-500 transition-colors group/invoice {{ $invVoided ? 'text-slate-400 dark:text-slate-600' : 'text-slate-600 dark:text-slate-400' }}"
title="{{ __('Go to E-Invoice') }}"> title="{{ __('Go to E-Invoice') }}">
<svg class="w-4 h-4 text-slate-300 dark:text-slate-600 group-hover/invoice:text-cyan-500" <svg class="w-4 h-4 text-slate-300 dark:text-slate-600 group-hover/invoice:text-cyan-500"
fill="none" stroke="currentColor" viewBox="0 0 24 24"> fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg> </svg>
<span class="group-hover/invoice:underline decoration-2 underline-offset-4">{{ <span class="group-hover/invoice:underline decoration-2 underline-offset-4 {{ $invVoided ? 'line-through' : '' }}">{{
$order->invoice->invoice_no }}</span> $order->invoice->invoice_no }}</span>
@if($invVoided)
<span class="px-1.5 py-0.5 rounded bg-slate-500/10 border border-slate-500/20 text-[9px] font-black text-slate-500 uppercase tracking-widest no-underline">{{ __('Voided') }}</span>
@endif
</div> </div>
@else @else
<span class="text-xs font-bold text-slate-300 dark:text-slate-700 tracking-widest">---</span> <span class="text-xs font-bold text-slate-300 dark:text-slate-700 tracking-widest">---</span>
@ -428,9 +432,13 @@
<div> <div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ <p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{
__('Invoice Number') }}</p> __('Invoice Number') }}</p>
@php $invVoided = $order->invoice && $order->invoice->status === 'void'; @endphp
<p @click.stop="switchTab('invoices', '{{ $targetUrl }}')" <p @click.stop="switchTab('invoices', '{{ $targetUrl }}')"
class="text-sm font-bold text-slate-700 dark:text-slate-300 cursor-pointer hover:text-cyan-500 transition-colors"> class="text-sm font-bold cursor-pointer hover:text-cyan-500 transition-colors flex items-center gap-1.5 {{ $invVoided ? 'text-slate-400 dark:text-slate-600' : 'text-slate-700 dark:text-slate-300' }}">
{{ $order->invoice->invoice_no ?? '---' }} <span class="{{ $invVoided ? 'line-through' : '' }}">{{ $order->invoice->invoice_no ?? '---' }}</span>
@if($invVoided)
<span class="px-1.5 py-0.5 rounded bg-slate-500/10 border border-slate-500/20 text-[9px] font-black text-slate-500 uppercase tracking-widest">{{ __('Voided') }}</span>
@endif
</p> </p>
</div> </div>
<div> <div>

View File

@ -76,6 +76,12 @@
</div> </div>
</div> </div>
</div> </div>
{{-- 選用插槽:作廢視窗用來放備註輸入框(其他用途留空) --}}
@if(trim($slot) !== '')
<div class="mt-6">
{{ $slot }}
</div>
@endif
<div class="mt-8 sm:mt-10 sm:flex sm:flex-row-reverse gap-3"> <div class="mt-8 sm:mt-10 sm:flex sm:flex-row-reverse gap-3">
<button type="button" @click="{{ $confirmAction }}" <button type="button" @click="{{ $confirmAction }}"
@if($isSubmittingVar) :disabled="{{ $isSubmittingVar }}" @endif @if($isSubmittingVar) :disabled="{{ $isSubmittingVar }}" @endif

View File

@ -148,6 +148,10 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
Route::post('/invoices/{invoice}/reissue', [App\Http\Controllers\Admin\SalesController::class, 'reissueInvoice'])->name('invoices.reissue'); Route::post('/invoices/{invoice}/reissue', [App\Http\Controllers\Admin\SalesController::class, 'reissueInvoice'])->name('invoices.reissue');
Route::post('/invoices/{invoice}/void', [App\Http\Controllers\Admin\SalesController::class, 'voidInvoice'])->name('invoices.void'); Route::post('/invoices/{invoice}/void', [App\Http\Controllers\Admin\SalesController::class, 'voidInvoice'])->name('invoices.void');
// 管理者備註(訂單/發票各自獨立)
Route::patch('/orders/{order}/remark', [App\Http\Controllers\Admin\SalesController::class, 'updateOrderRemark'])->name('orders.remark');
Route::patch('/invoices/{invoice}/remark', [App\Http\Controllers\Admin\SalesController::class, 'updateInvoiceRemark'])->name('invoices.remark');
// 取貨碼 // 取貨碼
Route::get('/pickup-codes', [App\Http\Controllers\Admin\SalesController::class, 'pickupCodes'])->name('pickup-codes'); Route::get('/pickup-codes', [App\Http\Controllers\Admin\SalesController::class, 'pickupCodes'])->name('pickup-codes');
Route::post('/pickup-codes', [App\Http\Controllers\Admin\SalesController::class, 'storePickupCode'])->name('pickup-codes.store'); Route::post('/pickup-codes', [App\Http\Controllers\Admin\SalesController::class, 'storePickupCode'])->name('pickup-codes.store');