feat(procurement): 統一採購單按鈕樣式與術語更名為「作廢」,並加強權限控管
This commit is contained in:
@@ -447,11 +447,17 @@ class PurchaseOrderController extends Controller
|
|||||||
$taxAmount = !is_null($inputTax) ? $inputTax : round($totalAmount * 0.05, 2);
|
$taxAmount = !is_null($inputTax) ? $inputTax : round($totalAmount * 0.05, 2);
|
||||||
$grandTotal = $totalAmount + $taxAmount;
|
$grandTotal = $totalAmount + $taxAmount;
|
||||||
|
|
||||||
|
// 狀態轉移權限檢查
|
||||||
|
if (isset($validated['status']) && $order->status !== $validated['status']) {
|
||||||
|
if (!$order->canTransitionTo($validated['status'])) {
|
||||||
|
return back()->withErrors(['error' => '您沒有權限將狀態從 ' . $order->status . ' 變更為 ' . $validated['status']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
// 1. 填充屬性但暫不儲存以捕捉變更
|
// 1. 填充屬性但暫不儲存以捕捉變更
|
||||||
$order->fill([
|
$order->fill([
|
||||||
'vendor_id' => $validated['vendor_id'],
|
'vendor_id' => $validated['vendor_id'],
|
||||||
'warehouse_id' => $validated['warehouse_id'],
|
'warehouse_id' => $validated['warehouse_id'],
|
||||||
'order_date' => $validated['order_date'], // 新增
|
'order_date' => $validated['order_date'],
|
||||||
'expected_delivery_date' => $validated['expected_delivery_date'],
|
'expected_delivery_date' => $validated['expected_delivery_date'],
|
||||||
'total_amount' => $totalAmount,
|
'total_amount' => $totalAmount,
|
||||||
'tax_amount' => $taxAmount,
|
'tax_amount' => $taxAmount,
|
||||||
@@ -460,11 +466,22 @@ class PurchaseOrderController extends Controller
|
|||||||
'status' => $validated['status'],
|
'status' => $validated['status'],
|
||||||
'invoice_number' => $validated['invoice_number'] ?? null,
|
'invoice_number' => $validated['invoice_number'] ?? null,
|
||||||
'invoice_date' => $validated['invoice_date'] ?? null,
|
'invoice_date' => $validated['invoice_date'] ?? null,
|
||||||
'invoice_amount' => $validated['invoice_amount'] ?? null,
|
'invoice_amount' => (float) ($validated['invoice_amount'] ?? 0),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 捕捉變更屬性以進行手動記錄
|
// 捕捉變更屬性
|
||||||
$dirty = $order->getDirty();
|
$dirty = $order->getDirty();
|
||||||
|
|
||||||
|
// 嚴格權限檢查:如果修改了 status 以外的任何欄位,必須具備編輯權限
|
||||||
|
$otherChanges = array_diff(array_keys($dirty), ['status']);
|
||||||
|
if (!empty($otherChanges)) {
|
||||||
|
$canEdit = auth()->user()->hasRole('super-admin') || auth()->user()->can('purchase_orders.edit');
|
||||||
|
if (!$canEdit) {
|
||||||
|
throw new \Exception('您沒有權限修改採購單的基本內容,僅能執行流程異動(如:送審)。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 捕捉舊屬性以進行記錄
|
||||||
$oldAttributes = [];
|
$oldAttributes = [];
|
||||||
$newAttributes = [];
|
$newAttributes = [];
|
||||||
|
|
||||||
@@ -657,7 +674,7 @@ class PurchaseOrderController extends Controller
|
|||||||
|
|
||||||
DB::commit();
|
DB::commit();
|
||||||
|
|
||||||
return redirect()->route('purchase-orders.index')->with('success', '採購單已刪除');
|
return redirect()->route('purchase-orders.index')->with('success', '採購單已作廢');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
DB::rollBack();
|
DB::rollBack();
|
||||||
return back()->withErrors(['error' => '刪除失敗:' . $e->getMessage()]);
|
return back()->withErrors(['error' => '刪除失敗:' . $e->getMessage()]);
|
||||||
|
|||||||
@@ -70,4 +70,50 @@ class PurchaseOrder extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(PurchaseOrderItem::class);
|
return $this->hasMany(PurchaseOrderItem::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 檢查是否可以轉移至新狀態,並驗證權限。
|
||||||
|
*/
|
||||||
|
public function canTransitionTo(string $newStatus, $user = null): bool
|
||||||
|
{
|
||||||
|
$user = $user ?? auth()->user();
|
||||||
|
if (!$user) return false;
|
||||||
|
if ($user->hasRole('super-admin')) return true;
|
||||||
|
|
||||||
|
$currentStatus = $this->status;
|
||||||
|
|
||||||
|
// 定義合法的狀態轉移路徑與所需權限
|
||||||
|
$transitions = [
|
||||||
|
'draft' => [
|
||||||
|
'pending' => 'purchase_orders.view', // 基本檢視者即可送審
|
||||||
|
'cancelled' => 'purchase_orders.cancel',
|
||||||
|
],
|
||||||
|
'pending' => [
|
||||||
|
'approved' => 'purchase_orders.approve',
|
||||||
|
'draft' => 'purchase_orders.approve', // 退回草稿
|
||||||
|
'cancelled' => 'purchase_orders.cancel',
|
||||||
|
],
|
||||||
|
'approved' => [
|
||||||
|
'cancelled' => 'purchase_orders.cancel',
|
||||||
|
'partial' => null, // 系統自動轉移,不需手動權限點
|
||||||
|
],
|
||||||
|
'partial' => [
|
||||||
|
'completed' => null, // 系統自動轉移
|
||||||
|
'closed' => 'purchase_orders.approve', // 手動結案通常需要核准權限
|
||||||
|
'cancelled' => 'purchase_orders.cancel',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!isset($transitions[$currentStatus])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!array_key_exists($newStatus, $transitions[$currentStatus])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requiredPermission = $transitions[$currentStatus][$newStatus];
|
||||||
|
|
||||||
|
return $requiredPermission ? $user->can($requiredPermission) : true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::get('/purchase-orders/{id}', [PurchaseOrderController::class, 'show'])->name('purchase-orders.show');
|
Route::get('/purchase-orders/{id}', [PurchaseOrderController::class, 'show'])->name('purchase-orders.show');
|
||||||
|
|
||||||
Route::get('/purchase-orders/{id}/edit', [PurchaseOrderController::class, 'edit'])->middleware('permission:purchase_orders.edit')->name('purchase-orders.edit');
|
Route::get('/purchase-orders/{id}/edit', [PurchaseOrderController::class, 'edit'])->middleware('permission:purchase_orders.edit')->name('purchase-orders.edit');
|
||||||
Route::put('/purchase-orders/{id}', [PurchaseOrderController::class, 'update'])->middleware('permission:purchase_orders.edit')->name('purchase-orders.update');
|
Route::match(['PUT', 'PATCH'], '/purchase-orders/{id}', [PurchaseOrderController::class, 'update'])->name('purchase-orders.update');
|
||||||
Route::delete('/purchase-orders/{id}', [PurchaseOrderController::class, 'destroy'])->middleware('permission:purchase_orders.delete')->name('purchase-orders.destroy');
|
Route::delete('/purchase-orders/{id}', [PurchaseOrderController::class, 'destroy'])->middleware('permission:purchase_orders.delete')->name('purchase-orders.destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ class PermissionSeeder extends Seeder
|
|||||||
'purchase_orders.create',
|
'purchase_orders.create',
|
||||||
'purchase_orders.edit',
|
'purchase_orders.edit',
|
||||||
'purchase_orders.delete',
|
'purchase_orders.delete',
|
||||||
|
'purchase_orders.approve', // 核准權限
|
||||||
|
'purchase_orders.cancel', // 作廢權限(原取消)
|
||||||
|
|
||||||
|
|
||||||
// 庫存管理
|
// 庫存管理
|
||||||
@@ -138,7 +140,7 @@ class PermissionSeeder extends Seeder
|
|||||||
$admin->givePermissionTo([
|
$admin->givePermissionTo([
|
||||||
'products.view', 'products.create', 'products.edit', 'products.delete',
|
'products.view', 'products.create', 'products.edit', 'products.delete',
|
||||||
'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit',
|
'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit',
|
||||||
'purchase_orders.delete',
|
'purchase_orders.delete', 'purchase_orders.approve', 'purchase_orders.cancel',
|
||||||
'inventory.view', 'inventory.view_cost', 'inventory.delete',
|
'inventory.view', 'inventory.view_cost', 'inventory.delete',
|
||||||
'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete',
|
'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete',
|
||||||
'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete',
|
'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete',
|
||||||
|
|||||||
@@ -46,28 +46,32 @@ export function PurchaseOrderActions({
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Can permission="purchase_orders.edit">
|
<Can permission="purchase_orders.edit">
|
||||||
<Link href={`/purchase-orders/${order.id}/edit`}>
|
{order.status === 'draft' && (
|
||||||
|
<Link href={`/purchase-orders/${order.id}/edit`}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="button-outlined-primary"
|
||||||
|
title="編輯"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Can>
|
||||||
|
<Can permission="purchase_orders.delete">
|
||||||
|
{order.status === 'draft' && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="button-outlined-primary"
|
className="button-outlined-error"
|
||||||
title="編輯"
|
title="刪除"
|
||||||
|
onClick={() => setShowDeleteDialog(true)}
|
||||||
|
disabled={processing}
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
)}
|
||||||
</Can>
|
|
||||||
<Can permission="purchase_orders.delete">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="button-outlined-error"
|
|
||||||
title="刪除"
|
|
||||||
onClick={() => setShowDeleteDialog(true)}
|
|
||||||
disabled={processing}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const buttonVariants = cva(
|
|||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
xl: "h-14 px-12 text-lg font-bold rounded-xl shadow-lg transition-all hover:scale-[1.02] active:scale-[0.98] gap-2",
|
||||||
icon: "size-9 rounded-md",
|
icon: "size-9 rounded-md",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export default function PermissionSelector({ groupedPermissions, selectedPermiss
|
|||||||
'view_cost': '檢視成本',
|
'view_cost': '檢視成本',
|
||||||
'view_logs': '檢視日誌',
|
'view_logs': '檢視日誌',
|
||||||
'activate': '啟用/停用',
|
'activate': '啟用/停用',
|
||||||
|
'approve': '核准/退回',
|
||||||
|
'cancel': '取消',
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionText = map[action] || action;
|
const actionText = map[action] || action;
|
||||||
@@ -203,6 +205,11 @@ export default function PermissionSelector({ groupedPermissions, selectedPermiss
|
|||||||
const totalCount = group.permissions.length;
|
const totalCount = group.permissions.length;
|
||||||
const isAllSelected = selectedCount === totalCount;
|
const isAllSelected = selectedCount === totalCount;
|
||||||
|
|
||||||
|
// 將權限分為「基本操作」與「狀態/進階操作」
|
||||||
|
const statusActions = ['approve', 'cancel', 'complete', 'activate'];
|
||||||
|
const normalPermissions = group.permissions.filter(p => !statusActions.includes(p.name.split('.').pop() || ''));
|
||||||
|
const specialPermissions = group.permissions.filter(p => statusActions.includes(p.name.split('.').pop() || ''));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItem
|
<AccordionItem
|
||||||
key={group.key}
|
key={group.key}
|
||||||
@@ -210,18 +217,11 @@ export default function PermissionSelector({ groupedPermissions, selectedPermiss
|
|||||||
className="bg-white border rounded-lg px-2 data-[state=open]:bg-gray-50/50 last:border-b"
|
className="bg-white border rounded-lg px-2 data-[state=open]:bg-gray-50/50 last:border-b"
|
||||||
>
|
>
|
||||||
<div className="flex items-center w-full">
|
<div className="flex items-center w-full">
|
||||||
{/* Group Selection Checkbox - Moved outside trigger to avoid bubbling issues, positioned left */}
|
|
||||||
<div className="flex items-center pl-2 pr-1">
|
<div className="flex items-center pl-2 pr-1">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`group-select-${group.key}`}
|
id={`group-select-${group.key}`}
|
||||||
checked={isAllSelected}
|
checked={isAllSelected}
|
||||||
onCheckedChange={() => {
|
onCheckedChange={() => toggleGroup(group.permissions)}
|
||||||
// Stop propagation to prevent accordion from toggling
|
|
||||||
// This is implicitly handled by the checkbox being a sibling,
|
|
||||||
// but if it were a child of AccordionTrigger, stopPropagation would be needed.
|
|
||||||
// For clarity, we can add it here if needed, but the current structure makes it unnecessary.
|
|
||||||
toggleGroup(group.permissions);
|
|
||||||
}}
|
|
||||||
className="data-[state=checked]:bg-primary-main"
|
className="data-[state=checked]:bg-primary-main"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,30 +241,47 @@ export default function PermissionSelector({ groupedPermissions, selectedPermiss
|
|||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
</div>
|
</div>
|
||||||
<AccordionContent className="px-2 pb-4">
|
<AccordionContent className="px-2 pb-4">
|
||||||
<div className="pl-10 space-y-3 pt-1">
|
<div className="pl-10 space-y-6 pt-1">
|
||||||
{/* Permissions Grid */}
|
{/* 基本操作 */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-y-3 gap-x-4">
|
{normalPermissions.length > 0 && (
|
||||||
{group.permissions.map((permission) => (
|
<div className="space-y-3">
|
||||||
<div key={permission.id} className="flex items-start space-x-3">
|
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||||
<Checkbox
|
基本功能權限
|
||||||
id={permission.name}
|
|
||||||
checked={selectedPermissions.includes(permission.name)}
|
|
||||||
onCheckedChange={() => togglePermission(permission.name)}
|
|
||||||
/>
|
|
||||||
<div className="grid gap-1.5 leading-none">
|
|
||||||
<label
|
|
||||||
htmlFor={permission.name}
|
|
||||||
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer text-gray-700 hover:text-primary-main transition-colors"
|
|
||||||
>
|
|
||||||
{translateAction(permission.name)}
|
|
||||||
</label>
|
|
||||||
<p className="text-[10px] text-gray-400 font-mono">
|
|
||||||
{permission.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-y-3 gap-x-4">
|
||||||
</div>
|
{normalPermissions.map((permission) => (
|
||||||
|
<PermissionItem
|
||||||
|
key={permission.id}
|
||||||
|
permission={permission}
|
||||||
|
selectedPermissions={selectedPermissions}
|
||||||
|
onToggle={togglePermission}
|
||||||
|
translate={translateAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 狀態操作/進階權限 */}
|
||||||
|
{specialPermissions.length > 0 && (
|
||||||
|
<div className="space-y-3 pt-2 border-t border-gray-100 italic">
|
||||||
|
<div className="text-xs font-semibold text-amber-600/70 uppercase tracking-wider flex items-center gap-1">
|
||||||
|
<span className="w-1 h-3 bg-amber-500 rounded-full" />
|
||||||
|
單據狀態與進階操作權限
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-y-3 gap-x-4">
|
||||||
|
{specialPermissions.map((permission) => (
|
||||||
|
<PermissionItem
|
||||||
|
key={permission.id}
|
||||||
|
permission={permission}
|
||||||
|
selectedPermissions={selectedPermissions}
|
||||||
|
onToggle={togglePermission}
|
||||||
|
translate={translateAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
@@ -275,3 +292,26 @@ export default function PermissionSelector({ groupedPermissions, selectedPermiss
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PermissionItem({ permission, selectedPermissions, onToggle, translate }: any) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id={permission.name}
|
||||||
|
checked={selectedPermissions.includes(permission.name)}
|
||||||
|
onCheckedChange={() => onToggle(permission.name)}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-1.5 leading-none">
|
||||||
|
<label
|
||||||
|
htmlFor={permission.name}
|
||||||
|
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer text-gray-700 hover:text-primary-main transition-colors"
|
||||||
|
>
|
||||||
|
{translate(permission.name)}
|
||||||
|
</label>
|
||||||
|
<p className="text-[10px] text-gray-400 font-mono">
|
||||||
|
{permission.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Textarea } from "@/Components/ui/textarea";
|
|||||||
import { Alert, AlertDescription } from "@/Components/ui/alert";
|
import { Alert, AlertDescription } from "@/Components/ui/alert";
|
||||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
import { Head, Link, router } from "@inertiajs/react";
|
import { Head, Link, router, usePage } from "@inertiajs/react";
|
||||||
import { PurchaseOrderItemsTable } from "@/Components/PurchaseOrder/PurchaseOrderItemsTable";
|
import { PurchaseOrderItemsTable } from "@/Components/PurchaseOrder/PurchaseOrderItemsTable";
|
||||||
import type { PurchaseOrder, Supplier } from "@/types/purchase-order";
|
import type { PurchaseOrder, Supplier } from "@/types/purchase-order";
|
||||||
import type { Warehouse } from "@/types/requester";
|
import type { Warehouse } from "@/types/requester";
|
||||||
@@ -21,8 +21,9 @@ import {
|
|||||||
getTodayDate,
|
getTodayDate,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
} from "@/utils/purchase-order";
|
} from "@/utils/purchase-order";
|
||||||
import { STATUS_OPTIONS } from "@/constants/purchase-order";
|
import { STATUS_CONFIG, MANUAL_STATUS_OPTIONS } from "@/constants/purchase-order";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { Can } from "@/Components/Permission/Can";
|
||||||
import { getCreateBreadcrumbs, getEditBreadcrumbs } from "@/utils/breadcrumb";
|
import { getCreateBreadcrumbs, getEditBreadcrumbs } from "@/utils/breadcrumb";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -36,6 +37,17 @@ export default function CreatePurchaseOrder({
|
|||||||
suppliers,
|
suppliers,
|
||||||
warehouses,
|
warehouses,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { auth } = usePage<any>().props;
|
||||||
|
const permissions = auth.user?.permissions || [];
|
||||||
|
const isSuperAdmin = auth.user?.roles?.some((r: any) => r.name === 'super-admin');
|
||||||
|
|
||||||
|
const canApprove = isSuperAdmin || permissions.includes('purchase_orders.approve');
|
||||||
|
const canCreate = isSuperAdmin || permissions.includes('purchase_orders.create');
|
||||||
|
const canEdit = isSuperAdmin || permissions.includes('purchase_orders.edit');
|
||||||
|
|
||||||
|
// 儲存權限判斷
|
||||||
|
const canSave = order ? canEdit : canCreate;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
supplierId,
|
supplierId,
|
||||||
expectedDate,
|
expectedDate,
|
||||||
@@ -273,12 +285,26 @@ export default function CreatePurchaseOrder({
|
|||||||
{order && (
|
{order && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-sm font-bold text-gray-700">狀態</label>
|
<label className="text-sm font-bold text-gray-700">狀態</label>
|
||||||
<SearchableSelect
|
<Can permission="purchase_orders.approve">
|
||||||
value={status}
|
<SearchableSelect
|
||||||
onValueChange={(v) => setStatus(v as any)}
|
value={status}
|
||||||
options={STATUS_OPTIONS.map((opt) => ({ label: opt.label, value: opt.value }))}
|
onValueChange={(v) => setStatus(v as any)}
|
||||||
placeholder="選擇狀態"
|
options={MANUAL_STATUS_OPTIONS}
|
||||||
/>
|
placeholder="選擇狀態"
|
||||||
|
/>
|
||||||
|
</Can>
|
||||||
|
<div className="!mt-1">
|
||||||
|
{!canApprove && (
|
||||||
|
<>
|
||||||
|
<div className="px-3 py-2 bg-gray-50 border rounded-md text-sm text-gray-600">
|
||||||
|
{STATUS_CONFIG[status as keyof typeof STATUS_CONFIG]?.label || status}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-400 mt-1 italic">
|
||||||
|
* 您沒有權限在此修改狀態,請使用詳情頁面的動作按鈕進行操作。
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -454,9 +480,11 @@ export default function CreatePurchaseOrder({
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="xl"
|
||||||
className="bg-primary hover:bg-primary/90 text-white px-12 h-14 rounded-xl shadow-lg shadow-primary/20 text-lg font-bold transition-all hover:scale-[1.02] active:scale-[0.98]"
|
className="bg-primary hover:bg-primary/90 text-white shadow-primary/20"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
|
disabled={!canSave}
|
||||||
|
title={!canSave ? "您沒有執行此動作的權限" : ""}
|
||||||
>
|
>
|
||||||
{order ? "更新採購單" : "確認發布採購單"}
|
{order ? "更新採購單" : "確認發布採購單"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/Components/ui/select";
|
} from "@/Components/ui/select";
|
||||||
import { STATUS_OPTIONS } from "@/constants/purchase-order";
|
import { MANUAL_STATUS_OPTIONS } from "@/constants/purchase-order";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
orders: {
|
orders: {
|
||||||
@@ -177,7 +177,7 @@ export default function PurchaseOrderIndex({ orders, filters, warehouses }: Prop
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">全部狀態</SelectItem>
|
<SelectItem value="all">全部狀態</SelectItem>
|
||||||
{STATUS_OPTIONS.map((option) => (
|
{MANUAL_STATUS_OPTIONS.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
* 查看採購單詳情頁面
|
* 查看採購單詳情頁面
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ArrowLeft, ShoppingCart } from "lucide-react";
|
import { ArrowLeft, ShoppingCart, Send, CheckCircle, XCircle, RotateCcw } from "lucide-react";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
import { Head, Link } from "@inertiajs/react";
|
import { Head, Link, useForm, usePage, router } from "@inertiajs/react";
|
||||||
import { StatusProgressBar } from "@/Components/PurchaseOrder/StatusProgressBar";
|
import { StatusProgressBar } from "@/Components/PurchaseOrder/StatusProgressBar";
|
||||||
import PurchaseOrderStatusBadge from "@/Components/PurchaseOrder/PurchaseOrderStatusBadge";
|
import PurchaseOrderStatusBadge from "@/Components/PurchaseOrder/PurchaseOrderStatusBadge";
|
||||||
import CopyButton from "@/Components/shared/CopyButton";
|
import CopyButton from "@/Components/shared/CopyButton";
|
||||||
@@ -13,6 +13,8 @@ import { PurchaseOrderItemsTable } from "@/Components/PurchaseOrder/PurchaseOrde
|
|||||||
import type { PurchaseOrder } from "@/types/purchase-order";
|
import type { PurchaseOrder } from "@/types/purchase-order";
|
||||||
import { formatCurrency, formatDateTime } from "@/utils/format";
|
import { formatCurrency, formatDateTime } from "@/utils/format";
|
||||||
import { getShowBreadcrumbs } from "@/utils/breadcrumb";
|
import { getShowBreadcrumbs } from "@/utils/breadcrumb";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { PageProps } from "@/types/global";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
order: PurchaseOrder;
|
order: PurchaseOrder;
|
||||||
@@ -44,11 +46,6 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
|
|||||||
<p className="text-gray-500 mt-1">單號:{order.poNumber}</p>
|
<p className="text-gray-500 mt-1">單號:{order.poNumber}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href={`/purchase-orders/${order.id}/edit`}>
|
|
||||||
<Button variant="outline" className="button-outlined-primary">
|
|
||||||
編輯採購單
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<PurchaseOrderStatusBadge status={order.status} />
|
<PurchaseOrderStatusBadge status={order.status} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,9 +168,111 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按鈕 (底部) */}
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<PurchaseOrderActions order={order} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PurchaseOrderActions({ order }: { order: PurchaseOrder }) {
|
||||||
|
const { auth } = usePage<PageProps>().props;
|
||||||
|
const permissions = auth.user?.permissions || [];
|
||||||
|
|
||||||
|
const { processing } = useForm({
|
||||||
|
status: order.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUpdateStatus = (newStatus: string, actionName: string) => {
|
||||||
|
const formData = {
|
||||||
|
vendor_id: order.supplierId,
|
||||||
|
warehouse_id: order.warehouse_id,
|
||||||
|
order_date: order.orderDate,
|
||||||
|
expected_delivery_date: order.expectedDate ? new Date(order.expectedDate).toISOString().split('T')[0] : null,
|
||||||
|
items: order.items.map((item: any) => ({
|
||||||
|
productId: item.productId,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unitId: item.unitId,
|
||||||
|
subtotal: item.subtotal,
|
||||||
|
})),
|
||||||
|
tax_amount: order.taxAmount,
|
||||||
|
status: newStatus,
|
||||||
|
remark: order.remark || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
router.patch(route('purchase-orders.update', order.id), formData, {
|
||||||
|
onSuccess: () => toast.success(`採購單已${actionName === '取消' ? '作廢' : actionName}`),
|
||||||
|
onError: (errors: any) => {
|
||||||
|
console.error("Status Update Error:", errors);
|
||||||
|
toast.error(errors.error || "操作失敗");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 權限判斷 (包含超級管理員檢查)
|
||||||
|
const isSuperAdmin = auth.user?.roles?.some((r: any) => r.name === 'super-admin');
|
||||||
|
const canApprove = isSuperAdmin || permissions.includes('purchase_orders.approve');
|
||||||
|
const canCancel = isSuperAdmin || permissions.includes('purchase_orders.cancel');
|
||||||
|
const canEdit = isSuperAdmin || permissions.includes('purchase_orders.edit');
|
||||||
|
const canView = isSuperAdmin || permissions.includes('purchase_orders.view');
|
||||||
|
|
||||||
|
// 送審權限:擁有檢視或編輯權限的人都可以送審
|
||||||
|
const canSubmit = canEdit || canView;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{['draft', 'pending', 'approved'].includes(order.status) && canCancel && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleUpdateStatus('cancelled', '作廢')}
|
||||||
|
disabled={processing}
|
||||||
|
variant="outline"
|
||||||
|
size="xl"
|
||||||
|
className="button-outlined-error shadow-red-200/20 border-red-600 text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<XCircle className="h-5 w-5" /> 作廢
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{order.status === 'pending' && canApprove && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleUpdateStatus('draft', '退回')}
|
||||||
|
disabled={processing}
|
||||||
|
variant="outline"
|
||||||
|
size="xl"
|
||||||
|
className="button-outlined-warning shadow-amber-200/20"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-5 w-5" /> 退回
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{order.status === 'draft' && canSubmit && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleUpdateStatus('pending', '送出審核')}
|
||||||
|
disabled={processing}
|
||||||
|
size="xl"
|
||||||
|
className="button-filled-primary shadow-primary/20"
|
||||||
|
>
|
||||||
|
<Send className="h-5 w-5" /> 送審
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{order.status === 'pending' && canApprove && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleUpdateStatus('approved', '核准')}
|
||||||
|
disabled={processing}
|
||||||
|
size="xl"
|
||||||
|
className="button-filled-primary shadow-primary/20"
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-5 w-5" /> 核准
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ export const STATUS_CONFIG: Record<
|
|||||||
cancelled: { label: "已作廢", variant: "destructive" },
|
cancelled: { label: "已作廢", variant: "destructive" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 供下單/編輯頁面使用的手動狀態選項 (排除系統自動狀態)
|
||||||
|
export const MANUAL_STATUS_OPTIONS = [
|
||||||
|
{ value: 'draft', label: '草稿' },
|
||||||
|
{ value: 'pending', label: '送審中' },
|
||||||
|
{ value: 'approved', label: '已核准' },
|
||||||
|
{ value: 'cancelled', label: '已作廢' },
|
||||||
|
];
|
||||||
|
|
||||||
export const STATUS_OPTIONS = Object.entries(STATUS_CONFIG).map(([value, config]) => ({
|
export const STATUS_OPTIONS = Object.entries(STATUS_CONFIG).map(([value, config]) => ({
|
||||||
value,
|
value,
|
||||||
label: (config as any).label,
|
label: (config as any).label,
|
||||||
|
|||||||
Reference in New Issue
Block a user