Skip to content

Commit 0b216f7

Browse files
committed
feat(phase-31): unified calendar api aggregating multi-module events
- CalendarController: GET /api/v1/calendar with from/to date range + types filter - Aggregates: PM tasks (by due_date/start_date), HR leaves (approved/pending), Events (starts_at/ends_at), Invoice due dates (non-paid invoices) - Each item normalized to: id, type, title, start, end, color, status, source_url - Tasks color-coded by status (green=done, blue=in_progress, purple=review, grey=other) - Invoice items highlighted in red (overdue/urgent context) - Supports ?types[]=tasks&types[]=leaves&types[]=events&types[]=invoices filter - Events sorted by start date ascending - 6 feature tests: range query, tasks, events, invoices, type filter, auth Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 9fde9fa commit 0b216f7

3 files changed

Lines changed: 269 additions & 0 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use Illuminate\Http\JsonResponse;
6+
use Illuminate\Http\Request;
7+
use Illuminate\Support\Carbon;
8+
use Illuminate\Support\Collection;
9+
use App\Modules\Appointments\Models\Appointment;
10+
use App\Modules\Appointments\Models\AppointmentSlot;
11+
use App\Modules\Events\Models\Event;
12+
use App\Modules\HR\Models\LeaveRequest;
13+
use App\Modules\PM\Models\Task;
14+
use App\Modules\Finance\Models\Invoice;
15+
16+
class CalendarController extends ApiController
17+
{
18+
public function index(Request $request): JsonResponse
19+
{
20+
$tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
21+
$from = Carbon::parse($request->input('from', now()->startOfMonth()));
22+
$to = Carbon::parse($request->input('to', now()->endOfMonth()));
23+
$types = $request->input('types', ['tasks', 'leaves', 'events', 'invoices']);
24+
25+
$events = collect();
26+
27+
if (in_array('tasks', $types)) {
28+
$events = $events->merge($this->getTasks($tenantId, $from, $to));
29+
}
30+
if (in_array('leaves', $types)) {
31+
$events = $events->merge($this->getLeaves($tenantId, $from, $to));
32+
}
33+
if (in_array('events', $types)) {
34+
$events = $events->merge($this->getEvents($tenantId, $from, $to));
35+
}
36+
if (in_array('invoices', $types)) {
37+
$events = $events->merge($this->getInvoiceDueDates($tenantId, $from, $to));
38+
}
39+
40+
$sorted = $events->sortBy('start')->values();
41+
42+
return $this->success([
43+
'from' => $from->toDateString(),
44+
'to' => $to->toDateString(),
45+
'total' => $sorted->count(),
46+
'events' => $sorted,
47+
]);
48+
}
49+
50+
private function getTasks(int $tenantId, Carbon $from, Carbon $to): Collection
51+
{
52+
return Task::where('tenant_id', $tenantId)
53+
->where(function ($q) use ($from, $to) {
54+
$q->whereBetween('due_date', [$from, $to])
55+
->orWhereBetween('start_date', [$from, $to]);
56+
})
57+
->get()
58+
->map(fn ($task) => [
59+
'id' => "task-{$task->id}",
60+
'type' => 'task',
61+
'title' => $task->title,
62+
'start' => $task->start_date?->toDateString() ?? $task->due_date?->toDateString(),
63+
'end' => $task->due_date?->toDateString(),
64+
'color' => $this->taskColor($task->status),
65+
'status' => $task->status,
66+
'priority' => $task->priority,
67+
'source_id' => $task->id,
68+
'source_url' => "/pm/tasks/{$task->id}",
69+
]);
70+
}
71+
72+
private function getLeaves(int $tenantId, Carbon $from, Carbon $to): Collection
73+
{
74+
return LeaveRequest::where('tenant_id', $tenantId)
75+
->whereIn('status', ['approved', 'pending'])
76+
->where(function ($q) use ($from, $to) {
77+
$q->whereBetween('start_date', [$from, $to])
78+
->orWhereBetween('end_date', [$from, $to]);
79+
})
80+
->with('employee:id,first_name,last_name')
81+
->get()
82+
->map(fn ($leave) => [
83+
'id' => "leave-{$leave->id}",
84+
'type' => 'leave',
85+
'title' => ($leave->employee ? "{$leave->employee->first_name} {$leave->employee->last_name}" : 'Employee') . ' – Leave',
86+
'start' => $leave->start_date?->toDateString(),
87+
'end' => $leave->end_date?->toDateString(),
88+
'color' => $leave->status === 'approved' ? '#f59e0b' : '#6b7280',
89+
'status' => $leave->status,
90+
'source_id' => $leave->id,
91+
'source_url' => "/hr/leaves/{$leave->id}",
92+
]);
93+
}
94+
95+
private function getEvents(int $tenantId, Carbon $from, Carbon $to): Collection
96+
{
97+
return Event::where('tenant_id', $tenantId)
98+
->where(function ($q) use ($from, $to) {
99+
$q->whereBetween('starts_at', [$from, $to])
100+
->orWhereBetween('ends_at', [$from, $to]);
101+
})
102+
->get()
103+
->map(fn ($event) => [
104+
'id' => "event-{$event->id}",
105+
'type' => 'event',
106+
'title' => $event->title,
107+
'start' => $event->starts_at?->toIso8601String(),
108+
'end' => $event->ends_at?->toIso8601String(),
109+
'color' => '#6366f1',
110+
'source_id' => $event->id,
111+
'source_url' => "/events/{$event->id}",
112+
]);
113+
}
114+
115+
private function getInvoiceDueDates(int $tenantId, Carbon $from, Carbon $to): Collection
116+
{
117+
return Invoice::where('tenant_id', $tenantId)
118+
->whereNotIn('status', ['paid', 'cancelled'])
119+
->whereBetween('due_date', [$from, $to])
120+
->with('contact:id,name')
121+
->get()
122+
->map(fn ($inv) => [
123+
'id' => "invoice-{$inv->id}",
124+
'type' => 'invoice_due',
125+
'title' => "Invoice {$inv->number} due",
126+
'start' => $inv->due_date?->toDateString(),
127+
'end' => $inv->due_date?->toDateString(),
128+
'color' => '#dc2626',
129+
'status' => $inv->status,
130+
'amount' => $inv->total,
131+
'source_id' => $inv->id,
132+
'source_url' => "/finance/invoices/{$inv->id}",
133+
]);
134+
}
135+
136+
private function taskColor(string $status): string
137+
{
138+
return match ($status) {
139+
'done', 'completed' => '#16a34a',
140+
'in_progress' => '#2563eb',
141+
'review' => '#9333ea',
142+
default => '#6b7280',
143+
};
144+
}
145+
}

