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:
No files matched your search
@@ -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 {
|
export interface Summary {
|
||||||
title: string
|
title: string
|
||||||
icon: Component
|
icon: Component
|
||||||
metric: number
|
metric: number | string
|
||||||
trend: number
|
trend: number
|
||||||
timeframe: 'yearly' | 'monthly' | 'weekly' | 'daily'
|
timeframe: 'yearly' | 'monthly' | 'weekly' | 'daily'
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ const isTrending = computed<boolean>(() => (props.stat?.trend ?? 0) > 0)
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Skeleton class="mb-2 h-6 w-48 bg-gray-100 text-2xl" />
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card v-else-if="props.stat && !props.isSkeleton">
|
<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') }}
|
{{ 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
|
<component :is="isTrending ? ChevronUp : ChevronDown"
|
||||||
:is="isTrending ? ChevronUp : ChevronDown"
|
:class="cn('h-4 w-4', { 'text-green-500': isTrending }, { 'text-red-500': !isTrending })" />
|
||||||
: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) }}% -->
|
||||||
|
|||||||
@@ -9,4 +9,12 @@ export const PAGE_PERMISSIONS = {
|
|||||||
billing: ['R'],
|
billing: ['R'],
|
||||||
management: ['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>
|
} 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