feat: add Plans page with CRUD functionality, including PriceDisplay component for price input and toggle status feature

This commit is contained in:
Yoga Pangestu 2026-07-25 09:54:31 +07:00
parent 8374a1e0f6
commit 18d73cdccf
8 changed files with 685 additions and 0 deletions

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import {
CreditCard,
Database,
Grid,
HelpCircle,
@ -34,6 +35,11 @@ const data = {
url: "/admin/business-types",
icon: Grid,
},
{
title: "Plans",
url: "/admin/plans",
icon: CreditCard,
},
],
}
</script>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from '@/components/ui/input-group'
const props = withDefaults(defineProps<{
value?: number | string
modelValue?: string
placeholder?: string
readonly?: boolean
disabled?: boolean
}>(), {
placeholder: '0',
readonly: false,
disabled: false,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
function formatIdr(n: number | string): string {
const num = typeof n === 'string' ? parseInt(n.replace(/\D/g, ''), 10) : n
if (isNaN(num)) return ''
return num.toLocaleString('id-ID')
}
const displayValue = computed(() => {
if (props.modelValue !== undefined) return formatIdr(props.modelValue)
if (props.value == null) return ''
return formatIdr(props.value)
})
function onInput(e: Event) {
const raw = (e.target as HTMLInputElement).value.replace(/\D/g, '')
emit('update:modelValue', raw)
}
function onFocus(e: FocusEvent) {
const el = e.target as HTMLInputElement
const pos = el.value.length
el.setSelectionRange(pos, pos)
}
</script>
<template>
<InputGroup>
<InputGroupAddon>
<InputGroupText>Rp</InputGroupText>
</InputGroupAddon>
<InputGroupInput
:model-value="displayValue"
:placeholder="placeholder"
:readonly="readonly"
:disabled="disabled"
type="text"
inputmode="numeric"
class="font-medium"
@input="onInput"
@focus="onFocus"
/>
</InputGroup>
</template>

View File

@ -0,0 +1,145 @@
import type { ColumnDef, FilterFn } from '@tanstack/vue-table'
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,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
export interface Plan {
id: string
name: string
description: string | null
price: number
limits: Record<string, any> | null
is_active: boolean | number
}
const nameFilter: FilterFn<Plan> = (row, _columnId, filterValue) => {
const search = (filterValue as string).toLowerCase()
const name = (row.original.name as string).toLowerCase()
return name.includes(search)
}
const booleanFilter: FilterFn<Plan> = (row, columnId, filterValue) => {
if (!filterValue || filterValue === '') return true
const raw = row.original[columnId as keyof Plan]
const cellValue = raw === true || raw === 1 || raw === '1'
const filterBool = filterValue === 'true'
return cellValue === filterBool
}
export function getColumns(callbacks: {
onEdit: (plan: Plan) => void
onDelete: (plan: Plan) => void
onToggleStatus: (plan: Plan, value: boolean) => void
}): ColumnDef<Plan>[] {
return [
{
id: 'name_search',
accessorFn: row => row.name,
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', { class: 'font-medium' }, row.original.name),
filterFn: nameFilter,
enableHiding: false,
},
{
accessorKey: 'price',
header: ({ column }) => {
return h(Button, {
variant: 'ghost',
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
}, () => ['Harga', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })])
},
cell: ({ row }) => {
const val = row.original.price
const formatted = val.toLocaleString('id-ID')
return h('div', { class: 'flex items-center gap-1 text-sm' }, [
h('span', { class: 'text-muted-foreground' }, 'Rp'),
h('span', { class: 'font-medium' }, formatted),
])
},
},
{
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'
const plan = row.original
return h('div', { class: 'flex items-center gap-2' }, [
h(Switch, {
modelValue: isActive,
'onUpdate:modelValue': (val: boolean) => callbacks.onToggleStatus(plan, 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 plan = 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(plan),
}, () =>
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(plan),
}, () =>
h(Trash2, { class: 'h-4 w-4' })
)
),
h(TooltipContent, null, () => 'Hapus'),
]),
])
)
},
},
]
}

