Add employee management components including DataTable for displaying employee details, a dropdown for actions, and a calendar UI for date selection. Introduce native select components for improved form handling.

This commit is contained in:
Yoga Pangestu 2026-06-10 00:20:49 +07:00
parent c5ec7b5afc
commit 413021c0d0
21 changed files with 670 additions and 1 deletions

View File

@ -0,0 +1,59 @@
<script setup lang="ts">
import { Link, router } from '@inertiajs/vue3';
import { MoreHorizontal, Pencil, Trash2 } from '@lucide/vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { EmployeeListItem } from '@/types/employee';
const props = defineProps<{
employee: EmployeeListItem;
}>();
function destroyEmployee() {
if (!confirm(`Hapus pegawai ${props.employee.full_name ?? ''}?`)) {
return;
}
router.delete(`/admin/hr/employees/${props.employee.id}`, {
onError: () => {
toast.error('Gagal menghapus pegawai.');
},
});
}
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="h-8 w-8 p-0">
<span class="sr-only">Buka menu</span>
<MoreHorizontal class="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Aksi</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem as-child>
<Link :href="`/admin/hr/employees/${employee.id}/edit`" class="flex items-center gap-2">
<Pencil class="size-4" />
Ubah
</Link>
</DropdownMenuItem>
<DropdownMenuItem
class="text-destructive focus:text-destructive flex items-center gap-2"
@click="destroyEmployee"
>
<Trash2 class="size-4" />
Hapus
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>

View File

