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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user