feat: granular permissions, export backup, user/role management
Some checks failed
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
Tests / PHP 8.5 (push) Has been cancelled

- PermissionSeeder: 29 permissions with role mapping (admin/manager/staff)
- Policies updated to use hasPermissionTo() instead of hasRole()
- ExportBackup command: JSON per-table backup with chunk processing
- UserResource: CRUD users with role assignment (admin only)
- RoleResource: CRUD roles with permission assignment (admin only)
- UserPolicy + RolePolicy: admin-only access control
- ImportCskh: detailed skip logging with color in dry-run mode
- Widgets: permission-based visibility checks
- Tests: 8/8 pass (21 assertions)
This commit is contained in:
2026-05-02 04:47:10 +00:00
parent 7c5055075b
commit 3a8db5cae6
40 changed files with 1804 additions and 440 deletions

View File

@@ -36,7 +36,7 @@ class EditFeedback extends EditRecord
->label('Transfer Department')
->icon('heroicon-o-arrows-right-left')
->color('warning')
->visible(fn (): bool => auth()->user()->hasRole(['admin', 'manager']))
->visible(fn (): bool => auth()->user()->hasPermissionTo('transfer-department'))
->form([
Select::make('to_department_id')
->label('Target Department')
@@ -106,7 +106,7 @@ class EditFeedback extends EditRecord
->label('Close Ticket')
->icon('heroicon-o-check-circle')
->color('success')
->visible(fn (): bool => $this->getRecord()->status !== 'closed' && auth()->user()->hasRole(['admin', 'manager']))
->visible(fn (): bool => $this->getRecord()->status !== 'closed' && auth()->user()->hasPermissionTo('close-ticket'))
->requiresConfirmation()
->modalHeading('Close Ticket')
->modalDescription('Bạn có chắc muốn đóng phiếu này? Hành động này KHÔNG thể hoàn tác.')

View File

@@ -53,7 +53,7 @@ class InteractionsRelationManager extends RelationManager
Select::make('new_status')
->label('New Status')
->options(fn (): array => auth()->user()->hasRole(['admin', 'manager'])
->options(fn (): array => auth()->user()->hasPermissionTo('close-ticket')
? ['pending' => 'Pending', 'processing' => 'Processing', 'resolved' => 'Resolved', 'closed' => 'Closed']
: ['pending' => 'Pending', 'processing' => 'Processing', 'resolved' => 'Resolved'],
)

View File

@@ -11,6 +11,7 @@ use Filament\Forms\Components\Textarea;
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 FeedbackForm
@@ -19,18 +20,22 @@ class FeedbackForm
{
return $schema
->components([
Section::make('Feedback Information')
Section::make('Customer & Product')
->description('Select the customer and their associated product')
->icon('heroicon-o-user-group')
->schema([
Select::make('customer_id')
->relationship('customer', 'name')
->searchable()
->required()
->live(),
->live()
->columnSpan(1),
Toggle::make('is_general')
->label('General feedback (not related to a product)')
->default(false)
->live(),
->live()
->columnSpan(1),
Select::make('customer_product_id')
->label('Product')
@@ -57,7 +62,7 @@ class FeedbackForm
->live(),
Select::make('contract_id')
->label('Hợp đồng')
->label('Contract')
->visible(fn ($get): bool => ! $get('is_general') && filled($get('customer_product_id')))
->options(function ($get): array {
$customerProductId = $get('customer_product_id');
@@ -102,48 +107,46 @@ class FeedbackForm
})
->nullable()
->searchable(),
])
->columns(2),
Section::make('Feedback Details')
->description('Describe the customer feedback')
->icon('heroicon-o-chat-bubble-left-right')
->schema([
Select::make('feedback_channel_id')
->relationship('feedbackChannel', 'name')
->required()
->preload(),
->preload()
->columnSpan(1),
TextInput::make('title')
->required()
->maxLength(255),
->maxLength(255)
->columnSpan(1),
Textarea::make('content')
->required()
->rows(6)
->columnSpanFull(),
Select::make('attachment_collection')
->label('Attachment Collection')
->options([
'general' => 'General',
'proof_of_resolution' => 'Proof of Resolution',
'customer_evidence' => 'Customer Evidence',
])
->default('general'),
FileUpload::make('attachments')
->label('Attachments')
->multiple()
->disk('local')
->directory('feedback-attachments')
->columnSpanFull()
->dehydrated(false),
Select::make('tags')
->relationship('tags', 'name')
->multiple()
->preload()
->columnSpanFull()
->createOptionForm([
TextInput::make('name')->required(),
TextInput::make('slug')->required(),
ColorPicker::make('color')->default('#3b82f6'),
]),
])
->columns(2),
Section::make('Assignment & Status')
->description('Assign handler and set initial status')
->icon('heroicon-o-arrow-path-rounded-square')
->schema([
Select::make('status')
->options([
'pending' => 'Pending',
@@ -152,22 +155,50 @@ class FeedbackForm
'closed' => 'Closed',
])
->default('pending')
->required(),
->required()
->columnSpan(1),
Select::make('assigned_to')
->relationship('assignedTo', 'name')
->searchable()
->nullable(),
->nullable()
->columnSpan(1),
Select::make('current_department_id')
->label('Department')
->options(Department::pluck('name', 'id')->toArray())
->searchable()
->nullable(),
->nullable()
->columnSpan(1),
Toggle::make('is_escalated')
->label('Escalated')
->visible(fn (): bool => auth()->user()?->hasRole(['admin', 'manager']) ?? false),
->visible(fn (): bool => auth()->user()?->hasPermissionTo('close-ticket') ?? false)
->columnSpan(1),
])
->columns(2),
Section::make('Attachments')
->description('Upload files related to this feedback')
->icon('heroicon-o-paper-clip')
->schema([
Select::make('attachment_collection')
->label('Collection')
->options([
'general' => 'General',
'proof_of_resolution' => 'Proof of Resolution',
'customer_evidence' => 'Customer Evidence',
])
->default('general')
->columnSpan(1),
FileUpload::make('attachments')
->label('Files')
->multiple()
->disk('local')
->directory('feedback-attachments')
->columnSpan(1)
->dehydrated(false),
])
->columns(2),
]);

View File

@@ -16,16 +16,28 @@ class FeedbackTable
{
return $table
->columns([
TextColumn::make('id')
->label('#')
->sortable()
->weight('bold')
->color('primary'),
TextColumn::make('title')
->searchable()
->sortable(),
->sortable()
->weight('semibold')
->limit(40),
TextColumn::make('customer.name')
->searchable()
->sortable(),
TextColumn::make('customerProduct.product.name')
->label('Product')
->placeholder('General')
->sortable(),
->sortable()
->toggleable(),
TextColumn::make('contract.type')
->label('Contract')
->badge()
@@ -38,17 +50,17 @@ class FeedbackTable
})
->placeholder('-')
->toggleable(),
TextColumn::make('feedbackChannel.name')
->label('Channel')
->formatStateUsing(fn ($state, $record) => sprintf(
'<span style="display:inline-block;background:%s;color:#fff;padding:2px 10px;border-radius:9999px;font-size:0.75rem;">%s</span>',
$record->feedbackChannel?->color ?: '#6b7280',
e($state)
))
->html(),
->badge()
->color('gray'),
TextColumn::make('tags.name')
->badge()
->separator(','),
->separator(',')
->toggleable(),
TextColumn::make('status')
->badge()
->color(fn(string $state): string => match ($state) {
@@ -58,20 +70,28 @@ class FeedbackTable
'closed' => 'gray',
default => 'gray',
}),
TextColumn::make('currentDepartment.name')
->label('Department')
->badge()
->color('info')
->toggleable(),
TextColumn::make('assignedTo.name')
->label('Assigned To')
->toggleable(),
IconColumn::make('is_escalated')
->label('Escalated')
->boolean()
->toggleable(),
TextColumn::make('created_at')
->dateTime()
->label('Created')
->since()
->sortable()
->toggleable(),
->toggleable()
->color('secondary'),
])
->filters([
SelectFilter::make('status')