feat: Add attendance management pages and sharing functionality, including detailed views and statistics for courses.
This commit is contained in:
parent
f7efd30b2a
commit
de1877695d
122
app/Filament/Pages/Manage/Attendance/Detail.php
Normal file
122
app/Filament/Pages/Manage/Attendance/Detail.php
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Manage\Attendance;
|
||||||
|
|
||||||
|
use App\Filament\Actions\BackAction;
|
||||||
|
use App\Models\Attendance;
|
||||||
|
use App\Models\Course;
|
||||||
|
use App\Models\CourseSchedule;
|
||||||
|
use App\Models\Student;
|
||||||
|
use BezhanSalleh\FilamentShield\Traits\HasPageShield;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class Detail extends Page
|
||||||
|
{
|
||||||
|
use HasPageShield;
|
||||||
|
|
||||||
|
public static function getPagePermission(): string
|
||||||
|
{
|
||||||
|
return 'View:AttendanceMonitoring';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static ?string $title = 'Detail Monitoring Presensi';
|
||||||
|
|
||||||
|
protected static ?string $slug = 'manage/attendance-monitoring/{course}';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.manage.attendance.detail';
|
||||||
|
|
||||||
|
public Course $course;
|
||||||
|
|
||||||
|
public function mount(Course $course): void
|
||||||
|
{
|
||||||
|
$this->course = $course;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
BackAction::make()
|
||||||
|
->url(Index::getUrl()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getViewData(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'selectedCourse' => $this->course,
|
||||||
|
'meetingHistory' => $this->getMeetingHistory(),
|
||||||
|
'activeMeetingStats' => $this->getActiveMeetingStats(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getMeetingHistory(): Collection
|
||||||
|
{
|
||||||
|
return Attendance::query()
|
||||||
|
->whereHas('courseSchedule', function ($q) {
|
||||||
|
$q->where('course_id', $this->course->id);
|
||||||
|
})
|
||||||
|
->select('date')
|
||||||
|
->distinct()
|
||||||
|
->orderBy('date', 'desc')
|
||||||
|
->get()
|
||||||
|
->map(function ($att) {
|
||||||
|
$attendedCount = Attendance::whereHas('courseSchedule', function ($q) {
|
||||||
|
$q->where('course_id', $this->course->id);
|
||||||
|
})
|
||||||
|
->whereDate('date', $att->date)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$totalStudents = Student::count();
|
||||||
|
|
||||||
|
return (object) [
|
||||||
|
'date' => [
|
||||||
|
'month' => $att->date->translatedFormat('M'),
|
||||||
|
'day' => $att->date->translatedFormat('d'),
|
||||||
|
],
|
||||||
|
'formatted_date' => $att->date->translatedFormat('l, d F Y'),
|
||||||
|
'attended_count' => $attendedCount,
|
||||||
|
'total_students' => $totalStudents,
|
||||||
|
'share_url' => route('share.attendance', [
|
||||||
|
'token' => $this->course->sharing_token,
|
||||||
|
'date' => $att->date->toDateString(),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getActiveMeetingStats(): ?object
|
||||||
|
{
|
||||||
|
$today = now()->dayOfWeekIso;
|
||||||
|
$nowTime = now()->format('H:i');
|
||||||
|
|
||||||
|
$activeSchedule = CourseSchedule::where('course_id', $this->course->id)
|
||||||
|
->where('day_of_week', $today)
|
||||||
|
->where('start_time', '<=', $nowTime)
|
||||||
|
->where('end_time', '>=', $nowTime)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $activeSchedule) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attendedIds = Attendance::where('course_schedule_id', $activeSchedule->id)
|
||||||
|
->whereDate('date', now()->toDateString())
|
||||||
|
->pluck('student_id')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$totalStudents = Student::count();
|
||||||
|
|
||||||
|
$absentStudents = Student::whereNotIn('id', $attendedIds)->get();
|
||||||
|
|
||||||
|
return (object) [
|
||||||
|
'time_range' => $activeSchedule->start_time->format('H:i').' - '.$activeSchedule->end_time->format('H:i'),
|
||||||
|
'attended_count' => count($attendedIds),
|
||||||
|
'total_students' => $totalStudents,
|
||||||
|
'absent_students' => $absentStudents,
|
||||||
|
'percentage' => round((count($attendedIds) / $totalStudents) * 100),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/Filament/Pages/Manage/Attendance/Index.php
Normal file
65
app/Filament/Pages/Manage/Attendance/Index.php
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Manage\Attendance;
|
||||||
|
|
||||||
|
use App\Models\Course;
|
||||||
|
use App\Settings\GeneralSettings;
|
||||||
|
use BackedEnum;
|
||||||
|
use BezhanSalleh\FilamentShield\Traits\HasPageShield;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class Index extends Page
|
||||||
|
{
|
||||||
|
use HasPageShield;
|
||||||
|
|
||||||
|
public static function getPagePermission(): string
|
||||||
|
{
|
||||||
|
return 'View:AttendanceMonitoring';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Kelola';
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedPresentationChartLine;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Monitoring Presensi';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Monitoring Presensi';
|
||||||
|
|
||||||
|
protected static ?string $slug = 'manage/attendance-monitoring';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 5;
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.manage.attendance.index';
|
||||||
|
|
||||||
|
protected function getViewData(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'courses' => $this->getCourses(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCourses(): Collection
|
||||||
|
{
|
||||||
|
$semester = app(GeneralSettings::class)->current_semester;
|
||||||
|
$today = now()->dayOfWeekIso;
|
||||||
|
|
||||||
|
return Course::query()
|
||||||
|
->where('semester', $semester)
|
||||||
|
->with(['lecturer', 'courseSchedules'])
|
||||||
|
->get()
|
||||||
|
->map(function ($course) use ($today) {
|
||||||
|
$isOngoing = $course->courseSchedules->contains(function ($schedule) use ($today) {
|
||||||
|
return $schedule->day_of_week == $today;
|
||||||
|
});
|
||||||
|
|
||||||
|
$course->lecturer_name = $course->lecturer?->full_name;
|
||||||
|
$course->is_ongoing = $isOngoing;
|
||||||
|
$course->detail_url = Detail::getUrl(['course' => $course]);
|
||||||
|
|
||||||
|
return $course;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Http/Controllers/ShareAttendanceController.php
Normal file
50
app/Http/Controllers/ShareAttendanceController.php
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Assignment;
|
||||||
|
use App\Models\Attendance;
|
||||||
|
use App\Models\Course;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ShareAttendanceController extends Controller
|
||||||
|
{
|
||||||
|
public function show(Request $request, string $token)
|
||||||
|
{
|
||||||
|
app()->setLocale('id');
|
||||||
|
$course = Course::where('sharing_token', $token)->firstOrFail();
|
||||||
|
|
||||||
|
// Define default date: if today has no attendance, use the most recent attendance date
|
||||||
|
$latestAttendance = Attendance::whereHas('courseSchedule', function ($query) use ($course) {
|
||||||
|
$query->where('course_id', $course->id);
|
||||||
|
})
|
||||||
|
->latest('date')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$defaultDate = $latestAttendance ? $latestAttendance->date->toDateString() : now()->toDateString();
|
||||||
|
$date = $request->query('date', $defaultDate);
|
||||||
|
|
||||||
|
$attendances = Attendance::with('student')
|
||||||
|
->whereHas('courseSchedule', function ($query) use ($course) {
|
||||||
|
$query->where('course_id', $course->id);
|
||||||
|
})
|
||||||
|
->whereDate('date', $date)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$availableDates = Attendance::whereHas('courseSchedule', function ($query) use ($course) {
|
||||||
|
$query->where('course_id', $course->id);
|
||||||
|
})
|
||||||
|
->select('date')
|
||||||
|
->distinct()
|
||||||
|
->orderBy('date', 'desc')
|
||||||
|
->get()
|
||||||
|
->pluck('date');
|
||||||
|
|
||||||
|
$assignments = Assignment::where('course_id', $course->id)
|
||||||
|
->withCount('assignmentSubmissions')
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('pages.share-attendance', compact('course', 'attendances', 'date', 'availableDates', 'assignments'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,6 +16,13 @@ class Course extends Model
|
|||||||
|
|
||||||
protected $guarded = ['id'];
|
protected $guarded = ['id'];
|
||||||
|
|
||||||
|
protected static function booted()
|
||||||
|
{
|
||||||
|
static::creating(function ($course) {
|
||||||
|
$course->sharing_token = \Illuminate\Support\Str::random(32);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function lecturer(): BelongsTo
|
public function lecturer(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Lecturer::class);
|
return $this->belongsTo(Lecturer::class);
|
||||||
@ -37,4 +44,9 @@ public function studyGroups(): BelongsToMany
|
|||||||
{
|
{
|
||||||
return $this->belongsToMany(StudyGroup::class, 'study_group_courses');
|
return $this->belongsToMany(StudyGroup::class, 'study_group_courses');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function courseSchedules(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CourseSchedule::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -131,6 +131,7 @@ public function panel(Panel $panel): Panel
|
|||||||
->breadcrumbs(false)
|
->breadcrumbs(false)
|
||||||
->navigationGroups([
|
->navigationGroups([
|
||||||
'Master',
|
'Master',
|
||||||
|
'Kelola',
|
||||||
'Pembelajaran',
|
'Pembelajaran',
|
||||||
'Informasi',
|
'Informasi',
|
||||||
'Sistem',
|
'Sistem',
|
||||||
|
|||||||
@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('courses', function (Blueprint $table) {
|
||||||
|
$table->string('sharing_token', 64)->nullable()->unique()->after('semester');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate existing courses with a random token
|
||||||
|
\App\Models\Course::all()->each(function ($course) {
|
||||||
|
$course->update(['sharing_token' => \Illuminate\Support\Str::random(32)]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('courses', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('sharing_token');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -131,7 +131,19 @@ public function run(): void
|
|||||||
"Reorder:Role",
|
"Reorder:Role",
|
||||||
|
|
||||||
"View:LogTable",
|
"View:LogTable",
|
||||||
"View:ManageSettings"
|
"View:ManageSettings",
|
||||||
|
|
||||||
|
"ViewAny:AttendanceMonitoring",
|
||||||
|
"View:AttendanceMonitoring",
|
||||||
|
"Create:AttendanceMonitoring",
|
||||||
|
"Update:AttendanceMonitoring",
|
||||||
|
"Delete:AttendanceMonitoring",
|
||||||
|
"Restore:AttendanceMonitoring",
|
||||||
|
"ForceDelete:AttendanceMonitoring",
|
||||||
|
"ForceDeleteAny:AttendanceMonitoring",
|
||||||
|
"RestoreAny:AttendanceMonitoring",
|
||||||
|
"Replicate:AttendanceMonitoring",
|
||||||
|
"Reorder:AttendanceMonitoring"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -195,7 +207,19 @@ public function run(): void
|
|||||||
"Update:StudyGroup",
|
"Update:StudyGroup",
|
||||||
"Delete:StudyGroup",
|
"Delete:StudyGroup",
|
||||||
|
|
||||||
"View:ManageSettings"
|
"View:ManageSettings",
|
||||||
|
|
||||||
|
"ViewAny:AttendanceMonitoring",
|
||||||
|
"View:AttendanceMonitoring",
|
||||||
|
"Create:AttendanceMonitoring",
|
||||||
|
"Update:AttendanceMonitoring",
|
||||||
|
"Delete:AttendanceMonitoring",
|
||||||
|
"Restore:AttendanceMonitoring",
|
||||||
|
"ForceDelete:AttendanceMonitoring",
|
||||||
|
"ForceDeleteAny:AttendanceMonitoring",
|
||||||
|
"RestoreAny:AttendanceMonitoring",
|
||||||
|
"Replicate:AttendanceMonitoring",
|
||||||
|
"Reorder:AttendanceMonitoring"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,145 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- Left: History -->
|
||||||
|
<div class="lg:col-span-2 space-y-6">
|
||||||
|
<x-filament::section>
|
||||||
|
<x-slot name="heading">Riwayat Pertemuan</x-slot>
|
||||||
|
<x-slot name="description">Rekapitulasi presensi tiap pertemuan.</x-slot>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
@forelse ($meetingHistory as $meeting)
|
||||||
|
<div
|
||||||
|
class="fi-card flex items-center justify-between p-4 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-sm transition duration-200 hover:shadow-md hover:border-primary-500">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-lg bg-gray-50 dark:bg-gray-800 flex flex-col items-center justify-center border border-gray-100 dark:border-gray-700">
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-bold uppercase text-gray-400">{{ $meeting->date['month'] }}</span>
|
||||||
|
<span
|
||||||
|
class="text-xl font-bold text-gray-700 dark:text-gray-200">{{ $meeting->date['day'] }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold text-sm text-gray-950 dark:text-white">
|
||||||
|
{{ $meeting->formatted_date }}</h4>
|
||||||
|
<p
|
||||||
|
class="text-[11px] font-medium text-gray-500 uppercase tracking-tight mt-0.5">
|
||||||
|
HADIR: <span
|
||||||
|
class="text-primary-600 dark:text-primary-400">{{ $meeting->attended_count }}
|
||||||
|
/ {{ $meeting->total_students }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-filament::button
|
||||||
|
x-on:click="window.navigator.clipboard.writeText('{{ $meeting->share_url }}'); new FilamentNotification().title('Tautan Berhasil Disalin ✨').body('Tautan presensi telah berhasil disalin ke papan klip.').success().send()"
|
||||||
|
color="gray" size="xs" icon="heroicon-m-share"
|
||||||
|
class="rounded-lg shadow-sm font-bold uppercase tracking-tight text-[10px]">
|
||||||
|
Salin Link
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<x-filament::empty-state icon="heroicon-o-calendar" heading="Belum Ada Riwayat Pertemuan"
|
||||||
|
description="Riwayat pertemuan untuk mata kuliah ini belum tersedia. Data presensi akan muncul setelah sesi perkuliahan dilakukan."
|
||||||
|
iconColor="gray">
|
||||||
|
</x-filament::empty-state>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Ongoing Stats -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
@if ($activeMeetingStats)
|
||||||
|
<div
|
||||||
|
class="fi-card flex flex-col justify-between rounded-xl border transition duration-200 relative bg-primary-50/30 dark:bg-primary-900/10 border-primary-500 shadow-md ring-1 ring-primary-500">
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h3
|
||||||
|
class="text-lg font-bold leading-tight flex items-center gap-2 text-primary-900 dark:text-primary-100">
|
||||||
|
<x-heroicon-o-presentation-chart-line class="w-5 h-5 shrink-0 text-primary-500" />
|
||||||
|
Monitoring Sesi Ini
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4 pt-4 border-t border-primary-200 dark:border-primary-800">
|
||||||
|
<div
|
||||||
|
class="flex justify-between items-center text-[10px] font-bold uppercase tracking-widest text-primary-700 dark:text-primary-300">
|
||||||
|
<span>WAKTU PERKULIAHAN</span>
|
||||||
|
<span
|
||||||
|
class="font-mono bg-primary-100 dark:bg-primary-800 px-2 py-0.5 rounded text-primary-600 dark:text-primary-400">{{ $activeMeetingStats->time_range }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex justify-between items-end">
|
||||||
|
<div
|
||||||
|
class="text-3xl font-black text-primary-950 dark:text-white tracking-tight">
|
||||||
|
{{ $activeMeetingStats->attended_count }} <span
|
||||||
|
class="text-sm font-normal text-primary-600/70 italic">/
|
||||||
|
{{ $activeMeetingStats->total_students }}</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="text-xs font-bold text-primary-600 dark:text-primary-400 bg-primary-100 dark:bg-primary-800 px-2 py-1 rounded-lg">
|
||||||
|
{{ $activeMeetingStats->percentage }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="w-full h-3 bg-white/50 dark:bg-gray-800/50 rounded-full overflow-hidden border border-primary-200 dark:border-primary-700 p-0.5">
|
||||||
|
<div class="h-full bg-primary-500 rounded-full transition-all duration-1000 shadow-[0_0_12px_rgba(var(--primary-500),0.4)]"
|
||||||
|
style="width: {{ $activeMeetingStats->percentage }}%">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-4 border-t border-primary-200 dark:border-primary-800">
|
||||||
|
<h5
|
||||||
|
class="text-[10px] font-bold text-primary-700 dark:text-primary-300 uppercase tracking-widest mb-3 flex items-center justify-between">
|
||||||
|
<span>Belum Melakukan Absen</span>
|
||||||
|
<span
|
||||||
|
class="bg-warning-500 text-white px-2 py-0.5 rounded text-[10px] shadow-sm font-bold">{{ $activeMeetingStats->total_students - $activeMeetingStats->attended_count }}</span>
|
||||||
|
</h5>
|
||||||
|
<div class="max-h-[350px] overflow-y-auto space-y-2 thin-scrollbar pr-1">
|
||||||
|
@foreach ($activeMeetingStats->absent_students as $absent)
|
||||||
|
<div
|
||||||
|
class="p-3 rounded-xl bg-white/60 dark:bg-gray-900/60 border border-primary-200/50 dark:border-primary-800/50 text-xs shadow-sm group hover:border-warning-500 transition">
|
||||||
|
<div class="font-bold text-gray-950 dark:text-white truncate">
|
||||||
|
{{ $absent->full_name }}</div>
|
||||||
|
<div class="text-[10px] text-gray-500 font-mono mt-0.5">
|
||||||
|
{{ $absent->nim }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<x-filament::empty-state icon="heroicon-o-clock" heading="Tidak Ada Sesi Perkuliahan Aktif"
|
||||||
|
description="Saat ini belum ada sesi perkuliahan yang berlangsung. Silakan lihat daftar riwayat pertemuan di sebelah kiri."
|
||||||
|
iconColor="gray">
|
||||||
|
</x-filament::empty-state>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.thin-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thin-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thin-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .thin-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</x-filament-panels::page>
|
||||||
102
resources/views/filament/pages/manage/attendance/index.blade.php
Normal file
102
resources/views/filament/pages/manage/attendance/index.blade.php
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<x-filament::section>
|
||||||
|
<x-slot name="heading">Monitoring Presensi</x-slot>
|
||||||
|
<x-slot name="description">Pilih mata kuliah untuk memantau kehadiran mahasiswa per pertemuan.</x-slot>
|
||||||
|
|
||||||
|
<div class="columns-1 md:columns-2 lg:columns-3 xl:columns-4 gap-6 space-y-6">
|
||||||
|
@foreach ($courses as $course)
|
||||||
|
<a href="{{ $course->detail_url }}" class="break-inside-avoid mb-6 block group">
|
||||||
|
<div @class([
|
||||||
|
'fi-card flex flex-col justify-between rounded-xl border transition duration-200 group relative',
|
||||||
|
'bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md hover:border-primary-500' => !$course->is_ongoing,
|
||||||
|
'bg-primary-50/30 dark:bg-primary-900/10 border-primary-500 shadow-md ring-1 ring-primary-500' =>
|
||||||
|
$course->is_ongoing,
|
||||||
|
])>
|
||||||
|
|
||||||
|
@if ($course->is_ongoing)
|
||||||
|
<div class="absolute top-0 right-0 -translate-x-1 -translate-y-1/2 z-10">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1 rounded-full bg-primary-600 px-3 py-1 text-[10px] font-bold text-white shadow-lg uppercase tracking-widest">
|
||||||
|
<div class="w-1.5 h-1.5 bg-white rounded-full animate-pulse"></div>
|
||||||
|
Hari Ini
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="p-6 space-y-4 flex-1">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h3 @class([
|
||||||
|
'text-lg font-bold leading-tight flex items-center gap-2 transition-colors',
|
||||||
|
'text-gray-950 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400' => !$course->is_ongoing,
|
||||||
|
'text-primary-900 dark:text-primary-100' => $course->is_ongoing,
|
||||||
|
])>
|
||||||
|
<x-heroicon-o-folder @class([
|
||||||
|
'w-5 h-5 shrink-0',
|
||||||
|
'opacity-40' => !$course->is_ongoing,
|
||||||
|
'text-primary-500' => $course->is_ongoing,
|
||||||
|
]) />
|
||||||
|
{{ $course->name }}
|
||||||
|
</h3>
|
||||||
|
<div @class([
|
||||||
|
'flex items-center gap-1.5',
|
||||||
|
'text-gray-500 dark:text-gray-400' => !$course->is_ongoing,
|
||||||
|
'text-primary-600 dark:text-primary-400' => $course->is_ongoing,
|
||||||
|
])>
|
||||||
|
<x-heroicon-m-hashtag @class(['w-3.5 h-3.5 shrink-0', 'opacity-50' => !$course->is_ongoing]) />
|
||||||
|
<span class="text-[11px] font-medium leading-none uppercase tracking-widest">
|
||||||
|
{{ $course->code }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div @class([
|
||||||
|
'pt-4 border-t',
|
||||||
|
'border-gray-100 dark:border-gray-800' => !$course->is_ongoing,
|
||||||
|
'border-primary-200 dark:border-primary-800' => $course->is_ongoing,
|
||||||
|
])>
|
||||||
|
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<div @class([
|
||||||
|
'w-8 h-8 rounded-full flex items-center justify-center mr-3 shrink-0 border',
|
||||||
|
'bg-primary-100 dark:bg-primary-900/40 border-primary-200 dark:border-primary-800' => !$course->is_ongoing,
|
||||||
|
'bg-white dark:bg-primary-800 border-primary-300 dark:border-primary-600' =>
|
||||||
|
$course->is_ongoing,
|
||||||
|
])>
|
||||||
|
<x-heroicon-m-user @class([
|
||||||
|
'w-4 h-4',
|
||||||
|
'text-primary-600 dark:text-primary-300' => !$course->is_ongoing,
|
||||||
|
'text-primary-700 dark:text-primary-200' => $course->is_ongoing,
|
||||||
|
]) />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col min-w-0">
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-bold uppercase text-gray-400 tracking-widest leading-none mb-1">Dosen
|
||||||
|
Pengampu</span>
|
||||||
|
<span @class([
|
||||||
|
'truncate font-medium',
|
||||||
|
'text-gray-900 dark:text-gray-200' => !$course->is_ongoing,
|
||||||
|
'text-primary-900 dark:text-primary-50' => $course->is_ongoing,
|
||||||
|
])>
|
||||||
|
{{ $course->lecturer_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div @class([
|
||||||
|
'flex items-center justify-end gap-2 p-4 pt-0 rounded-b-xl',
|
||||||
|
'bg-primary-100/30 dark:bg-primary-900/10 pt-4' => $course->is_ongoing,
|
||||||
|
])>
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-bold text-primary-600 dark:text-primary-400 uppercase tracking-tight group-hover:underline">
|
||||||
|
Buka →
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
</div>
|
||||||
|
</x-filament-panels::page>
|
||||||
284
resources/views/pages/share-attendance.blade.php
Normal file
284
resources/views/pages/share-attendance.blade.php
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full antialiased font-sans">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Presensi {{ $course->name }} - {{ config('app.name', 'MyClass') }}</title>
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||||
|
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
|
<!-- Styles -->
|
||||||
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
|
||||||
|
<style>
|
||||||
|
[x-cloak] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
background-color: #fafafa;
|
||||||
|
color: #18181b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark body {
|
||||||
|
background-color: #09090b;
|
||||||
|
color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #e4e4e7;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .card {
|
||||||
|
background-color: #09090b;
|
||||||
|
border-color: #27272a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid #e4e4e7;
|
||||||
|
padding-left: 0.625rem;
|
||||||
|
padding-right: 0.625rem;
|
||||||
|
padding-top: 0.125rem;
|
||||||
|
padding-bottom: 0.125rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .badge {
|
||||||
|
border-color: #27272a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-trigger {
|
||||||
|
display: flex;
|
||||||
|
height: 2.5rem;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid #e4e4e7;
|
||||||
|
background-color: transparent;
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
padding-right: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .select-trigger {
|
||||||
|
border-color: #27272a;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #d4d4d8;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: #3f3f46;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="min-h-screen">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
||||||
|
|
||||||
|
<div class="space-y-8 tracking-tight">
|
||||||
|
<!-- Header -->
|
||||||
|
<div
|
||||||
|
class="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-8 border-b border-zinc-200 dark:border-zinc-800">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span
|
||||||
|
class="badge bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 capitalize tracking-tighter">{{ $course->code }}</span>
|
||||||
|
<span class="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">SEMESTER
|
||||||
|
GENAP</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-3xl font-bold tracking-tighter">{{ $course->name }}</h1>
|
||||||
|
<p class="text-zinc-500 flex items-center gap-2 tracking-tight text-sm font-medium">
|
||||||
|
<x-heroicon-o-user class="w-4 h-4 opacity-50" />
|
||||||
|
{{ $course->lecturer->full_name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5 min-w-[240px]">
|
||||||
|
<label for="date-selector"
|
||||||
|
class="text-[10px] font-bold text-zinc-500 ml-1 uppercase tracking-wider">Pilih Tanggal
|
||||||
|
Sesi</label>
|
||||||
|
<div x-data="{ date: '{{ $date }}' }" class="relative">
|
||||||
|
<select id="date-selector" x-model="date" @change="window.location.href = '?date=' + date"
|
||||||
|
class="select-trigger cursor-pointer appearance-none pr-10 hover:bg-zinc-50 dark:hover:bg-zinc-900/50 transition-colors">
|
||||||
|
@foreach ($availableDates as $availableDate)
|
||||||
|
<option value="{{ $availableDate->toDateString() }}"
|
||||||
|
{{ $date == $availableDate->toDateString() ? 'selected' : '' }}>
|
||||||
|
Pertemuan: {{ $availableDate->translatedFormat('d F Y') }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
@if ($availableDates->isEmpty())
|
||||||
|
<option value="{{ now()->toDateString() }}">Tidak ada riwayat</option>
|
||||||
|
@endif
|
||||||
|
</select>
|
||||||
|
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-zinc-400">
|
||||||
|
<x-heroicon-m-chevron-up-down class="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Grid -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-12 gap-10">
|
||||||
|
<!-- Attendance Content -->
|
||||||
|
<div class="lg:col-span-8 space-y-6">
|
||||||
|
<div class="flex items-center justify-between mb-2 px-1">
|
||||||
|
<h2 class="text-lg font-bold tracking-tight uppercase tracking-tighter">Daftar Kehadiran
|
||||||
|
</h2>
|
||||||
|
<span
|
||||||
|
class="text-[11px] font-black text-zinc-400 uppercase tracking-widest">{{ $attendances->count() }}
|
||||||
|
Terdata</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card overflow-hidden shadow-sm">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left text-sm whitespace-nowrap">
|
||||||
|
<thead class="border-b border-zinc-200 dark:border-zinc-800">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 font-semibold text-zinc-900 dark:text-zinc-100 tracking-tight">
|
||||||
|
Mahasiswa</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 font-semibold text-zinc-900 dark:text-zinc-100 tracking-tight">
|
||||||
|
Nomor Induk</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 font-semibold text-zinc-900 dark:text-zinc-100 tracking-tight text-right">
|
||||||
|
Waktu Presensi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800/50">
|
||||||
|
@forelse($attendances as $attendance)
|
||||||
|
<tr class="hover:bg-zinc-50/50 dark:hover:bg-zinc-900/10 transition-colors">
|
||||||
|
<td class="px-6 py-5">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="w-7 h-7 rounded-sm bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 flex items-center justify-center text-[9px] font-bold text-zinc-500 dark:text-zinc-400 shrink-0 uppercase">
|
||||||
|
{{ collect(explode(' ', $attendance->student->full_name))->map(fn($n) => substr($n, 0, 1))->take(2)->join('') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<span
|
||||||
|
class="font-semibold text-zinc-950 dark:text-zinc-50 tracking-tight">{{ $attendance->student->full_name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-5">
|
||||||
|
<span class="text-zinc-500 font-mono text-xs tracking-tight">
|
||||||
|
{{ $attendance->student->student_number }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-5 text-right">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 px-2.5 py-1 rounded-md bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-400 font-bold text-[10px] tracking-widest uppercase">
|
||||||
|
<span class="w-1 h-1 rounded-full bg-emerald-500"></span>
|
||||||
|
{{ $attendance->attended_at ? $attendance->attended_at->format('H:i') : '-' }}
|
||||||
|
WIB
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="px-6 py-20 text-center">
|
||||||
|
<p
|
||||||
|
class="text-xs font-medium text-zinc-400 uppercase tracking-widest opacity-60">
|
||||||
|
Belum ada data kehadiran untuk sesi ini</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar Panels -->
|
||||||
|
<div class="lg:col-span-4 space-y-8">
|
||||||
|
<!-- Class Status -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h3 class="text-xs font-bold text-zinc-400 uppercase tracking-widest pl-1">Informasi Sesi
|
||||||
|
</h3>
|
||||||
|
<div class="card p-6 space-y-5 shadow-sm">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">Tanggal
|
||||||
|
Sesi</span>
|
||||||
|
<span
|
||||||
|
class="text-sm font-bold tracking-tight">{{ \Carbon\Carbon::parse($date)->translatedFormat('l, d F Y') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-px bg-zinc-100 dark:bg-zinc-800"></div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">Partisipan</span>
|
||||||
|
<span class="text-sm font-bold">{{ $attendances->count() }} Orang</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 text-right">
|
||||||
|
<span class="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">Jam
|
||||||
|
Kelas</span>
|
||||||
|
<span class="text-sm font-bold whitespace-nowrap">08:00 - 10:30</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assignments -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h3 class="text-xs font-bold text-zinc-400 uppercase tracking-widest pl-1">Tugas Terkait
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
@forelse($assignments as $assignment)
|
||||||
|
<div
|
||||||
|
class="card p-5 hover:bg-zinc-50 dark:hover:bg-zinc-900/40 transition-colors shadow-sm cursor-default">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<h4
|
||||||
|
class="text-xs font-bold leading-tight tracking-tight uppercase line-clamp-2">
|
||||||
|
{{ $assignment->title }}</h4>
|
||||||
|
<span
|
||||||
|
class="shrink-0 text-[8px] font-black px-1.5 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded border border-zinc-200 dark:border-zinc-700 uppercase tracking-widest text-zinc-400">TGS</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-4 text-[10px] text-zinc-400 font-bold uppercase tracking-widest">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<x-heroicon-m-calendar class="w-3 h-3 opacity-40 text-rose-500" />
|
||||||
|
<span>{{ $assignment->deadline ? $assignment->deadline->translatedFormat('d M') : 'N/A' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<x-heroicon-m-document-check
|
||||||
|
class="w-3 h-3 opacity-40 text-blue-500" />
|
||||||
|
<span>{{ $assignment->assignment_submissions_count }} Kirim</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div
|
||||||
|
class="text-[10px] text-zinc-400 font-bold italic pl-1 uppercase tracking-widest opacity-50">
|
||||||
|
Tidak ada tugas terdaftar.
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@ -5,3 +5,5 @@
|
|||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
return view('welcome');
|
return view('welcome');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::get('/share/presensi/{token}', [\App\Http\Controllers\ShareAttendanceController::class, 'show'])->name('share.attendance');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user