470 lines
14 KiB
Vue
470 lines
14 KiB
Vue
<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>`
|
||
: '';
|
||
|
||
return {
|
||
html: `
|
||
<div class="attendance-event__inner">
|
||
${nameMarkup}
|
||
<span class="attendance-event__time">${escapeHtml(timeLabel)}</span>
|
||
</div>
|
||
`,
|
||
};
|
||
}
|
||
|
||
function escapeHtml(value: string): string {
|
||
return value
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll('\'', ''');
|
||
}
|
||
|
||
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-emerald-600/50 bg-emerald-500/15 font-normal text-emerald-700 dark:text-emerald-400">
|
||
Selesai
|
||
</Badge>
|
||
<Badge variant="outline" class="border-primary/50 bg-primary/20 font-normal text-primary-foreground">
|
||
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: oklch(0.59 0.14 152) !important;
|
||
border-color: oklch(0.52 0.12 152) !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: var(--primary) !important;
|
||
border-color: color-mix(in oklch, var(--primary) 80%, var(--foreground)) !important;
|
||
color: var(--primary-foreground) !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: var(--primary-foreground);
|
||
opacity: 0.95;
|
||
}
|
||
|
||
.dark .attendance-calendar .fc .attendance-event--complete,
|
||
.dark .attendance-calendar .fc .attendance-event--complete .fc-event-main {
|
||
background-color: oklch(0.55 0.13 152) !important;
|
||
border-color: oklch(0.48 0.11 152) !important;
|
||
}
|
||
|
||
.dark .attendance-calendar .fc .attendance-event--active,
|
||
.dark .attendance-calendar .fc .attendance-event--active .fc-event-main {
|
||
background-color: var(--primary) !important;
|
||
border-color: color-mix(in oklch, var(--primary) 75%, var(--foreground)) !important;
|
||
color: var(--primary-foreground) !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: var(--primary-foreground);
|
||
}
|
||
|
||
@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>
|