feat(satusehat): add search component, card summary and date picker
- Implement search component with lucide-vue-next icon - Create card summary component for displaying summary data - Add date picker component with range selection functionality - Update list configuration and styling - Reorganize package.json structure
This commit is contained in:
No files matched your search
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
text: string
|
||||||
|
icon: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Button
|
||||||
|
variant="outline" class="text-primary border-primary bg-white hover:bg-gray-50 focus:bg-gray-150
|
||||||
|
focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<Icon :name="props.icon" class="size-4 me-2" />
|
||||||
|
{{ props.text }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Summary } from '~/components/pub/base/summary-card.type';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isLoading: boolean
|
||||||
|
summaryData: Summary[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 md:gap-8 lg:grid-cols-4">
|
||||||
|
<template v-if="props.isLoading">
|
||||||
|
<PubBaseSummaryCard v-for="n in 4" :key="n" is-skeleton :stat="summaryData[n]" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<PubBaseSummaryCard v-for="card in summaryData" :key="card.title" :stat="card" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -26,7 +26,6 @@ export const cols: Col[] = [
|
|||||||
{ width: 100 },
|
{ width: 100 },
|
||||||
{ width: 100 },
|
{ width: 100 },
|
||||||
{ width: 100 },
|
{ width: 100 },
|
||||||
{ width: 100 },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export const header: Th[][] = [
|
export const header: Th[][] = [
|
||||||
@@ -37,11 +36,10 @@ export const header: Th[][] = [
|
|||||||
{ label: 'Status' },
|
{ label: 'Status' },
|
||||||
{ label: 'Terakhir Update' },
|
{ label: 'Terakhir Update' },
|
||||||
{ label: 'FHIR ID' },
|
{ label: 'FHIR ID' },
|
||||||
{ label: '' },
|
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
|
|
||||||
export const keys = ['id', 'resource_type', 'patient', 'status', 'updated_at', 'fhir_id', 'action']
|
export const keys = ['id', 'resource_type', 'patient', 'status', 'updated_at', 'fhir_id']
|
||||||
|
|
||||||
export const delKeyNames: KeyLabel[] = [
|
export const delKeyNames: KeyLabel[] = [
|
||||||
{ key: 'code', label: 'Kode' },
|
{ key: 'code', label: 'Kode' },
|
||||||
@@ -72,14 +70,6 @@ export const funcComponent: RecStrFuncComponent = {
|
|||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
},
|
},
|
||||||
action(rec, idx) {
|
|
||||||
const res: RecComponent = {
|
|
||||||
idx,
|
|
||||||
rec: rec as object,
|
|
||||||
component: action,
|
|
||||||
}
|
|
||||||
return res
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const funcHtml: RecStrFuncUnknown = {
|
export const funcHtml: RecStrFuncUnknown = {
|
||||||
|
|||||||
@@ -7,13 +7,6 @@ defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PubBaseDataTable
|
<PubBaseDataTable :rows="data" :cols="cols" :header="header" :keys="keys" :func-parsed="funcParsed"
|
||||||
:rows="data"
|
:func-html="funcHtml" :func-component="funcComponent" />
|
||||||
:cols="cols"
|
|
||||||
:header="header"
|
|
||||||
:keys="keys"
|
|
||||||
:func-parsed="funcParsed"
|
|
||||||
:func-html="funcHtml"
|
|
||||||
:func-component="funcComponent"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DateValue } from '@internationalized/date'
|
||||||
|
import type { DateRange } from 'radix-vue'
|
||||||
|
import type { Grid } from 'radix-vue/date'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
import { CalendarDate } from '@internationalized/date'
|
||||||
|
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||||
|
import { RangeCalendarRoot, useDateFormatter } from 'radix-vue'
|
||||||
|
import { createMonth, toDate } from 'radix-vue/date'
|
||||||
|
import { buttonVariants } from '~/components/pub/ui/button'
|
||||||
|
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
// Props untuk kustomisasi
|
||||||
|
interface Props {
|
||||||
|
modelValue?: DateRange
|
||||||
|
placeholder?: string
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
placeholder: 'Pick a date range',
|
||||||
|
class: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: DateRange]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const value = ref(props.modelValue || {
|
||||||
|
start: new CalendarDate(2022, 1, 20),
|
||||||
|
end: new CalendarDate(2022, 2, 9),
|
||||||
|
}) as Ref<DateRange>
|
||||||
|
|
||||||
|
watch(value, (newValue) => {
|
||||||
|
emit('update:modelValue', newValue)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
const locale = ref('en-US')
|
||||||
|
const formatter = useDateFormatter(locale.value)
|
||||||
|
|
||||||
|
const placeholder = ref(value.value.start) as Ref<DateValue>
|
||||||
|
const secondMonthPlaceholder = ref(value.value.end) as Ref<DateValue>
|
||||||
|
|
||||||
|
// Create independent month grids
|
||||||
|
const firstMonth = ref(
|
||||||
|
createMonth({
|
||||||
|
dateObj: placeholder.value,
|
||||||
|
locale: locale.value,
|
||||||
|
fixedWeeks: true,
|
||||||
|
weekStartsOn: 0,
|
||||||
|
}),
|
||||||
|
) as Ref<Grid<DateValue>>
|
||||||
|
|
||||||
|
const secondMonth = ref(
|
||||||
|
createMonth({
|
||||||
|
dateObj: secondMonthPlaceholder.value,
|
||||||
|
locale: locale.value,
|
||||||
|
fixedWeeks: true,
|
||||||
|
weekStartsOn: 0,
|
||||||
|
}),
|
||||||
|
) as Ref<Grid<DateValue>>
|
||||||
|
|
||||||
|
// Function to update months independently
|
||||||
|
function updateMonth(reference: 'first' | 'second', months: number) {
|
||||||
|
if (reference === 'first') {
|
||||||
|
placeholder.value = placeholder.value.add({ months })
|
||||||
|
} else {
|
||||||
|
secondMonthPlaceholder.value = secondMonthPlaceholder.value.add({ months })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch first month placeholder
|
||||||
|
watch(placeholder, (_placeholder) => {
|
||||||
|
firstMonth.value = createMonth({
|
||||||
|
dateObj: _placeholder,
|
||||||
|
weekStartsOn: 0,
|
||||||
|
fixedWeeks: true,
|
||||||
|
locale: locale.value,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch second month placeholder
|
||||||
|
watch(secondMonthPlaceholder, (_secondMonthPlaceholder) => {
|
||||||
|
secondMonth.value = createMonth({
|
||||||
|
dateObj: _secondMonthPlaceholder,
|
||||||
|
weekStartsOn: 0,
|
||||||
|
fixedWeeks: true,
|
||||||
|
locale: locale.value,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="cn('w-full', props.class)">
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<Button variant="outline" :class="cn(
|
||||||
|
'w-full justify-start text-left font-normal',
|
||||||
|
!value && 'text-muted-foreground',
|
||||||
|
)">
|
||||||
|
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||||
|
<template v-if="value.start">
|
||||||
|
<template v-if="value.end">
|
||||||
|
{{
|
||||||
|
formatter.custom(toDate(value.start), {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
-
|
||||||
|
{{
|
||||||
|
formatter.custom(toDate(value.end), {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
{{
|
||||||
|
formatter.custom(toDate(value.start), {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ props.placeholder }}
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
class="w-auto p-0 rounded-xl shadow-lg border bg-white dark:bg-neutral-900 z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
||||||
|
align="start">
|
||||||
|
<RangeCalendarRoot v-slot="{ weekDays }" v-model="value" v-model:placeholder="placeholder" class="p-4">
|
||||||
|
<div class="flex flex-col gap-6 sm:flex-row sm:gap-8">
|
||||||
|
<!-- First Month -->
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Button :class="cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||||
|
)" @click="updateMonth('first', -1)">
|
||||||
|
<ChevronLeft class="h-4 w-4 text-primary" />
|
||||||
|
</Button>
|
||||||
|
<div class="text-sm font-medium">
|
||||||
|
{{
|
||||||
|
formatter.fullMonthAndYear(
|
||||||
|
toDate(firstMonth.value),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<Button :class="cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||||
|
)" @click="updateMonth('first', 1)">
|
||||||
|
<ChevronRight class="h-4 w-4 text-primary" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<RangeCalendarGrid>
|
||||||
|
<RangeCalendarGridHead>
|
||||||
|
<RangeCalendarGridRow>
|
||||||
|
<RangeCalendarHeadCell v-for="day in weekDays" :key="day" class="w-full">
|
||||||
|
{{ day }}
|
||||||
|
</RangeCalendarHeadCell>
|
||||||
|
</RangeCalendarGridRow>
|
||||||
|
</RangeCalendarGridHead>
|
||||||
|
<RangeCalendarGridBody class="mt-2">
|
||||||
|
<RangeCalendarGridRow v-for="(weekDates, index) in firstMonth.rows" :key="`weekDate-${index}`"
|
||||||
|
class="w-full">
|
||||||
|
<RangeCalendarCell v-for="weekDate in weekDates" :key="weekDate.toString()" :date="weekDate">
|
||||||
|
<RangeCalendarCellTrigger :day="weekDate" :month="firstMonth.value" :class="cn(
|
||||||
|
'h-9 w-9 p-0 text-sm rounded-md transition-colors flex items-center justify-center',
|
||||||
|
|
||||||
|
// tanggal bulan lain (past/next month)
|
||||||
|
'data-[outside-month]:text-muted-foreground data-[outside-month]:opacity-50',
|
||||||
|
|
||||||
|
// tanggal terpilih (start / end)
|
||||||
|
'data-[selected]:bg-green-600 data-[selected]:text-white',
|
||||||
|
|
||||||
|
// tanggal dalam rentang
|
||||||
|
'data-[in-range]:bg-green-500 data-[in-range]:text-white',
|
||||||
|
|
||||||
|
// rounded pill untuk range
|
||||||
|
'data-[selection-start]:rounded-l-full data-[selection-end]:rounded-r-full',
|
||||||
|
|
||||||
|
// hover
|
||||||
|
'hover:bg-accent hover:text-accent-foreground'
|
||||||
|
)" />
|
||||||
|
|
||||||
|
</RangeCalendarCell>
|
||||||
|
</RangeCalendarGridRow>
|
||||||
|
</RangeCalendarGridBody>
|
||||||
|
</RangeCalendarGrid>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Second Month -->
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Button :class="cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||||
|
)" @click="updateMonth('second', -1)">
|
||||||
|
<ChevronLeft class="h-4 w-4 text-primary" />
|
||||||
|
</Button>
|
||||||
|
<div class="text-sm font-medium">
|
||||||
|
{{
|
||||||
|
formatter.fullMonthAndYear(
|
||||||
|
toDate(secondMonth.value),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<Button :class="cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||||
|
)" @click="updateMonth('second', 1)">
|
||||||
|
<ChevronRight class="h-4 w-4 text-primary" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<RangeCalendarGrid>
|
||||||
|
<RangeCalendarGridHead>
|
||||||
|
<RangeCalendarGridRow>
|
||||||
|
<RangeCalendarHeadCell v-for="day in weekDays" :key="day" class="w-full">
|
||||||
|
{{ day }}
|
||||||
|
</RangeCalendarHeadCell>
|
||||||
|
</RangeCalendarGridRow>
|
||||||
|
</RangeCalendarGridHead>
|
||||||
|
<RangeCalendarGridBody class="mt-2">
|
||||||
|
<RangeCalendarGridRow v-for="(weekDates, index) in secondMonth.rows" :key="`weekDate-${index}`"
|
||||||
|
class="w-full">
|
||||||
|
<RangeCalendarCell v-for="weekDate in weekDates" :key="weekDate.toString()" :date="weekDate">
|
||||||
|
<RangeCalendarCellTrigger :day="weekDate" :month="secondMonth.value" :class="cn(
|
||||||
|
'h-9 w-9 p-0 text-sm rounded-md transition-colors flex items-center justify-center',
|
||||||
|
|
||||||
|
// tanggal bulan lain (past/next month)
|
||||||
|
'data-[outside-month]:text-muted-foreground data-[outside-month]:opacity-50',
|
||||||
|
|
||||||
|
// tanggal terpilih (start / end)
|
||||||
|
'data-[selected]:bg-green-600 data-[selected]:text-white',
|
||||||
|
|
||||||
|
// tanggal dalam rentang
|
||||||
|
'data-[in-range]:bg-green-500 data-[in-range]:text-white',
|
||||||
|
|
||||||
|
// rounded pill untuk range
|
||||||
|
'data-[selection-start]:rounded-l-full data-[selection-end]:rounded-r-full',
|
||||||
|
|
||||||
|
// hover
|
||||||
|
'hover:bg-accent hover:text-accent-foreground'
|
||||||
|
)" />
|
||||||
|
|
||||||
|
</RangeCalendarCell>
|
||||||
|
</RangeCalendarGridRow>
|
||||||
|
</RangeCalendarGridBody>
|
||||||
|
</RangeCalendarGrid>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RangeCalendarRoot>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Search } from 'lucide-vue-next'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative w-full max-w-sm items-center">
|
||||||
|
<Input id="search" type="text" placeholder="Search..." class="pl-10" />
|
||||||
|
<span class="absolute start-0 inset-y-0 flex items-center justify-center px-2">
|
||||||
|
<Search class="size-6 text-muted-foreground" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { ServiceStatus } from '~/components/pub/base/service-status.type'
|
import type { ServiceStatus } from '~/components/pub/base/service-status.type'
|
||||||
import type { Summary } from '~/components/pub/base/summary-card.type'
|
import type { Summary } from '~/components/pub/base/summary-card.type'
|
||||||
import type { HeaderPrep, RefSearchNav } from '~/components/pub/nav/types'
|
import type { HeaderPrep, RefSearchNav } from '~/components/pub/nav/types'
|
||||||
import { CircleCheckBig, CircleDashed, CircleX, Send } from 'lucide-vue-next'
|
import { CircleCheckBig, CircleDashed, CircleX, Download, Ellipsis, ListFilter, Search, Send } from 'lucide-vue-next'
|
||||||
|
|
||||||
const data = ref([
|
const data = ref([
|
||||||
{
|
{
|
||||||
@@ -38,6 +38,39 @@ const data = ref([
|
|||||||
updated_at: '2025-03-11',
|
updated_at: '2025-03-11',
|
||||||
fhir_id: 'ENC-001236',
|
fhir_id: 'ENC-001236',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'RSC001',
|
||||||
|
resource_type: 'Encounter',
|
||||||
|
patient: {
|
||||||
|
name: 'Ahmad Wepe',
|
||||||
|
mrn: 'RM001234',
|
||||||
|
},
|
||||||
|
status: 2,
|
||||||
|
updated_at: '2025-03-12',
|
||||||
|
fhir_id: 'ENC-00123',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'RSC001',
|
||||||
|
resource_type: 'Encounter',
|
||||||
|
patient: {
|
||||||
|
name: 'Ahmad Wepe',
|
||||||
|
mrn: 'RM001234',
|
||||||
|
},
|
||||||
|
status: 2,
|
||||||
|
updated_at: '2025-03-12',
|
||||||
|
fhir_id: 'ENC-00123',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'RSC001',
|
||||||
|
resource_type: 'Encounter',
|
||||||
|
patient: {
|
||||||
|
name: 'Ahmad Wepe',
|
||||||
|
mrn: 'RM001234',
|
||||||
|
},
|
||||||
|
status: 2,
|
||||||
|
updated_at: '2025-03-12',
|
||||||
|
fhir_id: 'ENC-00123',
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const refSearchNav: RefSearchNav = {
|
const refSearchNav: RefSearchNav = {
|
||||||
@@ -131,20 +164,91 @@ onMounted(() => {
|
|||||||
provide('rec_id', recId)
|
provide('rec_id', recId)
|
||||||
provide('rec_action', recAction)
|
provide('rec_action', recAction)
|
||||||
provide('rec_item', recItem)
|
provide('rec_item', recItem)
|
||||||
|
|
||||||
|
const listTitle = 'FHIR Resource'
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
value: 'all',
|
||||||
|
label: 'Semua Resource',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'patient',
|
||||||
|
label: 'Patient',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'encounter',
|
||||||
|
label: 'Encounter',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'observation',
|
||||||
|
label: 'Observation',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const form = [
|
||||||
|
{
|
||||||
|
value: 'search',
|
||||||
|
label: 'Cari pasien'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'date-picker',
|
||||||
|
label: 'Filter berdasarkan tanggal',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const actions = [
|
||||||
|
{
|
||||||
|
value: 'filter',
|
||||||
|
label: 'Filter',
|
||||||
|
icon: 'i-lucide-list-filter',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'export',
|
||||||
|
label: 'Ekspor',
|
||||||
|
icon: 'i-lucide-download',
|
||||||
|
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const activeTabFilter = ref('all')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PubNavHeaderPrep :prep="headerPrep" :ref-search-nav="refSearchNav" />
|
<PubNavHeaderPrep :prep="headerPrep" :ref-search-nav="refSearchNav" />
|
||||||
<div class="my-4 flex flex-1 flex-col gap-4 md:gap-8">
|
<div class="my-4 flex flex-1 flex-col gap-3 md:gap-4">
|
||||||
<PubBaseServiceStatus v-bind="service" />
|
<PubBaseServiceStatus v-bind="service" />
|
||||||
<div class="grid gap-4 md:grid-cols-2 md:gap-8 lg:grid-cols-4">
|
<AppSatusehatCardSummary :is-loading="isLoading.satusehatConn" :summary-data="summaryData" />
|
||||||
<template v-if="isLoading.satusehatConn">
|
</div>
|
||||||
<PubBaseSummaryCard v-for="n in 4" :key="n" is-skeleton :stat="summaryData[n]" />
|
<div class="rounded-md border p-4">
|
||||||
</template>
|
<h2 class="text-md font-semibold py-2">FHIR Resource</h2>
|
||||||
<template v-else>
|
<Tabs v-model="activeTabFilter">
|
||||||
<PubBaseSummaryCard v-for="card in summaryData" :key="card.title" :stat="card" />
|
<div class="scrollbar-hide overflow-x-auto flex gap-2 justify-between">
|
||||||
</template>
|
<TabsList>
|
||||||
</div>
|
<TabsTrigger v-for="tab in tabs" :key="tab.value" :value="tab.value"
|
||||||
<AppSatusehatList v-if="!isLoading.satusehatConn" :data="data" />
|
class="flex-shrink-0 px-4 py-2 text-sm font-medium data-[state=active]:bg-green-600 data-[state=inactive]:bg-gray-100 data-[state=active]:text-white data-[state=inactive]:text-gray-700">
|
||||||
|
{{ tab.label }}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<AppSatusehatPicker />
|
||||||
|
<div class="relative w-full max-w-sm">
|
||||||
|
<Input id="search" type="text" placeholder="Cari pasien..." class="pl-7 h-9" />
|
||||||
|
<span class="absolute start-0 inset-y-0 flex items-center justify-center px-2">
|
||||||
|
<Search class="size-4 text-muted-foreground" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<AppSatusehatButtonAction v-for="action in actions" :key="action.value" :icon="action.icon"
|
||||||
|
:text="action.label" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<TabsContent v-for="tab in tabs" :key="`content-${tab.value}`" :value="tab.value">
|
||||||
|
<div class="rounded-md border p-4">
|
||||||
|
<Ellipsis v-if="isLoading.satusehatConn" class="size-6 animate-pulse text-muted-foreground mx-auto" />
|
||||||
|
<AppSatusehatList v-if="!isLoading.satusehatConn" :data="data" />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -12,12 +12,12 @@ const teams: {
|
|||||||
logo: string
|
logo: string
|
||||||
plan: string
|
plan: string
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
name: 'SIMRS - RSSA',
|
name: 'SIMRS - RSSA',
|
||||||
logo: '/rssa-logo.png',
|
logo: '/rssa-logo.png',
|
||||||
plan: 'Saiful Anwar Hospital',
|
plan: 'Saiful Anwar Hospital',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const sidebar = {
|
const sidebar = {
|
||||||
collapsible: 'offcanvas', // 'offcanvas' | 'icon' | 'none'
|
collapsible: 'offcanvas', // 'offcanvas' | 'icon' | 'none'
|
||||||
side: 'left', // 'left' | 'right'
|
side: 'left', // 'left' | 'right'
|
||||||
@@ -59,8 +59,10 @@ async function setMenu() {
|
|||||||
<SidebarGroupLabel>
|
<SidebarGroupLabel>
|
||||||
{{ navMenu.heading }}
|
{{ navMenu.heading }}
|
||||||
</SidebarGroupLabel>
|
</SidebarGroupLabel>
|
||||||
<component :is="resolveNavItemComponent(item)" v-for="(item, index) in navMenu.items" :key="index" :item="item"
|
<component
|
||||||
class="my-2 mb-2" />
|
:is="resolveNavItemComponent(item)" v-for="(item, index) in navMenu.items" :key="index" :item="item"
|
||||||
|
class="my-2 mb-2"
|
||||||
|
/>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
@@ -68,8 +70,10 @@ async function setMenu() {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<SidebarGroup class="mt-auto">
|
<SidebarGroup class="mt-auto">
|
||||||
<component :is="resolveNavItemComponent(item)" v-for="(item, index) in navMenuBottom" :key="index" :item="item"
|
<component
|
||||||
size="sm" />
|
:is="resolveNavItemComponent(item)" v-for="(item, index) in navMenuBottom" :key="index" :item="item"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ defineProps<{
|
|||||||
<template>
|
<template>
|
||||||
<Table>
|
<Table>
|
||||||
|
|
||||||
<TableHeader>
|
<TableHeader class="bg-gray-50">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead v-for="(h, idx) in header[0]" :key="`head-${idx}`"
|
<TableHead
|
||||||
:style="{ width: cols[idx]?.width ? `${cols[idx].width}px` : undefined }">
|
v-for="(h, idx) in header[0]" :key="`head-${idx}`"
|
||||||
|
:style="{ width: cols[idx]?.width ? `${cols[idx].width}px` : undefined }"
|
||||||
|
>
|
||||||
{{ h.label }}
|
{{ h.label }}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -28,8 +30,10 @@ defineProps<{
|
|||||||
<TableRow v-for="(row, rowIndex) in rows" :key="`row-${rowIndex}`">
|
<TableRow v-for="(row, rowIndex) in rows" :key="`row-${rowIndex}`">
|
||||||
<TableCell v-for="(key, cellIndex) in keys" :key="`cell-${rowIndex}-${cellIndex}`">
|
<TableCell v-for="(key, cellIndex) in keys" :key="`cell-${rowIndex}-${cellIndex}`">
|
||||||
<!-- If funcComponent has a renderer -->
|
<!-- If funcComponent has a renderer -->
|
||||||
<component :is="funcComponent[key](row, rowIndex).component" v-if="funcComponent[key]"
|
<component
|
||||||
v-bind="funcComponent[key](row, rowIndex)" />
|
:is="funcComponent[key](row, rowIndex).component" v-if="funcComponent[key]"
|
||||||
|
v-bind="funcComponent[key](row, rowIndex)"
|
||||||
|
/>
|
||||||
<!-- If funcParsed or funcHtml returns a value -->
|
<!-- If funcParsed or funcHtml returns a value -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Use v-html for funcHtml to render HTML content -->
|
<!-- Use v-html for funcHtml to render HTML content -->
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ const tokenStatus = computed((): string => {
|
|||||||
<Card v-else class="py-6">
|
<Card v-else class="py-6">
|
||||||
<div class="flex gap-4 justify-between px-6">
|
<div class="flex gap-4 justify-between px-6">
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<span :class="cn(' rounded-md w-12 h-12 flex items-center justify-center',
|
<span
|
||||||
|
:class="cn(' rounded-md w-12 h-12 flex items-center justify-center',
|
||||||
{ 'bg-red-500': props.status === 'error' },
|
{ 'bg-red-500': props.status === 'error' },
|
||||||
{ 'bg-blue-500': props.status !== 'error' },
|
{ 'bg-blue-500': props.status !== 'error' },
|
||||||
)">
|
)"
|
||||||
|
>
|
||||||
<Icon v-if="props.status === 'error'" name="i-lucide-cable" class="text-white w-6 h-6 sm:w-8 sm:h-8" />
|
<Icon v-if="props.status === 'error'" name="i-lucide-cable" class="text-white w-6 h-6 sm:w-8 sm:h-8" />
|
||||||
<Icon v-else name="i-lucide-bring-to-front" class="text-white w-6 h-6 sm:w-8 sm:h-8" />
|
<Icon v-else name="i-lucide-bring-to-front" class="text-white w-6 h-6 sm:w-8 sm:h-8" />
|
||||||
</span>
|
</span>
|
||||||
@@ -58,10 +60,12 @@ const tokenStatus = computed((): string => {
|
|||||||
<p v-if="props.status === 'connecting'">
|
<p v-if="props.status === 'connecting'">
|
||||||
<Loader class="ml-2 h-4 w-4 animate-spin" />
|
<Loader class="ml-2 h-4 w-4 animate-spin" />
|
||||||
</p>
|
</p>
|
||||||
<p v-else :class="cn('text-xs md:text-md font-bold',
|
<p
|
||||||
|
v-else :class="cn('text-xs md:text-md font-bold',
|
||||||
{ 'text-blue-500': props.sessionActive },
|
{ 'text-blue-500': props.sessionActive },
|
||||||
{ 'text-red-500': !props.sessionActive },
|
{ 'text-red-500': !props.sessionActive },
|
||||||
)">{{ tokenStatus }}</p>
|
)"
|
||||||
|
>{{ tokenStatus }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -54,8 +54,10 @@ const isTrending = computed<boolean>(() => (props.stat?.trend ?? 0) > 0)
|
|||||||
{{ props.stat.metric.toLocaleString('id-ID') }}
|
{{ props.stat.metric.toLocaleString('id-ID') }}
|
||||||
</div>
|
</div>
|
||||||
<p v-if="props.stat.trend !== 0" class="text-muted-foreground flex items-center gap-1 text-xs">
|
<p v-if="props.stat.trend !== 0" class="text-muted-foreground flex items-center gap-1 text-xs">
|
||||||
<component :is="isTrending ? ChevronUp : ChevronDown"
|
<component
|
||||||
:class="cn('h-4 w-4', { 'text-green-500': isTrending }, { 'text-red-500': !isTrending })" />
|
:is="isTrending ? ChevronUp : ChevronDown"
|
||||||
|
:class="cn('h-4 w-4', { 'text-green-500': isTrending }, { 'text-red-500': !isTrending })"
|
||||||
|
/>
|
||||||
<span :class="cn('font-medium', { 'text-green-500': isTrending }, { 'text-red-500': !isTrending })">
|
<span :class="cn('font-medium', { 'text-green-500': isTrending }, { 'text-red-500': !isTrending })">
|
||||||
{{ props.stat.trend.toFixed(1) }}%
|
{{ props.stat.trend.toFixed(1) }}%
|
||||||
<!-- {{ Math.abs(props.stat.trend).toFixed(1) }}% -->
|
<!-- {{ Math.abs(props.stat.trend).toFixed(1) }}% -->
|
||||||
|
|||||||
+2
-2
@@ -2,6 +2,7 @@
|
|||||||
"name": "nuxt-app",
|
"name": "nuxt-app",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"packageManager": "[email protected]+sha512.499434c9d8fdd1a2794ebf4552b3b25c0a633abcee5bb15e7b5de90f32f47b513aca98cd5cfd001c31f0db454bc3804edccd578501e4ca293a6816166bbd9f81",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nuxt build",
|
"build": "nuxt build",
|
||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
@@ -65,6 +66,5 @@
|
|||||||
"vue-sonner": "^1.3.0",
|
"vue-sonner": "^1.3.0",
|
||||||
"vue-tsc": "^2.1.10",
|
"vue-tsc": "^2.1.10",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
}
|
||||||
"packageManager": "[email protected]+sha512.499434c9d8fdd1a2794ebf4552b3b25c0a633abcee5bb15e7b5de90f32f47b513aca98cd5cfd001c31f0db454bc3804edccd578501e4ca293a6816166bbd9f81"
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user