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 static function booted()
|
||||
{
|
||||
static::creating(function ($course) {
|
||||
$course->sharing_token = \Illuminate\Support\Str::random(32);
|
||||
});
|
||||
}
|
||||
|
||||
public function lecturer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Lecturer::class);
|
||||
@ -37,4 +44,9 @@ public function studyGroups(): BelongsToMany
|
||||
{
|
||||
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)
|
||||
->navigationGroups([
|
||||
'Master',
|
||||
'Kelola',
|
||||
'Pembelajaran',
|
||||
'Informasi',
|
||||
'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",
|
||||
|
||||
"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",
|
||||
"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 () {
|
||||
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