dev: hotfix refactor

+ merged pub/custom-ui and pub/base into my-ui
- droped pub/custom-ui
This commit is contained in:
2025-10-05 09:45:17 +07:00
parent 72627b8a37
commit 2da4e616ba
219 changed files with 474 additions and 474 deletions
@@ -0,0 +1,33 @@
<script lang="ts" setup>
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from '~/components/pub/ui/breadcrumb'
const props = defineProps<{
links: {
title: string
href: string
}[]
}>()
</script>
<template>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem v-for="(link, index) in props.links" :key="link.href">
<BreadcrumbLink as-child>
<NuxtLink :to="link.href">
{{ link.title }}
</NuxtLink>
</BreadcrumbLink>
<BreadcrumbSeparator v-if="index < props.links.length - 1" />
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</template>
@@ -0,0 +1,137 @@
# Confirmation Modal Components
Sistem confirmation modal yang modular dan dapat digunakan kembali di seluruh aplikasi.
## Components
### 1. `confirmation.vue` - Base Confirmation Modal
Komponen dasar untuk modal konfirmasi yang dapat dikustomisasi.
**Props:**
- `open?: boolean` - Status modal (terbuka/tertutup)
- `title?: string` - Judul modal (default: "Konfirmasi")
- `message?: string` - Pesan konfirmasi (default: "Apakah Anda yakin ingin melanjutkan?")
- `confirmText?: string` - Text tombol konfirmasi (default: "Ya")
- `cancelText?: string` - Text tombol batal (default: "Batal")
- `variant?: 'default' | 'destructive' | 'warning'` - Varian tampilan (default: "default")
- `size?: 'sm' | 'md' | 'lg' | 'xl'` - Ukuran modal (default: "md")
**Events:**
- `@confirm` - Dipanggil saat tombol konfirmasi diklik
- `@cancel` - Dipanggil saat tombol batal diklik
- `@update:open` - Dipanggil saat status modal berubah
**Contoh penggunaan:**
```vue
<template>
<Confirmation
v-model:open="isConfirmOpen"
title="Hapus Data"
message="Apakah Anda yakin ingin menghapus data ini?"
confirm-text="Hapus"
variant="destructive"
@confirm="handleConfirm"
@cancel="handleCancel"
/>
</template>
```
### 2. `record-confirmation.vue` - Record-specific Confirmation
Komponen khusus untuk konfirmasi operasi pada record/data tertentu.
**Props:**
- `open?: boolean` - Status modal
- `action?: 'delete' | 'deactivate' | 'activate' | 'archive' | 'restore'` - Jenis aksi (default: "delete")
- `record?: RecordData | null` - Data record yang akan diproses
- `customTitle?: string` - Custom judul (opsional)
- `customMessage?: string` - Custom pesan (opsional)
- `customConfirmText?: string` - Custom text tombol konfirmasi (opsional)
- `customCancelText?: string` - Custom text tombol batal (opsional)
**Events:**
- `@confirm` - Dipanggil dengan parameter `(record, action)`
- `@cancel` - Dipanggil saat batal
- `@update:open` - Update status modal
**Contoh penggunaan:**
```vue
<template>
<RecordConfirmation
v-model:open="isRecordConfirmOpen"
action="delete"
:record="selectedRecord"
@confirm="handleDeleteRecord"
@cancel="handleCancel"
>
<template #default="{ record }">
<div class="text-sm">
<p><strong>ID:</strong> {{ record?.id }}</p>
<p><strong>Nama:</strong> {{ record?.name }}</p>
</div>
</template>
</RecordConfirmation>
</template>
```
## Action Types
Record confirmation mendukung beberapa jenis aksi dengan konfigurasi default:
- **delete**: Hapus data (variant: destructive, warna merah)
- **deactivate**: Nonaktifkan data (variant: warning, warna kuning)
- **activate**: Aktifkan data (variant: default, warna biru)
- **archive**: Arsipkan data (variant: warning, warna kuning)
- **restore**: Pulihkan data (variant: default, warna biru)
## Integration Example
Contoh implementasi di komponen list:
```vue
<script setup>
// State management
const isRecordConfirmationOpen = ref(false)
const selectedRecord = ref(null)
const confirmAction = ref('delete')
// Handle action dari table
function handleRowAction(action, record) {
selectedRecord.value = record
confirmAction.value = action
isRecordConfirmationOpen.value = true
}
// Handle konfirmasi
async function handleConfirmAction(record, action) {
try {
// API call berdasarkan action
await performAction(action, record.id)
// Refresh data
await refreshData()
} catch (error) {
// Handle error
}
}
</script>
<template>
<!-- Your list component -->
<DataTable @action="handleRowAction" />
<!-- Confirmation modal -->
<RecordConfirmation
v-model:open="isRecordConfirmationOpen"
:action="confirmAction"
:record="selectedRecord"
@confirm="handleConfirmAction"
/>
</template>
```
## Styling
Komponen menggunakan Tailwind CSS dan shadcn/ui components. Pastikan dependencies berikut tersedia:
- `~/components/pub/my-ui/modal/dialog.vue`
- `~/components/pub/ui/button`
@@ -0,0 +1,99 @@
<script setup lang="ts">
import Dialog from '~/components/pub/my-ui/modal/dialog.vue'
import { Button } from '~/components/pub/ui/button'
interface ConfirmationProps {
open?: boolean
title?: string
message?: string
confirmText?: string
cancelText?: string
variant?: 'default' | 'destructive' | 'warning'
size?: 'sm' | 'md' | 'lg' | 'xl'
}
interface ConfirmationEmits {
'update:open': [value: boolean]
'confirm': []
'cancel': []
}
const props = withDefaults(defineProps<ConfirmationProps>(), {
open: false,
title: 'Konfirmasi',
message: 'Apakah Anda yakin ingin melanjutkan?',
confirmText: 'Ya',
cancelText: 'Batal',
variant: 'default',
size: 'md',
})
const emit = defineEmits<ConfirmationEmits>()
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value),
})
const variantClasses = computed(() => {
const variants = {
default: {
icon: 'i-lucide-help-circle',
iconColor: 'text-blue-500',
confirmVariant: 'default' as const,
},
destructive: {
icon: 'i-lucide-alert-triangle',
iconColor: 'text-red-500',
confirmVariant: 'destructive' as const,
},
warning: {
icon: 'i-lucide-alert-circle',
iconColor: 'text-yellow-500',
confirmVariant: 'default' as const,
},
}
return variants[props.variant]
})
function handleConfirm() {
emit('confirm')
emit('update:open', false)
}
function handleCancel() {
emit('cancel')
emit('update:open', false)
}
</script>
<template>
<Dialog v-model:open="isOpen" :title="title" :size="size">
<div class="space-y-4">
<!-- Icon dan pesan -->
<div class="flex items-start gap-3">
<div :class="[variantClasses.icon, variantClasses.iconColor]" class="w-6 h-6 mt-1 flex-shrink-0" />
<div class="flex-1">
<p class="text-sm text-muted-foreground leading-relaxed">
{{ message }}
</p>
</div>
</div>
<!-- Slot untuk konten custom -->
<div v-if="$slots.default">
<slot />
</div>
<!-- Footer buttons -->
<div class="flex justify-end gap-3 pt-4">
<Button variant="outline" @click="handleCancel">
{{ cancelText }}
</Button>
<Button :variant="variantClasses.confirmVariant" @click="handleConfirm">
{{ confirmText }}
</Button>
</div>
</div>
</Dialog>
</template>
@@ -0,0 +1,131 @@
<script setup lang="ts">
import Confirmation from '~/components/pub/my-ui/confirmation/confirmation.vue'
interface RecordData {
id: number | string
name?: string
title?: string
code?: string
[key: string]: any
}
interface RecordConfirmationProps {
open?: boolean
action?: 'delete' | 'deactivate' | 'activate' | 'archive' | 'restore'
record?: RecordData | null
customTitle?: string
customMessage?: string
customConfirmText?: string
customCancelText?: string
}
interface RecordConfirmationEmits {
'update:open': [value: boolean]
'confirm': [record: RecordData, action: string]
'cancel': []
}
const props = withDefaults(defineProps<RecordConfirmationProps>(), {
open: false,
action: 'delete',
record: null,
customTitle: '',
customMessage: '',
customConfirmText: '',
customCancelText: '',
})
const emit = defineEmits<RecordConfirmationEmits>()
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value),
})
const actionConfig = computed(() => {
const configs = {
delete: {
title: 'Hapus Data',
message: 'Apakah Anda yakin ingin menghapus data ini? Tindakan ini tidak dapat dibatalkan.',
confirmText: 'Hapus',
variant: 'destructive' as const,
},
deactivate: {
title: 'Nonaktifkan Data',
message: 'Apakah Anda yakin ingin menonaktifkan data ini?',
confirmText: 'Nonaktifkan',
variant: 'warning' as const,
},
activate: {
title: 'Aktifkan Data',
message: 'Apakah Anda yakin ingin mengaktifkan data ini?',
confirmText: 'Aktifkan',
variant: 'default' as const,
},
archive: {
title: 'Arsipkan Data',
message: 'Apakah Anda yakin ingin mengarsipkan data ini?',
confirmText: 'Arsipkan',
variant: 'warning' as const,
},
restore: {
title: 'Pulihkan Data',
message: 'Apakah Anda yakin ingin memulihkan data ini?',
confirmText: 'Pulihkan',
variant: 'default' as const,
},
}
return configs[props.action]
})
const finalTitle = computed(() => {
return props.customTitle || actionConfig.value.title
})
const finalMessage = computed(() => {
if (props.customMessage) {
return props.customMessage
}
const baseMessage = actionConfig.value.message
// if (props.record) {
// const recordName = props.record.name || props.record.title || props.record.code || `ID: ${props.record.id}`
// return `${baseMessage}\n\nData: ${recordName}`
// }
return baseMessage
})
const finalConfirmText = computed(() => {
return props.customConfirmText || actionConfig.value.confirmText
})
const finalCancelText = computed(() => {
return props.customCancelText || 'Batal'
})
function handleConfirm() {
if (props.record) {
emit('confirm', props.record, props.action)
}
emit('update:open', false)
}
function handleCancel() {
emit('cancel')
emit('update:open', false)
}
</script>
<template>
<Confirmation
v-model:open="isOpen" :title="finalTitle" :message="finalMessage" :confirm-text="finalConfirmText"
:cancel-text="finalCancelText" :variant="actionConfig.variant" size="md" @confirm="handleConfirm"
@cancel="handleCancel"
>
<!-- Slot untuk informasi tambahan record -->
<div v-if="record && $slots.default" class="mt-4 p-3 bg-muted rounded-md">
<slot :record="record" :action="action" />
</div>
</Confirmation>
</template>
@@ -0,0 +1,137 @@
<script setup lang="ts">
import type { DataTableLoader } from './type'
import type { Col, RecStrFuncComponent, RecStrFuncUnknown, Th } from '~/components/pub/my-ui/data/types'
import { Info } from 'lucide-vue-next'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '~/components/pub/ui/table'
const props = defineProps<{
skeletonSize?: number
rows: unknown[]
cols: Col[]
header: Th[][]
keys: string[]
funcParsed?: RecStrFuncUnknown
funcHtml?: RecStrFuncUnknown
funcComponent?: RecStrFuncComponent
selectMode?: 'single' | 'multiple'
modelValue?: any[] | any
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: any[] | any): void
}>()
const getSkeletonSize = computed(() => {
return props.skeletonSize || 5
})
const loader = inject('table_data_loader') as DataTableLoader
// local state utk selection
const selected = ref<any[]>([])
function toggleSelection(row: any) {
if (props.selectMode === 'single') {
selected.value = [row]
emit('update:modelValue', row)
} else {
const idx = selected.value.findIndex((r) => r === row)
if (idx >= 0) {
selected.value.splice(idx, 1)
} else {
selected.value.push(row)
}
emit('update:modelValue', [...selected.value])
}
}
function handleActionCellClick(event: Event, _cellRef: string) {
// Prevent event if clicked directly on the button/dropdown
const target = event.target as HTMLElement
if (target.closest('button') || target.closest('[role="button"]')) {
return
}
// Find the dropdown trigger button and click it
const cell = event.currentTarget as HTMLElement
const triggerButton = cell.querySelector('button[data-state]') || cell.querySelector('button')
if (triggerButton) {
;(triggerButton as HTMLButtonElement).click()
}
}
</script>
<template>
<Table>
<TableHeader class="bg-gray-50 dark:bg-gray-800">
<TableRow>
<TableHead
v-for="(h, idx) in header[0]"
:key="`head-${idx}`"
class="border"
:style="{ width: cols[idx]?.width ? `${cols[idx].width}px` : undefined }"
>
{{ h.label }}
</TableHead>
</TableRow>
</TableHeader>
<TableBody v-if="loader.isTableLoading">
<!-- Loading state with 5 skeleton rows -->
<TableRow v-for="n in getSkeletonSize" :key="`skeleton-${n}`">
<TableCell v-for="(key, cellIndex) in keys" :key="`cell-skel-${n}-${cellIndex}`" class="border">
<Skeleton class="h-6 w-full animate-pulse bg-gray-100 dark:bg-gray-700 text-muted-foreground" />
</TableCell>
</TableRow>
</TableBody>
<TableBody v-else-if="rows.length === 0">
<TableRow>
<TableCell :colspan="keys.length" class="py-8 text-center">
<div class="flex items-center justify-center">
<Info class="size-5 text-muted-foreground" />
<span class="ml-2">Tidak ada data tersedia</span>
</div>
</TableCell>
</TableRow>
</TableBody>
<TableBody v-else>
<TableRow
v-for="(row, rowIndex) in rows"
:key="`row-${rowIndex}`"
:class="{
'bg-green-50': props.selectMode === 'single' && selected.includes(row),
'bg-blue-50': props.selectMode === 'multiple' && selected.includes(row),
}"
@click="toggleSelection(row)"
>
<!-- opsional: kalau mau tampilkan checkbox/radio di cell pertama -->
<TableCell v-if="props.selectMode">
<input
v-if="props.selectMode === 'single'"
type="radio"
:checked="selected.includes(row)"
@change="toggleSelection(row)"
/>
<input v-else type="checkbox" :checked="selected.includes(row)" @change="toggleSelection(row)" />
</TableCell>
<!-- lanjut render cell normal -->
<TableCell v-for="(key, cellIndex) in keys" :key="`cell-${rowIndex}-${cellIndex}`" class="border">
<!-- existing cell renderer -->
<component
:is="funcComponent?.[key]?.(row, rowIndex).component"
v-if="funcComponent?.[key]"
:rec="row"
:idx="rowIndex"
v-bind="funcComponent[key]?.(row, rowIndex).props"
/>
<template v-else>
<div v-if="funcHtml?.[key]" v-html="funcHtml?.[key]?.(row, rowIndex)"></div>
<template v-else>
{{ funcParsed?.[key]?.(row, rowIndex) ?? (row as any)[key] }}
</template>
</template>
</TableCell>
</TableRow>
</TableBody>
</Table>
</template>
@@ -0,0 +1,4 @@
export interface DataTableLoader {
isTableLoading: boolean
[key: string]: boolean
}
@@ -0,0 +1,70 @@
<script setup lang="ts">
import type { LinkItem, ListItemDto } from './types'
import { ActionEvents } from './types'
const props = defineProps<{
rec: ListItemDto
}>()
const recId = inject<Ref<number>>('rec_id')!
const recAction = inject<Ref<string>>('rec_action')!
const recItem = inject<Ref<any>>('rec_item')!
function detail() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showDetail
recItem.value = props.rec
}
function edit() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showEdit
recItem.value = props.rec
}
const linkItems: LinkItem[] = [
{
label: 'Detail',
onClick: () => {
detail()
},
icon: 'i-lucide-eye',
},
{
label: 'Edit',
onClick: () => {
edit()
},
icon: 'i-lucide-pencil',
},
]
</script>
<template>
<div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:text-sidebar-accent-foreground data-[state=open]:bg-white"
>
<Icon name="i-lucide-chevrons-up-down" class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-40 rounded-lg bg-white" align="end">
<DropdownMenuGroup>
<DropdownMenuItem
v-for="item in linkItems"
:key="item.label"
v-slot="{ active }"
class="hover:bg-gray-100"
@click="item.onClick"
>
<Icon :name="item.icon" />
<span :class="active ? 'text-sidebar-accent-foreground' : ''">{{ item.label }}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
@@ -0,0 +1,90 @@
<script setup lang="ts">
import type { LinkItem, ListItemDto } from './types'
import { ActionEvents } from './types'
const props = defineProps<{
rec: ListItemDto
}>()
const recId = inject<Ref<number>>('rec_id')!
const recAction = inject<Ref<string>>('rec_action')!
const recItem = inject<Ref<any>>('rec_item')!
function detail() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showDetail
recItem.value = props.rec
}
function edit() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showEdit
recItem.value = props.rec
}
function del() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showConfirmDelete
recItem.value = props.rec
}
const linkItems: LinkItem[] = [
{
label: 'Detail',
onClick: () => {
detail()
},
icon: 'i-lucide-eye',
},
{
label: 'Edit',
onClick: () => {
edit()
},
icon: 'i-lucide-pencil',
},
{
label: 'Batal',
onClick: () => {
del()
},
icon: 'i-lucide-x',
},
{
label: 'Hapus',
onClick: () => {
del()
},
icon: 'i-lucide-trash',
},
]
</script>
<template>
<div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:text-sidebar-accent-foreground data-[state=open]:bg-white"
>
<Icon name="i-lucide-chevrons-up-down" class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-40 rounded-lg bg-white" align="end">
<DropdownMenuGroup>
<DropdownMenuItem
v-for="item in linkItems"
:key="item.label"
v-slot="{ active }"
class="hover:bg-gray-100"
@click="item.onClick"
>
<Icon :name="item.icon" />
<span :class="active ? 'text-sidebar-accent-foreground' : ''">{{ item.label }}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
@@ -0,0 +1,83 @@
<script setup lang="ts">
import type { LinkItem, ListItemDto } from './types'
import { ActionEvents } from './types'
const props = defineProps<{
rec: ListItemDto
}>()
const recId = inject<Ref<number>>('rec_id')!
const recAction = inject<Ref<string>>('rec_action')!
const recItem = inject<Ref<any>>('rec_item')!
function detail() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showDetail
recItem.value = props.rec
}
function edit() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showEdit
recItem.value = props.rec
}
function del() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showConfirmDelete
recItem.value = props.rec
}
const linkItems: LinkItem[] = [
{
label: 'Detail',
onClick: () => {
detail()
},
icon: 'i-lucide-eye',
},
{
label: 'Edit',
onClick: () => {
edit()
},
icon: 'i-lucide-pencil',
},
{
label: 'Hapus',
onClick: () => {
del()
},
icon: 'i-lucide-trash',
},
]
</script>
<template>
<div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:text-sidebar-accent-foreground data-[state=open]:bg-white"
>
<Icon name="i-lucide-chevrons-up-down" class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-40 rounded-lg bg-white" align="end">
<DropdownMenuGroup>
<DropdownMenuItem
v-for="item in linkItems"
:key="item.label"
v-slot="{ active }"
class="hover:bg-gray-100"
@click="item.onClick"
>
<Icon :name="item.icon" />
<span :class="active ? 'text-sidebar-accent-foreground' : ''">{{ item.label }}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import type { LinkItem, ListItemDto } from './types'
import { ActionEvents } from './types'
const props = defineProps<{
rec: ListItemDto
}>()
const recId = inject<Ref<number>>('rec_id')!
const recAction = inject<Ref<string>>('rec_action')!
const recItem = inject<Ref<any>>('rec_item')!
function detail() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showDetail
recItem.value = props.rec
}
function process() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showProcess
recItem.value = props.rec
}
function edit() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showEdit
recItem.value = props.rec
}
function del() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showConfirmDelete
recItem.value = props.rec
}
const linkItems: LinkItem[] = [
{
label: 'Proses',
onClick: () => {
process()
},
icon: 'i-lucide-file',
},
{
label: 'Detail',
onClick: () => {
detail()
},
icon: 'i-lucide-eye',
},
{
label: 'Edit',
onClick: () => {
edit()
},
icon: 'i-lucide-pencil',
},
{
label: 'Hapus',
onClick: () => {
del()
},
icon: 'i-lucide-trash',
},
]
</script>
<template>
<div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:text-sidebar-accent-foreground data-[state=open]:bg-white"
>
<Icon name="i-lucide-chevrons-up-down" class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-40 rounded-lg bg-white" align="end">
<DropdownMenuGroup>
<DropdownMenuItem
v-for="item in linkItems"
:key="item.label"
v-slot="{ active }"
class="hover:bg-gray-100"
@click="item.onClick"
>
<Icon :name="item.icon" />
<span :class="active ? 'text-sidebar-accent-foreground' : ''">{{ item.label }}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import type { LinkItem, ListItemDto } from './types'
import { ActionEvents } from './types'
interface Props {
rec: ListItemDto
size?: 'default' | 'sm' | 'lg'
}
const props = withDefaults(defineProps<Props>(), {
size: 'lg',
})
const recId = inject<Ref<number>>('rec_id')!
const recAction = inject<Ref<string>>('rec_action')!
const recItem = inject<Ref<any>>('rec_item')!
function edit() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showEdit
recItem.value = props.rec
}
function del() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showConfirmDelete
recItem.value = props.rec
}
const linkItems: LinkItem[] = [
{
label: 'Edit',
onClick: () => {
edit()
},
icon: 'i-lucide-pencil',
},
{
label: 'Hapus',
onClick: () => {
del()
},
icon: 'i-lucide-trash',
},
]
</script>
<template>
<div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
:size="size"
class="data-[state=open]:text-sidebar-accent-foreground data-[state=open]:bg-white"
>
<Icon name="i-lucide-chevrons-up-down" class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-40 rounded-lg bg-white" align="end">
<DropdownMenuGroup>
<DropdownMenuItem
v-for="item in linkItems"
:key="item.label"
v-slot="{ active }"
class="hover:bg-gray-100"
@click="item.onClick"
>
<Icon :name="item.icon" />
<span :class="active ? 'text-sidebar-accent-foreground' : ''">{{ item.label }}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
+109
View File
@@ -0,0 +1,109 @@
// import type { ComponentType } from '@unovis/ts'
import type { Component } from 'vue'
export interface ListItemDto {
id: number
name: string
code: string
}
export type ComponentType = Component
export interface RecComponent {
idx?: number
rec: object
props?: any
component: ComponentType
}
export interface Col {
span?: number
classVal?: string
style?: string
width?: number // specific for width
widthUnit?: string // specific for width
}
export interface Th {
label: string
colSpan?: number
rowSpan?: number
classVal?: string
childClassVal?: string
style?: string
childStyle?: string
hideOnSm?: boolean
}
export interface ButtonNav {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
classVal?: string
classValExt?: string
icon?: string
label: string
onClick?: () => void
}
export interface QuickSearchNav {
inputClass?: string
inputPlaceHolder?: string
btnClass?: string
btnIcon?: string
btnLabel?: string
mainField?: string
searchParams: object
onSubmit?: (searchParams: object) => void
}
export interface RefSearchNav {
modelValue?: string
placeholder?: string
minLength?: number
debounceMs?: number
inputClass?: string
showValidationFeedback?: boolean
onInput: (val: string) => void
onClick: () => void
onClear: () => void
}
// prepared header for relatively common usage
export interface HeaderPrep {
title?: string
icon?: string
refSearchNav?: RefSearchNav
quickSearchNav?: QuickSearchNav
filterNav?: ButtonNav
addNav?: ButtonNav
printNav?: ButtonNav
}
export interface KeyLabel {
key: string
label: string
}
export type FuncRecUnknown = (rec: unknown, idx: number) => unknown
export type FuncComponent = (rec: unknown, idx: number) => RecComponent
export type RecStrFuncUnknown = Record<string, FuncRecUnknown>
export type RecStrFuncComponent = Record<string, FuncComponent>
export interface KeyNames {
key: string
label: string
}
export interface LinkItem {
label: string
icon?: string
href?: string // to cover the needs of stating full external origins full url
action?: string // for local paths
onClick?: (event: Event) => void
headerStatus?: boolean
}
export const ActionEvents = {
showConfirmDelete: 'showConfirmDel',
showEdit: 'showEdit',
showDetail: 'showDetail',
showProcess: 'showProcess',
}
@@ -0,0 +1,79 @@
<script setup lang="ts">
// ---------- Imports ----------
import { computed, useSlots } from 'vue'
// Types
const props = defineProps({
mode: { type: String, default: 'entry' },
gridPoint: { type: String, default: 'lg' },
cellFlex: { type: Boolean, default: true },
cellFlexPoint: { type: String, default: 'md' },
labelSize: { type: String, default: 'medium' },
labelSizePoint: { type: String, default: 'md' },
colCount: { type: Number, default: 1 },
defaultClass: { type: String, default: 'mb-5' },
class: { type: String, default: '' },
})
const slots = useSlots()
// Utility functions (minimal, can be expanded)
const breakpoints = ['grid', 'sm:grid', 'md:grid', 'lg:grid', 'xl:grid', '2xl:grid']
const getBreakpointIdx = (point: string) => {
return Math.max(0, breakpoints.findIndex(bp => bp.startsWith(point)))
}
const labelSizes = ['small', 'medium', 'large', 'xl', '2xl']
const getLabelSizeIdx = (size: string) => {
return Math.max(0, labelSizes.findIndex(s => s === size))
}
const settingClass = computed(() => {
const breakPointIdx = getBreakpointIdx(props.gridPoint)
let cls = breakpoints[breakPointIdx]
cls += ' gap-x-4 xl:gap-x-5 gap-y-2 xl:gap-y-3 ' + [
'grid-cols-1', 'grid-cols-2', 'grid-cols-3', 'grid-cols-4', 'grid-cols-5',
'grid-cols-6', 'grid-cols-7', 'grid-cols-8', 'grid-cols-9', 'grid-cols-10',
][props.colCount - 1]
cls += breakPointIdx === 0 ? ' gap-x-3 ' : ''
cls += ' ' + [
' [&_.cell]:!mb-0',
' [&_.cell]:mb-2.5 [&_.cell]:sm:mb-0',
' [&_.cell]:mb-2.5 [&_.cell]:md:mb-0',
' [&_.cell]:mb-2.5 [&_.cell]:lg:mb-0',
' [&_.cell]:mb-3 [&_.cell]:xl:mb-0',
' [&_.cell]:mb-3 [&_.cell]:2xl:mb-0',
][breakPointIdx]
if (props.cellFlex) {
cls += ' ' + [
'[&_.cell]:flex',
'[&_.cell]:sm:flex',
'[&_.cell]:md:flex',
'[&_.cell]:lg:flex',
'[&_.cell]:xl:flex',
'[&_.cell]:2xl:flex',
][getBreakpointIdx(props.cellFlexPoint)]
cls += ' [&_.label]:flex-shrink-0 ' + [
'[&_.label]:md:w-12 [&_.label]:xl:w-20',
'[&_.label]:md:w-16 [&_.label]:xl:w-24',
'[&_.label]:md:w-24 [&_.label]:xl:w-32',
'[&_.label]:md:w-32 [&_.label]:xl:w-40',
'[&_.label]:md:w-44 [&_.label]:xl:w-52',
][getLabelSizeIdx(props.labelSize)]
} else {
cls += ' [&_.label]:pb-1 ';
}
cls += ' [&:not(.preview)_.height-default]:pt-2 [&:not(.preview)_.height-default]:2xl:!pt-1.5 [&:not(.preview)_.height-compact]:!pt-1 '
cls += '[&_textarea]:text-xs [&_textarea]:xl:text-sm '
cls += '[&_label]:text-xs [&_label]:xl:text-sm'
return cls
})
</script>
<template>
<div :class="`block ${mode} ${props.defaultClass} ${settingClass} ${props.class}`">
<slot />
</div>
</template>
<style scoped>
</style>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps({
colSpan: { type: Number, default: undefined },
colStart: { type: Number, default: undefined },
colEnd: { type: Number, default: undefined },
class: { type: String, default: '' },
})
const settingClass = computed(() => {
let cls = ' cell'
if (props.colSpan) {
cls += ' ' + [
'col-span-1', 'col-span-2', 'col-span-3', 'col-span-4', 'col-span-5',
'col-span-6', 'col-span-7', 'col-span-8', 'col-span-9', 'col-span-10',
][props.colSpan - 1]
}
if (props.colStart) {
cls += ' ' + [
'col-start-1', 'col-start-2', 'col-start-3', 'col-start-4', 'col-start-5',
'col-start-6', 'col-start-7', 'col-start-8', 'col-start-9', 'col-start-10',
][props.colStart - 1]
}
if (props.colEnd) {
cls += ' ' + [
'col-end-1', 'col-end-2', 'col-end-3', 'col-end-4', 'col-end-5',
'col-end-6', 'col-end-7', 'col-end-8', 'col-end-9', 'col-end-10',
][props.colEnd - 1]
}
if (props.class) {
cls += ' ' + props.class.trim()
}
return cls
})
</script>
<template>
<div :class="`w-full${settingClass}`">
<slot />
</div>
</template>
@@ -0,0 +1,7 @@
<script lang="ts" setup>
</script>
<template>
<div class="w-5 text-center">:</div>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
const props = defineProps({
errMessage: { type: String, default: '' },
defaultClass: { type: String, default: 'grow shrink-0 overflow-hidden' },
class: { type: String, default: '' },
})
</script>
<template>
<div :class="`field ${props.defaultClass} ${props.class}`">
<slot />
<div v-if="props.errMessage" class="mt-1 text-xs font-medium text-red-500">{{ props.errMessage }}</div>
</div>
</template>
@@ -0,0 +1,4 @@
export { default as Block } from './block.vue'
export { default as Cell } from './cell.vue'
export { default as Label } from './label.vue'
export { default as Field } from './field.vue'
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps({
height: { type: String, default: 'default' }, // 'default' | 'compact'
position: { type: String, default: 'default' }, // 'default' | 'dynamic'
positionPoint: { type: String, default: 'lg' },
class: { type: String, default: '' },
})
const breakpoints = ['','sm','md','lg','xl','2xl']
const getBreakpointIdx = (point: string) => {
return Math.max(0, breakpoints.findIndex(bp => bp === point))
}
const settingClass = computed(() => {
let cls = 'label'
cls += props.height === 'compact' ? ' height-compact ' : ' height-default '
if (props.position === 'dynamic') {
cls += ' ' + [
'text-end pe-2.5',
'sm:text-end pe-2.5',
'md:text-end pe-2.5',
'lg:text-end pe-2.5',
'xl:text-end pe-2.5',
'2xl:text-end pe-2.5',
][getBreakpointIdx(props.positionPoint)]
}
return cls + ' ' + (props.class?.trim() || '')
})
</script>
<template>
<div :class="settingClass">
<label>
<slot />
</label>
</div>
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
defineProps<{
statusCode: number
}>()
const router = useRouter()
</script>
<template>
<div class="h-svh">
<div class="m-auto flex h-full w-full flex-col items-center justify-center gap-2">
<template v-if="statusCode === 403">
<h1 class="text-[7rem] font-bold leading-tight">403</h1>
<span class="font-medium">Access Forbidden</span>
<p class="text-muted-foreground text-center">
You don't have necessary permission <br />
to access this resource.
</p>
</template>
<template v-else-if="statusCode === 404">
<h1 class="text-[7rem] font-bold leading-tight">404</h1>
<span class="font-medium">Page Not Found</span>
<p class="text-muted-foreground text-center">
The page you are looking for <br />
doesn't exist.
</p>
</template>
<template v-else-if="statusCode === 401">
<h1 class="text-[7rem] font-bold leading-tight">401</h1>
<span class="font-medium">Unauthorized Access</span>
<p class="text-muted-foreground text-center">
Please log in with the appropriate credentials <br />
to access this resource.
</p>
</template>
<template v-else>
<h1 class="text-[7rem] font-bold leading-tight">500</h1>
<span class="font-medium">Internal Server Error</span>
<p class="text-muted-foreground text-center">
Something went wrong on our end. <br />
Please try again later.
</p>
</template>
<div class="mt-6 flex gap-4">
<Button variant="outline" @click="router.back()"> Kembali </Button>
<Button v-if="statusCode === 401" @click="router.push('/auth/login')">Login</Button>
<Button v-else @click="router.push('/')">Kembali ke Dashboard</Button>
</div>
</div>
</div>
</template>
+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
defineProps<{
classValExt?: string
}>()
</script>
<template>
<div :class="`m-3 mb-5 flex-wrap md:flex ${classValExt || ''}`">
<slot />
</div>
</template>
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { cn } from '~/lib/utils'
interface Item {
value: string
label: string
code?: string
}
const props = defineProps<{
id: string
modelValue?: string
items: Item[]
placeholder?: string
searchPlaceholder?: string
emptyMessage?: string
class?: string
disabled?: 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(() =>
selectedItem.value?.label || props.placeholder || '---pilih item',
)
// Create searchable items with combined code and label for better search
// Sort by:
// 1. Selected item first (highest priority)
// 2. Then by label alphabetically
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) => {
// Selected item always comes first
if (a.isSelected && !b.isSelected) return -1
if (!a.isSelected && b.isSelected) return 1
// If neither or both are selected, sort by label alphabetically
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.disabled"
variant="outline"
role="combobox"
:aria-expanded="open"
:aria-controls="`${props.id}-list`"
:aria-describedby="`${props.id}-search`"
:class="cn(
'w-full justify-between border-1 border-gray-400 bg-white hover:bg-gray-50 text-sm font-normal focus:outline-none focus:ring-1 focus:ring-black',
!modelValue && 'text-muted-foreground',
props.class,
)"
>
{{ displayText }}
<Icon name="i-lucide-chevrons-up-down" class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-full p-0">
<Command>
<CommandInput
:id="`${props.id}-search`"
class="h-9"
:placeholder="searchPlaceholder || 'Cari...'"
:aria-label="`Cari ${displayText}`"
/>
<CommandEmpty>{{ emptyMessage || 'Item tidak ditemukan.' }}</CommandEmpty>
<CommandList :id="`${props.id}-list`" role="listbox">
<CommandGroup>
<CommandItem v-for="item in searchableItems" :key="item.value" :value="item.searchValue" @select="onSelect(item)">
<div class="flex items-center justify-between w-full">
<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,56 @@
<script setup lang="ts">
// helpers
import { format, parseISO } from 'date-fns'
// components
import { Button } from '~/components/pub/ui/button'
import { Calendar } from '~/components/pub/ui/calendar'
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')
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') }}</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" />
</PopoverContent>
</Popover>
</div>
</template>
@@ -0,0 +1,43 @@
<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: 'default',
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 mb-3',
widthClass.value,
props.layout === 'stacked' ? 'flex flex-col p-2' : 'md:flex',
props.density !== 'dense' ? 'mb-2 md:mb-2.5 xl:mb-3' : '',
props.class,
])
</script>
<template>
<div :class="wrapperClass">
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { XErrors } from '~/types/error'
defineProps<{
id?: string
errors?: XErrors
}>()
</script>
<template>
<div class="grow">
<slot />
<div v-if="id && errors?.[id]" class="field-error-info">
{{ errors[id]?.message }}
</div>
</div>
</template>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
labelFor?: string
size?: 'default' | 'narrow' | 'wide'
height?: 'default' | 'compact'
position?: 'default' | 'dynamic'
stacked?: boolean
}>(),
{
size: 'default',
height: 'default',
position: 'default',
stacked: false,
},
)
const sizeMap = {
default: 'w-28 2xl:w-36',
narrow: 'w-24 2xl:w-28',
wide: 'w-44 2xl:w-48',
} 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-medium' : positionChildMap[props.position]])
</script>
<template>
<div :class="wrapperClass">
<label class="block" :class="labelClass" :for="labelFor">
<slot />
</label>
</div>
</template>
+82
View File
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { SelectRoot } from 'radix-vue'
import {
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '~/components/pub/ui/select'
import { cn } from '~/lib/utils'
interface Item {
value: string
label: string
code?: string
}
const props = defineProps<{
modelValue?: string
items: Item[]
placeholder?: string
label?: string
separator?: boolean
class?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// Sort items with selected item first, then alphabetically
const sortedItems = computed(() => {
const itemsWithSelection = props.items.map(item => ({
...item,
isSelected: item.value === props.modelValue,
}))
return itemsWithSelection.sort((a, b) => {
// Selected item always comes first
if (a.isSelected && !b.isSelected) return -1
if (!a.isSelected && b.isSelected) return 1
// If neither or both are selected, sort by label alphabetically
return a.label.localeCompare(b.label)
})
})
function onValueChange(value: string) {
emit('update:modelValue', value)
}
</script>
<template>
<SelectRoot :model-value="modelValue" @update:model-value="onValueChange">
<SelectTrigger :class="cn('w-full focus:outline-none focus:ring-1 focus:ring-black bg-white', props.class)">
<SelectValue :placeholder="placeholder || 'Pilih item'" :class="cn(
props.modelValue ? 'text-black' : 'text-muted-foreground',
)" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel v-if="label">
{{ label }}
</SelectLabel>
<SelectItem v-for="item in sortedItems" :key="item.value" :value="item.value" class="cursor-pointer">
<div class="flex items-center justify-between w-full">
<span>{{ item.label }}</span>
<span v-if="item.code" class="text-xs text-muted-foreground ml-2">
{{ item.code }}
</span>
</div>
</SelectItem>
<SelectSeparator v-if="separator" />
</SelectGroup>
</SelectContent>
</SelectRoot>
</template>
+54
View File
@@ -0,0 +1,54 @@
<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>{{ props.title }}</DialogTitle>
<DialogDescription v-if="props.description">{{ props.description }}</DialogDescription>
</DialogHeader>
<slot />
</DialogContent>
</Dialog>
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { Dialog } from '~/components/pub/ui/dialog'
interface DialogProps {
title: string
description?: string
preventOutside?: boolean
open?: boolean
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
}
const props = withDefaults(defineProps<DialogProps>(), {
preventOutside: false,
open: false,
size: 'md',
})
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
const sizeClass = computed(() => {
const sizeMap = {
sm: 'sm:max-w-[350px]',
md: 'sm:max-w-[425px]',
lg: 'sm:max-w-[600px]',
xl: 'sm:max-w-[800px]',
full: 'sm:max-w-[95vw]',
}
return sizeMap[props.size]
})
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value),
})
</script>
<template>
<Dialog v-model:open="isOpen">
<DialogContent
:class="sizeClass"
@interact-outside="(e: any) => preventOutside && e.preventDefault()"
@pointer-down-outside="(e: any) => preventOutside && e.preventDefault()"
>
<DialogHeader>
<DialogTitle>{{ props.title }}</DialogTitle>
<DialogDescription v-if="props.description">{{ props.description }}</DialogDescription>
</DialogHeader>
<slot />
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,32 @@
<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" />
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' | '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" />
Edit
</Button>
<Button class="bg-red-500" type="button" @click="onClick('submit')">
<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' | '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" />
Edit
</Button>
<Button class="bg-red-500" type="button" @click="onClick('submit')">
<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' | '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" />
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,24 @@
<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 align-middle" />
Edit
</Button>
</div>
</template>
@@ -0,0 +1,24 @@
<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,50 @@
<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 justify-between">
<div class="flex items-center">
<div class="md:text-base xl:text-lg font-semibold 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">
<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="flex items-center ms-2">
<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">{{ 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 {
@apply w-full;
}
</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,69 @@
<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)
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'
})
</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..." />
<CommandEmpty>Item tidak ditemukan.</CommandEmpty>
<CommandList class="max-h-[300px] overflow-x-auto overflow-y-auto">
<CommandGroup>
<TreeView
:data="data"
: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'
}