Merge pull request #4 from dikstub-rssa/feat/patient
Feat/patient: add summary card analytics
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { Summary } from '~/components/pub/base/summary-card.type'
|
||||
import type { HeaderPrep, RefSearchNav } from '~/components/pub/nav/types'
|
||||
import { Calendar, Hospital, UserCheck, UsersRound } from 'lucide-vue-next'
|
||||
|
||||
const data = ref([])
|
||||
|
||||
const refSearchNav = {
|
||||
const refSearchNav: RefSearchNav = {
|
||||
|
||||
onClick: () => {
|
||||
// open filter modal
|
||||
},
|
||||
@@ -13,20 +18,70 @@ const refSearchNav = {
|
||||
},
|
||||
}
|
||||
|
||||
// Loading state management
|
||||
const isLoading = reactive({
|
||||
summary: false,
|
||||
table: false,
|
||||
})
|
||||
const recId = ref<number>(0)
|
||||
const recAction = ref<string>('')
|
||||
const recItem = ref<any>(null)
|
||||
|
||||
const hreaderPrep: HeaderPrep = {
|
||||
title: 'Pasien',
|
||||
icon: 'bi bi-journal-check',
|
||||
icon: 'i-lucide-add',
|
||||
addNav: {
|
||||
label: 'Tambah',
|
||||
onClick: () => navigateTo('/patient/add'),
|
||||
},
|
||||
}
|
||||
|
||||
// NOTE: example api
|
||||
// Initial/default data structure
|
||||
const summaryData: Summary[] = [
|
||||
{
|
||||
title: 'Total Pasien',
|
||||
icon: UsersRound,
|
||||
metric: 23,
|
||||
trend: 15,
|
||||
timeframe: 'daily',
|
||||
},
|
||||
{
|
||||
title: 'Pasien Aktif',
|
||||
icon: UserCheck,
|
||||
metric: 100,
|
||||
trend: 9,
|
||||
timeframe: 'daily',
|
||||
},
|
||||
{
|
||||
title: 'Kunjungan Hari Ini',
|
||||
icon: Calendar,
|
||||
metric: 52,
|
||||
trend: 1,
|
||||
timeframe: 'daily',
|
||||
},
|
||||
{
|
||||
title: 'Peserta BPJS',
|
||||
icon: Hospital,
|
||||
metric: 71,
|
||||
trend: -3,
|
||||
timeframe: 'daily',
|
||||
},
|
||||
]
|
||||
|
||||
// API call function
|
||||
async function getPatientSummary() {
|
||||
try {
|
||||
isLoading.summary = true
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
} catch (error) {
|
||||
console.error('Error fetching patient summary:', error)
|
||||
// Keep default/existing data on error
|
||||
} finally {
|
||||
isLoading.summary = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getPatientList() {
|
||||
const resp = await xfetch('/api/v1/patient')
|
||||
console.log('data patient', resp)
|
||||
@@ -36,6 +91,7 @@ async function getPatientList() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getPatientSummary()
|
||||
getPatientList()
|
||||
})
|
||||
|
||||
@@ -45,8 +101,16 @@ provide('rec_item', recItem)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PubNavHeaderPrep :prep="{ ...hreaderPrep }" :ref-search-nav="refSearchNav" icon="i-lucide-add" />
|
||||
<div>
|
||||
<PubNavHeaderPrep :prep="{ ...hreaderPrep }" :ref-search-nav="refSearchNav" />
|
||||
<div class="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">
|
||||
<template v-if="isLoading.summary">
|
||||
<PubBaseSummaryCard v-for="n in 4" :key="n" is-skeleton />
|
||||
</template>
|
||||
<template v-else>
|
||||
<PubBaseSummaryCard v-for="card in summaryData" :key="card.title" :stat="card" />
|
||||
</template>
|
||||
</div>
|
||||
<AppPatientList :data="data" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface Summary {
|
||||
title: string
|
||||
icon: Component
|
||||
metric: number
|
||||
trend: number
|
||||
timeframe: 'yearly' | 'monthly' | 'weekly' | 'daily'
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import type { Summary } from './summary-card.type'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-vue-next'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
stat?: Summary
|
||||
isSkeleton?: boolean
|
||||
}>()
|
||||
|
||||
const timeFrame = computed((): string => {
|
||||
if (!props.stat?.timeframe) return 'from unknown timeframe'
|
||||
|
||||
let word: string = ''
|
||||
switch (props.stat.timeframe) {
|
||||
case 'daily':
|
||||
word = 'from yesterday'
|
||||
break
|
||||
case 'weekly':
|
||||
word = 'from last week'
|
||||
break
|
||||
case 'monthly':
|
||||
word = 'from last month'
|
||||
break
|
||||
case 'yearly':
|
||||
word = 'from last year'
|
||||
break
|
||||
}
|
||||
|
||||
return word
|
||||
})
|
||||
|
||||
const isTrending = computed<boolean>(() => (props.stat?.trend ?? 0) > 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card v-if="props.isSkeleton">
|
||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<Skeleton class="h-6 w-32 bg-gray-100 text-sm font-medium" />
|
||||
<Skeleton class="h-4 w-4 bg-gray-100" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton class="mb-2 h-6 w-48 bg-gray-100 text-2xl" />
|
||||
<Skeleton class="h-4 w-64 bg-gray-100 text-xs font-medium" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card v-else-if="props.stat && !props.isSkeleton">
|
||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle class="text-sm font-medium"> {{ props.stat.title }} </CardTitle>
|
||||
<component :is="props.stat.icon" class="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ props.stat.metric.toLocaleString('id-ID') }}
|
||||
</div>
|
||||
<p v-if="props.stat.trend !== 0" class="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<component
|
||||
: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 })">
|
||||
{{ props.stat.trend.toFixed(1) }}%
|
||||
<!-- {{ Math.abs(props.stat.trend).toFixed(1) }}% -->
|
||||
</span>
|
||||
<span>{{ timeFrame }}</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
Reference in New Issue
Block a user