Merge branch 'dev' of https://github.com/dikstub-rssa/simrs-fe into fe-integrasi-device-material-65
This commit is contained in:
+64
-36
@@ -67,41 +67,43 @@
|
|||||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||||
--sidebar-border: 220 13% 91%;
|
--sidebar-border: 220 13% 91%;
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
/* .dark { */
|
}
|
||||||
/* --background: 210 25% 8%; */
|
|
||||||
/* --foreground: 210 20% 95%; */
|
.dark {
|
||||||
/* --card: 210 25% 10%; */
|
--background: 210 25% 8%;
|
||||||
/* --card-foreground: 210 20% 95%; */
|
--foreground: 210 20% 95%;
|
||||||
/* --popover: 210 25% 10%; */
|
--card: 210 25% 10%;
|
||||||
/* --popover-foreground: 210 20% 95%; */
|
--card-foreground: 210 20% 95%;
|
||||||
/* --primary: 150 75% 45%; */
|
--popover: 210 25% 10%;
|
||||||
/* --primary-foreground: 0 0% 100%; */
|
--popover-foreground: 210 20% 95%;
|
||||||
/* --primary-hover: 150 75% 50%; */
|
--primary: 150 75% 45%;
|
||||||
/* --secondary: 210 25% 15%; */
|
--primary-foreground: 0 0% 100%;
|
||||||
/* --secondary-foreground: 210 20% 90%; */
|
--primary-hover: 150 75% 50%;
|
||||||
/* --muted: 210 25% 15%; */
|
--secondary: 210 25% 15%;
|
||||||
/* --muted-foreground: 210 15% 65%; */
|
--secondary-foreground: 210 20% 90%;
|
||||||
/* --accent: 210 100% 55%; */
|
--muted: 210 25% 15%;
|
||||||
/* --accent-foreground: 0 0% 100%; */
|
--muted-foreground: 210 15% 65%;
|
||||||
/* --destructive: 0 75% 60%; */
|
--accent: 210 100% 55%;
|
||||||
/* --destructive-foreground: 0 0% 100%; */
|
--accent-foreground: 0 0% 100%;
|
||||||
/* --border: 210 25% 20%; */
|
--destructive: 0 75% 60%;
|
||||||
/* --input: 210 25% 15%; */
|
--destructive-foreground: 0 0% 100%;
|
||||||
/* --ring: 150 75% 45%; */
|
--border: 210 25% 20%;
|
||||||
/* --success: 150 75% 50%; */
|
--input: 210 25% 15%;
|
||||||
/* --warning: 45 95% 65%; */
|
--ring: 150 75% 45%;
|
||||||
/* --info: 210 100% 60%; */
|
--success: 150 75% 50%;
|
||||||
/* --gradient-primary: linear-gradient(135deg, hsl(150 75% 45%), hsl(150 75% 55%)); */
|
--warning: 45 95% 65%;
|
||||||
/* --gradient-medical: linear-gradient(135deg, hsl(150 75% 45%), hsl(210 100% 55%)); */
|
--info: 210 100% 60%;
|
||||||
/* --gradient-subtle: linear-gradient(180deg, hsl(210 25% 8%), hsl(210 25% 12%)); */
|
--gradient-primary: linear-gradient(135deg, hsl(150 75% 45%), hsl(150 75% 55%));
|
||||||
/* --sidebar-background: 240 5.9% 10%; */
|
--gradient-medical: linear-gradient(135deg, hsl(150 75% 45%), hsl(210 100% 55%));
|
||||||
/* --sidebar-foreground: 240 4.8% 95.9%; */
|
--gradient-subtle: linear-gradient(180deg, hsl(210 25% 8%), hsl(210 25% 12%));
|
||||||
/* --sidebar-primary: 224.3 76.3% 48%; */
|
--sidebar-background: 240 5.9% 10%;
|
||||||
/* --sidebar-primary-foreground: 0 0% 100%; */
|
--sidebar-foreground: 240 4.8% 95.9%;
|
||||||
/* --sidebar-accent: 240 3.7% 15.9%; */
|
--sidebar-primary: 224.3 76.3% 48%;
|
||||||
/* --sidebar-accent-foreground: 240 4.8% 95.9%; */
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
/* --sidebar-border: 240 3.7% 15.9%; */
|
--sidebar-accent: 240 3.7% 15.9%;
|
||||||
/* --sidebar-ring: 217.2 91.2% 59.8%; */
|
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||||
|
--sidebar-border: 240 3.7% 15.9%;
|
||||||
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keyframes for Animations */
|
/* Keyframes for Animations */
|
||||||
@@ -330,7 +332,8 @@ body {
|
|||||||
|
|
||||||
/* Form Error Styling */
|
/* Form Error Styling */
|
||||||
.field-error-info {
|
.field-error-info {
|
||||||
@apply text-xs ml-1;
|
font-size: 0.75rem;
|
||||||
|
margin-left: 0.25rem;
|
||||||
color: hsl(var(--destructive));
|
color: hsl(var(--destructive));
|
||||||
/* font-size: 0.875rem; */
|
/* font-size: 0.875rem; */
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
@@ -338,3 +341,28 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* .rounded-md { border-radius: var */
|
/* .rounded-md { border-radius: var */
|
||||||
|
|
||||||
|
/* Dashboard grid utility */
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { TreeItem } from '~/components/pub/base/select-tree/type'
|
||||||
import type { FormErrors } from '~/types/error'
|
import type { FormErrors } from '~/types/error'
|
||||||
import { toTypedSchema } from '@vee-validate/zod'
|
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 Combobox from '~/components/pub/custom-ui/form/combobox.vue'
|
||||||
import FieldGroup from '~/components/pub/custom-ui/form/field-group.vue'
|
import FieldGroup from '~/components/pub/custom-ui/form/field-group.vue'
|
||||||
import Field from '~/components/pub/custom-ui/form/field.vue'
|
import Field from '~/components/pub/custom-ui/form/field.vue'
|
||||||
@@ -11,7 +13,6 @@ interface DivisionFormData {
|
|||||||
code: string
|
code: string
|
||||||
parentId: string
|
parentId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
division: {
|
division: {
|
||||||
msg: {
|
msg: {
|
||||||
@@ -22,9 +23,17 @@ const props = defineProps<{
|
|||||||
items: {
|
items: {
|
||||||
value: string
|
value: string
|
||||||
label: string
|
label: string
|
||||||
code: string
|
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
divisionTree?: {
|
||||||
|
msg: {
|
||||||
|
placeholder: string
|
||||||
|
search: string
|
||||||
|
empty: string
|
||||||
|
}
|
||||||
|
data: TreeItem[]
|
||||||
|
onFetchChildren: (parentId: string) => Promise<void>
|
||||||
|
}
|
||||||
schema: any
|
schema: any
|
||||||
initialValues?: Partial<DivisionFormData>
|
initialValues?: Partial<DivisionFormData>
|
||||||
errors?: FormErrors
|
errors?: FormErrors
|
||||||
@@ -92,17 +101,27 @@ function onCancelForm({ resetForm }: { resetForm: () => void }) {
|
|||||||
</Field>
|
</Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|
||||||
<FieldGroup :column="2">
|
<FieldGroup>
|
||||||
<Label label-for="parentId">Kelompok</Label>
|
<Label label-for="parentId">Divisi Induk</Label>
|
||||||
<Field id="parentId" :errors="errors">
|
<Field id="parentId" :errors="errors">
|
||||||
<FormField v-slot="{ componentField }" name="parentId">
|
<FormField v-slot="{ componentField }" name="parentId">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<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
|
<Combobox
|
||||||
|
v-else
|
||||||
id="parentId" v-bind="componentField" :items="props.division.items"
|
id="parentId" v-bind="componentField" :items="props.division.items"
|
||||||
:placeholder="props.division.msg.placeholder" :search-placeholder="props.division.msg.search"
|
:placeholder="props.division.msg.placeholder" :search-placeholder="props.division.msg.search"
|
||||||
:empty-message="props.division.msg.empty"
|
:empty-message="props.division.msg.empty"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -12,31 +12,11 @@ type SmallDetailDto = any
|
|||||||
|
|
||||||
const action = defineAsyncComponent(() => import('~/components/pub/custom-ui/data/dropdown-action-ud.vue'))
|
const action = defineAsyncComponent(() => import('~/components/pub/custom-ui/data/dropdown-action-ud.vue'))
|
||||||
|
|
||||||
export const cols: Col[] = [
|
export const cols: Col[] = [{ width: 100 }, {}, {}, { width: 50 }]
|
||||||
{ width: 100 },
|
|
||||||
{ },
|
|
||||||
{ },
|
|
||||||
{ },
|
|
||||||
{ width: 50 },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const header: Th[][] = [
|
export const header: Th[][] = [[{ label: 'Kode' }, { label: 'Nama' }, { label: 'Divisi Induk' }, { label: '' }]]
|
||||||
[
|
|
||||||
{ label: 'Id' },
|
|
||||||
{ label: 'Nama' },
|
|
||||||
{ label: 'Kode' },
|
|
||||||
{ label: 'Kelompok' },
|
|
||||||
{ label: '' },
|
|
||||||
],
|
|
||||||
]
|
|
||||||
|
|
||||||
export const keys = [
|
export const keys = ['code', 'name', 'ancestor', 'action']
|
||||||
'id',
|
|
||||||
'firstName',
|
|
||||||
'cellphone',
|
|
||||||
'birth_place',
|
|
||||||
'action',
|
|
||||||
]
|
|
||||||
|
|
||||||
export const delKeyNames: KeyLabel[] = [
|
export const delKeyNames: KeyLabel[] = [
|
||||||
{ key: 'code', label: 'Kode' },
|
{ key: 'code', label: 'Kode' },
|
||||||
@@ -44,24 +24,13 @@ export const delKeyNames: KeyLabel[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export const funcParsed: RecStrFuncUnknown = {
|
export const funcParsed: RecStrFuncUnknown = {
|
||||||
name: (rec: unknown): unknown => {
|
ancestor: (rec: unknown): unknown => {
|
||||||
const recX = rec as SmallDetailDto
|
const recX = rec as SmallDetailDto
|
||||||
return `${recX.frontTitle} ${recX.name} ${recX.endTitle}`.trim()
|
if (recX.meta === null) {
|
||||||
},
|
return '-'
|
||||||
identity_number: (rec: unknown): unknown => {
|
|
||||||
const recX = rec as SmallDetailDto
|
|
||||||
if (recX.identity_number?.substring(0, 5) === 'BLANK') {
|
|
||||||
return '(TANPA NIK)'
|
|
||||||
}
|
}
|
||||||
return recX.identity_number
|
|
||||||
},
|
return recX.meta.name
|
||||||
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')
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -113,7 +113,7 @@ onMounted(() => {
|
|||||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-2">
|
<div class="mt-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
<h2 class="text-2xl font-bold tracking-tight">Dashboard SIMRS</h2>
|
<h2 class="text-2xl font-bold tracking-tight">Dashboard SIMRS</h2>
|
||||||
<div class="flex items-center gap-4 space-x-2">
|
<div class="flex items-center gap-4 space-x-2">
|
||||||
<div class="bg-primary rounded-xl border p-1 text-white">Status: Aktif</div>
|
<div class="rounded-xl border bg-primary p-1 text-white">Status: Aktif</div>
|
||||||
<Button class="bg-primary p-2 text-white" size="lg">
|
<Button class="bg-primary p-2 text-white" size="lg">
|
||||||
<Icon name="i-lucide-refresh-ccw" class="h-6 w-6" />
|
<Icon name="i-lucide-refresh-ccw" class="h-6 w-6" />
|
||||||
Sinkronisasi
|
Sinkronisasi
|
||||||
@@ -121,10 +121,10 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<main class="my-6 flex flex-1 flex-col gap-4 md:gap-8">
|
<main class="my-6 flex flex-1 flex-col gap-4 md:gap-8">
|
||||||
<div class="grid gap-4 md:grid-cols-2 md:gap-8 lg:grid-cols-4">
|
<div class="dashboard-grid">
|
||||||
<PubBaseSummaryCard v-for="card in summaryData" :key="card.title" :stat="card" />
|
<PubBaseSummaryCard v-for="card in summaryData" :key="card.title" :stat="card" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-4 md:gap-8 lg:grid-cols-1 xl:grid-cols-3">
|
<div class="dashboard-grid">
|
||||||
<Card v-for="n in 3" :key="n">
|
<Card v-for="n in 3" :key="n">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Recent Sales</CardTitle>
|
<CardTitle>Recent Sales</CardTitle>
|
||||||
@@ -143,7 +143,7 @@ onMounted(() => {
|
|||||||
<p class="text-sm font-medium leading-none">
|
<p class="text-sm font-medium leading-none">
|
||||||
{{ recentSales.name }}
|
{{ recentSales.name }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-muted-foreground text-sm">
|
<p class="text-sm text-muted-foreground">
|
||||||
{{ recentSales.email }}
|
{{ recentSales.email }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,19 +156,19 @@ onMounted(() => {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<Icon name="i-lucide-activity" class="text-primary me-2 h-6 w-6" />
|
<Icon name="i-lucide-activity" class="me-2 h-6 w-6 text-primary" />
|
||||||
<h2 class="text-xl font-semibold tracking-tight">Aksi Cepat</h2>
|
<h2 class="text-xl font-semibold tracking-tight">Aksi Cepat</h2>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="grid cursor-pointer gap-8 md:grid-cols-4 md:gap-8">
|
<CardContent class="grid cursor-pointer gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-5">
|
||||||
<Card
|
<Card
|
||||||
v-for="item in linkItems"
|
v-for="item in linkItems"
|
||||||
:key="item.title"
|
:key="item.title"
|
||||||
class="border-primary hover:bg-primary my-2 h-32 border transition-colors duration-200 hover:bg-gray-200"
|
class="my-2 h-32 border border-primary transition-colors duration-200 hover:bg-gray-200 hover:bg-primary"
|
||||||
>
|
>
|
||||||
<NuxtLink :to="item.link">
|
<NuxtLink :to="item.link">
|
||||||
<CardContent class="my-2 grid h-full grid-rows-2 place-items-center">
|
<CardContent class="my-2 grid h-full grid-rows-2 place-items-center">
|
||||||
<Icon :name="item.icon" class="text-primary h-9 w-[60px]" />
|
<Icon :name="item.icon" class="h-9 w-[60px] text-primary" />
|
||||||
<h1>{{ item.title }}</h1>
|
<h1>{{ item.title }}</h1>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|||||||
@@ -1,58 +1,145 @@
|
|||||||
|
import type { TreeItem } from '~/components/pub/base/select-tree/type'
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
|
|
||||||
export const division = {
|
export const divisionConf = {
|
||||||
msg: {
|
msg: {
|
||||||
placeholder: '---pilih divisi utama',
|
placeholder: '---pilih divisi utama',
|
||||||
search: 'kode, nama divisi',
|
search: 'kode, nama divisi',
|
||||||
empty: 'divisi tidak ditemukan',
|
empty: 'divisi tidak ditemukan',
|
||||||
},
|
},
|
||||||
items: [
|
items: [
|
||||||
{ value: '1', label: 'Medical', code: 'MED' },
|
{ value: '1', label: 'Medical' },
|
||||||
{ value: '2', label: 'Nursing', code: 'NUR' },
|
{ value: '2', label: 'Nursing' },
|
||||||
{ value: '3', label: 'Admin', code: 'ADM' },
|
{ value: '3', label: 'Admin' },
|
||||||
{ value: '4', label: 'Support', code: 'SUP' },
|
{ value: '4', label: 'Support' },
|
||||||
{ value: '5', label: 'Education', code: 'EDU' },
|
{ value: '5', label: 'Education' },
|
||||||
{ value: '6', label: 'Pharmacy', code: 'PHA' },
|
{ value: '6', label: 'Pharmacy' },
|
||||||
{ value: '7', label: 'Radiology', code: 'RAD' },
|
{ value: '7', label: 'Radiology' },
|
||||||
{ value: '8', label: 'Laboratory', code: 'LAB' },
|
{ value: '8', label: 'Laboratory' },
|
||||||
{ value: '9', label: 'Finance', code: 'FIN' },
|
{ value: '9', label: 'Finance' },
|
||||||
{ value: '10', label: 'Human Resources', code: 'HR' },
|
{ value: '10', label: 'Human Resources' },
|
||||||
{ value: '11', label: 'IT Services', code: 'ITS' },
|
{ value: '11', label: 'IT Services' },
|
||||||
{ value: '12', label: 'Maintenance', code: 'MNT' },
|
{ value: '12', label: 'Maintenance' },
|
||||||
{ value: '13', label: 'Catering', code: 'CAT' },
|
{ value: '13', label: 'Catering' },
|
||||||
{ value: '14', label: 'Security', code: 'SEC' },
|
{ value: '14', label: 'Security' },
|
||||||
{ value: '15', label: 'Emergency', code: 'EMR' },
|
{ value: '15', label: 'Emergency' },
|
||||||
{ value: '16', label: 'Surgery', code: 'SUR' },
|
{ value: '16', label: 'Surgery' },
|
||||||
{ value: '17', label: 'Outpatient', code: 'OUT' },
|
{ value: '17', label: 'Outpatient' },
|
||||||
{ value: '18', label: 'Inpatient', code: 'INP' },
|
{ value: '18', label: 'Inpatient' },
|
||||||
{ value: '19', label: 'Rehabilitation', code: 'REB' },
|
{ value: '19', label: 'Rehabilitation' },
|
||||||
{ value: '20', label: 'Research', code: 'RSH' },
|
{ value: '20', label: 'Research' },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const schema = z.object({
|
export const schema = z.object({
|
||||||
name: z.string({
|
name: z
|
||||||
required_error: 'Nama wajib diisi',
|
.string({
|
||||||
}).min(1, 'Nama divisi wajib diisi'),
|
required_error: 'Nama wajib diisi',
|
||||||
|
})
|
||||||
|
.min(1, 'Nama divisi wajib diisi'),
|
||||||
|
|
||||||
code: z.string({
|
code: z
|
||||||
required_error: 'Kode wajib diisi',
|
.string({
|
||||||
}).min(1, 'Kode divisi wajib diisi'),
|
required_error: 'Kode wajib diisi',
|
||||||
|
})
|
||||||
|
.min(1, 'Kode divisi wajib diisi'),
|
||||||
|
|
||||||
parentId: z.preprocess(
|
parentId: z.string().optional(),
|
||||||
(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'),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}))
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HeaderPrep } from '~/components/pub/custom-ui/data/types'
|
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 Dialog from '~/components/pub/base/modal/dialog.vue'
|
||||||
import RecordConfirmation from '~/components/pub/custom-ui/confirmation/record-confirmation.vue'
|
import RecordConfirmation from '~/components/pub/custom-ui/confirmation/record-confirmation.vue'
|
||||||
import { ActionEvents } from '~/components/pub/custom-ui/data/types'
|
import { ActionEvents } from '~/components/pub/custom-ui/data/types'
|
||||||
import Header from '~/components/pub/custom-ui/nav-header/header.vue'
|
import Header from '~/components/pub/custom-ui/nav-header/header.vue'
|
||||||
import { usePaginatedList } from '~/composables/usePaginatedList'
|
import { usePaginatedList } from '~/composables/usePaginatedList'
|
||||||
import { division as divisionConf, schema as schemaConf } from './entry'
|
import { divisionConf, divisionTreeConfig, schema } from './entry'
|
||||||
|
|
||||||
// #region State & Computed
|
// #region State & Computed
|
||||||
// Dialog state
|
// Dialog state
|
||||||
const isFormEntryDialogOpen = ref(false)
|
const isFormEntryDialogOpen = ref(false)
|
||||||
@@ -18,8 +17,8 @@ const recId = ref<number>(0)
|
|||||||
const recAction = ref<string>('')
|
const recAction = ref<string>('')
|
||||||
const recItem = ref<any>(null)
|
const recItem = ref<any>(null)
|
||||||
|
|
||||||
async function fetchDivisionData(params: any) {
|
async function fetchDivisionData(_params: any) {
|
||||||
const endpoint = transform('/api/v1/patient', params)
|
const endpoint = '/api/v1/_dev/division/list'
|
||||||
return await xfetch(endpoint)
|
return await xfetch(endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,17 +181,19 @@ function handleCancelConfirmation() {
|
|||||||
<AppDivisonList :data="data" :pagination-meta="paginationMeta" @page-change="handlePageChange" />
|
<AppDivisonList :data="data" :pagination-meta="paginationMeta" @page-change="handlePageChange" />
|
||||||
|
|
||||||
<Dialog v-model:open="isFormEntryDialogOpen" title="Tambah Divisi" size="lg" prevent-outside>
|
<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"
|
:initial-values="{ name: '', code: '', parentId: '' }" @submit="onSubmitForm" @cancel="onCancelForm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<!-- Record Confirmation Modal -->
|
<!-- Record Confirmation Modal -->
|
||||||
<RecordConfirmation
|
<RecordConfirmation
|
||||||
v-model:open="isRecordConfirmationOpen" action="delete" :record="recItem"
|
v-model:open="isRecordConfirmationOpen" action="delete" :record="recItem"
|
||||||
@confirm="handleConfirmDelete" @cancel="handleCancelConfirmation"
|
@confirm="handleConfirmDelete" @cancel="handleCancelConfirmation"
|
||||||
>
|
>
|
||||||
<template #default="{ record }">
|
<template #default="{ record }">
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<p><strong>ID:</strong> {{ record?.id }}</p>
|
<p><strong>ID:</strong> {{ record?.id }}</p>
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import ThemeToggle from "./ThemeToggle.vue"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
function setLinks() {
|
function setLinks() {
|
||||||
if (route.fullPath === '/') {
|
if (route.path === '/') {
|
||||||
return [{ title: 'Home', href: '/' }]
|
return [{ title: 'Home', href: '/' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
const segments = route.fullPath.split('/').filter((item) => item !== '')
|
const segments = route.path.split('/').filter((item) => item !== '')
|
||||||
|
|
||||||
const breadcrumbs = segments.map((item, index) => {
|
const breadcrumbs = segments.map((item, index) => {
|
||||||
const str = item.replace(/-/g, ' ')
|
// Bersihkan query parameters dari segment jika ada
|
||||||
|
const cleanItem = item.split('?')[0] || item
|
||||||
|
const str = cleanItem.replace(/-/g, ' ')
|
||||||
const title = str
|
const title = str
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||||
@@ -17,7 +21,7 @@ function setLinks() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
href: `/${segments.slice(0, index + 1).join('/')}`,
|
href: `/${segments.slice(0, index + 1).map(seg => seg.split('?')[0] || seg).join('/')}`,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -32,7 +36,7 @@ const links = ref<
|
|||||||
>(setLinks())
|
>(setLinks())
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.fullPath,
|
() => route.path,
|
||||||
(val) => {
|
(val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
links.value = setLinks()
|
links.value = setLinks()
|
||||||
@@ -46,11 +50,12 @@ watch(
|
|||||||
<div class="flex w-full items-center gap-4">
|
<div class="flex w-full items-center gap-4">
|
||||||
<SidebarTrigger />
|
<SidebarTrigger />
|
||||||
<Separator orientation="vertical" class="h-4" />
|
<Separator orientation="vertical" class="h-4" />
|
||||||
<!-- <BaseBreadcrumbCustom :links="links" /> -->
|
<PubBaseBreadcrumb :links="links" />
|
||||||
</div>
|
|
||||||
<div class="ml-auto">
|
|
||||||
<slot />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ml-auto flex items-center gap-2">
|
||||||
|
<slot />
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const openCollapsible = ref(false)
|
|||||||
<SidebarMenuSubButton as-child>
|
<SidebarMenuSubButton as-child>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="subItem.link"
|
:to="subItem.link"
|
||||||
class="mx-4 rounded-lg py-5 text-sm font-medium transition-all duration-200"
|
class="mx-4 rounded-lg py-5 text-sm transition-all duration-200"
|
||||||
active-class="bg-primary text-white"
|
active-class="bg-primary text-white"
|
||||||
@click="setOpenMobile(false)"
|
@click="setOpenMobile(false)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const { setOpenMobile } = useSidebar()
|
|||||||
<SidebarMenuButton as-child :tooltip="item.title" :size="size" class="">
|
<SidebarMenuButton as-child :tooltip="item.title" :size="size" class="">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="item.link"
|
:to="item.link"
|
||||||
class="group flex items-center gap-3 rounded-lg px-2 py-4 text-sm font-medium transition-all duration-200"
|
class="group flex items-center gap-3 rounded-lg px-2 py-4 text-sm transition-all duration-200"
|
||||||
active-class="bg-primary text-white"
|
active-class="bg-primary text-white"
|
||||||
@click="setOpenMobile(false)"
|
@click="setOpenMobile(false)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useTheme } from '~/composables/useTheme'
|
||||||
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
@click="toggleTheme"
|
||||||
|
class="ml-2 rounded border px-2 py-1 text-sm"
|
||||||
|
:title="theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||||
|
>
|
||||||
|
<span v-if="theme === 'dark'"><Icon name="i-lucide-moon" class="h-4 w-4 mt-1" /></span>
|
||||||
|
<span v-else><Icon name="i-lucide-sun" class="h-4 w-4 mt-1" /></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from '~/components/pub/ui/breadcrumb'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
links: {
|
||||||
|
title: string
|
||||||
|
href: string
|
||||||
|
}[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem v-for="(link, index) in props.links" :key="link.href">
|
||||||
|
<BreadcrumbLink as-child>
|
||||||
|
<NuxtLink :to="link.href">
|
||||||
|
{{ link.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
</BreadcrumbLink>
|
||||||
|
|
||||||
|
<BreadcrumbSeparator v-if="index < props.links.length - 1" />
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
|
||||||
|
</Breadcrumb>
|
||||||
|
</template>
|
||||||
@@ -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[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// ---------- Imports ----------
|
||||||
|
import { computed, useSlots } from 'vue'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
const props = defineProps({
|
||||||
|
mode: { type: String, default: 'entry' },
|
||||||
|
gridPoint: { type: String, default: 'lg' },
|
||||||
|
cellFlex: { type: Boolean, default: true },
|
||||||
|
cellFlexPoint: { type: String, default: 'md' },
|
||||||
|
labelSize: { type: String, default: 'medium' },
|
||||||
|
labelSizePoint: { type: String, default: 'md' },
|
||||||
|
colCount: { type: Number, default: 1 },
|
||||||
|
defaultClass: { type: String, default: 'mb-5' },
|
||||||
|
class: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const slots = useSlots()
|
||||||
|
|
||||||
|
// Utility functions (minimal, can be expanded)
|
||||||
|
const breakpoints = ['grid', 'sm:grid', 'md:grid', 'lg:grid', 'xl:grid', '2xl:grid']
|
||||||
|
const getBreakpointIdx = (point: string) => {
|
||||||
|
return Math.max(0, breakpoints.findIndex(bp => bp.startsWith(point)))
|
||||||
|
}
|
||||||
|
const labelSizes = ['small', 'medium', 'large', 'xl', '2xl']
|
||||||
|
const getLabelSizeIdx = (size: string) => {
|
||||||
|
return Math.max(0, labelSizes.findIndex(s => s === size))
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingClass = computed(() => {
|
||||||
|
const breakPointIdx = getBreakpointIdx(props.gridPoint)
|
||||||
|
let cls = breakpoints[breakPointIdx]
|
||||||
|
cls += ' gap-4 xl:gap-5 ' + [
|
||||||
|
'grid-cols-1', 'grid-cols-2', 'grid-cols-3', 'grid-cols-4', 'grid-cols-5',
|
||||||
|
'grid-cols-6', 'grid-cols-7', 'grid-cols-8', 'grid-cols-9', 'grid-cols-10',
|
||||||
|
][props.colCount - 1]
|
||||||
|
cls += breakPointIdx === 0 ? ' gap-3 ' : ''
|
||||||
|
cls += ' ' + [
|
||||||
|
' [&_.cell]:!mb-0',
|
||||||
|
' [&_.cell]:mb-2.5 [&_.cell]:sm:mb-0',
|
||||||
|
' [&_.cell]:mb-2.5 [&_.cell]:md:mb-0',
|
||||||
|
' [&_.cell]:mb-2.5 [&_.cell]:lg:mb-0',
|
||||||
|
' [&_.cell]:mb-3 [&_.cell]:xl:mb-0',
|
||||||
|
' [&_.cell]:mb-3 [&_.cell]:2xl:mb-0',
|
||||||
|
][breakPointIdx]
|
||||||
|
if (props.cellFlex) {
|
||||||
|
cls += ' ' + [
|
||||||
|
'[&_.cell]:flex',
|
||||||
|
'[&_.cell]:sm:flex',
|
||||||
|
'[&_.cell]:md:flex',
|
||||||
|
'[&_.cell]:lg:flex',
|
||||||
|
'[&_.cell]:xl:flex',
|
||||||
|
'[&_.cell]:2xl:flex',
|
||||||
|
][getBreakpointIdx(props.cellFlexPoint)]
|
||||||
|
cls += ' [&_.label]:sm:pt-2 ' + [
|
||||||
|
'[&_.label]:md:w-12 [&_.label]:xl:w-20',
|
||||||
|
'[&_.label]:md:w-16 [&_.label]:xl:w-24',
|
||||||
|
'[&_.label]:md:w-24 [&_.label]:xl:w-32',
|
||||||
|
'[&_.label]:md:w-32 [&_.label]:xl:w-40',
|
||||||
|
'[&_.label]:md:w-44 [&_.label]:xl:w-52',
|
||||||
|
][getLabelSizeIdx(props.labelSize)]
|
||||||
|
}
|
||||||
|
cls += ' [&_.height-default]:pt-2 [&_.height-default]:2xl:!pt-1.5 [&_.height-compact]:!pt-1 '
|
||||||
|
cls += '[&_textarea]:text-xs [&_textarea]:xl:text-sm '
|
||||||
|
cls += '[&_label]:text-xs [&_label]:xl:text-sm'
|
||||||
|
return cls
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="`block ${props.defaultClass} ${settingClass} ${props.class}`">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
colSpan: { type: Number, default: undefined },
|
||||||
|
colStart: { type: Number, default: undefined },
|
||||||
|
colEnd: { type: Number, default: undefined },
|
||||||
|
class: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const settingClass = computed(() => {
|
||||||
|
let cls = 'cell'
|
||||||
|
if (props.colSpan) {
|
||||||
|
cls += ' ' + [
|
||||||
|
'col-span-1', 'col-span-2', 'col-span-3', 'col-span-4', 'col-span-5',
|
||||||
|
'col-span-6', 'col-span-7', 'col-span-8', 'col-span-9', 'col-span-10',
|
||||||
|
][props.colSpan - 1]
|
||||||
|
}
|
||||||
|
if (props.colStart) {
|
||||||
|
cls += ' ' + [
|
||||||
|
'col-start-1', 'col-start-2', 'col-start-3', 'col-start-4', 'col-start-5',
|
||||||
|
'col-start-6', 'col-start-7', 'col-start-8', 'col-start-9', 'col-start-10',
|
||||||
|
][props.colStart - 1]
|
||||||
|
}
|
||||||
|
if (props.colEnd) {
|
||||||
|
cls += ' ' + [
|
||||||
|
'col-end-1', 'col-end-2', 'col-end-3', 'col-end-4', 'col-end-5',
|
||||||
|
'col-end-6', 'col-end-7', 'col-end-8', 'col-end-9', 'col-end-10',
|
||||||
|
][props.colEnd - 1]
|
||||||
|
}
|
||||||
|
if (props.class) {
|
||||||
|
cls += ' ' + props.class.trim()
|
||||||
|
}
|
||||||
|
return cls
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="settingClass">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps({
|
||||||
|
errMessage: { type: String, default: '' },
|
||||||
|
defaultClass: { type: String, default: 'field grow shrink-0 overflow-hidden' },
|
||||||
|
class: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="`${props.defaultClass} ${props.class}`">
|
||||||
|
<slot />
|
||||||
|
<div v-if="props.errMessage" class="text-red-500">{{ props.errMessage }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
height: { type: String, default: 'default' }, // 'default' | 'compact'
|
||||||
|
position: { type: String, default: 'default' }, // 'default' | 'dynamic'
|
||||||
|
positionPoint: { type: String, default: 'lg' },
|
||||||
|
class: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const breakpoints = ['','sm','md','lg','xl','2xl']
|
||||||
|
const getBreakpointIdx = (point: string) => {
|
||||||
|
return Math.max(0, breakpoints.findIndex(bp => bp === point))
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingClass = computed(() => {
|
||||||
|
let cls = 'label'
|
||||||
|
cls += props.height === 'compact' ? ' height-compact ' : ' height-default '
|
||||||
|
if (props.position === 'dynamic') {
|
||||||
|
cls += ' ' + [
|
||||||
|
'text-end pe-2.5',
|
||||||
|
'sm:text-end pe-2.5',
|
||||||
|
'md:text-end pe-2.5',
|
||||||
|
'lg:text-end pe-2.5',
|
||||||
|
'xl:text-end pe-2.5',
|
||||||
|
'2xl:text-end pe-2.5',
|
||||||
|
][getBreakpointIdx(props.positionPoint)]
|
||||||
|
}
|
||||||
|
return cls + ' ' + (props.class?.trim() || '')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="settingClass">
|
||||||
|
<label>
|
||||||
|
<slot />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { ref, watchEffect } from 'vue'
|
||||||
|
|
||||||
|
const THEME_KEY = 'theme-mode'
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const theme = ref<'light' | 'dark'>(getInitialTheme())
|
||||||
|
|
||||||
|
function getInitialTheme() {
|
||||||
|
if (typeof window === 'undefined') return 'light'
|
||||||
|
const persisted = localStorage.getItem(THEME_KEY)
|
||||||
|
if (persisted === 'dark' || persisted === 'light') return persisted
|
||||||
|
// fallback: system preference
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTheme(newTheme: 'light' | 'dark') {
|
||||||
|
theme.value = newTheme
|
||||||
|
localStorage.setItem(THEME_KEY, newTheme)
|
||||||
|
document.documentElement.classList.toggle('dark', newTheme === 'dark')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
setTheme(theme.value === 'dark' ? 'light' : 'dark')
|
||||||
|
}
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
setTheme(theme.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { theme, toggleTheme }
|
||||||
|
}
|
||||||
+49
-7
@@ -45,6 +45,8 @@ const contentContent = computed(() => {
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
padding-bottom: 5rem;
|
padding-bottom: 5rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cf-container > *,
|
.cf-container > *,
|
||||||
@@ -56,9 +58,9 @@ const contentContent = computed(() => {
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
padding: 0.75rem; /* p-3 */
|
padding: 0.75rem; /* p-3 */
|
||||||
padding-bottom: 5rem; /* pb-20 */
|
padding-bottom: 5rem; /* pb-20 */
|
||||||
border-width: 1px;
|
background-color: hsl(var(--background));
|
||||||
background-color: white !important;
|
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
border-color: rgb(226 232 240); /* slate-200 */
|
border-color: rgb(226 232 240); /* slate-200 */
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
@@ -78,18 +80,58 @@ const contentContent = computed(() => {
|
|||||||
.cf-frame-width {
|
.cf-frame-width {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
background-color: white;
|
background-color: hsl(var(--background));
|
||||||
border: 1px solid rgb(226 232 240);
|
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cf-frame {
|
.cf-frame {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
padding: 0.75rem;
|
padding: 1rem;
|
||||||
background-color: white;
|
background-color: hsl(var(--background));
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
border: 1px solid rgb(226 232 240);
|
border: 1px solid hsl(var(--border));
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.cf-container,
|
||||||
|
.cf-container-lg,
|
||||||
|
.cf-container-md,
|
||||||
|
.cf-container-sm {
|
||||||
|
padding-left: 2rem;
|
||||||
|
padding-right: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-frame {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-frame-width {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.cf-container,
|
||||||
|
.cf-container-lg,
|
||||||
|
.cf-container-md,
|
||||||
|
.cf-container-sm {
|
||||||
|
padding-left: 3rem;
|
||||||
|
padding-right: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-frame {
|
||||||
|
padding: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-frame-width {
|
||||||
|
padding: 3rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,81 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import TreeSelect from '~/components/pub/base/select-tree/tree-select.vue'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'blank',
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
</template>
|
||||||
|
|||||||
@@ -15,6 +15,28 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
|
||||||
|
// SPA optimizations
|
||||||
|
router: {
|
||||||
|
options: {
|
||||||
|
hashMode: false, // Use history mode for cleaner URLs
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Enable client-side rendering optimizations
|
||||||
|
nitro: {
|
||||||
|
prerender: {
|
||||||
|
crawlLinks: false, // Disable crawling for SPA
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Optimize app loading
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
viewport: 'width=device-width,initial-scale=1',
|
||||||
|
charset: 'utf-8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
modules: [
|
modules: [
|
||||||
'@unocss/nuxt',
|
'@unocss/nuxt',
|
||||||
'@vueuse/nuxt',
|
'@vueuse/nuxt',
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
"link": "/rehabilitasi",
|
"link": "/rehabilitasi",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"title": "Antrian Pendaftaran",
|
"title": "Antrian Poliklinik",
|
||||||
"icon": "i-lucide-stethoscope",
|
"icon": "i-lucide-stethoscope",
|
||||||
"link": "/rehab/examination-queue"
|
"link": "/rehab/examination-queue"
|
||||||
},
|
},
|
||||||
@@ -171,14 +171,33 @@
|
|||||||
{
|
{
|
||||||
"title": "BPJS",
|
"title": "BPJS",
|
||||||
"icon": "i-lucide-circuit-board",
|
"icon": "i-lucide-circuit-board",
|
||||||
"link": "/integration/bpjs",
|
"children": [
|
||||||
"badge": "Live"
|
{
|
||||||
|
"title": "SEP",
|
||||||
|
"icon": "i-lucide-circuit-board",
|
||||||
|
"link": "/bpjs-integration/sep"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Peserta",
|
||||||
|
"icon": "i-lucide-circuit-board",
|
||||||
|
"link": "/bpjs-integration/member"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "SATUSEHAT",
|
"title": "SATUSEHAT",
|
||||||
"icon": "i-lucide-database",
|
"icon": "i-lucide-database",
|
||||||
"link": "/integration/satusehat",
|
"link": "/satusehat-integration"
|
||||||
"badge": "FHIR"
|
},
|
||||||
|
{
|
||||||
|
"heading": "Keuangan",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"title": "Daftar harga",
|
||||||
|
"icon": "i-lucide-list",
|
||||||
|
"link": "/item"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -306,4 +325,4 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user