feat: update sidebar and header layout; fix dnd-kit-vue import error
- Fixed import error for `dnd-kit-vue` by installing the package. - Moved user navigation from sidebar to header, adding a dropdown for account options. - Added homepage and notification icons to the header. - Implemented sticky header functionality during scroll. - Grouped sidebar menu items under a "Master" label for better organization. - Updated relevant components and package.json to include new dependencies.
This commit is contained in:
parent
f9172d1431
commit
a71be47b58
47
AGENTS.md
Normal file
47
AGENTS.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Aturan Utama
|
||||||
|
|
||||||
|
### Dokumentasi Perubahan
|
||||||
|
|
||||||
|
**WAJIB** catat setiap perubahan yang dilakukan ke dalam folder `docs/history/` setelah selesai mengerjakan suatu fitur/bugfix/refactor.
|
||||||
|
|
||||||
|
#### Format Penulisan
|
||||||
|
|
||||||
|
Buat file markdown dengan nama format: `YYYY-MM-DD-singkatan-perubahan.md`
|
||||||
|
|
||||||
|
Contoh: `2026-07-24-sidebar-icon-mode.md`
|
||||||
|
|
||||||
|
#### Struktur Dokumen
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Judul Perubahan
|
||||||
|
|
||||||
|
**Tanggal:** YYYY-MM-DD
|
||||||
|
**Status:** Selesai
|
||||||
|
|
||||||
|
## Tujuan
|
||||||
|
Deskripsi singkat tujuan perubahan.
|
||||||
|
|
||||||
|
## Yang Dikerjakan
|
||||||
|
|
||||||
|
### 1. Judul Sub Perubahan
|
||||||
|
- Detail perubahan yang dilakukan
|
||||||
|
- Sebelum → Sesudah (jika ada perubahan value)
|
||||||
|
|
||||||
|
## File yang Diubah
|
||||||
|
|
||||||
|
| File | Aksi | Detail |
|
||||||
|
|------|------|--------|
|
||||||
|
| `path/to/file` | Diubah/Dibuat/Dihapus | Penjelasan singkat |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
Catatan tambahan jika perlu.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Aturan Tambahan
|
||||||
|
|
||||||
|
- Selalu cantumkan file apa saja yang diubah, dibuat, atau dihapus
|
||||||
|
- Gunakan tabel untuk perubahan value (sebelum → sesudah)
|
||||||
|
- Cantumkan behavior/efek perubahan jika relevan
|
||||||
|
- Dokumen harus dalam Bahasa Indonesia
|
||||||
@ -1,4 +1,5 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||||
|
@import "vue-sonner/style.css";
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
|||||||
59
app/components/DeleteDialog.vue
Normal file
59
app/components/DeleteDialog.vue
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Loader2 } from '@lucide/vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
itemName?: string
|
||||||
|
loading?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:open': [value: boolean]
|
||||||
|
confirm: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => props.title ?? 'Hapus Item')
|
||||||
|
const dialogDescription = computed(() => {
|
||||||
|
if (props.description) return props.description
|
||||||
|
if (props.itemName) {
|
||||||
|
return `Apakah Anda yakin ingin menghapus "${props.itemName}"? Tindakan ini tidak dapat dibatalkan.`
|
||||||
|
}
|
||||||
|
return 'Apakah Anda yakin ingin menghapus item ini? Tindakan ini tidak dapat dibatalkan.'
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
emit('update:open', false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="open" @update:open="emit('update:open', $event)">
|
||||||
|
<DialogContent class="sm:max-w-[400px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ dialogTitle }}</DialogTitle>
|
||||||
|
<DialogDescription>{{ dialogDescription }}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose as-child>
|
||||||
|
<Button variant="outline" :disabled="loading">Batal</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button variant="destructive" :disabled="loading" @click="emit('confirm')">
|
||||||
|
<Loader2 v-if="loading" class="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
Hapus
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
38
app/components/ui/switch/Switch.vue
Normal file
38
app/components/ui/switch/Switch.vue
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SwitchRootEmits, SwitchRootProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import {
|
||||||
|
SwitchRoot,
|
||||||
|
SwitchThumb,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const emits = defineEmits<SwitchRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SwitchRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="switch"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn(
|
||||||
|
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<SwitchThumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
:class="cn('bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0')"
|
||||||
|
>
|
||||||
|
<slot name="thumb" v-bind="slotProps" />
|
||||||
|
</SwitchThumb>
|
||||||
|
</SwitchRoot>
|
||||||
|
</template>
|
||||||
1
app/components/ui/switch/index.ts
Normal file
1
app/components/ui/switch/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as Switch } from "./Switch.vue"
|
||||||
@ -3,6 +3,7 @@ import { ArrowUpDown, Pencil, Trash2 } from '@lucide/vue'
|
|||||||
import { h } from 'vue'
|
import { h } from 'vue'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@ -37,82 +38,105 @@ const booleanFilter: FilterFn<BusinessType> = (row, columnId, filterValue) => {
|
|||||||
return cellValue === filterBool
|
return cellValue === filterBool
|
||||||
}
|
}
|
||||||
|
|
||||||
export const columns: ColumnDef<BusinessType>[] = [
|
export function getColumns(callbacks: {
|
||||||
{
|
onEdit: (businessType: BusinessType) => void
|
||||||
accessorKey: 'code',
|
onDelete: (businessType: BusinessType) => void
|
||||||
header: ({ column }) => {
|
onToggleStatus: (businessType: BusinessType, value: boolean) => void
|
||||||
return h(Button, {
|
}): ColumnDef<BusinessType>[] {
|
||||||
variant: 'ghost',
|
return [
|
||||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
{
|
||||||
}, () => ['Kode', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })])
|
accessorKey: 'code',
|
||||||
|
header: ({ column }) => {
|
||||||
|
return h(Button, {
|
||||||
|
variant: 'ghost',
|
||||||
|
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||||
|
}, () => ['Kode', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })])
|
||||||
|
},
|
||||||
|
cell: ({ row }) => h('div', { class: 'font-medium' }, row.getValue('code')),
|
||||||
},
|
},
|
||||||
cell: ({ row }) => h('div', { class: 'font-medium' }, row.getValue('code')),
|
{
|
||||||
},
|
id: 'name_search',
|
||||||
{
|
accessorFn: row => `${row.name} ${row.code}`,
|
||||||
id: 'name_search',
|
header: ({ column }) => {
|
||||||
accessorFn: row => `${row.name} ${row.code}`,
|
return h(Button, {
|
||||||
header: ({ column }) => {
|
variant: 'ghost',
|
||||||
return h(Button, {
|
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||||
variant: 'ghost',
|
}, () => ['Nama', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })])
|
||||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
},
|
||||||
}, () => ['Nama', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })])
|
cell: ({ row }) => h('div', {}, row.original.name),
|
||||||
|
filterFn: nameAndCodeFilter,
|
||||||
|
enableHiding: false,
|
||||||
},
|
},
|
||||||
cell: ({ row }) => h('div', {}, row.original.name),
|
{
|
||||||
filterFn: nameAndCodeFilter,
|
accessorKey: 'description',
|
||||||
enableHiding: false,
|
header: 'Deskripsi',
|
||||||
},
|
cell: ({ row }) => {
|
||||||
{
|
const desc = row.getValue('description') as string | null
|
||||||
accessorKey: 'description',
|
return h('div', { class: 'text-muted-foreground truncate max-w-[300px]' }, desc ?? '-')
|
||||||
header: 'Deskripsi',
|
},
|
||||||
cell: ({ row }) => {
|
|
||||||
const desc = row.getValue('description') as string | null
|
|
||||||
return h('div', { class: 'text-muted-foreground truncate max-w-[300px]' }, desc ?? '-')
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
accessorKey: 'is_active',
|
||||||
accessorKey: 'is_active',
|
header: 'Status',
|
||||||
header: 'Status',
|
filterFn: booleanFilter,
|
||||||
filterFn: booleanFilter,
|
cell: ({ row }) => {
|
||||||
cell: ({ row }) => {
|
const raw = row.getValue('is_active')
|
||||||
const raw = row.getValue('is_active')
|
const isActive = raw === true || raw === 1 || raw === '1'
|
||||||
const isActive = raw === true || raw === 1 || raw === '1'
|
const businessType = row.original
|
||||||
return h(Badge, {
|
return h('div', { class: 'flex items-center gap-2' }, [
|
||||||
variant: 'outline',
|
h(Switch, {
|
||||||
class: isActive
|
modelValue: isActive,
|
||||||
? 'border-emerald-500 text-emerald-600 bg-emerald-50'
|
'onUpdate:modelValue': (val: boolean) => callbacks.onToggleStatus(businessType, val),
|
||||||
: 'border-red-500 text-red-600 bg-red-50',
|
}),
|
||||||
}, () => isActive ? 'Aktif' : 'Nonaktif')
|
h(Badge, {
|
||||||
},
|
variant: 'outline',
|
||||||
},
|
class: isActive
|
||||||
{
|
? 'border-emerald-500 text-emerald-600 bg-emerald-50'
|
||||||
id: 'actions',
|
: 'border-red-500 text-red-600 bg-red-50',
|
||||||
header: ' ',
|
}, () => isActive ? 'Aktif' : 'Nonaktif'),
|
||||||
size: 0,
|
|
||||||
minSize: 0,
|
|
||||||
enableHiding: false,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const businessType = row.original
|
|
||||||
|
|
||||||
return h(TooltipProvider, null, () =>
|
|
||||||
h('div', { class: 'flex items-center gap-1' }, [
|
|
||||||
h(Tooltip, null, () => [
|
|
||||||
h(TooltipTrigger, { asChild: true }, () =>
|
|
||||||
h(Button, { variant: 'ghost', size: 'icon', class: 'h-8 w-8 text-yellow-500' }, () =>
|
|
||||||
h(Pencil, { class: 'h-4 w-4' })
|
|
||||||
)
|
|
||||||
),
|
|
||||||
h(TooltipContent, null, () => 'Edit'),
|
|
||||||
]),
|
|
||||||
h(Tooltip, null, () => [
|
|
||||||
h(TooltipTrigger, { asChild: true }, () =>
|
|
||||||
h(Button, { variant: 'ghost', size: 'icon', class: 'h-8 w-8 text-destructive' }, () =>
|
|
||||||
h(Trash2, { class: 'h-4 w-4' })
|
|
||||||
)
|
|
||||||
),
|
|
||||||
h(TooltipContent, null, () => 'Hapus'),
|
|
||||||
]),
|
|
||||||
])
|
])
|
||||||
)
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
]
|
id: 'actions',
|
||||||
|
header: ' ',
|
||||||
|
size: 0,
|
||||||
|
minSize: 0,
|
||||||
|
enableHiding: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const businessType = row.original
|
||||||
|
|
||||||
|
return h(TooltipProvider, null, () =>
|
||||||
|
h('div', { class: 'flex items-center gap-1' }, [
|
||||||
|
h(Tooltip, null, () => [
|
||||||
|
h(TooltipTrigger, { asChild: true }, () =>
|
||||||
|
h(Button, {
|
||||||
|
variant: 'ghost',
|
||||||
|
size: 'icon',
|
||||||
|
class: 'h-8 w-8 text-yellow-500',
|
||||||
|
onClick: () => callbacks.onEdit(businessType),
|
||||||
|
}, () =>
|
||||||
|
h(Pencil, { class: 'h-4 w-4' })
|
||||||
|
)
|
||||||
|
),
|
||||||
|
h(TooltipContent, null, () => 'Edit'),
|
||||||
|
]),
|
||||||
|
h(Tooltip, null, () => [
|
||||||
|
h(TooltipTrigger, { asChild: true }, () =>
|
||||||
|
h(Button, {
|
||||||
|
variant: 'ghost',
|
||||||
|
size: 'icon',
|
||||||
|
class: 'h-8 w-8 text-destructive',
|
||||||
|
onClick: () => callbacks.onDelete(businessType),
|
||||||
|
}, () =>
|
||||||
|
h(Trash2, { class: 'h-4 w-4' })
|
||||||
|
)
|
||||||
|
),
|
||||||
|
h(TooltipContent, null, () => 'Hapus'),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@ -3,11 +3,20 @@ import AppSidebar from '@/components/AppSidebar.vue'
|
|||||||
import SiteHeader from '@/components/SiteHeader.vue'
|
import SiteHeader from '@/components/SiteHeader.vue'
|
||||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
|
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
|
||||||
|
|
||||||
import { ChevronDown, X } from '@lucide/vue'
|
import { ChevronDown, Loader2, Plus, X } from '@lucide/vue'
|
||||||
import { ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
import DataTable from '@/components/data-table/DataTable.vue'
|
import DataTable from '@/components/data-table/DataTable.vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@ -16,6 +25,11 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
} from '@/components/ui/field'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@ -24,13 +38,142 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { columns } from './columns'
|
import { type BusinessType, getColumns } from './columns'
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const { data: businessTypes, status } = useFetch<BusinessType[]>(`${config.public.apiBase}/business-types`)
|
|
||||||
|
const { data: businessTypes, status, refresh } = useFetch<BusinessType[]>(`${config.public.apiBase}/business-types`)
|
||||||
|
|
||||||
const tableRef = ref<InstanceType<typeof DataTable>>()
|
const tableRef = ref<InstanceType<typeof DataTable>>()
|
||||||
|
|
||||||
|
// --- Dialog states ---
|
||||||
|
const formOpen = ref(false)
|
||||||
|
const deleteOpen = ref(false)
|
||||||
|
|
||||||
|
// --- Form state ---
|
||||||
|
type FormMode = 'create' | 'edit'
|
||||||
|
|
||||||
|
const formMode = ref<FormMode>('create')
|
||||||
|
const selectedBusinessType = ref<BusinessType | null>(null)
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = ref<FormData>({ code: '', name: '', description: '' })
|
||||||
|
|
||||||
|
// --- Loading states ---
|
||||||
|
const formLoading = ref(false)
|
||||||
|
const deleteLoading = ref(false)
|
||||||
|
|
||||||
|
// --- Computed ---
|
||||||
|
const formTitle = computed(() => formMode.value === 'create' ? 'Tambah Jenis Bisnis' : 'Edit Jenis Bisnis')
|
||||||
|
|
||||||
|
// --- Table callbacks ---
|
||||||
|
function openCreateForm() {
|
||||||
|
formMode.value = 'create'
|
||||||
|
selectedBusinessType.value = null
|
||||||
|
form.value = { code: '', name: '', description: '' }
|
||||||
|
formOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(bt: BusinessType) {
|
||||||
|
formMode.value = 'edit'
|
||||||
|
selectedBusinessType.value = bt
|
||||||
|
form.value = {
|
||||||
|
code: bt.code,
|
||||||
|
name: bt.name,
|
||||||
|
description: bt.description ?? '',
|
||||||
|
}
|
||||||
|
formOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(bt: BusinessType) {
|
||||||
|
selectedBusinessType.value = bt
|
||||||
|
deleteOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleStatus(bt: BusinessType, value: boolean) {
|
||||||
|
try {
|
||||||
|
await $fetch(`${config.public.apiBase}/business-types/${bt.id}/active`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { is_active: value },
|
||||||
|
})
|
||||||
|
if (businessTypes.value) {
|
||||||
|
businessTypes.value = businessTypes.value.map(b =>
|
||||||
|
b.id === bt.id ? { ...b, is_active: value } : b
|
||||||
|
)
|
||||||
|
}
|
||||||
|
toast.success(`Jenis bisnis berhasil di${value ? 'aktifkan' : 'nonaktifkan'}`)
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.data?.message ?? 'Gagal mengubah status')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = computed(() => getColumns({ onEdit: handleEdit, onDelete: handleDelete, onToggleStatus: handleToggleStatus }))
|
||||||
|
|
||||||
|
// --- CRUD operations ---
|
||||||
|
async function handleFormSubmit() {
|
||||||
|
if (!form.value.code.trim() || !form.value.name.trim()) {
|
||||||
|
toast.error('Kode dan nama wajib diisi')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
formLoading.value = true
|
||||||
|
try {
|
||||||
|
if (formMode.value === 'create') {
|
||||||
|
await $fetch(`${config.public.apiBase}/business-types`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
code: form.value.code.trim(),
|
||||||
|
name: form.value.name.trim(),
|
||||||
|
description: form.value.description.trim() || null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
toast.success('Jenis bisnis berhasil ditambahkan')
|
||||||
|
} else {
|
||||||
|
await $fetch(`${config.public.apiBase}/business-types/${selectedBusinessType.value!.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
code: form.value.code.trim(),
|
||||||
|
name: form.value.name.trim(),
|
||||||
|
description: form.value.description.trim() || null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
toast.success('Jenis bisnis berhasil diperbarui')
|
||||||
|
}
|
||||||
|
|
||||||
|
formOpen.value = false
|
||||||
|
await refresh()
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.data?.message ?? 'Gagal menyimpan jenis bisnis')
|
||||||
|
} finally {
|
||||||
|
formLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmDelete() {
|
||||||
|
if (!selectedBusinessType.value) return
|
||||||
|
|
||||||
|
deleteLoading.value = true
|
||||||
|
try {
|
||||||
|
await $fetch(`${config.public.apiBase}/business-types/${selectedBusinessType.value.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
toast.success('Jenis bisnis berhasil dihapus')
|
||||||
|
deleteOpen.value = false
|
||||||
|
selectedBusinessType.value = null
|
||||||
|
await refresh()
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.data?.message ?? 'Gagal menghapus jenis bisnis')
|
||||||
|
} finally {
|
||||||
|
deleteLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Filters ---
|
||||||
function getStatusFilter(table: any) {
|
function getStatusFilter(table: any) {
|
||||||
const val = table.getColumn('is_active')?.getFilterValue()
|
const val = table.getColumn('is_active')?.getFilterValue()
|
||||||
if (val === true || val === 'true' || val === 1) return 'true'
|
if (val === true || val === 'true' || val === 1) return 'true'
|
||||||
@ -48,6 +191,18 @@ function resetFilters(table: any) {
|
|||||||
table.getColumn('name_search')?.setFilterValue('')
|
table.getColumn('name_search')?.setFilterValue('')
|
||||||
table.getColumn('is_active')?.setFilterValue('')
|
table.getColumn('is_active')?.setFilterValue('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Reset on dialog close ---
|
||||||
|
watch(formOpen, (val) => {
|
||||||
|
if (!val) {
|
||||||
|
form.value = { code: '', name: '', description: '' }
|
||||||
|
selectedBusinessType.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(deleteOpen, (val) => {
|
||||||
|
if (!val) selectedBusinessType.value = null
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -62,7 +217,13 @@ function resetFilters(table: any) {
|
|||||||
<div class="@container/main flex flex-1 flex-col gap-2">
|
<div class="@container/main flex flex-1 flex-col gap-2">
|
||||||
<div class="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
<div class="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||||
<div class="px-4 lg:px-6">
|
<div class="px-4 lg:px-6">
|
||||||
<h1 class="text-2xl font-bold">Jenis Bisnis</h1>
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold">Jenis Bisnis</h1>
|
||||||
|
<Button size="sm" @click="openCreateForm">
|
||||||
|
<Plus class="h-4 w-4 mr-1" />
|
||||||
|
Tambah
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-4 lg:px-6">
|
<div class="px-4 lg:px-6">
|
||||||
<div v-if="status === 'pending'" class="flex items-center justify-center h-24">
|
<div v-if="status === 'pending'" class="flex items-center justify-center h-24">
|
||||||
@ -126,5 +287,45 @@ function resetFilters(table: any) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
|
|
||||||
|
<!-- ==================== FORM DIALOG (CREATE / EDIT) ==================== -->
|
||||||
|
<Dialog v-model:open="formOpen">
|
||||||
|
<DialogContent class="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ formTitle }}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form id="businessTypeForm" @submit.prevent="handleFormSubmit">
|
||||||
|
<FieldGroup class="gap-4">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="form-code">Kode <span class="text-red-500">*</span></FieldLabel>
|
||||||
|
<Input id="form-code" v-model="form.code" placeholder="Masukkan kode" :disabled="formLoading" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="form-name">Nama <span class="text-red-500">*</span></FieldLabel>
|
||||||
|
<Input id="form-name" v-model="form.name" placeholder="Masukkan nama" :disabled="formLoading" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="form-description">Deskripsi</FieldLabel>
|
||||||
|
<textarea id="form-description" v-model="form.description" placeholder="Masukkan deskripsi (opsional)"
|
||||||
|
:disabled="formLoading" rows="3"
|
||||||
|
class="flex w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground md:text-sm focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50" />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose as-child>
|
||||||
|
<Button variant="outline" :disabled="formLoading">Batal</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button type="submit" form="businessTypeForm" :disabled="formLoading">
|
||||||
|
<Loader2 v-if="formLoading" class="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
Simpan
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- ==================== DELETE DIALOG ==================== -->
|
||||||
|
<DeleteDialog v-model:open="deleteOpen" title="Hapus Jenis Bisnis" :item-name="selectedBusinessType?.name"
|
||||||
|
:loading="deleteLoading" @confirm="handleConfirmDelete" />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
67
docs/history/2026-07-24-crud-business-type-dialog.md
Normal file
67
docs/history/2026-07-24-crud-business-type-dialog.md
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# CRUD Business Type dengan Dialog Modal
|
||||||
|
|
||||||
|
**Tanggal:** 2026-07-24
|
||||||
|
**Status:** Selesai
|
||||||
|
|
||||||
|
## Tujuan
|
||||||
|
Membangun fitur CRUD lengkap untuk Jenis Bisnis (Business Type) menggunakan dialog modal shadcn-vue, termasuk toggle status inline, dan endpoint API terpisah untuk toggle status.
|
||||||
|
|
||||||
|
## Yang Dikerjakan
|
||||||
|
|
||||||
|
### 1. CRUD dengan Dialog Modal
|
||||||
|
- Membuat form dialog gabungan untuk Create dan Edit (1 dialog, 2 mode)
|
||||||
|
- Membuat komponen `DeleteDialog` reusable untuk konfirmasi hapus
|
||||||
|
- Form fields: Kode (input), Nama (input), Deskripsi (textarea)
|
||||||
|
- Field Status dihapus dari form edit, diganti dengan toggle inline di tabel
|
||||||
|
|
||||||
|
### 2. Toggle Status Inline
|
||||||
|
- Menambahkan komponen `Switch` di samping Badge status pada tabel
|
||||||
|
- Klik toggle langsung memanggil endpoint PATCH tanpa buka modal
|
||||||
|
- Optimistic update pada data lokal tanpa refresh seluruh tabel (menggunakan `.map()` untuk trigger reactivity)
|
||||||
|
|
||||||
|
### 3. Switch Component
|
||||||
|
- Install komponen `switch` dari shadcn-vue
|
||||||
|
- Menggunakan `modelValue`/`@update:modelValue` (bukan `checked`) karena reka-ui SwitchRoot
|
||||||
|
|
||||||
|
### 4. Sonner Toast
|
||||||
|
- Menambahkan import CSS `vue-sonner/style.css` di `tailwind.css` agar toast muncul
|
||||||
|
|
||||||
|
### 5. Endpoint API Toggle Status
|
||||||
|
- Menambahkan schema `BusinessTypeToggleStatus` (hanya field `is_active: bool`)
|
||||||
|
- Menambahkan service `toggle_business_type_status()`
|
||||||
|
- Menambahkan endpoint `PATCH /v1/business-types/{id}/active`
|
||||||
|
- Frontend diupdate dari `PUT` ke `PATCH .../active`
|
||||||
|
|
||||||
|
### 6. columns.ts Refactor
|
||||||
|
- Export diubah dari konstanta ke fungsi `getColumns(callbacks)` yang menerima `onEdit`, `onDelete`, `onToggleStatus`
|
||||||
|
- Tombol aksi (Pencil, Trash2) sekarang memanggil callback dengan data row
|
||||||
|
|
||||||
|
## File yang Diubah
|
||||||
|
|
||||||
|
| File | Aksi | Detail |
|
||||||
|
|------|------|--------|
|
||||||
|
| `web2/app/pages/admin/business-types/columns.ts` | Diubah | Export ke fungsi `getColumns()`, tambah Switch di status column, tambah callback `onToggleStatus` |
|
||||||
|
| `web2/app/pages/admin/business-types/index.vue` | Diubah | CRUD dialog (create/edit gabung), toggle status inline, hapus field status dari form edit |
|
||||||
|
| `web2/app/components/DeleteDialog.vue` | Dibuat | Komponen reusable delete confirmation dialog |
|
||||||
|
| `web2/app/components/ui/switch/Switch.vue` | Dibuat | Komponen switch dari shadcn-vue |
|
||||||
|
| `web2/app/components/ui/switch/index.ts` | Dibuat | Export switch component |
|
||||||
|
| `web2/app/assets/css/tailwind.css` | Diubah | Tambah `@import "vue-sonner/style.css"` |
|
||||||
|
| `api/app/schemas/business_type.py` | Diubah | Tambah `BusinessTypeToggleStatus` schema |
|
||||||
|
| `api/app/services/business_type_service.py` | Diubah | Tambah `toggle_business_type_status()` function |
|
||||||
|
| `api/app/routers/business_types.py` | Diubah | Tambah `PATCH /{id}/active` endpoint |
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Fungsi |
|
||||||
|
|--------|----------|--------|
|
||||||
|
| `GET` | `/v1/business-types/` | List (opsional `?is_active=`) |
|
||||||
|
| `GET` | `/v1/business-types/{id}` | Get by ID |
|
||||||
|
| `POST` | `/v1/business-types/` | Create |
|
||||||
|
| `PUT` | `/v1/business-types/{id}` | Update (code, name, description) |
|
||||||
|
| `PATCH` | `/v1/business-types/{id}/active` | Toggle status (is_active) |
|
||||||
|
| `DELETE` | `/v1/business-types/{id}` | Soft delete |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- `DeleteDialog` dipindah dari `components/dialog/DeleteDialog.vue` ke `components/DeleteDialog.vue` karena Nuxt auto-import dari root `components/`
|
||||||
|
- Switch reka-ui menggunakan `modelValue` bukan `checked` saat di-render via `h()` function
|
||||||
|
- Toggle status menggunakan optimistic update (`.map()` + spread) supaya switch langsung bergerak tanpa re-render tabel
|
||||||
Loading…
Reference in New Issue
Block a user