dev: hotfix refactor
+ merged pub/custom-ui and pub/base into my-ui - droped pub/custom-ui
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,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="`m-3 mb-5 flex-wrap md:flex ${classValExt || ''}`">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user