Skip to content

Commit 368cf63

Browse files
committed
feat(core): Phase 56 — Notifications & Alerts inbox
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent a24c6a8 commit 368cf63

14 files changed

Lines changed: 776 additions & 63 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
namespace App\Modules\Core\Http\Controllers;
4+
5+
use App\Modules\Core\Models\NotificationInbox;
6+
use Illuminate\Http\RedirectResponse;
7+
use Illuminate\Http\Request;
8+
use Illuminate\Routing\Controller;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class NotificationInboxController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$user = $request->user();
17+
18+
$notifications = NotificationInbox::where('user_id', $user->id)
19+
->orderByDesc('created_at')
20+
->paginate(20);
21+
22+
$unreadCount = NotificationInbox::where('user_id', $user->id)
23+
->where('is_read', false)
24+
->count();
25+
26+
return Inertia::render('Core/Notifications/Index', [
27+
'notifications' => $notifications,
28+
'unread_count' => $unreadCount,
29+
]);
30+
}
31+
32+
public function markRead(Request $request, NotificationInbox $notification): RedirectResponse
33+
{
34+
if ($notification->user_id !== $request->user()->id) {
35+
abort(403);
36+
}
37+
38+
$notification->markRead();
39+
40+
return back()->with('success', 'Notification marked as read.');
41+
}
42+
43+
public function markAllRead(Request $request): RedirectResponse
44+
{
45+
NotificationInbox::where('user_id', $request->user()->id)
46+
->where('is_read', false)
47+
->update([
48+
'is_read' => true,
49+
'read_at' => now(),
50+
]);
51+
52+
return back()->with('success', 'All notifications marked as read.');
53+
}
54+
55+
public function destroy(Request $request, NotificationInbox $notification): RedirectResponse
56+
{
57+
if ($notification->user_id !== $request->user()->id) {
58+
abort(403);
59+
}
60+
61+
$notification->delete();
62+
63+
return back()->with('success', 'Notification deleted.');
64+
}
65+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
namespace App\Modules\Core\Http\Controllers;
4+
5+
use App\Modules\Core\Models\NotificationRule;
6+
use Illuminate\Http\RedirectResponse;
7+
use Illuminate\Http\Request;
8+
use Illuminate\Routing\Controller;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class NotificationRuleController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$rules = NotificationRule::where('user_id', $request->user()->id)
17+
->orderByDesc('created_at')
18+
->paginate(20);
19+
20+
return Inertia::render('Core/NotificationRules/Index', [
21+
'rules' => $rules,
22+
]);
23+
}
24+
25+
public function create(): Response
26+
{
27+
return Inertia::render('Core/NotificationRules/Create');
28+
}
29+
30+
public function store(Request $request): RedirectResponse
31+
{
32+
$validated = $request->validate([
33+
'name' => ['required', 'string', 'max:255'],
34+
'event_type' => ['required', 'string', 'max:100'],
35+
'conditions' => ['nullable', 'array'],
36+
]);
37+
38+
NotificationRule::create(array_merge($validated, [
39+
'user_id' => auth()->id(),
40+
]));
41+
42+
return redirect()->route('notification-rules.index')
43+
->with('success', 'Notification rule created.');
44+
}
45+
46+
public function destroy(Request $request, NotificationRule $notificationRule): RedirectResponse
47+
{
48+
if ($notificationRule->user_id !== $request->user()->id) {
49+
abort(403);
50+
}
51+
52+
$notificationRule->delete();
53+
54+
return redirect()->route('notification-rules.index')
55+
->with('success', 'Notification rule deleted.');
56+
}
57+
58+
public function toggle(Request $request, NotificationRule $notificationRule): RedirectResponse
59+
{
60+
if ($notificationRule->user_id !== $request->user()->id) {
61+
abort(403);
62+
}
63+
64+
$notificationRule->update(['is_active' => ! $notificationRule->is_active]);
65+
66+
return back()->with('success', 'Notification rule updated.');
67+
}
68+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
namespace App\Modules\Core\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 NotificationInbox extends Model
11+
{
12+
use BelongsToTenant;
13+
14+
public const UPDATED_AT = null;
15+
16+
public $timestamps = false;
17+
18+
protected $table = 'notification_inbox';
19+
20+
protected $fillable = [
21+
'tenant_id',
22+
'user_id',
23+
'title',
24+
'body',
25+
'type',
26+
'link',
27+
'is_read',
28+
'read_at',
29+
'created_at',
30+
];
31+
32+
protected $casts = [
33+
'is_read' => 'boolean',
34+
'read_at' => 'datetime',
35+
'created_at' => 'datetime',
36+
];
37+
38+
public function user(): BelongsTo
39+
{
40+
return $this->belongsTo(User::class);
41+
}
42+
43+
public function markRead(): void
44+
{
45+
$this->is_read = true;
46+
$this->read_at = now();
47+
$this->save();
48+
}
49+
50+
public static function send(
51+
int $tenantId,
52+
int $userId,
53+
string $title,
54+
string $type,
55+
?string $body = null,
56+
?string $link = null
57+
): self {
58+
$notification = new self();
59+
$notification->tenant_id = $tenantId;
60+
$notification->user_id = $userId;
61+
$notification->title = $title;
62+
$notification->type = $type;
63+
$notification->body = $body;
64+
$notification->link = $link;
65+
$notification->is_read = false;
66+
$notification->created_at = now();
67+
$notification->save();
68+
69+
return $notification;
70+
}
71+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace App\Modules\Core\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Builder;
8+
use Illuminate\Database\Eloquent\Model;
9+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
10+
11+
class NotificationRule extends Model
12+
{
13+
use BelongsToTenant;
14+
15+
protected $fillable = [
16+
'tenant_id',
17+
'user_id',
18+
'name',
19+
'event_type',
20+
'conditions',
21+
'is_active',
22+
];
23+
24+
protected $casts = [
25+
'conditions' => 'array',
26+
'is_active' => 'boolean',
27+
];
28+
29+
public function user(): BelongsTo
30+
{
31+
return $this->belongsTo(User::class);
32+
}
33+
34+
public function scopeActive(Builder $query): Builder
35+
{
36+
return $query->where('is_active', true);
37+
}
38+
}

erp/app/Modules/Core/routes/core.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
use App\Http\Controllers\AnalyticsController;
66
use App\Http\Controllers\DashboardController;
77
use App\Http\Controllers\ExportController;
8-
use App\Http\Controllers\NotificationController;
8+
use App\Modules\Core\Http\Controllers\NotificationInboxController;
99
use App\Http\Controllers\SearchController;
1010
use App\Http\Controllers\SettingController;
1111
use Illuminate\Support\Facades\Route;
@@ -15,10 +15,10 @@
1515

1616
Route::get('/analytics', [AnalyticsController::class, 'index'])->name('analytics');
1717

18-
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
19-
Route::patch('/notifications/{id}/read', [NotificationController::class, 'markRead'])->name('notifications.read');
20-
Route::patch('/notifications/read-all', [NotificationController::class, 'markAllRead'])->name('notifications.read-all');
21-
Route::delete('/notifications/{id}', [NotificationController::class, 'destroy'])->name('notifications.destroy');
18+
Route::get('/notifications', [NotificationInboxController::class, 'index'])->name('notifications.index');
19+
Route::post('/notifications/mark-all-read', [NotificationInboxController::class, 'markAllRead'])->name('notifications.mark-all-read');
20+
Route::patch('/notifications/{notification}/read', [NotificationInboxController::class, 'markRead'])->name('notifications.read');
21+
Route::delete('/notifications/{notification}', [NotificationInboxController::class, 'destroy'])->name('notifications.destroy');
2222

2323
Route::get('/search', SearchController::class)->name('search');
2424

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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('notification_rules', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id')->index();
14+
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
15+
$table->string('name');
16+
$table->string('event_type', 100);
17+
$table->json('conditions')->nullable();
18+
$table->boolean('is_active')->default(true);
19+
$table->timestamps();
20+
});
21+
}
22+
23+
public function down(): void
24+
{
25+
Schema::dropIfExists('notification_rules');
26+
}
27+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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('notification_inbox', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id')->index();
14+
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
15+
$table->string('title');
16+
$table->text('body')->nullable();
17+
$table->string('type');
18+
$table->string('link')->nullable();
19+
$table->boolean('is_read')->default(false);
20+
$table->timestamp('read_at')->nullable();
21+
$table->timestamp('created_at');
22+
});
23+
}
24+
25+
public function down(): void
26+
{
27+
Schema::dropIfExists('notification_inbox');
28+
}
29+
};

erp/resources/js/Components/Layout/Sidebar.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ const analyticsIcon = (
2727
</svg>
2828
);
2929

30+
const notificationBellIcon = (
31+
<svg className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
32+
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
33+
</svg>
34+
);
35+
3036
const navItems: NavItem[] = [
3137
{
3238
label: 'Dashboard',
@@ -37,6 +43,11 @@ const navItems: NavItem[] = [
3743
</svg>
3844
),
3945
},
46+
{
47+
label: 'Notifications',
48+
href: '/notifications',
49+
icon: notificationBellIcon,
50+
},
4051
{
4152
label: 'Inventory',
4253
href: '/inventory/products',

0 commit comments

Comments
 (0)