Compare commits

...

6 Commits

31 changed files with 927 additions and 34 deletions

View File

@@ -331,10 +331,10 @@
4. **Command generate schedule hàng loạt:** `php artisan contracts:generate-schedules` cho 139 hợp đồng đã import 4. **Command generate schedule hàng loạt:** `php artisan contracts:generate-schedules` cho 139 hợp đồng đã import
### Giai đoạn 2: Module Bổ sung (Ưu tiên TRUNG BÌNH) ### Giai đoạn 2: Module Bổ sung (Ưu tiên TRUNG BÌNH)
5. **PaymentFine Resource:** Quản lý tiền phạt chậm thanh toán 5. **PaymentFine Resource:** Quản lý tiền phạt chậm thanh toán
6. **Appendix Resource:** Quản lý phụ lục hợp đồng 6. **Appendix Resource:** Quản lý phụ lục hợp đồng
7. **Settlement Resource:** Quản lý thanh lý hợp đồng 7. **Settlement Resource:** Quản lý thanh lý hợp đồng
8. **Discount Engine:** Tính toán tự động chiết khấu từ `discount_details` vào giá trị hợp đồng 8. **Discount Engine:** Tính toán tự động chiết khấu từ `discount_details` vào giá trị hợp đồng
### Giai đoạn 3: Báo cáo & Tối ưu (Ưu tiên THẤP) ### Giai đoạn 3: Báo cáo & Tối ưu (Ưu tiên THẤP)
9. **Dashboard Tài chính:** Tổng doanh thu, dòng tiền dự kiến, công nợ phải thu ✅ 9. **Dashboard Tài chính:** Tổng doanh thu, dòng tiền dự kiến, công nợ phải thu ✅
@@ -342,6 +342,19 @@
11. **Export Excel:** Xuất báo cáo công nợ khách hàng ✅ 11. **Export Excel:** Xuất báo cáo công nợ khách hàng ✅
12. **Notification:** Cảnh báo đợt thanh toán sắp đến hạn ✅ 12. **Notification:** Cảnh báo đợt thanh toán sắp đến hạn ✅
### Giai đoạn 4: An toàn & Audit (Đang làm)
13. **Soft Delete:** Contract, Payment, Customer + Restore/ForceDelete UI ✅
14. **Payment.collected_by:** Ghi nhận ngườ thu tiền ✅
### Giai đoạn 5: Phân quyền (Thiết kế xong, chờ triển khai)
15. **Permission System (Hướng B - Tự viết, không Spatie):**
- `role_templates`: Mẫu nhóm với permissions JSONB
- `users`: role_template_id + extra_permissions + excluded_permissions
- Session cache effective permissions (tính 1 lần/login)
- `php artisan permissions:sync` thủ công khi thêm module
- Action mới mặc định TẮT, admin bật thủ công
- Xem chi tiết: `WORKFLOW.md` Phần VIII
--- ---
## 7. CÂU LỆNH THƯỜNG DÙNG ## 7. CÂU LỆNH THƯỜNG DÙNG

View File

@@ -55,3 +55,58 @@ DB_HOST=127.0.0.1 ./vendor/bin/pest
--- ---
*Commit ngay lập tức trước khi tắt máy!* *Commit ngay lập tức trước khi tắt máy!*
---
## PHIÊN TIẾP THEO - PHÂN QUYỀN (CÒN DỞ)
### Đã có:
- [x] Migration: permission_modules, role_templates, users columns
- [x] Models: PermissionModule, RoleTemplate
- [x] Command: `php artisan permissions:sync`
- [x] User Model: getEffectivePermissions(), hasEffectivePermission(), can() override
- [x] RoleTemplateResource: Form + Table + Pages (UI tạo mẫu nhóm)
- [x] UserResource: Form + Table + Pages (UI gán quyền user)
- [x] permissionActions trong 10 Resource
### CHƯA CÓ (Ưu tiên):
1. [ ] Áp dụng can() checks vào TẤT CẢ Resource
- Chưa có canViewAny(), canCreate(), canEdit(), canDelete()... trong Resource
- Cần thêm vào hoặc tạo base trait để auto check
- Hiện tại tất cả user vẫn full quyền!
2. [ ] Tạo seeder/sample data cho role_templates
- Admin: full quyền
- Sales: contracts CRUD, customers CRUD, products view
- Kế toán: payments CRUD, contracts view, reports view
3. [ ] Test User::can() override hoạt động đúng
- Login → tính effective permissions → lưu session
- Logout → xóa session
- can('contracts.create') → true/false đúng
### Cách áp dụng can() vào Resource (gợi ý):
```php
// Trong mỗi Resource class
public static function canViewAny(): bool
{
return auth()->user()?->can('contracts.view') ?? false;
}
public static function canCreate(): bool
{
return auth()->user()?->can('contracts.create') ?? false;
}
public static function canEdit($record): bool
{
return auth()->user()?->can('contracts.update') ?? false;
}
public static function canDelete($record): bool
{
return auth()->user()?->can('contracts.delete') ?? false;
}
```
### Lưu ý:
- Không chạy `php artisan permissions:sync` tự động (chạy tay khi thêm module)
- Action mới mặc định TẮT
- Layout RoleTemplateForm đã fix full width