@ -0,0 +1,162 @@
<script lang="ts" setup>
import type { CalendarRootEmits, CalendarRootProps, DateValue } from "reka-ui"
import type { HTMLAttributes, Ref } from "vue"
import type { LayoutTypes } from "."
import { getLocalTimeZone, today } from "@internationalized/date"
import { createReusableTemplate, reactiveOmit, useVModel } from "@vueuse/core"
import { CalendarRoot, useDateFormatter, useForwardPropsEmits } from "reka-ui"
import { createYear, createYearRange, toDate } from "reka-ui/date"
import { computed, toRaw } from "vue"
import { cn } from "@/lib/utils"
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { CalendarCell, CalendarCellTrigger, CalendarGrid, CalendarGridBody, CalendarGridHead, CalendarGridRow, CalendarHeadCell, CalendarHeader, CalendarHeading, CalendarNextButton, CalendarPrevButton } from "."
const props = withDefaults(defineProps<CalendarRootProps & { class?: HTMLAttributes["class"], layout?: LayoutTypes, yearRange?: DateValue[] }>(), {
modelValue: undefined,
layout: undefined,
})
const emits = defineEmits<CalendarRootEmits>()
const delegatedProps = reactiveOmit(props, "class", "layout", "placeholder")
const placeholder = useVModel(props, "placeholder", emits, {
passive: true,
defaultValue: props.defaultPlaceholder ?? today(getLocalTimeZone()),
}) as Ref<DateValue>
const formatter = useDateFormatter(props.locale ?? "en")
const yearRange = computed(() => {
return props.yearRange ?? createYearRange({
start: props?.minValue ?? (toRaw(props.placeholder) ?? props.defaultPlaceholder ?? today(getLocalTimeZone()))
.cycle("year", -100),
end: props?.maxValue ?? (toRaw(props.placeholder) ?? props.defaultPlaceholder ?? today(getLocalTimeZone()))
.cycle("year", 10),
})
})
const [DefineMonthTemplate, ReuseMonthTemplate] = createReusableTemplate<{ date: DateValue }>()
const [DefineYearTemplate, ReuseYearTemplate] = createReusableTemplate<{ date: DateValue }>()
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DefineMonthTemplate v-slot="{ date }">
<div class="**:data-[slot=native-select-icon]:right-1">
<div class="relative">
<div class="absolute inset-0 flex h-full items-center text-sm pl-2 pointer-events-none">
{{ formatter.custom(toDate(date), { month: 'short' }) }}
</div>
<NativeSelect
class="text-xs h-8 pr-6 pl-2 text-transparent relative"
:model-value="date.month"
@change="(e: Event) => {
placeholder = placeholder.set({
month: Number((e?.target as any)?.value),
})
}"
>
<NativeSelectOption v-for="(month) in createYear({ dateObj: date })" :key="month.toString()" :value="month.month" :selected="date.month === month.month">
{{ formatter.custom(toDate(month), { month: 'short' }) }}
</NativeSelectOption>
</NativeSelect>
</div>
</div>
</DefineMonthTemplate>
<DefineYearTemplate v-slot="{ date }">
<div class="**:data-[slot=native-select-icon]:right-1">
<div class="relative">
<div class="absolute inset-0 flex h-full items-center text-sm pl-2 pointer-events-none">
{{ formatter.custom(toDate(date), { year: 'numeric' }) }}
</div>
<NativeSelect
class="text-xs h-8 pr-6 pl-2 text-transparent relative"
:model-value="date.year"
@change="(e: Event) => {
placeholder = placeholder.set({
year: Number((e?.target as any)?.value),
})
}"
>
<NativeSelectOption v-for="(year) in yearRange" :key="year.toString()" :value="year.year" :selected="date.year === year.year">
{{ formatter.custom(toDate(year), { year: 'numeric' }) }}
</NativeSelectOption>
</NativeSelect>
</div>
</div>
</DefineYearTemplate>
<CalendarRoot
v-slot="{ grid, weekDays, date }"
v-bind="forwarded"
v-model:placeholder="placeholder"
data-slot="calendar"
:class="cn('p-3', props.class)"
>
<CalendarHeader class="pt-0">
<nav class="flex items-center gap-1 absolute top-0 inset-x-0 justify-between">
<CalendarPrevButton>
<slot name="calendar-prev-icon" />
</CalendarPrevButton>
<CalendarNextButton>
<slot name="calendar-next-icon" />
</CalendarNextButton>
</nav>
<slot name="calendar-heading" :date="date" :month="ReuseMonthTemplate" :year="ReuseYearTemplate">
<template v-if="layout === 'month-and-year'">
<div class="flex items-center justify-center gap-1">
<ReuseMonthTemplate :date="date" />
<ReuseYearTemplate :date="date" />
</div>
</template>
<template v-else-if="layout === 'month-only'">
<div class="flex items-center justify-center gap-1">
<ReuseMonthTemplate :date="date" />
{{ formatter.custom(toDate(date), { year: 'numeric' }) }}
</div>
</template>
<template v-else-if="layout === 'year-only'">
<div class="flex items-center justify-center gap-1">
{{ formatter.custom(toDate(date), { month: 'short' }) }}
<ReuseYearTemplate :date="date" />
</div>
</template>
<template v-else>
<CalendarHeading />
</template>
</slot>
</CalendarHeader>
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
<CalendarGridHead>
<CalendarGridRow>
<CalendarHeadCell
v-for="day in weekDays" :key="day"
>
{{ day }}
</CalendarHeadCell>
</CalendarGridRow>
</CalendarGridHead>
<CalendarGridBody>
<CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
<CalendarCell
v-for="weekDate in weekDates"
:key="weekDate.toString()"
:date="weekDate"
>
<CalendarCellTrigger
:day="weekDate"
:month="month.value"
/>
</CalendarCell>
</CalendarGridRow>
</CalendarGridBody>
</CalendarGrid>
</div>
</CalendarRoot>
</template>

View File

@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { CalendarCellProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarCell, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarCellProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCell
data-slot="calendar-cell"
:class="cn('relative p-0 text-center text-sm focus-within:relative focus-within:z-20 flex-1 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarCell>
</template>

View File

@ -0,0 +1,39 @@
<script lang="ts" setup>
import type { CalendarCellTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarCellTrigger, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<CalendarCellTriggerProps & { class?: HTMLAttributes["class"] }>(), {
as: "button",
})
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCellTrigger
data-slot="calendar-cell-trigger"
:class="cn(
buttonVariants({ variant: 'ghost' }),
'size-8 p-0 font-normal aria-selected:opacity-100 cursor-default',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selected
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 [&[data-selected]:hover]:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
// Unavailable
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
// Outside months
'data-[outside-view]:text-muted-foreground',
props.class,
)"
v-bind="forwardedProps"
>
<slot />
</CalendarCellTrigger>
</template>

