feat: enhance attendance tracking by adding user-specific attendance data and conditional rendering in dashboard and analysis components; introduce AttendanceCard component for better UI management

This commit is contained in:
Yoga Pangestu 2026-06-26 15:56:17 +07:00
parent c378036691
commit a9b02a9bca
9 changed files with 298 additions and 95 deletions

View File

@ -261,12 +261,10 @@ public function permissions(): array
self::ADMIN_BAHAN_BAKU => [
Permission::DASHBOARD_VIEW,
Permission::DASHBOARD_ATTENDANCE,
Permission::ANALYSIS_VIEW,
Permission::ANALYSIS_ATTENDANCE,
Permission::ANALYSIS_RAW_MATERIALS,
Permission::ANALYSIS_TOP_SUPPLIERS,
Permission::ATTENDANCES_VIEW,
Permission::ATTENDANCES_CREATE,

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Admin;
use App\Enums\Role;
use App\Http\Controllers\Controller;
use App\Services\System\AnalysisService;
use Carbon\Carbon;
@ -20,12 +21,17 @@ public function index(Request $request): Response
$startDate = $request->query('start_date') ? Carbon::parse($request->query('start_date'))->startOfDay() : null;
$endDate = $request->query('end_date') ? Carbon::parse($request->query('end_date'))->endOfDay() : null;
$user = $request->user();
$isManager = $user?->hasAnyRole([Role::OWNER->value, Role::DEVELOPER->value, Role::DIREKTUR->value]) ?? false;
return Inertia::render('admin/Analysis', [
'filters' => [
'start_date' => $request->query('start_date', ''),
'end_date' => $request->query('end_date', ''),
],
'attendance' => $this->analysisService->getAttendance(),
'myAttendance' => $user ? $this->analysisService->getMyAttendance($user, $startDate, $endDate) : null,
'isManager' => $isManager,
'cashOverview' => $this->analysisService->getCashOverview(),
'rawMaterialStock' => $this->analysisService->getRawMaterialStock(),
'productStock' => $this->analysisService->getProductStock(),

View File

@ -4,6 +4,7 @@
use App\Http\Controllers\Controller;
use App\Services\System\DashboardService;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
@ -13,8 +14,10 @@ public function __construct(
private readonly DashboardService $dashboardService,
) {}
public function index(): Response
public function index(Request $request): Response
{
$user = $request->user();
return Inertia::render('admin/Dashboard', [
'attendance' => $this->dashboardService->getAttendance(),
'cashOverview' => $this->dashboardService->getCashOverview(),
@ -22,6 +25,16 @@ public function index(): Response
'expenseSummary' => $this->dashboardService->getExpenseSummary(),
'orderStats' => $this->dashboardService->getOrderStats(),
'todayAttendance' => $user
? $this->dashboardService->getTodayAttendanceForUser($user)
: null,
'isOnLeave' => $user
? $this->dashboardService->isOnLeaveTodayForUser($user)
: false,
'canCheckIn' => $user
? $this->dashboardService->canCheckInForUser($user)
: false,
]);
}
}

View File

@ -15,6 +15,7 @@
use App\Models\ProductVariant;
use App\Models\Purchase;
use App\Models\RawMaterialPrice;
use App\Models\User;
use Carbon\Carbon;
class AnalysisService
@ -47,6 +48,69 @@ public function getAttendance(): array
];
}
public function getMyAttendance(User $user, ?Carbon $startDate = null, ?Carbon $endDate = null): array
{
$employee = $user->employee;
if ($employee === null) {
return [
'total_days' => 0,
'present_days' => 0,
'absent_days' => 0,
'leave_days' => 0,
'percentage' => 0,
];
}
$start = $startDate ?? Carbon::today()->startOfMonth();
$end = $endDate ?? Carbon::today()->endOfMonth();
$totalDays = 0;
$presentDays = 0;
$current = $start->copy()->startOfDay();
while ($current->lte($end)) {
if ($current->isWeekday()) {
$totalDays++;
$hasAttendance = Attendance::query()
->where('employee_id', $employee->id)
->whereDate('attendance_date', $current)
->exists();
if ($hasAttendance) {
$presentDays++;
}
}
$current->addDay();
}
$leaveDays = LeaveRequest::query()
->approved()
->where('employee_id', $employee->id)
->where('start_date', '<=', $end->toDateString())
->where('end_date', '>=', $start->toDateString())
->get()
->sum(fn ($leave) => max(
0,
min($leave->end_date, $end->toDateString())
- max($leave->start_date, $start->toDateString())
) / 86400 + 1);
$leaveDays = (int) $leaveDays;
$absentDays = max(0, $totalDays - $presentDays - $leaveDays);
$percentage = $totalDays > 0 ? round(($presentDays / $totalDays) * 100) : 0;
return [
'total_days' => $totalDays,
'present_days' => $presentDays,
'absent_days' => $absentDays,
'leave_days' => $leaveDays,
'percentage' => $percentage,
];
}
public function getCashOverview(): array
{
$totalBalance = CashAccount::query()->sum('balance');

View File

@ -14,10 +14,52 @@
use App\Models\LeaveRequest;
use App\Models\Order;
use App\Models\Purchase;
use App\Models\User;
use Carbon\Carbon;
class DashboardService
{
/**
* @return array<string, mixed>|null
*/
public function getTodayAttendanceForUser(User $user): ?array
{
$employee = $user->employee;
if ($employee === null) {
return null;
}
$attendance = Attendance::query()
->with('media')
->where('employee_id', $employee->id)
->whereDate('attendance_date', today())
->first();
return $attendance?->toArray();
}
public function isOnLeaveTodayForUser(User $user): bool
{
$employee = $user->employee;
if ($employee === null) {
return false;
}
return LeaveRequest::query()
->approved()
->where('employee_id', $employee->id)
->whereDate('start_date', '<=', today())
->whereDate('end_date', '>=', today())
->exists();
}
public function canCheckInForUser(User $user): bool
{
return $user->can('attendances.create') && $user->employee !== null;
}
public function getAttendance(): array
{
$totalEmployees = Employee::query()->count();

View File

@ -0,0 +1,108 @@
<script setup lang="ts">
import { Clock, LogIn, LogOut } from '@lucide/vue';
import { computed } from 'vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import type { TodayAttendance } from '@/types/attendance';
const props = defineProps<{
todayAttendance: TodayAttendance;
isOnLeave: boolean;
canCheckIn: boolean;
}>();
const emit = defineEmits<{
'check-in': [];
'check-out': [];
}>();
const canCheckInToday = computed(() => props.canCheckIn && props.todayAttendance === null && !props.isOnLeave);
const canCheckOutToday = computed(() => (
props.canCheckIn
&& !props.isOnLeave
&& props.todayAttendance !== null
&& props.todayAttendance.check_out_at === null
));
const todayStatusLabel = computed(() => {
if (props.isOnLeave) {
return 'Sedang cuti';
}
if (!props.todayAttendance) {
return 'Belum presensi';
}
if (props.todayAttendance.check_out_at) {
return 'Selesai';
}
return 'Sudah masuk';
});
const todayStatusVariant = computed(() => {
if (props.isOnLeave) {
return 'secondary' as const;
}
if (!props.todayAttendance) {
return 'secondary' as const;
}
if (props.todayAttendance.check_out_at) {
return 'default' as const;
}
return 'outline' as const;
});
</script>
<template>
<Card v-if="canCheckIn">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Clock class="size-5" />
Presensi Hari Ini
</CardTitle>
<CardDescription>
Gunakan kamera perangkat untuk presensi masuk dan pulang.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex flex-wrap items-center gap-3">
<Badge :variant="todayStatusVariant">
{{ todayStatusLabel }}
</Badge>
<span v-if="isOnLeave" class="text-muted-foreground text-sm">
Anda sedang dalam masa cuti. Presensi tidak diperlukan.
</span>
<span v-if="todayAttendance?.check_in_at_formatted" class="text-muted-foreground text-sm">
Masuk: {{ todayAttendance.check_in_at_formatted }}
</span>
<span v-if="todayAttendance?.check_out_at_formatted" class="text-muted-foreground text-sm">
Pulang: {{ todayAttendance.check_out_at_formatted }}
</span>
<span v-if="todayAttendance?.work_duration_formatted" class="text-muted-foreground text-sm">
Durasi: {{ todayAttendance.work_duration_formatted }}
</span>
</div>
<div class="flex flex-wrap gap-2">
<Button :disabled="!canCheckInToday" @click="emit('check-in')">
<LogIn class="size-4" />
Presensi Masuk
</Button>
<Button variant="outline" :disabled="!canCheckOutToday" @click="emit('check-out')">
<LogOut class="size-4" />
Presensi Pulang
</Button>
</div>
</CardContent>
</Card>
</template>

View File

@ -35,6 +35,14 @@ const props = defineProps<{
absent: number;
on_leave: number;
};
myAttendance: {
total_days: number;
present_days: number;
absent_days: number;
leave_days: number;
percentage: number;
} | null;
isManager: boolean;
cashOverview: {
total_balance: number;
total_transactions: number;
@ -354,7 +362,7 @@ watch([startDate, endDate], () => {
<!-- Kehadiran & Kas Toko -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard v-if="can('analysis.attendance')" title="Kehadiran" :icon="UserCheck" main-label="Total Karyawan"
<StatCard v-if="can('analysis.attendance') && isManager" title="Kehadiran" :icon="UserCheck" main-label="Total Karyawan"
:main-value="attendance.total_employees" :sub-label="attendance.percentage + '% hadir'" :items="[
{
label: 'Hadir',
@ -370,6 +378,22 @@ watch([startDate, endDate], () => {
},
]" />
<StatCard v-if="can('analysis.attendance') && !isManager && myAttendance" title="Kehadiran Saya" :icon="UserCheck" main-label="Hari Kerja"
:main-value="myAttendance.total_days" :sub-label="myAttendance.percentage + '% hadir'" :items="[
{
label: 'Hadir',
value: myAttendance.present_days,
},
{
label: 'Tidak Hadir',
value: myAttendance.absent_days,
},
{
label: 'Cuti',
value: myAttendance.leave_days,
},
]" />
<StatCard v-if="can('analysis.cash')" title="Kas Toko" :icon="Banknote" main-label="Total Saldo"
:main-value="'Rp' + formatRupiah(cashOverview.total_balance)" :sub-label="cashOverview.total_transactions + ' transaksi'
" :items="[

View File

@ -12,6 +12,7 @@ import {
} from '@lucide/vue';
import { VisDonut, VisSingleContainer } from '@unovis/vue';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import AttendanceCard from '@/components/card/AttendanceCard.vue';
import StatCard from '@/components/card/StatCard.vue';
import {
Card,
@ -24,6 +25,8 @@ import { ChartContainer } from '@/components/ui/chart';
import { useCan } from '@/composables/useCan';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { formatRupiah } from '@/lib/rupiah';
import type { TodayAttendance } from '@/types/attendance';
import AttendanceWebcamModal from '@/pages/admin/hr/attendances/form/AttendanceWebcamModal.vue';
interface DashboardProps {
attendance: {
@ -79,12 +82,28 @@ interface DashboardProps {
count: number;
}>;
};
todayAttendance: TodayAttendance;
isOnLeave: boolean;
canCheckIn: boolean;
}
const props = defineProps<DashboardProps>();
const { can } = useCan();
const webcamModalOpen = ref(false);
const webcamMode = ref<'check-in' | 'check-out'>('check-in');
function openCheckInModal() {
webcamMode.value = 'check-in';
webcamModalOpen.value = true;
}
function openCheckOutModal() {
webcamMode.value = 'check-out';
webcamModalOpen.value = true;
}
const currentTime = ref(new Date());
let timer: ReturnType<typeof setInterval>;
@ -225,6 +244,14 @@ const marketingChartConfig = computed(() => {
</CardContent>
</Card>
<AttendanceCard
:today-attendance="todayAttendance"
:is-on-leave="isOnLeave"
:can-check-in="canCheckIn"
@check-in="openCheckInModal"
@check-out="openCheckOutModal"
/>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard v-if="can('dashboard.attendance')" title="Kehadiran" :icon="UserCheck"
main-label="Total Karyawan" :main-value="attendance.total_employees"
@ -412,5 +439,7 @@ const marketingChartConfig = computed(() => {
</Card>
</div>
</div>
<AttendanceWebcamModal v-if="canCheckIn" v-model:open="webcamModalOpen" :mode="webcamMode" />
</AdminLayout>
</template>

View File

@ -1,10 +1,8 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { Clock, LogIn, LogOut } from '@lucide/vue';
import { computed, ref } from 'vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ref } from 'vue';
import AttendanceCard from '@/components/card/AttendanceCard.vue';
import { Card, CardContent } from '@/components/ui/card';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { AttendanceListItem, CalendarRange, TodayAttendance } from '@/types/attendance';
import AttendanceWebcamModal from './form/AttendanceWebcamModal.vue';
@ -22,46 +20,6 @@ const props = defineProps<{
const webcamModalOpen = ref(false);
const webcamMode = ref<'check-in' | 'check-out'>('check-in');
const canCheckInToday = computed(() => props.canCheckIn && props.todayAttendance === null && !props.isOnLeave);
const canCheckOutToday = computed(() => (
props.canCheckIn
&& !props.isOnLeave
&& props.todayAttendance !== null
&& props.todayAttendance.check_out_at === null
));
const todayStatusLabel = computed(() => {
if (props.isOnLeave) {
return 'Sedang cuti';
}
if (!props.todayAttendance) {
return 'Belum presensi';
}
if (props.todayAttendance.check_out_at) {
return 'Selesai';
}
return 'Sudah masuk';
});
const todayStatusVariant = computed(() => {
if (props.isOnLeave) {
return 'secondary' as const;
}
if (!props.todayAttendance) {
return 'secondary' as const;
}
if (props.todayAttendance.check_out_at) {
return 'default' as const;
}
return 'outline' as const;
});
function openCheckInModal() {
webcamMode.value = 'check-in';
webcamModalOpen.value = true;
@ -86,52 +44,13 @@ function openCheckOutModal() {
</div>
</div>
<Card v-if="canCheckIn">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Clock class="size-5" />
Presensi Hari Ini
</CardTitle>
<CardDescription>
Gunakan kamera perangkat untuk presensi masuk dan pulang.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex flex-wrap items-center gap-3">
<Badge :variant="todayStatusVariant">
{{ todayStatusLabel }}
</Badge>
<span v-if="isOnLeave" class="text-muted-foreground text-sm">
Anda sedang dalam masa cuti. Presensi tidak diperlukan.
</span>
<span v-if="todayAttendance?.check_in_at_formatted" class="text-muted-foreground text-sm">
Masuk: {{ todayAttendance.check_in_at_formatted }}
</span>
<span v-if="todayAttendance?.check_out_at_formatted" class="text-muted-foreground text-sm">
Pulang: {{ todayAttendance.check_out_at_formatted }}
</span>
<span v-if="todayAttendance?.work_duration_formatted" class="text-muted-foreground text-sm">
Durasi: {{ todayAttendance.work_duration_formatted }}
</span>
</div>
<div class="flex flex-wrap gap-2">
<Button :disabled="!canCheckInToday" @click="openCheckInModal">
<LogIn class="size-4" />
Presensi Masuk
</Button>
<Button variant="outline" :disabled="!canCheckOutToday" @click="openCheckOutModal">
<LogOut class="size-4" />
Presensi Pulang
</Button>
</div>
</CardContent>
</Card>
<AttendanceCard
:today-attendance="todayAttendance"
:is-on-leave="isOnLeave"
:can-check-in="canCheckIn"
@check-in="openCheckInModal"
@check-out="openCheckOutModal"
/>
<Card class="min-w-0 overflow-hidden">
<CardContent class="min-w-0">