Skip to content

Commit 00297a8

Browse files
committed
feat(hr): Phase 107 — HR Announcements & Company Notices
Implements full HR Announcements module with migration, model (publish/archive/is_active), policy (hr.view/create/delete), controller (index/store/show/publish/archive/destroy), routes, HRServiceProvider registration, React pages (Index/Show), TypeScript types, sidebar link, and 10 Pest tests (all passing, total 1110). https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 4cfae70 commit 00297a8

11 files changed

Lines changed: 536 additions & 0 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\HrAnnouncement;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class HrAnnouncementController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$this->authorize('viewAny', HrAnnouncement::class);
17+
18+
$announcements = HrAnnouncement::with('createdBy')
19+
->when($request->target_audience, fn ($q) => $q->where('target_audience', $request->target_audience))
20+
->latest()
21+
->paginate(20)
22+
->withQueryString();
23+
24+
return Inertia::render('HR/Announcements/Index', [
25+
'announcements' => $announcements,
26+
'filters' => $request->only(['target_audience']),
27+
]);
28+
}
29+
30+
public function store(Request $request): RedirectResponse
31+
{
32+
$this->authorize('create', HrAnnouncement::class);
33+
34+
$validated = $request->validate([
35+
'title' => 'required|string|max:255',
36+
'body' => 'required|string',
37+
'target_audience' => 'nullable|string|in:all,department,role',
38+
'department_id' => 'nullable|exists:departments,id',
39+
'priority' => 'nullable|string|in:low,normal,high,urgent',
40+
'publish_at' => 'nullable|date',
41+
'expire_at' => 'nullable|date',
42+
]);
43+
44+
$announcement = HrAnnouncement::create([
45+
'tenant_id' => auth()->user()->tenant_id,
46+
'created_by' => auth()->id(),
47+
'title' => $validated['title'],
48+
'body' => $validated['body'],
49+
'target_audience' => $validated['target_audience'] ?? 'all',
50+
'department_id' => $validated['department_id'] ?? null,
51+
'priority' => $validated['priority'] ?? 'normal',
52+
'publish_at' => $validated['publish_at'] ?? null,
53+
'expire_at' => $validated['expire_at'] ?? null,
54+
]);
55+
56+
return redirect()->route('hr.announcements.show', $announcement);
57+
}
58+
59+
public function show(HrAnnouncement $announcement): Response
60+
{
61+
$this->authorize('view', $announcement);
62+
63+
$announcement->load(['createdBy', 'department']);
64+
65+
return Inertia::render('HR/Announcements/Show', [
66+
'announcement' => $announcement,
67+
]);
68+
}
69+
70+
public function publish(HrAnnouncement $announcement): RedirectResponse
71+
{
72+
$this->authorize('update', $announcement);
73+
74+
$announcement->publish();
75+
76+
return redirect()->back()->with('success', 'Announcement published successfully.');
77+
}
78+
79+
public function archive(HrAnnouncement $announcement): RedirectResponse
80+
{
81+
$this->authorize('update', $announcement);
82+
83+
$announcement->archive();
84+
85+
return redirect()->back()->with('success', 'Announcement archived successfully.');
86+
}
87+
88+
public function destroy(HrAnnouncement $announcement): RedirectResponse
89+
{
90+
$this->authorize('delete', $announcement);
91+
92+
$announcement->delete();
93+
94+
return redirect()->route('hr.announcements.index');
95+
}
96+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
namespace App\Modules\HR\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\SoftDeletes;
10+
11+
class HrAnnouncement extends Model
12+
{
13+
use BelongsToTenant, SoftDeletes;
14+
15+
protected $fillable = [
16+
'tenant_id', 'title', 'body', 'target_audience', 'department_id',
17+
'is_published', 'publish_at', 'expire_at', 'created_by', 'priority',
18+
];
19+
20+
protected $casts = [
21+
'is_published' => 'boolean',
22+
'publish_at' => 'datetime',
23+
'expire_at' => 'datetime',
24+
];
25+
26+
public function department(): BelongsTo
27+
{
28+
return $this->belongsTo(Department::class);
29+
}
30+
31+
public function createdBy(): BelongsTo
32+
{
33+
return $this->belongsTo(User::class, 'created_by');
34+
}
35+
36+
public function publish(): void
37+
{
38+
$this->is_published = true;
39+
$this->publish_at = $this->publish_at ?? now();
40+
$this->save();
41+
}
42+
43+
public function archive(): void
44+
{
45+
$this->is_published = false;
46+
$this->expire_at = now();
47+
$this->save();
48+
}
49+
50+
public function getIsActiveAttribute(): bool
51+
{
52+
if (! $this->is_published) {
53+
return false;
54+
}
55+
if ($this->expire_at !== null && $this->expire_at->isPast()) {
56+
return false;
57+
}
58+
return true;
59+
}
60+
}
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\HR\Policies;
4+
5+
use App\Models\User;
6+
7+
class HrAnnouncementPolicy
8+
{
9+
public function viewAny(User $user): bool
10+
{
11+
return $user->hasPermissionTo('hr.view');
12+
}
13+
14+
public function view(User $user, $model): bool
15+
{
16+
return $user->hasPermissionTo('hr.view');
17+
}
18+
19+
public function create(User $user): bool
20+
{
21+
return $user->hasPermissionTo('hr.create');
22+
}
23+
24+
public function update(User $user, $model): bool
25+
{
26+
return $user->hasPermissionTo('hr.create');
27+
}
28+
29+
public function delete(User $user, $model): bool
30+
{
31+
return $user->hasPermissionTo('hr.delete');
32+
}
33+
}

