feat: add 'view readers' action to changelog with modal displaying student read status

This commit is contained in:
Yoga Pangestu 2026-04-07 13:07:46 +07:00
parent 927c9a440a
commit 400a0e3001
4 changed files with 148 additions and 8 deletions

View File

@ -9,8 +9,10 @@
use App\Filament\Pages\System\Changelog\Actions\MarkAsReadAction; use App\Filament\Pages\System\Changelog\Actions\MarkAsReadAction;
use App\Filament\Support\SystemNotification; use App\Filament\Support\SystemNotification;
use App\Models\Changelog; use App\Models\Changelog;
use App\Models\Student;
use BackedEnum; use BackedEnum;
use BezhanSalleh\FilamentShield\Traits\HasPageShield; use BezhanSalleh\FilamentShield\Traits\HasPageShield;
use Carbon\Carbon;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions; use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions; use Filament\Actions\Contracts\HasActions;
@ -23,6 +25,7 @@
use Filament\Forms\Contracts\HasForms; use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Grid;
use Filament\Support\Enums\Width;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use UnitEnum; use UnitEnum;
@ -80,10 +83,10 @@ public function changelogs(): Collection
return Changelog::with([ return Changelog::with([
'users' => fn ($query) => $query->where('user_id', auth()->id()), 'users' => fn ($query) => $query->where('user_id', auth()->id()),
]) ])
->orderBy('version', 'desc')
->get() ->get()
->sortBy([ ->sortBy([
fn ($a, $b) => $a->is_read <=> $b->is_read, fn ($a, $b) => $a->is_read <=> $b->is_read,
['release_date', 'desc'],
]) ])
->values(); ->values();
} }
@ -113,6 +116,44 @@ public function markAsReadAction(): Action
return MarkAsReadAction::make(); return MarkAsReadAction::make();
} }
public function viewReadersAction(): Action
{
return Action::make('viewReaders')
->label('Lihat')
->icon('heroicon-o-eye')
->color('info')
->link()
->tooltip('Daftar Mahasiswa')
->modalHeading('Daftar Mahasiswa yang Melihat')
->modalWidth(Width::TwoExtraLarge)
->modalSubmitAction(false)
->modalCancelActionLabel('Tutup')
->visible(fn () => auth()->user()?->can('Update:Changelog'))
->modalContent(function (array $arguments) {
$record = Changelog::find($arguments['record'] ?? null);
if (! $record) {
return null;
}
$students = Student::with('user')->get();
$readUsersMap = $record->users()->withPivot('read_at')->get()->keyBy('id');
$records = $students->map(fn ($student) => (object) [
'student' => $student,
'is_read' => $readUsersMap->has($student->user_id),
'read_at' => $readUsersMap->get($student->user_id)?->pivot?->read_at
? Carbon::parse($readUsersMap->get($student->user_id)->pivot->read_at)->translatedFormat('d F Y H:i')
: null,
]);
return view('filament.pages.system.changelog.view-readers', [
'records' => $records,
'readCount' => $readUsersMap->count(),
]);
});
}
#[Computed] #[Computed]
public function emptyHeading(): string public function emptyHeading(): string
{ {

View File

@ -137,6 +137,7 @@ class="w-5 h-5 text-gray-400 transition duration-300"
@canAny(['Update:Changelog', 'Delete:Changelog']) @canAny(['Update:Changelog', 'Delete:Changelog'])
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{{ ($this->viewReadersAction)(['record' => $log->id]) }}
{{ ($this->editChangelogAction)(['record' => $log->id]) }} {{ ($this->editChangelogAction)(['record' => $log->id]) }}
{{ ($this->deleteChangelogAction)(['record' => $log->id]) }} {{ ($this->deleteChangelogAction)(['record' => $log->id]) }}
</div> </div>

View File

@ -0,0 +1,57 @@
<div class="space-y-3">
<div class="fi-ta-content overflow-x-auto overflow-y-auto max-h-150 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
<table class="w-full text-sm text-left divider-y dark:divider-white/5 sticky-header-table">
<thead class="bg-gray-50 dark:bg-gray-800 sticky top-0 z-10">
<tr>
<th class="px-4 py-3 font-bold text-gray-900 dark:text-white">Mahasiswa</th>
<th class="px-4 py-3 font-bold text-gray-900 dark:text-white text-center">Status</th>
<th class="px-4 py-3 font-bold text-gray-900 dark:text-white text-right">Waktu Lihat</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/5">
@foreach ($records as $item)
<tr class="hover:bg-gray-50 dark:hover:bg-white/5 transition-colors">
<td class="px-4 py-3 text-gray-950 dark:text-white font-medium">
<div class="flex flex-col">
<span>{{ $item->student->full_name }}</span>
<span class="text-[10px] text-gray-500 dark:text-gray-400 font-normal uppercase tabular-nums">
NIM: {{ $item->student->student_number }}
</span>
</div>
</td>
<td class="px-4 py-3 text-center">
@if ($item->is_read)
<span class="inline-flex items-center rounded-md bg-success-50 dark:bg-success-400/10 px-2 py-1 text-xs font-bold text-success-700 dark:text-success-400 ring-1 ring-inset ring-success-600/20">
Sudah Melihat
</span>
@else
<span class="inline-flex items-center rounded-md bg-gray-50 dark:bg-gray-400/10 px-2 py-1 text-xs font-bold text-gray-600 dark:text-gray-400 ring-1 ring-inset ring-gray-500/20">
Belum Melihat
</span>
@endif
</td>
<td class="px-4 py-3 text-right tabular-nums">
@if ($item->is_read)
<span class="text-gray-600 dark:text-gray-400">
{{ $item->read_at }}
</span>
@else
<span class="text-gray-400 dark:text-gray-600 italic">
-
</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-4 flex items-center justify-between px-2">
<p class="text-xs text-gray-500 font-medium">
Total Mahasiswa: <span class="text-primary-600 dark:text-primary-400 font-bold font-mono">{{ $records->count() }}</span>
<span class="mx-2 text-gray-300">|</span>
Sudah Melihat: <span class="text-success-600 dark:text-success-400 font-bold font-mono">{{ $readCount }}</span>
</p>
</div>
</div>

View File

@ -4,6 +4,7 @@
use App\Enums\RoleEnum; use App\Enums\RoleEnum;
use App\Filament\Pages\System\Changelog\Index as ChangelogPage; use App\Filament\Pages\System\Changelog\Index as ChangelogPage;
use App\Models\Changelog; use App\Models\Changelog;
use App\Models\Student;
use App\Models\User; use App\Models\User;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@ -203,16 +204,16 @@
$this->actingAs($user); $this->actingAs($user);
}); });
it('returns changelogs sorted by latest release date', function () { it('returns changelogs sorted by version descending', function () {
$oldest = Changelog::factory()->create(['release_date' => now()->subYear()]); $oldVersion = Changelog::factory()->create(['version' => 'v1.0.0', 'release_date' => now()]);
$newest = Changelog::factory()->create(['release_date' => now()]); $newVersion = Changelog::factory()->create(['version' => 'v2.0.0', 'release_date' => now()->subYear()]);
$component = Livewire::test(ChangelogPage::class); $component = Livewire::test(ChangelogPage::class);
$changelogs = $component->get('changelogs');
// changelogs() computed prop returns latest first // Newest version should be first even if release_date is older
$ids = Changelog::latest('release_date')->pluck('id')->toArray(); expect($changelogs->first()->version)->toBe('v2.0.0');
expect($ids[0])->toBe($newest->id); expect($changelogs->last()->version)->toBe('v1.0.0');
expect($ids[1])->toBe($oldest->id);
}); });
it('reflects latest version in techStack', function () { it('reflects latest version in techStack', function () {
@ -308,3 +309,43 @@
expect(ChangelogPage::getNavigationBadge())->toBe('3'); expect(ChangelogPage::getNavigationBadge())->toBe('3');
}); });
}); });
describe('Changelog Action: View Readers', function () {
beforeEach(function () {
$user = User::factory()->create();
$user->assignRole(RoleEnum::Developer);
$user->givePermissionTo(['View:Changelog', 'Update:Changelog']);
$this->actingAs($user);
});
it('is visible to users with Update:Changelog permission', function () {
$changelog = Changelog::factory()->create();
Livewire::test(ChangelogPage::class)
->assertActionVisible('viewReaders');
});
it('is hidden from users without Update:Changelog permission', function () {
$kosma = User::factory()->create();
$kosma->assignRole(RoleEnum::Kosma);
$kosma->givePermissionTo(['View:Changelog']);
$this->actingAs($kosma);
Livewire::test(ChangelogPage::class)
->assertActionHidden('viewReaders');
});
it('shows all students and their read status in the modal', function () {
$changelog = Changelog::factory()->create();
$student = Student::factory()->create(['full_name' => 'Budi Sudarsono']);
$otherStudent = Student::factory()->create(['full_name' => 'Anomali Student']);
// Mark student as read
$changelog->users()->attach($student->user_id, ['read_at' => now()]);
Livewire::test(ChangelogPage::class)
->mountAction('viewReaders', ['record' => $changelog->id])
->assertActionMounted('viewReaders')
->assertHasNoErrors();
});
});