feat: 實作銷售單匯入權限控管並全面精簡權限顯示名稱
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 59s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

This commit is contained in:
2026-02-09 15:04:08 +08:00
parent b6fe9ad9f3
commit 65eb1a1b64
10 changed files with 227 additions and 141 deletions

View File

@@ -191,6 +191,7 @@ class RoleController extends Controller
'production_orders' => '生產工單管理', 'production_orders' => '生產工單管理',
'utility_fees' => '公共事業費管理', 'utility_fees' => '公共事業費管理',
'accounting' => '會計報表', 'accounting' => '會計報表',
'sales_imports' => '銷售單匯入管理',
'users' => '使用者管理', 'users' => '使用者管理',
'roles' => '角色與權限', 'roles' => '角色與權限',
'system' => '系統管理', 'system' => '系統管理',

View File

@@ -4,10 +4,14 @@ use Illuminate\Support\Facades\Route;
use App\Modules\Sales\Controllers\SalesImportController; use App\Modules\Sales\Controllers\SalesImportController;
Route::middleware(['auth', 'verified'])->prefix('sales')->name('sales-imports.')->group(function () { Route::middleware(['auth', 'verified'])->prefix('sales')->name('sales-imports.')->group(function () {
Route::middleware('permission:sales_imports.view')->group(function () {
Route::get('/imports', [SalesImportController::class, 'index'])->name('index'); Route::get('/imports', [SalesImportController::class, 'index'])->name('index');
Route::get('/imports/create', [SalesImportController::class, 'create'])->name('create');
Route::post('/imports', [SalesImportController::class, 'store'])->name('store');
Route::get('/imports/{import}', [SalesImportController::class, 'show'])->name('show'); Route::get('/imports/{import}', [SalesImportController::class, 'show'])->name('show');
Route::post('/imports/{import}/confirm', [SalesImportController::class, 'confirm'])->name('confirm'); });
Route::delete('/imports/{import}', [SalesImportController::class, 'destroy'])->name('destroy');
Route::post('/imports', [SalesImportController::class, 'store'])->middleware('permission:sales_imports.create')->name('store');
Route::get('/imports/create', [SalesImportController::class, 'create'])->middleware('permission:sales_imports.create')->name('create');
Route::post('/imports/{import}/confirm', [SalesImportController::class, 'confirm'])->middleware('permission:sales_imports.confirm')->name('confirm');
Route::delete('/imports/{import}', [SalesImportController::class, 'destroy'])->middleware('permission:sales_imports.delete')->name('destroy');
}); });

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('permissions', function (Blueprint $table) {
$table->string('display_name')->nullable()->after('name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('permissions', function (Blueprint $table) {
$table->dropColumn('display_name');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('permissions', function (Blueprint $table) {
$table->string('display_name')->nullable()->after('name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('permissions', function (Blueprint $table) {
$table->dropColumn('display_name');
});
}
};

View File

@@ -20,108 +20,116 @@ class PermissionSeeder extends Seeder
// 建立權限 // 建立權限
$permissions = [ $permissions = [
// 產品管理 // 產品管理
'products.view', 'products.view' => '檢視',
'products.create', 'products.create' => '建立',
'products.edit', 'products.edit' => '編輯',
'products.delete', 'products.delete' => '刪除',
// 採購單管理 // 採購單管理
'purchase_orders.view', 'purchase_orders.view' => '檢視',
'purchase_orders.create', 'purchase_orders.create' => '建立',
'purchase_orders.edit', 'purchase_orders.edit' => '編輯',
'purchase_orders.delete', 'purchase_orders.delete' => '刪除',
'purchase_orders.approve', // 核准權限 'purchase_orders.approve' => '核准',
'purchase_orders.cancel', // 作廢權限(原取消) 'purchase_orders.cancel' => '作廢',
// 庫存管理 // 庫存管理
'inventory.view', 'inventory.view' => '檢視',
'inventory.view_cost', // 查看成本與價值 'inventory.view_cost' => '檢視成本',
'inventory.delete', 'inventory.delete' => '刪除',
// 庫存盤點 (Stock Counting) // 庫存盤點 (Stock Counting)
'inventory_count.view', 'inventory_count.view' => '檢視',
'inventory_count.create', 'inventory_count.create' => '建立',
'inventory_count.edit', 'inventory_count.edit' => '編輯',
'inventory_count.delete', 'inventory_count.delete' => '刪除',
// 庫存調整 (Stock Adjustment) // 庫存調整 (Stock Adjustment)
'inventory_adjust.view', 'inventory_adjust.view' => '檢視',
'inventory_adjust.create', 'inventory_adjust.create' => '建立',
'inventory_adjust.edit', 'inventory_adjust.edit' => '編輯',
'inventory_adjust.delete', 'inventory_adjust.delete' => '刪除',
// 庫存調撥 (Stock Transfer) // 庫存調撥 (Stock Transfer)
'inventory_transfer.view', 'inventory_transfer.view' => '檢視',
'inventory_transfer.create', 'inventory_transfer.create' => '建立',
'inventory_transfer.edit', 'inventory_transfer.edit' => '編輯',
'inventory_transfer.delete', 'inventory_transfer.delete' => '刪除',
// 進貨單管理 // 進貨單管理
'goods_receipts.view', 'goods_receipts.view' => '檢視',
'goods_receipts.create', 'goods_receipts.create' => '建立',
'goods_receipts.edit', 'goods_receipts.edit' => '編輯',
'goods_receipts.delete', 'goods_receipts.delete' => '刪除',
// 出貨單管理 (Delivery Notes / Shipping Orders) // 出貨單管理 (Delivery Notes / Shipping Orders)
'delivery_notes.view', 'delivery_notes.view' => '檢視',
'delivery_notes.create', 'delivery_notes.create' => '建立',
'delivery_notes.edit', 'delivery_notes.edit' => '編輯',
'delivery_notes.delete', 'delivery_notes.delete' => '刪除',
// 生產工單管理 // 生產工單管理
'production_orders.view', 'production_orders.view' => '檢視',
'production_orders.create', 'production_orders.create' => '建立',
'production_orders.edit', 'production_orders.edit' => '編輯',
'production_orders.delete', 'production_orders.delete' => '刪除',
// 配方管理 // 配方管理
'recipes.view', 'recipes.view' => '檢視',
'recipes.create', 'recipes.create' => '建立',
'recipes.edit', 'recipes.edit' => '編輯',
'recipes.delete', 'recipes.delete' => '刪除',
// 供應商管理 // 供應商管理
'vendors.view', 'vendors.view' => '檢視',
'vendors.create', 'vendors.create' => '建立',
'vendors.edit', 'vendors.edit' => '編輯',
'vendors.delete', 'vendors.delete' => '刪除',
// 倉庫管理 // 倉庫管理
'warehouses.view', 'warehouses.view' => '檢視',
'warehouses.create', 'warehouses.create' => '建立',
'warehouses.edit', 'warehouses.edit' => '編輯',
'warehouses.delete', 'warehouses.delete' => '刪除',
// 使用者管理 // 使用者管理
'users.view', 'users.view' => '檢視',
'users.create', 'users.create' => '建立',
'users.edit', 'users.edit' => '編輯',
'users.delete', 'users.delete' => '刪除',
'users.activate', // 啟用/停用使用者 'users.activate' => '狀態管理',
// 角色權限管理 // 角色權限管理
'roles.view', 'roles.view' => '檢視',
'roles.create', 'roles.create' => '建立',
'roles.edit', 'roles.edit' => '編輯',
'roles.delete', 'roles.delete' => '刪除',
// 系統日誌 // 系統日誌
'system.view_logs', 'system.view_logs' => '檢視日誌',
// 公共事業費管理 // 公共事業費管理
'utility_fees.view', 'utility_fees.view' => '檢視',
'utility_fees.create', 'utility_fees.create' => '建立',
'utility_fees.edit', 'utility_fees.edit' => '編輯',
'utility_fees.delete', 'utility_fees.delete' => '刪除',
// 會計報表 // 會計報表
'accounting.view', 'accounting.view' => '檢視',
'accounting.export', 'accounting.export' => '匯出',
// 銷售匯入管理
'sales_imports.view' => '檢視',
'sales_imports.create' => '建立',
'sales_imports.confirm' => '確認',
'sales_imports.delete' => '刪除',
]; ];
foreach ($permissions as $permission) { foreach ($permissions as $name => $displayName) {
Permission::firstOrCreate(['name' => $permission]); Permission::updateOrCreate(
['name' => $name],
['display_name' => $displayName]
);
} }
// 建立角色 // 建立角色
@@ -156,6 +164,7 @@ class PermissionSeeder extends Seeder
'system.view_logs', 'system.view_logs',
'utility_fees.view', 'utility_fees.create', 'utility_fees.edit', 'utility_fees.delete', 'utility_fees.view', 'utility_fees.create', 'utility_fees.edit', 'utility_fees.delete',
'accounting.view', 'accounting.export', 'accounting.view', 'accounting.export',
'sales_imports.view', 'sales_imports.create', 'sales_imports.confirm', 'sales_imports.delete',
]); ]);
// warehouse-manager 管理庫存與倉庫 // warehouse-manager 管理庫存與倉庫

