Skip to content

Commit 1d12e1c

Browse files
simonhampclaude
andcommitted
Add support ticket system with admin panel, notifications, and chat UI
Implements the full support ticket workflow: multi-step creation wizard, product-specific bug report forms, admin Filament resource with chat-style replies widget, email notifications for both user and admin replies, and user-facing ticket view with reply capability. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent aa92cfa commit 1d12e1c

26 files changed

Lines changed: 2464 additions & 154 deletions
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
<?php
2+
3+
namespace App\Filament\Resources;
4+
5+
use App\Filament\Resources\SupportTicketResource\Pages;
6+
use App\Models\SupportTicket;
7+
use App\SupportTicket\Status;
8+
use Filament\Infolists;
9+
use Filament\Infolists\Infolist;
10+
use Filament\Resources\Resource;
11+
use Filament\Tables;
12+
use Filament\Tables\Table;
13+
14+
class SupportTicketResource extends Resource
15+
{
16+
protected static ?string $model = SupportTicket::class;
17+
18+
protected static ?string $navigationIcon = 'heroicon-o-ticket';
19+
20+
protected static ?string $navigationLabel = 'Support Tickets';
21+
22+
protected static ?string $pluralModelLabel = 'Support Tickets';
23+
24+
public static function canCreate(): bool
25+
{
26+
return false;
27+
}
28+
29+
public static function infolist(Infolist $infolist): Infolist
30+
{
31+
return $infolist
32+
->schema([
33+
Infolists\Components\Grid::make(2)
34+
->schema([
35+
Infolists\Components\Section::make('Ticket Details')
36+
->schema([
37+
Infolists\Components\TextEntry::make('mask')
38+
->label('Ticket ID'),
39+
Infolists\Components\TextEntry::make('status')
40+
->badge()
41+
->color(fn (Status $state): string => match ($state) {
42+
Status::OPEN => 'warning',
43+
Status::IN_PROGRESS => 'info',
44+
Status::ON_HOLD => 'gray',
45+
Status::RESPONDED => 'success',
46+
Status::CLOSED => 'danger',
47+
}),
48+
Infolists\Components\TextEntry::make('product')
49+
->label('Product'),
50+
Infolists\Components\TextEntry::make('issue_type')
51+
->label('Issue Type')
52+
->placeholder('N/A'),
53+
Infolists\Components\TextEntry::make('user.email')
54+
->label('User')
55+
->url(fn (SupportTicket $record): string => UserResource::getUrl('edit', ['record' => $record->user_id])),
56+
Infolists\Components\TextEntry::make('created_at')
57+
->label('Created')
58+
->dateTime(),
59+
Infolists\Components\TextEntry::make('updated_at')
60+
->label('Updated')
61+
->dateTime(),
62+
])
63+
->columns(2)
64+
->collapsible()
65+
->persistCollapsed()
66+
->columnSpan(1),
67+
68+
Infolists\Components\Section::make('Context')
69+
->schema([
70+
Infolists\Components\TextEntry::make('subject')
71+
->label('Subject')
72+
->columnSpanFull(),
73+
Infolists\Components\TextEntry::make('message')
74+
->label('Message')
75+
->markdown()
76+
->columnSpanFull(),
77+
])
78+
->collapsible()
79+
->persistCollapsed()
80+
->columnSpan(1),
81+
]),
82+
]);
83+
}
84+
85+
public static function table(Table $table): Table
86+
{
87+
return $table
88+
->columns([
89+
Tables\Columns\TextColumn::make('mask')
90+
->label('ID')
91+
->searchable()
92+
->sortable(),
93+
94+
Tables\Columns\TextColumn::make('subject')
95+
->searchable()
96+
->sortable()
97+
->limit(50),
98+
99+
Tables\Columns\TextColumn::make('user.email')
100+
->label('User')
101+
->searchable()
102+
->sortable(),
103+
104+
Tables\Columns\TextColumn::make('product')
105+
->sortable(),
106+
107+
Tables\Columns\TextColumn::make('status')
108+
->badge()
109+
->color(fn (Status $state): string => match ($state) {
110+
Status::OPEN => 'warning',
111+
Status::IN_PROGRESS => 'info',
112+
Status::ON_HOLD => 'gray',
113+
Status::RESPONDED => 'success',
114+
Status::CLOSED => 'danger',
115+
})
116+
->sortable(),
117+
118+
Tables\Columns\TextColumn::make('created_at')
119+
->label('Created')
120+
->dateTime()
121+
->sortable(),
122+
])
123+
->filters([
124+
Tables\Filters\SelectFilter::make('status')
125+
->options(collect(Status::cases())->mapWithKeys(fn (Status $s) => [$s->value => $s->name])),
126+
Tables\Filters\SelectFilter::make('product')
127+
->options([
128+
'mobile' => 'Mobile',
129+
'desktop' => 'Desktop',
130+
'bifrost' => 'Bifrost',
131+
'nativephp.com' => 'NativePHP.com',
132+
]),
133+
])
134+
->actions([
135+
Tables\Actions\ViewAction::make(),
136+
])
137+
->bulkActions([
138+
Tables\Actions\BulkActionGroup::make([
139+
Tables\Actions\DeleteBulkAction::make(),
140+
]),
141+
])
142+
->defaultSort('created_at', 'desc');
143+
}
144+
145+
public static function getRelations(): array
146+
{
147+
return [];
148+
}
149+
150+
public static function getPages(): array
151+
{
152+
return [
153+
'index' => Pages\ListSupportTickets::route('/'),
154+
'view' => Pages\ViewSupportTicket::route('/{record}'),
155+
];
156+
}
157+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
namespace App\Filament\Resources\SupportTicketResource\Pages;
4+
5+
use App\Filament\Resources\SupportTicketResource;
6+
use Filament\Resources\Pages\ListRecords;
7+
8+
class ListSupportTickets extends ListRecords
9+
{
10+
protected static string $resource = SupportTicketResource::class;
11+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace App\Filament\Resources\SupportTicketResource\Pages;
4+
5+
use App\Filament\Resources\SupportTicketResource;
6+
use App\Filament\Resources\SupportTicketResource\Widgets\TicketRepliesWidget;
7+
use App\SupportTicket\Status;
8+
use Filament\Actions;
9+
use Filament\Resources\Pages\ViewRecord;
10+
11+
class ViewSupportTicket extends ViewRecord
12+
{
13+
protected static string $resource = SupportTicketResource::class;
14+
15+
protected function getHeaderActions(): array
16+
{
17+
return [
18+
Actions\Action::make('updateStatus')
19+
->label('Update Status')
20+
->icon('heroicon-o-arrow-path')
21+
->form([
22+
\Filament\Forms\Components\Select::make('status')
23+
->label('Status')
24+
->options(collect(Status::cases())->mapWithKeys(fn (Status $s) => [$s->value => ucwords(str_replace('_', ' ', $s->value))]))
25+
->required(),
26+
])
27+
->fillForm(fn () => ['status' => $this->record->status->value])
28+
->action(function (array $data): void {
29+
$this->record->update(['status' => $data['status']]);
30+
$this->refreshFormData(['status']);
31+
}),
32+
];
33+
}
34+
35+
protected function getFooterWidgets(): array
36+
{
37+
return [
38+
TicketRepliesWidget::class,
39+
];
40+
}
41+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
namespace App\Filament\Resources\SupportTicketResource\RelationManagers;
4+
5+
use App\Models\SupportTicket\Reply;
6+
use App\Notifications\SupportTicketReplied;
7+
use Filament\Forms;
8+
use Filament\Forms\Form;
9+
use Filament\Resources\RelationManagers\RelationManager;
10+
use Filament\Tables;
11+
use Filament\Tables\Table;
12+
13+
class RepliesRelationManager extends RelationManager
14+
{
15+
protected static string $relationship = 'replies';
16+
17+
protected static ?string $title = 'Replies';
18+
19+
protected static bool $shouldSkipAuthorization = true;
20+
21+
public function isReadOnly(): bool
22+
{
23+
return false;
24+
}
25+
26+
public function form(Form $form): Form
27+
{
28+
return $form
29+
->schema([
30+
Forms\Components\Textarea::make('message')
31+
->required()
32+
->maxLength(5000)
33+
->columnSpanFull(),
34+
Forms\Components\Toggle::make('note')
35+
->label('Internal note (not visible to user)')
36+
->default(false),
37+
]);
38+
}
39+
40+
public function table(Table $table): Table
41+
{
42+
return $table
43+
->columns([
44+
Tables\Columns\TextColumn::make('user.name')
45+
->label('Author')
46+
->sortable(),
47+
48+
Tables\Columns\TextColumn::make('message')
49+
->limit(80)
50+
->tooltip(fn ($record) => $record->message),
51+
52+
Tables\Columns\IconColumn::make('note')
53+
->label('Note')
54+
->boolean(),
55+
56+
Tables\Columns\TextColumn::make('created_at')
57+
->label('Date')
58+
->dateTime()
59+
->sortable(),
60+
])
61+
->defaultSort('created_at', 'desc')
62+
->headerActions([
63+
Tables\Actions\CreateAction::make()
64+
->label('Add Reply')
65+
->mutateFormDataUsing(function (array $data): array {
66+
$data['user_id'] = auth()->id();
67+
68+
return $data;
69+
})
70+
->after(function (Reply $record): void {
71+
if ($record->note) {
72+
return;
73+
}
74+
75+
$ticket = $this->getOwnerRecord();
76+
$ticket->user->notify(new SupportTicketReplied($ticket, $record));
77+
}),
78+
])
79+
->actions([
80+
//
81+
]);
82+
}
83+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
namespace App\Filament\Resources\SupportTicketResource\Widgets;
4+
5+
use App\Notifications\SupportTicketReplied;
6+
use Filament\Widgets\Widget;
7+
use Illuminate\Database\Eloquent\Model;
8+
9+
class TicketRepliesWidget extends Widget
10+
{
11+
protected static string $view = 'filament.resources.support-ticket-resource.widgets.ticket-replies';
12+
13+
public ?Model $record = null;
14+
15+
public string $newMessage = '';
16+
17+
public bool $isNote = false;
18+
19+
protected int|string|array $columnSpan = 'full';
20+
21+
protected function getListeners(): array
22+
{
23+
return [];
24+
}
25+
26+
public function sendReply(): void
27+
{
28+
$this->validate([
29+
'newMessage' => ['required', 'string', 'max:5000'],
30+
]);
31+
32+
$reply = $this->record->replies()->create([
33+
'user_id' => auth()->id(),
34+
'message' => $this->newMessage,
35+
'note' => $this->isNote,
36+
]);
37+
38+
if (! $this->isNote) {
39+
$this->record->user->notify(new SupportTicketReplied($this->record, $reply));
40+
}
41+
42+
$this->newMessage = '';
43+
$this->isNote = false;
44+
}
45+
}

0 commit comments

Comments
 (0)