store/resources/js/components/admin/hr/attendances/AttendanceCalendar.vue

491 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import type { CalendarOptions, DatesSetArg, EventClickArg, EventContentArg, EventInput } from '@fullcalendar/core';
import idLocale from '@fullcalendar/core/locales/id';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import FullCalendar from '@fullcalendar/vue3';
import { router } from '@inertiajs/vue3';
import { ChevronLeft, ChevronRight } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import AttendanceDetailDialog from '@/components/admin/hr/attendances/AttendanceDetailDialog.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ui/date-picker';
import { cn } from '@/lib/utils';
import type { AttendanceListItem, CalendarRange } from '@/types/attendance';
const props = defineProps<{
attendances: AttendanceListItem[];
calendarRange: CalendarRange;
canManageAll: boolean;
}>();
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
const detailOpen = ref(false);
const selectedAttendance = ref<AttendanceListItem | null>(null);
const isNavigating = ref(false);
const calendarTitle = ref('');
const pickerDate = ref(props.calendarRange.start);
const isSyncingPicker = ref(false);
const attendanceById = computed(() => new Map(
props.attendances.map((attendance) => [attendance.id, attendance]),
));
function getEventClassNames(attendance: AttendanceListItem): string[] {
return [
'attendance-event',
attendance.check_out_at ? 'attendance-event--complete' : 'attendance-event--active',
];
}
const events = computed<EventInput[]>(() => props.attendances.map((attendance) => ({
id: String(attendance.id),
title: buildEventTitle(attendance),
start: attendance.attendance_date,
allDay: true,
classNames: getEventClassNames(attendance),
extendedProps: {
attendanceId: attendance.id,
},
})));
const calendarOptions = computed<CalendarOptions>(() => ({
plugins: [dayGridPlugin, interactionPlugin],
initialView: 'dayGridMonth',
headerToolbar: false,
locale: idLocale,
height: 'auto',
fixedWeekCount: false,
dayMaxEvents: 3,
moreLinkText: (count) => `+${count} lainnya`,
events: events.value,
eventClick: handleEventClick,
eventContent: renderEventContent,
datesSet: handleDatesSet,
noEventsContent: 'Tidak ada presensi pada periode ini.',
}));
function buildEventTitle(attendance: AttendanceListItem): string {
const timeRange = attendance.check_out_at_formatted
? `${attendance.check_in_at_formatted} ${attendance.check_out_at_formatted}`
: `${attendance.check_in_at_formatted} (belum pulang)`;
if (props.canManageAll && attendance.employee_name) {
return `${attendance.employee_name}: ${timeRange}`;
}
return timeRange;
}
function renderEventContent(arg: EventContentArg) {
const attendance = attendanceById.value.get(Number(arg.event.extendedProps.attendanceId));
if (!attendance) {
return { html: escapeHtml(arg.event.title) };
}
const isComplete = Boolean(attendance.check_out_at);
const timeLabel = isComplete
? `${attendance.check_in_at_formatted} ${attendance.check_out_at_formatted}`
: `${attendance.check_in_at_formatted}`;
const nameMarkup = props.canManageAll && attendance.employee_name
? `<span class="attendance-event__name">${escapeHtml(attendance.employee_name)}</span>`
: '';
const statusMarkup = isComplete
? ''
: '<span class="attendance-event__status">Belum pulang</span>';
return {
html: `
<div class="attendance-event__inner">
${nameMarkup}
<span class="attendance-event__time">${escapeHtml(timeLabel)}</span>
${statusMarkup}
</div>
`,
};
}
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll('\'', '&#39;');
}
function toISODate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function handleEventClick(arg: EventClickArg) {
const attendanceId = Number(arg.event.extendedProps.attendanceId);
const attendance = attendanceById.value.get(attendanceId) ?? null;
if (!attendance) {
return;
}
selectedAttendance.value = attendance;
detailOpen.value = true;
}
function handleDatesSet(arg: DatesSetArg) {
calendarTitle.value = arg.view.title;
isSyncingPicker.value = true;
pickerDate.value = toISODate(arg.view.currentStart);
isSyncingPicker.value = false;
if (isNavigating.value) {
return;
}
const start = arg.startStr.slice(0, 10);
const end = arg.endStr.slice(0, 10);
if (start === props.calendarRange.start && end === props.calendarRange.end) {
return;
}
isNavigating.value = true;
router.get('/admin/hr/attendances', { start, end }, {
preserveState: true,
preserveScroll: true,
only: ['attendances', 'calendarRange'],
onFinish: () => {
isNavigating.value = false;
},
});
}
function navigatePrev() {
calendarRef.value?.getApi().prev();
}
function navigateNext() {
calendarRef.value?.getApi().next();
}
function navigateToday() {
calendarRef.value?.getApi().today();
}
watch(pickerDate, (date) => {
if (!date || isSyncingPicker.value) {
return;
}
calendarRef.value?.getApi().gotoDate(date);
});
watch(
() => props.attendances,
() => {
if (detailOpen.value && selectedAttendance.value) {
selectedAttendance.value = attendanceById.value.get(selectedAttendance.value.id) ?? null;
}
},
);
watch(
() => props.calendarRange.start,
(start) => {
if (!pickerDate.value) {
pickerDate.value = start;
}
},
);
</script>
<template>
<div class="attendance-calendar space-y-4">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-wrap items-center gap-2">
<div class="bg-muted/50 flex items-center rounded-lg border p-1 shadow-xs">
<Button
variant="ghost"
size="icon-sm"
aria-label="Bulan sebelumnya"
@click="navigatePrev"
>
<ChevronLeft class="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
class="px-3"
@click="navigateToday"
>
Hari ini
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Bulan berikutnya"
@click="navigateNext"
>
<ChevronRight class="size-4" />
</Button>
</div>
<DatePicker
v-model="pickerDate"
class="w-auto min-w-[13rem] sm:min-w-[15rem]"
placeholder="Pilih bulan"
/>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-muted-foreground hidden text-sm font-medium sm:inline">
{{ calendarTitle }}
</span>
<Badge variant="outline" class="border-chart-2/50 bg-chart-2/15 text-chart-2 font-normal dark:text-chart-2">
Selesai
</Badge>
<Badge variant="outline" class="border-amber-500/50 bg-amber-400 font-normal text-amber-950">
Belum pulang
</Badge>
</div>
</div>
<div
:class="cn(
'overflow-hidden rounded-xl border bg-background shadow-xs transition-opacity',
isNavigating && 'pointer-events-none opacity-60',
)"
>
<FullCalendar ref="calendarRef" :options="calendarOptions" />
</div>
</div>
<AttendanceDetailDialog
v-model:open="detailOpen"
:attendance="selectedAttendance"
/>
</template>
<style>
.attendance-calendar .fc {
--fc-border-color: var(--border);
--fc-page-bg-color: transparent;
--fc-neutral-bg-color: var(--muted);
--fc-neutral-text-color: var(--muted-foreground);
--fc-today-bg-color: color-mix(in oklch, var(--primary) 6%, transparent);
--fc-now-indicator-color: var(--primary);
font-family: inherit;
}
.attendance-calendar .fc .fc-scrollgrid {
border: 0;
border-radius: 0;
}
.attendance-calendar .fc .fc-scrollgrid-section > td {
border: 0;
}
.attendance-calendar .fc .fc-col-header-cell {
border-color: var(--border);
background: color-mix(in oklch, var(--muted) 55%, transparent);
padding: 0.625rem 0;
}
.attendance-calendar .fc .fc-col-header-cell-cushion {
color: var(--muted-foreground);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
text-transform: uppercase;
}
.attendance-calendar .fc .fc-daygrid-day {
border-color: var(--border);
}
.attendance-calendar .fc .fc-daygrid-day-frame {
min-height: 6.5rem;
padding: 0.375rem;
}
.attendance-calendar .fc .fc-daygrid-day-top {
flex-direction: row;
justify-content: flex-end;
margin-bottom: 0.25rem;
}
.attendance-calendar .fc .fc-daygrid-day-number {
color: var(--foreground);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1;
padding: 0.25rem 0.45rem;
text-decoration: none;
}
.attendance-calendar .fc .fc-day-today .fc-daygrid-day-number {
background: var(--primary);
border-radius: calc(var(--radius) - 2px);
color: var(--primary-foreground);
}
.attendance-calendar .fc .fc-day-other .fc-daygrid-day-number {
color: var(--muted-foreground);
opacity: 0.65;
}
.attendance-calendar .fc .fc-daygrid-day-events {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-top: 0.125rem;
}
.attendance-calendar .fc .fc-daygrid-event-harness {
margin: 0 !important;
}
.attendance-calendar .fc .fc-daygrid-event {
margin: 0 !important;
}
.attendance-calendar .fc .fc-daygrid-more-link {
color: var(--muted-foreground);
font-size: 0.6875rem;
font-weight: 500;
}
.attendance-calendar .fc .fc-daygrid-more-link:hover {
background: var(--accent);
color: var(--accent-foreground);
}
.attendance-calendar .fc .fc-popover {
background: var(--popover);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 10px 30px color-mix(in oklch, var(--foreground) 8%, transparent);
}
.attendance-calendar .fc .fc-popover-header {
background: var(--muted);
color: var(--foreground);
font-size: 0.8125rem;
font-weight: 600;
padding: 0.5rem 0.75rem;
}
.attendance-calendar .fc .attendance-event {
border: 0;
border-radius: calc(var(--radius) - 4px);
box-shadow: none;
cursor: pointer;
margin: 0;
overflow: hidden;
padding: 0.25rem 0.375rem;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.attendance-calendar .fc .attendance-event:hover {
box-shadow: 0 1px 4px color-mix(in oklch, var(--foreground) 10%, transparent);
transform: translateY(-1px);
}
.attendance-calendar .fc .attendance-event--complete,
.attendance-calendar .fc .attendance-event--complete .fc-event-main {
background-color: var(--chart-2) !important;
border-color: color-mix(in oklch, var(--chart-2) 70%, var(--foreground)) !important;
color: oklch(0.99 0 0) !important;
}
.attendance-calendar .fc .attendance-event--active,
.attendance-calendar .fc .attendance-event--active .fc-event-main {
background-color: oklch(0.79 0.16 75) !important;
border-color: oklch(0.7 0.14 70) !important;
color: oklch(0.32 0.07 55) !important;
}
.attendance-calendar .fc .attendance-event__inner {
display: flex;
flex-direction: column;
gap: 0.25rem;
line-height: 1.25;
min-width: 0;
}
.attendance-calendar .fc .attendance-event__name {
font-size: 0.6875rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attendance-calendar .fc .attendance-event__time {
font-size: 0.6875rem;
font-weight: 500;
opacity: 0.95;
}
.attendance-calendar .fc .attendance-event__status {
align-self: flex-start;
background: color-mix(in oklch, currentColor 12%, transparent);
border: 1px solid color-mix(in oklch, currentColor 18%, transparent);
border-radius: calc(var(--radius) - 6px);
font-size: 0.625rem;
font-weight: 600;
line-height: 1;
padding: 0.125rem 0.375rem;
}
.attendance-calendar .fc .attendance-event--complete .attendance-event__time,
.attendance-calendar .fc .attendance-event--complete .attendance-event__name {
color: oklch(0.99 0 0);
opacity: 0.95;
}
.attendance-calendar .fc .attendance-event--active .attendance-event__time,
.attendance-calendar .fc .attendance-event--active .attendance-event__name,
.attendance-calendar .fc .attendance-event--active .attendance-event__status {
color: oklch(0.32 0.07 55);
opacity: 0.95;
}
.dark .attendance-calendar .fc .attendance-event--active,
.dark .attendance-calendar .fc .attendance-event--active .fc-event-main {
background-color: oklch(0.72 0.14 75) !important;
border-color: oklch(0.64 0.12 70) !important;
color: oklch(0.28 0.06 55) !important;
}
.dark .attendance-calendar .fc .attendance-event--active .attendance-event__time,
.dark .attendance-calendar .fc .attendance-event--active .attendance-event__name,
.dark .attendance-calendar .fc .attendance-event--active .attendance-event__status {
color: oklch(0.28 0.06 55);
}
@media (max-width: 640px) {
.attendance-calendar .fc .fc-daygrid-day-frame {
min-height: 5rem;
}
.attendance-calendar .fc .attendance-event__name,
.attendance-calendar .fc .attendance-event__time {
font-size: 0.625rem;
}
}
</style>