feat: AfterSales CRM - SOP compliant real-estate customer care system

- Departments + cross-department transfer workflow
- Role-based closing (staff→resolved, manager→closed) with proof_of_resolution
- Private file storage with collection system + temporary signed URLs
- Dashboard: resolved tickets table, stats, handler performance bar chart
- Spatie RBAC (admin/manager/staff)
- Notifications: TicketTransferred, TicketClosed (database + mail)
- Pest tests: 8 tests, 21 assertions
- Docker production-ready (Dockerfile + compose + entrypoint)
This commit is contained in:
2026-04-27 05:29:48 +00:00
parent 8ad7826f56
commit 887765bbd7
134 changed files with 17416 additions and 45 deletions

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Filament\Resources\Feedback;
use App\Filament\Resources\Feedback\Pages\CreateFeedback;
use App\Filament\Resources\Feedback\Pages\EditFeedback;
use App\Filament\Resources\Feedback\Pages\ListFeedback;
use App\Filament\Resources\Feedback\RelationManagers\InteractionsRelationManager;
use App\Filament\Resources\Feedback\Schemas\FeedbackForm;
use App\Filament\Resources\Feedback\Tables\FeedbackTable;
use App\Models\Feedback;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class FeedbackResource extends Resource
{
protected static ?string $model = Feedback::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChatBubbleLeftRight;
protected static ?string $pluralLabel = 'Feedbacks';
protected static ?int $navigationSort = 1;
public static function getNavigationGroup(): ?string
{
return 'Customer Care';
}
public static function form(Schema $schema): Schema
{
return FeedbackForm::configure($schema);
}
public static function table(Table $table): Table
{
return FeedbackTable::configure($table);
}
public static function getRelations(): array
{
return [
InteractionsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => ListFeedback::route('/'),
'create' => CreateFeedback::route('/create'),
'edit' => EditFeedback::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Filament\Resources\Feedback\Pages;
use App\Filament\Resources\Feedback\FeedbackResource;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Storage;
class CreateFeedback extends CreateRecord
{
protected static string $resource = FeedbackResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
if (($data['is_general'] ?? false) === true) {
$data['customer_product_id'] = null;
}
unset($data['is_general'], $data['attachment_collection']);
return $data;
}
protected function afterCreate(): void
{
$feedback = $this->getRecord();
$attachments = $this->data['attachments'] ?? [];
$collection = $this->data['attachment_collection'] ?? 'general';
$feedback->interactions()->create([
'user_id' => auth()->id(),
'type' => 'note',
'content' => 'Feedback created via ' . ($feedback->feedbackChannel?->name ?? 'unknown channel'),
'changes' => ['status' => ['old' => null, 'new' => $feedback->status]],
]);
if (! empty($attachments)) {
$disk = Storage::disk('local');
foreach ($attachments as $path) {
$feedback->attachments()->create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'user_id' => auth()->id(),
'name' => basename($path),
'path' => $path,
'disk' => 'local',
'collection' => $collection,
'mime_type' => $disk->mimeType($path),
'size' => $disk->size($path),
]);
}
}
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Filament\Resources\Feedback\Pages;
use App\Filament\Resources\Feedback\FeedbackResource;
use App\Models\Department;
use App\Services\ClosingService;
use App\Services\TransferService;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Storage;
class EditFeedback extends EditRecord
{
protected static string $resource = FeedbackResource::class;
protected function getHeaderActions(): array
{
return [
$this->transferDepartmentAction(),
$this->closeTicketAction(),
DeleteAction::make(),
];
}
protected function transferDepartmentAction(): Action
{
return Action::make('transferDepartment')
->label('Transfer Department')
->icon('heroicon-o-arrows-right-left')
->color('warning')
->visible(fn (): bool => auth()->user()->hasRole(['admin', 'manager']))
->form([
Select::make('to_department_id')
->label('Target Department')
->options(Department::pluck('name', 'id')->toArray())
->required()
->searchable(),
Select::make('new_assignee_id')
->label('Assign To (optional)')
->options(function (\Filament\Forms\Get $get) {
$deptId = $get('to_department_id');
if (! $deptId) {
return [];
}
$dept = Department::with('manager')->find($deptId);
return \App\Models\User::pluck('name', 'id')->toArray();
})
->searchable()
->nullable(),
Textarea::make('reason')
->label('Reason')
->required()
->rows(3),
FileUpload::make('transfer_attachments')
->label('Attachments')
->multiple()
->disk('local')
->directory('feedback-attachments')
->columnSpanFull(),
])
->action(function (array $data): void {
$feedback = $this->getRecord();
$toDepartment = Department::findOrFail($data['to_department_id']);
$sender = auth()->user();
$transferService = App::make(TransferService::class);
$log = $transferService->transfer(
$feedback,
$toDepartment,
$data['reason'],
$sender,
$data['new_assignee_id'] ?? null,
);
if (! empty($data['transfer_attachments'])) {
$fileService = App::make(FileService::class);
$fileService->upload(
$data['transfer_attachments'],
'general',
$feedback,
$sender->id,
);
}
Notification::make()
->title('Transferred successfully')
->success()
->send();
$this->redirect($this->getResource()::getUrl('edit', ['record' => $feedback]));
});
}
protected function closeTicketAction(): Action
{
return Action::make('closeTicket')
->label('Close Ticket')
->icon('heroicon-o-check-circle')
->color('success')
->visible(fn (): bool => $this->getRecord()->status !== 'closed' && auth()->user()->hasRole(['admin', 'manager']))
->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.')
->modalSubmitActionLabel('Close')
->action(function (): void {
$feedback = $this->getRecord();
$actor = auth()->user();
$closingService = App::make(ClosingService::class);
try {
$closingService->close($feedback, $actor);
Notification::make()
->title('Ticket closed successfully')
->success()
->send();
$this->redirect($this->getResource()::getUrl('edit', ['record' => $feedback]));
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
Notification::make()
->title($e->getMessage())
->danger()
->send();
} catch (\Illuminate\Validation\ValidationException $e) {
Notification::make()
->title($e->getMessage())
->danger()
->send();
}
});
}
public function getFooter(): ?View
{
return view('filament.resources.feedback.similar-cases');
}
protected function mutateFormDataBeforeSave(array $data): array
{
if (($data['is_general'] ?? false) === true) {
$data['customer_product_id'] = null;
}
unset($data['is_general'], $data['attachment_collection']);
$newStatus = $data['status'] ?? null;
if ($newStatus === 'closed' && $this->getRecord()->status !== 'closed') {
$closingService = App::make(ClosingService::class);
$closingService->close($this->getRecord(), auth()->user());
}
return $data;
}
protected function afterSave(): void
{
$attachments = $this->data['attachments'] ?? [];
$collection = $this->data['attachment_collection'] ?? 'general';
if (! empty($attachments)) {
$disk = Storage::disk('local');
$feedback = $this->getRecord();
foreach ($attachments as $path) {
$feedback->attachments()->create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'user_id' => auth()->id(),
'name' => basename($path),
'path' => $path,
'disk' => 'local',
'collection' => $collection,
'mime_type' => $disk->mimeType($path),
'size' => $disk->size($path),
]);
}
}
}
protected function mutateFormDataBeforeFill(array $data): array
{
$data['is_general'] = $data['customer_product_id'] === null;
return $data;
}
}

