Add ConfirmDialog component for confirmation actions, enhance DataTable with pagination summary and links, and update phone number formatting. Integrate new UI components and improve flash toast handling.

This commit is contained in:
Yoga Pangestu 2026-06-10 01:15:56 +07:00
parent 413021c0d0
commit 960bd71fd0
9 changed files with 270 additions and 68 deletions

View File

@ -1,5 +1,6 @@
import { createInertiaApp } from '@inertiajs/vue3';
import { createApp, h } from 'vue';
import 'vue-sonner/style.css';
import { Toaster } from '@/components/ui/sonner';
import { useFlashToast } from '@/composables/useFlashToast';

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { cn } from '@/lib/utils';
const open = defineModel<boolean>('open', { default: false });
withDefaults(
defineProps<{
title: string;
description?: string;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
loading?: boolean;
}>(),
{
confirmLabel: 'Ya, Lanjutkan',
cancelLabel: 'Batal',
destructive: false,
loading: false,
},
);
const emit = defineEmits<{
confirm: [];
}>();
function handleConfirm(event: Event) {
event.preventDefault();
emit('confirm');
}
</script>
<template>
<AlertDialog v-model:open="open">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ title }}</AlertDialogTitle>
<AlertDialogDescription v-if="description">
{{ description }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel :disabled="loading">
{{ cancelLabel }}
</AlertDialogCancel>
<AlertDialogAction
:disabled="loading"
:class="cn(destructive && 'bg-destructive text-white hover:bg-destructive/90')"
@click="handleConfirm"
>
{{ loading ? 'Memproses...' : confirmLabel }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>

View File

@ -1,8 +1,10 @@
<script setup lang="ts" generic="TData, TValue">
import { Link } from '@inertiajs/vue3';
import type { ColumnDef } from '@tanstack/vue-table';
import { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table';
import { computed, provide } from 'vue';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import { Button } from '@/components/ui/button';
import {
Empty, EmptyDescription,
EmptyHeader, EmptyTitle
@ -19,6 +21,7 @@ import TableEmpty from '@/components/ui/table/TableEmpty.vue';
import type {
DataTableFilterDef,
DataTablePagination,
DataTablePaginationLink,
DataTableSort,
} from '@/types/data-table';
@ -31,6 +34,7 @@ const props = withDefaults(
filterDefs?: DataTableFilterDef[];
filterValues?: Record<string, string>;
searchPlaceholder?: string;
paginationLinks?: DataTablePaginationLink[];
}>(),
{
searchPlaceholder: 'Ketikkan sesuatu...',
@ -94,6 +98,22 @@ provide('data-table-sort', {
sort: () => props.sort,
onSort: handleSort,
});
const showingCount = computed(() => props.data.length);
const paginationSummary = computed(() => {
if (!props.pagination) {
return null;
}
const { total } = props.pagination;
if (total === 0) {
return 'Menampilkan 0 data';
}
return `Menampilkan ${showingCount.value} data dari ${total}`;
});
</script>
<template>
@ -102,7 +122,7 @@ provide('data-table-sort', {
:search-placeholder="searchPlaceholder" @filter-change="(key, value) => emit('filter-change', key, value)"
@filters-reset="emit('filters-reset')" />
<div class="rounded-md border">
<div class="min-w-0 rounded-md border">
<Table>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
@ -134,5 +154,33 @@ provide('data-table-sort', {
</TableBody>
</Table>
</div>
<div
v-if="pagination"
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<p class="text-muted-foreground text-sm">
{{ paginationSummary }}
</p>
<div
v-if="paginationLinks?.length && pagination.lastPage > 1"
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end"
>
<Button
v-for="link in paginationLinks"
:key="`${link.label}-${link.url}`"
variant="outline"
size="sm"
:disabled="!link.url || link.active"
as-child
>
<Link v-if="link.url" :href="link.url" preserve-scroll>
<span v-html="link.label" />
</Link>
<span v-else v-html="link.label" />
</Button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,98 @@
<script setup lang="ts">
import { Link, router } from '@inertiajs/vue3';
import { KeyRound, Pencil, Trash2 } from '@lucide/vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { EmployeeListItem } from '@/types/employee';
const props = defineProps<{
employee: EmployeeListItem;
}>();
const deleteConfirmOpen = ref(false);
const resetPasswordConfirmOpen = ref(false);
const deleteProcessing = ref(false);
const resetPasswordProcessing = ref(false);
function destroyEmployee() {
deleteProcessing.value = true;
router.delete(`/admin/hr/employees/${props.employee.id}`, {
onSuccess: () => {
deleteConfirmOpen.value = false;
},
onError: () => {
toast.error('Gagal menghapus pegawai.');
},
onFinish: () => {
deleteProcessing.value = false;
},
});
}
function resetPassword() {
resetPasswordProcessing.value = true;
router.post(`/admin/hr/employees/${props.employee.id}/reset-password`, {}, {
preserveScroll: true,
onSuccess: () => {
resetPasswordConfirmOpen.value = false;
},
onError: () => {
toast.error('Gagal mereset kata sandi.');
},
onFinish: () => {
resetPasswordProcessing.value = false;
},
});
}
</script>
<template>
<div class="flex items-center justify-end gap-1">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" as-child>
<Link :href="`/admin/hr/employees/${employee.id}/edit`">
<Pencil class="size-4" />
<span class="sr-only">Ubah</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Ubah</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" @click="resetPasswordConfirmOpen = true">
<KeyRound class="size-4" />
<span class="sr-only">Reset Kata Sandi</span>
</Button>
</TooltipTrigger>
<TooltipContent>Reset Kata Sandi</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="text-destructive hover:text-destructive size-8"
@click="deleteConfirmOpen = true">
<Trash2 class="size-4" />
<span class="sr-only">Hapus</span>
</Button>
</TooltipTrigger>
<TooltipContent>Hapus</TooltipContent>
</Tooltip>
</div>
<ConfirmDialog v-model:open="resetPasswordConfirmOpen" title="Reset kata sandi pegawai?"
:description="`Kata sandi ${employee.full_name ?? 'pegawai'} akan direset ke kata sandi default sistem. Pengguna akan otomatis logout dari semua sesi aktif.`"
confirm-label="Reset Kata Sandi" cancel-label="Batal" :loading="resetPasswordProcessing"
@confirm="resetPassword" />
<ConfirmDialog v-model:open="deleteConfirmOpen" title="Hapus pegawai?"
:description="`Data pegawai ${employee.full_name ?? ''} akan dihapus secara permanen beserta akun pengguna terkait. Tindakan ini tidak dapat dibatalkan.`"
confirm-label="Hapus" cancel-label="Batal" destructive :loading="deleteProcessing" @confirm="destroyEmployee" />
</template>

View File

@ -1,59 +0,0 @@
<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,38 @@
<script setup lang="ts">
import type { SwitchRootEmits, SwitchRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
SwitchRoot,
SwitchThumb,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<SwitchRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SwitchRoot
v-slot="slotProps"
data-slot="switch"
v-bind="forwarded"
:class="cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)"
>
<SwitchThumb
data-slot="switch-thumb"
:class="cn('bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0')"
>
<slot name="thumb" v-bind="slotProps" />
</SwitchThumb>
</SwitchRoot>
</template>

View File

@ -6,7 +6,11 @@ type Flash = {
error?: string | null;
};
function showFlash(flash: Flash): void {
function showFlash(flash: Flash | undefined): void {
if (!flash) {
return;
}
if (flash.success) {
toast.success(flash.success);
}
@ -17,11 +21,11 @@ function showFlash(flash: Flash): void {
}
export function useFlashToast(): void {
router.on('success', (event) => {
const flash = event.detail.page.props.flash as Flash | undefined;
router.on('flash', (event) => {
showFlash(event.detail.flash as Flash);
});
if (flash) {
showFlash(flash);
}
router.on('success', (event) => {
showFlash(event.detail.page.props.flash as Flash | undefined);
});
}

View File

@ -16,8 +16,8 @@ export function formatPhoneNumber(value: string | number): string {
}
if (digits.length <= 8) {
return `${digits.slice(0, 4)}-${digits.slice(4)}`;
return `${digits.slice(0, 4)} ${digits.slice(4)}`;
}
return `${digits.slice(0, 4)}-${digits.slice(4, 8)}-${digits.slice(8)}`;
return `${digits.slice(0, 4)} ${digits.slice(4, 8)} ${digits.slice(8)}`;
}

View File

@ -23,6 +23,12 @@ export type DataTablePagination = {
total: number;
};
export type DataTablePaginationLink = {
url: string | null;
label: string;
active: boolean;
};
export type DataTableQuery = {
search?: string;
sort?: string;