Dev cleaning (#106)

This commit is contained in:
Munawwirul Jamal
2025-10-08 00:03:36 +07:00
committed by GitHub
parent 7fdd5c61f0
commit 3eb9dde21d
892 changed files with 51326 additions and 1 deletions
@@ -0,0 +1,33 @@
<script lang="ts" setup>
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from '~/components/pub/ui/breadcrumb'
const props = defineProps<{
links: {
title: string
href: string
}[]
}>()
</script>
<template>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem v-for="(link, index) in props.links" :key="link.href">
<BreadcrumbLink as-child>
<NuxtLink :to="link.href">
{{ link.title }}
</NuxtLink>
</BreadcrumbLink>
<BreadcrumbSeparator v-if="index < props.links.length - 1" />
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { type TabItem } from './type'
const props = defineProps<{
initialActiveTab: string
data: TabItem[]
}>()
const activeTab = ref(props.initialActiveTab)
const emit = defineEmits<{
changeTab: [value: string]
}>()
function changeTab(value: string) {
activeTab.value = value;
emit('changeTab', value);
}
</script>
<template>
<!-- Tabs -->
<div class="mt-4 flex flex-wrap gap-2 rounded-md border bg-white p-4 shadow-sm">
<Button
v-for="tab in data"
:key="tab.value"
:data-active="activeTab === tab.value"
class="rounded-full transition data-[active=false]:bg-gray-100 data-[active=true]:bg-primary data-[active=false]:text-gray-700 data-[active=true]:text-white"
@click="changeTab(tab.value)"
>
{{ tab.label }}
</Button>
</div>
<!-- Active Tab Content -->
<div class="mt-4 rounded-md border p-4">
<component
v-if="data.find((t) => t.value === activeTab)?.component"
:is="data.find((t) => t.value === activeTab)?.component"
:label="data.find((t) => t.value === activeTab)?.label"
v-bind="data.find((t) => t.value === activeTab)?.props || {}"
/>
</div>
</template>
@@ -0,0 +1,6 @@
export interface TabItem {
value: string
label: string
component?: any
props?: Record<string, any>
}
@@ -0,0 +1,137 @@
# Confirmation Modal Components
Sistem confirmation modal yang modular dan dapat digunakan kembali di seluruh aplikasi.
## Components
### 1. `confirmation.vue` - Base Confirmation Modal
Komponen dasar untuk modal konfirmasi yang dapat dikustomisasi.
**Props:**
- `open?: boolean` - Status modal (terbuka/tertutup)
- `title?: string` - Judul modal (default: "Konfirmasi")
- `message?: string` - Pesan konfirmasi (default: "Apakah Anda yakin ingin melanjutkan?")
- `confirmText?: string` - Text tombol konfirmasi (default: "Ya")
- `cancelText?: string` - Text tombol batal (default: "Batal")
- `variant?: 'default' | 'destructive' | 'warning'` - Varian tampilan (default: "default")
- `size?: 'sm' | 'md' | 'lg' | 'xl'` - Ukuran modal (default: "md")
**Events:**
- `@confirm` - Dipanggil saat tombol konfirmasi diklik
- `@cancel` - Dipanggil saat tombol batal diklik
- `@update:open` - Dipanggil saat status modal berubah
**Contoh penggunaan:**
```vue
<template>
<Confirmation
v-model:open="isConfirmOpen"
title="Hapus Data"
message="Apakah Anda yakin ingin menghapus data ini?"
confirm-text="Hapus"
variant="destructive"
@confirm="handleConfirm"
@cancel="handleCancel"
/>
</template>
```
### 2. `record-confirmation.vue` - Record-specific Confirmation
Komponen khusus untuk konfirmasi operasi pada record/data tertentu.
**Props:**
- `open?: boolean` - Status modal
- `action?: 'delete' | 'deactivate' | 'activate' | 'archive' | 'restore'` - Jenis aksi (default: "delete")
- `record?: RecordData | null` - Data record yang akan diproses
- `customTitle?: string` - Custom judul (opsional)
- `customMessage?: string` - Custom pesan (opsional)
- `customConfirmText?: string` - Custom text tombol konfirmasi (opsional)
- `customCancelText?: string` - Custom text tombol batal (opsional)
**Events:**
- `@confirm` - Dipanggil dengan parameter `(record, action)`
- `@cancel` - Dipanggil saat batal
- `@update:open` - Update status modal
**Contoh penggunaan:**
```vue
<template>
<RecordConfirmation
v-model:open="isRecordConfirmOpen"
action="delete"
:record="selectedRecord"
@confirm="handleDeleteRecord"
@cancel="handleCancel"
>
<template #default="{ record }">
<div class="text-sm">
<p><strong>ID:</strong> {{ record?.id }}</p>
<p><strong>Nama:</strong> {{ record?.name }}</p>
</div>
</template>
</RecordConfirmation>
</template>
```
## Action Types
Record confirmation mendukung beberapa jenis aksi dengan konfigurasi default:
- **delete**: Hapus data (variant: destructive, warna merah)
- **deactivate**: Nonaktifkan data (variant: warning, warna kuning)
- **activate**: Aktifkan data (variant: default, warna biru)
- **archive**: Arsipkan data (variant: warning, warna kuning)
- **restore**: Pulihkan data (variant: default, warna biru)
## Integration Example
Contoh implementasi di komponen list:
```vue
<script setup>
// State management
const isRecordConfirmationOpen = ref(false)
const selectedRecord = ref(null)
const confirmAction = ref('delete')
// Handle action dari table
function handleRowAction(action, record) {
selectedRecord.value = record
confirmAction.value = action
isRecordConfirmationOpen.value = true
}
// Handle konfirmasi
async function handleConfirmAction(record, action) {
try {
// API call berdasarkan action
await performAction(action, record.id)
// Refresh data
await refreshData()
} catch (error) {
// Handle error
}
}
</script>
<template>
<!-- Your list component -->
<DataTable @action="handleRowAction" />
<!-- Confirmation modal -->
<RecordConfirmation
v-model:open="isRecordConfirmationOpen"
:action="confirmAction"
:record="selectedRecord"
@confirm="handleConfirmAction"
/>
</template>
```
## Styling
Komponen menggunakan Tailwind CSS dan shadcn/ui components. Pastikan dependencies berikut tersedia:
- `~/components/pub/my-ui/modal/dialog.vue`
- `~/components/pub/ui/button`
@@ -0,0 +1,99 @@
<script setup lang="ts">
import Dialog from '~/components/pub/my-ui/modal/dialog.vue'
import { Button } from '~/components/pub/ui/button'
interface ConfirmationProps {
open?: boolean
title?: string
message?: string
confirmText?: string
cancelText?: string
variant?: 'default' | 'destructive' | 'warning'
size?: 'sm' | 'md' | 'lg' | 'xl'
}
interface ConfirmationEmits {
'update:open': [value: boolean]
'confirm': []
'cancel': []
}
const props = withDefaults(defineProps<ConfirmationProps>(), {
open: false,
title: 'Konfirmasi',
message: 'Apakah Anda yakin ingin melanjutkan?',
confirmText: 'Ya',
cancelText: 'Batal',
variant: 'default',
size: 'md',
})
const emit = defineEmits<ConfirmationEmits>()
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value),
})
const variantClasses = computed(() => {
const variants = {
default: {
icon: 'i-lucide-help-circle',
iconColor: 'text-blue-500',
confirmVariant: 'default' as const,
},
destructive: {
icon: 'i-lucide-alert-triangle',
iconColor: 'text-red-500',
confirmVariant: 'destructive' as const,
},
warning: {
icon: 'i-lucide-alert-circle',
iconColor: 'text-yellow-500',
confirmVariant: 'default' as const,
},
}
return variants[props.variant]
})
function handleConfirm() {
emit('confirm')
emit('update:open', false)
}
function handleCancel() {
emit('cancel')
emit('update:open', false)
}
</script>
<template>
<Dialog v-model:open="isOpen" :title="title" :size="size">
<div class="space-y-4">
<!-- Icon dan pesan -->
<div class="flex items-start gap-3">
<div :class="[variantClasses.icon, variantClasses.iconColor]" class="w-6 h-6 mt-1 flex-shrink-0" />
<div class="flex-1">
<p class="text-sm text-muted-foreground leading-relaxed">
{{ message }}
</p>
</div>
</div>
<!-- Slot untuk konten custom -->
<div v-if="$slots.default">
<slot />
</div>
<!-- Footer buttons -->
<div class="flex justify-end gap-3 pt-4">
<Button variant="outline" @click="handleCancel">
{{ cancelText }}
</Button>
<Button :variant="variantClasses.confirmVariant" @click="handleConfirm">
{{ confirmText }}
</Button>
</div>
</div>
</Dialog>
</template>
@@ -0,0 +1,131 @@
<script setup lang="ts">
import Confirmation from '~/components/pub/my-ui/confirmation/confirmation.vue'
interface RecordData {
id: number | string
name?: string
title?: string
code?: string
[key: string]: any
}
interface RecordConfirmationProps {
open?: boolean
action?: 'delete' | 'deactivate' | 'activate' | 'archive' | 'restore'
record?: RecordData | null
customTitle?: string
customMessage?: string
customConfirmText?: string
customCancelText?: string
}
interface RecordConfirmationEmits {
'update:open': [value: boolean]
'confirm': [record: RecordData, action: string]
'cancel': []
}
const props = withDefaults(defineProps<RecordConfirmationProps>(), {
open: false,
action: 'delete',
record: null,
customTitle: '',
customMessage: '',
customConfirmText: '',
customCancelText: '',
})
const emit = defineEmits<RecordConfirmationEmits>()
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value),
})
const actionConfig = computed(() => {
const configs = {
delete: {
title: 'Hapus Data',
message: 'Apakah Anda yakin ingin menghapus data ini? Tindakan ini tidak dapat dibatalkan.',
confirmText: 'Hapus',
variant: 'destructive' as const,
},
deactivate: {
title: 'Nonaktifkan Data',
message: 'Apakah Anda yakin ingin menonaktifkan data ini?',
confirmText: 'Nonaktifkan',
variant: 'warning' as const,
},
activate: {
title: 'Aktifkan Data',
message: 'Apakah Anda yakin ingin mengaktifkan data ini?',
confirmText: 'Aktifkan',
variant: 'default' as const,
},
archive: {
title: 'Arsipkan Data',
message: 'Apakah Anda yakin ingin mengarsipkan data ini?',
confirmText: 'Arsipkan',
variant: 'warning' as const,
},
restore: {
title: 'Pulihkan Data',
message: 'Apakah Anda yakin ingin memulihkan data ini?',
confirmText: 'Pulihkan',
variant: 'default' as const,
},
}
return configs[props.action]
})
const finalTitle = computed(() => {
return props.customTitle || actionConfig.value.title
})
const finalMessage = computed(() => {
if (props.customMessage) {
return props.customMessage
}
const baseMessage = actionConfig.value.message
// if (props.record) {
// const recordName = props.record.name || props.record.title || props.record.code || `ID: ${props.record.id}`
// return `${baseMessage}\n\nData: ${recordName}`
// }
return baseMessage
})
const finalConfirmText = computed(() => {
return props.customConfirmText || actionConfig.value.confirmText
})
const finalCancelText = computed(() => {
return props.customCancelText || 'Batal'
})
function handleConfirm() {
if (props.record) {
emit('confirm', props.record, props.action)
}
emit('update:open', false)
}
function handleCancel() {
emit('cancel')
emit('update:open', false)
}
</script>
<template>
<Confirmation
v-model:open="isOpen" :title="finalTitle" :message="finalMessage" :confirm-text="finalConfirmText"
:cancel-text="finalCancelText" :variant="actionConfig.variant" size="md" @confirm="handleConfirm"
@cancel="handleCancel"
>
<!-- Slot untuk informasi tambahan record -->
<div v-if="record && $slots.default" class="mt-4 p-3 bg-muted rounded-md">
<slot :record="record" :action="action" />
</div>
</Confirmation>
</template>
@@ -0,0 +1,137 @@
<script setup lang="ts">
import type { DataTableLoader } from './type'
import type { Col, RecStrFuncComponent, RecStrFuncUnknown, Th } from '~/components/pub/my-ui/data/types'
import { Info } from 'lucide-vue-next'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '~/components/pub/ui/table'
const props = defineProps<{
skeletonSize?: number
rows: unknown[]
cols: Col[]
header: Th[][]
keys: string[]
funcParsed?: RecStrFuncUnknown
funcHtml?: RecStrFuncUnknown
funcComponent?: RecStrFuncComponent
selectMode?: 'single' | 'multiple'
modelValue?: any[] | any
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: any[] | any): void
}>()
const getSkeletonSize = computed(() => {
return props.skeletonSize || 5
})
const loader = inject('table_data_loader') as DataTableLoader
// local state utk selection
const selected = ref<any[]>([])
function toggleSelection(row: any) {
if (props.selectMode === 'single') {
selected.value = [row]
emit('update:modelValue', row)
} else {
const idx = selected.value.findIndex((r) => r === row)
if (idx >= 0) {
selected.value.splice(idx, 1)
} else {
selected.value.push(row)
}
emit('update:modelValue', [...selected.value])
}
}
function handleActionCellClick(event: Event, _cellRef: string) {
// Prevent event if clicked directly on the button/dropdown
const target = event.target as HTMLElement
if (target.closest('button') || target.closest('[role="button"]')) {
return
}
// Find the dropdown trigger button and click it
const cell = event.currentTarget as HTMLElement
const triggerButton = cell.querySelector('button[data-state]') || cell.querySelector('button')
if (triggerButton) {
;(triggerButton as HTMLButtonElement).click()
}
}
</script>
<template>
<Table>
<TableHeader class="bg-gray-50 dark:bg-gray-800">
<TableRow>
<TableHead
v-for="(h, idx) in header[0]"
:key="`head-${idx}`"
class="border"
:style="{ width: cols[idx]?.width ? `${cols[idx].width}px` : undefined }"
>
{{ h.label }}
</TableHead>
</TableRow>
</TableHeader>
<TableBody v-if="loader.isTableLoading">
<!-- Loading state with 5 skeleton rows -->
<TableRow v-for="n in getSkeletonSize" :key="`skeleton-${n}`">
<TableCell v-for="(key, cellIndex) in keys" :key="`cell-skel-${n}-${cellIndex}`" class="border">
<Skeleton class="h-6 w-full animate-pulse bg-gray-100 dark:bg-gray-700 text-muted-foreground" />
</TableCell>
</TableRow>
</TableBody>
<TableBody v-else-if="rows.length === 0">
<TableRow>
<TableCell :colspan="keys.length" class="py-8 text-center">
<div class="flex items-center justify-center">
<Info class="size-5 text-muted-foreground" />
<span class="ml-2">Tidak ada data tersedia</span>
</div>
</TableCell>
</TableRow>
</TableBody>
<TableBody v-else>
<TableRow
v-for="(row, rowIndex) in rows"
:key="`row-${rowIndex}`"
:class="{
'bg-green-50': props.selectMode === 'single' && selected.includes(row),
'bg-blue-50': props.selectMode === 'multiple' && selected.includes(row),
}"
@click="toggleSelection(row)"
>
<!-- opsional: kalau mau tampilkan checkbox/radio di cell pertama -->
<TableCell v-if="props.selectMode">
<input
v-if="props.selectMode === 'single'"
type="radio"
:checked="selected.includes(row)"
@change="toggleSelection(row)"
/>
<input v-else type="checkbox" :checked="selected.includes(row)" @change="toggleSelection(row)" />
</TableCell>
<!-- lanjut render cell normal -->
<TableCell v-for="(key, cellIndex) in keys" :key="`cell-${rowIndex}-${cellIndex}`" class="border">
<!-- existing cell renderer -->
<component
:is="funcComponent?.[key]?.(row, rowIndex).component"
v-if="funcComponent?.[key]"
:rec="row"
:idx="rowIndex"
v-bind="funcComponent[key]?.(row, rowIndex).props"
/>
<template v-else>
<div v-if="funcHtml?.[key]" v-html="funcHtml?.[key]?.(row, rowIndex)"></div>
<template v-else>
{{ funcParsed?.[key]?.(row, rowIndex) ?? (row as any)[key] }}
</template>
</template>
</TableCell>
</TableRow>
</TableBody>
</Table>
</template>
@@ -0,0 +1,4 @@
export interface DataTableLoader {
isTableLoading: boolean
[key: string]: boolean
}
@@ -0,0 +1,70 @@
<script setup lang="ts">
import type { LinkItem, ListItemDto } from './types'
import { ActionEvents } from './types'
const props = defineProps<{
rec: ListItemDto
}>()
const recId = inject<Ref<number>>('rec_id')!
const recAction = inject<Ref<string>>('rec_action')!
const recItem = inject<Ref<any>>('rec_item')!
function detail() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showDetail
recItem.value = props.rec
}
function edit() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showEdit
recItem.value = props.rec
}
const linkItems: LinkItem[] = [
{
label: 'Detail',
onClick: () => {
detail()
},
icon: 'i-lucide-eye',
},
{
label: 'Edit',
onClick: () => {
edit()
},
icon: 'i-lucide-pencil',
},
]
</script>
<template>
<div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:text-sidebar-accent-foreground data-[state=open]:bg-white"
>
<Icon name="i-lucide-chevrons-up-down" class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-40 rounded-lg bg-white" align="end">
<DropdownMenuGroup>
<DropdownMenuItem
v-for="item in linkItems"
:key="item.label"
v-slot="{ active }"
class="hover:bg-gray-100"
@click="item.onClick"
>
<Icon :name="item.icon" />
<span :class="active ? 'text-sidebar-accent-foreground' : ''">{{ item.label }}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
@@ -0,0 +1,90 @@
<script setup lang="ts">
import type { LinkItem, ListItemDto } from './types'
import { ActionEvents } from './types'
const props = defineProps<{
rec: ListItemDto
}>()
const recId = inject<Ref<number>>('rec_id')!
const recAction = inject<Ref<string>>('rec_action')!
const recItem = inject<Ref<any>>('rec_item')!
function detail() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showDetail
recItem.value = props.rec
}
function edit() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showEdit
recItem.value = props.rec
}
function del() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showConfirmDelete
recItem.value = props.rec
}
const linkItems: LinkItem[] = [
{
label: 'Detail',
onClick: () => {
detail()
},
icon: 'i-lucide-eye',
},
{
label: 'Edit',
onClick: () => {
edit()
},
icon: 'i-lucide-pencil',
},
{
label: 'Batal',
onClick: () => {
del()
},
icon: 'i-lucide-x',
},
{
label: 'Hapus',
onClick: () => {
del()
},
icon: 'i-lucide-trash',
},
]
</script>
<template>
<div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:text-sidebar-accent-foreground data-[state=open]:bg-white"
>
<Icon name="i-lucide-chevrons-up-down" class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-40 rounded-lg bg-white" align="end">
<DropdownMenuGroup>
<DropdownMenuItem
v-for="item in linkItems"
:key="item.label"
v-slot="{ active }"
class="hover:bg-gray-100"
@click="item.onClick"
>
<Icon :name="item.icon" />
<span :class="active ? 'text-sidebar-accent-foreground' : ''">{{ item.label }}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
@@ -0,0 +1,83 @@
<script setup lang="ts">
import type { LinkItem, ListItemDto } from './types'
import { ActionEvents } from './types'
const props = defineProps<{
rec: ListItemDto
}>()
const recId = inject<Ref<number>>('rec_id')!
const recAction = inject<Ref<string>>('rec_action')!
const recItem = inject<Ref<any>>('rec_item')!
function detail() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showDetail
recItem.value = props.rec
}
function edit() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showEdit
recItem.value = props.rec
}
function del() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showConfirmDelete
recItem.value = props.rec
}
const linkItems: LinkItem[] = [
{
label: 'Detail',
onClick: () => {
detail()
},
icon: 'i-lucide-eye',
},
{
label: 'Edit',
onClick: () => {
edit()
},
icon: 'i-lucide-pencil',
},
{
label: 'Hapus',
onClick: () => {
del()
},
icon: 'i-lucide-trash',
},
]
</script>
<template>
<div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:text-sidebar-accent-foreground data-[state=open]:bg-white"
>
<Icon name="i-lucide-chevrons-up-down" class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-40 rounded-lg bg-white" align="end">
<DropdownMenuGroup>
<DropdownMenuItem
v-for="item in linkItems"
:key="item.label"
v-slot="{ active }"
class="hover:bg-gray-100"
@click="item.onClick"
>
<Icon :name="item.icon" />
<span :class="active ? 'text-sidebar-accent-foreground' : ''">{{ item.label }}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import type { LinkItem, ListItemDto } from './types'
import { ActionEvents } from './types'
const props = defineProps<{
rec: ListItemDto
}>()
const recId = inject<Ref<number>>('rec_id')!
const recAction = inject<Ref<string>>('rec_action')!
const recItem = inject<Ref<any>>('rec_item')!
function detail() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showDetail
recItem.value = props.rec
}
function process() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showProcess
recItem.value = props.rec
}
function edit() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showEdit
recItem.value = props.rec
}
function del() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showConfirmDelete
recItem.value = props.rec
}
const linkItems: LinkItem[] = [
{
label: 'Proses',
onClick: () => {
process()
},
icon: 'i-lucide-file',
},
{
label: 'Detail',
onClick: () => {
detail()
},
icon: 'i-lucide-eye',
},
{
label: 'Edit',
onClick: () => {
edit()
},
icon: 'i-lucide-pencil',
},
{
label: 'Hapus',
onClick: () => {
del()
},
icon: 'i-lucide-trash',
},
]
</script>
<template>
<div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:text-sidebar-accent-foreground data-[state=open]:bg-white"
>
<Icon name="i-lucide-chevrons-up-down" class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-40 rounded-lg bg-white" align="end">
<DropdownMenuGroup>
<DropdownMenuItem
v-for="item in linkItems"
:key="item.label"
v-slot="{ active }"
class="hover:bg-gray-100"
@click="item.onClick"
>
<Icon :name="item.icon" />
<span :class="active ? 'text-sidebar-accent-foreground' : ''">{{ item.label }}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import type { LinkItem, ListItemDto } from './types'
import { ActionEvents } from './types'
interface Props {
rec: ListItemDto
size?: 'default' | 'sm' | 'lg'
}
const props = withDefaults(defineProps<Props>(), {
size: 'lg',
})
const recId = inject<Ref<number>>('rec_id')!
const recAction = inject<Ref<string>>('rec_action')!
const recItem = inject<Ref<any>>('rec_item')!
function edit() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showEdit
recItem.value = props.rec
}
function del() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showConfirmDelete
recItem.value = props.rec
}
const linkItems: LinkItem[] = [
{
label: 'Edit',
onClick: () => {
edit()
},
icon: 'i-lucide-pencil',
},
{
label: 'Hapus',
onClick: () => {
del()
},
icon: 'i-lucide-trash',
},
]
</script>
<template>
<div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
:size="size"
class="data-[state=open]:text-sidebar-accent-foreground data-[state=open]:bg-white"
>
<Icon name="i-lucide-chevrons-up-down" class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-40 rounded-lg bg-white" align="end">
<DropdownMenuGroup>
<DropdownMenuItem
v-for="item in linkItems"
:key="item.label"
v-slot="{ active }"
class="hover:bg-gray-100"
@click="item.onClick"
>
<Icon :name="item.icon" />
<span :class="active ? 'text-sidebar-accent-foreground' : ''">{{ item.label }}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
+109
View File
@@ -0,0 +1,109 @@
// import type { ComponentType } from '@unovis/ts'
import type { Component } from 'vue'
export interface ListItemDto {
id: number
name: string
code: string
}
export type ComponentType = Component
export interface RecComponent {
idx?: number
rec: object
props?: any
component: ComponentType
}
export interface Col {
span?: number
classVal?: string
style?: string
width?: number // specific for width
widthUnit?: string // specific for width
}
export interface Th {
label: string
colSpan?: number
rowSpan?: number
classVal?: string
childClassVal?: string
style?: string
childStyle?: string
hideOnSm?: boolean
}
export interface ButtonNav {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
classVal?: string
classValExt?: string
icon?: string
label: string
onClick?: () => void
}
export interface QuickSearchNav {
inputClass?: string
inputPlaceHolder?: string
btnClass?: string
btnIcon?: string
btnLabel?: string
mainField?: string
searchParams: object
onSubmit?: (searchParams: object) => void
}
export interface RefSearchNav {
modelValue?: string
placeholder?: string
minLength?: number
debounceMs?: number
inputClass?: string
showValidationFeedback?: boolean
onInput: (val: string) => void
onClick: () => void
onClear: () => void
}
// prepared header for relatively common usage
export interface HeaderPrep {
title?: string
icon?: string
refSearchNav?: RefSearchNav
quickSearchNav?: QuickSearchNav
filterNav?: ButtonNav
addNav?: ButtonNav
printNav?: ButtonNav
}
export interface KeyLabel {
key: string
label: string
}
export type FuncRecUnknown = (rec: unknown, idx: number) => unknown
export type FuncComponent = (rec: unknown, idx: number) => RecComponent
export type RecStrFuncUnknown = Record<string, FuncRecUnknown>
export type RecStrFuncComponent = Record<string, FuncComponent>
export interface KeyNames {
key: string
label: string
}
export interface LinkItem {
label: string
icon?: string
href?: string // to cover the needs of stating full external origins full url
action?: string // for local paths
onClick?: (event: Event) => void
headerStatus?: boolean
}
export const ActionEvents = {
showConfirmDelete: 'showConfirmDel',
showEdit: 'showEdit',
showDetail: 'showDetail',
showProcess: 'showProcess',
}
@@ -0,0 +1,79 @@
<script setup lang="ts">
// ---------- Imports ----------
import { computed, useSlots } from 'vue'
// Types
const props = defineProps({
mode: { type: String, default: 'entry' },
gridPoint: { type: String, default: 'lg' },
cellFlex: { type: Boolean, default: true },
cellFlexPoint: { type: String, default: 'md' },
labelSize: { type: String, default: 'medium' },
labelSizePoint: { type: String, default: 'md' },
colCount: { type: Number, default: 1 },
defaultClass: { type: String, default: 'mb-5' },
class: { type: String, default: '' },
})
const slots = useSlots()
// Utility functions (minimal, can be expanded)
const breakpoints = ['grid', 'sm:grid', 'md:grid', 'lg:grid', 'xl:grid', '2xl:grid']
const getBreakpointIdx = (point: string) => {
return Math.max(0, breakpoints.findIndex(bp => bp.startsWith(point)))
}
const labelSizes = ['small', 'medium', 'large', 'xl', '2xl']
const getLabelSizeIdx = (size: string) => {
return Math.max(0, labelSizes.findIndex(s => s === size))
}
const settingClass = computed(() => {
const breakPointIdx = getBreakpointIdx(props.gridPoint)
let cls = breakpoints[breakPointIdx]
cls += ' gap-x-4 xl:gap-x-5 gap-y-2 xl:gap-y-3 ' + [
'grid-cols-1', 'grid-cols-2', 'grid-cols-3', 'grid-cols-4', 'grid-cols-5',
'grid-cols-6', 'grid-cols-7', 'grid-cols-8', 'grid-cols-9', 'grid-cols-10',
][props.colCount - 1]
cls += breakPointIdx === 0 ? ' gap-x-3 ' : ''
cls += ' ' + [
' [&_.cell]:!mb-0',
' [&_.cell]:mb-2.5 [&_.cell]:sm:mb-0',
' [&_.cell]:mb-2.5 [&_.cell]:md:mb-0',
' [&_.cell]:mb-2.5 [&_.cell]:lg:mb-0',
' [&_.cell]:mb-3 [&_.cell]:xl:mb-0',
' [&_.cell]:mb-3 [&_.cell]:2xl:mb-0',
][breakPointIdx]
if (props.cellFlex) {
cls += ' ' + [
'[&_.cell]:flex',
'[&_.cell]:sm:flex',
'[&_.cell]:md:flex',
'[&_.cell]:lg:flex',
'[&_.cell]:xl:flex',
'[&_.cell]:2xl:flex',
][getBreakpointIdx(props.cellFlexPoint)]
cls += ' [&_.label]:flex-shrink-0 ' + [
'[&_.label]:md:w-12 [&_.label]:xl:w-20',
'[&_.label]:md:w-16 [&_.label]:xl:w-24',
'[&_.label]:md:w-24 [&_.label]:xl:w-32',
'[&_.label]:md:w-32 [&_.label]:xl:w-40',
'[&_.label]:md:w-44 [&_.label]:xl:w-52',
][getLabelSizeIdx(props.labelSize)]
} else {
cls += ' [&_.label]:pb-1 ';
}
cls += ' [&:not(.preview)_.height-default]:pt-2 [&:not(.preview)_.height-default]:2xl:!pt-1.5 [&:not(.preview)_.height-compact]:!pt-1 '
cls += '[&_textarea]:text-xs [&_textarea]:xl:text-sm '
cls += '[&_label]:text-xs [&_label]:xl:text-sm'
return cls
})
</script>
<template>
<div :class="`block ${mode} ${props.defaultClass} ${settingClass} ${props.class}`">
<slot />
</div>
</template>
<style scoped>
</style>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps({
colSpan: { type: Number, default: undefined },
colStart: { type: Number, default: undefined },
colEnd: { type: Number, default: undefined },
class: { type: String, default: '' },
})
const settingClass = computed(() => {
let cls = ' cell'
if (props.colSpan) {
cls += ' ' + [
'col-span-1', 'col-span-2', 'col-span-3', 'col-span-4', 'col-span-5',
'col-span-6', 'col-span-7', 'col-span-8', 'col-span-9', 'col-span-10',
][props.colSpan - 1]
}
if (props.colStart) {
cls += ' ' + [
'col-start-1', 'col-start-2', 'col-start-3', 'col-start-4', 'col-start-5',
'col-start-6', 'col-start-7', 'col-start-8', 'col-start-9', 'col-start-10',
][props.colStart - 1]
}
if (props.colEnd) {
cls += ' ' + [
'col-end-1', 'col-end-2', 'col-end-3', 'col-end-4', 'col-end-5',
'col-end-6', 'col-end-7', 'col-end-8', 'col-end-9', 'col-end-10',
][props.colEnd - 1]
}
if (props.class) {
cls += ' ' + props.class.trim()
}
return cls
})
</script>
<template>
<div :class="`w-full${settingClass}`">
<slot />
</div>
</template>
@@ -0,0 +1,7 @@
<script lang="ts" setup>
</script>
<template>
<div class="w-5 text-center">:</div>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
const props = defineProps({
errMessage: { type: String, default: '' },
defaultClass: { type: String, default: 'grow shrink-0 overflow-hidden' },
class: { type: String, default: '' },
})
</script>
<template>
<div :class="`field ${props.defaultClass} ${props.class}`">
<slot />
<div v-if="props.errMessage" class="mt-1 text-xs font-medium text-red-500">{{ props.errMessage }}</div>
</div>
</template>
@@ -0,0 +1,4 @@
export { default as Block } from './block.vue'
export { default as Cell } from './cell.vue'
export { default as Label } from './label.vue'
export { default as Field } from './field.vue'
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps({
height: { type: String, default: 'default' }, // 'default' | 'compact'
position: { type: String, default: 'default' }, // 'default' | 'dynamic'
positionPoint: { type: String, default: 'lg' },
class: { type: String, default: '' },
})
const breakpoints = ['','sm','md','lg','xl','2xl']
const getBreakpointIdx = (point: string) => {
return Math.max(0, breakpoints.findIndex(bp => bp === point))
}
const settingClass = computed(() => {
let cls = 'label'
cls += props.height === 'compact' ? ' height-compact ' : ' height-default '
if (props.position === 'dynamic') {
cls += ' ' + [
'text-end pe-2.5',
'sm:text-end pe-2.5',
'md:text-end pe-2.5',
'lg:text-end pe-2.5',
'xl:text-end pe-2.5',
'2xl:text-end pe-2.5',
][getBreakpointIdx(props.positionPoint)]
}
return cls + ' ' + (props.class?.trim() || '')
})
</script>
<template>
<div :class="settingClass">
<label>
<slot />
</label>
</div>
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
defineProps<{
statusCode: number
}>()
const router = useRouter()
</script>
<template>
<div class="h-svh">
<div class="m-auto flex h-full w-full flex-col items-center justify-center gap-2">
<template v-if="statusCode === 403">
<h1 class="text-[7rem] font-bold leading-tight">403</h1>
<span class="font-medium">Access Forbidden</span>
<p class="text-muted-foreground text-center">
You don't have necessary permission <br />
to access this resource.
</p>
</template>
<template v-else-if="statusCode === 404">
<h1 class="text-[7rem] font-bold leading-tight">404</h1>
<span class="font-medium">Page Not Found</span>
<p class="text-muted-foreground text-center">
The page you are looking for <br />
doesn't exist.
</p>
</template>
<template v-else-if="statusCode === 401">
<h1 class="text-[7rem] font-bold leading-tight">401</h1>
<span class="font-medium">Unauthorized Access</span>
<p class="text-muted-foreground text-center">
Please log in with the appropriate credentials <br />
to access this resource.
</p>
</template>
<template v-else>
<h1 class="text-[7rem] font-bold leading-tight">500</h1>
<span class="font-medium">Internal Server Error</span>
<p class="text-muted-foreground text-center">
Something went wrong on our end. <br />
Please try again later.
</p>
</template>
<div class="mt-6 flex gap-4">
<Button variant="outline" @click="router.back()"> Kembali </Button>
<Button v-if="statusCode === 401" @click="router.push('/auth/login')">Login</Button>
<Button v-else @click="router.push('/')">Kembali ke Dashboard</Button>
</div>
</div>
</div>
</template>
+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
defineProps<{
classValExt?: string
}>()
</script>
<template>
<div :class="` mb-5 flex-wrap md:flex ${classValExt || ''}`">
<slot />
</div>
</template>
+149
View File
@@ -0,0 +1,149 @@
<script setup lang="ts">
import { cn } from '~/lib/utils'
interface Item {
value: string
label: string
code?: string
priority?: number
}
const props = defineProps<{
id: string
modelValue?: string
items: Item[]
placeholder?: string
searchPlaceholder?: string
emptyMessage?: string
class?: string
isDisabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const open = ref(false)
const selectedItem = computed(() => props.items.find((item) => item.value === props.modelValue))
const displayText = computed(() => {
if (selectedItem.value?.label) {
return selectedItem.value.label
}
return props.placeholder || 'Pilih item'
})
const searchableItems = computed(() => {
const itemsWithSearch = props.items.map((item) => ({
...item,
searchValue: `${item.code || ''} ${item.label}`.trim(),
isSelected: item.value === props.modelValue,
}))
return itemsWithSearch.sort((a, b) => {
const aPriority = a.priority ?? 0
const bPriority = b.priority ?? 0
if (aPriority !== bPriority) {
return bPriority - aPriority
}
if (a.isSelected && !b.isSelected) return -1
if (!a.isSelected && b.isSelected) return 1
return a.label.localeCompare(b.label)
})
})
function onSelect(item: Item) {
emit('update:modelValue', item.value)
open.value = false
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
:id="props.id"
:disabled="props.isDisabled"
variant="outline"
role="combobox"
:aria-expanded="open"
:aria-controls="`${props.id}-list`"
:aria-describedby="`${props.id}-search`"
:class="
cn(
'w-full justify-between border text-sm font-normal rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white',
{
'cursor-not-allowed bg-gray-100 opacity-50 border-gray-300 text-gray-500': props.isDisabled,
'bg-white text-black dark:bg-gray-800 dark:text-white dark:border-gray-600 border-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700': !props.isDisabled,
'text-gray-400 dark:text-gray-500': !modelValue && !props.isDisabled,
},
props.class,
)
"
>
{{ displayText }}
<Icon
name="i-lucide-chevrons-up-down"
:class="cn('ml-2 h-4 w-4 shrink-0', {
'opacity-30': props.isDisabled,
'opacity-50 text-gray-500 dark:text-gray-300': !props.isDisabled
})"
/>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[var(--radix-popover-trigger-width)] p-0 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<Command class="bg-white dark:bg-gray-800">
<CommandInput
:id="`${props.id}-search`"
class="h-9 bg-white dark:bg-gray-800 text-black dark:text-white border-0 border-b border-gray-200 dark:border-gray-700 focus:ring-0"
:placeholder="searchPlaceholder || 'Cari...'"
:aria-label="`Cari ${displayText}`"
/>
<CommandEmpty class="text-gray-500 dark:text-gray-400 py-6 text-center text-sm">
{{ emptyMessage || 'Item tidak ditemukan.' }}
</CommandEmpty>
<CommandList
:id="`${props.id}-list`"
role="listbox"
class="max-h-60 overflow-auto"
>
<CommandGroup>
<CommandItem
v-for="item in searchableItems"
:key="item.value"
:value="item.searchValue"
:class="
cn(
'flex w-full cursor-pointer items-center justify-between rounded-sm px-2 py-1.5 text-sm',
'focus:outline-none text-black dark:text-white',
'hover:bg-primary hover:text-white focus:bg-primary focus:text-white',
'data-[highlighted]:bg-primary data-[highlighted]:text-white',
)
"
@select="onSelect(item)"
>
<div class="flex w-full items-center justify-between">
<span>{{ item.label }}</span>
<div class="flex items-center gap-2">
<span
v-if="item.code"
class="text-xs text-muted-foreground"
>
{{ item.code }}
</span>
<Icon
name="i-lucide-check"
:class="cn('h-4 w-4', modelValue === item.value ? 'opacity-100' : 'opacity-0')"
/>
</div>
</div>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1,57 @@
<script setup lang="ts">
// helpers
import { format, parseISO } from 'date-fns'
import { id as localeID } from 'date-fns/locale'
// components
import { Button } from '~/components/pub/ui/button'
import Calendar from '~/components/pub/ui/calendar/Calendar.vue'
import { Popover, PopoverContent, PopoverTrigger } from '~/components/pub/ui/popover'
const props = defineProps<{
placeholder?: string
modelValue?: Date | string | undefined
}>()
const emit = defineEmits<{
'update:modelValue': [value: Date | string | undefined]
}>()
const date = ref<Date | any>(undefined)
watch(date, (value) => {
const newValue = format(value, 'yyyy-MM-dd', { locale: localeID })
emit('update:modelValue', newValue)
})
onMounted(() => {
if (props.modelValue) {
const value = props.modelValue
if (value instanceof Date) {
date.value = value
} else if (typeof value === 'string' && value) {
date.value = parseISO(value)
} else {
date.value = undefined
}
}
})
</script>
<template>
<div class="flex flex-col space-y-2">
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" class="bg-white border-gray-400 font-normal text-right h-[40px] w-full">
<div class="flex justify-between items-center w-full">
<p v-if="date">{{ format(date, 'PPP', { locale: localeID }) }}</p>
<p v-else class="text-sm text-black text-opacity-50">{{ props.placeholder || 'Tanggal' }}</p>
<Icon name="i-lucide-calendar" class="h-5 w-5" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<Calendar v-model="date" mode="single" locale="id" />
</PopoverContent>
</Popover>
</div>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
column?: 1 | 2 | 3
density?: 'default' | 'dense'
side?: 'default' | 'break'
position?: 'default' | 'dynamic'
layout?: 'default' | 'stacked'
class?: string
}>(),
{
column: 1,
density: 'default',
side: 'default',
position: 'default',
layout: 'stacked',
class: '',
},
)
const widthClass = computed(() => {
if (props.column === 1) return 'md:w-full pe-4'
if (props.column === 2) return 'md:w-1/2 pe-4'
if (props.column === 3) return 'md:w-1/3 pe-4'
return 'w-full'
})
const wrapperClass = computed(() => [
'w-full flex-shrink-0',
widthClass.value,
props.layout === 'stacked' ? 'flex flex-col' : 'md:flex',
// Only add margin bottom if no custom class overrides it
props.class?.includes('mb-') ? '' : (props.density !== 'dense' ? 'mb-2 md:mb-2.5 xl:mb-3' : 'mb-3'),
props.class,
])
</script>
<template>
<div :class="wrapperClass">
<slot />
</div>
</template>
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { XErrors } from '~/types/error'
defineProps<{
id?: string
errors?: XErrors
}>()
</script>
<template>
<div class="grow">
<slot />
<!-- Always reserve space for error message to prevent CLS -->
<div class="field-error-info">
{{ (id && errors?.[id]) ? errors[id]?.message : '' }}
</div>
</div>
</template>
@@ -0,0 +1,82 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import FieldGroup from '~/components/pub/my-ui/form/field-group.vue'
import Field from '~/components/pub/my-ui/form/field.vue'
import Label from '~/components/pub/my-ui/form/label.vue'
import { Input } from '~/components/pub/ui/input'
import { cn } from '~/lib/utils'
const props = defineProps<{
fieldName: string
placeholder: string
label: string
errors?: FormErrors
class?: string
numericOnly?: boolean
maxLength?: number
isRequired?: boolean
isDisabled?: boolean
}>()
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
let value = target.value
// Filter numeric only jika diperlukan
if (props.numericOnly) {
value = value.replace(/\D/g, '')
}
// Batasi panjang maksimal jika diperlukan
if (props.maxLength && value.length > props.maxLength) {
value = value.slice(0, props.maxLength)
}
// Update value jika ada perubahan
if (target.value !== value) {
target.value = value
// Trigger input event untuk update form
target.dispatchEvent(new Event('input', { bubbles: true }))
}
}
</script>
<template>
<FieldGroup>
<Label
v-if="label !== ''"
:label-for="fieldName"
:is-required="isRequired && !isDisabled"
>
{{ label }}
</Label>
<Field
:id="fieldName"
:errors="errors"
>
<FormField
v-slot="{ componentField }"
:name="fieldName"
>
<FormItem>
<FormControl>
<Input
:disabled="isDisabled"
v-bind="componentField"
:placeholder="placeholder"
:maxlength="maxLength"
:class="cn('focus:border-primary focus:ring-2 focus:ring-primary focus:ring-offset-0')"
autocomplete="off"
aria-autocomplete="none"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
@input="handleInput"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</template>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
labelFor?: string
size?: 'default' | 'narrow' | 'wide' | 'fit'
height?: 'default' | 'compact'
position?: 'default' | 'dynamic'
stacked?: boolean
isRequired?: boolean
}>(),
{
size: 'default',
height: 'default',
position: 'default',
stacked: true,
isRequired: false,
},
)
const sizeMap = {
default: 'w-28 2xl:w-36',
narrow: 'w-24 2xl:w-28',
wide: 'w-44 2xl:w-48',
fit: 'w-fit',
} as const
const heightMap = {
default: 'pt-2 2xl:pt-2.5',
compact: 'leading-[14pt]',
} as const
const positionWrapMap = {
default: 'pe-2 text-start',
dynamic: 'md:text-end',
} as const
const positionChildMap = {
default: '',
dynamic: 'block pe-2.5',
} as const
const wrapperClass = computed(() => [
'block shrink-0',
props.stacked ? 'w-full mb-1 text-start' : sizeMap[props.size],
props.stacked ? '' : heightMap[props.height],
props.stacked ? '' : positionWrapMap[props.position],
])
const labelClass = computed(() => [props.stacked ? 'block mb-1 text-sm font-normal' : positionChildMap[props.position]])
</script>
<template>
<div :class="wrapperClass">
<label
class="block"
:class="labelClass"
:for="labelFor"
>
<slot />
<span
v-if="isRequired"
class="text-red-600"
>
*
</span>
</label>
</div>
</template>
+171
View File
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { SelectRoot } from 'radix-vue'
import { watch } from 'vue'
import SelectContent from '~/components/pub/ui/select/SelectContent.vue'
import SelectGroup from '~/components/pub/ui/select/SelectGroup.vue'
import SelectItem from '~/components/pub/ui/select/SelectItem.vue'
import SelectLabel from '~/components/pub/ui/select/SelectLabel.vue'
import SelectSeparator from '~/components/pub/ui/select/SelectSeparator.vue'
import SelectTrigger from '~/components/pub/ui/select/SelectTrigger.vue'
import SelectValue from '~/components/pub/ui/select/SelectValue.vue'
import { cn } from '~/lib/utils'
interface Item {
value: string
label: string
code?: string
priority?: number // Priority untuk sorting: negatif = bawah, positif = atas, 0/undefined = normal sorting
}
const props = defineProps<{
modelValue?: string
items: Item[]
placeholder?: string
label?: string
separator?: boolean
class?: string
isSelectedFirst?: boolean
preserveOrder?: boolean
isDisabled?: boolean
autoWidth?: boolean
autoFill?: boolean
// otherPlacement sudah tidak digunakan, diganti dengan priority system di Item interface
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const processedItems = computed(() => {
const itemsWithSelection = props.items.map((item) => ({
...item,
isSelected: item.value === props.modelValue,
}))
// Jika preserveOrder true, kembalikan urutan array asli
if (props.preserveOrder) {
return itemsWithSelection
}
// Sorting dengan priority system
return itemsWithSelection.sort((a, b) => {
const aPriority = a.priority ?? 0
const bPriority = b.priority ?? 0
// Jika ada priority, sort berdasarkan priority (descending: tinggi ke rendah)
if (aPriority !== bPriority) {
return bPriority - aPriority
}
// Jika priority sama (termasuk 0/undefined), lakukan sorting normal
if (props.isSelectedFirst) {
if (a.isSelected && !b.isSelected) return -1
if (!a.isSelected && b.isSelected) return 1
}
return a.label.localeCompare(b.label)
})
})
// Computed property untuk menghitung width optimal berdasarkan konten terpanjang
const optimalWidth = computed(() => {
// Jika autoWidth false atau undefined, gunakan full width
if (!props.autoWidth) return '100%'
if (!props.items.length) return 'auto'
// Mencari label terpanjang
const longestLabel = props.items.reduce((longest, item) => {
const itemText = item.code ? `${item.label} ${item.code}` : item.label
return itemText.length > longest.length ? itemText : longest
}, '')
// Menghitung width berdasarkan panjang karakter (estimasi)
// Setiap karakter ~0.6em, ditambah padding dan space untuk icon
const estimatedWidth = Math.max(longestLabel.length * 0.6 + 3, 8) // minimum 8em
return `${Math.min(estimatedWidth, 25)}em` // maksimum 25em
})
function onValueChange(value: string) {
emit('update:modelValue', value)
}
// Auto fill logic - automatically select first item if autoFill is enabled
watch(
() => props.items,
(newItems) => {
if (props.autoFill && newItems.length > 0 && !props.modelValue) {
// Auto select first item only if no value is currently selected
const firstItem = newItems[0]
if (firstItem?.value) {
emit('update:modelValue', firstItem.value)
}
}
},
{ immediate: true },
)
</script>
<template>
<SelectRoot
:model-value="modelValue"
:disabled="isDisabled"
@update:model-value="onValueChange"
>
<SelectTrigger
:class="
cn(
'rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white',
{
'cursor-not-allowed bg-gray-100 opacity-50': isDisabled,
'bg-white text-black dark:bg-gray-800 dark:text-white': !isDisabled,
'w-full': !autoWidth,
},
props.class,
)
"
:style="autoWidth ? { width: optimalWidth, minWidth: '8em' } : {}"
icon-name="i-radix-icons-chevron-down"
icon-class="text-gray-500 dark:text-gray-300"
>
<SelectValue
:placeholder="placeholder || 'Pilih item'"
:class="
cn('text-sm', {
'text-gray-400': !props.modelValue,
'text-black dark:text-white': props.modelValue,
'text-gray-500': isDisabled,
})
"
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel v-if="label">
{{ label }}
</SelectLabel>
<SelectItem
v-for="item in processedItems"
:key="item.value"
:value="item.value"
class="cursor-pointer hover:bg-primary hover:text-white focus:bg-primary focus:text-white"
>
<div class="flex w-full items-center justify-between">
<span>{{ item.label }}</span>
<span
v-if="item.code"
class="ml-2 text-xs text-muted-foreground"
>
{{ item.code }}
</span>
</div>
</SelectItem>
<SelectSeparator v-if="separator" />
</SelectGroup>
</SelectContent>
</SelectRoot>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
defineProps<{
label: string
}>()
</script>
<template>
<div class="flex flex-col gap-1 lg:grid lg:grid-cols-[180px_minmax(0,1fr)] lg:gap-x-3">
<!-- Label -->
<span class="text-md font-normal text-muted-foreground">
{{ label }}
</span>
<!-- Value -->
<span class="truncate lg:whitespace-normal">
<span class="me-3 hidden lg:inline-block">:</span>
<slot />
</span>
</div>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { cn } from '~/lib/utils'
defineProps<{
title: string
headerClass?: string
contentClass?: string
}>()
</script>
<template>
<section class="mb-6">
<h3 :class="cn('mb-3 flex items-center gap-1 pb-1 text-base font-semibold', headerClass)">
<slot name="icon" />
{{ title }}
</h3>
<div :class="cn('space-y-2', contentClass)">
<slot />
</div>
</section>
</template>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { Dialog } from '~/components/pub/ui/dialog'
interface DialogProps {
title: string
description?: string
preventOutside?: boolean
open?: boolean
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
}
const props = withDefaults(defineProps<DialogProps>(), {
preventOutside: false,
open: false,
size: 'md',
})
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
// Computed untuk menentukan class size berdasarkan prop size
const sizeClass = computed(() => {
const sizeMap = {
sm: 'sm:max-w-[350px]',
md: 'sm:max-w-[425px]',
lg: 'sm:max-w-[600px]',
xl: 'sm:max-w-[800px]',
full: 'sm:max-w-[95vw]',
}
return sizeMap[props.size]
})
// Computed untuk state dialog
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value),
})
</script>
<template>
<Dialog v-model:open="isOpen">
<DialogContent
:class="sizeClass"
@interact-outside="(e: any) => preventOutside && e.preventDefault()"
@pointer-down-outside="(e: any) => preventOutside && e.preventDefault()"
>
<DialogHeader>
<DialogTitle class="text-[20px]">{{ props.title }}</DialogTitle>
<DialogDescription v-if="props.description">{{ props.description }}</DialogDescription>
</DialogHeader>
<slot />
</DialogContent>
</Dialog>
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { Dialog } from '~/components/pub/ui/dialog'
interface DialogProps {
title: string
description?: string
preventOutside?: boolean
open?: boolean
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
}
const props = withDefaults(defineProps<DialogProps>(), {
preventOutside: false,
open: false,
size: 'md',
})
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
const sizeClass = computed(() => {
const sizeMap = {
sm: 'sm:max-w-[350px]',
md: 'sm:max-w-[425px]',
lg: 'sm:max-w-[600px]',
xl: 'sm:max-w-[800px]',
full: 'sm:max-w-[95vw]',
}
return sizeMap[props.size]
})
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value),
})
</script>
<template>
<Dialog v-model:open="isOpen">
<DialogContent
:class="sizeClass"
@interact-outside="(e: any) => preventOutside && e.preventDefault()"
@pointer-down-outside="(e: any) => preventOutside && e.preventDefault()"
>
<DialogHeader>
<DialogTitle>{{ props.title }}</DialogTitle>
<DialogDescription v-if="props.description">{{ props.description }}</DialogDescription>
</DialogHeader>
<slot />
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
type ClickType = 'cancel' | 'draft' | 'submit'
const props = defineProps<{
label: string
}>()
const emit = defineEmits<{
(e: 'click', type: ClickType): void
}>()
function onClick(type: ClickType) {
emit('click', type)
}
</script>
<template>
<Button
@click="onClick('draft')"
class="flex items-center gap-2 rounded-full border border-orange-400 bg-orange-50 px-3 py-1 text-sm font-medium text-orange-600 hover:bg-orange-100"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{{ props.label }}
</Button>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
type ClickType = 'cancel' | 'draft' | 'submit' | 'print'
const emit = defineEmits<{
(e: 'click', type: ClickType): void
}>()
function onClick(type: ClickType) {
emit('click', type)
}
</script>
<template>
<div class="m-2 flex gap-2 px-2">
<Button class="bg-gray-400" type="button" @click="onClick('cancel')">
<Icon name="i-lucide-arrow-left" class="me-2 align-middle" />
Back
</Button>
<Button class="bg-orange-500" variant="outline" type="button" @click="onClick('draft')">
<Icon name="i-lucide-file" class="me-2" />
Draf
</Button>
<Button class="bg-primary" type="button" @click="onClick('submit')">
<Icon name="i-lucide-check" class="me-2 align-middle" />
Submit
</Button>
<Button class="bg-primary" type="button" @click="onClick('print')">
<Icon name="i-lucide-printer" class="me-2 align-middle" />
Print
</Button>
</div>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
type ClickType = 'cancel' | 'draft' | 'submit'
const emit = defineEmits<{
(e: 'click', type: ClickType): void
}>()
function onClick(type: ClickType) {
emit('click', type)
}
</script>
<template>
<div class="m-2 flex gap-2 px-2">
<Button class="bg-gray-400" type="button" @click="onClick('cancel')">
<Icon name="i-lucide-arrow-left" class="me-2 align-middle" />
Back
</Button>
<Button class="bg-orange-500" variant="outline" type="button" @click="onClick('draft')">
<Icon name="i-lucide-file" class="me-2" />
Draft
</Button>
<Button class="bg-primary" type="button" @click="onClick('submit')">
<Icon name="i-lucide-check" class="me-2 align-middle" />
Submit
</Button>
</div>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
type ClickType = 'cancel' | 'draft' | 'delete' | 'print'
const emit = defineEmits<{
(e: 'click', type: ClickType): void
}>()
function onClick(type: ClickType) {
emit('click', type)
}
</script>
<template>
<div class="m-2 flex gap-2 px-2">
<Button class="bg-gray-400" type="button" @click="onClick('cancel')">
<Icon name="i-lucide-arrow-left" class="me-2 align-middle" />
Back
</Button>
<Button class="bg-orange-500" variant="outline" type="button" @click="onClick('draft')">
<Icon name="i-lucide-file" class="me-2" />
Edit
</Button>
<Button class="bg-red-500" type="button" @click="onClick('delete')">
<Icon name="i-lucide-trash" class="me-2 align-middle" />
Delete
</Button>
<Button class="bg-primary" type="button" @click="onClick('print')">
<Icon name="i-lucide-printer" class="me-2 align-middle" />
Print
</Button>
</div>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
type ClickType = 'cancel' | 'draft' | 'delete'
const emit = defineEmits<{
(e: 'click', type: ClickType): void
}>()
function onClick(type: ClickType) {
emit('click', type)
}
</script>
<template>
<div class="m-2 flex gap-2 px-2">
<Button class="bg-gray-400" type="button" @click="onClick('cancel')">
<Icon name="i-lucide-arrow-left" class="me-2 align-middle" />
Back
</Button>
<Button class="bg-orange-500" variant="outline" type="button" @click="onClick('draft')">
<Icon name="i-lucide-file" class="me-2" />
Edit
</Button>
<Button class="bg-red-500" type="button" @click="onClick('delete')">
<Icon name="i-lucide-trash" class="me-2 align-middle" />
Delete
</Button>
</div>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
type ClickType = 'cancel' | 'draft' | 'print'
const emit = defineEmits<{
(e: 'click', type: ClickType): void
}>()
function onClick(type: ClickType) {
emit('click', type)
}
</script>
<template>
<div class="m-2 flex gap-2 px-2">
<Button class="bg-gray-400" type="button" @click="onClick('cancel')">
<Icon name="i-lucide-arrow-left" class="me-2 align-middle" />
Back
</Button>
<Button class="bg-orange-500" variant="outline" type="button" @click="onClick('draft')">
<Icon name="i-lucide-file" class="me-2" />
Edit
</Button>
<Button class="bg-primary" type="button" @click="onClick('print')">
<Icon name="i-lucide-printer" class="me-2 align-middle" />
Print
</Button>
</div>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
type ClickType = 'cancel' | 'edit'
const emit = defineEmits<{
(e: 'click', type: ClickType): void
}>()
function onClick(type: ClickType) {
emit('click', type)
}
</script>
<template>
<div class="m-2 flex gap-2 px-2">
<Button
class="bg-gray-400"
type="button"
@click="onClick('cancel')"
>
<Icon
name="i-lucide-arrow-left"
class="me-2 align-middle"
/>
Back
</Button>
<Button
class="bg-orange-500"
variant="outline"
type="button"
@click="onClick('edit')"
>
<Icon
name="i-lucide-file"
class="me-2 align-middle"
/>
Edit
</Button>
</div>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
type ClickType = 'cancel' | 'submit'
const emit = defineEmits<{
(e: 'click', type: ClickType): void
}>()
function onClick(type: ClickType) {
emit('click', type)
}
</script>
<template>
<div class="m-2 flex gap-2 px-2">
<Button
class="bg-gray-400"
@click="onClick('cancel')"
>
<Icon
name="i-lucide-arrow-left"
class="me-2 align-middle"
/>
Back
</Button>
<Button
class="bg-primary"
@click="onClick('submit')"
>
<Icon
name="i-lucide-check"
class="me-2 align-middle"
/>
Submit
</Button>
</div>
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { Calendar as CalendarIcon, Filter as FilterIcon, Search } from 'lucide-vue-next'
import { ref } from 'vue'
import type { Ref } from 'vue'
import type { DateRange } from 'radix-vue'
import { CalendarDate, DateFormatter, getLocalTimeZone } from '@internationalized/date'
import { cn } from '~/lib/utils'
import type { HeaderPrep, RefSearchNav } from '~/components/pub/my-ui/data/types'
const props = defineProps<{
prep: HeaderPrep
refSearchNav?: RefSearchNav
}>()
// function emitSearchNavClick() {
// props.refSearchNav?.onClick()
// }
//
// function onInput(event: Event) {
// props.refSearchNav?.onInput((event.target as HTMLInputElement).value)
// }
//
// function btnClick() {
// props.prep?.addNav?.onClick?.()
// }
const searchQuery = ref('')
const dateRange = ref<{ from: Date | null; to: Date | null }>({
from: new Date(),
to: new Date(),
})
const df = new DateFormatter('en-US', {
dateStyle: 'medium',
})
const value = ref({
start: new CalendarDate(2022, 1, 20),
end: new CalendarDate(2022, 1, 20).add({ days: 20 }),
}) as Ref<DateRange>
function onFilterClick() {
console.log('Search:', searchQuery.value)
console.log('Date Range:', dateRange.value)
props.refSearchNav?.onClick()
}
</script>
<template>
<header>
<div class="flex items-center space-x-2">
<div class="relative w-64">
<Search class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-gray-400" />
<Input v-model="searchQuery" type="text" placeholder="Cari Nama /No.RM" class="pl-9" />
</div>
<Popover>
<PopoverTrigger as-child>
<Button
variant="outline"
:class="cn('w-[280px] justify-start text-left font-normal', !value && 'text-muted-foreground')"
>
<CalendarIcon class="mr-2 h-4 w-4" />
<template v-if="value.start">
<template v-if="value.end">
{{ df.format(value.start.toDate(getLocalTimeZone())) }} -
{{ df.format(value.end.toDate(getLocalTimeZone())) }}
</template>
<template v-else>
{{ df.format(value.start.toDate(getLocalTimeZone())) }}
</template>
</template>
<template v-else> Pick a date </template>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<RangeCalendar
v-model="value"
initial-focus
:number-of-months="2"
@update:start-value="(startDate) => (value.start = startDate)"
/>
</PopoverContent>
</Popover>
<Button variant="outline" class="border-orange-500 text-orange-600 hover:bg-orange-50" @click="onFilterClick">
<FilterIcon class="mr-2 size-4" />
Filter
</Button>
</div>
</header>
</template>
@@ -0,0 +1,143 @@
<script setup lang="ts">
import type { HeaderPrep } from '~/components/pub/my-ui/data/types'
import { refDebounced } from '@vueuse/core'
const props = defineProps<{
prep: HeaderPrep
modelValue?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'search': [value: string]
}>()
// Internal search state
const searchInput = ref(props.modelValue || '')
const debouncedSearch = refDebounced(searchInput, props.prep.refSearchNav?.debounceMs || 500)
// Computed search model for v-model
const searchModel = computed({
get: () => searchInput.value,
set: (value: string) => {
searchInput.value = value
emit('update:modelValue', value)
},
})
// Watch for external changes to modelValue
watch(() => props.modelValue, (newValue) => {
if (newValue !== searchInput.value) {
searchInput.value = newValue || ''
}
})
// Watch debounced search and emit search event
watch(debouncedSearch, (newValue) => {
const minLength = props.prep.refSearchNav?.minLength || 3
// Only search if meets minimum length or empty (to clear search)
if (newValue.length === 0 || newValue.length >= minLength) {
emit('search', newValue)
props.prep.refSearchNav?.onInput(newValue)
}
})
// Handle clear search
function clearSearch() {
searchModel.value = ''
props.prep.refSearchNav?.onClear()
}
</script>
<template>
<header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="ml-3 text-lg font-bold text-gray-900">
<Icon :name="props.prep.icon!" class="mr-2 size-4 md:size-6 align-middle" />
{{ props.prep.title }}
</div>
</div>
<div class="flex items-center">
<!-- Search Section -->
<div v-if="props.prep.refSearchNav" class="ml-3 text-lg text-gray-900 relative">
<div class="relative">
<Input
id="search-table"
v-model="searchModel"
name="search-table"
type="text"
class="w-full rounded-md border bg-white px-4 py-2 text-gray-900 sm:text-sm"
:class="[
props.prep.refSearchNav.inputClass,
{
'border-amber-300 bg-amber-50': searchInput.length > 0 && searchInput.length < (props.prep.refSearchNav.minLength || 3),
'border-green-300 bg-green-50': searchInput.length >= (props.prep.refSearchNav.minLength || 3),
},
]"
:placeholder="props.prep.refSearchNav.placeholder || 'Cari (min. 3 karakter)...'"
/>
<!-- Clear button -->
<button
v-if="searchInput.length > 0"
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
type="button"
@click="clearSearch"
>
<Icon name="i-lucide-x" class="h-4 w-4" />
</button>
<!-- Validation feedback -->
<div
v-if="props.prep.refSearchNav.showValidationFeedback !== false && searchInput.length > 0 && searchInput.length < (props.prep.refSearchNav.minLength || 3)"
class="absolute -bottom-6 left-0 text-xs text-amber-600 whitespace-nowrap"
>
Minimal {{ props.prep.refSearchNav.minLength || 3 }} karakter untuk mencari
</div>
</div>
</div>
<!-- Add Button -->
<div v-if="props.prep.addNav" class="m-2 flex items-center">
<Button
class="rounded-md border border-gray-300 px-4 py-2 text-white sm:text-sm"
:class="props.prep.addNav.classVal"
:variant="(props.prep.addNav.variant as any) || 'default'"
@click="props.prep.addNav?.onClick"
>
<Icon :name="props.prep.addNav.icon || 'i-lucide-plus'" class="mr-2 h-4 w-4 align-middle" />
{{ props.prep.addNav.label }}
</Button>
</div>
<!-- Filter Button -->
<div v-if="props.prep.filterNav" class="m-2 flex items-center">
<Button
class="rounded-md border border-gray-300 px-4 py-2 sm:text-sm"
:class="props.prep.filterNav.classVal"
:variant="(props.prep.filterNav.variant as any) || 'default'"
@click="props.prep.filterNav?.onClick"
>
<Icon :name="props.prep.filterNav.icon || 'i-lucide-filter'" class="mr-2 h-4 w-4 align-middle" />
{{ props.prep.filterNav.label }}
</Button>
</div>
<!-- Print Button -->
<div v-if="props.prep.printNav" class="m-2 flex items-center">
<Button
class="rounded-md border border-gray-300 px-4 py-2 sm:text-sm"
:class="props.prep.printNav.classVal"
:variant="(props.prep.printNav.variant as any) || 'default'"
@click="props.prep.printNav?.onClick"
>
<Icon :name="props.prep.printNav.icon || 'i-lucide-printer'" class="mr-2 h-4 w-4 align-middle" />
{{ props.prep.printNav.label }}
</Button>
</div>
</div>
</div>
</header>
</template>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import type { HeaderPrep, RefSearchNav } from '~/components/pub/my-ui/data/types'
import { cn } from '~/lib/utils'
const props = defineProps<{
prep: HeaderPrep
refSearchNav?: RefSearchNav
class?: string
}>()
function emitSearchNavClick() {
props.refSearchNav?.onClick()
}
function onInput(event: Event) {
props.refSearchNav?.onInput((event.target as HTMLInputElement).value)
}
function btnClick() {
props.prep?.addNav?.onClick?.()
}
</script>
<template>
<header>
<div
class="flex items-center justify-between"
:class="cn('', props.class)"
>
<div class="flex items-center">
<div class="font-semibold text-gray-900 md:text-base xl:text-lg">
<Icon
:name="props.prep.icon!"
class="mr-2 size-4 align-middle md:size-6"
/>
{{ props.prep.title }}
</div>
</div>
<div class="flex items-center">
<div
v-if="props.refSearchNav"
class="text-lg text-gray-900"
>
<Input
type="text"
placeholder="Search"
class="sm:text-sm"
@click="emitSearchNavClick"
@input="onInput"
/>
</div>
<div
v-if="prep.addNav"
class="ms-2 flex items-center"
>
<Button
class="rounded-md border border-gray-300 text-white"
@click="btnClick"
>
<Icon
name="i-lucide-plus"
class="mr-2 h-4 w-4 align-middle"
/>
{{ prep.addNav.label }}
</Button>
</div>
</div>
</div>
</header>
</template>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import type { HeaderPrep, RefSearchNav } from '~/components/pub/my-ui/data/types'
const props = defineProps<{
prep: HeaderPrep
refSearchNav: RefSearchNav
}>()
function emitSearchNavClick() {
props.refSearchNav.onClick()
}
function onInput(event: Event) {
props.refSearchNav.onInput((event.target as HTMLInputElement).value)
}
function btnClick() {
props.prep?.addNav?.onClick?.()
}
</script>
<template>
<header>
<div class="flex items-center">
<div class="ml-3 text-lg text-gray-900">
<Input
type="text"
placeholder="Search"
class="w-full rounded-md border bg-white px-4 py-2 text-gray-900 sm:text-sm"
@click="emitSearchNavClick"
@input="onInput"
/>
</div>
<div v-if="prep.addNav" class="m-2 flex items-center">
<Button size="md" class="rounded-md border border-gray-300 px-4 py-2 text-white sm:text-sm" @click="btnClick">
<Icon name="i-lucide-plus" class="mr-2 h-4 w-4 align-middle" />
{{ prep.addNav.label }}
</Button>
</div>
</div>
</header>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { PaginationMeta } from '~/components/pub/my-ui/pagination/pagination.type'
import Pagination from './pagination.vue'
const props = defineProps<{
paginationMeta: PaginationMeta
}>()
const emit = defineEmits<{
pageChange: [page: number]
}>()
function handlePageChange(page: number) {
emit('pageChange', page)
}
</script>
<template>
<Pagination
v-if="props.paginationMeta && props.paginationMeta.pageSize > 0"
:pagination-meta="paginationMeta"
@page-change="handlePageChange"
/>
</template>
@@ -0,0 +1,13 @@
export interface PaginationMeta {
recordCount: number
// page : current pointer for viewing data
page: number
// pageSize: limit each page request
pageSize: number
// totalPage: recourdCount / pageSize
totalPage: number
// hasNext: check if there is next page
hasNext: boolean
// hasPrev: check if there is previous page
hasPrev: boolean
}
@@ -0,0 +1,131 @@
<script setup lang="ts">
import type { PaginationMeta } from './pagination.type'
import {
Pagination,
PaginationEllipsis,
PaginationFirst,
PaginationLast,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev,
} from '~/components/pub/ui/pagination'
interface Props {
paginationMeta: PaginationMeta
onPageChange?: (page: number) => void
showInfo?: boolean
}
const props = withDefaults(defineProps<Props>(), {
onPageChange: undefined,
showInfo: true,
})
const emit = defineEmits<{
pageChange: [page: number]
}>()
function handlePageChange(page: number) {
if (props.onPageChange) {
props.onPageChange(page)
}
emit('pageChange', page)
}
function handleFirst() {
if (props.paginationMeta.hasPrev) {
handlePageChange(1)
}
}
function handlePrev() {
if (props.paginationMeta.hasPrev) {
handlePageChange(props.paginationMeta.page - 1)
}
}
function handleNext() {
if (props.paginationMeta.hasNext) {
handlePageChange(props.paginationMeta.page + 1)
}
}
function handleLast() {
if (props.paginationMeta.hasNext) {
handlePageChange(props.paginationMeta.totalPage)
}
}
// Computed properties for formatted numbers
const formattedRecordCount = computed(() => {
const count = props.paginationMeta.recordCount
if (count == null || count === undefined) return '0'
return Number(count).toLocaleString('id-ID')
})
const startRecord = computed(() => {
const start = ((props.paginationMeta.page - 1) * props.paginationMeta.pageSize) + 1
return Number(start).toLocaleString('id-ID')
})
const endRecord = computed(() => {
return Math.min(props.paginationMeta.page * props.paginationMeta.pageSize, props.paginationMeta.recordCount)
})
// Function to determine button width based on page number
function getButtonClass(pageNumber: number) {
const digits = pageNumber.toString().length
if (digits >= 4) { // 1000+ (1k+)
return 'h-9 px-4 min-w-12'
} else if (digits === 3) { // 100-999
return 'h-9 px-3 min-w-10'
} else { // 1-99
return 'w-9 h-9 p-0'
}
}
</script>
<template>
<div class="flex items-center justify-between px-2 py-2 w-full min-w-0">
<!-- Info text -->
<div v-if="showInfo && endRecord > 0" class="text-sm text-muted-foreground shrink-0">
Menampilkan {{ startRecord }}
hingga {{ Number(endRecord).toLocaleString('id-ID') }}
dari {{ formattedRecordCount }} data
</div>
<div v-else class="shrink-0">-</div>
<!-- Spacer untuk memastikan ada ruang di tengah -->
<div class="flex-1 min-w-4"></div>
<!-- Pagination controls -->
<div class="shrink-0">
<Pagination
v-slot="{ page }" :total="paginationMeta.recordCount" :sibling-count="1" :page="paginationMeta.page"
:items-per-page="paginationMeta.pageSize" show-edges
>
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
<PaginationFirst :disabled="!paginationMeta.hasPrev" @click="handleFirst" />
<PaginationPrev :disabled="!paginationMeta.hasPrev" @click="handlePrev" />
<template v-for="(item, index) in items">
<PaginationListItem
v-if="item.type === 'page'" :key="index" :value="item.value" as-child
@click="handlePageChange(item.value)"
>
<Button :class="getButtonClass(item.value)" :variant="item.value === page ? 'default' : 'outline'">
{{ item.value }}
</Button>
</PaginationListItem>
<PaginationEllipsis v-else :key="item.type" :index="index" />
</template>
<PaginationNext :disabled="!paginationMeta.hasNext" @click="handleNext" />
<PaginationLast :disabled="!paginationMeta.hasNext" @click="handleLast" />
</PaginationList>
</Pagination>
</div>
</div>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { ComboboxItemEmits, ComboboxItemProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import { ComboboxItem, useForwardPropsEmits } from 'radix-vue'
import { computed } from 'vue'
import { cn } from '~/lib/utils'
const props = defineProps<ComboboxItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<ComboboxItemEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ComboboxItem
v-bind="forwarded"
:class="cn('relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-normal outline-none hover:bg-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>*]:text-sm [&>*]:font-normal', props.class)"
>
<slot />
</ComboboxItem>
</template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { TreeItem } from './type'
import { Check } from 'lucide-vue-next'
import CommandItem from './command-item.vue'
defineProps<{
item: TreeItem
selectedValue?: string
shouldAlign?: boolean
}>()
const emit = defineEmits(['select'])
function handleSelect(value: string) {
emit('select', value)
}
</script>
<template>
<div class="leaf-node min-w-max">
<CommandItem
:value="item.value"
class="flex items-center justify-between p-2 w-full text-sm font-normal hover:text-primary cursor-pointer rounded-md"
:class="{ 'pl-8': shouldAlign }"
@select="() => handleSelect(item.value)"
>
<span class="text-sm font-normal text-gray-400">{{ item.label }}</span>
<Check
v-if="selectedValue === item.value"
class="w-4 h-4 text-primary ml-2 flex-shrink-0"
/>
</CommandItem>
</div>
</template>
<style scoped>
.leaf-node {
width: 100%;
}
</style>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import type { TreeItem } from './type'
import { Check, ChevronRight, Loader2 } from 'lucide-vue-next'
import TreeView from './tree-view.vue'
const props = defineProps<{
item: TreeItem
selectedValue?: string
onFetchChildren: (parentId: string) => Promise<void>
level?: number
}>()
const emit = defineEmits(['select'])
const hasChildren = computed(() => props.item.children && props.item.children.length > 0)
const isOpen = ref(false)
const isLoading = ref(false)
const isChevronRotated = ref(false)
function handleSelect(value: string) {
emit('select', value)
}
function handleLabelClick() {
handleSelect(props.item.value)
}
watch(isOpen, async (newValue) => {
console.log(`[TreeNode] ${props.item.label} - isOpen changed to:`, newValue)
isChevronRotated.value = newValue
if (newValue && props.item.hasChildren && !props.item.children && !isLoading.value) {
console.log(`[TreeNode] Fetching children for: ${props.item.label}`)
isLoading.value = true
try {
await props.onFetchChildren(props.item.value)
console.log(`[TreeNode] Fetch completed for: ${props.item.label}`, props.item.children)
// Force reactivity update dengan nextTick
await nextTick()
} catch (error) {
console.error('Gagal memuat data anak:', error)
// Tutup kembali jika gagal fetch
isOpen.value = false
} finally {
isLoading.value = false
}
}
})
</script>
<template>
<div class="tree-node min-w-max">
<Collapsible v-model:open="isOpen" class="w-full">
<!-- Node Header -->
<div class="flex items-center justify-start w-full p-2 rounded-md hover:bg-accent gap-2">
<!-- Chevron Toggle Button -->
<CollapsibleTrigger as-child>
<Button
variant="ghost"
class="h-4 w-4 p-0 flex items-center justify-center"
>
<Loader2 v-if="isLoading" class="w-4 h-4 animate-spin text-muted-foreground" />
<ChevronRight
v-else
class="w-4 h-4 transition-transform duration-200 ease-in-out text-muted-foreground"
:class="{
'rotate-90': isChevronRotated,
}"
/>
</Button>
</CollapsibleTrigger>
<!-- Node Label -->
<span
class="text-sm font-normal cursor-pointer hover:text-primary flex-1 flex items-center justify-between"
@click="handleLabelClick"
>
{{ item.label }}
<!-- Check Icon untuk selected state -->
<Check
v-if="selectedValue === item.value"
class="w-4 h-4 text-primary ml-2 flex-shrink-0"
/>
</span>
</div>
<!-- Children Container -->
<CollapsibleContent class="pl-6">
<div v-if="!hasChildren" class="text-sm text-muted-foreground p-2">
{{ isLoading ? 'Memuat...' : 'Tidak ada data' }}
</div>
<TreeView
v-else
:data="item.children!"
:selected-value="selectedValue"
:on-fetch-children="onFetchChildren"
:level="(level || 0) + 1"
@select="handleSelect"
/>
</CollapsibleContent>
</Collapsible>
</div>
</template>
<style scoped>
.tree-node {
@apply w-full;
}
/* Animasi tambahan untuk smooth transition */
.tree-node .collapsible-content {
transition: all 0.2s ease-in-out;
}
</style>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import type { TreeItem } from './type'
import { ChevronsUpDown } from 'lucide-vue-next'
import { cn } from '~/lib/utils'
import TreeView from './tree-view.vue'
const props = defineProps<{
data: TreeItem[]
onFetchChildren: (parentId: string) => Promise<void>
}>()
const modelValue = defineModel<string>()
const open = ref(false)
const searchValue = ref('')
function handleSelect(newVal: string) {
modelValue.value = newVal
open.value = false
}
function findLabel(value: string, items: TreeItem[]): string | undefined {
for (const item of items) {
if (item.value === value) return item.label
if (item.children) {
const found = findLabel(value, item.children)
if (found) return found
}
}
}
const selectedLabel = computed(() => {
return modelValue.value ? findLabel(modelValue.value, props.data) : '--- select item'
})
const filteredData = computed(() => {
if (!searchValue.value) return props.data
// recursive filter
function filterTree(items: TreeItem[]): TreeItem[] {
return items
.map(item => {
const match = item.label.toLowerCase().includes(searchValue.value.toLowerCase())
let children: TreeItem[] | undefined = undefined
if (item.children) {
children = filterTree(item.children)
}
if (match || (children && children.length > 0)) {
return { ...item, children }
}
return null
})
.filter(Boolean) as TreeItem[]
}
return filterTree(props.data)
})
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button variant="outline" role="combobox" class="w-full justify-between bg-white border-1 border-gray-400">
<span
class="font-normal text-muted-foreground" :class="cn(
'font-normal',
!modelValue && 'text-muted-foreground',
modelValue && 'text-black',
)"
>
{{ selectedLabel }}
</span>
<ChevronsUpDown class="w-4 h-4 ml-2 opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent class="min-w-full max-w-[350px] p-0">
<Command>
<CommandInput placeholder="Cari item..." v-model="searchValue" />
<CommandEmpty>Item tidak ditemukan.</CommandEmpty>
<CommandList class="max-h-[300px] overflow-x-auto overflow-y-auto">
<CommandGroup>
<TreeView
:data="filteredData"
:selected-value="modelValue"
:on-fetch-children="onFetchChildren"
:level="0"
@select="handleSelect"
/>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { TreeItem } from './type'
import Leaf from './leaf.vue'
import TreeNode from './tree-node.vue'
const props = defineProps<{
data: TreeItem[]
selectedValue?: string
onFetchChildren: (parentId: string) => Promise<void>
level?: number
}>()
const emit = defineEmits(['select'])
function handleSelect(value: string) {
emit('select', value)
}
// Computed untuk mendeteksi apakah ada node dengan children dalam level ini
const hasAnyChildrenInLevel = computed(() => {
return props.data.some(item => item.hasChildren)
})
// Computed untuk menentukan apakah perlu alignment berdasarkan level
const shouldAlignLeaves = computed(() => {
// Di root level (level 0), selalu align leaf dengan tree nodes jika ada mixed content
// Di level lain, hanya align jika ada mixed content
const isRootLevel = (props.level || 0) === 0
const hasMixedContent = hasAnyChildrenInLevel.value && props.data.some(item => !item.hasChildren)
return isRootLevel ? hasAnyChildrenInLevel.value : hasMixedContent
})
</script>
<template>
<div class="tree-view min-w-max">
<template v-for="item in data" :key="item.value">
<TreeNode
v-if="item.hasChildren"
:item="item"
:selected-value="selectedValue"
:on-fetch-children="onFetchChildren"
:level="level || 0"
@select="handleSelect"
/>
<Leaf
v-else
:item="item"
:selected-value="selectedValue"
:should-align="shouldAlignLeaves"
@select="handleSelect"
/>
</template>
</div>
</template>
@@ -0,0 +1,6 @@
export interface TreeItem {
value: string
label: string
hasChildren: boolean
children?: TreeItem[]
}
@@ -0,0 +1,72 @@
<script setup lang="ts">
import type { ServiceStatus } from './type'
import { Loader, Loader2 } from 'lucide-vue-next'
import { cn } from '~/lib/utils'
const props = defineProps<ServiceStatus>()
const tokenStatus = computed((): string => {
return props.sessionActive ? 'Valid' : 'Invalid'
})
</script>
<template>
<Card v-if="props.isSkeleton" class="py-6">
<div class="flex gap-4 justify-between px-6">
<div class="flex gap-2 items-center">
<span class="bg-gray-100">
<Skeleton class="bg-gray-100 w-6 h-6 sm:w-8 sm:h-8" />
</span>
<div>
<Skeleton class="w-64 h-8 bg-gray-100 text-xs md:text-sm text-muted-foreground" />
</div>
</div>
<div class="text-right flex flex-col items-end">
<Skeleton class="w-32 h-2 bg-gray-100 text-xs md:text-md text-muted-foreground" />
<Skeleton class="w-32 h-3 bg-gray-100 text-xs md:text-md font-bold" />
</div>
</div>
</Card>
<Card v-else class="py-6">
<div class="flex gap-4 justify-between px-6">
<div class="flex gap-2 items-center">
<span
:class="cn(' rounded-md w-12 h-12 flex items-center justify-center',
{ 'bg-red-500': props.status === 'error' },
{ 'bg-blue-500': props.status !== 'error' },
)"
>
<Icon v-if="props.status === 'error'" name="i-lucide-cable" class="text-white w-6 h-6 sm:w-8 sm:h-8" />
<Icon v-else name="i-lucide-bring-to-front" class="text-white w-6 h-6 sm:w-8 sm:h-8" />
</span>
<div>
<p v-if="props.status === 'connected'" class="text-xs md:text-md font-bold">Koneksi {{ props.serviceName }}
Aktif</p>
<p v-if="props.status === 'connecting'" class="flex flex-row text-xs md:text-md font-bold">Menghubungkan ke
API {{
props.serviceName }}
<Loader2 class="ml-2 h-4 w-4 animate-spin" />
</p>
<p v-if="props.status === 'error'" class="text-xs md:text-md font-bold">Koneksi ke API {{ props.serviceName
}}
Gagal</p>
<p v-if="props.status === 'connected'" class="text-xs md:text-sm text-muted-foreground">Koneksi Terhubung ke
API {{ props.serviceDesc }}
</p>
</div>
</div>
<div class="text-right flex flex-col items-end">
<p class="text-xs md:text-md text-muted-foreground">Session Token</p>
<p v-if="props.status === 'connecting'">
<Loader class="ml-2 h-4 w-4 animate-spin" />
</p>
<p
v-else :class="cn('text-xs md:text-md font-bold',
{ 'text-blue-500': props.sessionActive },
{ 'text-red-500': !props.sessionActive },
)"
>{{ tokenStatus }}</p>
</div>
</div>
</Card>
</template>
@@ -0,0 +1,7 @@
export interface ServiceStatus {
serviceName: string
serviceDesc: string
sessionActive: boolean
status: 'connected' | 'connecting' | 'error' | 'disconnected'
isSkeleton?: boolean
}
@@ -0,0 +1,71 @@
<script setup lang="ts">
import type { Summary } from './type'
import { ChevronDown, ChevronUp } from 'lucide-vue-next'
import { cn } from '~/lib/utils'
const props = defineProps<{
stat?: Summary
isSkeleton?: boolean
}>()
const timeFrame = computed((): string => {
if (!props.stat?.timeframe) return 'from unknown timeframe'
let word: string = ''
switch (props.stat.timeframe) {
case 'daily':
word = 'from yesterday'
break
case 'weekly':
word = 'from last week'
break
case 'monthly':
word = 'from last month'
break
case 'yearly':
word = 'from last year'
break
}
return word
})
const isTrending = computed<boolean>(() => (props.stat?.trend ?? 0) > 0)
</script>
<template>
<Card v-if="props.isSkeleton">
<CardHeader class="flex flex-row items-center justify-between pb-2">
<Skeleton class="h-6 w-32 bg-gray-100 text-sm font-medium" />
<Skeleton class="h-4 w-4 bg-gray-100" />
</CardHeader>
<CardContent>
<Skeleton class="mb-2 h-6 w-48 bg-gray-100 text-2xl" />
<Skeleton v-if="props.stat?.trend" class="h-4 w-64 bg-gray-100 text-xs font-medium" />
</CardContent>
</Card>
<Card v-else-if="props.stat && !props.isSkeleton" class="h-42">
<CardHeader class="flex flex-row items-center justify-between pb-2">
<CardTitle class="text-sm font-medium"> {{ props.stat.title }} </CardTitle>
<component :is="props.stat.icon" class="bg-primary h-[40px] w-auto rounded-md p-2 text-white" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">
{{ props.stat.metric.toLocaleString('id-ID') }}
</div>
<p v-if="props.stat.trend !== 0" class="text-muted-foreground flex items-center gap-1 text-xs">
<component
:is="isTrending ? ChevronUp : ChevronDown"
:class="cn('h-4 w-4', { 'text-green-500': isTrending }, { 'text-red-500': !isTrending })"
/>
<span :class="cn('font-medium', { 'text-green-500': isTrending }, { 'text-red-500': !isTrending })">
{{ props.stat.trend.toFixed(1) }}%
<!-- {{ Math.abs(props.stat.trend).toFixed(1) }}% -->
</span>
<span>{{ timeFrame }}</span>
</p>
</CardContent>
</Card>
</template>
<style scoped></style>
@@ -0,0 +1,7 @@
export interface Summary {
title: string
icon: Component
metric: number | string
trend: number
timeframe: 'yearly' | 'monthly' | 'weekly' | 'daily'
}
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { AccordionRootEmits, AccordionRootProps } from 'radix-vue'
import {
AccordionRoot,
useForwardPropsEmits,
} from 'radix-vue'
const props = defineProps<AccordionRootProps>()
const emits = defineEmits<AccordionRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AccordionRoot v-bind="forwarded">
<slot />
</AccordionRoot>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { AccordionContentProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import { AccordionContent } from 'radix-vue'
import { computed } from 'vue'
import { cn } from '~/lib/utils'
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AccordionContent
v-bind="delegatedProps"
class="overflow-hidden text-sm transition-all data-[state=open]:animate-accordion-down data-[state=closed]:animate-accordion-up"
>
<div :class="cn('pb-4 pt-0', props.class)">
<slot />
</div>
</AccordionContent>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { AccordionItemProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import { AccordionItem, useForwardProps } from 'radix-vue'
import { computed } from 'vue'
import { cn } from '~/lib/utils'
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<AccordionItem
v-bind="forwardedProps"
:class="cn('border-b', props.class)"
>
<slot />
</AccordionItem>
</template>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import type { AccordionTriggerProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import { ChevronDown } from 'lucide-vue-next'
import {
AccordionHeader,
AccordionTrigger,
} from 'radix-vue'
import { computed } from 'vue'
import { cn } from '~/lib/utils'
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AccordionHeader class="flex">
<AccordionTrigger
v-bind="delegatedProps"
:class="
cn(
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
props.class,
)
"
>
<slot />
<slot name="icon">
<ChevronDown
class="h-4 w-4 shrink-0 transition-transform duration-200"
/>
</slot>
</AccordionTrigger>
</AccordionHeader>
</template>
+4
View File
@@ -0,0 +1,4 @@
export { default as Accordion } from './Accordion.vue'
export { default as AccordionContent } from './AccordionContent.vue'
export { default as AccordionItem } from './AccordionItem.vue'
export { default as AccordionTrigger } from './AccordionTrigger.vue'
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { AlertDialogEmits, AlertDialogProps } from 'radix-vue'
import { AlertDialogRoot, useForwardPropsEmits } from 'radix-vue'
const props = defineProps<AlertDialogProps>()
const emits = defineEmits<AlertDialogEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AlertDialogRoot v-bind="forwarded">
<slot />
</AlertDialogRoot>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { AlertDialogActionProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import { AlertDialogAction } from 'radix-vue'
import { computed } from 'vue'
import { cn } from '~/lib/utils'
import { buttonVariants } from '../button'
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { AlertDialogCancelProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import { AlertDialogCancel } from 'radix-vue'
import { computed } from 'vue'
import { cn } from '~/lib/utils'
import { buttonVariants } from '../button'
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AlertDialogCancel v-bind="delegatedProps" :class="cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)">
<slot />
</AlertDialogCancel>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { AlertDialogContentEmits, AlertDialogContentProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import {
AlertDialogContent,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from 'radix-vue'
import { computed } from 'vue'
import { cn } from '~/lib/utils'
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<AlertDialogContentEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
class="fixed inset-0 z-50 data-[state=closed]:animate-out data-[state=open]:animate-in bg-black/80 data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0"
/>
<AlertDialogContent
v-bind="forwarded"
:class="
cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
props.class,
)
"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { AlertDialogDescriptionProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import {
AlertDialogDescription,
} from 'radix-vue'
import { computed } from 'vue'
import { cn } from '~/lib/utils'
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AlertDialogDescription
v-bind="delegatedProps"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</AlertDialogDescription>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '~/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -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
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { AlertDialogTitleProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import { AlertDialogTitle } from 'radix-vue'
import { computed } from 'vue'
import { cn } from '~/lib/utils'
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AlertDialogTitle
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 'radix-vue'
import { AlertDialogTrigger } from 'radix-vue'
const props = defineProps<AlertDialogTriggerProps>()
</script>
<template>
<AlertDialogTrigger v-bind="props">
<slot />
</AlertDialogTrigger>
</template>
@@ -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'
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { AlertVariants } from '.'
import { cn } from '~/lib/utils'
import { alertVariants } from '.'
const props = defineProps<{
class?: HTMLAttributes['class']
variant?: AlertVariants['variant']
}>()
</script>
<template>
<div :class="cn(alertVariants({ variant }), props.class)" role="alert">
<slot />
</div>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '~/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('text-sm [&_p]:leading-relaxed', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '~/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<h5 :class="cn('mb-1 font-medium leading-none tracking-tight', props.class)">
<slot />
</h5>
</template>
+24
View File
@@ -0,0 +1,24 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Alert } from './Alert.vue'
export { default as AlertDescription } from './AlertDescription.vue'
export { default as AlertTitle } from './AlertTitle.vue'
export const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export type AlertVariants = VariantProps<typeof alertVariants>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { AspectRatioProps } from 'radix-vue'
import { AspectRatio } from 'radix-vue'
const props = defineProps<AspectRatioProps>()
</script>
<template>
<AspectRatio v-bind="props">
<slot />
</AspectRatio>
</template>
@@ -0,0 +1 @@
export { default as AspectRatio } from './AspectRatio.vue'
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { AvatarVariants } from '.'
import { AvatarRoot } from 'radix-vue'
import { cn } from '~/lib/utils'
import { avatarVariant } from '.'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
size?: AvatarVariants['size']
shape?: AvatarVariants['shape']
}>(), {
size: 'sm',
shape: 'circle',
})
</script>
<template>
<AvatarRoot :class="cn(avatarVariant({ size, shape }), props.class)">
<slot />
</AvatarRoot>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { AvatarFallbackProps } from 'radix-vue'
import { AvatarFallback } from 'radix-vue'
const props = defineProps<AvatarFallbackProps>()
</script>
<template>
<AvatarFallback v-bind="props">
<slot />
</AvatarFallback>
</template>
@@ -0,0 +1,10 @@
<script setup lang="ts">
import type { AvatarImageProps } from 'radix-vue'
import { AvatarImage } from 'radix-vue'
const props = defineProps<AvatarImageProps>()
</script>
<template>
<AvatarImage v-bind="props" class="h-full w-full object-cover" />
</template>
+25
View File
@@ -0,0 +1,25 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Avatar } from './Avatar.vue'
export { default as AvatarFallback } from './AvatarFallback.vue'
export { default as AvatarImage } from './AvatarImage.vue'
export const avatarVariant = cva(
'inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden',
{
variants: {
size: {
sm: 'h-10 w-10 text-xs',
base: 'h-16 w-16 text-2xl',
lg: 'h-32 w-32 text-5xl',
},
shape: {
circle: 'rounded-full',
square: 'rounded-md',
},
},
},
)
export type AvatarVariants = VariantProps<typeof avatarVariant>
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { BadgeVariants } from '.'
import { cn } from '~/lib/utils'
import { badgeVariants } from '.'
const props = defineProps<{
variant?: BadgeVariants['variant']
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn(badgeVariants({ variant }), props.class)">
<slot />
</div>
</template>
+26
View 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 rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
@@ -0,0 +1,13 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<nav aria-label="breadcrumb" :class="props.class">
<slot />
</nav>
</template>
@@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { MoreHorizontal } from 'lucide-vue-next'
import { cn } from '~/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
role="presentation"
aria-hidden="true"
:class="cn('flex h-9 w-9 items-center justify-center', props.class)"
>
<slot>
<MoreHorizontal class="h-4 w-4" />
</slot>
<span class="sr-only">More</span>
</span>
</template>
@@ -0,0 +1,16 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '~/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<li
:class="cn('inline-flex items-center gap-1.5', props.class)"
>
<slot />
</li>
</template>
@@ -0,0 +1,20 @@
<script lang="ts" setup>
import type { PrimitiveProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import { Primitive } from 'radix-vue'
import { cn } from '~/lib/utils'
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes['class'] }>(), {
as: 'a',
})
</script>
<template>
<Primitive
:as="as"
:as-child="asChild"
:class="cn('transition-colors hover:text-foreground', props.class)"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,16 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '~/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<ol
:class="cn('flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5', props.class)"
>
<slot />
</ol>
</template>
@@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '~/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
role="link"
aria-disabled="true"
aria-current="page"
:class="cn('font-normal text-foreground', props.class)"
>
<slot />
</span>
</template>
@@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { ChevronRight } from 'lucide-vue-next'
import { cn } from '~/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<li
role="presentation"
aria-hidden="true"
:class="cn('[&>svg]:size-3.5', props.class)"
>
<slot>
<ChevronRight />
</slot>
</li>
</template>
@@ -0,0 +1,7 @@
export { default as Breadcrumb } from './Breadcrumb.vue'
export { default as BreadcrumbEllipsis } from './BreadcrumbEllipsis.vue'
export { default as BreadcrumbItem } from './BreadcrumbItem.vue'
export { default as BreadcrumbLink } from './BreadcrumbLink.vue'
export { default as BreadcrumbList } from './BreadcrumbList.vue'
export { default as BreadcrumbPage } from './BreadcrumbPage.vue'
export { default as BreadcrumbSeparator } from './BreadcrumbSeparator.vue'
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'radix-vue'
import { cn } from '~/lib/utils'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
})
</script>
<template>
<Primitive
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
+36
View File
@@ -0,0 +1,36 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md md:tex-xs xl:text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'md:h8 xl:h-9 px-4 py-2',
xs: 'h-7 rounded px-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
@@ -0,0 +1,73 @@
<script lang="ts" setup>
import type { CalendarRootEmits, CalendarRootProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import { CalendarRoot, useForwardPropsEmits } from 'radix-vue'
import { computed } from 'vue'
import { cn } from '~/lib/utils'
import CalendarCell from './CalendarCell.vue'
import CalendarCellTrigger from './CalendarCellTrigger.vue'
import CalendarGrid from './CalendarGrid.vue'
import CalendarGridBody from './CalendarGridBody.vue'
import CalendarGridHead from './CalendarGridHead.vue'
import CalendarGridRow from './CalendarGridRow.vue'
import CalendarHeadCell from './CalendarHeadCell.vue'
import CalendarHeader from './CalendarHeader.vue'
import CalendarHeading from './CalendarHeading.vue'
import CalendarNextButton from './CalendarNextButton.vue'
import CalendarPrevButton from './CalendarPrevButton.vue'
const props = defineProps<CalendarRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<CalendarRootEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CalendarRoot
v-slot="{ grid, weekDays }"
:class="cn('p-3', props.class)"
v-bind="forwarded"
>
<CalendarHeader>
<CalendarPrevButton />
<CalendarHeading />
<CalendarNextButton />
</CalendarHeader>
<div class="mt-4 flex flex-col gap-y-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>
@@ -0,0 +1,26 @@
<script lang="ts" setup>
import type { CalendarCellProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import { CalendarCell, useForwardProps } from 'radix-vue'
import { computed } from 'vue'
import { cn } from '~/lib/utils'
const props = defineProps<CalendarCellProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCell
:class="cn('relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-month])]:bg-accent/50', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarCell>
</template>
@@ -0,0 +1,41 @@
<script lang="ts" setup>
import type { CalendarCellTriggerProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import { CalendarCellTrigger, useForwardProps } from 'radix-vue'
import { computed } from 'vue'
import { cn } from '~/lib/utils'
import { buttonVariants } from '../button'
const props = defineProps<CalendarCellTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCellTrigger
:class="cn(
buttonVariants({ variant: 'ghost' }),
'h-8 w-8 p-0 font-normal',
'[&[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-month]:pointer-events-none data-[outside-month]:text-muted-foreground data-[outside-month]:opacity-50 [&[data-outside-month][data-selected]]:bg-accent/50 [&[data-outside-month][data-selected]]:text-muted-foreground [&[data-outside-month][data-selected]]:opacity-30',
props.class,
)"
v-bind="forwardedProps"
>
<slot />
</CalendarCellTrigger>
</template>
@@ -0,0 +1,26 @@
<script lang="ts" setup>
import type { CalendarGridProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import { CalendarGrid, useForwardProps } from 'radix-vue'
import { computed } from 'vue'
import { cn } from '~/lib/utils'
const props = defineProps<CalendarGridProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarGrid
:class="cn('w-full border-collapse space-y-1', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarGrid>
</template>
@@ -0,0 +1,12 @@
<script lang="ts" setup>
import type { CalendarGridBodyProps } from 'radix-vue'
import { CalendarGridBody } from 'radix-vue'
const props = defineProps<CalendarGridBodyProps>()
</script>
<template>
<CalendarGridBody v-bind="props">
<slot />
</CalendarGridBody>
</template>

Some files were not shown because too many files have changed in this diff Show More