Merge branch 'dev' into feat/kfr-kemoterapi-174

This commit is contained in:
2025-12-01 20:47:45 +07:00
161 changed files with 5956 additions and 2860 deletions
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { type TabItem } from '../comp-tab/type'
const props = defineProps<{
initialActiveMenu: string
data: TabItem[]
}>()
const activeMenu = ref(props.initialActiveMenu)
const emit = defineEmits<{
changeMenu: [value: string]
}>()
function changeMenu(value: string) {
activeMenu.value = value
emit('changeMenu', value)
}
</script>
<template>
<div class="mt-4 flex gap-4">
<!-- Menu Sidebar -->
<div v-if="data.length > 0" class="w-72 flex-shrink-0 rounded-md border bg-white shadow-sm dark:bg-neutral-950">
<div class="max-h-[calc(100vh-12rem)] overflow-y-auto p-2">
<button
v-for="menu in data"
:key="menu.value"
:data-active="activeMenu === menu.value"
class="w-full rounded-md px-4 py-3 text-left text-sm transition-colors data-[active=false]:text-gray-700 data-[active=false]:hover:bg-gray-100 data-[active=true]:bg-primary data-[active=true]:text-white dark:data-[active=false]:text-gray-300 dark:data-[active=false]:hover:bg-neutral-800"
@click="changeMenu(menu.value)"
>
{{ menu.label }}
</button>
</div>
</div>
<!-- Active Menu Content -->
<div v-if="data.find((m) => m.value === activeMenu)?.component" class="flex-1 rounded-md border bg-white p-4 shadow-sm dark:bg-neutral-950">
<component
:is="data.find((m) => m.value === activeMenu)?.component"
v-bind="data.find((m) => m.value === activeMenu)?.props"
/>
</div>
</div>
</template>
@@ -3,5 +3,7 @@ export interface TabItem {
label: string
component?: any
groups?: string[]
classCode?: string[]
subClassCode?: string[]
props?: Record<string, any>
}
@@ -0,0 +1,130 @@
<script setup lang="ts">
import type { Config } from './index'
import { refDebounced } from '@vueuse/core'
const props = defineProps<Config>()
const emit = defineEmits<{
'update:searchModelValue': [value: string]
'search': [value: string]
}>()
// Quick search
const search = ref(props.quickSearchNav?.modelValue || props.refSearchNav?.modelValue || '')
const debouncedSearch = refDebounced(search, props.quickSearchNav?.debounceDuration || 500)
// Computed search model for v-model
const searchModel = computed({
get: () => search.value,
set: (value: string) => {
search.value = value
emit('update:searchModelValue', value)
},
})
// Watch for external changes to modelValue
watch(() => props.quickSearchNav?.modelValue, (newValue) => {
if (newValue !== props.quickSearchNav?.modelValue) {
search.value = newValue || ''
}
})
// Watch debounced search and emit search event
watch(debouncedSearch, (newValue) => {
const minLength = props.quickSearchNav?.minLength || 3
// Only search if meets minimum length or empty (to clear search)
if (newValue.length === 0 || newValue.length >= minLength) {
emit('search', newValue)
props.refSearchNav?.onInput(newValue)
}
})
// Handle clear search
function clearSearch() {
searchModel.value = ''
props.quickSearchNav?.onClear()
props.refSearchNav?.onClear()
}
</script>
<template>
<div class="flex items-center justify-between pb-4 2xl:pb-5 ">
<div class="flex items-center">
<div class="ml-3 text-lg font-semibold text-gray-900">
<Icon v-if="icon" :name="icon" class="mr-2 size-4 md:size-6 align-middle" />
{{ title }}
</div>
</div>
<div class="flex items-center [&>*]:ms-2">
<!-- Slot -->
<slot />
<!-- Search Section -->
<div v-if="quickSearchNav || refSearchNav" class="relative">
<Input
v-model="searchModel"
name="search"
type="text"
:class="quickSearchNav?.inputClass || refSearchNav?.inputClass"
:placeholder="quickSearchNav?.placeholder || refSearchNav?.placeholder || 'Cari (min. 3 karakter)...'"
/>
<button
v-if="search.length > 0"
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
type="button"
@click="clearSearch"
>
<Icon name="i-lucide-x" class="h-4 w-4" />
</button>
<div
v-if="quickSearchNav && quickSearchNav.showValidationFeedback !== false && searchModel.length > 0 && searchModel.length < (quickSearchNav.minLength || 3)"
class="absolute -bottom-6 left-0 text-xs text-amber-600 whitespace-nowrap"
>
Minimal {{ quickSearchNav.minLength || 3 }} karakter untuk mencari
</div>
</div>
<!-- Add Button -->
<div v-if="addNav" class="flex items-center">
<Button
class="rounded-md border border-gray-300 px-4 py-2 text-white sm:text-sm"
:class="addNav.classVal"
:variant="(addNav.variant as any) || 'default'"
@click="addNav?.onClick"
>
<Icon :name="addNav.icon || 'i-lucide-plus'" class="mr-2 h-4 w-4 align-middle" />
{{ addNav.label }}
</Button>
</div>
<!-- Filter Button -->
<div v-if="filterNav" class="flex items-center">
<Button
class="rounded-md border border-gray-300 px-4 py-2 sm:text-sm"
:class="filterNav.classVal"
:variant="(filterNav.variant as any) || 'default'"
@click="filterNav?.onClick"
>
<Icon :name="filterNav.icon || 'i-lucide-filter'" class="mr-2 h-4 w-4 align-middle" />
{{ filterNav.label }}
</Button>
</div>
<!-- Print Button -->
<div v-if="printNav" class="flex items-center">
<Button
class="rounded-md border border-gray-300 px-4 py-2 sm:text-sm"
:class="printNav.classVal"
:variant="(printNav.variant as any) || 'default'"
@click="printNav?.onClick"
>
<Icon :name="printNav.icon || 'i-lucide-printer'" class="mr-2 h-4 w-4 align-middle" />
{{ printNav.label }}
</Button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,59 @@
export type ComponentWithProps = { component: Component, props: Record<string, any> }
export interface ButtonNav {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
classVal?: string
classValExt?: string
icon?: string
label: string
onClick?: () => void
}
// can type directly
export interface QuickSearchNav {
modelValue?: string
placeholder?: string
inputClass?: string
inputPlaceHolder?: string
minLength?: number
btnClass?: string
btnIcon?: string
btnLabel?: string
showValidationFeedback?: boolean
debounceDuration?: number
searchParams: object
onSubmit?: (searchParams: object) => void
onClear: () => void
}
// callback on event
export interface RefSearchNav {
modelValue?: string
placeholder?: string
inputClass?: string
inputPlaceHolder?: string
btnClass?: string
btnIcon?: string
onInput: (val: string) => void
onClick: () => void
onClear: () => void
}
export interface RefExportNav {
onExportPdf?: () => void
onExportCsv?: () => void
onExportExcel?: () => void
}
export interface Config {
title?: string
icon?: string
components?: ComponentWithProps[]
quickSearchNav?: QuickSearchNav
refSearchNav?: RefSearchNav // either ref or quick
filterNav?: ButtonNav
addNav?: ButtonNav
printNav?: ButtonNav
}
export { default as ContentHeader } from './content-header.vue'
@@ -0,0 +1,86 @@
<script setup lang="ts">
const props = defineProps<{
height?: number
activeTab?: 1 | 2
}>()
const activeTab = ref(props.activeTab || 1)
function handleClick(value: 1 | 2) {
activeTab.value = value
}
</script>
<template>
<div class="content-switcher" :style="`height: ${height || 200}px`">
<div :class="`${activeTab === 1 ? 'active' : 'inactive'}`">
<div class="content-wrapper">
<div>
<slot name="content1" />
</div>
</div>
<div class="content-nav">
<button @click="handleClick(1)">
<Icon name="i-lucide-chevron-right" />
</button>
</div>
</div>
<div :class="`${activeTab === 2 ? 'active' : 'inactive'}`">
<div class="content-nav">
<button @click="handleClick(2)">
<Icon name="i-lucide-chevron-left" />
</button>
</div>
<div class="content-wrapper">
<div>
<slot name="content2" />
</div>
</div>
</div>
</div>
</template>
<style>
.content-switcher {
@apply flex overflow-hidden gap-3
}
.content-switcher > * {
@apply border border-slate-300 rounded-md flex overflow-hidden
}
.content-wrapper {
@apply p-4 2xl:p-5 overflow-hidden grow
}
.inactive .content-wrapper {
@apply p-0 w-0
}
.content-nav {
@apply h-full flex flex-row items-center justify-center content-center !text-2xl overflow-hidden
}
.content-nav button {
@apply pt-2 px-2 h-full w-full
}
/* .content-switcher .inactive > .content-wrapper {
@apply w-0 p-0 opacity-0 transition-all duration-500 ease-in-out
} */
.content-switcher .inactive {
@apply w-16 transition-all duration-500 ease-in-out
}
.content-switcher .inactive > .content-nav {
@apply w-full transition-all duration-100 ease-in-out
}
.content-switcher .active {
@apply grow transition-all duration-500 ease-in-out
}
.content-switcher .active > .content-nav {
@apply w-0 transition-all duration-100 ease-in-out
}
/* .content-switcher .active > .content-wrapper {
@apply w-full delay-1000 transition-all duration-1000 ease-in-out
} */
</style>
+2
View File
@@ -71,6 +71,7 @@ export interface KeyNames {
export interface LinkItem {
label: string
value?: string
icon?: string
href?: string // to cover the needs of stating full external origins full url
action?: string // for local paths
@@ -84,6 +85,7 @@ export const ActionEvents = {
showEdit: 'showEdit',
showDetail: 'showDetail',
showProcess: 'showProcess',
showCancel: 'showCancel',
showVerify: 'showVerify',
showConfirmVerify: 'showConfirmVerify',
showValidate: 'showValidate',
+4 -2
View File
@@ -1,7 +1,9 @@
<script lang="ts" setup>
const { class: classProp } = defineProps<{
class?: string
}>()
</script>
<template>
<div class="w-5 text-center">:</div>
<div :class="`w-4 ps-1 ${classProp}`">:</div>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { type EncounterItem } from "~/handlers/encounter-init.handler";
const props = defineProps<{
initialActiveMenu: string
data: EncounterItem[]
}>()
const activeMenu = ref(props.initialActiveMenu)
const emit = defineEmits<{
changeMenu: [value: string]
}>()
function changeMenu(value: string) {
activeMenu.value = value
emit('changeMenu', value)
}
</script>
<template>
<div class="flex">
<!-- Menu Sidebar -->
<div v-if="data.length > 0" class="w-56 2xl:w-64 flex-shrink-0 rounded-md border bg-white dark:bg-slate-800 shadow-sm">
<div class="max-h-[calc(100vh-12rem)] overflow-y-auto px-2 py-3">
<button
v-for="menu in data"
:key="menu.id"
:data-active="activeMenu === menu.id"
class="w-full rounded-md px-4 py-3 text-left text-xs 2xl:text-sm transition-colors data-[active=false]:text-gray-700 data-[active=false]:hover:bg-gray-100 data-[active=true]:bg-primary data-[active=true]:text-white dark:data-[active=false]:text-gray-300 dark:data-[active=false]:hover:bg-neutral-800"
@click="changeMenu(menu.id)">
{{ menu.title }}
</button>
</div>
</div>
<!-- Active Menu Content -->
<div class="p-4 2xl:p-5 flex-grow">
<div v-if="data.find((m) => m.id === activeMenu)?.component"
class="flex-1 rounded-md border bg-white p-4 shadow-sm dark:bg-neutral-950">
<component
:is="data.find((m) => m.id === activeMenu)?.component"
v-bind="data.find((m) => m.id === activeMenu)?.props" />
</div>
</div>
</div>
</template>
+1 -1
View File
@@ -20,7 +20,7 @@ function onClick(type: ClickType) {
@click="onClick('draft')"
class="flex items-center gap-2 rounded-full border border-orange-400 bg-orange-50 px-3 py-1 text-sm font-medium text-orange-600 hover:bg-orange-100"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{{ props.label }}
@@ -37,6 +37,10 @@ function btnClick() {
</div>
</div>
<div class="flex items-center">
<!-- For components as slots -->
<slot />
<!-- For components passed by props -->
<div v-if="prep.components">
<template v-for="cwp in prep.components">
<component
@@ -32,7 +32,7 @@ function btnClick() {
/>
</div>
<div v-if="prep.addNav" class="m-2 flex items-center">
<Button size="md" class="rounded-md border border-gray-300 px-4 py-2 text-white sm:text-sm" @click="btnClick">
<Button size="default" class="rounded-md border border-gray-300 px-4 py-2 text-white sm:text-sm" @click="btnClick">
<Icon name="i-lucide-plus" class="mr-2 h-4 w-4 align-middle" />
{{ prep.addNav.label }}
</Button>