feat: implement new actions for viewing assignments, attendance, and materials in class sessions, and update routing for course sessions

This commit is contained in:
Yoga Pangestu 2026-04-04 00:37:29 +07:00
parent 37f2c78852
commit 0519e1cd35
16 changed files with 606 additions and 338 deletions

View File

@ -0,0 +1,72 @@
<?php
namespace App\Filament\Resources\Learning\ClassSessions\Actions;
use App\Filament\Resources\Learning\Assignments\AssignmentResource;
use App\Models\ClassSession;
use App\Models\Student;
use Filament\Actions\Action;
use Filament\Support\Colors\Color;
use Filament\Support\Enums\Width;
class ViewAssignmentsAction extends Action
{
public static function getDefaultName(): ?string
{
return 'viewAssignments';
}
protected function setUp(): void
{
parent::setUp();
$this->label('Tugas')
->color(Color::Sky)
->icon('heroicon-o-clipboard-document-list')
->outlined()
->modalHeading('Tugas Sesi')
->modalSubmitAction(false)
->modalCancelActionLabel('Tutup')
->modalWidth(Width::FourExtraLarge)
->modalContent(fn (array $arguments) => view('filament.resources.learning.class-sessions.assignment-modal', (function () use ($arguments) {
$session = ClassSession::with([
'assignments.assignmentSubmissions',
])->find($arguments['session'] ?? null);
if (! $session) {
return ['assignment' => null];
}
$assignment = $session->assignments->first();
if (! $assignment) {
return ['assignment' => null];
}
$activeStudents = Student::query()
->whereHas('user', fn ($q) => $q->active())
->orderBy('full_name')
->get();
$submissionMap = $assignment->assignmentSubmissions->keyBy('student_id');
$data = (object) [
'id' => $assignment->id,
'title' => $assignment->title,
'type_label' => $assignment->type?->getLabel() ?? 'Tugas',
'due_date_formatted' => $assignment->due_date?->translatedFormat('d F Y H:i') ?? '-',
'submissions' => $activeStudents->map(fn ($student) => (object) [
'student_name' => $student->full_name,
'student_number' => $student->student_number,
'submitted_at_formatted' => $submissionMap->get($student->id)?->submitted_at?->translatedFormat('d F Y H:i'),
'is_submitted' => $submissionMap->has($student->id),
]),
'submission_count' => $submissionMap->count(),
'total_students' => $activeStudents->count(),
'url' => AssignmentResource::getUrl('submit', ['record' => $assignment->id]),
];
return ['assignment' => $data];
})()));
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Filament\Resources\Learning\ClassSessions\Actions;
use App\Models\ClassSession;
use App\Models\Student;
use Filament\Actions\Action;
use Filament\Support\Enums\Width;
class ViewAttendanceAction extends Action
{
public static function getDefaultName(): ?string
{
return 'viewAttendance';
}
protected function setUp(): void
{
parent::setUp();
$this->label('Presensi')
->color('success')
->icon('heroicon-o-check-circle')
->link()
->modalHeading('Daftar Presensi')
->modalSubmitAction(false)
->modalCancelActionLabel('Tutup')
->modalWidth(Width::TwoExtraLarge)
->modalContent(fn (array $arguments) => view('filament.resources.learning.class-sessions.attendance-modal', (function () use ($arguments) {
$session = ClassSession::with([
'attendances.student',
])->find($arguments['session'] ?? null);
if (! $session) {
return ['students' => collect(), 'attendedCount' => 0];
}
$activeStudents = Student::query()
->whereHas('user', fn ($q) => $q->active())
->orderBy('full_name')
->get();
$attendanceMap = $session->attendances->keyBy('student_id');
$students = $activeStudents->map(fn ($student) => (object) [
'student' => $student,
'attended_at' => $attendanceMap->get($student->id)?->attended_at?->translatedFormat('d F Y H:i'),
'has_attended' => $attendanceMap->has($student->id),
]);
return [
'students' => $students,
'attendedCount' => $attendanceMap->count(),
];
})()));
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Filament\Resources\Learning\ClassSessions\Actions;
use App\Models\ClassSession;
use Filament\Actions\Action;
use Filament\Support\Colors\Color;
use Filament\Support\Enums\Width;
class ViewMaterialsAction extends Action
{
public static function getDefaultName(): ?string
{
return 'viewMaterials';
}
protected function setUp(): void
{
parent::setUp();
$this->label('Materi')
->color(Color::Purple)
->icon('heroicon-o-book-open')
->outlined()
->modalHeading('Materi Sesi')
->modalSubmitAction(false)
->modalCancelActionLabel('Tutup')
->modalWidth(Width::FourExtraLarge)
->modalContent(function (array $arguments) {
$material = ClassSession::find($arguments['session'] ?? null)?->materials()->first();
return view('filament.resources.learning.class-sessions.material-modal', ['record' => $material]);
});
}
}

View File

@ -29,7 +29,7 @@ public static function getPages(): array
{
return [
'index' => ManageClassSessions::route('/'),
'course' => ListCourseSessions::route('/course/{courseId}'),
'course' => ListCourseSessions::route('/course/{course}'),
];
}

View File

@ -7,23 +7,20 @@
use App\Filament\Resources\Learning\ClassSessions\Actions\EditSessionAction;
use App\Filament\Resources\Learning\ClassSessions\Actions\GenerateSessionsAction;
use App\Filament\Resources\Learning\ClassSessions\Actions\ShareAttendanceAction;
use App\Filament\Resources\Learning\ClassSessions\Actions\ViewAssignmentsAction;
use App\Filament\Resources\Learning\ClassSessions\Actions\ViewAttendanceAction;
use App\Filament\Resources\Learning\ClassSessions\Actions\ViewMaterialsAction;
use App\Filament\Resources\Learning\ClassSessions\ClassSessionResource;
use App\Models\ClassSession;
use App\Filament\Resources\Learning\ClassSessions\Schemas\SessionForm;
use App\Filament\Support\SystemNotification;
use App\Models\Course;
use App\Models\Material;
use App\Models\Student;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\TimePicker;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Resources\Pages\Page;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Width;
use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
@ -35,17 +32,11 @@ class ListCourseSessions extends Page implements HasActions, HasForms
protected string $view = 'filament.resources.learning.class-sessions.show';
public $courseId;
public Course $course;
public function mount($courseId): void
public function mount(Course $course): void
{
$this->courseId = $courseId;
}
#[Computed]
public function course(): Course
{
return Course::findOrFail($this->courseId);
$this->course = $course;
}
#[Computed]
@ -74,44 +65,7 @@ public function sessions(): Collection
public function form(Schema $schema): Schema
{
return $schema
->schema([
Grid::make(['default' => 2])
->schema([
TextInput::make('session_number')
->label('Pertemuan Ke-')
->placeholder('1')
->numeric()
->required()
->minValue(1)
->maxValue(16)
->autocomplete(false),
DatePicker::make('date')
->label('Tanggal')
->placeholder('Pilih Tanggal')
->required()
->native(false)
->displayFormat('l, d F Y')
->default(now()->toDateString()),
TimePicker::make('start_time')
->label('Waktu Mulai')
->placeholder('08:00')
->native(false)
->displayFormat('H:i')
->seconds(false)
->required(),
TimePicker::make('end_time')
->label('Waktu Selesai')
->placeholder('10:00')
->native(false)
->displayFormat('H:i')
->seconds(false)
->required(),
]),
]);
return SessionForm::configure($schema);
}
#[Computed]
@ -135,114 +89,51 @@ protected function getHeaderActions(): array
];
}
protected function getActions(): array
public function viewAttendanceAction(): ViewAttendanceAction
{
return [
$this->shareAttendanceAction(),
$this->viewAttendanceAction(),
$this->viewMaterialsAction(),
$this->viewAssignmentsAction(),
$this->editSessionAction(),
$this->deleteSessionAction(),
$this->viewMaterialDetailAction(),
];
return ViewAttendanceAction::make();
}
public function shareAttendanceAction(): Action
public function viewMaterialsAction(): ViewMaterialsAction
{
return ViewMaterialsAction::make();
}
public function viewAssignmentsAction(): ViewAssignmentsAction
{
return ViewAssignmentsAction::make();
}
public function shareAttendanceAction(): ShareAttendanceAction
{
return ShareAttendanceAction::make();
}
public function viewAttendanceAction(): Action
{
return Action::make('viewAttendance')
->label('Presensi')
->color('success')
->icon('heroicon-o-check-circle')
->link()
->modalHeading('Daftar Presensi')
->modalSubmitAction(false)
->modalCancelActionLabel('Tutup')
->modalWidth(Width::TwoExtraLarge)
->modalContent(fn (array $arguments) => view('filament.resources.learning.class-sessions.attendance-modal', [
'attendances' => ClassSession::find($arguments['session'] ?? null, ['*'])?->attendances()->with('student')->latest('attended_at')->get() ?? collect(),
]));
}
public function viewMaterialsAction(): Action
{
return Action::make('viewMaterials')
->label('Materi')
->color('purple')
->icon('heroicon-o-book-open')
->link()
->modalHeading('Materi Sesi')
->modalSubmitAction(false)
->modalCancelActionLabel('Tutup')
->modalWidth(Width::TwoExtraLarge)
->modalContent(fn (array $arguments) => view('filament.resources.learning.class-sessions.materials-modal', [
'materials' => ClassSession::find($arguments['session'] ?? null, ['*'])?->materials()
->latest()
->get()
->map(fn ($m) => (object) [
'id' => $m->id,
'title' => $m->title,
'created_at_formatted' => $m->created_at?->translatedFormat('d F Y') ?? '-',
]) ?? collect(),
]));
}
public function viewAssignmentsAction(): Action
{
return Action::make('viewAssignments')
->label('Tugas')
->color('sky')
->icon('heroicon-o-clipboard-document-list')
->link()
->modalHeading('Tugas Sesi')
->modalSubmitAction(false)
->modalCancelActionLabel('Tutup')
->modalWidth(Width::TwoExtraLarge)
->modalContent(fn (array $arguments) => view('filament.resources.learning.class-sessions.assignments-modal', [
'assignments' => ClassSession::find($arguments['session'] ?? null, ['*'])?->assignments()
->with(['assignmentSubmissions.student'])
->latest()
->get()
->map(fn ($a) => (object) [
'id' => $a->id,
'title' => $a->title,
'type_label' => $a->type?->getLabel() ?? 'Tugas',
'due_date_formatted' => $a->due_date?->translatedFormat('d F Y H:i') ?? '-',
'submissions' => $a->assignmentSubmissions->map(fn ($s) => (object) [
'student_name' => $s->student->full_name,
'student_number' => $s->student->student_number,
'submitted_at_formatted' => $s->submitted_at?->translatedFormat('d F Y H:i') ?? '-',
'id' => $s->id,
]),
]) ?? collect(),
]));
}
public function editSessionAction(): Action
public function editSessionAction(): EditSessionAction
{
return EditSessionAction::make();
}
public function deleteSessionAction(): Action
public function deleteSessionAction(): DeleteSessionAction
{
return DeleteSessionAction::make();
}
public function viewMaterialDetailAction(): Action
#[Computed]
public function emptyStateHeading(): string
{
return Action::make('viewMaterialDetail')
->record(fn (array $arguments) => Material::find($arguments['record'] ?? null, ['*']))
->modalHeading('Detail Materi')
->modalSubmitAction(false)
->modalCancelActionLabel('Tutup')
->modalWidth(Width::FourExtraLarge)
->modalContent(fn (?Material $record) => $record ? view('filament.resources.learning.class-sessions.material-detail-modal', [
'record' => $record,
]) : null);
return SystemNotification::getByKey('labels.empty_course_sessions.title');
}
#[Computed]
public function emptyStateDescription(): string
{
return SystemNotification::getByKey('labels.empty_course_sessions.description');
}
#[Computed]
public function emptyStateIcon(): string
{
return SystemNotification::getByKey('icons.empty_course_sessions');
}
}

View File

@ -3,6 +3,7 @@
namespace App\Filament\Resources\Learning\ClassSessions\Pages;
use App\Filament\Resources\Learning\ClassSessions\ClassSessionResource;
use App\Filament\Support\SystemNotification;
use App\Models\ClassSession;
use App\Models\Course;
use App\Models\Student;
@ -84,7 +85,7 @@ public function todaySessions(): Collection
'attendance_percentage' => $percentage,
'materials_count' => $session->materials_count,
'assignments_count' => $session->assignments_count,
'url' => ClassSessionResource::getUrl('course', ['courseId' => $session->course_id]),
'url' => ClassSessionResource::getUrl('course', ['course' => $session->course]),
// Pre-calculated classes
'card_classes' => 'group flex w-full rounded-xl border border-primary-300 dark:border-primary-700 bg-primary-50/30 dark:bg-primary-900/10 shadow-sm overflow-hidden relative transition-all hover:shadow-md',
@ -125,8 +126,80 @@ public function courses(): Collection
'lecturer' => $course->lecturer ?? 'Dosen Belum Ditentukan',
'sessions_count' => $course->classSessions->count(),
'total_students' => $totalActiveStudents,
'url' => ClassSessionResource::getUrl('course', ['courseId' => $course->id]),
'url' => ClassSessionResource::getUrl('course', ['course' => $course]),
];
});
}
#[Computed]
public function sessionsHeading(): string
{
return SystemNotification::getByKey('labels.today_sessions.title');
}
#[Computed]
public function sessionsDescription(): string
{
return SystemNotification::getByKey('labels.today_sessions.description', ['date' => $this->todayDate]);
}
#[Computed]
public function coursesHeading(): string
{
return SystemNotification::getByKey('labels.semester_courses.title');
}
#[Computed]
public function coursesDescription(): string
{
return SystemNotification::getByKey('labels.semester_courses.description');
}
#[Computed]
public function sessionsIcon(): string
{
return SystemNotification::getByKey('icons.today_sessions');
}
#[Computed]
public function coursesIcon(): string
{
return SystemNotification::getByKey('icons.semester_courses');
}
#[Computed]
public function sessionsEmptyHeading(): string
{
return SystemNotification::getByKey('labels.empty_today_sessions.title');
}
#[Computed]
public function sessionsEmptyDescription(): string
{
return SystemNotification::getByKey('labels.empty_today_sessions.description');
}
#[Computed]
public function sessionsEmptyIcon(): string
{
return SystemNotification::getByKey('icons.empty_today_sessions');
}
#[Computed]
public function coursesEmptyHeading(): string
{
return SystemNotification::getByKey('labels.empty_semester_courses.title');
}
#[Computed]
public function coursesEmptyDescription(): string
{
return SystemNotification::getByKey('labels.empty_semester_courses.description');
}
#[Computed]
public function coursesEmptyIcon(): string
{
return SystemNotification::getByKey('icons.empty_semester_courses');
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Filament\Resources\Learning\ClassSessions\Schemas;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\TimePicker;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;
class SessionForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->schema([
Grid::make(['default' => 2])
->schema([
TextInput::make('session_number')
->label('Pertemuan Ke-')
->placeholder('1')
->numeric()
->required()
->minValue(1)
->maxValue(16)
->autocomplete(false),
DatePicker::make('date')
->label('Tanggal')
->placeholder('Pilih Tanggal')
->required()
->native(false)
->displayFormat('l, d F Y')
->default(now()->toDateString()),
TimePicker::make('start_time')
->label('Waktu Mulai')
->placeholder('08:00')
->native(false)
->displayFormat('H:i')
->seconds(false)
->required(),
TimePicker::make('end_time')
->label('Waktu Selesai')
->placeholder('10:00')
->native(false)
->displayFormat('H:i')
->seconds(false)
->required(),
]),
]);
}
}