View File

@@ -452,7 +452,7 @@ User 1───* Notification (MorphMany)
| # | Vấn đề | Mức độ | Ghi chú | | # | Vấn đề | Mức độ | Ghi chú |
|---|--------|--------|---------| |---|--------|--------|---------|
| 1 | **~~Soft Delete~~ ✅** | 🟢 Đã xong | Contract, Payment, Customer có SoftDeletes + Restore/ForceDelete UI | | 1 | **~~Soft Delete~~ ✅** | 🟢 Đã xong | Contract, Payment, Customer có SoftDeletes + Restore/ForceDelete UI |
| 2 | **Phân quyền** | 🟡 Trung bình | Chỉ 1 loại user. Ai cũng xóa/sửa được mọi thứ | | 2 | **Phân quyền** | 🟡 Đang thiết kế | Kiến trúc Hướng B (Tự viết, không Spatie). Xem chi tiết Phần VIII |
| 3 | **~~Payment.collected_by~~ ✅** | 🟢 Đã xong | Đã thêm collected_by (FK → users) + hiển thị Form/Table | | 3 | **~~Payment.collected_by~~ ✅** | 🟢 Đã xong | Đã thêm collected_by (FK → users) + hiển thị Form/Table |
| 4 | **Sổ quỹ** | 🟡 Trung bình | Thu tiền nhưng không ghi vào quỹ TM/NH. Không đối soát được | | 4 | **Sổ quỹ** | 🟡 Trung bình | Thu tiền nhưng không ghi vào quỹ TM/NH. Không đối soát được |
| 5 | **CRM Pipeline** | 🟢 Thấp | Chưa quản lý Lead/Khách hàng tiềm năng | | 5 | **CRM Pipeline** | 🟢 Thấp | Chưa quản lý Lead/Khách hàng tiềm năng |
@@ -460,4 +460,107 @@ User 1───* Notification (MorphMany)
--- ---
---
## VIII. PERMISSION SYSTEM DESIGN (Hướng B - Tự viết, không Spatie)
> **Quyết định kiến trúc:** Không dùng Spatie Permission. Tự viết module phân quyền đơn giản, đủ dùng, kiểm soát 100%.
> **Lý do:** Tránh config phức tạp, tránh xung đột UUID/BIGINT, phù hợp 5-10 role, không cần advanced features.
### Kiến trúc 3 lớp
```
LAYER 1: ROLE TEMPLATE (Mẫu nhóm)
role_templates.id(UUID) | name | description | permissions(JSONB) | is_active
permissions lưu dạng:
{
"contracts": ["view","create","update","delete","restore","forceDelete","export"],
"payments": ["view","create","update","delete"],
"customers": ["view","create","update","delete"]
}
LAYER 2: USER (Kế thừa + Override)
users.role_template_id(UUID) → nullable
users.extra_permissions(JSONB) → thêm quyền vượt cấp
users.excluded_permissions(JSONB) → bớt quyền so với mẫu
LAYER 3: EFFECTIVE PERMISSIONS (Tính toán động, cache trong Session)
effective = template_permissions + extra_permissions - excluded_permissions
Tính 1 lần khi user login → lưu vào session()->get('user.{id}.permissions')
Tự động xóa khi logout
Có thể xóa thủ công: auth()->user()->clearPermissionCache()
```
### Quy tắc khai báo Permission trong Resource
Mỗi Resource khai báo:
```php
class ContractResource extends Resource
{
protected static array $permissionActions = [
'view', 'create', 'update', 'delete',
'restore', 'forceDelete', 'export'
];
protected static string $permissionLabel = 'Hợp đồng';
}
```
Naming: `{snake_case_resource_name}.{action}` (ví dụ: `contracts.create`, `payments.delete`)
### Command đồng bộ (THỦ CÔNG - KHÔNG TỰ ĐỘNG)
```bash
php artisan permissions:sync
```
**Khi nào chạy:**
- Khi thêm Resource mới
- Khi thêm/bớt `$permissionActions` trong Resource
- KHÔNG chạy tự động trong composer post-autoload-dump
**Command làm gì:**
1. Quét tất cả Filament Resources, lấy `$permissionActions` + `$permissionLabel`
2. Lưu vào bảng `permission_modules` (module, label, actions)
3. Action mới mặc định **TẮT** cho tất cả role hiện tại (an toàn)
4. Admin phải vào bật thủ công sau
### Luồng thêm module/action mới
**Step 1: Dev code**
- Tạo Resource mới / thêm action vào Resource
- Thêm `$permissionActions` vào Resource class
**Step 2: Dev chạy command**
- `php artisan permissions:sync`
**Step 3: Admin cấu hình**
- Vào Role Template UI → thấy module/action mới (badge "Mới")
- Tick bật quyền cho các nhóm cần dùng
### Các hàm kiểm tra quyền
```php
// User Model
public function getEffectivePermissions(): array;
public function hasEffectivePermission(string $permission): bool;
public function clearPermissionCache(): void;
// Trong Resource
public static function canCreate(): bool
{
return auth()->user()->can('contracts.create');
}
```
### UI quản lý (Filament Resources)
| Resource | Chức năng |
|----------|-----------|
| **RoleTemplateResource** | CRUD mẫu nhóm. Form chọn module → tick actions. CheckboxList per module |
| **UserPermissionPage** | Chọn user → hiện role template đang dùng → thêm/bớt quyền chi tiết → preview effective permissions |
---
*File này cần được cập nhật mỗi khi có thay đổi lớn trong kiến trúc hoặc nghiệp vụ.* *File này cần được cập nhật mỗi khi có thay đổi lớn trong kiến trúc hoặc nghiệp vụ.*