erp/app/Modules/HR/Providers/HRServiceProvider.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@
6464
use App\Modules\HR\Models\SkillDefinition;
6565
use App\Modules\HR\Models\EmployeeSkill;
6666
use App\Modules\HR\Policies\EmployeeSkillPolicy;
67+
use App\Modules\HR\Models\HrAnnouncement;
68+
use App\Modules\HR\Policies\HrAnnouncementPolicy;
6769
use Illuminate\Support\Facades\Gate;
6870
use Illuminate\Support\ServiceProvider;
6971

@@ -115,5 +117,6 @@ public function boot(): void
115117
Gate::policy(EmployeeDocument::class, EmployeeDocumentPolicy::class);
116118
Gate::policy(SkillDefinition::class, EmployeeSkillPolicy::class);
117119
Gate::policy(EmployeeSkill::class, EmployeeSkillPolicy::class);
120+
Gate::policy(HrAnnouncement::class, HrAnnouncementPolicy::class);
118121
}
119122
}

erp/app/Modules/HR/routes/hr.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,11 @@
220220
Route::resource('employee-skills', EmployeeSkillController::class)->only(['index', 'store', 'show', 'destroy']);
221221
Route::resource('skill-definitions', SkillDefinitionController::class)->only(['index', 'store', 'destroy']);
222222
});
223+
224+
// HR Announcements
225+
use App\Modules\HR\Http\Controllers\HrAnnouncementController;
226+
Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () {
227+
Route::post('announcements/{announcement}/publish', [HrAnnouncementController::class, 'publish'])->name('announcements.publish');
228+
Route::post('announcements/{announcement}/archive', [HrAnnouncementController::class, 'archive'])->name('announcements.archive');
229+
Route::resource('announcements', HrAnnouncementController::class)->only(['index', 'store', 'show', 'destroy']);
230+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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('hr_announcements', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->string('title');
15+
$table->text('body');
16+
$table->string('target_audience')->default('all'); // all, department, role
17+
$table->unsignedBigInteger('department_id')->nullable();
18+
$table->boolean('is_published')->default(false);
19+
$table->timestamp('publish_at')->nullable();
20+
$table->timestamp('expire_at')->nullable();
21+
$table->unsignedBigInteger('created_by')->nullable();
22+
$table->string('priority')->default('normal'); // low, normal, high, urgent
23+
$table->timestamps();
24+
$table->softDeletes();
25+
});
26+
}
27+
28+
public function down(): void
29+
{
30+
Schema::dropIfExists('hr_announcements');
31+
}
32+
};

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ const navItems: NavItem[] = [
187187
{ label: 'Employee Benefits', href: '/hr/employee-benefits', icon: <span /> },
188188
{ label: 'Skills', href: '/hr/employee-skills', icon: <span /> },
189189
{ label: 'Skill Definitions', href: '/hr/skill-definitions', icon: <span /> },
190+
{ label: 'Announcements', href: '/hr/announcements', icon: <span /> },
190191
],
191192
},
192193
{
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { Head, Link, router } from '@inertiajs/react';
2+
import AppLayout from '@/Layouts/AppLayout';
3+
import { Table } from '@/Components/Common/Table';
4+
import { Button } from '@/Components/Common/Button';
5+
import { Pagination } from '@/Components/Inventory/Pagination';
6+
import { usePermission } from '@/Hooks/usePermission';
7+
import type { PageProps } from '@/types';
8+
import type { Paginator } from '@/types/inventory';
9+
import type { HrAnnouncement } from '@/types/hr';
10+
11+
interface Props extends PageProps {
12+
announcements: Paginator<HrAnnouncement>;
13+
filters: { target_audience?: string };
14+
}
15+
16+
const PRIORITY_COLORS: Record<string, string> = {
17+
low: 'bg-slate-100 text-slate-700',
18+
normal: 'bg-blue-100 text-blue-700',
19+
high: 'bg-yellow-100 text-yellow-700',
20+
urgent: 'bg-red-100 text-red-700',
21+
};
22+
23+
export default function AnnouncementsIndex({ announcements, filters }: Props) {
24+
const { can } = usePermission();
25+
26+
function setAudience(audience: string) {
27+
router.get('/hr/announcements', { ...filters, target_audience: audience || undefined }, { preserveState: true, replace: true });
28+
}
29+
30+
return (
31+
<AppLayout>
32+
<Head title="Announcements" />
33+
<div className="space-y-6">
34+
<div className="flex items-center justify-between">
35+
<div>
36+
<h1 className="text-2xl font-semibold text-slate-900">HR Announcements</h1>
37+
<p className="text-sm text-slate-500 mt-1">{announcements.total} announcements</p>
38+
</div>
39+
{can('hr.create') && (
40+
<Button onClick={() => router.visit('/hr/announcements/create')}>
41+
New Announcement
42+
</Button>
43+
)}
44+
</div>
45+
46+
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
47+
<Table
48+
columns={[
49+
{
50+
key: 'title',
51+
header: 'Title',
52+
render: (r) => (
53+
<Link href={`/hr/announcements/${r.id}`} className="text-indigo-600 hover:underline font-medium">
54+
{r.title}
55+
</Link>
56+
),
57+
},
58+
{
59+
key: 'target_audience',
60+
header: 'Audience',
61+
render: (r) => (
62+
<span className="capitalize text-slate-700">{r.target_audience}</span>
63+
),
64+
},
65+
{
66+
key: 'priority',
67+
header: 'Priority',
68+
render: (r) => (
69+
<span className={`inline-flex items-center rounded px-2 py-0.5 text-xs font-medium capitalize ${PRIORITY_COLORS[r.priority] ?? 'bg-slate-100 text-slate-700'}`}>
70+
{r.priority}
71+
</span>
72+
),
73+
},
74+
{
75+
key: 'is_published',
76+
header: 'Status',
77+
render: (r) => (
78+
<span className={`inline-flex items-center rounded px-2 py-0.5 text-xs font-medium ${r.is_published ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-700'}`}>
79+
{r.is_published ? 'Published' : 'Draft'}
80+
</span>
81+
),
82+
},
83+
{
84+
key: 'publish_at',
85+
header: 'Publish At',
86+
render: (r) => <span className="text-slate-600">{r.publish_at ?? '—'}</span>,
87+
},
88+
]}
89+
rows={announcements.data}
90+
/>
91+
</div>
92+
93+
<Pagination paginator={announcements} />
94+
</div>
95+
</AppLayout>
96+
);
97+
}

0 commit comments

Comments
 (0)