Add DataTable component with sorting and filtering capabilities, integrate HR sidebar link, and update package dependencies for @tanstack/vue-table. Introduce new UI components for alert dialogs and empty states.
This commit is contained in:
parent
e3dff20377
commit
c5ec7b5afc
33
package-lock.json
generated
33
package-lock.json
generated
@ -9,6 +9,7 @@
|
||||
"@inertiajs/vue3": "^3.0.0",
|
||||
"@lucide/vue": "^1.17.0",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@ -1202,6 +1203,19 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz",
|
||||
@ -1212,6 +1226,25 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/vue-table": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.21.3.tgz",
|
||||
"integrity": "sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.21.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/vue-virtual": {
|
||||
"version": "3.13.28",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.28.tgz",
|
||||
|
||||
@ -34,6 +34,7 @@
|
||||
"@inertiajs/vue3": "^3.0.0",
|
||||
"@lucide/vue": "^1.17.0",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import { LayoutDashboard } from '@lucide/vue';
|
||||
import { LayoutDashboard, Users } from '@lucide/vue';
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
Sidebar,
|
||||
@ -18,6 +18,7 @@ import {
|
||||
const page = usePage();
|
||||
|
||||
const isDashboardActive = computed(() => page.url.startsWith('/admin/dashboard'));
|
||||
const isEmployeesActive = computed(() => page.url.startsWith('/admin/hr/employees'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -51,6 +52,21 @@ const isDashboardActive = computed(() => page.url.startsWith('/admin/dashboard')
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>HR</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton as-child tooltip="Pegawai" :is-active="isEmployeesActive">
|
||||
<Link href="/admin/hr/employees">
|
||||
<Users />
|
||||
<span>Pegawai</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
138
resources/js/components/data-table/DataTable.vue
Normal file
138
resources/js/components/data-table/DataTable.vue
Normal file
@ -0,0 +1,138 @@
|
||||
<script setup lang="ts" generic="TData, TValue">
|
||||
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 {
|
||||
Empty, EmptyDescription,
|
||||
EmptyHeader, EmptyTitle
|
||||
} from '@/components/ui/empty';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import TableEmpty from '@/components/ui/table/TableEmpty.vue';
|
||||
import type {
|
||||
DataTableFilterDef,
|
||||
DataTablePagination,
|
||||
DataTableSort,
|
||||
} from '@/types/data-table';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pagination?: DataTablePagination;
|
||||
sort?: DataTableSort | null;
|
||||
filterDefs?: DataTableFilterDef[];
|
||||
filterValues?: Record<string, string>;
|
||||
searchPlaceholder?: string;
|
||||
}>(),
|
||||
{
|
||||
searchPlaceholder: 'Ketikkan sesuatu...',
|
||||
}
|
||||
);
|
||||
|
||||
const search = defineModel<string>('search', { default: '' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
'sort-change': [sort: DataTableSort | null];
|
||||
'filter-change': [key: string, value: string];
|
||||
'filters-reset': [];
|
||||
}>();
|
||||
|
||||
const rowNumberColumn: ColumnDef<TData> = {
|
||||
id: '_row_number',
|
||||
header: 'No.',
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const offset = props.pagination
|
||||
? (props.pagination.currentPage - 1) * props.pagination.perPage
|
||||
: 0;
|
||||
|
||||
return offset + row.index + 1;
|
||||
},
|
||||
};
|
||||
|
||||
const resolvedColumns = computed(() => [rowNumberColumn, ...props.columns]);
|
||||
|
||||
const table = useVueTable({
|
||||
get data() {
|
||||
return props.data;
|
||||
},
|
||||
get columns() {
|
||||
return resolvedColumns.value;
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualSorting: true,
|
||||
});
|
||||
|
||||
function handleSort(column: string): void {
|
||||
const current = props.sort;
|
||||
|
||||
if (current?.column !== column) {
|
||||
emit('sort-change', { column, direction: 'asc' });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (current.direction === 'asc') {
|
||||
emit('sort-change', { column, direction: 'desc' });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
emit('sort-change', null);
|
||||
}
|
||||
|
||||
provide('data-table-sort', {
|
||||
sort: () => props.sort,
|
||||
onSort: handleSort,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<DataTableToolbar v-model:search="search" :filter-defs="filterDefs" :filter-values="filterValues"
|
||||
:search-placeholder="searchPlaceholder" @filter-change="(key, value) => emit('filter-change', key, value)"
|
||||
@filters-reset="emit('filters-reset')" />
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead v-for="header in headerGroup.headers" :key="header.id">
|
||||
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header"
|
||||
:props="header.getContext()" />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-if="table.getRowModel().rows.length">
|
||||
<TableRow v-for="row in table.getRowModel().rows" :key="row.id"
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined">
|
||||
<TableCell v-for="cell in row.getVisibleCells()" :key="cell.id">
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
<TableEmpty v-else :colspan="resolvedColumns.length">
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Data tidak ditemukan</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Silakan lakukan pencarian atau filter untuk menemukan data yang Anda cari.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</TableEmpty>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
51
resources/js/components/data-table/DataTableColumnHeader.vue
Normal file
51
resources/js/components/data-table/DataTableColumnHeader.vue
Normal file
@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowDown, ArrowUp, ChevronsUpDown } from '@lucide/vue';
|
||||
import { inject } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DataTableSort } from '@/types/data-table';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string;
|
||||
column: string;
|
||||
class?: string;
|
||||
}>(),
|
||||
{},
|
||||
);
|
||||
|
||||
type DataTableSortContext = {
|
||||
sort: () => DataTableSort | null | undefined;
|
||||
onSort: (column: string) => void;
|
||||
};
|
||||
|
||||
const sortContext = inject<DataTableSortContext>('data-table-sort');
|
||||
|
||||
function isSorted(): boolean {
|
||||
return sortContext?.sort()?.column === props.column;
|
||||
}
|
||||
|
||||
function sortDirection(): 'asc' | 'desc' | null {
|
||||
if (!isSorted()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sortContext?.sort()?.direction ?? null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex items-center gap-2', props.class)">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="cn('-ml-3 h-8 data-[state=open]:bg-accent', props.class?.includes('justify-end') && 'ml-auto')"
|
||||
@click="sortContext?.onSort(column)"
|
||||
>
|
||||
<span>{{ title }}</span>
|
||||
<ArrowDown v-if="sortDirection() === 'desc'" class="size-4" />
|
||||
<ArrowUp v-else-if="sortDirection() === 'asc'" class="size-4" />
|
||||
<ChevronsUpDown v-else class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
146
resources/js/components/data-table/DataTableToolbar.vue
Normal file
146
resources/js/components/data-table/DataTableToolbar.vue
Normal file
@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { ListFilter, Search } from '@lucide/vue';
|
||||
import { computed } from 'vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { DataTableFilterDef } from '@/types/data-table';
|
||||
|
||||
const props = defineProps<{
|
||||
filterDefs?: DataTableFilterDef[];
|
||||
filterValues?: Record<string, string>;
|
||||
searchPlaceholder?: string;
|
||||
}>();
|
||||
|
||||
const search = defineModel<string>('search', { default: '' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
'filter-change': [key: string, value: string];
|
||||
'filters-reset': [];
|
||||
}>();
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
if (!props.filterDefs?.length || !props.filterValues) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return props.filterDefs.filter((filter) => {
|
||||
const value = props.filterValues?.[filter.key];
|
||||
|
||||
return value !== undefined && value !== '';
|
||||
}).length;
|
||||
});
|
||||
|
||||
function filterValue(key: string): string {
|
||||
return props.filterValues?.[key] ?? '';
|
||||
}
|
||||
|
||||
function onFilterChange(key: string, value: string): void {
|
||||
emit('filter-change', key, value === 'all' ? '' : value);
|
||||
}
|
||||
|
||||
function resetFilters(): void {
|
||||
emit('filters-reset');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="relative w-full max-w-sm">
|
||||
<Search class="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
v-model="search"
|
||||
type="search"
|
||||
:placeholder="searchPlaceholder ?? 'Cari...'"
|
||||
class="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Popover v-if="filterDefs?.length">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" class="shrink-0">
|
||||
<ListFilter class="size-4" />
|
||||
Filter
|
||||
<Badge
|
||||
v-if="activeFilterCount > 0"
|
||||
variant="secondary"
|
||||
class="ml-1 size-5 rounded-full px-1 text-xs"
|
||||
>
|
||||
{{ activeFilterCount }}
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-80 space-y-4 p-4">
|
||||
<div class="space-y-1">
|
||||
<h4 class="text-sm font-medium">
|
||||
Filter
|
||||
</h4>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Sesuaikan filter untuk mempersempit data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="filter in filterDefs"
|
||||
:key="filter.key"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label :for="`filter-${filter.key}`">{{ filter.label }}</Label>
|
||||
|
||||
<Input
|
||||
v-if="filter.type === 'text'"
|
||||
:id="`filter-${filter.key}`"
|
||||
:model-value="filterValue(filter.key)"
|
||||
:placeholder="filter.placeholder ?? `Filter ${filter.label.toLowerCase()}`"
|
||||
@update:model-value="onFilterChange(filter.key, String($event ?? ''))"
|
||||
/>
|
||||
|
||||
<Select
|
||||
v-else
|
||||
:model-value="filterValue(filter.key) || 'all'"
|
||||
@update:model-value="onFilterChange(filter.key, String($event ?? ''))"
|
||||
>
|
||||
<SelectTrigger :id="`filter-${filter.key}`" class="w-full">
|
||||
<SelectValue :placeholder="filter.placeholder ?? 'Semua'" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
Semua
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="option in filter.options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="activeFilterCount > 0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
@click="resetFilters"
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
3
resources/js/components/data-table/index.ts
Normal file
3
resources/js/components/data-table/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as DataTable } from './DataTable.vue';
|
||||
export { default as DataTableColumnHeader } from './DataTableColumnHeader.vue';
|
||||
export { default as DataTableToolbar } from './DataTableToolbar.vue';
|
||||
15
resources/js/components/ui/alert-dialog/AlertDialog.vue
Normal file
15
resources/js/components/ui/alert-dialog/AlertDialog.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
|
||||
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<AlertDialogProps>()
|
||||
const emits = defineEmits<AlertDialogEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogRoot v-slot="slotProps" data-slot="alert-dialog" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogActionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogAction } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
|
||||
<slot />
|
||||
</AlertDialogAction>
|
||||
</template>
|
||||
@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogCancelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogCancel } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogCancel
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogCancel>
|
||||
</template>
|
||||
@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
AlertDialogContent,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<AlertDialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80"
|
||||
/>
|
||||
<AlertDialogContent
|
||||
data-slot="alert-dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</template>
|
||||
@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
AlertDialogDescription,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogDescription
|
||||
data-slot="alert-dialog-description"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogDescription>
|
||||
</template>
|
||||
@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
resources/js/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
21
resources/js/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogTitleProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogTitle } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTitle
|
||||
data-slot="alert-dialog-title"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-lg font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogTitle>
|
||||
</template>
|
||||
@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogTriggerProps } from "reka-ui"
|
||||
import { AlertDialogTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<AlertDialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
|
||||
<slot />
|
||||
</AlertDialogTrigger>
|
||||
</template>
|
||||
9
resources/js/components/ui/alert-dialog/index.ts
Normal file
9
resources/js/components/ui/alert-dialog/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export { default as AlertDialog } from "./AlertDialog.vue"
|
||||
export { default as AlertDialogAction } from "./AlertDialogAction.vue"
|
||||
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"
|
||||
export { default as AlertDialogContent } from "./AlertDialogContent.vue"
|
||||
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"
|
||||
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"
|
||||
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"
|
||||
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"
|
||||
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue"
|
||||
26
resources/js/components/ui/badge/Badge.vue
Normal file
26
resources/js/components/ui/badge/Badge.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { BadgeVariants } from "."
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { badgeVariants } from "."
|
||||
|
||||
const props = defineProps<PrimitiveProps & {
|
||||
variant?: BadgeVariants["variant"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="badge"
|
||||
:class="cn(badgeVariants({ variant }), props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
26
resources/js/components/ui/badge/index.ts
Normal file
26
resources/js/components/ui/badge/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Badge } from "./Badge.vue"
|
||||
|
||||
export const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none 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 transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
20
resources/js/components/ui/empty/Empty.vue
Normal file
20
resources/js/components/ui/empty/Empty.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty"
|
||||
:class="cn(
|
||||
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-lg border-dashed p-6 text-center md:p-12',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
20
resources/js/components/ui/empty/EmptyContent.vue
Normal file
20
resources/js/components/ui/empty/EmptyContent.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
:class="cn(
|
||||
'flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
20
resources/js/components/ui/empty/EmptyDescription.vue
Normal file
20
resources/js/components/ui/empty/EmptyDescription.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="empty-description"
|
||||
:class="cn(
|
||||
'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
|
||||
$attrs.class ?? '',
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
20
resources/js/components/ui/empty/EmptyHeader.vue
Normal file
20
resources/js/components/ui/empty/EmptyHeader.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
:class="cn(
|
||||
'flex max-w-sm flex-col items-center gap-2 text-center',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
resources/js/components/ui/empty/EmptyMedia.vue
Normal file
21
resources/js/components/ui/empty/EmptyMedia.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { EmptyMediaVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { emptyMediaVariants } from "."
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: EmptyMediaVariants["variant"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
:data-variant="variant"
|
||||
:class="cn(emptyMediaVariants({ variant }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
resources/js/components/ui/empty/EmptyTitle.vue
Normal file
17
resources/js/components/ui/empty/EmptyTitle.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
:class="cn('text-lg font-medium tracking-tight', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
26
resources/js/components/ui/empty/index.ts
Normal file
26
resources/js/components/ui/empty/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Empty } from "./Empty.vue"
|
||||
export { default as EmptyContent } from "./EmptyContent.vue"
|
||||
export { default as EmptyDescription } from "./EmptyDescription.vue"
|
||||
export { default as EmptyHeader } from "./EmptyHeader.vue"
|
||||
export { default as EmptyMedia } from "./EmptyMedia.vue"
|
||||
export { default as EmptyTitle } from "./EmptyTitle.vue"
|
||||
|
||||
export const emptyMediaVariants = cva(
|
||||
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type EmptyMediaVariants = VariantProps<typeof emptyMediaVariants>
|
||||
@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { formatPhoneNumber, parsePhoneNumber } from '@/lib/phone-number';
|
||||
|
||||
const props = defineProps<{
|
||||
id?: string;
|
||||
modelValue?: string | number;
|
||||
class?: HTMLAttributes['class'];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void;
|
||||
}>();
|
||||
|
||||
const displayValue = computed({
|
||||
get: () => formatPhoneNumber(props.modelValue ?? ''),
|
||||
set: (value: string) => {
|
||||
emit('update:modelValue', parsePhoneNumber(value));
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Input
|
||||
:id="id"
|
||||
v-model="displayValue"
|
||||
type="tel"
|
||||
inputmode="tel"
|
||||
autocomplete="tel"
|
||||
:placeholder="placeholder ?? '08xx-xxxx-xxxx'"
|
||||
:disabled="disabled"
|
||||
:class="props.class"
|
||||
/>
|
||||
</template>
|
||||
1
resources/js/components/ui/phone-number-input/index.ts
Normal file
1
resources/js/components/ui/phone-number-input/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as PhoneNumberInput } from './PhoneNumberInput.vue';
|
||||
19
resources/js/components/ui/popover/Popover.vue
Normal file
19
resources/js/components/ui/popover/Popover.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverRootEmits, PopoverRootProps } from "reka-ui"
|
||||
import { PopoverRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<PopoverRootProps>()
|
||||
const emits = defineEmits<PopoverRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="popover"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
15
resources/js/components/ui/popover/PopoverAnchor.vue
Normal file
15
resources/js/components/ui/popover/PopoverAnchor.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverAnchorProps } from "reka-ui"
|
||||
import { PopoverAnchor } from "reka-ui"
|
||||
|
||||
const props = defineProps<PopoverAnchorProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverAnchor
|
||||
data-slot="popover-anchor"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</PopoverAnchor>
|
||||
</template>
|
||||
45
resources/js/components/ui/popover/PopoverContent.vue
Normal file
45
resources/js/components/ui/popover/PopoverContent.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverContentEmits, PopoverContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<PopoverContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
align: "center",
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<PopoverContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
data-slot="popover-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md origin-(--reka-popover-content-transform-origin) outline-hidden',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</template>
|
||||
15
resources/js/components/ui/popover/PopoverTrigger.vue
Normal file
15
resources/js/components/ui/popover/PopoverTrigger.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverTriggerProps } from "reka-ui"
|
||||
import { PopoverTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<PopoverTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverTrigger
|
||||
data-slot="popover-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</PopoverTrigger>
|
||||
</template>
|
||||
4
resources/js/components/ui/popover/index.ts
Normal file
4
resources/js/components/ui/popover/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { default as Popover } from "./Popover.vue"
|
||||
export { default as PopoverAnchor } from "./PopoverAnchor.vue"
|
||||
export { default as PopoverContent } from "./PopoverContent.vue"
|
||||
export { default as PopoverTrigger } from "./PopoverTrigger.vue"
|
||||
25
resources/js/components/ui/radio-group/RadioGroup.vue
Normal file
25
resources/js/components/ui/radio-group/RadioGroup.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { RadioGroupRootEmits, RadioGroupRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { RadioGroupRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<RadioGroupRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<RadioGroupRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RadioGroupRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="radio-group"
|
||||
:class="cn('grid gap-3', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</RadioGroupRoot>
|
||||
</template>
|
||||
40
resources/js/components/ui/radio-group/RadioGroupItem.vue
Normal file
40
resources/js/components/ui/radio-group/RadioGroupItem.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { RadioGroupItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { CircleIcon } from "@lucide/vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
RadioGroupIndicator,
|
||||
RadioGroupItem,
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<RadioGroupItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RadioGroupItem
|
||||
data-slot="radio-group-item"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<RadioGroupIndicator
|
||||
data-slot="radio-group-indicator"
|
||||
class="relative flex items-center justify-center"
|
||||
>
|
||||
<slot>
|
||||
<CircleIcon class="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</slot>
|
||||
</RadioGroupIndicator>
|
||||
</RadioGroupItem>
|
||||
</template>
|
||||
2
resources/js/components/ui/radio-group/index.ts
Normal file
2
resources/js/components/ui/radio-group/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as RadioGroup } from "./RadioGroup.vue"
|
||||
export { default as RadioGroupItem } from "./RadioGroupItem.vue"
|
||||
45
resources/js/components/ui/rupiah-input/RupiahInput.vue
Normal file
45
resources/js/components/ui/rupiah-input/RupiahInput.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const props = defineProps<{
|
||||
id?: string;
|
||||
modelValue?: string | number;
|
||||
class?: HTMLAttributes['class'];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void;
|
||||
}>();
|
||||
|
||||
const displayValue = computed({
|
||||
get: () => formatRupiah(props.modelValue ?? ''),
|
||||
set: (value: string) => {
|
||||
emit('update:modelValue', parseRupiah(value));
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<span
|
||||
class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-sm"
|
||||
>
|
||||
Rp
|
||||
</span>
|
||||
<Input
|
||||
:id="id"
|
||||
v-model="displayValue"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
:placeholder="placeholder ?? '0'"
|
||||
:disabled="disabled"
|
||||
:class="cn('pl-9', props.class)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
1
resources/js/components/ui/rupiah-input/index.ts
Normal file
1
resources/js/components/ui/rupiah-input/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as RupiahInput } from './RupiahInput.vue';
|
||||
19
resources/js/components/ui/select/Select.vue
Normal file
19
resources/js/components/ui/select/Select.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectRootEmits, SelectRootProps } from "reka-ui"
|
||||
import { SelectRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<SelectRootProps>()
|
||||
const emits = defineEmits<SelectRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="select"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</SelectRoot>
|
||||
</template>
|
||||
51
resources/js/components/ui/select/SelectContent.vue
Normal file
51
resources/js/components/ui/select/SelectContent.vue
Normal file
@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
SelectContent,
|
||||
SelectPortal,
|
||||
SelectViewport,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { SelectScrollDownButton, SelectScrollUpButton } from "."
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
position: "popper",
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<SelectContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
data-slot="select-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
position === 'popper'
|
||||
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1')">
|
||||
<slot />
|
||||
</SelectViewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</template>
|
||||
15
resources/js/components/ui/select/SelectGroup.vue
Normal file
15
resources/js/components/ui/select/SelectGroup.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectGroupProps } from "reka-ui"
|
||||
import { SelectGroup } from "reka-ui"
|
||||
|
||||
const props = defineProps<SelectGroupProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectGroup
|
||||
data-slot="select-group"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</SelectGroup>
|
||||
</template>
|
||||
44
resources/js/components/ui/select/SelectItem.vue
Normal file
44
resources/js/components/ui/select/SelectItem.vue
Normal file
@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { Check } from "@lucide/vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectItem
|
||||
data-slot="select-item"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Check class="size-4" />
|
||||
</slot>
|
||||
</SelectItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectItemText>
|
||||
<slot />
|
||||
</SelectItemText>
|
||||
</SelectItem>
|
||||
</template>
|
||||
15
resources/js/components/ui/select/SelectItemText.vue
Normal file
15
resources/js/components/ui/select/SelectItemText.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectItemTextProps } from "reka-ui"
|
||||
import { SelectItemText } from "reka-ui"
|
||||
|
||||
const props = defineProps<SelectItemTextProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectItemText
|
||||
data-slot="select-item-text"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</SelectItemText>
|
||||
</template>
|
||||
17
resources/js/components/ui/select/SelectLabel.vue
Normal file
17
resources/js/components/ui/select/SelectLabel.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectLabelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { SelectLabel } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes["class"] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectLabel
|
||||
data-slot="select-label"
|
||||
:class="cn('text-muted-foreground px-2 py-1.5 text-xs', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</SelectLabel>
|
||||
</template>
|
||||
26
resources/js/components/ui/select/SelectScrollDownButton.vue
Normal file
26
resources/js/components/ui/select/SelectScrollDownButton.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectScrollDownButtonProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { ChevronDown } from "@lucide/vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { SelectScrollDownButton, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<ChevronDown class="size-4" />
|
||||
</slot>
|
||||
</SelectScrollDownButton>
|
||||
</template>
|
||||
26
resources/js/components/ui/select/SelectScrollUpButton.vue
Normal file
26
resources/js/components/ui/select/SelectScrollUpButton.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectScrollUpButtonProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { ChevronUp } from "@lucide/vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { SelectScrollUpButton, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<ChevronUp class="size-4" />
|
||||
</slot>
|
||||
</SelectScrollUpButton>
|
||||
</template>
|
||||
19
resources/js/components/ui/select/SelectSeparator.vue
Normal file
19
resources/js/components/ui/select/SelectSeparator.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectSeparatorProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { SelectSeparator } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectSeparator
|
||||
data-slot="select-separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-border pointer-events-none -mx-1 my-1 h-px', props.class)"
|
||||
/>
|
||||
</template>
|
||||
33
resources/js/components/ui/select/SelectTrigger.vue
Normal file
33
resources/js/components/ui/select/SelectTrigger.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { ChevronDown } from "@lucide/vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(),
|
||||
{ size: "default" },
|
||||
)
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "size")
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectTrigger
|
||||
data-slot="select-trigger"
|
||||
:data-size="size"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
<SelectIcon as-child>
|
||||
<ChevronDown class="size-4 opacity-50" />
|
||||
</SelectIcon>
|
||||
</SelectTrigger>
|
||||
</template>
|
||||
15
resources/js/components/ui/select/SelectValue.vue
Normal file
15
resources/js/components/ui/select/SelectValue.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectValueProps } from "reka-ui"
|
||||
import { SelectValue } from "reka-ui"
|
||||
|
||||
const props = defineProps<SelectValueProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectValue
|
||||
data-slot="select-value"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</SelectValue>
|
||||
</template>
|
||||
11
resources/js/components/ui/select/index.ts
Normal file
11
resources/js/components/ui/select/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export { default as Select } from "./Select.vue"
|
||||
export { default as SelectContent } from "./SelectContent.vue"
|
||||
export { default as SelectGroup } from "./SelectGroup.vue"
|
||||
export { default as SelectItem } from "./SelectItem.vue"
|
||||
export { default as SelectItemText } from "./SelectItemText.vue"
|
||||
export { default as SelectLabel } from "./SelectLabel.vue"
|
||||
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue"
|
||||
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue"
|
||||
export { default as SelectSeparator } from "./SelectSeparator.vue"
|
||||
export { default as SelectTrigger } from "./SelectTrigger.vue"
|
||||
export { default as SelectValue } from "./SelectValue.vue"
|
||||
16
resources/js/components/ui/table/Table.vue
Normal file
16
resources/js/components/ui/table/Table.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="table-container" class="relative w-full overflow-auto">
|
||||
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
17
resources/js/components/ui/table/TableBody.vue
Normal file
17
resources/js/components/ui/table/TableBody.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
:class="cn('[&_tr:last-child]:border-0', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</tbody>
|
||||
</template>
|
||||
17
resources/js/components/ui/table/TableCaption.vue
Normal file
17
resources/js/components/ui/table/TableCaption.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</caption>
|
||||
</template>
|
||||
22
resources/js/components/ui/table/TableCell.vue
Normal file
22
resources/js/components/ui/table/TableCell.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
:class="
|
||||
cn(
|
||||
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-0.5',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</td>
|
||||
</template>
|
||||
34
resources/js/components/ui/table/TableEmpty.vue
Normal file
34
resources/js/components/ui/table/TableEmpty.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { cn } from "@/lib/utils"
|
||||
import TableCell from "./TableCell.vue"
|
||||
import TableRow from "./TableRow.vue"
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
colspan?: number
|
||||
}>(), {
|
||||
colspan: 1,
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
:class="
|
||||
cn(
|
||||
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<div class="flex items-center justify-center">
|
||||
<slot />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
17
resources/js/components/ui/table/TableFooter.vue
Normal file
17
resources/js/components/ui/table/TableFooter.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</tfoot>
|
||||
</template>
|
||||
17
resources/js/components/ui/table/TableHead.vue
Normal file
17
resources/js/components/ui/table/TableHead.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<th
|
||||
data-slot="table-head"
|
||||
:class="cn('text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-0.5', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</th>
|
||||
</template>
|
||||
17
resources/js/components/ui/table/TableHeader.vue
Normal file
17
resources/js/components/ui/table/TableHeader.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
:class="cn('[&_tr]:border-b', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</thead>
|
||||
</template>
|
||||
17
resources/js/components/ui/table/TableRow.vue
Normal file
17
resources/js/components/ui/table/TableRow.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
:class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</tr>
|
||||
</template>
|
||||
9
resources/js/components/ui/table/index.ts
Normal file
9
resources/js/components/ui/table/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export { default as Table } from "./Table.vue"
|
||||
export { default as TableBody } from "./TableBody.vue"
|
||||
export { default as TableCaption } from "./TableCaption.vue"
|
||||
export { default as TableCell } from "./TableCell.vue"
|
||||
export { default as TableEmpty } from "./TableEmpty.vue"
|
||||
export { default as TableFooter } from "./TableFooter.vue"
|
||||
export { default as TableHead } from "./TableHead.vue"
|
||||
export { default as TableHeader } from "./TableHeader.vue"
|
||||
export { default as TableRow } from "./TableRow.vue"
|
||||
10
resources/js/components/ui/table/utils.ts
Normal file
10
resources/js/components/ui/table/utils.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import type { Updater } from "@tanstack/vue-table"
|
||||
|
||||
import type { Ref } from "vue"
|
||||
import { isFunction } from "@tanstack/vue-table"
|
||||
|
||||
export function valueUpdater<T>(updaterOrValue: Updater<T>, ref: Ref<T>) {
|
||||
ref.value = isFunction(updaterOrValue)
|
||||
? updaterOrValue(ref.value)
|
||||
: updaterOrValue
|
||||
}
|
||||
28
resources/js/components/ui/textarea/Textarea.vue
Normal file
28
resources/js/components/ui/textarea/Textarea.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { useVModel } from "@vueuse/core"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
defaultValue?: string | number
|
||||
modelValue?: string | number
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: "update:modelValue", payload: string | number): void
|
||||
}>()
|
||||
|
||||
const modelValue = useVModel(props, "modelValue", emits, {
|
||||
passive: true,
|
||||
defaultValue: props.defaultValue,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
v-model="modelValue"
|
||||
data-slot="textarea"
|
||||
:class="cn('border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', props.class)"
|
||||
/>
|
||||
</template>
|
||||
1
resources/js/components/ui/textarea/index.ts
Normal file
1
resources/js/components/ui/textarea/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as Textarea } from "./Textarea.vue"
|
||||
85
resources/js/composables/useDataTableQuery.ts
Normal file
85
resources/js/composables/useDataTableQuery.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { ref, watch, type WatchSource } from 'vue';
|
||||
import type { DataTableQuery, DataTableSort } from '@/types/data-table';
|
||||
|
||||
type UseDataTableQueryOptions = {
|
||||
url: string;
|
||||
initial: DataTableQuery;
|
||||
filterKeys?: string[];
|
||||
debounceMs?: number;
|
||||
};
|
||||
|
||||
function buildParams(query: DataTableQuery, filterKeys: string[]): Record<string, string | undefined> {
|
||||
const params: Record<string, string | undefined> = {
|
||||
search: query.search || undefined,
|
||||
sort: query.sort || undefined,
|
||||
direction: query.direction || undefined,
|
||||
};
|
||||
|
||||
for (const key of filterKeys) {
|
||||
params[key] = query[key] || undefined;
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export function useDataTableQuery(options: UseDataTableQueryOptions) {
|
||||
const filterKeys = options.filterKeys ?? [];
|
||||
const query = ref<DataTableQuery>({ ...options.initial });
|
||||
|
||||
const reload = useDebounceFn(() => {
|
||||
router.get(options.url, buildParams(query.value, filterKeys), {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
preserveScroll: true,
|
||||
});
|
||||
}, options.debounceMs ?? 300);
|
||||
|
||||
function setSearch(value: string): void {
|
||||
query.value.search = value;
|
||||
reload();
|
||||
}
|
||||
|
||||
function setSort(sort: DataTableSort | null): void {
|
||||
query.value.sort = sort?.column;
|
||||
query.value.direction = sort?.direction;
|
||||
reload();
|
||||
}
|
||||
|
||||
function setFilter(key: string, value: string): void {
|
||||
query.value[key] = value;
|
||||
reload();
|
||||
}
|
||||
|
||||
function resetFilters(): void {
|
||||
for (const key of filterKeys) {
|
||||
query.value[key] = '';
|
||||
}
|
||||
|
||||
reload();
|
||||
}
|
||||
|
||||
function syncFromServer(filters: DataTableQuery): void {
|
||||
query.value = { ...filters };
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
setSearch,
|
||||
setSort,
|
||||
setFilter,
|
||||
resetFilters,
|
||||
reload,
|
||||
syncFromServer,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDataTableQuerySync(
|
||||
filters: WatchSource<DataTableQuery>,
|
||||
syncFromServer: (filters: DataTableQuery) => void,
|
||||
): void {
|
||||
watch(filters, (value) => {
|
||||
syncFromServer(value);
|
||||
});
|
||||
}
|
||||
23
resources/js/lib/phone-number.ts
Normal file
23
resources/js/lib/phone-number.ts
Normal file
@ -0,0 +1,23 @@
|
||||
const MAX_PHONE_DIGITS = 13;
|
||||
|
||||
export function parsePhoneNumber(value: string | number): string {
|
||||
return String(value).replace(/\D/g, '').slice(0, MAX_PHONE_DIGITS);
|
||||
}
|
||||
|
||||
export function formatPhoneNumber(value: string | number): string {
|
||||
const digits = parsePhoneNumber(value);
|
||||
|
||||
if (!digits) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (digits.length <= 4) {
|
||||
return digits;
|
||||
}
|
||||
|
||||
if (digits.length <= 8) {
|
||||
return `${digits.slice(0, 4)}-${digits.slice(4)}`;
|
||||
}
|
||||
|
||||
return `${digits.slice(0, 4)}-${digits.slice(4, 8)}-${digits.slice(8)}`;
|
||||
}
|
||||
13
resources/js/lib/rupiah.ts
Normal file
13
resources/js/lib/rupiah.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export function parseRupiah(value: string | number): string {
|
||||
return String(value).replace(/\D/g, '');
|
||||
}
|
||||
|
||||
export function formatRupiah(value: string | number): string {
|
||||
const digits = parseRupiah(value);
|
||||
|
||||
if (!digits) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return Number(digits).toLocaleString('id-ID');
|
||||
}
|
||||
31
resources/js/types/data-table.ts
Normal file
31
resources/js/types/data-table.ts
Normal file
@ -0,0 +1,31 @@
|
||||
export type DataTableSort = {
|
||||
column: string;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export type DataTableFilterOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type DataTableFilterDef = {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'text' | 'select';
|
||||
placeholder?: string;
|
||||
options?: DataTableFilterOption[];
|
||||
};
|
||||
|
||||
export type DataTablePagination = {
|
||||
currentPage: number;
|
||||
perPage: number;
|
||||
lastPage: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type DataTableQuery = {
|
||||
search?: string;
|
||||
sort?: string;
|
||||
direction?: 'asc' | 'desc';
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
60
resources/js/types/employee.ts
Normal file
60
resources/js/types/employee.ts
Normal file
@ -0,0 +1,60 @@
|
||||
export type EnumOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type EmployeeListItem = {
|
||||
id: number;
|
||||
employee_code: string;
|
||||
join_date: string | null;
|
||||
resign_date: string | null;
|
||||
employment_status: string;
|
||||
employment_status_label: string;
|
||||
employee_status: string;
|
||||
employee_status_label: string;
|
||||
base_salary: number;
|
||||
base_salary_formatted: string;
|
||||
email: string | null;
|
||||
username: string | null;
|
||||
is_active: boolean;
|
||||
full_name: string | null;
|
||||
phone_number: string | null;
|
||||
gender: string | null;
|
||||
gender_label: string | null;
|
||||
birth_date: string | null;
|
||||
address: string | null;
|
||||
};
|
||||
|
||||
export type EmployeeFormData = {
|
||||
email: string;
|
||||
username: string;
|
||||
full_name: string;
|
||||
phone_number: string;
|
||||
gender: string;
|
||||
birth_date: string;
|
||||
address: string;
|
||||
join_date: string;
|
||||
employment_status: string;
|
||||
base_salary: string;
|
||||
};
|
||||
|
||||
export type EmployeeFilters = {
|
||||
search: string;
|
||||
sort?: string;
|
||||
direction?: 'asc' | 'desc' | null;
|
||||
employment_status?: string;
|
||||
employee_status?: string;
|
||||
};
|
||||
|
||||
export type PaginatedEmployees = {
|
||||
data: EmployeeListItem[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
links: Array<{
|
||||
url: string | null;
|
||||
label: string;
|
||||
active: boolean;
|
||||
}>;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user