View File

@ -0,0 +1,333 @@
<script setup lang="ts">
import AppSidebar from '@/components/AppSidebar.vue'
import SiteHeader from '@/components/SiteHeader.vue'
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
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,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Field,
FieldGroup,
FieldLabel,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { type Plan, getColumns } from './columns'
const config = useRuntimeConfig()
const { data: plans, status, refresh } = useFetch<Plan[]>(`${config.public.apiBase}/plans`)
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 selectedPlan = ref<Plan | null>(null)
interface FormData {
name: string
description: string
price: string
}
const form = ref<FormData>({ name: '', description: '', price: '' })
// --- Loading states ---
const formLoading = ref(false)
const deleteLoading = ref(false)
// --- Computed ---
const formTitle = computed(() => formMode.value === 'create' ? 'Tambah Plan' : 'Edit Plan')
// --- Table callbacks ---
function openCreateForm() {
formMode.value = 'create'
selectedPlan.value = null
form.value = { name: '', description: '', price: '' }
formOpen.value = true
}
function handleEdit(plan: Plan) {
formMode.value = 'edit'
selectedPlan.value = plan
form.value = {
name: plan.name,
description: plan.description ?? '',
price: String(plan.price),
}
formOpen.value = true
}
function handleDelete(plan: Plan) {
selectedPlan.value = plan
deleteOpen.value = true
}
async function handleToggleStatus(plan: Plan, value: boolean) {
try {
await $fetch(`${config.public.apiBase}/plans/${plan.id}/active`, {
method: 'PATCH',
body: { is_active: value },
})
if (plans.value) {
plans.value = plans.value.map(p =>
p.id === plan.id ? { ...p, is_active: value } : p
)
}
toast.success(`Plan 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.name.trim()) {
toast.error('Nama wajib diisi')
return
}
if (!form.value.price.trim() || isNaN(Number(form.value.price))) {
toast.error('Harga wajib diisi dan harus berupa angka')
return
}
formLoading.value = true
try {
const body = {
name: form.value.name.trim(),
description: form.value.description.trim() || null,
price: Number(form.value.price),
}
if (formMode.value === 'create') {
await $fetch(`${config.public.apiBase}/plans`, {
method: 'POST',
body,
})
toast.success('Plan berhasil ditambahkan')
} else {
await $fetch(`${config.public.apiBase}/plans/${selectedPlan.value!.id}`, {
method: 'PUT',
body,
})
toast.success('Plan berhasil diperbarui')
}
formOpen.value = false
await refresh()
} catch (e: any) {
toast.error(e?.data?.message ?? 'Gagal menyimpan plan')
} finally {
formLoading.value = false
}
}
async function handleConfirmDelete() {
if (!selectedPlan.value) return
deleteLoading.value = true
try {
await $fetch(`${config.public.apiBase}/plans/${selectedPlan.value.id}`, {
method: 'DELETE',
})
toast.success('Plan berhasil dihapus')
deleteOpen.value = false
selectedPlan.value = null
await refresh()
} catch (e: any) {
toast.error(e?.data?.message ?? 'Gagal menghapus plan')
} 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'
if (val === false || val === 'false' || val === 0) return 'false'
return 'all'
}
function hasActiveFilter(table: any) {
const search = table.getColumn('name_search')?.getFilterValue()
const status = table.getColumn('is_active')?.getFilterValue()
return (search && search !== '') || (status !== '' && status !== undefined && status !== null)
}
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 = { name: '', description: '', price: '' }
selectedPlan.value = null
}
})
watch(deleteOpen, (val) => {
if (!val) selectedPlan.value = null
})
</script>
<template>
<SidebarProvider :style="{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
}">
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div class="flex flex-1 flex-col">
<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="px-4 lg:px-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Plans</h1>
<Button size="sm" @click="openCreateForm">
<Plus class="h-4 w-4 mr-1" />
Tambah
</Button>
</div>
</div>
<div class="px-4 lg:px-6">
<div v-if="status === 'pending'" class="flex items-center justify-center h-24">
<p class="text-sm text-muted-foreground">Memuat data...</p>
</div>
<DataTable v-else ref="tableRef" :columns="columns" :data="plans ?? []">
<template #filters="{ table }">
<div class="flex items-center gap-2 py-4">
<Input class="max-w-sm" placeholder="Cari nama..."
:model-value="(table.getColumn('name_search')?.getFilterValue() as string) ?? ''"
@update:model-value="table.getColumn('name_search')?.setFilterValue($event)" />
<Select :model-value="getStatusFilter(table)"
@update:model-value="table.getColumn('is_active')?.setFilterValue($event === 'all' ? '' : $event)">
<SelectTrigger class="hidden h-8 w-auto md:flex">
<SelectValue placeholder="Semua Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="true">Aktif</SelectItem>
<SelectItem value="false">Nonaktif</SelectItem>
</SelectContent>
</Select>
<Button v-if="hasActiveFilter(table)" variant="ghost" size="sm" class="h-8"
@click="resetFilters(table)">
<X class="h-4 w-4 mr-1" />
Reset
</Button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" class="ml-auto md:hidden">
Filter
<ChevronDown class="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Status</DropdownMenuLabel>
<div class="px-2 pb-2">
<Select :model-value="getStatusFilter(table)"
@update:model-value="table.getColumn('is_active')?.setFilterValue($event === 'all' ? '' : $event)">
<SelectTrigger class="h-8 w-37.5">
<SelectValue placeholder="Semua Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="true">Aktif</SelectItem>
<SelectItem value="false">Nonaktif</SelectItem>
</SelectContent>
</Select>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem :disabled="!hasActiveFilter(table)" @click="resetFilters(table)">
Reset Filter
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
</DataTable>
</div>
</div>
</div>
</div>
</SidebarInset>
<!-- ==================== FORM DIALOG (CREATE / EDIT) ==================== -->
<Dialog v-model:open="formOpen">
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{{ formTitle }}</DialogTitle>
</DialogHeader>
<form id="planForm" @submit.prevent="handleFormSubmit">
<FieldGroup class="gap-4">
<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-price">Harga <span class="text-red-500">*</span></FieldLabel>
<PriceDisplay v-model="form.price" placeholder="0" :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="planForm" :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 Plan" :item-name="selectedPlan?.name" :loading="deleteLoading"
@confirm="handleConfirmDelete" />
</SidebarProvider>
</template>

View File

@ -0,0 +1,49 @@
# Component PriceDisplay
**Tanggal:** 2026-07-24
**Status:** Selesai
## Tujuan
Membuat component reusable untuk menampilkan dan menginput format harga Rupiah.
## Yang Dikerjakan
### 1. Component PriceDisplay
- Menggunakan `InputGroup` dengan prefix `Rp`
- Support 2 mode: display (readonly) dan input (editable)
- Format angka dengan pemisah titik (1.000.000)
- Input type `text` dengan `inputmode="numeric"` agar format titik berfungsi
### 2. Integrasi
- Tabel plans: menampilkan harga dengan format Rp 50.000
- Form plans: input harga dengan format Rp
## File yang Diubah
| File | Aksi | Detail |
|------|------|--------|
| `web2/app/components/PriceDisplay.vue` | Dibuat | Component PriceDisplay |
| `web2/app/pages/admin/plans/columns.ts` | Diubah | Format harga di tabel |
| `web2/app/pages/admin/plans/index.vue` | Diubah | Input harga pakai PriceDisplay |
## Props
| Prop | Tipe | Default | Deskripsi |
|------|------|---------|-----------|
| `value` | `number` | - | Nilai harga (display mode) |
| `modelValue` | `string` | - | v-model (input mode) |
| `placeholder` | `string` | `'0'` | Placeholder input |
| `readonly` | `boolean` | `false` | Readonly mode |
| `disabled` | `boolean` | `false` | Disabled mode |
## Contoh Penggunaan
```vue
<!-- Display -->
<PriceDisplay :value="50000" :readonly="true" />
<!-- Output: [Rp] [50.000] -->
<!-- Input -->
<PriceDisplay v-model="form.price" />
<!-- Output: [Rp] [0 (editable)] -->
```

View File

@ -0,0 +1,29 @@
# Halaman Plans
**Tanggal:** 2026-07-24
**Status:** Selesai
## Tujuan
Menambahkan halaman Plans pada menu Master dengan style coding yang sama seperti halaman Business Types.
## Yang Dikerjakan
### 1. Halaman Plans (shadcn-vue)
- Membuat `/admin/plans/index.vue` dengan DataTable, dialog create/edit, delete dialog
- Membuat `columns.ts` dengan TanStack Table columns (nama, kode, harga, deskripsi, status, aksi)
- Menggunakan shadcn-vue components (Dialog, Field, Input, Select, Badge, Button)
- Form fields: code, name, price (number), description
- Formatting harga ke Rupiah (IDR)
- API: GET/POST/PUT/DELETE ke `/v1/plans`
### 2. Sidebar Navigation
- Menambahkan menu "Plans" di navMain dengan icon `CreditCard`
- Link ke `/admin/plans`
## File yang Diubah
| File | Aksi | Detail |
|------|------|--------|
| `web2/app/pages/admin/plans/index.vue` | Dibuat | Halaman Plans |
| `web2/app/pages/admin/plans/columns.ts` | Dibuat | Column definitions |
| `web2/app/components/AppSidebar.vue` | Diubah | Tambah menu Plans di sidebar |

View File

@ -0,0 +1,33 @@
# Hapus Kolom Code dari Plan (Frontend)
**Tanggal:** 2026-07-24
**Status:** Selesai
## Tujuan
Menghapus kolom `code` dari tampilan dan form data Plans di admin panel.
## Yang Dikerjakan
### 1. DataTable Columns
- Hapus kolom `code` dari tabel
- Hapus `code` dari `Plan` interface
### 2. Form Dialog
- Hapus field `code` dari form create/edit
- Hapus `code` dari interface `FormData`
- Sebelum: form fields = Kode (wajib), Nama (wajib), Harga (wajib), Deskripsi
- Sesudah: form fields = Nama (wajib), Harga (wajib), Deskripsi
### 3. CRUD Operations
- Hapus `code` dari body POST (create) dan PUT (update)
- Validasi diubah dari "Kode wajib diisi" → hanya "Nama wajib diisi"
## File yang Diubah
| File | Aksi | Detail |
|------|------|--------|
| `web2/app/pages/admin/plans/columns.ts` | Diubah | Hapus kolom code, hapus code dari interface |
| `web2/app/pages/admin/plans/index.vue` | Diubah | Hapus code dari form, body, validasi, FormData |
## Notes
- Tidak ada perubahan pada toggle status atau delete dialog

View File

@ -0,0 +1,24 @@
# Toggle Status Plan (Frontend)
**Tanggal:** 2026-07-24
**Status:** Selesai
## Tujuan
Menambahkan fitur toggle status aktif/nonaktif pada halaman Plans di admin panel.
## Yang Dikerjakan
### 1. DataTable Columns
- Kolom Status sekarang ada Switch toggle (mirip Business Types)
- Badge tetap ditampilkan di samping Switch
### 2. CRUD Operations
- Tambah `handleToggleStatus()` yang call `PATCH /v1/plans/{id}/active`
- Update data lokal setelah toggle berhasil
## File yang Diubah
| File | Aksi | Detail |
|------|------|--------|
| `web2/app/pages/admin/plans/columns.ts` | Diubah | Tambah Switch di kolom Status |
| `web2/app/pages/admin/plans/index.vue` | Diubah | Tambah `handleToggleStatus()` |