From a71be47b585156cdd7d62b16b3023c3f85ebfdfb Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Fri, 24 Jul 2026 11:47:46 +0700 Subject: [PATCH] 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. --- AGENTS.md | 47 ++++ app/assets/css/tailwind.css | 1 + app/components/DeleteDialog.vue | 59 +++++ app/components/ui/switch/Switch.vue | 38 ++++ app/components/ui/switch/index.ts | 1 + app/pages/admin/business-types/columns.ts | 172 ++++++++------ app/pages/admin/business-types/index.vue | 211 +++++++++++++++++- .../2026-07-24-crud-business-type-dialog.md | 67 ++++++ 8 files changed, 517 insertions(+), 79 deletions(-) create mode 100644 AGENTS.md create mode 100644 app/components/DeleteDialog.vue create mode 100644 app/components/ui/switch/Switch.vue create mode 100644 app/components/ui/switch/index.ts create mode 100644 docs/history/2026-07-24-crud-business-type-dialog.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..43cbe49 --- /dev/null +++ b/AGENTS.md @@ -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 diff --git a/app/assets/css/tailwind.css b/app/assets/css/tailwind.css index f72234e..5a1e24d 100644 --- a/app/assets/css/tailwind.css +++ b/app/assets/css/tailwind.css @@ -1,4 +1,5 @@ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); +@import "vue-sonner/style.css"; @import "tailwindcss"; @import "tw-animate-css"; diff --git a/app/components/DeleteDialog.vue b/app/components/DeleteDialog.vue new file mode 100644 index 0000000..80201e8 --- /dev/null +++ b/app/components/DeleteDialog.vue @@ -0,0 +1,59 @@ + + + diff --git a/app/components/ui/switch/Switch.vue b/app/components/ui/switch/Switch.vue new file mode 100644 index 0000000..96bf705 --- /dev/null +++ b/app/components/ui/switch/Switch.vue @@ -0,0 +1,38 @@ + + + diff --git a/app/components/ui/switch/index.ts b/app/components/ui/switch/index.ts new file mode 100644 index 0000000..cc081f3 --- /dev/null +++ b/app/components/ui/switch/index.ts @@ -0,0 +1 @@ +export { default as Switch } from "./Switch.vue" diff --git a/app/pages/admin/business-types/columns.ts b/app/pages/admin/business-types/columns.ts index 6a5dfd9..718bc3c 100644 --- a/app/pages/admin/business-types/columns.ts +++ b/app/pages/admin/business-types/columns.ts @@ -3,6 +3,7 @@ import { ArrowUpDown, Pencil, Trash2 } from '@lucide/vue' import { h } from 'vue' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { Switch } from '@/components/ui/switch' import { Tooltip, TooltipContent, @@ -37,82 +38,105 @@ const booleanFilter: FilterFn = (row, columnId, filterValue) => { return cellValue === filterBool } -export const columns: ColumnDef[] = [ - { - 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' })]) +export function getColumns(callbacks: { + onEdit: (businessType: BusinessType) => void + onDelete: (businessType: BusinessType) => void + onToggleStatus: (businessType: BusinessType, value: boolean) => void +}): ColumnDef[] { + return [ + { + 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}`, - header: ({ column }) => { - return h(Button, { - variant: 'ghost', - onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'), - }, () => ['Nama', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })]) + { + id: 'name_search', + accessorFn: row => `${row.name} ${row.code}`, + header: ({ column }) => { + return h(Button, { + variant: 'ghost', + 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, - enableHiding: false, - }, - { - accessorKey: 'description', - 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: 'description', + 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', - header: 'Status', - filterFn: booleanFilter, - cell: ({ row }) => { - const raw = row.getValue('is_active') - const isActive = raw === true || raw === 1 || raw === '1' - return h(Badge, { - variant: 'outline', - class: isActive - ? 'border-emerald-500 text-emerald-600 bg-emerald-50' - : 'border-red-500 text-red-600 bg-red-50', - }, () => isActive ? 'Aktif' : 'Nonaktif') - }, - }, - { - 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' }, () => - 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'), - ]), + { + accessorKey: 'is_active', + header: 'Status', + filterFn: booleanFilter, + cell: ({ row }) => { + const raw = row.getValue('is_active') + const isActive = raw === true || raw === 1 || raw === '1' + const businessType = row.original + return h('div', { class: 'flex items-center gap-2' }, [ + h(Switch, { + modelValue: isActive, + 'onUpdate:modelValue': (val: boolean) => callbacks.onToggleStatus(businessType, val), + }), + h(Badge, { + variant: 'outline', + class: isActive + ? 'border-emerald-500 text-emerald-600 bg-emerald-50' + : 'border-red-500 text-red-600 bg-red-50', + }, () => isActive ? 'Aktif' : 'Nonaktif'), ]) - ) + }, }, - }, -] + { + 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'), + ]), + ]) + ) + }, + }, + ] +} diff --git a/app/pages/admin/business-types/index.vue b/app/pages/admin/business-types/index.vue index 2f1fc21..42ed875 100644 --- a/app/pages/admin/business-types/index.vue +++ b/app/pages/admin/business-types/index.vue @@ -3,11 +3,20 @@ import AppSidebar from '@/components/AppSidebar.vue' import SiteHeader from '@/components/SiteHeader.vue' import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar' -import { ChevronDown, X } from '@lucide/vue' -import { ref } from 'vue' +import { ChevronDown, Loader2, Plus, X } from '@lucide/vue' +import { computed, ref, watch } from 'vue' +import { toast } from 'vue-sonner' import DataTable from '@/components/data-table/DataTable.vue' import { Button } from '@/components/ui/button' +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' import { DropdownMenu, DropdownMenuContent, @@ -16,6 +25,11 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { + Field, + FieldGroup, + FieldLabel, +} from '@/components/ui/field' import { Input } from '@/components/ui/input' import { Select, @@ -24,13 +38,142 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { columns } from './columns' +import { type BusinessType, getColumns } from './columns' const config = useRuntimeConfig() -const { data: businessTypes, status } = useFetch(`${config.public.apiBase}/business-types`) + +const { data: businessTypes, status, refresh } = useFetch(`${config.public.apiBase}/business-types`) const tableRef = ref>() +// --- Dialog states --- +const formOpen = ref(false) +const deleteOpen = ref(false) + +// --- Form state --- +type FormMode = 'create' | 'edit' + +const formMode = ref('create') +const selectedBusinessType = ref(null) + +interface FormData { + code: string + name: string + description: string +} + +const form = ref({ 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) { const val = table.getColumn('is_active')?.getFilterValue() if (val === true || val === 'true' || val === 1) return 'true' @@ -48,6 +191,18 @@ function resetFilters(table: any) { table.getColumn('name_search')?.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 +})