View File

@ -201,6 +201,26 @@
'title' => 'Belum Ada Catatan Rilis 📭',
'description' => 'Belum ada update yang tercatat nih. Buat mulai catat riwayat pembaruan pertama! 🚀',
],
'today_sessions' => [
'title' => 'Sesi Kelas Hari Ini ✨🚀',
'description' => 'Daftar sesi seru yang udah dijadwalkan buat hari ini (:date). Semangat belajar! 💪',
],
'semester_courses' => [
'title' => 'Daftar Mata Kuliah Semester Ini 📚🎓',
'description' => 'Pilih aja mata kuliahnya buat ngecek materi sama tugas-tugas seru lainnya! 🧐✨',
],
'empty_course_sessions' => [
'title' => 'Duh, Belum Ada Sesi Kelas! 🏢🛌',
'description' => 'Belum ada sesi perkuliahan yang dibuat buat mata kuliah ini. Waktunya istirahat mungkin? 😴✨',
],
'empty_today_sessions' => [
'title' => 'Yah, Gak Ada Kelas Hari Ini! 😴🏖️',
'description' => 'Hari ini kosong melompong. Kamu bisa rebahan sebentar atau kejar materi lain! ✨',
],
'empty_semester_courses' => [
'title' => 'Belum Ada Mata Kuliah Nih! 📚😴',
'description' => 'Semester ini kayanya masih kosong. Coba hubungi admin kalau ada yang salah ya! ✨',
],
],
// Icons
@ -212,6 +232,11 @@
'user_account' => 'heroicon-o-face-smile',
'account_security' => 'heroicon-o-shield-check',
'appearance_settings' => 'heroicon-o-swatch',
'today_sessions' => 'heroicon-o-sparkles',
'semester_courses' => 'heroicon-o-book-open',
'empty_course_sessions' => 'heroicon-o-presentation-chart-bar',
'empty_today_sessions' => 'heroicon-o-calendar-days',
'empty_semester_courses' => 'heroicon-o-academic-cap',
],
],
'formal' => [
@ -414,6 +439,26 @@
'title' => 'Tidak Ada Riwayat Pembaruan',
'description' => 'Belum ada catatan rilis yang tersedia. Buat untuk mendokumentasikan pembaruan aplikasi.',
],
'today_sessions' => [
'title' => 'Sesi Hari Ini',
'description' => 'Sesi yang dijadwalkan pada :date',
],
'semester_courses' => [
'title' => 'Daftar Mata Kuliah Semester Ini',
'description' => 'Pilih mata kuliah untuk melihat dan mengelola semua riwayat sesi.',
],
'empty_course_sessions' => [
'title' => 'Tidak ada data yang ditemukan',
'description' => 'Belum ada sesi perkuliahan yang dibuat untuk mata kuliah ini.',
],
'empty_today_sessions' => [
'title' => 'Tidak ada data yang ditemukan',
'description' => 'Tidak ada sesi perkuliahan yang dijadwalkan untuk hari ini.',
],
'empty_semester_courses' => [
'title' => 'Mata Kuliah Belum Terdaftar',
'description' => 'Belum ada mata kuliah yang terdaftar untuk semester aktif ini.',
],
],
// Icons
@ -425,6 +470,11 @@
'user_account' => 'heroicon-o-user',
'account_security' => 'heroicon-o-lock-closed',
'appearance_settings' => 'heroicon-o-paint-brush',
'today_sessions' => 'heroicon-o-bolt',
'semester_courses' => 'heroicon-o-academic-cap',
'empty_course_sessions' => 'heroicon-o-presentation-chart-bar',
'empty_today_sessions' => 'heroicon-o-calendar-days',
'empty_semester_courses' => 'heroicon-o-academic-cap',
],
],
];

