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:
@@ -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,7 +1,7 @@
|
||||
export interface Summary {
|
||||
title: string
|
||||
icon: Component
|
||||
metric: number
|
||||
metric: number | string
|
||||
trend: number
|
||||
timeframe: 'yearly' | 'monthly' | 'weekly' | 'daily'
|
||||
}
|
||||
|
||||
@@ -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) }}% -->
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user