Merge pull request #62 from dikstub-rssa/fe-refactor-division-40

feat(division): add tree-select component for recursive division parent-child relations
This commit is contained in:
Munawwirul Jamal
2025-09-19 07:50:44 +07:00
committed by GitHub
13 changed files with 888 additions and 99 deletions
+24 -5
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { TreeItem } from '~/components/pub/base/select-tree/type'
import type { FormErrors } from '~/types/error'
import { toTypedSchema } from '@vee-validate/zod'
import TreeSelect from '~/components/pub/base/select-tree/tree-select.vue'
import Combobox from '~/components/pub/custom-ui/form/combobox.vue'
import FieldGroup from '~/components/pub/custom-ui/form/field-group.vue'
import Field from '~/components/pub/custom-ui/form/field.vue'
@@ -11,7 +13,6 @@ interface DivisionFormData {
code: string
parentId: string
}
const props = defineProps<{
division: {
msg: {
@@ -22,9 +23,17 @@ const props = defineProps<{
items: {
value: string
label: string
code: string
}[]
}
divisionTree?: {
msg: {
placeholder: string
search: string
empty: string
}
data: TreeItem[]
onFetchChildren: (parentId: string) => Promise<void>
}
schema: any
initialValues?: Partial<DivisionFormData>
errors?: FormErrors
@@ -92,17 +101,27 @@ function onCancelForm({ resetForm }: { resetForm: () => void }) {
</Field>
</FieldGroup>
<FieldGroup :column="2">
<Label label-for="parentId">Kelompok</Label>
<FieldGroup>
<Label label-for="parentId">Divisi Induk</Label>
<Field id="parentId" :errors="errors">
<FormField v-slot="{ componentField }" name="parentId">
<FormItem>
<FormControl>
<!-- Gunakan TreeSelect jika divisionTree tersedia, fallback ke Combobox -->
<TreeSelect
v-if="props.divisionTree"
id="parentId"
:model-value="componentField.modelValue"
:data="props.divisionTree.data"
:on-fetch-children="props.divisionTree.onFetchChildren"
@update:model-value="componentField.onChange"
/>
<Combobox
v-else
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"
/>
/>
</FormControl>
<FormMessage />
</FormItem>
+8 -39
View File
@@ -12,31 +12,11 @@ type SmallDetailDto = any
const action = defineAsyncComponent(() => import('~/components/pub/custom-ui/data/dropdown-action-ud.vue'))
export const cols: Col[] = [
{ width: 100 },
{ },
{ },
{ },
{ width: 50 },
]
export const cols: Col[] = [{ width: 100 }, {}, {}, { width: 50 }]
export const header: Th[][] = [
[
{ label: 'Id' },
{ label: 'Nama' },
{ label: 'Kode' },
{ label: 'Kelompok' },
{ label: '' },
],
]
export const header: Th[][] = [[{ label: 'Kode' }, { label: 'Nama' }, { label: 'Divisi Induk' }, { label: '' }]]
export const keys = [
'id',
'firstName',
'cellphone',
'birth_place',
'action',
]
export const keys = ['code', 'name', 'ancestor', 'action']
export const delKeyNames: KeyLabel[] = [
{ key: 'code', label: 'Kode' },
@@ -44,24 +24,13 @@ export const delKeyNames: KeyLabel[] = [
]
export const funcParsed: RecStrFuncUnknown = {
name: (rec: unknown): unknown => {
ancestor: (rec: unknown): unknown => {
const recX = rec as SmallDetailDto
return `${recX.frontTitle} ${recX.name} ${recX.endTitle}`.trim()
},
identity_number: (rec: unknown): unknown => {
const recX = rec as SmallDetailDto
if (recX.identity_number?.substring(0, 5) === 'BLANK') {
return '(TANPA NIK)'
if (recX.meta === null) {
return '-'
}
return recX.identity_number
},
inPatient_itemPrice: (rec: unknown): unknown => {
const recX = rec as SmallDetailDto
return Number(recX.inPatient_itemPrice.price).toLocaleString('id-ID')
},
outPatient_itemPrice: (rec: unknown): unknown => {
const recX = rec as SmallDetailDto
return Number(recX.outPatient_itemPrice.price).toLocaleString('id-ID')
return recX.meta.name
},
}
+165
View File
@@ -0,0 +1,165 @@
<script setup lang="ts">
import TreeSelect from '~/components/pub/base/select-tree/tree-select.vue'
/**
* DEMO COMPONENT - Tree Select dengan Lazy Loading
*
* Komponen ini adalah contoh penggunaan TreeSelect dengan data teknologi.
* Untuk penggunaan dalam aplikasi nyata, lihat komponen content/division/entry.vue
* yang menggunakan tree select untuk data divisi rumah sakit.
*/
// Tipe data untuk konsistensi
interface TreeItem {
value: string
label: string
hasChildren: boolean
children?: TreeItem[]
}
// State untuk data pohon demo - data teknologi sebagai contoh
const treeData = ref<TreeItem[]>([
{ value: 'frontend', label: 'Frontend Development', hasChildren: true },
{ value: 'backend', label: 'Backend Development', hasChildren: true },
{ value: 'mobile', label: 'Mobile Development', hasChildren: true },
{ value: 'devops', label: 'DevOps & Infrastructure', hasChildren: false },
])
// State untuk menampung nilai yang dipilih
const selectedValue = ref<string>()
// --- DEMO LOGIC: SIMULASI API DAN MANIPULASI DATA ---
// Helper: Fungsi rekursif untuk mencari dan menyisipkan data anak ke dalam state
function findAndInsertChildren(nodes: TreeItem[], parentId: string, newChildren: TreeItem[]): boolean {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
if (node && node.value === parentId) {
// Gunakan Vue.set equivalent untuk memastikan reactivity
node.children = [...newChildren]
console.log(`[findAndInsertChildren] Updated children for ${parentId}:`, node.children)
return true
}
if (node && node.children && findAndInsertChildren(node.children, parentId, newChildren)) {
return true
}
}
return false
}
// Fungsi demo untuk simulasi fetch data dari API
async function handleFetchChildren(parentId: string): Promise<void> {
console.log(`[DEMO] Mengambil data anak untuk parent: ${parentId}`)
// Simulasi delay API call
await new Promise(resolve => setTimeout(resolve, 600))
let childrenData: TreeItem[] = []
// Sample data berdasarkan parent ID
switch (parentId) {
case 'frontend':
childrenData = [
{ value: 'vue', label: 'Vue.js', hasChildren: true },
{ value: 'react', label: 'React.js', hasChildren: true },
{ value: 'angular', label: 'Angular', hasChildren: false },
{ value: 'svelte', label: 'Svelte', hasChildren: false },
]
break
case 'backend':
childrenData = [
{ value: 'nodejs', label: 'Node.js', hasChildren: true },
{ value: 'python', label: 'Python', hasChildren: true },
{ value: 'golang', label: 'Go', hasChildren: false },
{ value: 'rust', label: 'Rust', hasChildren: false },
]
break
case 'mobile':
childrenData = [
{ value: 'flutter', label: 'Flutter', hasChildren: false },
{ value: 'react-native', label: 'React Native', hasChildren: false },
{ value: 'ionic', label: 'Ionic', hasChildren: false },
]
break
case 'vue':
childrenData = [
{ value: 'nuxt', label: 'Nuxt.js', hasChildren: false },
{ value: 'quasar', label: 'Quasar', hasChildren: false },
]
break
case 'react':
childrenData = [
{ value: 'nextjs', label: 'Next.js', hasChildren: false },
{ value: 'gatsby', label: 'Gatsby', hasChildren: false },
]
break
case 'nodejs':
childrenData = [
{ value: 'express', label: 'Express.js', hasChildren: false },
{ value: 'nestjs', label: 'NestJS', hasChildren: false },
{ value: 'fastify', label: 'Fastify', hasChildren: false },
]
break
case 'python':
childrenData = [
{ value: 'django', label: 'Django', hasChildren: false },
{ value: 'fastapi', label: 'FastAPI', hasChildren: false },
{ value: 'flask', label: 'Flask', hasChildren: false },
]
break
}
// Insert data ke dalam tree state
const success = findAndInsertChildren(treeData.value, parentId, childrenData)
console.log(`[DEMO] Insert children result:`, success)
// Force trigger reactivity
triggerRef(treeData)
console.log(`[DEMO] Current tree data:`, JSON.stringify(treeData.value, null, 2))
}
</script>
<template>
<div class="p-10 max-w-2xl mx-auto">
<div class="mb-6">
<h1 class="mb-2 text-3xl font-bold text-slate-800">Demo: Tree Select dengan Lazy Loading</h1>
<p class="text-slate-600">
Contoh penggunaan komponen TreeSelect dengan data teknologi.
Pilih item untuk melihat sub-kategori yang dimuat secara lazy.
</p>
</div>
<div class="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h3 class="font-semibold text-blue-800 mb-2">💡 Catatan untuk Developer:</h3>
<p class="text-sm text-blue-700">
Untuk implementasi nyata dengan data divisi rumah sakit,
lihat komponen <code class="px-1 bg-blue-100 rounded">content/division/entry.vue</code>
</p>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-2">
Pilih Teknologi:
</label>
<TreeSelect
v-model="selectedValue"
:data="treeData"
:on-fetch-children="handleFetchChildren"
/>
</div>
<div class="p-4 bg-slate-50 rounded-lg">
<p class="text-sm text-slate-600 mb-1">Value yang terpilih:</p>
<span class="px-3 py-1 font-mono text-sm bg-white border rounded-md">
{{ selectedValue || 'Belum ada yang dipilih' }}
</span>
</div>
</div>
<div class="mt-8 text-xs text-slate-500">
<p>🔄 Data dimuat secara lazy saat node parent dibuka</p>
<p> Simulasi delay 600ms untuk menampilkan loading state</p>
</div>
</div>
</template>
+130 -43
View File
@@ -1,58 +1,145 @@
import type { TreeItem } from '~/components/pub/base/select-tree/type'
import * as z from 'zod'
export const division = {
export const divisionConf = {
msg: {
placeholder: '---pilih divisi utama',
search: 'kode, nama divisi',
empty: 'divisi tidak ditemukan',
},
items: [
{ value: '1', label: 'Medical', code: 'MED' },
{ value: '2', label: 'Nursing', code: 'NUR' },
{ value: '3', label: 'Admin', code: 'ADM' },
{ value: '4', label: 'Support', code: 'SUP' },
{ value: '5', label: 'Education', code: 'EDU' },
{ value: '6', label: 'Pharmacy', code: 'PHA' },
{ value: '7', label: 'Radiology', code: 'RAD' },
{ value: '8', label: 'Laboratory', code: 'LAB' },
{ value: '9', label: 'Finance', code: 'FIN' },
{ value: '10', label: 'Human Resources', code: 'HR' },
{ value: '11', label: 'IT Services', code: 'ITS' },
{ value: '12', label: 'Maintenance', code: 'MNT' },
{ value: '13', label: 'Catering', code: 'CAT' },
{ value: '14', label: 'Security', code: 'SEC' },
{ value: '15', label: 'Emergency', code: 'EMR' },
{ value: '16', label: 'Surgery', code: 'SUR' },
{ value: '17', label: 'Outpatient', code: 'OUT' },
{ value: '18', label: 'Inpatient', code: 'INP' },
{ value: '19', label: 'Rehabilitation', code: 'REB' },
{ value: '20', label: 'Research', code: 'RSH' },
{ value: '1', label: 'Medical' },
{ value: '2', label: 'Nursing' },
{ value: '3', label: 'Admin' },
{ value: '4', label: 'Support' },
{ value: '5', label: 'Education' },
{ value: '6', label: 'Pharmacy' },
{ value: '7', label: 'Radiology' },
{ value: '8', label: 'Laboratory' },
{ value: '9', label: 'Finance' },
{ value: '10', label: 'Human Resources' },
{ value: '11', label: 'IT Services' },
{ value: '12', label: 'Maintenance' },
{ value: '13', label: 'Catering' },
{ value: '14', label: 'Security' },
{ value: '15', label: 'Emergency' },
{ value: '16', label: 'Surgery' },
{ value: '17', label: 'Outpatient' },
{ value: '18', label: 'Inpatient' },
{ value: '19', label: 'Rehabilitation' },
{ value: '20', label: 'Research' },
],
}
export const schema = z.object({
name: z.string({
required_error: 'Nama wajib diisi',
}).min(1, 'Nama divisi wajib diisi'),
name: z
.string({
required_error: 'Nama wajib diisi',
})
.min(1, 'Nama divisi wajib diisi'),
code: z.string({
required_error: 'Kode wajib diisi',
}).min(1, 'Kode divisi wajib diisi'),
code: z
.string({
required_error: 'Kode wajib diisi',
})
.min(1, 'Kode divisi wajib diisi'),
parentId: z.preprocess(
(input: unknown) => {
if (typeof input === 'string') {
// Handle empty string case
if (input.trim() === '') {
return undefined
}
return Number(input)
}
return input
},
z.number({
required_error: 'Kelompok wajib dipilih',
}).min(1, 'Kelompok wajib dipilih'),
),
parentId: z.string().optional(),
})
// State untuk tree data divisi - dimulai dengan data level atas
const divisionTreeData = ref<TreeItem[]>([
{ value: '1', label: 'Medical', hasChildren: true },
{ value: '2', label: 'Nursing', hasChildren: true },
{ value: '3', label: 'Admin', hasChildren: false },
{ value: '4', label: 'Support', hasChildren: true },
{ value: '5', label: 'Education', hasChildren: false },
{ value: '6', label: 'Pharmacy', hasChildren: true },
{ value: '7', label: 'Radiology', hasChildren: false },
{ value: '8', label: 'Laboratory', hasChildren: true },
])
// Helper function untuk mencari dan menyisipkan data anak ke dalam tree
function findAndInsertChildren(nodes: TreeItem[], parentId: string, newChildren: TreeItem[]): boolean {
for (const node of nodes) {
if (node.value === parentId) {
node.children = newChildren
return true
}
if (node.children && findAndInsertChildren(node.children as TreeItem[], parentId, newChildren)) {
return true
}
}
return false
}
// Fungsi untuk fetch data anak divisi (lazy loading)
async function handleFetchDivisionChildren(parentId: string): Promise<void> {
console.log(`Mengambil data sub-divisi untuk parent: ${parentId}`)
// Simulasi delay API call
await new Promise((resolve) => setTimeout(resolve, 800))
let childrenData: TreeItem[] = []
// Sample data berdasarkan parent ID
switch (parentId) {
case '1': // Medical
childrenData = [
{ value: '1-1', label: 'Cardiology', hasChildren: true },
{ value: '1-2', label: 'Neurology', hasChildren: false },
{ value: '1-3', label: 'Oncology', hasChildren: false },
]
break
case '2': // Nursing
childrenData = [
{ value: '2-1', label: 'ICU Nursing', hasChildren: false },
{ value: '2-2', label: 'ER Nursing', hasChildren: false },
{ value: '2-3', label: 'Ward Nursing', hasChildren: true },
]
break
case '4': // Support
childrenData = [
{ value: '4-1', label: 'IT Support', hasChildren: false },
{ value: '4-2', label: 'Maintenance', hasChildren: false },
]
break
case '6': // Pharmacy
childrenData = [
{ value: '6-1', label: 'Inpatient Pharmacy', hasChildren: false },
{ value: '6-2', label: 'Outpatient Pharmacy', hasChildren: false },
]
break
case '8': // Laboratory
childrenData = [
{ value: '8-1', label: 'Clinical Lab', hasChildren: false },
{ value: '8-2', label: 'Pathology Lab', hasChildren: false },
]
break
case '1-1': // Cardiology sub-divisions
childrenData = [
{ value: '1-1-1', label: 'Cardiac Surgery', hasChildren: false },
{ value: '1-1-2', label: 'Cardiac Cathlab', hasChildren: false },
]
break
case '2-3': // Ward Nursing sub-divisions
childrenData = [
{ value: '2-3-1', label: 'Pediatric Ward', hasChildren: false },
{ value: '2-3-2', label: 'Surgical Ward', hasChildren: false },
]
break
}
// Insert data ke dalam tree state
findAndInsertChildren(divisionTreeData.value, parentId, childrenData)
}
export const divisionTreeConfig = computed(() => ({
msg: {
placeholder: '--- Pilih divisi induk',
search: 'Cari divisi...',
empty: 'Divisi tidak ditemukan',
},
data: divisionTreeData.value,
onFetchChildren: handleFetchDivisionChildren,
}))
+12 -11
View File
@@ -1,13 +1,12 @@
<script setup lang="ts">
import type { HeaderPrep } from '~/components/pub/custom-ui/data/types'
import AppDivisonEntryForm from '~/components/app/divison/entry-form.vue'
import AppDivisionEntryForm 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 { usePaginatedList } from '~/composables/usePaginatedList'
import { division as divisionConf, schema as schemaConf } from './entry'
import { divisionConf, divisionTreeConfig, schema } from './entry'
// #region State & Computed
// Dialog state
const isFormEntryDialogOpen = ref(false)
@@ -18,8 +17,8 @@ const recId = ref<number>(0)
const recAction = ref<string>('')
const recItem = ref<any>(null)
async function fetchDivisionData(params: any) {
const endpoint = transform('/api/v1/patient', params)
async function fetchDivisionData(_params: any) {
const endpoint = '/api/v1/_dev/division/list'
return await xfetch(endpoint)
}
@@ -182,17 +181,19 @@ function handleCancelConfirmation() {
<AppDivisonList :data="data" :pagination-meta="paginationMeta" @page-change="handlePageChange" />
<Dialog v-model:open="isFormEntryDialogOpen" title="Tambah Divisi" size="lg" prevent-outside>
<AppDivisonEntryForm
:division="divisionConf" :schema="schemaConf"
<AppDivisionEntryForm
:division="divisionConf" :division-tree="divisionTreeConfig" :schema="schema"
: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"
>
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>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { ComboboxItemEmits, ComboboxItemProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import { ComboboxItem, useForwardPropsEmits } from 'radix-vue'
import { computed } from 'vue'
import { cn } from '~/lib/utils'
const props = defineProps<ComboboxItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<ComboboxItemEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ComboboxItem
v-bind="forwarded"
:class="cn('relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-normal outline-none hover:bg-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>*]:text-sm [&>*]:font-normal', props.class)"
>
<slot />
</ComboboxItem>
</template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { TreeItem } from './type'
import { Check } from 'lucide-vue-next'
import CommandItem from './command-item.vue'
defineProps<{
item: TreeItem
selectedValue?: string
shouldAlign?: boolean
}>()
const emit = defineEmits(['select'])
function handleSelect(value: string) {
emit('select', value)
}
</script>
<template>
<div class="leaf-node min-w-max">
<CommandItem
:value="item.value"
class="flex items-center justify-between p-2 w-full text-sm font-normal hover:text-primary cursor-pointer rounded-md"
:class="{ 'pl-8': shouldAlign }"
@select="() => handleSelect(item.value)"
>
<span class="text-sm font-normal">{{ item.label }}</span>
<Check
v-if="selectedValue === item.value"
class="w-4 h-4 text-primary ml-2 flex-shrink-0"
/>
</CommandItem>
</div>
</template>
<style scoped>
.leaf-node {
@apply w-full;
}
</style>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import type { TreeItem } from './type'
import { Check, ChevronRight, Loader2 } from 'lucide-vue-next'
import TreeView from './tree-view.vue'
const props = defineProps<{
item: TreeItem
selectedValue?: string
onFetchChildren: (parentId: string) => Promise<void>
level?: number
}>()
const emit = defineEmits(['select'])
const hasChildren = computed(() => props.item.children && props.item.children.length > 0)
const isOpen = ref(false)
const isLoading = ref(false)
const isChevronRotated = ref(false)
function handleSelect(value: string) {
emit('select', value)
}
function handleLabelClick() {
handleSelect(props.item.value)
}
watch(isOpen, async (newValue) => {
console.log(`[TreeNode] ${props.item.label} - isOpen changed to:`, newValue)
isChevronRotated.value = newValue
if (newValue && props.item.hasChildren && !props.item.children && !isLoading.value) {
console.log(`[TreeNode] Fetching children for: ${props.item.label}`)
isLoading.value = true
try {
await props.onFetchChildren(props.item.value)
console.log(`[TreeNode] Fetch completed for: ${props.item.label}`, props.item.children)
// Force reactivity update dengan nextTick
await nextTick()
} catch (error) {
console.error('Gagal memuat data anak:', error)
// Tutup kembali jika gagal fetch
isOpen.value = false
} finally {
isLoading.value = false
}
}
})
</script>
<template>
<div class="tree-node min-w-max">
<Collapsible v-model:open="isOpen" class="w-full">
<!-- Node Header -->
<div class="flex items-center justify-start w-full p-2 rounded-md hover:bg-accent gap-2">
<!-- Chevron Toggle Button -->
<CollapsibleTrigger as-child>
<Button
variant="ghost"
class="h-4 w-4 p-0 flex items-center justify-center"
>
<Loader2 v-if="isLoading" class="w-4 h-4 animate-spin text-muted-foreground" />
<ChevronRight
v-else
class="w-4 h-4 transition-transform duration-200 ease-in-out text-muted-foreground"
:class="{
'rotate-90': isChevronRotated,
}"
/>
</Button>
</CollapsibleTrigger>
<!-- Node Label -->
<span
class="text-sm font-normal cursor-pointer hover:text-primary flex-1 flex items-center justify-between"
@click="handleLabelClick"
>
{{ item.label }}
<!-- Check Icon untuk selected state -->
<Check
v-if="selectedValue === item.value"
class="w-4 h-4 text-primary ml-2 flex-shrink-0"
/>
</span>
</div>
<!-- Children Container -->
<CollapsibleContent class="pl-6">
<div v-if="!hasChildren" class="text-sm text-muted-foreground p-2">
{{ isLoading ? 'Memuat...' : 'Tidak ada data' }}
</div>
<TreeView
v-else
:data="item.children!"
:selected-value="selectedValue"
:on-fetch-children="onFetchChildren"
:level="(level || 0) + 1"
@select="handleSelect"
/>
</CollapsibleContent>
</Collapsible>
</div>
</template>
<style scoped>
.tree-node {
@apply w-full;
}
/* Animasi tambahan untuk smooth transition */
.tree-node .collapsible-content {
transition: all 0.2s ease-in-out;
}
</style>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import type { TreeItem } from './type'
import { ChevronsUpDown } from 'lucide-vue-next'
import { cn } from '~/lib/utils'
import TreeView from './tree-view.vue'
const props = defineProps<{
data: TreeItem[]
onFetchChildren: (parentId: string) => Promise<void>
}>()
const modelValue = defineModel<string>()
const open = ref(false)
function handleSelect(newVal: string) {
modelValue.value = newVal
open.value = false
}
function findLabel(value: string, items: TreeItem[]): string | undefined {
for (const item of items) {
if (item.value === value) return item.label
if (item.children) {
const found = findLabel(value, item.children)
if (found) return found
}
}
}
const selectedLabel = computed(() => {
return modelValue.value ? findLabel(modelValue.value, props.data) : '--- select item'
})
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button variant="outline" role="combobox" class="w-full justify-between bg-white border-1 border-gray-400">
<span
class="font-normal text-muted-foreground" :class="cn(
'font-normal',
!modelValue && 'text-muted-foreground',
modelValue && 'text-black',
)"
>
{{ selectedLabel }}
</span>
<ChevronsUpDown class="w-4 h-4 ml-2 opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent class="min-w-full max-w-[350px] p-0">
<Command>
<CommandInput placeholder="Cari item..." />
<CommandEmpty>Item tidak ditemukan.</CommandEmpty>
<CommandList class="max-h-[300px] overflow-x-auto overflow-y-auto">
<CommandGroup>
<TreeView
:data="data"
:selected-value="modelValue"
:on-fetch-children="onFetchChildren"
:level="0"
@select="handleSelect"
/>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { TreeItem } from './type'
import Leaf from './leaf.vue'
import TreeNode from './tree-node.vue'
const props = defineProps<{
data: TreeItem[]
selectedValue?: string
onFetchChildren: (parentId: string) => Promise<void>
level?: number
}>()
const emit = defineEmits(['select'])
function handleSelect(value: string) {
emit('select', value)
}
// Computed untuk mendeteksi apakah ada node dengan children dalam level ini
const hasAnyChildrenInLevel = computed(() => {
return props.data.some(item => item.hasChildren)
})
// Computed untuk menentukan apakah perlu alignment berdasarkan level
const shouldAlignLeaves = computed(() => {
// Di root level (level 0), selalu align leaf dengan tree nodes jika ada mixed content
// Di level lain, hanya align jika ada mixed content
const isRootLevel = (props.level || 0) === 0
const hasMixedContent = hasAnyChildrenInLevel.value && props.data.some(item => !item.hasChildren)
return isRootLevel ? hasAnyChildrenInLevel.value : hasMixedContent
})
</script>
<template>
<div class="tree-view min-w-max">
<template v-for="item in data" :key="item.value">
<TreeNode
v-if="item.hasChildren"
:item="item"
:selected-value="selectedValue"
:on-fetch-children="onFetchChildren"
:level="level || 0"
@select="handleSelect"
/>
<Leaf
v-else
:item="item"
:selected-value="selectedValue"
:should-align="shouldAlignLeaves"
@select="handleSelect"
/>
</template>
</div>
</template>
@@ -0,0 +1,6 @@
export interface TreeItem {
value: string
label: string
hasChildren: boolean
children?: TreeItem[]
}
+73 -1
View File
@@ -1,9 +1,81 @@
<script setup lang="ts">
import TreeSelect from '~/components/pub/base/select-tree/tree-select.vue'
definePageMeta({
layout: 'blank',
})
// Tipe data untuk konsistensi
interface TreeItem {
value: string
label: string
hasChildren: boolean
children?: TreeItem[]
}
// State untuk data pohon. Awalnya hanya berisi data level atas.
const treeData = ref<TreeItem[]>([
{ value: 'frontend', label: 'Frontend', hasChildren: true },
{ value: 'backend', label: 'Backend', hasChildren: true },
{ value: 'devops', label: 'DevOps', hasChildren: false },
])
// State untuk menampung nilai yang dipilih
const selectedValue = ref<string>()
// --- INI BAGIAN PENTING: LOGIKA API DAN MANIPULASI DATA ---
// Helper: Fungsi rekursif untuk mencari dan menyisipkan data anak ke dalam state
function findAndInsertChildren(nodes: TreeItem[], parentId: string, newChildren: TreeItem[]) {
for (const node of nodes) {
if (node.value === parentId) {
node.children = newChildren
return true
}
if (node.children && findAndInsertChildren(node.children, parentId, newChildren)) {
return true
}
}
return false
}
// Fungsi yang akan dipanggil oleh komponen TreeSelectItem untuk mengambil data
async function handleFetchChildren(parentId: string) {
console.log(`Mengambil data anak untuk parent: ${parentId}`)
// Simulasi panggilan API ke backend Anda
await new Promise(resolve => setTimeout(resolve, 500)) // Delay 0.5 detik
let childrenData: TreeItem[] = []
if (parentId === 'frontend') {
childrenData = [
{ value: 'vue', label: 'Vue.js', hasChildren: true },
{ value: 'react', label: 'React.js', hasChildren: false },
]
} else if (parentId === 'backend') {
childrenData = [
{ value: 'nodejs', label: 'Node.js', hasChildren: false },
{ value: 'golang', label: 'Go', hasChildren: false },
]
} else if (parentId === 'vue') { // Contoh untuk level yang lebih dalam
childrenData = [
{ value: 'nuxt', label: 'Nuxt.js', hasChildren: false },
]
}
// Akhir simulasi
// Masukkan data anak yang baru didapat ke dalam state treeData
findAndInsertChildren(treeData.value, parentId, childrenData)
}
</script>
<template>
<div>test list user</div>
<div class="p-10">
<h1 class="mb-4 text-2xl font-bold">Tree Select (Lazy Loading)</h1>
<TreeSelect v-model="selectedValue" :data="treeData" :on-fetch-children="handleFetchChildren" />
<p class="mt-4">
Value yang terpilih:
<span class="p-1 font-mono text-sm rounded-md bg-slate-100">{{ selectedValue || 'Belum ada' }}</span>
</p>
</div>
</template>
+163
View File
@@ -0,0 +1,163 @@
export default defineEventHandler(async (event) => {
// Ambil query parameters
const payload = { ...getQuery(event) }
const isTreeFormat = payload.tree === 'true' || payload.tree === '1'
// Mock data division dengan struktur nested meta parent
const baseDivisions = [
{
id: 1,
name: 'Direktorat Medis',
code: 'DIR-MED',
parentId: null,
},
{
id: 2,
name: 'Bidang Medik',
code: 'BDG-MED',
parentId: 1,
},
{
id: 3,
name: 'Tim Kerja Ranap, ICU, Bedah',
code: 'TIM-RAN',
parentId: 2,
},
{
id: 4,
name: 'Direktorat Keperawatan',
code: 'DIR-KEP',
parentId: null,
},
{
id: 5,
name: 'Bidang Keperawatan',
code: 'BDG-KEP',
parentId: 4,
},
{
id: 6,
name: 'Tim Kerja Keperawatan Ranap, ICU, Bedah',
code: 'TIM-KEP',
parentId: 5,
},
{
id: 7,
name: 'Direktorat Penunjang',
code: 'DIR-PNJ',
parentId: null,
},
{
id: 8,
name: 'Bidang Penunjang Medik',
code: 'BDG-PNJ',
parentId: 7,
},
{
id: 9,
name: 'Tim Kerja Radiologi',
code: 'TIM-RAD',
parentId: 8,
},
{
id: 10,
name: 'Direktorat Produksi',
code: 'DIR-PRD',
parentId: null,
},
{
id: 11,
name: 'Bidang Teknologi',
code: 'BDG-TEK',
parentId: 10,
},
{
id: 12,
name: 'Tim Kerja Software Engineering',
code: 'TIM-SWE',
parentId: 11,
},
{
id: 13,
name: 'Direktorat Operasional',
code: 'DIR-OPS',
parentId: null,
},
{
id: 14,
name: 'Bidang HR & GA',
code: 'BDG-HRG',
parentId: 13,
},
{
id: 15,
name: 'Tim Kerja Rekrutmen',
code: 'TIM-REC',
parentId: 14,
},
]
// Menambahkan meta parent pada setiap division
const divisions = baseDivisions
.map((division) => {
const parent = baseDivisions.find((d) => d.id === division.parentId)
const mapped = {
...division,
meta: {
parentId: parent?.id || null,
name: parent?.name || null,
code: parent?.code || null,
},
}
if (mapped.meta.parentId === null) {
mapped.meta = null
}
return mapped
})
.sort((a, b) => {
if (a.parentId === null && b.parentId !== null) return -1
if (a.parentId !== null && b.parentId === null) return 1
return a.id - b.id
})
// Jika tree format diminta, konversi ke struktur hierarki
if (isTreeFormat) {
const buildTree = (parentId = null) => {
return baseDivisions
.filter(division => division.parentId === parentId)
.map(division => ({
id: division.id,
name: division.name,
code: division.code,
children: buildTree(division.id),
}))
.sort((a, b) => a.id - b.id)
}
const treeData = buildTree()
return {
success: true,
data: treeData,
message: 'Data division dalam format tree berhasil diambil',
meta: {
record_totalCount: baseDivisions.length,
format: 'tree',
},
}
}
return {
success: true,
data: divisions,
message: 'Data division berhasil diambil',
meta: {
record_totalCount: divisions.length,
format: 'flat',
},
}
})