Binary file not shown.

View File

@@ -165,14 +165,14 @@ export default function AuthenticatedLayout({
id: "sales-management", id: "sales-management",
label: "銷售管理", label: "銷售管理",
icon: <TrendingUp className="h-5 w-5" />, icon: <TrendingUp className="h-5 w-5" />,
// permission: ["sales.view_imports"], // Temporarily disabled for immediate visibility permission: "sales_imports.view",
children: [ children: [
{ {
id: "sales-import-list", id: "sales-import-list",
label: "銷售單匯入", label: "銷售單匯入",
icon: <FileUp className="h-4 w-4" />, icon: <FileUp className="h-4 w-4" />,
route: "/sales/imports", route: "/sales/imports",
// permission: "sales.view_imports", permission: "sales_imports.view",
}, },
], ],
}, },

View File

@@ -9,6 +9,7 @@ import { ChevronsDown, ChevronsUp } from "lucide-react";
export interface Permission { export interface Permission {
id: number; id: number;
name: string; name: string;
display_name?: string;
} }
export interface GroupedPermission { export interface GroupedPermission {
@@ -104,7 +105,7 @@ export default function PermissionSelector({ groupedPermissions, selectedPermiss
// Filter permissions that match // Filter permissions that match
const matchingPermissions = group.permissions.filter(p => { const matchingPermissions = group.permissions.filter(p => {
const translatedName = translateAction(p.name); const translatedName = p.display_name || translateAction(p.name);
return translatedName.includes(searchQuery) || return translatedName.includes(searchQuery) ||
p.name.toLowerCase().includes(searchQuery.toLowerCase()); p.name.toLowerCase().includes(searchQuery.toLowerCase());
}); });
@@ -306,7 +307,7 @@ function PermissionItem({ permission, selectedPermissions, onToggle, translate }
htmlFor={permission.name} 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" 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)} {permission.display_name || translate(permission.name)}
</label> </label>
<p className="text-[10px] text-gray-400 font-mono"> <p className="text-[10px] text-gray-400 font-mono">
{permission.name} {permission.name}

View File

@@ -27,6 +27,7 @@ import { format } from 'date-fns';
import Pagination from "@/Components/shared/Pagination"; import Pagination from "@/Components/shared/Pagination";
import { SearchableSelect } from "@/Components/ui/searchable-select"; import { SearchableSelect } from "@/Components/ui/searchable-select";
import { router } from "@inertiajs/react"; import { router } from "@inertiajs/react";
import { usePermission } from "@/hooks/usePermission";
interface ImportBatch { interface ImportBatch {
id: number; id: number;
@@ -51,6 +52,7 @@ interface Props {
} }
export default function SalesImportIndex({ batches, filters = {} }: Props) { export default function SalesImportIndex({ batches, filters = {} }: Props) {
const { can } = usePermission();
const [perPage, setPerPage] = useState(filters?.per_page?.toString() || "10"); const [perPage, setPerPage] = useState(filters?.per_page?.toString() || "10");
useEffect(() => { useEffect(() => {
@@ -88,12 +90,14 @@ export default function SalesImportIndex({ batches, filters = {} }: Props) {
</p> </p>
</div> </div>
{can('sales_imports.create') && (
<Link href={route('sales-imports.create')}> <Link href={route('sales-imports.create')}>
<Button className="button-filled-primary gap-2"> <Button className="button-filled-primary gap-2">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
)}
</div> </div>
<div className="bg-white rounded-lg border shadow-sm overflow-hidden"> <div className="bg-white rounded-lg border shadow-sm overflow-hidden">
@@ -142,7 +146,7 @@ export default function SalesImportIndex({ batches, filters = {} }: Props) {
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
{batch.status === 'pending' && ( {batch.status === 'pending' && can('sales_imports.delete') && (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="button-outlined-error" title="刪除"> <Button variant="outline" size="sm" className="button-outlined-error" title="刪除">

View File

@@ -26,6 +26,7 @@ import { Badge } from "@/Components/ui/badge";
import { ArrowLeft, CheckCircle, Trash2, Printer } from 'lucide-react'; import { ArrowLeft, CheckCircle, Trash2, Printer } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import Pagination from "@/Components/shared/Pagination"; import Pagination from "@/Components/shared/Pagination";
import { usePermission } from "@/hooks/usePermission";
interface ImportItem { interface ImportItem {
id: number; id: number;
@@ -76,6 +77,7 @@ interface Props {
} }
export default function SalesImportShow({ import: batch, items, filters = {} }: Props) { export default function SalesImportShow({ import: batch, items, filters = {} }: Props) {
const { can } = usePermission();
const { post, processing } = useForm({}); const { post, processing } = useForm({});
const [perPage, setPerPage] = useState(filters?.per_page?.toString() || "10"); const [perPage, setPerPage] = useState(filters?.per_page?.toString() || "10");
@@ -139,22 +141,23 @@ export default function SalesImportShow({ import: batch, items, filters = {} }:
{batch.status === 'confirmed' ? '已確認' : '待確認'} {batch.status === 'confirmed' ? '已確認' : '待確認'}
</Badge> </Badge>
{batch.status === 'pending' && ( {batch.status === 'pending' && (
<> <div className="flex gap-3">
{can('sales_imports.delete') && (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button
variant="outline" variant="outline"
className="gap-2 button-outlined-error" className="button-outlined-error gap-2 h-10 px-6"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle> <AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
#{batch.id}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@@ -168,36 +171,44 @@ export default function SalesImportShow({ import: batch, items, filters = {} }:
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
)}
{can('sales_imports.confirm') && (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button
className="button-filled-primary gap-2" className="button-filled-primary gap-2 h-10 px-8 shadow-md hover:shadow-lg transition-all"
disabled={processing} disabled={processing}
> >
<CheckCircle className="h-4 w-4" /> <CheckCircle className="h-4 w-4" />
{processing ? '處理中...' : '確認扣庫'} {processing ? '處理中...' : '確認扣庫並入帳'}
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent className="sm:max-w-md">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle> <AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription className="text-gray-600 leading-relaxed">
<div className="bg-amber-50 border-l-4 border-amber-400 p-4 mt-3 rounded">
<p className="text-amber-800 text-sm font-medium">
</p>
</div>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter className="mt-4">
<AlertDialogCancel></AlertDialogCancel> <AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className="button-filled-primary" className="bg-primary-main hover:bg-primary-dark text-white px-8"
onClick={handleConfirm} onClick={handleConfirm}
> >
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</> )}
</div>
)} )}
{batch.status === 'confirmed' && ( {batch.status === 'confirmed' && (
<Button variant="outline" className="gap-2 button-outlined-primary"> <Button variant="outline" className="gap-2 button-outlined-primary">