View File

@@ -0,0 +1,180 @@
<?php
namespace App\Console\Commands;
use App\Models\PermissionModule;
use App\Models\RoleTemplate;
use Illuminate\Console\Command;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Str;
use ReflectionClass;
use Symfony\Component\Finder\Finder;
class SyncPermissions extends Command
{
protected $signature = 'permissions:sync {--dry-run : Chỉ liệt kê, không lưu}';
protected $description = 'Quét Filament Resources và đồng bộ permission modules. Action mới mặc định TẮT.';
public function handle(): int
{
$dryRun = $this->option('dry-run');
$this->info('Đang quét Filament Resources...');
$resourcesPath = app_path('Filament/Resources');
if (! is_dir($resourcesPath)) {
$this->error('Không tìm thấy thư mục Filament/Resources');
return self::FAILURE;
}
$modules = [];
$finder = new Finder();
$finder->files()->in($resourcesPath)->name('*Resource.php');
foreach ($finder as $file) {
$class = $this->getClassFromFile($file->getRealPath());
if (! $class || ! class_exists($class)) {
continue;
}
$ref = new ReflectionClass($class);
if (! $ref->hasProperty('permissionActions')) {
continue;
}
$prop = $ref->getProperty('permissionActions');
$prop->setAccessible(true);
$actions = $prop->getDefaultValue();
$label = 'Resource';
if ($ref->hasProperty('pluralModelLabel')) {
$labelProp = $ref->getProperty('pluralModelLabel');
$labelProp->setAccessible(true);
$label = $labelProp->getDefaultValue() ?? $label;
}
$moduleName = Str::snake(class_basename($class));
$moduleName = str_replace('_resource', '', $moduleName);
$modules[] = [
'module' => $moduleName,
'label' => $label,
'actions' => $actions ?? [],
];
}
if (empty($modules)) {
$this->warn('Không tìm thấy Resource nào có $permissionActions.');
return self::SUCCESS;
}
$this->info('Tìm thấy ' . count($modules) . ' module(s):');
foreach ($modules as $m) {
$this->line(" - {$m['module']}: " . implode(', ', $m['actions']));
}
if ($dryRun) {
$this->info('Dry-run: Không lưu thay đổi.');
return self::SUCCESS;
}
foreach ($modules as $m) {
$existing = PermissionModule::where('module', $m['module'])->first();
if ($existing) {
$oldActions = $existing->actions ?? [];
$newActions = $m['actions'];
if ($oldActions === $newActions) {
continue;
}
$added = array_diff($newActions, $oldActions);
$removed = array_diff($oldActions, $newActions);
$existing->update([
'label' => $m['label'],
'actions' => $newActions,
]);
if (! empty($added)) {
$this->warn(" [{$m['module']}] Thêm actions: " . implode(', ', $added));
// Action mới mặc định TẮT cho tất cả role
$this->disableNewActionsForAllRoles($m['module'], $added);
}
if (! empty($removed)) {
$this->warn(" [{$m['module']}] Xóa actions: " . implode(', ', $removed));
$this->removeActionsFromAllRoles($m['module'], $removed);
}
} else {
PermissionModule::create([
'module' => $m['module'],
'label' => $m['label'],
'actions' => $m['actions'],
]);
$this->info(" [{$m['module']}] Tạo mới module.");
// Module mới mặc định TẮT cho tất cả role
$this->disableNewActionsForAllRoles($m['module'], $m['actions']);
}
}
$this->info('Đồng bộ hoàn tất.');
return self::SUCCESS;
}
private function getClassFromFile(string $path): ?string
{
$contents = file_get_contents($path);
// Tìm namespace
$namespace = null;
if (preg_match('/namespace\s+([^;]+);/', $contents, $matches)) {
$namespace = $matches[1];
}
// Tìm class name
$class = null;
if (preg_match('/class\s+(\w+)/', $contents, $matches)) {
$class = $matches[1];
}
if ($namespace && $class) {
return $namespace . '\\' . $class;
}
return null;
}
private function disableNewActionsForAllRoles(string $module, array $actions): void
{
$roles = RoleTemplate::all();
foreach ($roles as $role) {
$perms = $role->permissions ?? [];
foreach ($actions as $action) {
if (isset($perms[$module]) && in_array($action, $perms[$module])) {
continue; // Đã có thì giữ nguyên
}
// Không thêm vào = mặc định TẮT
}
// Không cần update vì JSONB không lưu action mới = TẮT
}
}
private function removeActionsFromAllRoles(string $module, array $actions): void
{
$roles = RoleTemplate::all();
foreach ($roles as $role) {
$perms = $role->permissions ?? [];
if (isset($perms[$module])) {
$perms[$module] = array_values(array_diff($perms[$module], $actions));
if (empty($perms[$module])) {
unset($perms[$module]);
}
$role->update(['permissions' => $perms]);
}
}
}
}

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\Appendices\Tables\AppendicesTable;
class AppendixResource extends Resource class AppendixResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Appendix::class; protected static ?string $model = Appendix::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-text'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-text';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value;

