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:
parent
c378036691
commit
a9b02a9bca
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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();
|
||||
|
||||
108
resources/js/components/card/AttendanceCard.vue
Normal file
108
resources/js/components/card/AttendanceCard.vue
Normal 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>
|
||||
@ -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="[
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user