View File

@ -0,0 +1,86 @@
<div class="space-y-4 py-2">
<a href="{{ $assignment->url }}"
class="flex items-center justify-between p-3 rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 shadow-sm hover:bg-white dark:hover:bg-white/10 transition-colors group">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-primary-100 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400">
<x-heroicon-o-clipboard-document-list class="w-5 h-5" />
</div>
<div class="flex flex-col">
<span
class="font-bold text-gray-900 dark:text-white leading-tight uppercase font-mono text-[11px] mb-0.5 tracking-tight text-primary-600/60 dark:text-primary-400/50 block">
{{ $assignment->type_label }}
</span>
<span class="font-bold text-gray-900 dark:text-white leading-tight">{{ $assignment->title }}</span>
<div class="flex items-center gap-1.5 mt-0.5">
<x-heroicon-o-clock class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
<span class="text-[10px] text-gray-500 dark:text-gray-400 font-medium uppercase tracking-tight">
Deadline: {{ $assignment->due_date_formatted }}
</span>
</div>
</div>
</div>
<x-heroicon-o-chevron-right
class="w-5 h-5 text-gray-400 group-hover:text-primary-500 transition-colors shrink-0" />
</a>
<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 ring-1 ring-gray-950/5 dark:ring-white/10">
<table class="w-full text-sm text-left divide-y divide-gray-200 dark:divide-white/5">
<thead class="bg-gray-50 dark:bg-gray-800 sticky top-0 z-10">
<tr>
<th class="px-4 py-2.5 font-bold text-gray-900 dark:text-white">Mahasiswa</th>
<th class="px-4 py-2.5 font-bold text-gray-900 dark:text-white text-center">Status</th>
<th class="px-4 py-2.5 font-bold text-gray-900 dark:text-white text-right">Waktu Unggah</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/5">
@foreach ($assignment->submissions as $submission)
<tr class="hover:bg-gray-50 dark:hover:bg-white/5 transition-colors">
<td class="px-4 py-2.5 text-gray-950 dark:text-white font-medium">
<div class="flex flex-col">
<span class="text-sm">{{ $submission->student_name }}</span>
<span
class="text-[10px] text-gray-500 dark:text-gray-400 font-normal uppercase tabular-nums">
NIM: {{ $submission->student_number }}
</span>
</div>
</td>
<td class="px-4 py-2.5 text-center">
@if ($submission->is_submitted)
<span
class="inline-flex items-center rounded-md bg-success-50 dark:bg-success-400/10 px-2 py-0.5 text-xs font-bold text-success-700 dark:text-success-400 ring-1 ring-inset ring-success-600/20">
Sudah Unggah
</span>
@else
<span
class="inline-flex items-center rounded-md bg-gray-50 dark:bg-gray-400/10 px-2 py-0.5 text-xs font-bold text-gray-600 dark:text-gray-400 ring-1 ring-inset ring-gray-500/20">
Belum Unggah
</span>
@endif
</td>
<td class="px-4 py-2.5 text-right tabular-nums">
@if ($submission->is_submitted)
<span class="text-gray-600 dark:text-gray-400">
{{ $submission->submitted_at_formatted }}
</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">{{ $assignment->total_students }}</span>
<span class="mx-2 text-gray-300">|</span>
Sudah Mengumpulkan: <span
class="text-success-600 dark:text-success-400 font-bold font-mono">{{ $assignment->submission_count }}</span>
</p>
</div>
</div>

