Dev cleaning (#106)
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user