erp/routes/api.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,9 @@
329329
// Audit Logs
330330
Route::get('/audit-logs', [\App\Http\Controllers\Api\V1\AuditLogController::class, 'index']);
331331

332+
// Unified Calendar
333+
Route::get('/calendar', [\App\Http\Controllers\Api\V1\CalendarController::class, 'index']);
334+
332335
// Activity Feed
333336
Route::prefix('activity')->group(function () {
334337
Route::get('/', [\App\Http\Controllers\Api\V1\ActivityFeedController::class, 'index']);
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use App\Modules\Events\Models\Event;
6+
use App\Modules\Finance\Models\Contact;
7+
use App\Modules\Finance\Models\Invoice;
8+
use App\Modules\PM\Models\Project;
9+
use App\Modules\PM\Models\Task;
10+
use Database\Seeders\RolePermissionSeeder;
11+
12+
beforeEach(function () {
13+
$this->seed(RolePermissionSeeder::class);
14+
$this->tenant = Tenant::create(['name' => 'Calendar Co', 'slug' => 'cal-co-' . uniqid()]);
15+
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
16+
$this->user->assignRole('super-admin');
17+
$this->token = $this->user->createToken('test')->plainTextToken;
18+
app()->instance('tenant', $this->tenant);
19+
});
20+
21+
test('calendar returns events within date range', function () {
22+
$response = $this->withToken($this->token)->getJson('/api/v1/calendar?from=2025-01-01&to=2025-12-31');
23+
$response->assertStatus(200);
24+
expect($response->json('data'))->toHaveKeys(['from', 'to', 'total', 'events']);
25+
});
26+
27+
test('calendar includes pm tasks due in range', function () {
28+
$project = Project::create([
29+
'tenant_id' => $this->tenant->id,
30+
'name' => 'Test Project',
31+
'status' => 'active',
32+
'start_date' => now()->subDays(10),
33+
'end_date' => now()->addDays(10),
34+
]);
35+
36+
Task::create([
37+
'tenant_id' => $this->tenant->id,
38+
'project_id' => $project->id,
39+
'title' => 'Due Task',
40+
'status' => 'open',
41+
'due_date' => now()->addDays(5),
42+
'priority' => 'high',
43+
]);
44+
45+
$from = now()->subDay()->toDateString();
46+
$to = now()->addDays(10)->toDateString();
47+
48+
$response = $this->withToken($this->token)->getJson("/api/v1/calendar?from={$from}&to={$to}&types[]=tasks");
49+
$response->assertStatus(200);
50+
51+
$events = collect($response->json('data.events'));
52+
$task = $events->firstWhere('type', 'task');
53+
expect($task)->not->toBeNull();
54+
expect($task['title'])->toBe('Due Task');
55+
});
56+
57+
test('calendar includes events in range', function () {
58+
Event::create([
59+
'tenant_id' => $this->tenant->id,
60+
'title' => 'Company All-Hands',
61+
'starts_at' => now()->addDays(3),
62+
'ends_at' => now()->addDays(3)->addHours(2),
63+
'status' => 'published',
64+
'max_attendees' => 100,
65+
]);
66+
67+
$from = now()->toDateString();
68+
$to = now()->addDays(7)->toDateString();
69+
70+
$response = $this->withToken($this->token)->getJson("/api/v1/calendar?from={$from}&to={$to}&types[]=events");
71+
$response->assertStatus(200);
72+
73+
$events = collect($response->json('data.events'));
74+
$event = $events->firstWhere('type', 'event');
75+
expect($event)->not->toBeNull();
76+
expect($event['title'])->toBe('Company All-Hands');
77+
});
78+
79+
test('calendar includes invoice due dates', function () {
80+
$contact = Contact::create([
81+
'tenant_id' => $this->tenant->id,
82+
'name' => 'Calendar Customer',
83+
'type' => 'customer',
84+
]);
85+
86+
Invoice::create([
87+
'tenant_id' => $this->tenant->id,
88+
'contact_id' => $contact->id,
89+
'number' => 'CAL-001',
90+
'issue_date' => now(),
91+
'due_date' => now()->addDays(5),
92+
'status' => 'sent',
93+
'subtotal' => 500,
94+
'tax' => 0,
95+
'total' => 500,
96+
]);
97+
98+
$from = now()->toDateString();
99+
$to = now()->addDays(10)->toDateString();
100+
101+
$response = $this->withToken($this->token)->getJson("/api/v1/calendar?from={$from}&to={$to}&types[]=invoices");
102+
$response->assertStatus(200);
103+
104+
$events = collect($response->json('data.events'));
105+
$invoice = $events->firstWhere('type', 'invoice_due');
106+
expect($invoice)->not->toBeNull();
107+
expect($invoice['title'])->toContain('CAL-001');
108+
});
109+
110+
test('calendar can filter by type', function () {
111+
$response = $this->withToken($this->token)->getJson('/api/v1/calendar?types[]=tasks&from=2025-01-01&to=2025-12-31');
112+
$response->assertStatus(200);
113+
114+
foreach ($response->json('data.events') as $event) {
115+
expect($event['type'])->toBe('task');
116+
}
117+
});
118+
119+
test('calendar requires authentication', function () {
120+
$this->getJson('/api/v1/calendar')->assertStatus(401);
121+
});

0 commit comments

Comments
 (0)