View File

@ -1,77 +0,0 @@
<div class="space-y-6 py-2">
@forelse($assignments as $assignment)
<div class="space-y-3">
<div
class="flex items-center justify-between p-3 rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 shadow-sm">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-primary-100 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400">
<x-heroicon-o-clipboard-document-list class="w-5 h-5" />
</div>
<div class="flex flex-col">
<span
class="font-bold text-gray-900 dark:text-white leading-tight uppercase font-mono text-[11px] mb-0.5 tracking-tight text-primary-600/60 dark:text-primary-400/50 block">
{{ $assignment->type_label }}
</span>
<span class="font-bold text-gray-900 dark:text-white leading-tight">{{ $assignment->title }}</span>
<div class="flex items-center gap-1.5 mt-0.5">
<x-heroicon-o-clock class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
<span class="text-[10px] text-gray-500 dark:text-gray-400 font-medium uppercase tracking-tight">
Deadline: {{ $assignment->due_date_formatted }}
</span>
</div>
</div>
</div>
</div>
@if ($assignment->submissions->count() > 0)
<div
class="fi-ta-content overflow-x-auto overflow-y-auto max-h-60 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 ring-1 ring-gray-950/5 dark:ring-white/10">
<table class="w-full text-sm text-left divide-y divide-gray-200 dark:divide-white/5">
<thead class="bg-gray-50 dark:bg-gray-800 sticky top-0 z-10">
<tr>
<th class="px-4 py-2.5 font-bold text-gray-900 dark:text-white">Mahasiswa</th>
<th class="px-4 py-2.5 font-bold text-gray-900 dark:text-white text-right">Waktu Unggah</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/5">
@foreach ($assignment->submissions as $submission)
<tr class="hover:bg-gray-50 dark:hover:bg-white/5 transition-colors">
<td class="px-4 py-2.5 text-gray-950 dark:text-white font-medium">
<div class="flex flex-col">
<span class="text-sm">{{ $submission->student_name }}</span>
<span class="text-[10px] text-gray-500 dark:text-gray-400 font-normal uppercase tabular-nums">
NIM: {{ $submission->student_number }}
</span>
</div>
</td>
<td class="px-4 py-2.5 text-right">
<span
class="inline-flex items-center rounded-md bg-success-50 dark:bg-success-400/10 px-2 py-0.5 text-xs font-bold text-success-700 dark:text-success-400 ring-1 ring-inset ring-success-600/20 tabular-nums">
{{ $submission->submitted_at_formatted }}
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="px-2">
<p class="text-[10px] text-gray-500 font-medium uppercase tracking-wider">
Total Pengumpulan: <span class="text-primary-600 dark:text-primary-400 font-bold font-mono">{{ $assignment->submissions->count() }}
Mahasiswa</span>
</p>
</div>
@else
<div class="flex flex-col items-center justify-center p-6 border border-dashed rounded-xl border-gray-200 dark:border-white/10 bg-gray-50/50 dark:bg-white/5">
<x-heroicon-o-document-minus class="w-8 h-8 text-gray-400 dark:text-gray-600 mb-2" />
<p class="text-xs text-gray-500 dark:text-gray-400 font-medium">Belum ada pengumpulan untuk tugas ini.</p>
</div>
@endif
</div>
@empty
<x-filament::empty-state icon="heroicon-o-clipboard-document" heading="Tidak ada data yang ditemukan"
description="Belum ada tugas untuk sesi ini." iconColor="gray">
</x-filament::empty-state>
@endforelse
</div>

