feat(satusehat): add integration pages and components

Add new SatuSehat integration feature including:
- Page components for list, add, edit, and detail views
- Service status component and type definitions
- Summary card component updates for string metrics
- RBAC permissions configuration for SatuSehat routes
This commit is contained in:
Khafid Prayoga
2025-08-19 11:01:28 +07:00
parent 878211bc7f
commit b1cb24cae3
10 changed files with 291 additions and 6 deletions
+107
View File
@@ -0,0 +1,107 @@
<script setup lang="ts">
import type { ServiceStatus } from '~/components/pub/base/service-status.type'
import type { Summary } from '~/components/pub/base/summary-card.type'
import type { HeaderPrep, RefSearchNav } from '~/components/pub/nav/types'
import { CircleCheckBig, CircleDashed, CircleX, Send } from 'lucide-vue-next'
const refSearchNav: RefSearchNav = {
onClick: () => {
// open filter modal
},
onInput: (_val: string) => {
// filter patient list
},
onClear: () => {
// clear url param
},
}
// Loading state management
const isLoading = reactive({
satusehatConn: false,
})
const hreaderPrep: HeaderPrep = {
title: 'SATUSEHAT Integration',
icon: 'i-lucide-box',
addNav: {
label: 'Kirim Resource',
icon: 'i-lucide-send',
// onClick: () => navigateTo('/patient/add'),
},
}
// SATUSEHAT Service integration
const service = reactive<ServiceStatus>({
serviceName: 'SATUSEHAT',
serviceDesc: 'SATUSEHAT - FHIR R4 Compliant',
sessionActive: false,
status: 'connecting',
isSkeleton: false,
})
// Initial/default data structure
const summaryData: Summary[] = [
{
title: 'Resource Terkirim',
icon: Send,
metric: 1245,
trend: 0,
timeframe: 'daily',
},
{
title: 'Sync Success',
icon: CircleCheckBig,
metric: '97%',
trend: 0,
timeframe: 'daily',
},
{
title: 'Pending Queue',
icon: CircleDashed,
metric: 32,
trend: 0,
timeframe: 'daily',
},
{
title: 'Failed Items',
icon: CircleX,
metric: 10,
trend: 0,
timeframe: 'daily',
},
]
async function callSatuSehat() {
try {
isLoading.satusehatConn = true
await new Promise((resolve) => setTimeout(resolve, 3000))
service.status = 'connected'
// service.status = 'error'
service.sessionActive = true
// service.sessionActive = false
} finally {
isLoading.satusehatConn = false
}
}
onMounted(() => {
callSatuSehat()
})
</script>
<template>
<PubNavHeaderPrep :prep="hreaderPrep" :ref-search-nav="refSearchNav" />
<div class="my-4 flex flex-1 flex-col gap-4 md:gap-8">
<PubBaseServiceStatus v-bind="service" />
<div class="grid gap-4 md:grid-cols-2 md:gap-8 lg:grid-cols-4">
<template v-if="isLoading.satusehatConn">
<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>
</div>
</template>
@@ -0,0 +1,7 @@
export interface ServiceStatus {
serviceName: string
serviceDesc: string
sessionActive: boolean
status: 'connected' | 'connecting' | 'error' | 'disconnected'
isSkeleton?: boolean
}
@@ -0,0 +1,68 @@
<script setup lang="ts">
import type { ServiceStatus } from './service-status.type'
import { Loader2 } from 'lucide-vue-next'
import { cn } from '~/lib/utils'
const props = defineProps<ServiceStatus>()
const tokenStatus = computed((): string => {
return props.sessionActive ? 'Valid' : 'Invalid'
})
</script>
<template>
<Card v-if="props.isSkeleton" class="py-6">
<div class="flex gap-4 justify-between px-6">
<div class="flex gap-2 items-center">
<span class="bg-gray-100">
<Skeleton class="bg-gray-100 w-6 h-6 sm:w-8 sm:h-8" />
</span>
<div>
<Skeleton class="w-64 h-8 bg-gray-100 text-xs md:text-sm text-muted-foreground" />
</div>
</div>
<div class="text-right flex flex-col items-end">
<Skeleton class="w-32 h-2 bg-gray-100 text-xs md:text-md text-muted-foreground" />
<Skeleton class="w-32 h-3 bg-gray-100 text-xs md:text-md font-bold" />
</div>
</div>
</Card>
<Card v-else class="py-6">
<div class="flex gap-4 justify-between px-6">
<div class="flex gap-2 items-center">
<span
:class="cn(' rounded-md w-12 h-12 flex items-center justify-center',
{ 'bg-red-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-else name="i-lucide-bring-to-front" class="text-white w-6 h-6 sm:w-8 sm:h-8" />
</span>
<div>
<p v-if="props.status === 'connected'" class="text-xs md:text-md font-bold">Koneksi {{ props.serviceName }}
Aktif</p>
<p v-if="props.status === 'connecting'" class="flex flex-row text-xs md:text-md font-bold">Menghubungkan ke API {{
props.serviceName }}
<Loader2 class="ml-2 h-4 w-4 animate-spin" />
</p>
<p v-if="props.status === 'error'" class="text-xs md:text-md font-bold">Koneksi ke API {{ props.serviceName
}}
Gagal</p>
<p v-if="props.status === 'connected'" class="text-xs md:text-sm text-muted-foreground">Koneksi Terhubung ke
API {{ props.serviceDesc }}
</p>
</div>
</div>
<div class="text-right flex flex-col items-end">
<p class="text-xs md:text-md text-muted-foreground">Session Token</p>
<p
:class="cn('text-xs md:text-md font-bold',
{ 'text-blue-500': props.sessionActive },
{ 'text-red-500': !props.sessionActive },
)"
>{{ tokenStatus }}</p>
</div>
</div>
</Card>
</template>
+1 -1
View File
@@ -1,7 +1,7 @@
export interface Summary {
title: string
icon: Component
metric: number
metric: number | string
trend: number
timeframe: 'yearly' | 'monthly' | 'weekly' | 'daily'
}
+3 -5
View File
@@ -41,7 +41,7 @@ const isTrending = computed<boolean>(() => (props.stat?.trend ?? 0) > 0)
</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" />
<Skeleton v-if="props.stat?.trend" class="h-4 w-64 bg-gray-100 text-xs font-medium" />
</CardContent>
</Card>
<Card v-else-if="props.stat && !props.isSkeleton">
@@ -54,10 +54,8 @@ const isTrending = computed<boolean>(() => (props.stat?.trend ?? 0) > 0)
{{ 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 })"
/>
<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) }}% -->
+8
View File
@@ -9,4 +9,12 @@ export const PAGE_PERMISSIONS = {
billing: ['R'],
management: ['R'],
},
'/satusehat': {
doctor: ['R'],
nurse: ['R'],
admisi: ['C', 'R', 'U', 'D'],
pharmacy: ['R'],
billing: ['R'],
management: ['R'],
},
} as const satisfies Record<string, RoleAccess>
@@ -0,0 +1,9 @@
<script setup lang="ts">
definePageMeta({
roles: ['sys', 'doc'],
})
</script>
<template>
<div>detail satusehat</div>
</template>
@@ -0,0 +1,9 @@
<script setup lang="ts">
definePageMeta({
roles: ['sys', 'doc'],
})
</script>
<template>
<div>edit satusehat</div>
</template>
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { PagePermission } from '~/models/role'
import { PAGE_PERMISSIONS } from '~/lib/page-permission'
definePageMeta({
middleware: ['rbac'],
roles: ['doctor', 'nurse', 'admisi', 'pharmacy', 'billing', 'management'],
title: 'Tambah Pasien',
contentFrame: 'cf-full-width',
})
const route = useRoute()
useHead({
title: () => route.meta.title as string,
})
const roleAccess: PagePermission = PAGE_PERMISSIONS['/satusehat']
const { checkRole, hasCreateAccess } = useRBAC()
// Check if user has access to this page
const hasAccess = checkRole(roleAccess)
if (!hasAccess) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied',
})
}
// Define permission-based computed properties
const canCreate = hasCreateAccess(roleAccess)
</script>
<template>
<div v-if="canCreate">
<FlowPatientAdd />
</div>
<PubBaseError v-else :status-code="403" />
</template>
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { PagePermission } from '~/models/role'
import { PAGE_PERMISSIONS } from '~/lib/page-permission'
definePageMeta({
middleware: ['rbac'],
roles: ['doctor', 'nurse', 'admisi', 'pharmacy', 'billing', 'management'],
title: 'SATUSEHAT Integration',
contentFrame: 'cf-full-width',
})
const route = useRoute()
useHead({
title: () => route.meta.title as string,
})
const roleAccess: PagePermission = PAGE_PERMISSIONS['/satusehat']
const { checkRole, hasReadAccess } = useRBAC()
// Check if user has access to this page
const hasAccess = checkRole(roleAccess)
if (!hasAccess) {
navigateTo('/403')
}
// Define permission-based computed properties
const canRead = hasReadAccess(roleAccess)
</script>
<template>
<div>
<div v-if="canRead">
<FlowSatusehatList />
</div>
<PubBaseError v-else :status-code="403" />
</div>
</template>