Skip to content

Commit 2adc2bb

Browse files
committed
Phases 181-185: Helpdesk Module — 19 tests passing
4 migrations (helpdesk_teams+pivot, helpdesk_tickets, helpdesk_messages, helpdesk_sla_policies), 4 models (Team/Ticket/Message/SlaPolicy), HelpdeskPolicy, 3 controllers (Dashboard/Ticket/Team), HelpdeskServiceProvider, 5 React pages (Dashboard, Tickets CRUD + Show with message thread, Teams), Sidebar section. SLA deadline auto-set from policy, overdue detection, resolve/close/reopen/reply actions, internal notes. 19/19 feature tests passing. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent bdfd1e4 commit 2adc2bb

23 files changed

Lines changed: 1999 additions & 0 deletions

erp/app/Modules/Core/Providers/CoreServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use App\Modules\CRM\Providers\CRMServiceProvider;
1414
use App\Modules\PM\Providers\PMServiceProvider;
1515
use App\Modules\POS\Providers\POSServiceProvider;
16+
use App\Modules\Helpdesk\Providers\HelpdeskServiceProvider;
1617
use Illuminate\Support\Facades\Gate;
1718
use Illuminate\Support\ServiceProvider;
1819

@@ -27,6 +28,7 @@ public function register(): void
2728
$this->app->register(CRMServiceProvider::class);
2829
$this->app->register(PMServiceProvider::class);
2930
$this->app->register(POSServiceProvider::class);
31+
$this->app->register(HelpdeskServiceProvider::class);
3032
}
3133