View File

@ -1,12 +1,13 @@
<div class="space-y-3">
@forelse ($attendances as $attendance)
@foreach ($students as $item)
@if ($loop->first)
<div
class="fi-ta-content overflow-x-auto overflow-y-auto max-h-96 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
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 Presensi</th>
</tr>
</thead>
@ -15,16 +16,34 @@ class="fi-ta-content overflow-x-auto overflow-y-auto max-h-96 rounded-xl border
<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>{{ $attendance->student->full_name }}</span>
<span>{{ $item->student->full_name }}</span>
<span class="text-[10px] text-gray-500 dark:text-gray-400 font-normal uppercase tabular-nums">NIM:
{{ $attendance->student->student_number }}</span>
{{ $item->student->student_number }}</span>
</div>
</td>
<td class="px-4 py-3 text-right">
<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 tabular-nums">
{{ $attendance->attended_at?->format('H:i') }}
</span>
<td class="px-4 py-3 text-center">
@if ($item->has_attended)
<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">
Hadir
</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 Hadir
</span>
@endif
</td>
<td class="px-4 py-3 text-right tabular-nums">
@if ($item->has_attended)
<span class="text-gray-600 dark:text-gray-400">
{{ $item->attended_at }}
</span>
@else
<span class="text-gray-400 dark:text-gray-600 italic">
-
</span>
@endif
</td>
</tr>
@if ($loop->last)
@ -33,14 +52,13 @@ class="inline-flex items-center rounded-md bg-success-50 dark:bg-success-400/10
</div>
<div class="mt-4 flex items-center justify-between px-2">
<p class="text-xs text-gray-500 font-medium">
Total Kehadiran: <span class="text-primary-600 dark:text-primary-400 font-bold font-mono">{{ $loop->count }}
Mahasiswa</span>
Total Mahasiswa: <span
class="text-primary-600 dark:text-primary-400 font-bold font-mono">{{ $loop->count }}</span>
<span class="mx-2 text-gray-300">|</span>
Sudah Hadir: <span
class="text-success-600 dark:text-success-400 font-bold font-mono">{{ $attendedCount }}</span>
</p>
</div>
@endif
@empty
<x-filament::empty-state icon="heroicon-o-user-group" heading="Tidak ada data yang ditemukan"
description="Belum ada mahasiswa yang melakukan presensi pada sesi ini." iconColor="gray">
</x-filament::empty-state>
@endforelse
@endforeach
</div>

