feat(division): impl division list+entry
feat(form): add accessibility improvements to form components - Add labelFor prop to Label component for better form element association - Enhance Combobox with ARIA attributes for better screen reader support - Update form fields with proper IDs and label associations feat(pagination): adjust button width based on page number length Add dynamic button sizing for pagination items to accommodate different digit lengths (1-99, 100-999, 1000+). This improves visual consistency when displaying varying page numbers. feat(modal): add reusable dialog component and refactor division form - Create new Dialog.vue component with configurable size and outside click prevention - Replace inline dialog implementation in division list with new Dialog component - Fix formatting in entry-form.vue feat(data-table): add click handling for action cells Implement handleActionCellClick function to manage click events on action cells, triggering dropdown buttons when clicked outside interactive elements. Add cursor-pointer class and click handler to action cells for better UX. refactor(custom-ui): centralize action event strings in types Replace hardcoded action event strings with constants from types.ts to improve maintainability and reduce potential typos feat(confirmation): add reusable confirmation modal components - Implement base confirmation.vue component with customizable props - Create record-specific record-confirmation.vue for data operations - Add comprehensive README.md documentation for usage - Integrate confirmation flow in division list component refactor(components): move dialog component to base directory and update imports The dialog component was moved from custom-ui/modal to base/modal to better reflect its shared usage across the application. All import paths referencing the old location have been updated accordingly. refactor(select): reorganize imports and adjust conditional formatting - Reorder imports in Select.vue for better organization - Adjust logical operator formatting in SelectContent.vue for consistency
This commit is contained in:
@@ -54,19 +54,23 @@ function onCancelForm({ resetForm }: { resetForm: () => void }) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form v-slot="{ handleSubmit, resetForm }" as="" keep-values :validation-schema="formSchema"
|
||||
:initial-values="initialValues">
|
||||
<Form
|
||||
v-slot="{ handleSubmit, resetForm }" as="" keep-values :validation-schema="formSchema"
|
||||
:initial-values="initialValues"
|
||||
>
|
||||
<form id="entry-form" @submit="handleSubmit($event, (values) => onSubmitForm(values, { resetForm }))">
|
||||
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
|
||||
<div class="flex flex-col justify-between">
|
||||
<FieldGroup>
|
||||
<Label>Nama</Label>
|
||||
<Label label-for="name">Nama</Label>
|
||||
<Field id="name" :errors="errors">
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Masukkan nama divisi" autocomplete="organization"
|
||||
v-bind="componentField" />
|
||||
<Input
|
||||
id="name" type="text" placeholder="Masukkan nama divisi" autocomplete="off"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -75,12 +79,12 @@ function onCancelForm({ resetForm }: { resetForm: () => void }) {
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup>
|
||||
<Label>Kode</Label>
|
||||
<Label label-for="code">Kode</Label>
|
||||
<Field id="code" :errors="errors">
|
||||
<FormField v-slot="{ componentField }" name="code">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Masukkan kode divisi" autocomplete="off" v-bind="componentField" />
|
||||
<Input id="code" type="text" placeholder="Masukkan kode divisi" autocomplete="off" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -89,14 +93,16 @@ function onCancelForm({ resetForm }: { resetForm: () => void }) {
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup :column="2">
|
||||
<Label>Kelompok</Label>
|
||||
<Label label-for="parentId">Kelompok</Label>
|
||||
<Field id="parentId" :errors="errors">
|
||||
<FormField v-slot="{ componentField }" name="parentId">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Combobox id="parentId" v-bind="componentField" :items="props.division.items"
|
||||
<Combobox
|
||||
id="parentId" v-bind="componentField" :items="props.division.items"
|
||||
:placeholder="props.division.msg.placeholder" :search-placeholder="props.division.msg.search"
|
||||
:empty-message="props.division.msg.empty" />
|
||||
:empty-message="props.division.msg.empty"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -4,6 +4,9 @@ import type { HeaderPrep } from '~/components/pub/custom-ui/data/types'
|
||||
import type { PaginationMeta } from '~/components/pub/custom-ui/pagination/pagination.type'
|
||||
import { refDebounced, useUrlSearchParams } from '@vueuse/core'
|
||||
import AppDivisonEntryForm from '~/components/app/divison/entry-form.vue'
|
||||
import Dialog from '~/components/pub/base/modal/dialog.vue'
|
||||
import RecordConfirmation from '~/components/pub/custom-ui/confirmation/record-confirmation.vue'
|
||||
import { ActionEvents } from '~/components/pub/custom-ui/data/types'
|
||||
import Header from '~/components/pub/custom-ui/nav-header/header.vue'
|
||||
import { division as divisionConf, schema as schemaConf } from './entry'
|
||||
import { defaultQuery, querySchema } from './schema.query'
|
||||
@@ -13,7 +16,10 @@ const data = ref([])
|
||||
const isLoading = reactive<DataTableLoader>({
|
||||
isTableLoading: false,
|
||||
})
|
||||
const isDialogOpen = ref(false)
|
||||
|
||||
// Dialog state
|
||||
const isFormEntryDialogOpen = ref(false)
|
||||
const isRecordConfirmationOpen = ref(false)
|
||||
|
||||
// URL state management
|
||||
const queryParams = useUrlSearchParams('history', {
|
||||
@@ -67,7 +73,7 @@ const headerPrep: HeaderPrep = {
|
||||
label: 'Tambah Divisi',
|
||||
icon: 'i-lucide-send',
|
||||
onClick: () => {
|
||||
isDialogOpen.value = true
|
||||
isFormEntryDialogOpen.value = true
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -136,12 +142,38 @@ function handlePageChange(page: number) {
|
||||
queryParams.page = page
|
||||
}
|
||||
|
||||
async function handleDeleteRow(record: any) {
|
||||
try {
|
||||
// TODO : hit backend request untuk delete
|
||||
console.log('Deleting record:', record)
|
||||
|
||||
// Simulate API call
|
||||
// const response = await xfetch(`/api/v1/division/${record.id}`, {
|
||||
// method: 'DELETE'
|
||||
// })
|
||||
|
||||
// Refresh data setelah berhasil delete
|
||||
await getDivisionList()
|
||||
|
||||
// TODO: Show success message
|
||||
console.log('Record deleted successfully')
|
||||
} catch (error) {
|
||||
console.error('Error deleting record:', error)
|
||||
// TODO: Show error message
|
||||
} finally {
|
||||
// Reset record state
|
||||
recId.value = 0
|
||||
recAction.value = ''
|
||||
recItem.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// #endregion region
|
||||
|
||||
// #region Form event handlers
|
||||
|
||||
function onCancelForm(resetForm: () => void) {
|
||||
isDialogOpen.value = false
|
||||
isFormEntryDialogOpen.value = false
|
||||
setTimeout(() => {
|
||||
resetForm()
|
||||
}, 500)
|
||||
@@ -160,7 +192,7 @@ async function onSubmitForm(values: any, resetForm: () => void) {
|
||||
// })
|
||||
|
||||
// If successful, mark as success and close dialog
|
||||
isDialogOpen.value = false
|
||||
isFormEntryDialogOpen.value = false
|
||||
isSuccess = true
|
||||
|
||||
// Refresh data after successful submission
|
||||
@@ -208,34 +240,61 @@ watch(debouncedSearch, (newValue) => {
|
||||
queryParams.page = 1 // Reset to first page when searching
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for row actions
|
||||
watch(recId, () => {
|
||||
switch (recAction.value) {
|
||||
case ActionEvents.showEdit:
|
||||
// TODO: Handle edit action
|
||||
// isFormEntryDialogOpen.value = true
|
||||
break
|
||||
case ActionEvents.showConfirmDelete:
|
||||
// Trigger confirmation modal open
|
||||
isRecordConfirmationOpen.value = true
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
// Handle confirmation result
|
||||
function handleConfirmDelete(record: any, action: string) {
|
||||
console.log('Confirmed action:', action, 'for record:', record)
|
||||
handleDeleteRow(record)
|
||||
}
|
||||
|
||||
function handleCancelConfirmation() {
|
||||
// Reset record state when cancelled
|
||||
recId.value = 0
|
||||
recAction.value = ''
|
||||
recItem.value = null
|
||||
}
|
||||
// #endregion
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-md border p-4">
|
||||
<div class="rounded-md border p-4">
|
||||
<Header v-model="searchInput" :prep="headerPrep" @search="handleSearch" />
|
||||
<AppDivisonList :data="data" :pagination-meta="paginationMeta" @page-change="handlePageChange" />
|
||||
|
||||
<Dialog v-model:open="isDialogOpen">
|
||||
<DialogContent
|
||||
class="sm:max-w-[425px]"
|
||||
@interact-outside="(e) => e.preventDefault()"
|
||||
@pointer-down-outside="(e) => e.preventDefault()"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tambah Divisi</DialogTitle>
|
||||
<DialogDescription></DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<AppDivisonEntryForm
|
||||
:division="divisionConf"
|
||||
:schema="schemaConf"
|
||||
:initial-values="{ name: '', code: '', parentId: '' }"
|
||||
@submit="onSubmitForm"
|
||||
@cancel="onCancelForm"
|
||||
/>
|
||||
</DialogContent>
|
||||
<Dialog v-model:open="isFormEntryDialogOpen" title="Tambah Divisi" size="lg">
|
||||
<AppDivisonEntryForm
|
||||
:division="divisionConf" :schema="schemaConf"
|
||||
:initial-values="{ name: '', code: '', parentId: '' }" @submit="onSubmitForm" @cancel="onCancelForm"
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
<!-- Record Confirmation Modal -->
|
||||
<RecordConfirmation
|
||||
v-model:open="isRecordConfirmationOpen" action="delete" :record="recItem"
|
||||
@confirm="handleConfirmDelete" @cancel="handleCancelConfirmation"
|
||||
>
|
||||
<template #default="{ record }">
|
||||
<div class="text-sm">
|
||||
<p><strong>ID:</strong> {{ record?.id }}</p>
|
||||
<p v-if="record?.firstName"><strong>Nama:</strong> {{ record.firstName }}</p>
|
||||
<p v-if="record?.code"><strong>Kode:</strong> {{ record.cellphone }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</RecordConfirmation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -15,6 +15,21 @@ defineProps<{
|
||||
}>()
|
||||
|
||||
const loader = inject('table_data_loader') as DataTableLoader
|
||||
|
||||
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>
|
||||
@@ -50,11 +65,18 @@ v-for="(h, idx) in header[0]" :key="`head-${idx}`" class="border"
|
||||
</TableBody>
|
||||
<TableBody v-else>
|
||||
<TableRow v-for="(row, rowIndex) in rows" :key="`row-${rowIndex}`">
|
||||
<TableCell v-for="(key, cellIndex) in keys" :key="`cell-${rowIndex}-${cellIndex}`" class="border">
|
||||
<TableCell
|
||||
v-for="(key, cellIndex) in keys"
|
||||
:key="`cell-${rowIndex}-${cellIndex}`"
|
||||
class="border"
|
||||
:class="{ 'cursor-pointer': key === 'action' && funcComponent[key] }"
|
||||
@click="key === 'action' && funcComponent[key] ? handleActionCellClick($event, `cell-${rowIndex}-${cellIndex}`) : null"
|
||||
>
|
||||
<!-- If funcComponent has a renderer -->
|
||||
<component
|
||||
:is="funcComponent[key]?.(row, rowIndex).component"
|
||||
v-if="funcComponent[key]"
|
||||
:ref="key === 'action' ? `actionComponent-${rowIndex}-${cellIndex}` : undefined"
|
||||
:rec="row"
|
||||
:idx="rowIndex"
|
||||
v-bind="funcComponent[key]?.(row, rowIndex).props"
|
||||
|
||||
@@ -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,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/custom-ui/modal/dialog.vue`
|
||||
- `~/components/pub/ui/button`
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import Dialog from '~/components/pub/base/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/custom-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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { LinkItem, ListItemDto } from './types'
|
||||
import { ActionEvents } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
rec: ListItemDto
|
||||
@@ -11,13 +12,13 @@ const recItem = inject<Ref<any>>('rec_item')!
|
||||
|
||||
function detail() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = 'showDetail'
|
||||
recAction.value = ActionEvents.showDetail
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
function edit() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = 'showEdit'
|
||||
recAction.value = ActionEvents.showEdit
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { LinkItem, ListItemDto } from './types'
|
||||
import { ActionEvents } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
rec: ListItemDto
|
||||
@@ -11,19 +12,19 @@ const recItem = inject<Ref<any>>('rec_item')!
|
||||
|
||||
function detail() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = 'showDetail'
|
||||
recAction.value = ActionEvents.showDetail
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
function edit() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = 'showEdit'
|
||||
recAction.value = ActionEvents.showEdit
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
function del() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = 'showConfirmDel'
|
||||
recAction.value = ActionEvents.showConfirmDelete
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { LinkItem, ListItemDto } from './types'
|
||||
import { ActionEvents } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
rec: ListItemDto
|
||||
@@ -11,19 +12,19 @@ const recItem = inject<Ref<any>>('rec_item')!
|
||||
|
||||
function detail() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = 'showDetail'
|
||||
recAction.value = ActionEvents.showDetail
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
function edit() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = 'showEdit'
|
||||
recAction.value = ActionEvents.showEdit
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
function del() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = 'showConfirmDel'
|
||||
recAction.value = ActionEvents.showConfirmDelete
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { LinkItem, ListItemDto } from './types'
|
||||
import { ActionEvents } from './types'
|
||||
|
||||
interface Props {
|
||||
rec: ListItemDto
|
||||
@@ -16,13 +17,13 @@ const recItem = inject<Ref<any>>('rec_item')!
|
||||
|
||||
function edit() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = 'showEdit'
|
||||
recAction.value = ActionEvents.showEdit
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
function del() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = 'showConfirmDel'
|
||||
recAction.value = ActionEvents.showConfirmDelete
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
|
||||
@@ -100,3 +100,9 @@ export interface LinkItem {
|
||||
onClick?: (event: Event) => void
|
||||
headerStatus?: boolean
|
||||
}
|
||||
|
||||
export const ActionEvents = {
|
||||
showConfirmDelete: 'showConfirmDel',
|
||||
showEdit: 'showEdit',
|
||||
showDetail: 'showDetail',
|
||||
}
|
||||
|
||||
@@ -63,7 +63,12 @@ function onSelect(item: Item) {
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
:id="props.id"
|
||||
variant="outline" role="combobox" :aria-expanded="open" :class="cn(
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
:aria-controls="`${props.id}-list`"
|
||||
:aria-describedby="`${props.id}-search`"
|
||||
:class="cn(
|
||||
'w-full justify-between border-black bg-white hover:bg-gray-50 text-sm font-normal',
|
||||
!modelValue && 'text-muted-foreground',
|
||||
props.class,
|
||||
@@ -75,9 +80,14 @@ function onSelect(item: Item) {
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput class="h-9" :placeholder="searchPlaceholder || 'Cari...'" />
|
||||
<CommandInput
|
||||
:id="`${props.id}-search`"
|
||||
class="h-9"
|
||||
:placeholder="searchPlaceholder || 'Cari...'"
|
||||
:aria-label="`Cari ${displayText}`"
|
||||
/>
|
||||
<CommandEmpty>{{ emptyMessage || 'Item tidak ditemukan.' }}</CommandEmpty>
|
||||
<CommandList>
|
||||
<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">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
labelFor: string
|
||||
size?: 'default' | 'narrow' | 'wide'
|
||||
height?: 'default' | 'compact'
|
||||
position?: 'default' | 'dynamic'
|
||||
@@ -45,7 +46,7 @@ const labelClass = computed(() => positionChildMap[props.position])
|
||||
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<label :class="labelClass">
|
||||
<label :class="labelClass" :for="labelFor">
|
||||
<slot />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -71,6 +71,19 @@ const startRecord = computed(() => {
|
||||
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>
|
||||
@@ -97,7 +110,7 @@ v-slot="{ page }" :total="paginationMeta.recordCount" :sibling-count="1" :page="
|
||||
v-if="item.type === 'page'" :key="index" :value="item.value" as-child
|
||||
@click="handlePageChange(item.value)"
|
||||
>
|
||||
<Button class="w-9 h-9 p-0" :variant="item.value === page ? 'default' : 'outline'">
|
||||
<Button :class="getButtonClass(item.value)" :variant="item.value === page ? 'default' : 'outline'">
|
||||
{{ item.value }}
|
||||
</Button>
|
||||
</PaginationListItem>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { SelectRoot } from 'radix-vue'
|
||||
import type { SelectRootEmits, SelectRootProps } from 'radix-vue'
|
||||
import { SelectRoot, useForwardPropsEmits } from 'radix-vue'
|
||||
import {
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectValue,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '~/components/pub/ui/select'
|
||||
import type { SelectRootProps, SelectRootEmits } from 'radix-vue'
|
||||
import { useForwardPropsEmits } from 'radix-vue'
|
||||
|
||||
interface Item {
|
||||
value: string
|
||||
|
||||
@@ -32,8 +32,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
position === 'popper'
|
||||
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
|
||||
Reference in New Issue
Block a user