View File

@@ -14,6 +14,7 @@ use App\Filament\Resources\Contracts\Tables\ContractsTable;
class ContractResource extends Resource class ContractResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Contract::class; protected static ?string $model = Contract::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-text'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-text';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value;

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\Customers\Tables\CustomersTable;
class CustomerResource extends Resource class CustomerResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Customer::class; protected static ?string $model = Customer::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-users'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-users';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::CUSTOMER->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::CUSTOMER->value;

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\FormTemplates\Tables\FormTemplatesTable;
class FormTemplateResource extends Resource class FormTemplateResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = FormTemplate::class; protected static ?string $model = FormTemplate::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-duplicate'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-duplicate';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::SETTING->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::SETTING->value;

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\PaymentFines\Tables\PaymentFinesTable;
class PaymentFineResource extends Resource class PaymentFineResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = PaymentFine::class; protected static ?string $model = PaymentFine::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-exclamation-triangle'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-exclamation-triangle';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::FINANCE->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::FINANCE->value;

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\Payments\Tables\PaymentsTable;
class PaymentResource extends Resource class PaymentResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Payment::class; protected static ?string $model = Payment::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-banknotes'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-banknotes';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::FINANCE->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::FINANCE->value;

View File

@@ -14,6 +14,7 @@ use App\Filament\Resources\Products\Schemas\ProductForm;
class ProductResource extends Resource class ProductResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Product::class; protected static ?string $model = Product::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-squares-2x2'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-squares-2x2';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::WAREHOUSE->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::WAREHOUSE->value;

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\Projects\Schemas\ProjectForm;
class ProjectResource extends Resource class ProjectResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Project::class; protected static ?string $model = Project::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-building-office-2'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-building-office-2';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::PROJECT->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::PROJECT->value;

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\RoleTemplates\Pages;
use App\Filament\Resources\RoleTemplates\RoleTemplateResource;
use Filament\Resources\Pages\CreateRecord;
class CreateRoleTemplate extends CreateRecord
{
protected static string $resource = RoleTemplateResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\RoleTemplates\Pages;
use App\Filament\Resources\RoleTemplates\RoleTemplateResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditRoleTemplate extends EditRecord
{
protected static string $resource = RoleTemplateResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\RoleTemplates\Pages;
use App\Filament\Resources\RoleTemplates\RoleTemplateResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListRoleTemplates extends ListRecords
{
protected static string $resource = RoleTemplateResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Filament\Resources\RoleTemplates;
use App\Filament\Resources\RoleTemplates\Pages;
use App\Models\RoleTemplate;
use App\Enums\NavigationGroup;
use Filament\Schemas\Schema;
use Filament\Resources\Resource;
use Filament\Tables\Table;
use App\Filament\Resources\RoleTemplates\Schemas\RoleTemplateForm;
use App\Filament\Resources\RoleTemplates\Tables\RoleTemplatesTable;
class RoleTemplateResource extends Resource
{
protected static ?string $model = RoleTemplate::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-shield-check';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::SETTING->value;
protected static ?int $navigationSort = 90;
protected static ?string $modelLabel = 'Mẫu phân quyền';
protected static ?string $pluralModelLabel = 'Mẫu phân quyền';
public static function form(Schema $schema): Schema
{
return RoleTemplateForm::configure($schema);
}
public static function table(Table $table): Table
{
return RoleTemplatesTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => Pages\ListRoleTemplates::route('/'),
'create' => Pages\CreateRoleTemplate::route('/create'),
'edit' => Pages\EditRoleTemplate::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Filament\Resources\RoleTemplates\Schemas;
use App\Models\PermissionModule;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;
class RoleTemplateForm
{
public static function configure(Schema $schema): Schema
{
$modules = PermissionModule::orderBy('label')->get();
$permissionComponents = [];
foreach ($modules as $module) {
$options = [];
foreach ($module->actions as $action) {
$label = match ($action) {
'view' => 'Xem',
'create' => 'Thêm',
'update' => 'Sửa',
'delete' => 'Xóa',
'restore' => 'Khôi phục',
'forceDelete' => 'Xóa vĩnh viễn',
'export' => 'Xuất Excel',
default => $action,
};
$options[$action] = $label;
}
$permissionComponents[] = CheckboxList::make("permissions.{$module->module}")
->label($module->label)
->options($options)
->columns(6)
->columnSpanFull();
}
return $schema
->components([
Section::make('Thông tin nhóm')
->columnSpanFull()
->columns(3)
->schema([
TextInput::make('name')
->label('Tên nhóm')
->required(),
TextInput::make('description')
->label('Mô tả'),
Toggle::make('is_active')
->label('Kích hoạt')
->default(true),
]),
Section::make('Phân quyền theo module')
->columnSpanFull()
->schema($permissionComponents),
]);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Filament\Resources\RoleTemplates\Tables;
use Filament\Tables;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class RoleTemplatesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Tên nhóm')
->searchable()
->sortable(),
TextColumn::make('description')
->label('Mô tả')
->limit(50)
->toggleable(),
IconColumn::make('is_active')
->label('Kích hoạt')
->boolean()
->alignCenter(),
TextColumn::make('users_count')
->label('Số user')
->counts('users')
->alignCenter(),
TextColumn::make('created_at')
->label('Ngày tạo')
->dateTime('d/m/Y')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\TernaryFilter::make('is_active')
->label('Trạng thái'),
])
->defaultSort('created_at', 'desc');
}
}

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\SalesPhases\Tables\SalesPhasesTable;
class SalesPhaseResource extends Resource class SalesPhaseResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = SalesPhase::class; protected static ?string $model = SalesPhase::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-rocket-launch'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-rocket-launch';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::WAREHOUSE->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::WAREHOUSE->value;

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\Settlements\Tables\SettlementsTable;
class SettlementResource extends Resource class SettlementResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Settlement::class; protected static ?string $model = Settlement::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-clipboard-document-check'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-clipboard-document-check';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value;

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource;
use Filament\Resources\Pages\CreateRecord;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditUser extends EditRecord
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Filament\Resources\Users\Schemas;
use App\Models\RoleTemplate;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;
class UserForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Grid::make(2)
->schema([
Section::make('Thông tin tài khoản')
->columnSpan(1)
->schema([
TextInput::make('name')
->label('Họ tên')
->required(),
TextInput::make('email')
->label('Email')
->email()
->required()
->unique(ignoreRecord: true),
]),
Section::make('Phân quyền')
->columnSpan(1)
->schema([
Select::make('role_template_id')
->label('Nhóm quyền (Mẫu)')
->relationship('roleTemplate', 'name')
->searchable()
->preload()
->placeholder('Không theo mẫu nào'),
TagsInput::make('extra_permissions')
->label('Thêm quyền (vượt cấp)')
->placeholder('ví dụ: contracts.export, payments.delete')
->helperText('Nhập quyền muốn thêm cho user này, bất chấp mẫu nhóm')
->separator(',')
->splitKeys([',', 'Enter']),
TagsInput::make('excluded_permissions')
->label('Bớt quyền (hạn chế)')
->placeholder('ví dụ: contracts.delete')
->helperText('Nhập quyền muốn tắt cho user này, bất chấp mẫu nhóm')
->separator(',')
->splitKeys([',', 'Enter']),
]),
]),
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Filament\Resources\Users\Tables;
use Filament\Tables;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class UsersTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Họ tên')
->searchable()
->sortable(),
TextColumn::make('email')
->label('Email')
->searchable()
->sortable(),
TextColumn::make('roleTemplate.name')
->label('Nhóm quyền')
->placeholder('Không có')
->badge()
->color('primary'),
TextColumn::make('created_at')
->label('Ngày tạo')
->dateTime('d/m/Y')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('role_template_id')
->label('Nhóm quyền')
->relationship('roleTemplate', 'name'),
])
->defaultSort('created_at', 'desc');
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Filament\Resources\Users;
use App\Filament\Resources\Users\Pages;
use App\Models\User;
use App\Enums\NavigationGroup;
use Filament\Schemas\Schema;
use Filament\Resources\Resource;
use Filament\Tables\Table;
use App\Filament\Resources\Users\Schemas\UserForm;
use App\Filament\Resources\Users\Tables\UsersTable;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-user-group';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::SETTING->value;
protected static ?int $navigationSort = 100;
protected static ?string $modelLabel = 'Ngườ dùng';
protected static ?string $pluralModelLabel = 'Ngườ dùng';
public static function form(Schema $schema): Schema
{
return UserForm::configure($schema);
}
public static function table(Table $table): Table
{
return UsersTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => Pages\ListUsers::route('/'),
'create' => Pages\CreateUser::route('/create'),
'edit' => Pages\EditUser::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PermissionModule extends Model
{
use HasUuids, HasFactory;
protected $fillable = ['module', 'label', 'actions'];
protected $casts = [
'actions' => 'array',
];
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class RoleTemplate extends Model
{
use HasUuids, HasFactory;
protected $fillable = ['name', 'description', 'permissions', 'is_active'];
protected $casts = [
'permissions' => 'array',
'is_active' => 'boolean',
];
public function users()
{
return $this->hasMany(User::class);
}
}

View File

@@ -29,9 +29,83 @@ class User extends Authenticatable implements FilamentUser
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'extra_permissions' => 'array',
'excluded_permissions' => 'array',
]; ];
} }
public function roleTemplate()
{
return $this->belongsTo(RoleTemplate::class);
}
/**
* Tính toán effective permissions từ role template + extra - excluded.
* Cache trong session (1 lần/login).
*/
public function getEffectivePermissions(): array
{
$cacheKey = "user.{$this->id}.permissions";
if (session()->has($cacheKey)) {
return session()->get($cacheKey);
}
$permissions = $this->calculateEffectivePermissions();
session()->put($cacheKey, $permissions);
return $permissions;
}
public function hasEffectivePermission(string $permission): bool
{
return in_array($permission, $this->getEffectivePermissions());
}
public function clearPermissionCache(): void
{
session()->forget("user.{$this->id}.permissions");
}
protected function calculateEffectivePermissions(): array
{
$templatePerms = [];
if ($this->roleTemplate) {
$templatePerms = $this->roleTemplate->permissions ?? [];
}
// Flatten template permissions từ {"contracts":["view","create"]} thành ["contracts.view","contracts.create"]
$templateFlat = [];
foreach ($templatePerms as $module => $actions) {
foreach ($actions as $action) {
$templateFlat[] = "{$module}.{$action}";
}
}
$extra = $this->extra_permissions ?? [];
$excluded = $this->excluded_permissions ?? [];
return array_values(array_diff(
array_unique(array_merge($templateFlat, $extra)),
$excluded
));
}
/**
* Override can() để tích hợp với Laravel Authorization.
* Nếu ability dạng "contracts.view" dùng effective permissions.
* Ngược lại fallback về parent.
*/
public function can($abilities, $arguments = []): bool
{
if (is_string($abilities) && str_contains($abilities, '.')) {
return $this->hasEffectivePermission($abilities);
}
return parent::can($abilities, $arguments);
}
public function canAccessPanel(Panel $panel): bool public function canAccessPanel(Panel $panel): bool
{ {
return true; return true;

View File

@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Bảng đăng ký các module có thể phân quyền (auto từ Resource)
Schema::create('permission_modules', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('module')->unique(); // contracts, payments, customers...
$table->string('label'); // Hợp đồng, Thu tiền...
$table->jsonb('actions'); // ["view","create","update","delete","restore","forceDelete","export"]
$table->timestamps();
});
// Bảng mẫu nhóm (Role Template)
Schema::create('role_templates', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('name'); // Sales, Kế toán, Admin...
$table->text('description')->nullable();
$table->jsonb('permissions')->default('{}'); // {"contracts":["view","create"], "payments":["view"]}
$table->boolean('is_active')->default(true);
$table->timestamps();
});
// Sửa users: thêm role_template_id, extra_permissions, excluded_permissions
Schema::table('users', function (Blueprint $table) {
$table->foreignUuid('role_template_id')->nullable()->constrained('role_templates')->nullOnDelete();
$table->jsonb('extra_permissions')->default('[]');
$table->jsonb('excluded_permissions')->default('[]');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropForeign(['role_template_id']);
$table->dropColumn(['role_template_id', 'extra_permissions', 'excluded_permissions']);
});
Schema::dropIfExists('role_templates');
Schema::dropIfExists('permission_modules');
}
};