View File

@ -6,11 +6,11 @@
</x-filament::section>
<x-filament::section
icon="{{ \App\Filament\Support\SystemNotification::getNotifStyle() === \App\Enums\NotifStyle::Cheerful ? 'heroicon-o-sparkles' : 'heroicon-o-bolt' }}"
icon="{{ $this->sessionsIcon }}"
icon-color="primary"
>
<x-slot name="heading">{{ \App\Filament\Support\SystemNotification::getMessage('Sesi Kelas Hari Ini ✨🚀', 'Sesi Hari Ini') }}</x-slot>
<x-slot name="description">{{ \App\Filament\Support\SystemNotification::getMessage('Daftar sesi seru yang udah dijadwalkan buat hari ini (' . $this->today_date . '). Semangat belajar! 💪', 'Sesi yang dijadwalkan pada ' . $this->today_date) }}</x-slot>
<x-slot name="heading">{{ $this->sessionsHeading }}</x-slot>
<x-slot name="description">{{ $this->sessionsDescription }}</x-slot>
<div class="space-y-4">
@forelse ($this->todaySessions as $session)
@ -92,9 +92,9 @@
</div>
</a>
@empty
<x-filament::empty-state icon="heroicon-o-calendar-days"
heading="Tidak ada data yang ditemukan"
description="Tidak ada sesi perkuliahan yang dijadwalkan untuk hari ini."
<x-filament::empty-state icon="{{ $this->sessionsEmptyIcon }}"
heading="{{ $this->sessionsEmptyHeading }}"
description="{{ $this->sessionsEmptyDescription }}"
iconColor="gray">
</x-filament::empty-state>
@endforelse
@ -102,11 +102,11 @@
</x-filament::section>
<x-filament::section
icon="{{ \App\Filament\Support\SystemNotification::getNotifStyle() === \App\Enums\NotifStyle::Cheerful ? 'heroicon-o-book-open' : 'heroicon-o-academic-cap' }}"
icon="{{ $this->coursesIcon }}"
icon-color="gray"
>
<x-slot name="heading">{{ \App\Filament\Support\SystemNotification::getMessage('Daftar Mata Kuliah Semester Ini 📚🎓', 'Daftar Mata Kuliah Semester Ini') }}</x-slot>
<x-slot name="description">{{ \App\Filament\Support\SystemNotification::getMessage('Pilih aja mata kuliahnya buat ngecek materi sama tugas-tugas seru lainnya! 🧐✨', 'Pilih mata kuliah untuk melihat dan mengelola semua riwayat sesi.') }}</x-slot>
<x-slot name="heading">{{ $this->coursesHeading }}</x-slot>
<x-slot name="description">{{ $this->coursesDescription }}</x-slot>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
@forelse ($this->courses as $course)
@ -137,9 +137,9 @@ class="fi-card rounded-xl border border-gray-200 dark:border-gray-700 bg-white d
</a>
@empty
<div class="col-span-full text-center">
<x-filament::empty-state icon="heroicon-o-academic-cap"
heading="Mata Kuliah Belum Terdaftar"
description="Belum ada mata kuliah yang terdaftar untuk semester aktif ini."
<x-filament::empty-state icon="{{ $this->coursesEmptyIcon }}"
heading="{{ $this->coursesEmptyHeading }}"
description="{{ $this->coursesEmptyDescription }}"
iconColor="gray">
</x-filament::empty-state>
</div>

View File

@ -1,9 +1,6 @@
<div class="space-y-4 py-2">
@foreach($record->getMedia('materials') as $item)
<div class="space-y-4">
@foreach ($record->getMedia('materials') as $item)
<div class="rounded-xl border border-gray-100 dark:border-white/5 overflow-hidden shadow-sm">
<div class="p-3 bg-gray-50/50 dark:bg-white/5 border-b border-gray-100 dark:border-white/5">
<span class="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-tight">Lampiran PDF</span>
</div>
<div class="h-[600px] w-full bg-white dark:bg-gray-900 overflow-hidden">
<iframe src="{{ $item->getUrl() }}" class="w-full h-full border-0" frameborder="0"></iframe>
</div>

View File

@ -1,29 +0,0 @@
<div class="space-y-4 py-2">
@forelse($materials as $material)
<div
class="flex items-center justify-between p-3 rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 hover:bg-white dark:hover:bg-white/10 transition-colors shadow-sm">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-amber-100 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400">
<x-heroicon-o-document-text class="w-5 h-5" />
</div>
<div class="flex flex-col">
<span class="font-bold text-gray-900 dark:text-white leading-tight">{{ $material->title }}</span>
<span class="text-[10px] text-gray-500 dark:text-gray-400 font-medium uppercase mt-0.5">
Dibuat: {{ $material->created_at_formatted }}
</span>
</div>
</div>
<div class="flex items-center gap-2">
<button type="button" wire:click="mountAction('viewMaterialDetail', { record: {{ $material->id }} })"
class="p-1.5 rounded-full hover:bg-gray-200 dark:hover:bg-white/10 text-gray-500 transition-colors"
title="Lihat Detail">
<x-heroicon-o-chevron-right class="w-5 h-5" />
</button>
</div>
</div>
@empty
<x-filament::empty-state icon="heroicon-o-document-minus" heading="Tidak ada data yang ditemukan"
description="Belum ada materi untuk sesi ini." iconColor="gray">
</x-filament::empty-state>
@endforelse
</div>

