Merge branch 'dev' of https://github.com/dikstub-rssa/simrs-fe into fe-integrasi-device-material-65

This commit is contained in:
riefive
2025-09-24 11:55:19 +07:00
28 changed files with 1326 additions and 170 deletions
+64 -36
View File
@@ -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;
}
}
+24 -5
View File
@@ -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>
+8 -39
View File
@@ -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')
}, },
} }
+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>
+11 -11
View File
@@ -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>
+130 -43
View File
@@ -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,
}))
+12 -11
View File
@@ -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>
+14 -9
View File
@@ -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>
+1 -1
View File
@@ -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)"
> >
+1 -1
View File
@@ -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)"
> >
+15
View File
@@ -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>
+31
View File
@@ -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
View File
@@ -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>
+73 -1
View File
@@ -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>
+22
View File
@@ -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',
+25 -6
View File
@@ -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 @@
} }
] ]
} }
] ]
+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',
},
}
})