View File

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

View File

@@ -0,0 +1,258 @@
<?php
namespace App\Filament\Resources\Feedback\RelationManagers;
use App\Models\Department;
use App\Models\FeedbackInteraction;
use App\Models\User;
use App\Services\ClosingService;
use App\Services\FileService;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Illuminate\Support\Facades\Storage;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Facades\App;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
class InteractionsRelationManager extends RelationManager
{
protected static string $relationship = 'interactions';
protected static ?string $title = 'Interaction History';
public function form(Schema $schema): Schema
{
return $schema
->components([
Select::make('type')
->options([
'note' => 'Note',
'status_change' => 'Status Change',
'assignment' => 'Assignment / Forward',
])
->default('note')
->required()
->live(),
TextInput::make('title')
->maxLength(255)
->visible(fn ($get): bool => $get('type') === 'note'),
RichEditor::make('content')
->required()
->columnSpanFull(),
Select::make('new_status')
->label('New Status')
->options(fn (): array => auth()->user()->hasRole(['admin', 'manager'])
? ['pending' => 'Pending', 'processing' => 'Processing', 'resolved' => 'Resolved', 'closed' => 'Closed']
: ['pending' => 'Pending', 'processing' => 'Processing', 'resolved' => 'Resolved'],
)
->visible(fn ($get): bool => $get('type') === 'status_change'),
Select::make('new_assignee')
->label('Assign To')
->options(User::pluck('name', 'id')->toArray())
->searchable()
->visible(fn ($get): bool => $get('type') === 'assignment'),
Select::make('department_id')
->label('Target Department')
->options(Department::pluck('name', 'id')->toArray())
->searchable()
->nullable()
->visible(fn ($get): bool => $get('type') === 'assignment'),
FileUpload::make('attachments')
->label('Attachments')
->multiple()
->disk('local')
->directory('feedback-attachments')
->columnSpanFull(),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('content')
->columns([
TextColumn::make('created_at')
->dateTime('d/m/Y H:i')
->sortable(),
TextColumn::make('user.name')
->label('By'),
TextColumn::make('type')
->badge()
->formatStateUsing(fn (string $state): string => match ($state) {
'note' => 'Note',
'status_change' => 'Status Change',
'assignment' => 'Assignment',
default => $state,
})
->color(fn (string $state): string => match ($state) {
'note' => 'gray',
'status_change' => 'info',
'assignment' => 'warning',
default => 'gray',
}),
TextColumn::make('content')
->html()
->limit(80)
->wrap(),
TextColumn::make('changes')
->label('Details')
->formatStateUsing(function (?array $state): string {
if (! $state) return '';
$lines = [];
foreach ($state as $field => $values) {
if (is_array($values)) {
$lines[] = "<b>{$field}</b>: {$values['old']}{$values['new']}";
}
}
return implode('<br>', $lines);
})
->html(),
TextColumn::make('attachments_count')
->label('Files')
->counts('attachments')
->badge()
->color(fn (int $state): string => $state > 0 ? 'primary' : 'gray'),
])
->defaultSort('created_at', 'desc')
->recordActions([
Action::make('viewAttachments')
->label('View Attachments')
->icon('heroicon-o-paper-clip')
->modalHeading('Attachments')
->modalContent(function (FeedbackInteraction $record): \Illuminate\Contracts\View\View {
$fileService = App::make(FileService::class);
$attachments = $record->attachments;
$links = $attachments->map(function ($a) use ($fileService) {
return [
'name' => $a->name,
'url' => $fileService->getTemporaryUrl($a, 60),
'size' => $a->size ? round($a->size / 1024, 1) . ' KB' : 'N/A',
'collection' => $a->collection,
];
});
return view('filament.resources.feedback.attachment-links', [
'links' => $links,
]);
})
->visible(fn (FeedbackInteraction $record): bool => $record->attachments()->count() > 0),
])
->headerActions([
CreateAction::make()
->label('Add Interaction')
->icon('heroicon-o-plus')
->mutateFormDataUsing(function (array $data, $livewire): array {
$data['user_id'] = auth()->id();
$data['feedback_id'] = $livewire->getOwnerRecord()->id;
if ($data['type'] === 'status_change' && isset($data['new_status'])) {
$feedback = $livewire->getOwnerRecord();
if ($data['new_status'] === 'closed') {
$this->handleClosingViaService($feedback, auth()->user(), $data);
}
$data['changes'] = [
'status' => ['old' => $feedback->status, 'new' => $data['new_status']],
];
$feedback->update(['status' => $data['new_status']]);
unset($data['new_status']);
}
if ($data['type'] === 'assignment' && isset($data['new_assignee'])) {
$feedback = $livewire->getOwnerRecord();
$oldAssignee = $feedback->assignedTo?->name ?? 'Unassigned';
$newAssignee = User::find($data['new_assignee'])?->name ?? 'Unknown';
$data['changes'] = [
'assignment' => ['old' => $oldAssignee, 'new' => $newAssignee],
];
$update = ['assigned_to' => $data['new_assignee']];
if (! empty($data['department_id'])) {
$update['current_department_id'] = $data['department_id'];
$dept = Department::find($data['department_id']);
$data['changes']['department'] = [
'old' => $feedback->currentDepartment?->name ?? 'N/A',
'new' => $dept?->name ?? 'N/A',
];
}
$feedback->update($update);
unset($data['new_assignee'], $data['department_id']);
}
$data['_attachments'] = $data['attachments'] ?? [];
unset($data['attachments'], $data['title']);
return $data;
})
->after(function (FeedbackInteraction $record, array $data): void {
$attachments = $data['_attachments'] ?? [];
if (! empty($attachments)) {
$disk = Storage::disk('local');
foreach ($attachments as $path) {
$record->attachments()->create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'feedback_id' => $record->feedback_id,
'user_id' => auth()->id(),
'name' => basename($path),
'path' => $path,
'disk' => 'local',
'collection' => 'general',
'mime_type' => $disk->mimeType($path),
'size' => $disk->size($path),
]);
}
}
if ($record->type === 'assignment') {
Notification::make()
->title('Feedback assigned successfully')
->success()
->send();
}
if ($record->type === 'status_change') {
Notification::make()
->title('Status updated successfully')
->success()
->send();
}
}),
]);
}
protected function handleClosingViaService($feedback, $actor, array &$data): void
{
try {
$closingService = App::make(ClosingService::class);
$closingService->close($feedback, $actor);
} catch (HttpException $e) {
Notification::make()
->title($e->getMessage())
->danger()
->send();
unset($data['new_status']);
throw new \Filament\Exceptions\SilentActionException;
} catch (ValidationException $e) {
Notification::make()
->title(collect($e->errors())->flatten()->first())
->danger()
->send();
unset($data['new_status']);
throw new \Filament\Exceptions\SilentActionException;
}
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Filament\Resources\Feedback\Schemas;
use App\Models\CustomerProduct;
use App\Models\Department;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class FeedbackForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make('Feedback Information')
->schema([
Select::make('customer_id')
->relationship('customer', 'name')
->searchable()
->preload()
->required()
->live(),
Toggle::make('is_general')
->label('General feedback (not related to a product)')
->default(false)
->live(),
Select::make('customer_product_id')
->label('Product')
->options(function ($get): array {
$customerId = $get('customer_id');
if (! $customerId) {
return [];
}
return CustomerProduct::where('customer_id', $customerId)
->with('product')
->get()
->pluck('product.name', 'id')
->toArray();
})
->visible(fn ($get): bool => ! $get('is_general'))
->nullable()
->searchable(),
Select::make('feedback_channel_id')
->relationship('feedbackChannel', 'name')
->required()
->preload(),
TextInput::make('title')
->required()
->maxLength(255),
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()
->createOptionForm([
TextInput::make('name')->required(),
TextInput::make('slug')->required(),
TextInput::make('color')->type('color'),
]),
Select::make('status')
->options([
'pending' => 'Pending',
'processing' => 'Processing',
'resolved' => 'Resolved',
'closed' => 'Closed',
])
->default('pending')
->required(),
Select::make('assigned_to')
->relationship('assignedTo', 'name')
->searchable()
->preload()
->nullable(),
Select::make('current_department_id')
->label('Department')
->options(Department::pluck('name', 'id')->toArray())
->searchable()
->nullable(),
Toggle::make('is_escalated')
->label('Escalated')
->visible(fn (): bool => auth()->user()?->hasRole(['admin', 'manager']) ?? false),
])
->columns(2),
]);
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Filament\Resources\Feedback\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class FeedbackTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('title')
->searchable()
->sortable(),
TextColumn::make('customer.name')
->searchable()
->sortable(),
TextColumn::make('customerProduct.product.name')
->label('Product')
->placeholder('General')
->sortable(),
TextColumn::make('feedbackChannel.name')
->label('Channel'),
TextColumn::make('tags.name')
->badge()
->separator(','),
TextColumn::make('status')
->badge()
->color(fn(string $state): string => match ($state) {
'pending' => 'warning',
'processing' => 'info',
'resolved' => 'success',
'closed' => 'gray',
default => 'gray',
}),
TextColumn::make('currentDepartment.name')
->label('Department')
->toggleable(),
TextColumn::make('assignedTo.name')
->label('Assigned To')
->toggleable(),
IconColumn::make('is_escalated')
->label('Escalated')
->boolean()
->toggleable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(),
])
->filters([
SelectFilter::make('status')
->options([
'pending' => 'Pending',
'processing' => 'Processing',
'resolved' => 'Resolved',
'closed' => 'Closed',
]),
SelectFilter::make('feedback_channel_id')
->relationship('feedbackChannel', 'name')
->label('Channel'),
SelectFilter::make('current_department_id')
->relationship('currentDepartment', 'name')
->label('Department'),
SelectFilter::make('assigned_to')
->relationship('assignedTo', 'name')
->label('Assigned To'),
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->defaultSort('created_at', 'desc');
}
}