View File

@ -2,67 +2,72 @@
<x-filament::section>
<div class="space-y-4">
@forelse ($this->sessions as $session)
<div class="fi-card rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-sm hover:shadow-md transition flex flex-col">
<div
class="fi-card rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-sm hover:shadow-md transition flex flex-col">
<div class="flex flex-1 items-center gap-4 p-4 sm:p-5 min-w-0">
<div class="flex flex-col items-center justify-center w-12 h-12 rounded-lg bg-primary-100 dark:bg-primary-500/10 text-primary-700 dark:text-primary-300 font-bold border border-primary-200 dark:border-primary-500/20 shrink-0">
<div
class="flex flex-col items-center justify-center w-12 h-12 rounded-lg bg-primary-100 dark:bg-primary-500/10 text-primary-700 dark:text-primary-300 font-bold border border-primary-200 dark:border-primary-500/20 shrink-0">
<span class="text-[10px] uppercase leading-none opacity-60 mb-0.5 mt-1">Sesi</span>
<span class="text-sm leading-none mb-1 text-primary-600 dark:text-primary-400 font-bold">Ke-{{ $session->session_number }}</span>
<span
class="text-sm leading-none mb-1 text-primary-600 dark:text-primary-400 font-bold">Ke-{{ $session->session_number }}</span>
</div>
<div class="flex flex-1 flex-wrap items-center justify-between gap-x-4 gap-y-2 min-w-0">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-1.5 font-bold text-gray-900 dark:text-white">
<x-heroicon-o-calendar class="w-4 h-4 text-primary-500 shrink-0" />
{{ $session->date_formatted }}
<div class="flex flex-1 flex-wrap items-center justify-between gap-x-6 gap-y-2 min-w-0">
<div class="flex flex-col gap-1.5 min-w-0">
<div
class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-1.5 font-bold text-gray-900 dark:text-white">
<x-heroicon-o-calendar class="w-4 h-4 text-primary-500 shrink-0" />
{{ $session->date_formatted }}
</div>
<div class="flex items-center gap-1.5 font-medium">
<x-heroicon-o-clock class="w-4 h-4 text-primary-500 shrink-0" />
{{ $session->time_range }}
</div>
</div>
<div class="flex items-center gap-1.5 font-medium">
<x-heroicon-o-clock class="w-4 h-4 text-primary-500 shrink-0" />
{{ $session->time_range }}
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="flex flex-col gap-1">
<div class="flex items-center gap-1.5 font-bold text-success-600 dark:text-success-400 hover:bg-success-50 dark:hover:bg-success-900/10 px-2 py-0.5 rounded-lg transition-colors cursor-pointer"
wire:click="mountAction('viewAttendance', { session: {{ $session->id }} })">
<div class="flex items-center gap-2">
<div
class="flex items-center gap-1.5 text-xs font-bold text-success-600 dark:text-success-400">
<x-heroicon-o-user-group class="w-4 h-4 shrink-0" />
{{ $session->attendances_count }}<span class="text-[10px] opacity-60 ml-0.5">/{{ $session->total_students }}</span> Presensi
{{ $session->attendances_count }}<span
class="text-[10px] opacity-60">/{{ $session->total_students }}</span>
</div>
<div class="flex items-center gap-2 px-2">
<div class="w-20 h-1 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
<div class="h-full bg-success-500 rounded-full transition-all duration-500" style="width: {{ $session->attendance_percentage }}%"></div>
</div>
<span class="text-[10px] font-bold text-gray-500">{{ $session->attendance_percentage }}%</span>
<div class="w-16 h-1 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
<div class="h-full bg-success-500 rounded-full transition-all duration-500"
style="width: {{ $session->attendance_percentage }}%"></div>
</div>
</div>
<div class="flex items-center gap-1.5 font-bold text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/10 px-2 py-0.5 rounded-lg transition-colors cursor-pointer"
wire:click="mountAction('viewMaterials', { session: {{ $session->id }} })">
<x-heroicon-o-document-text class="w-4 h-4 shrink-0" />
{{ $session->materials_count }} Materi
</div>
<div class="flex items-center gap-1.5 font-bold text-primary-600 dark:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/10 px-2 py-0.5 rounded-lg transition-colors cursor-pointer"
wire:click="mountAction('viewAssignments', { session: {{ $session->id }} })">
<x-heroicon-o-clipboard-document-list class="w-4 h-4 shrink-0" />
{{ $session->assignments_count }} Tugas
<span
class="text-[10px] font-bold text-gray-500">{{ $session->attendance_percentage }}%</span>
</div>
</div>
@if ($session->materials_count || $session->assignments_count)
<div class="flex items-center gap-2 shrink-0">
@if ($session->materials_count)
{{ ($this->viewMaterialsAction)(['session' => $session->id]) }}
@endif
@if ($session->assignments_count)
{{ ($this->viewAssignmentsAction)(['session' => $session->id]) }}
@endif
</div>
@endif
</div>
</div>
<div class="flex flex-wrap items-center gap-3 px-4 py-3 border-t border-gray-100 dark:border-gray-700">
<div
class="flex flex-wrap items-center gap-3 px-4 py-3 border-t border-gray-100 dark:border-gray-700">
{{ ($this->viewAttendanceAction)(['session' => $session->id]) }}
{{ ($this->shareAttendanceAction)(['session' => $session->id]) }}
{{ ($this->editSessionAction)(['session' => $session->id]) }}
{{ ($this->deleteSessionAction)(['session' => $session->id]) }}
</div>
</div>
@empty
<x-filament::empty-state icon="heroicon-o-presentation-chart-bar"
heading="{{ \App\Filament\Support\SystemNotification::getMessage('Duh, Belum Ada Sesi Kelas! 🏢🛌', 'Tidak ada data yang ditemukan') }}"
description="{{ \App\Filament\Support\SystemNotification::getMessage('Belum ada sesi perkuliahan yang dibuat buat mata kuliah ini. Waktunya istirahat mungkin? 😴✨', 'Belum ada sesi perkuliahan yang dibuat untuk mata kuliah ini.') }}"
<x-filament::empty-state icon="{{ $this->emptyStateIcon }}"
heading="{{ $this->emptyStateHeading }}"
description="{{ $this->emptyStateDescription }}"
iconColor="gray">
</x-filament::empty-state>
@endforelse

