Skip to content

Commit 90d2afa

Browse files
committed
feat(finance): Phase 87 — Customer Support Ticketing with comments
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent d7fff49 commit 90d2afa

14 files changed

Lines changed: 1038 additions & 1 deletion

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Models\User;
7+
use App\Modules\Finance\Models\Contact;
8+
use App\Modules\Finance\Models\SupportTicket;
9+
use App\Modules\Finance\Models\TicketComment;
10+
use Illuminate\Http\RedirectResponse;
11+
use Illuminate\Http\Request;
12+
use Inertia\Inertia;
13+
use Inertia\Response;
14+
15+
class SupportTicketController extends Controller
16+
{
17+
public function index(Request $request): Response
18+
{
19+
$this->authorize('viewAny', SupportTicket::class);
20+
21+
$query = SupportTicket::with(['assignedTo', 'createdBy']);
22+
23+
if ($request->filled('status')) {
24+
$query->where('status', $request->status);
25+
}
26+
27+
if ($request->filled('priority')) {
28+
$query->where('priority', $request->priority);
29+
}
30+
31+
$tickets = $query->latest()->paginate(20)->withQueryString();
32+
33+
return Inertia::render('Finance/SupportTickets/Index', [
34+
'tickets' => $tickets,
35+
'filters' => $request->only('status', 'priority'),
36+
]);
37+
}
38+
39+
public function create(): Response
40+
{
41+
$this->authorize('create', SupportTicket::class);
42+
43+
$contacts = Contact::orderBy('name')->get(['id', 'name']);
44+
45+
return Inertia::render('Finance/SupportTickets/Create', [
46+
'contacts' => $contacts,
47+
]);
48+
}
49+
50+
public function store(Request $request): RedirectResponse
51+
{
52+
$this->authorize('create', SupportTicket::class);
53+
54+
$data = $request->validate([
55+
'subject' => ['required', 'string'],
56+
'description'=> ['required', 'string'],
57+
'priority' => ['nullable', 'in:low,normal,high,urgent'],
58+
'category' => ['nullable', 'string'],
59+
'contact_id' => ['nullable', 'exists:contacts,id'],
60+
]);
61+
62+
$tenantId = app('tenant')->id;
63+
64+
$data['tenant_id'] = $tenantId;
65+
$data['reference'] = SupportTicket::generateReference($tenantId);
66+
$data['created_by'] = auth()->id();
67+
$data['priority'] = $data['priority'] ?? 'normal';
68+
69+
$ticket = SupportTicket::create($data);
70+
71+
return redirect()->route('finance.support-tickets.show', $ticket);
72+
}
73+
74+
public function show(SupportTicket $supportTicket): Response
75+
{
76+
$this->authorize('view', $supportTicket);
77+
78+
$supportTicket->load(['comments.createdBy', 'assignedTo', 'createdBy', 'contact']);
79+
80+
return Inertia::render('Finance/SupportTickets/Show', [
81+
'ticket' => $supportTicket,
82+
]);
83+
}
84+
85+
public function destroy(SupportTicket $supportTicket): RedirectResponse
86+
{
87+
$this->authorize('delete', $supportTicket);
88+
89+
$supportTicket->delete();
90+
91+
return redirect()->route('finance.support-tickets.index');
92+
}
93+
94+
public function resolve(Request $request, SupportTicket $supportTicket): RedirectResponse
95+
{
96+
$this->authorize('update', $supportTicket);
97+
98+
$supportTicket->resolve();
99+
100+
return back()->with('success', 'Ticket resolved.');
101+
}
102+
103+
public function close(Request $request, SupportTicket $supportTicket): RedirectResponse
104+
{
105+
$this->authorize('update', $supportTicket);
106+
107+
$supportTicket->close();
108+
109+
return back()->with('success', 'Ticket closed.');
110+
}
111+
112+
public function reopen(Request $request, SupportTicket $supportTicket): RedirectResponse
113+
{
114+
$this->authorize('update', $supportTicket);
115+
116+
$supportTicket->reopen();
117+
118+
return back()->with('success', 'Ticket reopened.');
119+
}
120+
121+
public function assign(Request $request, SupportTicket $supportTicket): RedirectResponse
122+
{
123+
$this->authorize('update', $supportTicket);
124+
125+
$data = $request->validate([
126+
'assigned_to' => ['required', 'integer', 'exists:users,id'],
127+
]);
128+
129+
$supportTicket->assign($data['assigned_to']);
130+
131+
return back()->with('success', 'Ticket assigned.');
132+
}
133+
134+
public function addComment(Request $request, SupportTicket $supportTicket): RedirectResponse
135+
{
136+
$this->authorize('view', $supportTicket);
137+
138+
$data = $request->validate([
139+
'body' => ['required', 'string'],
140+
'is_internal' => ['nullable', 'boolean'],
141+
]);
142+
143+
TicketComment::create([
144+
'tenant_id' => app('tenant')->id,
145+
'support_ticket_id' => $supportTicket->id,
146+
'created_by' => auth()->id(),
147+
'body' => $data['body'],
148+
'is_internal' => $data['is_internal'] ?? false,
149+
]);
150+
151+
return back()->with('success', 'Comment added.');
152+
}
153+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\Relations\HasMany;
10+
use Illuminate\Database\Eloquent\SoftDeletes;
11+
12+
class SupportTicket extends Model
13+
{
14+
use BelongsToTenant;
15+
use SoftDeletes;
16+
17+
protected $fillable = [
18+
'tenant_id',
19+
'reference',
20+
'subject',
21+
'description',
22+
'status',
23+
'priority',
24+
'category',
25+
'contact_id',
26+
'assigned_to',
27+
'created_by',
28+
'resolved_at',
29+
'closed_at',
30+
];
31+
32+
protected $casts = [
33+
'resolved_at' => 'datetime',
34+
'closed_at' => 'datetime',
35+
];
36+
37+
public function comments(): HasMany
38+
{
39+
return $this->hasMany(TicketComment::class);
40+
}
41+
42+
public function assignedTo(): BelongsTo
43+
{
44+
return $this->belongsTo(User::class, 'assigned_to');
45+
}
46+
47+
public function createdBy(): BelongsTo
48+
{
49+
return $this->belongsTo(User::class, 'created_by');
50+
}
51+
52+
public function contact(): BelongsTo
53+
{
54+
return $this->belongsTo(Contact::class, 'contact_id');
55+
}
56+
57+
public static function generateReference(int $tenantId): string
58+
{
59+
return 'TKT-' . str_pad(
60+
SupportTicket::where('tenant_id', $tenantId)->count() + 1,
61+
4,
62+
'0',
63+
STR_PAD_LEFT
64+
);
65+
}
66+
67+
public function resolve(): void
68+
{
69+
$this->status = 'resolved';
70+
$this->resolved_at = now();
71+
$this->save();
72+
}
73+
74+
public function close(): void
75+
{
76+
$this->status = 'closed';
77+
$this->closed_at = now();
78+
$this->save();
79+
}
80+
81+
public function reopen(): void
82+
{
83+
$this->status = 'open';
84+
$this->resolved_at = null;
85+
$this->closed_at = null;
86+
$this->save();
87+
}
88+
89+
public function assign(int $userId): void
90+
{
91+
$this->assigned_to = $userId;
92+
if ($this->status === 'open') {
93+
$this->status = 'in_progress';
94+
}
95+
$this->save();
96+
}
97+
98+
public function getIsOpenAttribute(): bool
99+
{
100+
return $this->status === 'open' || $this->status === 'in_progress';
101+
}
102+
103+
public function getResponseTimeHoursAttribute(): ?float
104+
{
105+
if ($this->resolved_at === null) {
106+
return null;
107+
}
108+
109+
return round($this->created_at->diffInMinutes($this->resolved_at) / 60, 1);
110+
}
111+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
10+
class TicketComment extends Model
11+
{
12+
use BelongsToTenant;
13+
14+
protected $fillable = [
15+
'tenant_id',
16+
'support_ticket_id',
17+
'created_by',
18+
'body',
19+
'is_internal',
20+
];
21+
22+
protected $casts = [
23+
'is_internal' => 'boolean',
24+
];
25+
26+
public function ticket(): BelongsTo
27+
{
28+
return $this->belongsTo(SupportTicket::class);
29+
}
30+
31+
public function createdBy(): BelongsTo
32+
{
33+
return $this->belongsTo(User::class, 'created_by');
34+
}
35+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Policies;
4+
5+
use App\Models\User;
6+
7+
class SupportTicketPolicy
8+
{
9+
public function viewAny(User $user): bool { return $user->hasPermissionTo('finance.view'); }
10+
public function view(User $user, $model): bool { return $user->hasPermissionTo('finance.view'); }
11+
public function create(User $user): bool { return $user->hasPermissionTo('finance.create'); }
12+
public function update(User $user, $model): bool { return $user->hasPermissionTo('finance.create'); }
13+
public function delete(User $user, $model): bool { return $user->hasPermissionTo('finance.delete'); }
14+
}

erp/app/Modules/Finance/Providers/FinanceServiceProvider.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@
7878
use App\Modules\Finance\Models\LoyaltyEnrollment;
7979
use App\Modules\Finance\Models\LoyaltyTransaction;
8080
use App\Modules\Finance\Policies\LoyaltyPolicy;
81+
use App\Modules\Finance\Models\SupportTicket;
82+
use App\Modules\Finance\Models\TicketComment;
83+
use App\Modules\Finance\Policies\SupportTicketPolicy;
8184
use Illuminate\Support\Facades\Gate;
8285
use Illuminate\Support\ServiceProvider;
8386

@@ -140,6 +143,9 @@ public function boot(): void
140143
Gate::policy(LoyaltyEnrollment::class, LoyaltyPolicy::class);
141144
Gate::policy(LoyaltyTransaction::class, LoyaltyPolicy::class);
142145

146+
Gate::policy(SupportTicket::class, SupportTicketPolicy::class);
147+
Gate::policy(TicketComment::class, SupportTicketPolicy::class);
148+
143149
if ($this->app->runningInConsole()) {
144150
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);
145151
}

erp/app/Modules/Finance/routes/finance.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
use App\Modules\Finance\Http\Controllers\ServiceAgreementController;
4141
use App\Modules\Finance\Http\Controllers\LoyaltyProgramController;
4242
use App\Modules\Finance\Http\Controllers\LeadController;
43+
use App\Modules\Finance\Http\Controllers\SupportTicketController;
4344
use App\Modules\Finance\Http\Controllers\CurrencyController;
4445

4546
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
@@ -296,4 +297,13 @@
296297
Route::post('leads/{lead}/activities', [LeadController::class, 'addActivity'])->name('leads.activities.add');
297298
Route::resource('leads', LeadController::class)->except(['edit']);
298299

300+
// Support Tickets
301+
Route::post('support-tickets/{supportTicket}/resolve', [SupportTicketController::class, 'resolve'])->name('support-tickets.resolve');
302+
Route::post('support-tickets/{supportTicket}/close', [SupportTicketController::class, 'close'])->name('support-tickets.close');
303+
Route::post('support-tickets/{supportTicket}/reopen', [SupportTicketController::class, 'reopen'])->name('support-tickets.reopen');
304+
Route::patch('support-tickets/{supportTicket}/assign', [SupportTicketController::class, 'assign'])->name('support-tickets.assign');
305+
Route::post('support-tickets/{supportTicket}/comments', [SupportTicketController::class, 'addComment'])->name('support-tickets.comments.add');
306+
Route::resource('support-tickets', SupportTicketController::class)->except(['edit', 'update']);
307+
299308
});
309+
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::create('support_tickets', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->string('reference')->unique();
15+
$table->string('subject');
16+
$table->text('description');
17+
$table->string('status')->default('open');
18+
$table->string('priority')->default('normal');
19+
$table->string('category')->nullable();
20+
$table->unsignedBigInteger('contact_id')->nullable();
21+
$table->unsignedBigInteger('assigned_to')->nullable();
22+
$table->unsignedBigInteger('created_by');
23+
$table->timestamp('resolved_at')->nullable();
24+
$table->timestamp('closed_at')->nullable();
25+
$table->timestamps();
26+
$table->softDeletes();
27+
});
28+
}
29+
30+
public function down(): void
31+
{
32+
Schema::dropIfExists('support_tickets');
33+
}
34+
};

0 commit comments

Comments
 (0)