View File

@@ -1,29 +0,0 @@
name: chanphuong
email: admin@phuongtc.com
pass: 1Qazxsw2@!321
git add .
git commit -m "Thêm tính năng X"
git push
✦ Bước 2: Hướng dẫn bạn di chuyển
Bây giờ bạn chỉ cần thực hiện các thao tác sau để đảm bảo sự liền mạch:
1. Commit & Push: Hãy commit toàn bộ code hiện tại (bao gồm cả file GEMINI.md tôi vừa tạo) lên kho lưu trữ Git của bạn
(GitHub/GitLab/Bitbucket).
2. Môi trường mới: Trên máy tính mới, sau khi git clone dự án về:
* Hãy đảm bảo đã cài đặt đầy đủ môi trường (PHP, PostgreSQL...).
* Chạy composer install.
* Cấu hình file .env chính xác cho Database.
* Chạy lại php artisan migrate:fresh --seed để có bộ dữ liệu mẫu mà chúng ta đã thống nhất.
3. Kích hoạt Gemini: Khi bạn mở terminal tại thư mục dự án trên máy mới và gọi Gemini CLI, tôi sẽ tự động đọc file
GEMINI.md.
4. Kiểm tra: Bạn có thể hỏi tôi câu này để kiểm tra xem tôi có "nhận ra" dự án không:
> "Dự án này đang dùng kiến trúc Filament nào và logic chuyển nhượng bất động sản được quy định ra sao?"
Nếu tôi trả lời đúng về Filament v5.5 Schemas và các giá trị của transfer_order, nghĩa là cuộc "di cư tri thức" đã thành
công rực rỡ.
Chúc bạn có một hành trình làm việc thuận lợi trên máy tính mới! Tôi luôn sẵn sàng đồng hành cùng bạn.