View File

@ -3,6 +3,7 @@
use App\Enums\RoleEnum;
use App\Filament\Resources\Learning\ClassSessions\Pages\ListCourseSessions;
use App\Filament\Resources\Learning\ClassSessions\Pages\ManageClassSessions;
use App\Filament\Support\SystemNotification;
use App\Models\Assignment;
use App\Models\AssignmentSubmission;
use App\Models\ClassSession;
@ -65,15 +66,15 @@
// Check actions on ListCourseSessions page
$this->actingAs($developer);
Livewire::test(ListCourseSessions::class, ['courseId' => $course->id])
Livewire::test(ListCourseSessions::class, ['course' => $course])
->assertActionVisible('generateSessions');
$this->actingAs($kosma);
Livewire::test(ListCourseSessions::class, ['courseId' => $course->id])
Livewire::test(ListCourseSessions::class, ['course' => $course])
->assertActionVisible('generateSessions');
$this->actingAs($regularStudent);
Livewire::test(ListCourseSessions::class, ['courseId' => $course->id])
Livewire::test(ListCourseSessions::class, ['course' => $course])
->assertActionHidden('generateSessions');
});
});
@ -116,7 +117,7 @@
'end_time' => '10:00',
]);
Livewire::test(ListCourseSessions::class, ['courseId' => $course->id])
Livewire::test(ListCourseSessions::class, ['course' => $course])
->callAction('generateSessions', [
'total_sessions' => 2,
'start_date' => now()->toDateString(),
@ -137,7 +138,7 @@
$course = Course::factory()->create(['semester' => $this->currentSemester]);
$session = ClassSession::factory()->create(['course_id' => $course->id, 'session_number' => 1]);
Livewire::test(ListCourseSessions::class, ['courseId' => $course->id])
Livewire::test(ListCourseSessions::class, ['course' => $course])
->callAction('editSession', [
'session_number' => 5,
'date' => now()->toDateString(),
@ -153,7 +154,7 @@
$course = Course::factory()->create(['semester' => $this->currentSemester]);
$session = ClassSession::factory()->create(['course_id' => $course->id]);
Livewire::test(ListCourseSessions::class, ['courseId' => $course->id])
Livewire::test(ListCourseSessions::class, ['course' => $course])
->callAction('shareAttendance', [], ['session' => $session->id])
->assertHasNoActionErrors();
});
@ -162,7 +163,7 @@
$course = Course::factory()->create(['semester' => $this->currentSemester]);
$session = ClassSession::factory()->create(['course_id' => $course->id]);
Livewire::test(ListCourseSessions::class, ['courseId' => $course->id])
Livewire::test(ListCourseSessions::class, ['course' => $course])
->assertActionVisible('viewAttendance')
->assertActionVisible('viewMaterials')
->assertActionVisible('viewAssignments');
@ -184,11 +185,46 @@
'submitted_at' => now(),
]);
Livewire::test(ListCourseSessions::class, ['courseId' => $course->id])
Livewire::test(ListCourseSessions::class, ['course' => $course])
->mountAction('viewAssignments', ['session' => $session->id])
->assertActionMounted('viewAssignments')
->assertSuccessful();
});
it('can delete a session', function () {
$course = Course::factory()->create(['semester' => $this->currentSemester]);
$session = ClassSession::factory()->create(['course_id' => $course->id]);
Livewire::test(ListCourseSessions::class, ['course' => $course])
->callAction('deleteSession', [], ['session' => $session->id])
->assertHasNoActionErrors();
expect($session->refresh()->trashed())->toBeTrue();
});
it('shows attendance in the attendance modal', function () {
$course = Course::factory()->create(['semester' => $this->currentSemester]);
$session = ClassSession::factory()->create(['course_id' => $course->id]);
Livewire::test(ListCourseSessions::class, ['course' => $course])
->mountAction('viewAttendance', ['session' => $session->id])
->assertActionMounted('viewAttendance')
->assertSuccessful();
});
it('displays dynamic empty states based on system notification style', function () {
$course = Course::factory()->create(['semester' => $this->currentSemester]);
// Test ManageClassSessions empty states
Livewire::test(ManageClassSessions::class)
->assertSee(SystemNotification::getByKey('labels.empty_today_sessions.title'))
->assertSee(SystemNotification::getByKey('labels.empty_today_sessions.description'));
// Test ListCourseSessions empty states
Livewire::test(ListCourseSessions::class, ['course' => $course])
->assertSee(SystemNotification::getByKey('labels.empty_course_sessions.title'))
->assertSee(SystemNotification::getByKey('labels.empty_course_sessions.description'));
});
});
describe('Search and Dashboard Filter', function () {