View File

@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { CalendarGridProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarGrid, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarGridProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarGrid
data-slot="calendar-grid"
:class="cn('w-full border-collapse space-x-1', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarGrid>
</template>

View File

@ -0,0 +1,15 @@
<script lang="ts" setup>
import type { CalendarGridBodyProps } from "reka-ui"
import { CalendarGridBody } from "reka-ui"
const props = defineProps<CalendarGridBodyProps>()
</script>
<template>
<CalendarGridBody
data-slot="calendar-grid-body"
v-bind="props"
>
<slot />
</CalendarGridBody>
</template>

View File

@ -0,0 +1,16 @@
<script lang="ts" setup>
import type { CalendarGridHeadProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { CalendarGridHead } from "reka-ui"
const props = defineProps<CalendarGridHeadProps & { class?: HTMLAttributes["class"] }>()
</script>
<template>
<CalendarGridHead
data-slot="calendar-grid-head"
v-bind="props"
>
<slot />
</CalendarGridHead>
</template>

View File

@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { CalendarGridRowProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarGridRow, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarGridRowProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarGridRow
data-slot="calendar-grid-row"
:class="cn('flex', props.class)" v-bind="forwardedProps"
>
<slot />
</CalendarGridRow>
</template>

View File

@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { CalendarHeadCellProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarHeadCell, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarHeadCellProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeadCell
data-slot="calendar-head-cell"
:class="cn('text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem]', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarHeadCell>
</template>

View File

@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { CalendarHeaderProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarHeader, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarHeaderProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeader
data-slot="calendar-header"
:class="cn('flex justify-center pt-1 relative items-center w-full px-8', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarHeader>
</template>

View File

@ -0,0 +1,30 @@
<script lang="ts" setup>
import type { CalendarHeadingProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarHeading, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes["class"] }>()
defineSlots<{
default: (props: { headingValue: string }) => any
}>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeading
v-slot="{ headingValue }"
data-slot="calendar-heading"
:class="cn('text-sm font-medium', props.class)"
v-bind="forwardedProps"
>
<slot :heading-value>
{{ headingValue }}
</slot>
</CalendarHeading>
</template>

View File

@ -0,0 +1,31 @@
<script lang="ts" setup>
import type { CalendarNextProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { ChevronRight } from "@lucide/vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarNext, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<CalendarNextProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarNext
data-slot="calendar-next-button"
:class="cn(
buttonVariants({ variant: 'outline' }),
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)"
v-bind="forwardedProps"
>
<slot>
<ChevronRight class="size-4" />
</slot>
</CalendarNext>
</template>

View File

@ -0,0 +1,31 @@
<script lang="ts" setup>
import type { CalendarPrevProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { ChevronLeft } from "@lucide/vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarPrev, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<CalendarPrevProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarPrev
data-slot="calendar-prev-button"
:class="cn(
buttonVariants({ variant: 'outline' }),
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)"
v-bind="forwardedProps"
>
<slot>
<ChevronLeft class="size-4" />
</slot>
</CalendarPrev>
</template>

View File

@ -0,0 +1,14 @@
export { default as Calendar } from "./Calendar.vue"
export { default as CalendarCell } from "./CalendarCell.vue"
export { default as CalendarCellTrigger } from "./CalendarCellTrigger.vue"
export { default as CalendarGrid } from "./CalendarGrid.vue"
export { default as CalendarGridBody } from "./CalendarGridBody.vue"
export { default as CalendarGridHead } from "./CalendarGridHead.vue"
export { default as CalendarGridRow } from "./CalendarGridRow.vue"
export { default as CalendarHeadCell } from "./CalendarHeadCell.vue"
export { default as CalendarHeader } from "./CalendarHeader.vue"
export { default as CalendarHeading } from "./CalendarHeading.vue"
export { default as CalendarNextButton } from "./CalendarNextButton.vue"
export { default as CalendarPrevButton } from "./CalendarPrevButton.vue"
export type LayoutTypes = "month-and-year" | "month-only" | "year-only" | undefined

View File

@ -0,0 +1,80 @@
<script setup lang="ts">
import type { DateValue } from '@internationalized/date';
import { DateFormatter, getLocalTimeZone, parseDate, today } from '@internationalized/date';
import { CalendarIcon } from '@lucide/vue';
import type { HTMLAttributes } from 'vue';
import { computed } from 'vue';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
const props = defineProps<{
id?: string;
modelValue?: string;
class?: HTMLAttributes['class'];
placeholder?: string;
disabled?: boolean;
}>();
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void;
}>();
const defaultPlaceholder = today(getLocalTimeZone());
const df = new DateFormatter('id-ID', {
dateStyle: 'long',
});
const date = computed({
get: () => {
if (!props.modelValue) {
return undefined;
}
try {
return parseDate(props.modelValue);
} catch {
return undefined;
}
},
set: (value: DateValue | undefined) => {
emit('update:modelValue', value ? value.toString() : '');
},
});
</script>
<template>
<Popover v-slot="{ close }">
<PopoverTrigger as-child>
<Button
:id="id"
type="button"
variant="outline"
:disabled="disabled"
:class="cn(
'w-full justify-start text-left font-normal',
!date && 'text-muted-foreground',
props.class,
)"
>
<CalendarIcon class="size-4" />
{{ date ? df.format(date.toDate(getLocalTimeZone())) : (placeholder ?? 'Pilih tanggal') }}
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" align="start">
<Calendar
v-model="date"
:default-placeholder="defaultPlaceholder"
layout="month-and-year"
initial-focus
@update:model-value="close"
/>
</PopoverContent>
</Popover>
</template>

View File

@ -0,0 +1 @@
export { default as DatePicker } from './DatePicker.vue';

View File

@ -0,0 +1,50 @@
<script setup lang="ts">
import type { AcceptableValue } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { ChevronDownIcon } from "@lucide/vue"
import { reactiveOmit, useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = defineProps<{ modelValue?: AcceptableValue | AcceptableValue[], class?: HTMLAttributes["class"] }>()
const emit = defineEmits<{
"update:modelValue": AcceptableValue
}>()
const modelValue = useVModel(props, "modelValue", emit, {
passive: true,
defaultValue: "",
})
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<div
class="group/native-select relative w-fit has-[select:disabled]:opacity-50"
data-slot="native-select-wrapper"
>
<select
v-bind="{ ...$attrs, ...delegatedProps }"
v-model="modelValue"
data-slot="native-select"
:class="cn(
'border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 dark:hover:bg-input/50 h-9 w-full min-w-0 appearance-none rounded-md border bg-transparent px-3 py-2 pr-9 text-sm shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)"
>
<slot />
</select>
<ChevronDownIcon
class="text-muted-foreground pointer-events-none absolute top-1/2 right-3.5 size-4 -translate-y-1/2 opacity-50 select-none"
aria-hidden="true"
data-slot="native-select-icon"
/>
</div>
</template>

View File

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<optgroup data-slot="native-select-optgroup" :class="cn('bg-popover text-popover-foreground', props.class)">
<slot />
</optgroup>
</template>

View File

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<option data-slot="native-select-option" :class="cn('bg-popover text-popover-foreground', props.class)">
<slot />
</option>
</template>

View File

@ -0,0 +1,3 @@
export { default as NativeSelect } from "./NativeSelect.vue"
export { default as NativeSelectOptGroup } from "./NativeSelectOptGroup.vue"
export { default as NativeSelectOption } from "./NativeSelectOption.vue"

View File

@ -30,7 +30,7 @@ const displayValue = computed({
v-model="displayValue"
type="tel"
inputmode="tel"
autocomplete="tel"
autocomplete="off"
:placeholder="placeholder ?? '08xx-xxxx-xxxx'"
:disabled="disabled"
:class="props.class"