3234
public function boot(): void
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
namespace App\Modules\Helpdesk\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Helpdesk\Models\HelpdeskTicket;
7+
use Illuminate\Support\Facades\DB;
8+
use Inertia\Inertia;
9+
use Inertia\Response;
10+
11+
class HelpdeskDashboardController extends Controller
12+
{
13+
public function index(): Response
14+
{
15+
$openTickets = HelpdeskTicket::where('status', 'open')->count();
16+
17+
$overdueCount = HelpdeskTicket::whereNotNull('sla_deadline')
18+
->where('sla_deadline', '<', now())
19+
->whereNotIn('status', ['resolved', 'closed'])
20+
->count();
21+
22+
$avgResolutionHours = HelpdeskTicket::where('status', 'resolved')
23+
->whereNotNull('resolved_at')
24+
->select(DB::raw('AVG((julianday(resolved_at) - julianday(created_at)) * 24) as avg_hours'))
25+
->value('avg_hours');
26+
27+
$resolvedToday = HelpdeskTicket::where('status', 'resolved')
28+
->whereDate('resolved_at', today())
29+
->count();
30+
31+
$byPriority = HelpdeskTicket::whereNotIn('status', ['resolved', 'closed'])
32+
->select('priority', DB::raw('count(*) as count'))
33+
->groupBy('priority')
34+
->pluck('count', 'priority');
35+
36+
$byStatus = HelpdeskTicket::select('status', DB::raw('count(*) as count'))
37+
->groupBy('status')
38+
->pluck('count', 'status');
39+
40+
$recentTickets = HelpdeskTicket::with(['team', 'assignee'])
41+
->orderByDesc('created_at')
42+
->limit(10)
43+
->get();
44+
45+
return Inertia::render('Helpdesk/Dashboard', [
46+
'stats' => [
47+
'openTickets' => $openTickets,
48+
'overdueCount' => $overdueCount,
49+
'avgResolutionHours' => round((float) $avgResolutionHours, 1),
50+
'resolvedToday' => $resolvedToday,
51+
],
52+
'byPriority' => $byPriority,
53+
'byStatus' => $byStatus,
54+
'recentTickets' => $recentTickets,
55+
]);
56+
}
57+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
namespace App\Modules\Helpdesk\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Helpdesk\Models\HelpdeskTeam;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class HelpdeskTeamController extends Controller
13+
{
14+
public function index(): Response
15+
{
16+
$teams = HelpdeskTeam::withCount('tickets')
17+
->orderBy('name')
18+
->get();
19+
20+
return Inertia::render('Helpdesk/Teams/Index', [
21+
'teams' => $teams,
22+
]);
23+
}
24+
25+
public function store(Request $request): RedirectResponse
26+
{
27+
$validated = $request->validate([
28+
'name' => 'required|string|max:255',
29+
'description' => 'nullable|string',
30+
'auto_assign' => 'boolean',
31+
'is_active' => 'boolean',
32+
]);
33+
34+
HelpdeskTeam::create([
35+
...$validated,
36+
'tenant_id' => auth()->user()->tenant_id,
37+
]);
38+
39+
return redirect()->back()->with('success', 'Team created.');
40+
}
41+
42+
public function update(Request $request, HelpdeskTeam $team): RedirectResponse
43+
{
44+
$validated = $request->validate([
45+
'name' => 'required|string|max:255',
46+
'description' => 'nullable|string',
47+
'auto_assign' => 'boolean',
48+
'is_active' => 'boolean',
49+
]);
50+
51+
$team->update($validated);
52+
53+
return redirect()->back()->with('success', 'Team updated.');
54+
}
55+
56+
public function destroy(HelpdeskTeam $team): RedirectResponse
57+
{
58+
$team->delete();
59+
60+
return redirect()->back()->with('success', 'Team deleted.');
61+
}
62+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<?php
2+
3+
namespace App\Modules\Helpdesk\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Models\User;
7+
use App\Modules\Helpdesk\Models\HelpdeskSlaPolicy;
8+
use App\Modules\Helpdesk\Models\HelpdeskTeam;
9+
use App\Modules\Helpdesk\Models\HelpdeskTicket;
10+
use Illuminate\Http\RedirectResponse;
11+
use Illuminate\Http\Request;
12+
use Inertia\Inertia;
13+
use Inertia\Response;
14+
15+
class HelpdeskTicketController extends Controller
16+
{
17+
public function index(Request $request): Response
18+
{
19+
$tickets = HelpdeskTicket::with(['team', 'assignee'])
20+
->when($request->status, fn ($q) => $q->where('status', $request->status))
21+
->when($request->priority, fn ($q) => $q->where('priority', $request->priority))
22+
->when($request->team_id, fn ($q) => $q->where('team_id', $request->team_id))
23+
->when($request->assigned_to, fn ($q) => $q->where('assigned_to', $request->assigned_to))
24+
->when($request->search, fn ($q) => $q->where(function ($q2) use ($request) {
25+
$q2->where('subject', 'like', "%{$request->search}%")
26+
->orWhere('customer_name', 'like', "%{$request->search}%");
27+
}))
28+
->orderByDesc('created_at')
29+
->paginate(25)
30+
->withQueryString();
31+
32+
return Inertia::render('Helpdesk/Tickets/Index', [
33+
'tickets' => $tickets,
34+
'filters' => $request->only(['status', 'priority', 'team_id', 'assigned_to', 'search']),
35+
]);
36+
}
37+
38+
public function create(): Response
39+
{
40+
return Inertia::render('Helpdesk/Tickets/Create', [
41+
'teams' => HelpdeskTeam::where('is_active', true)->orderBy('name')->get(['id', 'name']),
42+
'users' => User::orderBy('name')->get(['id', 'name']),
43+
]);
44+
}
45+
46+
public function store(Request $request): RedirectResponse
47+
{
48+
$validated = $request->validate([
49+
'subject' => 'required|string|max:255',
50+
'description' => 'nullable|string',
51+
'type' => 'required|in:question,issue,feature_request,other',
52+
'priority' => 'required|in:low,medium,high,urgent',
53+
'team_id' => 'nullable|exists:helpdesk_teams,id',
54+
'assigned_to' => 'nullable|exists:users,id',
55+
'customer_name' => 'nullable|string|max:255',
56+
'customer_email' => 'nullable|email|max:255',
57+
]);
58+
59+
$ticket = HelpdeskTicket::create([
60+
...$validated,
61+
'tenant_id' => auth()->user()->tenant_id,
62+
'created_by' => auth()->id(),
63+
'status' => 'open',
64+
]);
65+
66+
$ticket->ticket_number = $ticket->generateTicketNumber();
67+
$ticket->saveQuietly();
68+
69+
// Set SLA deadline
70+
$slaPolicy = HelpdeskSlaPolicy::where('priority', $ticket->priority)
71+
->where('is_active', true)
72+
->first();
73+
74+
if ($slaPolicy) {
75+
$ticket->sla_deadline = now()->addHours($slaPolicy->resolution_hours);
76+
$ticket->saveQuietly();
77+
}
78+
79+
return redirect()->route('helpdesk.tickets.show', $ticket)->with('success', 'Ticket created.');
80+
}
81+
82+
public function show(HelpdeskTicket $ticket): Response
83+
{
84+
$ticket->load(['team', 'assignee', 'messages.author']);
85+
86+
return Inertia::render('Helpdesk/Tickets/Show', [
87+
'ticket' => $ticket,
88+
'teams' => HelpdeskTeam::where('is_active', true)->orderBy('name')->get(['id', 'name']),
89+
'users' => User::orderBy('name')->get(['id', 'name']),
90+
]);
91+
}
92+
93+
public function edit(HelpdeskTicket $ticket): Response
94+
{
95+
return Inertia::render('Helpdesk/Tickets/Edit', [
96+
'ticket' => $ticket,
97+
'teams' => HelpdeskTeam::where('is_active', true)->orderBy('name')->get(['id', 'name']),
98+
'users' => User::orderBy('name')->get(['id', 'name']),
99+
]);
100+
}
101+
102+
public function update(Request $request, HelpdeskTicket $ticket): RedirectResponse
103+
{
104+
$validated = $request->validate([
105+
'subject' => 'required|string|max:255',
106+
'description' => 'nullable|string',
107+
'type' => 'required|in:question,issue,feature_request,other',
108+
'priority' => 'required|in:low,medium,high,urgent',
109+
'status' => 'required|in:open,in_progress,pending,resolved,closed',
110+
'team_id' => 'nullable|exists:helpdesk_teams,id',
111+
'assigned_to' => 'nullable|exists:users,id',
112+
'customer_name' => 'nullable|string|max:255',
113+
'customer_email' => 'nullable|email|max:255',
114+
]);
115+
116+
$ticket->update($validated);
117+
118+
return redirect()->route('helpdesk.tickets.show', $ticket)->with('success', 'Ticket updated.');
119+
}
120+
121+
public function destroy(HelpdeskTicket $ticket): RedirectResponse
122+
{
123+
$ticket->delete();
124+
125+
return redirect()->route('helpdesk.tickets.index')->with('success', 'Ticket deleted.');
126+
}
127+
128+
public function resolve(HelpdeskTicket $ticket): RedirectResponse
129+
{
130+
$ticket->resolve();
131+
132+
return redirect()->back()->with('success', 'Ticket resolved.');
133+
}
134+
135+
public function close(HelpdeskTicket $ticket): RedirectResponse
136+
{
137+
$ticket->close();
138+
139+
return redirect()->back()->with('success', 'Ticket closed.');
140+
}
141+
142+
public function reopen(HelpdeskTicket $ticket): RedirectResponse
143+
{
144+
$ticket->reopen();
145+
146+
return redirect()->back()->with('success', 'Ticket reopened.');
147+
}
148+
149+
public function reply(Request $request, HelpdeskTicket $ticket): RedirectResponse
150+
{
151+
$validated = $request->validate([
152+
'body' => 'required|string',
153+
'is_internal' => 'boolean',
154+
]);
155+
156+
$ticket->messages()->create([
157+
'body' => $validated['body'],
158+
'is_internal' => $validated['is_internal'] ?? false,
159+
'author_id' => auth()->id(),
160+
]);
161+
162+
$ticket->recordFirstResponse();
163+
164+
return redirect()->back()->with('success', 'Reply sent.');
165+
}
166+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace App\Modules\Helpdesk\Models;
4+
5+
use App\Models\User;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class HelpdeskMessage extends Model
10+
{
11+
protected $table = 'helpdesk_messages';
12+
13+
protected $fillable = [
14+
'ticket_id',
15+
'body',
16+
'is_internal',
17+
'author_id',
18+
];
19+
20+
protected $casts = [
21+
'is_internal' => 'boolean',
22+
];
23+
24+
public function ticket(): BelongsTo
25+
{
26+
return $this->belongsTo(HelpdeskTicket::class, 'ticket_id');
27+
}
28+
29+
public function author(): BelongsTo
30+
{
31+
return $this->belongsTo(User::class, 'author_id');
32+
}
33+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
namespace App\Modules\Helpdesk\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
8+
class HelpdeskSlaPolicy extends Model
9+
{
10+
use BelongsToTenant;
11+
12+
protected $table = 'helpdesk_sla_policies';
13+
14+
protected $fillable = [
15+
'tenant_id',
16+
'name',
17+
'priority',
18+
'response_hours',
19+
'resolution_hours',
20+
'is_active',
21+
];
22+
23+
protected $casts = [
24+
'response_hours' => 'integer',
25+
'resolution_hours' => 'integer',
26+
'is_active' => 'boolean',
27+
];
28+
}

0 commit comments

Comments
 (0)