Merge branch 'dev' of https://github.com/dikstub-rssa/simrs-fe into fe-integrasi-device-material-65
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { TreeItem } from '~/components/pub/base/select-tree/type'
|
||||
import type { FormErrors } from '~/types/error'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import TreeSelect from '~/components/pub/base/select-tree/tree-select.vue'
|
||||
import Combobox from '~/components/pub/custom-ui/form/combobox.vue'
|
||||
import FieldGroup from '~/components/pub/custom-ui/form/field-group.vue'
|
||||
import Field from '~/components/pub/custom-ui/form/field.vue'
|
||||
@@ -11,7 +13,6 @@ interface DivisionFormData {
|
||||
code: string
|
||||
parentId: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
division: {
|
||||
msg: {
|
||||
@@ -22,9 +23,17 @@ const props = defineProps<{
|
||||
items: {
|
||||
value: string
|
||||
label: string
|
||||
code: string
|
||||
}[]
|
||||
}
|
||||
divisionTree?: {
|
||||
msg: {
|
||||
placeholder: string
|
||||
search: string
|
||||
empty: string
|
||||
}
|
||||
data: TreeItem[]
|
||||
onFetchChildren: (parentId: string) => Promise<void>
|
||||
}
|
||||
schema: any
|
||||
initialValues?: Partial<DivisionFormData>
|
||||
errors?: FormErrors
|
||||
@@ -92,17 +101,27 @@ function onCancelForm({ resetForm }: { resetForm: () => void }) {
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup :column="2">
|
||||
<Label label-for="parentId">Kelompok</Label>
|
||||
<FieldGroup>
|
||||
<Label label-for="parentId">Divisi Induk</Label>
|
||||
<Field id="parentId" :errors="errors">
|
||||
<FormField v-slot="{ componentField }" name="parentId">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<!-- Gunakan TreeSelect jika divisionTree tersedia, fallback ke Combobox -->
|
||||
<TreeSelect
|
||||
v-if="props.divisionTree"
|
||||
id="parentId"
|
||||
:model-value="componentField.modelValue"
|
||||
:data="props.divisionTree.data"
|
||||
:on-fetch-children="props.divisionTree.onFetchChildren"
|
||||
@update:model-value="componentField.onChange"
|
||||
/>
|
||||
<Combobox
|
||||
v-else
|
||||
id="parentId" v-bind="componentField" :items="props.division.items"
|
||||
:placeholder="props.division.msg.placeholder" :search-placeholder="props.division.msg.search"
|
||||
:empty-message="props.division.msg.empty"
|
||||
/>
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -12,31 +12,11 @@ type SmallDetailDto = any
|
||||
|
||||
const action = defineAsyncComponent(() => import('~/components/pub/custom-ui/data/dropdown-action-ud.vue'))
|
||||
|
||||
export const cols: Col[] = [
|
||||
{ width: 100 },
|
||||
{ },
|
||||
{ },
|
||||
{ },
|
||||
{ width: 50 },
|
||||
]
|
||||
export const cols: Col[] = [{ width: 100 }, {}, {}, { width: 50 }]
|
||||
|
||||
export const header: Th[][] = [
|
||||
[
|
||||
{ label: 'Id' },
|
||||
{ label: 'Nama' },
|
||||
{ label: 'Kode' },
|
||||
{ label: 'Kelompok' },
|
||||
{ label: '' },
|
||||
],
|
||||
]
|
||||
export const header: Th[][] = [[{ label: 'Kode' }, { label: 'Nama' }, { label: 'Divisi Induk' }, { label: '' }]]
|
||||
|
||||
export const keys = [
|
||||
'id',
|
||||
'firstName',
|
||||
'cellphone',
|
||||
'birth_place',
|
||||
'action',
|
||||
]
|
||||
export const keys = ['code', 'name', 'ancestor', 'action']
|
||||
|
||||
export const delKeyNames: KeyLabel[] = [
|
||||
{ key: 'code', label: 'Kode' },
|
||||
@@ -44,24 +24,13 @@ export const delKeyNames: KeyLabel[] = [
|
||||
]
|
||||
|
||||
export const funcParsed: RecStrFuncUnknown = {
|
||||
name: (rec: unknown): unknown => {
|
||||
ancestor: (rec: unknown): unknown => {
|
||||
const recX = rec as SmallDetailDto
|
||||
return `${recX.frontTitle} ${recX.name} ${recX.endTitle}`.trim()
|
||||
},
|
||||
identity_number: (rec: unknown): unknown => {
|
||||
const recX = rec as SmallDetailDto
|
||||
if (recX.identity_number?.substring(0, 5) === 'BLANK') {
|
||||
return '(TANPA NIK)'
|
||||
if (recX.meta === null) {
|
||||
return '-'
|
||||
}
|
||||
return recX.identity_number
|
||||
},
|
||||
inPatient_itemPrice: (rec: unknown): unknown => {
|
||||
const recX = rec as SmallDetailDto
|
||||
return Number(recX.inPatient_itemPrice.price).toLocaleString('id-ID')
|
||||
},
|
||||
outPatient_itemPrice: (rec: unknown): unknown => {
|
||||
const recX = rec as SmallDetailDto
|
||||
return Number(recX.outPatient_itemPrice.price).toLocaleString('id-ID')
|
||||
|
||||
return recX.meta.name
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
<h2 class="text-2xl font-bold tracking-tight">Dashboard SIMRS</h2>
|
||||
<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">
|
||||
<Icon name="i-lucide-refresh-ccw" class="h-6 w-6" />
|
||||
Sinkronisasi
|
||||
@@ -121,10 +121,10 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
<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" />
|
||||
</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">
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Sales</CardTitle>
|
||||
@@ -143,7 +143,7 @@ onMounted(() => {
|
||||
<p class="text-sm font-medium leading-none">
|
||||
{{ recentSales.name }}
|
||||
</p>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ recentSales.email }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -156,19 +156,19 @@ onMounted(() => {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<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>
|
||||
</div>
|
||||
</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
|
||||
v-for="item in linkItems"
|
||||
:key="item.title"
|
||||
class="border-primary hover:bg-primary my-2 h-32 border transition-colors duration-200 hover:bg-gray-200"
|
||||
>
|
||||
v-for="item in linkItems"
|
||||
:key="item.title"
|
||||
class="my-2 h-32 border border-primary transition-colors duration-200 hover:bg-gray-200 hover:bg-primary"
|
||||
>
|
||||
<NuxtLink :to="item.link">
|
||||
<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>
|
||||
</CardContent>
|
||||
</NuxtLink>
|
||||
|
||||
@@ -1,58 +1,145 @@
|
||||
import type { TreeItem } from '~/components/pub/base/select-tree/type'
|
||||
import * as z from 'zod'
|
||||
|
||||
export const division = {
|
||||
export const divisionConf = {
|
||||
msg: {
|
||||
placeholder: '---pilih divisi utama',
|
||||
search: 'kode, nama divisi',
|
||||
empty: 'divisi tidak ditemukan',
|
||||
},
|
||||
items: [
|
||||
{ value: '1', label: 'Medical', code: 'MED' },
|
||||
{ value: '2', label: 'Nursing', code: 'NUR' },
|
||||
{ value: '3', label: 'Admin', code: 'ADM' },
|
||||
{ value: '4', label: 'Support', code: 'SUP' },
|
||||
{ value: '5', label: 'Education', code: 'EDU' },
|
||||
{ value: '6', label: 'Pharmacy', code: 'PHA' },
|
||||
{ value: '7', label: 'Radiology', code: 'RAD' },
|
||||
{ value: '8', label: 'Laboratory', code: 'LAB' },
|
||||
{ value: '9', label: 'Finance', code: 'FIN' },
|
||||
{ value: '10', label: 'Human Resources', code: 'HR' },
|
||||
{ value: '11', label: 'IT Services', code: 'ITS' },
|
||||
{ value: '12', label: 'Maintenance', code: 'MNT' },
|
||||
{ value: '13', label: 'Catering', code: 'CAT' },
|
||||
{ value: '14', label: 'Security', code: 'SEC' },
|
||||
{ value: '15', label: 'Emergency', code: 'EMR' },
|
||||
{ value: '16', label: 'Surgery', code: 'SUR' },
|
||||
{ value: '17', label: 'Outpatient', code: 'OUT' },
|
||||
{ value: '18', label: 'Inpatient', code: 'INP' },
|
||||
{ value: '19', label: 'Rehabilitation', code: 'REB' },
|
||||
{ value: '20', label: 'Research', code: 'RSH' },
|
||||
{ value: '1', label: 'Medical' },
|
||||
{ value: '2', label: 'Nursing' },
|
||||
{ value: '3', label: 'Admin' },
|
||||
{ value: '4', label: 'Support' },
|
||||
{ value: '5', label: 'Education' },
|
||||
{ value: '6', label: 'Pharmacy' },
|
||||
{ value: '7', label: 'Radiology' },
|
||||
{ value: '8', label: 'Laboratory' },
|
||||
{ value: '9', label: 'Finance' },
|
||||
{ value: '10', label: 'Human Resources' },
|
||||
{ value: '11', label: 'IT Services' },
|
||||
{ value: '12', label: 'Maintenance' },
|
||||
{ value: '13', label: 'Catering' },
|
||||
{ value: '14', label: 'Security' },
|
||||
{ value: '15', label: 'Emergency' },
|
||||
{ value: '16', label: 'Surgery' },
|
||||
{ value: '17', label: 'Outpatient' },
|
||||
{ value: '18', label: 'Inpatient' },
|
||||
{ value: '19', label: 'Rehabilitation' },
|
||||
{ value: '20', label: 'Research' },
|
||||
],
|
||||
}
|
||||
|
||||
export const schema = z.object({
|
||||
name: z.string({
|
||||
required_error: 'Nama wajib diisi',
|
||||
}).min(1, 'Nama divisi wajib diisi'),
|
||||
name: z
|
||||
.string({
|
||||
required_error: 'Nama wajib diisi',
|
||||
})
|
||||
.min(1, 'Nama divisi wajib diisi'),
|
||||
|
||||
code: z.string({
|
||||
required_error: 'Kode wajib diisi',
|
||||
}).min(1, 'Kode divisi wajib diisi'),
|
||||
code: z
|
||||
.string({
|
||||
required_error: 'Kode wajib diisi',
|
||||
})
|
||||
.min(1, 'Kode divisi wajib diisi'),
|
||||
|
||||
parentId: z.preprocess(
|
||||
(input: unknown) => {
|
||||
if (typeof input === 'string') {
|
||||
// Handle empty string case
|
||||
if (input.trim() === '') {
|
||||
return undefined
|
||||
}
|
||||
return Number(input)
|
||||
}
|
||||
|
||||
return input
|
||||
},
|
||||
z.number({
|
||||
required_error: 'Kelompok wajib dipilih',
|
||||
}).min(1, 'Kelompok wajib dipilih'),
|
||||
),
|
||||
parentId: z.string().optional(),
|
||||
})
|
||||
|
||||
// State untuk tree data divisi - dimulai dengan data level atas
|
||||
const divisionTreeData = ref<TreeItem[]>([
|
||||
{ value: '1', label: 'Medical', hasChildren: true },
|
||||
{ value: '2', label: 'Nursing', hasChildren: true },
|
||||
{ value: '3', label: 'Admin', hasChildren: false },
|
||||
{ value: '4', label: 'Support', hasChildren: true },
|
||||
{ value: '5', label: 'Education', hasChildren: false },
|
||||
{ value: '6', label: 'Pharmacy', hasChildren: true },
|
||||
{ value: '7', label: 'Radiology', hasChildren: false },
|
||||
{ value: '8', label: 'Laboratory', hasChildren: true },
|
||||
])
|
||||
|
||||
// Helper function untuk mencari dan menyisipkan data anak ke dalam tree
|
||||
function findAndInsertChildren(nodes: TreeItem[], parentId: string, newChildren: TreeItem[]): boolean {
|
||||
for (const node of nodes) {
|
||||
if (node.value === parentId) {
|
||||
node.children = newChildren
|
||||
return true
|
||||
}
|
||||
if (node.children && findAndInsertChildren(node.children as TreeItem[], parentId, newChildren)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Fungsi untuk fetch data anak divisi (lazy loading)
|
||||
async function handleFetchDivisionChildren(parentId: string): Promise<void> {
|
||||
console.log(`Mengambil data sub-divisi untuk parent: ${parentId}`)
|
||||
|
||||
// Simulasi delay API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
|
||||
let childrenData: TreeItem[] = []
|
||||
|
||||
// Sample data berdasarkan parent ID
|
||||
switch (parentId) {
|
||||
case '1': // Medical
|
||||
childrenData = [
|
||||
{ value: '1-1', label: 'Cardiology', hasChildren: true },
|
||||
{ value: '1-2', label: 'Neurology', hasChildren: false },
|
||||
{ value: '1-3', label: 'Oncology', hasChildren: false },
|
||||
]
|
||||
break
|
||||
case '2': // Nursing
|
||||
childrenData = [
|
||||
{ value: '2-1', label: 'ICU Nursing', hasChildren: false },
|
||||
{ value: '2-2', label: 'ER Nursing', hasChildren: false },
|
||||
{ value: '2-3', label: 'Ward Nursing', hasChildren: true },
|
||||
]
|
||||
break
|
||||
case '4': // Support
|
||||
childrenData = [
|
||||
{ value: '4-1', label: 'IT Support', hasChildren: false },
|
||||
{ value: '4-2', label: 'Maintenance', hasChildren: false },
|
||||
]
|
||||
break
|
||||
case '6': // Pharmacy
|
||||
childrenData = [
|
||||
{ value: '6-1', label: 'Inpatient Pharmacy', hasChildren: false },
|
||||
{ value: '6-2', label: 'Outpatient Pharmacy', hasChildren: false },
|
||||
]
|
||||
break
|
||||
case '8': // Laboratory
|
||||
childrenData = [
|
||||
{ value: '8-1', label: 'Clinical Lab', hasChildren: false },
|
||||
{ value: '8-2', label: 'Pathology Lab', hasChildren: false },
|
||||
]
|
||||
break
|
||||
case '1-1': // Cardiology sub-divisions
|
||||
childrenData = [
|
||||
{ value: '1-1-1', label: 'Cardiac Surgery', hasChildren: false },
|
||||
{ value: '1-1-2', label: 'Cardiac Cathlab', hasChildren: false },
|
||||
]
|
||||
break
|
||||
case '2-3': // Ward Nursing sub-divisions
|
||||
childrenData = [
|
||||
{ value: '2-3-1', label: 'Pediatric Ward', hasChildren: false },
|
||||
{ value: '2-3-2', label: 'Surgical Ward', hasChildren: false },
|
||||
]
|
||||
break
|
||||
}
|
||||
|
||||
// Insert data ke dalam tree state
|
||||
findAndInsertChildren(divisionTreeData.value, parentId, childrenData)
|
||||
}
|
||||
|
||||
export const divisionTreeConfig = computed(() => ({
|
||||
msg: {
|
||||
placeholder: '--- Pilih divisi induk',
|
||||
search: 'Cari divisi...',
|
||||
empty: 'Divisi tidak ditemukan',
|
||||
},
|
||||
data: divisionTreeData.value,
|
||||
onFetchChildren: handleFetchDivisionChildren,
|
||||
}))
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { HeaderPrep } from '~/components/pub/custom-ui/data/types'
|
||||
import AppDivisonEntryForm from '~/components/app/divison/entry-form.vue'
|
||||
import AppDivisionEntryForm from '~/components/app/divison/entry-form.vue'
|
||||
import Dialog from '~/components/pub/base/modal/dialog.vue'
|
||||
import RecordConfirmation from '~/components/pub/custom-ui/confirmation/record-confirmation.vue'
|
||||
import { ActionEvents } from '~/components/pub/custom-ui/data/types'
|
||||
import Header from '~/components/pub/custom-ui/nav-header/header.vue'
|
||||
import { usePaginatedList } from '~/composables/usePaginatedList'
|
||||
import { division as divisionConf, schema as schemaConf } from './entry'
|
||||
|
||||
import { divisionConf, divisionTreeConfig, schema } from './entry'
|
||||
// #region State & Computed
|
||||
// Dialog state
|
||||
const isFormEntryDialogOpen = ref(false)
|
||||
@@ -18,8 +17,8 @@ const recId = ref<number>(0)
|
||||
const recAction = ref<string>('')
|
||||
const recItem = ref<any>(null)
|
||||
|
||||
async function fetchDivisionData(params: any) {
|
||||
const endpoint = transform('/api/v1/patient', params)
|
||||
async function fetchDivisionData(_params: any) {
|
||||
const endpoint = '/api/v1/_dev/division/list'
|
||||
return await xfetch(endpoint)
|
||||
}
|
||||
|
||||
@@ -182,17 +181,19 @@ function handleCancelConfirmation() {
|
||||
<AppDivisonList :data="data" :pagination-meta="paginationMeta" @page-change="handlePageChange" />
|
||||
|
||||
<Dialog v-model:open="isFormEntryDialogOpen" title="Tambah Divisi" size="lg" prevent-outside>
|
||||
<AppDivisonEntryForm
|
||||
:division="divisionConf" :schema="schemaConf"
|
||||
|
||||
<AppDivisionEntryForm
|
||||
:division="divisionConf" :division-tree="divisionTreeConfig" :schema="schema"
|
||||
:initial-values="{ name: '', code: '', parentId: '' }" @submit="onSubmitForm" @cancel="onCancelForm"
|
||||
/>
|
||||
/>
|
||||
|
||||
</Dialog>
|
||||
|
||||
<!-- Record Confirmation Modal -->
|
||||
<RecordConfirmation
|
||||
v-model:open="isRecordConfirmationOpen" action="delete" :record="recItem"
|
||||
@confirm="handleConfirmDelete" @cancel="handleCancelConfirmation"
|
||||
>
|
||||
v-model:open="isRecordConfirmationOpen" action="delete" :record="recItem"
|
||||
@confirm="handleConfirmDelete" @cancel="handleCancelConfirmation"
|
||||
>
|
||||
<template #default="{ record }">
|
||||
<div class="text-sm">
|
||||
<p><strong>ID:</strong> {{ record?.id }}</p>
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import ThemeToggle from "./ThemeToggle.vue"
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
function setLinks() {
|
||||
if (route.fullPath === '/') {
|
||||
if (route.path === '/') {
|
||||
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 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
|
||||
.split(' ')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
@@ -17,7 +21,7 @@ function setLinks() {
|
||||
|
||||
return {
|
||||
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())
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => route.path,
|
||||
(val) => {
|
||||
if (val) {
|
||||
links.value = setLinks()
|
||||
@@ -46,11 +50,12 @@ watch(
|
||||
<div class="flex w-full items-center gap-4">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" class="h-4" />
|
||||
<!-- <BaseBreadcrumbCustom :links="links" /> -->
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<slot />
|
||||
<PubBaseBreadcrumb :links="links" />
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<slot />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ const openCollapsible = ref(false)
|
||||
<SidebarMenuSubButton as-child>
|
||||
<NuxtLink
|
||||
: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"
|
||||
@click="setOpenMobile(false)"
|
||||
>
|
||||
|
||||
@@ -21,7 +21,7 @@ const { setOpenMobile } = useSidebar()
|
||||
<SidebarMenuButton as-child :tooltip="item.title" :size="size" class="">
|
||||
<NuxtLink
|
||||
: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"
|
||||
@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>
|
||||
Reference in New Issue
Block a user