Merge pull request #9 from dikstub-rssa/feat/satusehat

Feat/satusehat
This commit is contained in:
Khafid Prayoga
2025-08-27 09:58:10 +07:00
committed by GitHub
54 changed files with 2068 additions and 252 deletions
+15
View File
@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
}
]
}
+1
View File
@@ -9,6 +9,7 @@ RSSA - Front End
- [Vue Style Guide](https://vuejs.org/style-guide)
- [Nuxt Style Guide](https://nuxt.com/docs/4.x/guide)
- [Shadcn Vue @radix-ui](https://radix.shadcn-vue.com/)
## Configuration
+38 -40
View File
@@ -21,9 +21,9 @@
--muted: 210 25% 95%;
--muted-foreground: 210 15% 50%;
/* Accent - Professional Blue */
--accent: 210 100% 50%;
--accent-foreground: 0 0% 100%;
/* Accent - Neutral Gray */
--accent: 210 40% 96%;
--accent-foreground: 222.2 84% 4.9%;
--destructive: 0 75% 55%;
--destructive-foreground: 0 0% 100%;
--border: 210 20% 88%;
@@ -67,45 +67,43 @@
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
/* .dark { */
/* --background: 210 25% 8%; */
/* --foreground: 210 20% 95%; */
/* --card: 210 25% 10%; */
/* --card-foreground: 210 20% 95%; */
/* --popover: 210 25% 10%; */
/* --popover-foreground: 210 20% 95%; */
/* --primary: 150 75% 45%; */
/* --primary-foreground: 0 0% 100%; */
/* --primary-hover: 150 75% 50%; */
/* --secondary: 210 25% 15%; */
/* --secondary-foreground: 210 20% 90%; */
/* --muted: 210 25% 15%; */
/* --muted-foreground: 210 15% 65%; */
/* --accent: 210 100% 55%; */
/* --accent-foreground: 0 0% 100%; */
/* --destructive: 0 75% 60%; */
/* --destructive-foreground: 0 0% 100%; */
/* --border: 210 25% 20%; */
/* --input: 210 25% 15%; */
/* --ring: 150 75% 45%; */
/* --success: 150 75% 50%; */
/* --warning: 45 95% 65%; */
/* --info: 210 100% 60%; */
/* --gradient-primary: linear-gradient(135deg, hsl(150 75% 45%), hsl(150 75% 55%)); */
/* --gradient-medical: linear-gradient(135deg, hsl(150 75% 45%), hsl(210 100% 55%)); */
/* --gradient-subtle: linear-gradient(180deg, hsl(210 25% 8%), hsl(210 25% 12%)); */
/* --sidebar-background: 240 5.9% 10%; */
/* --sidebar-foreground: 240 4.8% 95.9%; */
/* --sidebar-primary: 224.3 76.3% 48%; */
/* --sidebar-primary-foreground: 0 0% 100%; */
/* --sidebar-accent: 240 3.7% 15.9%; */
/* --sidebar-accent-foreground: 240 4.8% 95.9%; */
/* --sidebar-border: 240 3.7% 15.9%; */
/* --sidebar-ring: 217.2 91.2% 59.8%; */
}
/* .dark { */
/* --background: 210 25% 8%; */
/* --foreground: 210 20% 95%; */
/* --card: 210 25% 10%; */
/* --card-foreground: 210 20% 95%; */
/* --popover: 210 25% 10%; */
/* --popover-foreground: 210 20% 95%; */
/* --primary: 150 75% 45%; */
/* --primary-foreground: 0 0% 100%; */
/* --primary-hover: 150 75% 50%; */
/* --secondary: 210 25% 15%; */
/* --secondary-foreground: 210 20% 90%; */
/* --muted: 210 25% 15%; */
/* --muted-foreground: 210 15% 65%; */
/* --accent: 210 100% 55%; */
/* --accent-foreground: 0 0% 100%; */
/* --destructive: 0 75% 60%; */
/* --destructive-foreground: 0 0% 100%; */
/* --border: 210 25% 20%; */
/* --input: 210 25% 15%; */
/* --ring: 150 75% 45%; */
/* --success: 150 75% 50%; */
/* --warning: 45 95% 65%; */
/* --info: 210 100% 60%; */
/* --gradient-primary: linear-gradient(135deg, hsl(150 75% 45%), hsl(150 75% 55%)); */
/* --gradient-medical: linear-gradient(135deg, hsl(150 75% 45%), hsl(210 100% 55%)); */
/* --gradient-subtle: linear-gradient(180deg, hsl(210 25% 8%), hsl(210 25% 12%)); */
/* --sidebar-background: 240 5.9% 10%; */
/* --sidebar-foreground: 240 4.8% 95.9%; */
/* --sidebar-primary: 224.3 76.3% 48%; */
/* --sidebar-primary-foreground: 0 0% 100%; */
/* --sidebar-accent: 240 3.7% 15.9%; */
/* --sidebar-accent-foreground: 240 4.8% 95.9%; */
/* --sidebar-border: 240 3.7% 15.9%; */
/* --sidebar-ring: 217.2 91.2% 59.8%; */
/* } */
/* Keyframes for Animations */
@keyframes accordion-down {
from {
+13 -8
View File
@@ -11,6 +11,7 @@ import { defineAsyncComponent } from 'vue'
type SmallDetailDto = any
const action = defineAsyncComponent(() => import('~/components/pub/custom-ui/data/dropdown-action-dud.vue'))
const statusBadge = defineAsyncComponent(() => import('./status-badge.vue'))
const doctorStatus = {
0: 'Tidak Aktif',
@@ -26,10 +27,10 @@ export const cols: Col[] = [
{},
{},
{},
{ width: 120 },
{ width: 100 },
{ width: 100 },
{ width: 100 },
{ width: 50 },
{},
{},
]
export const header: Th[][] = [
@@ -43,6 +44,7 @@ export const header: Th[][] = [
{ label: 'Fee Ranap' },
{ label: 'Fee Rajal' },
{ label: 'Status' },
{ label: '' },
],
]
@@ -66,7 +68,6 @@ export const delKeyNames: KeyLabel[] = [
export const funcParsed: RecStrFuncUnknown = {
name: (rec: unknown): unknown => {
console.log(rec)
const recX = rec as SmallDetailDto
return `${recX.frontTitle} ${recX.name} ${recX.endTitle}`.trim()
},
@@ -85,10 +86,6 @@ export const funcParsed: RecStrFuncUnknown = {
const recX = rec as SmallDetailDto
return Number(recX.outPatient_itemPrice.price).toLocaleString('id-ID')
},
status: (rec: unknown): unknown => {
const recX = rec as SmallDetailDto
return doctorStatus[recX.status_code as keyof typeof doctorStatus]
},
}
export const funcComponent: RecStrFuncComponent = {
@@ -100,6 +97,14 @@ export const funcComponent: RecStrFuncComponent = {
}
return res
},
status(rec, idx) {
const res: RecComponent = {
idx,
rec: rec as object,
component: statusBadge,
}
return res
},
}
export const funcHtml: RecStrFuncUnknown = {
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { Badge } from '~/components/pub/ui/badge'
const props = defineProps<{
rec: any
idx?: number
}>()
const doctorStatus = {
0: 'Tidak Aktif',
1: 'Aktif',
}
const statusText = computed(() => {
return doctorStatus[props.rec.status_code as keyof typeof doctorStatus]
})
const badgeVariant = computed(() => {
return props.rec.status_code === 1 ? 'default' : 'destructive'
})
</script>
<template>
<div class="flex justify-center">
<Badge :variant="badgeVariant">
{{ statusText }}
</Badge>
</div>
</template>
+12
View File
@@ -11,6 +11,7 @@ import { defineAsyncComponent } from 'vue'
type SmallDetailDto = any
const action = defineAsyncComponent(() => import('~/components/pub/custom-ui/data/dropdown-action-dud.vue'))
const statusBadge = defineAsyncComponent(() => import('./status-badge.vue'))
export const cols: Col[] = [
{},
@@ -110,6 +111,17 @@ export const funcComponent: RecStrFuncComponent = {
}
return res
},
status(rec, idx) {
if (rec.status === null) {
rec.status_code = 0
}
const res: RecComponent = {
idx,
rec: rec as object,
component: statusBadge,
}
return res
},
}
export const funcHtml: RecStrFuncUnknown = {
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { Badge } from '~/components/pub/ui/badge'
const props = defineProps<{
rec: any
idx?: number
}>()
const doctorStatus = {
0: 'Tidak Aktif',
1: 'Aktif',
}
const statusText = computed(() => {
return doctorStatus[props.rec.status_code as keyof typeof doctorStatus]
})
const badgeVariant = computed(() => {
return props.rec.status_code === 1 ? 'default' : 'destructive'
})
</script>
<template>
<div class="flex justify-center">
<Badge :variant="badgeVariant">
{{ statusText }}
</Badge>
</div>
</template>
@@ -1,9 +1,9 @@
import type { Col, KeyLabel, RecComponent, RecStrFuncComponent, RecStrFuncUnknown, Th } from '../../pub/nav/types'
import type { Col, KeyLabel, RecComponent, RecStrFuncComponent, RecStrFuncUnknown, Th } from '~/components/pub/custom-ui/data/types'
import { defineAsyncComponent } from 'vue'
type SmallDetailDto = any
const action = defineAsyncComponent(() => import('~/components/pub/nav/dropdown-action-dud.vue'))
const action = defineAsyncComponent(() => import('~/components/pub/custom-ui/data/dropdown-action-dud.vue'))
export const cols: Col[] = [
{},
@@ -0,0 +1,13 @@
<script setup lang="ts">
const props = defineProps<{
rec: any
idx?: number
}>()
</script>
<template>
<div class="flex flex-col justify-center">
<p class="font-semibold text-sm">{{ props.rec.patient.name }}</p>
<p class="text-xs text-muted-foreground">{{ props.rec.patient.mrn }}</p>
</div>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import Badge from './badge.vue'
import { rowStatus } from './list-cfg'
const props = defineProps<{
rec: { status: number }
idx?: number
}>()
const variants = {
0: 'error',
1: 'warning',
2: 'info',
} as const
const statusText = computed(() => {
return rowStatus[props.rec.status as keyof typeof rowStatus]
})
const badgeStatus = computed((): 'error' | 'warning' | 'success' | 'info' => {
return variants[props.rec.status as keyof typeof variants] ?? 'info'
})
</script>
<template>
<div class="flex">
<Badge :status="badgeStatus" :text="statusText" />
</div>
</template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { cva } from 'class-variance-authority'
import { cn } from '~/lib/utils'
interface BadgeProps {
status: 'success' | 'info' | 'warning' | 'error'
text: string
}
const props = defineProps<BadgeProps>()
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
success: 'rounded-full border-transparent bg-green-500 text-white hover:bg-green-600',
info: 'rounded-full border-transparent bg-blue-500 text-white hover:bg-blue-600',
warning: 'rounded-full border-transparent bg-yellow-500 text-white hover:bg-yellow-600',
error: 'rounded-full border-transparent bg-red-500 text-white hover:bg-red-600',
},
},
defaultVariants: {
variant: 'info',
},
},
)
</script>
<template>
<div class="flex">
<div :class="cn(badgeVariants({ variant: props.status }))">
{{ props.text }}
</div>
</div>
</template>
@@ -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>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import Block from '~/components/pub/form/block.vue'
import FieldGroup from '~/components/pub/form/field-group.vue'
import Field from '~/components/pub/form/field.vue'
import Label from '~/components/pub/form/label.vue'
</script>
<template>
<form id="entry-form">
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
<Icon name="i-lucide-user" class="me-2" />
<span class="font-semibold">Tambah</span> Pasien
</div>
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
<Block>
<FieldGroup :column="3">
<Label>Nama</Label>
<Field>
<Input type="text" name="name" />
</Field>
</FieldGroup>
<FieldGroup :column="3">
<Label>Nama</Label>
<Field>
<Input type="text" name="name" />
</Field>
</FieldGroup>
<FieldGroup :column="3">
<Label>Nomor RM</Label>
<Field>
<Input type="text" name="name" />
</Field>
</FieldGroup>
<FieldGroup>
<Label dynamic>Alamat</Label>
<Field>
<Input type="text" name="name" />
</Field>
</FieldGroup>
</Block>
</div>
<div class="my-2 flex justify-end py-2">
<PubNavFooterCsd />
</div>
</form>
</template>
+78
View File
@@ -0,0 +1,78 @@
import type { Col, KeyLabel, RecComponent, RecStrFuncComponent, RecStrFuncUnknown, Th } from '../../pub/nav/types'
import { defineAsyncComponent } from 'vue'
type SmallDetailDto = any
export const rowType = {
1: 'Patient',
2: 'Encounter',
3: 'Observation',
}
export const rowStatus = {
0: 'Gagal',
1: 'Pending',
2: 'Terkirim',
}
const patientBadge = defineAsyncComponent(() => import('./badge-patient.vue'))
const statusBadge = defineAsyncComponent(() => import('./badge-status.vue'))
export const cols: Col[] = [
{ width: 100 },
{ width: 100 },
{ width: 100 },
{ width: 100 },
{ width: 100 },
{ width: 100 },
]
export const header: Th[][] = [
[
{ label: 'ID' },
{ label: 'Jenis' },
{ label: 'Pasien' },
{ label: 'Status' },
{ label: 'Terakhir Update' },
{ label: 'FHIR ID' },
],
]
export const keys = ['id', 'resource_type', 'patient', 'status', 'updated_at', 'fhir_id']
export const delKeyNames: KeyLabel[] = [
{ key: 'code', label: 'Kode' },
{ key: 'name', label: 'Nama' },
]
export const funcParsed: RecStrFuncUnknown = {
name: (rec: unknown): unknown => {
const recX = rec as SmallDetailDto
return `${recX.firstName} ${recX.middleName || ''} ${recX.lastName || ''}`
},
}
export const funcComponent: RecStrFuncComponent = {
patient(rec, idx) {
const res: RecComponent = {
idx,
rec: rec as object,
component: patientBadge,
}
return res
},
status(rec, idx) {
const res: RecComponent = {
idx,
rec: rec as object,
component: statusBadge,
}
return res
},
}
export const funcHtml: RecStrFuncUnknown = {
patient_address(_rec) {
return '-'
},
}
+12
View File
@@ -0,0 +1,12 @@
<script setup lang="ts">
import { cols, funcComponent, funcHtml, funcParsed, header, keys } from './list-cfg'
defineProps<{
data: any[]
}>()
</script>
<template>
<PubBaseDataTable :rows="data" :cols="cols" :header="header" :keys="keys" :func-parsed="funcParsed"
:func-html="funcHtml" :func-component="funcComponent" />
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { DateRange } from 'radix-vue'
import type { Ref } from 'vue'
import {
CalendarDate,
DateFormatter,
getLocalTimeZone,
} from '@internationalized/date'
import { Calendar as CalendarIcon } from 'lucide-vue-next'
import { cn } from '~/lib/utils'
const df = new DateFormatter('en-US', {
dateStyle: 'medium',
})
const value = ref({
start: new CalendarDate(2022, 1, 20),
end: new CalendarDate(2022, 1, 20).add({ days: 20 }),
}) as Ref<DateRange>
</script>
<template>
<Popover>
<PopoverTrigger as-child>
<Button
variant="outline"
:class="cn(
'w-[280px] 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">
{{ df.format(value.start.toDate(getLocalTimeZone())) }} - {{ df.format(value.end.toDate(getLocalTimeZone())) }}
</template>
<template v-else>
{{ df.format(value.start.toDate(getLocalTimeZone())) }}
</template>
</template>
<template v-else>
Pick a date
</template>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<RangeCalendar v-model="value" initial-focus :number-of-months="2" @update:start-value="(startDate) => value.start = startDate" />
</PopoverContent>
</Popover>
</template>
+12
View File
@@ -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>
+6 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Activity, CreditCard, DollarSign, Users, UserCheck, UsersRound, Calendar, Hospital } from 'lucide-vue-next'
import { Calendar, Hospital, UserCheck, UsersRound } from 'lucide-vue-next'
const dataCard = ref({
totalRevenue: 0,
@@ -161,8 +161,11 @@ onMounted(() => {
</div>
</CardHeader>
<CardContent class="grid cursor-pointer gap-8 md:grid-cols-4 md:gap-8">
<Card v-for="item in linkItems" :key="item"
class="border-primary hover:bg-primary my-2 h-32 border transition-colors duration-200 hover:bg-gray-200">
<Card
v-for="item in linkItems"
:key="item.title"
class="border-primary hover:bg-primary my-2 h-32 border transition-colors duration-200 hover:bg-gray-200"
>
<NuxtLink :to="item.link">
<CardContent class="my-2 grid h-full grid-rows-2 place-items-center">
<Icon :name="item.icon" class="text-primary h-9 w-[60px]" />
+17 -4
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { HeaderPrep, RefSearchNav } from '~/components/pub/nav/types'
import type { HeaderPrep, RefSearchNav } from '~/components/pub/custom-ui/data/types'
import type { DataTableLoader } from '~/components/pub/base/data-table/type';
import Header from '~/components/pub/custom-ui/nav-header/prep.vue'
const data = ref([])
@@ -16,6 +17,10 @@ const refSearchNav: RefSearchNav = {
},
}
const isLoading = reactive<DataTableLoader>({
isTableLoading: false,
})
const recId = ref<number>(0)
const recAction = ref<string>('')
const recItem = ref<any>(null)
@@ -29,13 +34,16 @@ const headerPrep: HeaderPrep = {
},
}
useAsyncData('getDoctor', () => xfetch('/api/v1/doctor'), { server: false, immediate: true })
async function getDoctorList() {
isLoading.dataListLoading = true
const resp = await xfetch('/api/v1/doctor')
if (resp.success) {
data.value = (resp.body as Record<string, any>).data
}
isLoading.dataListLoading = false
}
onMounted(() => {
@@ -45,9 +53,14 @@ onMounted(() => {
provide('rec_id', recId)
provide('rec_action', recAction)
provide('rec_item', recItem)
provide('table_data_loader', isLoading)
</script>
<template>
<Header :prep="{ ...headerPrep }" :ref-search-nav="refSearchNav" />
<AppDoctorList :data="data" />
<div class="rounded-md border p-4">
<Header :prep="{ ...headerPrep }" :ref-search-nav="refSearchNav" />
<div class="rounded-md border p-4">
<AppDoctorList v-if="!isLoading.dataListLoading" :data="data" />
</div>
</div>
</template>
+9 -5
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { Summary } from '~/components/pub/base/summary-card.type'
import type { HeaderPrep, RefSearchNav } from '~/components/pub/nav/types'
import type { DataTableLoader } from '~/components/pub/base/data-table/type';
import type { Summary } from '~/components/pub/base/summary-card/type'
import type { HeaderPrep, RefSearchNav } from '~/components/pub/custom-ui/data/types'
import { Calendar, Hospital, UserCheck, UsersRound } from 'lucide-vue-next'
import SummaryCard from '~/components/pub/base/summary-card/summary-card.vue'
import Header from '~/components/pub/custom-ui/nav-header/prep.vue'
@@ -20,10 +21,11 @@ const refSearchNav: RefSearchNav = {
}
// Loading state management
const isLoading = reactive({
const isLoading = reactive<DataTableLoader>({
summary: false,
table: false,
isTableLoading: false,
})
const recId = ref<number>(0)
const recAction = ref<string>('')
const recItem = ref<any>(null)
@@ -84,11 +86,12 @@ async function getPatientSummary() {
}
async function getPatientList() {
isLoading.isTableLoading = true
const resp = await xfetch('/api/v1/patient')
console.log('data patient', resp)
if (resp.success) {
data.value = (resp.body as Record<string, any>).data
}
isLoading.isTableLoading = false
}
onMounted(() => {
@@ -99,6 +102,7 @@ onMounted(() => {
provide('rec_id', recId)
provide('rec_action', recAction)
provide('rec_item', recItem)
provide('table_data_loader', isLoading)
</script>
<template>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { HeaderPrep, RefSearchNav } from '~/components/pub/nav/types'
import type { HeaderPrep, RefSearchNav } from '~/components/pub/custom-ui/data/types'
import type { DataTableLoader } from '~/components/pub/base/data-table/type';
import RehabSepProsedurList from '~/components/app/rehab/registration/sep-prosedur/list.vue'
const props = defineProps<{
@@ -21,9 +22,9 @@ const refSearchNav: RefSearchNav = {
}
// Loading state management
const isLoading = reactive({
summary: false,
table: false,
const isLoading = reactive<DataTableLoader>({
isTableLoading: false,
})
const recId = ref<number>(0)
const recAction = ref<string>('')
@@ -40,7 +41,6 @@ const hreaderPrep: HeaderPrep = {
async function getPatientList() {
const resp = await xfetch('/api/v1/patient')
console.log('data patient', resp)
if (resp.success) {
data.value = (resp.body as Record<string, any>).data
}
@@ -53,12 +53,12 @@ onMounted(() => {
provide('rec_id', recId)
provide('rec_action', recAction)
provide('rec_item', recItem)
provide('table_data_loader', isLoading)
</script>
<template>
<PubNavHeaderPrep :prep="{ ...hreaderPrep }" :ref-search-nav="refSearchNav" />
<Header :prep="{ ...hreaderPrep }" :ref-search-nav="refSearchNav" />
<div class="my-4 flex flex-1 flex-col gap-4 md:gap-8">
<RehabSepProsedurList :data="data" />
</div>
</template>
/template>
+97
View File
@@ -0,0 +1,97 @@
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/custom-ui/data/types'
import { CircleCheckBig, CircleDashed, CircleX, Send } from 'lucide-vue-next'
export const tabs = [
{
value: 'all',
label: 'Semua Resource',
},
{
value: 'patient',
label: 'Patient',
},
{
value: 'encounter',
label: 'Encounter',
},
{
value: 'observation',
label: 'Observation',
},
]
export const actions = [
{
value: 'export',
label: 'Ekspor',
icon: 'i-lucide-download',
},
]
// Status filter options
export const statusOptions = [
{ value: '0', label: 'Failed' },
{ value: '1', label: 'Pending' },
{ value: '2', label: 'Success' },
]
export 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',
},
]
// SATUSEHAT Service integration
export const service = reactive<ServiceStatus>({
serviceName: 'SATUSEHAT',
serviceDesc: 'SATUSEHAT - FHIR R4 Compliant',
sessionActive: false,
status: 'connecting',
isSkeleton: false,
})
export const headerPrep: HeaderPrep = {
title: 'SATUSEHAT Integration',
icon: 'i-lucide-box',
addNav: {
label: 'Kirim Resource',
icon: 'i-lucide-send',
// onClick: () => navigateTo('/patient/add'),
},
}
export const refSearchNav: RefSearchNav = {
onClick: () => {
// open filter modal
},
onInput: (_val: string) => {
},
onClear: () => {
},
}
+226
View File
@@ -0,0 +1,226 @@
<script setup lang="ts">
import type { DataTableLoader } from '~/components/pub/base/data-table/type'
import { useUrlSearchParams } from '@vueuse/core'
import Header from '~/components/pub/custom-ui/nav-header/header.vue'
import { actions, headerPrep, refSearchNav, service, summaryData, tabs } from './const'
import { defaultQuery, querySchema, tabSwitcher } from './schema.query'
// State management
const data = ref([])
const recId = ref<number>(0)
const recAction = ref<string>('')
const recItem = ref<any>(null)
const isLoading = reactive<DataTableLoader>({
satusehatConn: true,
isTableLoading: false,
})
const queryParams = useUrlSearchParams('history', {
initialValue: defaultQuery,
removeFalsyValues: true,
})
const params = computed(() => {
const result = querySchema.safeParse(queryParams)
return result.data
})
// Pagination state
const pagination = ref({
total: 0,
page: 1,
limit: 10,
total_pages: 0,
has_next: false,
has_prev: false,
})
// API function to fetch data
async function fetchData() {
try {
isLoading.isTableLoading = true
data.value = []
const response = await xfetch('/api/v1/satusehat/list', 'POST', {
resource_type: params.value?.resource_type,
date_from: params.value?.date_from,
date_to: params.value?.date_to,
search: params.value?.q,
page: params.value?.page,
limit: params.value?.limit,
})
if (response.success) {
data.value = response.body.data
pagination.value = response.body.meta
}
} catch (error) {
console.error('Error fetching data:', error)
data.value = []
} finally {
isLoading.isTableLoading = false
}
}
async function callSatuSehat() {
try {
await new Promise((resolve) => setTimeout(resolve, 500))
service.status = 'connected'
// service.status = 'error'
service.sessionActive = true
// service.sessionActive = false
} finally {
isLoading.satusehatConn = false
}
}
// Initialize params from URL on mount
onMounted(async () => {
await callSatuSehat()
await fetchData()
})
// Watch for url param changed state and trigger refetch
watch(params, () => {
fetchData()
})
provide('rec_id', recId)
provide('rec_action', recAction)
provide('rec_item', recItem)
provide('table_data_loader', isLoading)
// Sync active tab filter with queryparams
// fallback and default to `all` filter
const activeTabFilter = computed({
get: () => {
const result = tabSwitcher.parse(queryParams.resource_type)
return result
},
set: (value) => {
queryParams.resource_type = value
queryParams.q = defaultQuery.q
queryParams.page = defaultQuery.page
queryParams.limit = defaultQuery.limit
queryParams.date_from = defaultQuery.date_from
queryParams.date_to = defaultQuery.date_to
},
})
</script>
<template>
<div class="rounded-md border p-4">
<Header :prep="headerPrep" :ref-search-nav="refSearchNav" />
<div class="my-4 flex flex-1 flex-col gap-3 md:gap-4">
<PubBaseServiceStatus v-bind="service" />
<AppSatusehatCardSummary :is-loading="isLoading.satusehatConn!" :summary-data="summaryData" />
</div>
<div class="rounded-md border p-4">
<h2 class="text-md font-semibold py-2">FHIR Resource</h2>
<Tabs v-model="activeTabFilter">
<div class="scrollbar-hide overflow-x-auto flex gap-2 justify-between">
<TabsList>
<TabsTrigger v-for="tab in tabs" :key="tab.value" :value="tab.value"
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">
<!-- Search Input -->
<AppSatusehatPicker/>
<div class="relative w-full max-w-sm">
<Dialog>
<DialogTrigger>
<Input type="text" placeholder="Cari pasien..." class="pl-3 h-9" />
</DialogTrigger>
<DialogContent>
<DialogHeader class="border-b-1">
<DialogTitle class="pb-2">Pencarian</DialogTitle>
</DialogHeader>
<Form>
<FormField v-slot="{ componentField }" name="username">
<FormItem>
<FormLabel>Pasien</FormLabel>
<FormControl>
<Input type="text" placeholder="nama pasien, id pasien" v-bind="componentField" />
</FormControl>
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="status">
<FormItem>
<FormLabel>Status</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger class="bg-white border border-gray-300">
<SelectValue class="text-gray-400" placeholder="-- select item" />
</SelectTrigger>
<SelectContent class="bg-white ">
<SelectItem value="0">
Gagal
</SelectItem>
<SelectItem value="1">
Pending
</SelectItem>
<SelectItem value="2">
Terkirim
</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="fhirId">
<FormItem>
<FormLabel>FHIR ID</FormLabel>
<FormControl>
<Input type="text" placeholder="fhir id" v-bind="componentField" />
</FormControl>
</FormItem>
</FormField>
</Form>
<DialogFooter>
<Button variant="outline">
Reset
</Button>
<Button>
Apply
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<!-- Action Buttons -->
<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">
<AppSatusehatList v-if="!isLoading.satusehatConn" :data="data" />
<!-- Pagination -->
<div v-if="!isLoading.satusehatConn && !isLoading.isTableLoading && pagination.total > 0"
class="mt-4 flex justify-between items-center">
<div class="text-sm text-muted-foreground">
Menampilkan {{ ((pagination.page - 1) * pagination.limit) + 1 }} -
{{ Math.min(pagination.page * pagination.limit, pagination.total) }}
dari {{ pagination.total }} data
</div>
<div class="flex gap-2">
<Button v-if="pagination.has_prev" variant="outline" size="sm" @click="queryParams.page--">
Sebelumnya
</Button>
<Button v-if="pagination.has_next" variant="outline" size="sm" @click="queryParams.page++">
Selanjutnya
</Button>
</div>
</div>
</div>
</TabsContent>
</div>
</Tabs>
</div>
</div>
</template>
@@ -0,0 +1,23 @@
import * as z from 'zod'
const resourceTabEnum = z.enum(['all', 'patient', 'encounter', 'observation'])
export const tabSwitcher = resourceTabEnum.default('all').catch('all')
export const querySchema = z.object({
q: z.string().min(3).optional().catch(''),
resource_type: tabSwitcher,
date_from: z.string().optional().catch(''),
date_to: z.string().optional().catch(''),
page: z.coerce.number().int().min(1).default(1).catch(1),
limit: z.coerce.number().int().min(1).max(20).default(10).catch(10),
})
export const defaultQuery = {
q: '',
status: '',
resource_type: 'all',
date_from: '',
date_to: '',
page: 1,
limit: 10,
}
+14 -10
View File
@@ -12,12 +12,12 @@ const teams: {
logo: string
plan: string
}[] = [
{
name: 'SIMRS - RSSA',
logo: '/rssa-logo.png',
plan: 'Saiful Anwar Hospital',
},
]
{
name: 'SIMRS - RSSA',
logo: '/rssa-logo.png',
plan: 'Saiful Anwar Hospital',
},
]
const sidebar = {
collapsible: 'offcanvas', // 'offcanvas' | 'icon' | 'none'
side: 'left', // 'left' | 'right'
@@ -59,8 +59,10 @@ async function setMenu() {
<SidebarGroupLabel>
{{ navMenu.heading }}
</SidebarGroupLabel>
<component :is="resolveNavItemComponent(item)" v-for="(item, index) in navMenu.items" :key="index" :item="item"
class="my-2 mb-2" />
<component
:is="resolveNavItemComponent(item)" v-for="(item, index) in navMenu.items" :key="index" :item="item"
class="my-2 mb-2"
/>
</SidebarGroup>
<template v-else>
<div class="p-5">
@@ -68,8 +70,10 @@ async function setMenu() {
</div>
</template>
<SidebarGroup class="mt-auto">
<component :is="resolveNavItemComponent(item)" v-for="(item, index) in navMenuBottom" :key="index" :item="item"
size="sm" />
<component
:is="resolveNavItemComponent(item)" v-for="(item, index) in navMenuBottom" :key="index" :item="item"
size="sm"
/>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
@@ -1,43 +1,64 @@
<script setup lang="ts">
import type { DataTableLoader } from './type'
import { Info } from 'lucide-vue-next'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '~/components/pub/ui/table'
defineProps<{
rows: unknown[]
cols: object
header: object[]
cols: any[]
header: any[]
keys: string[]
funcParsed: object
funcHtml: object
funcComponent: object
funcParsed: Record<string, (row: any) => any>
funcHtml: Record<string, (row: any) => string>
funcComponent: Record<string, (row: any, idx: number) => any>
}>()
const loader = inject('table_data_loader') as DataTableLoader
</script>
<template>
<Table>
<TableHeader>
<TableHeader class="bg-gray-50">
<TableRow>
<TableHead
v-for="(h, idx) in header[0]"
:key="`head-${idx}`"
:style="{ width: cols[idx]?.width ? `${cols[idx].width}px` : undefined }"
>
<TableHead v-for="(h, idx) in header[0]" :key="`head-${idx}`" class="border"
:style="{ width: cols[idx]?.width ? `${cols[idx].width}px` : undefined }">
{{ h.label }}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableBody v-if="loader.isTableLoading">
<!-- Loading state with 5 skeleton rows -->
<TableRow v-for="n in 5" :key="`skeleton-${n}`">
<TableCell v-for="(key, cellIndex) in keys" :key="`cell-skel-${n}-${cellIndex}`" class="border">
<Skeleton class="bg-gray-100 animate-pulse text-muted-foreground w-full h-6" />
</TableCell>
</TableRow>
</TableBody>
<TableBody v-else-if="rows.length === 0">
<TableRow>
<TableCell :colspan="keys.length" class="text-center py-8">
<div class="flex items-center justify-center">
<Info class="size-5 text-muted-foreground" />
<span class="ml-2">Tidak ada data tersedia</span>
</div>
</TableCell>
</TableRow>
</TableBody>
<TableBody v-else>
<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}`" class="border">
<!-- If funcComponent has a renderer -->
<component
:is="funcComponent[key](row, rowIndex).component"
v-if="funcComponent[key]"
v-bind="funcComponent[key](row, rowIndex)"
/>
<component :is="funcComponent[key](row, rowIndex).component" v-if="funcComponent[key]"
v-bind="funcComponent[key](row, rowIndex)" />
<!-- If funcParsed or funcHtml returns a value -->
<template v-else>
{{ funcParsed[key]?.(row) ?? funcHtml[key]?.(row) ?? row[key] }}
<!-- Use v-html for funcHtml to render HTML content -->
<div v-if="funcHtml[key]" v-html="funcHtml[key]?.(row)"></div>
<!-- Use normal interpolation for funcParsed and regular data -->
<template v-else>
{{ funcParsed[key]?.(row) ?? (row as any)[key] }}
</template>
</template>
</TableCell>
</TableRow>
@@ -0,0 +1,4 @@
export interface DataTableLoader {
isTableLoading: boolean
[key: string]: boolean
}
@@ -0,0 +1,72 @@
<script setup lang="ts">
import type { ServiceStatus } from './type'
import { Loader, 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 v-if="props.status === 'connecting'">
<Loader class="ml-2 h-4 w-4 animate-spin" />
</p>
<p
v-else :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>
@@ -0,0 +1,7 @@
export interface ServiceStatus {
serviceName: string
serviceDesc: string
sessionActive: boolean
status: 'connected' | 'connecting' | 'error' | 'disconnected'
isSkeleton?: boolean
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Summary } from './summary-card.type'
import type { Summary } from './type'
import { ChevronDown, ChevronUp } from 'lucide-vue-next'
import { cn } from '~/lib/utils'
@@ -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" class="h-42">
@@ -55,9 +55,9 @@ const isTrending = computed<boolean>(() => (props.stat?.trend ?? 0) > 0)
</div>
<p v-if="props.stat.trend !== 0" class="text-muted-foreground flex items-center gap-1 text-xs">
<component
:is="isTrending ? ChevronUp : ChevronDown"
: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) }}% -->
@@ -1,7 +1,7 @@
export interface Summary {
title: string
icon: Component
metric: number
metric: number | string
trend: number
timeframe: 'yearly' | 'monthly' | 'weekly' | 'daily'
}
@@ -3,19 +3,19 @@ import type { HeaderPrep, RefSearchNav } from '../types.ts'
const props = defineProps<{
prep: HeaderPrep
refSearchNav: RefSearchNav
refSearchNav?: RefSearchNav
}>()
function emitSearchNavClick() {
props.refSearchNav.onClick()
props.refSearchNav?.onClick()
}
function onInput(event: Event) {
props.refSearchNav.onInput((event.target as HTMLInputElement).value)
props.refSearchNav?.onInput((event.target as HTMLInputElement).value)
}
function btnClick() {
props.prep.addNav?.onClick()
props.prep?.addNav?.onClick()
}
</script>
@@ -24,12 +24,13 @@ function btnClick() {
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="ml-3 text-lg font-bold text-gray-900">
<Icon :name="prep.icon" class="mr-2 h-4 w-4 align-middle" />
<Icon :name="prep.icon" class="mr-2 size-4 md:size-6 align-middle" />
{{ prep.title }}
</div>
</div>
<div class="flex items-center">
<div class="ml-3 text-lg text-gray-900">
<div v-if="props.refSearchNav"
class="ml-3 text-lg text-gray-900">
<Input
type="text"
placeholder="Search"
+2 -3
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { PrimitiveProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '~/lib/utils'
import { Primitive } from 'radix-vue'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
@@ -19,7 +19,6 @@ const props = withDefaults(defineProps<Props>(), {
<template>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
+11 -12
View File
@@ -4,27 +4,26 @@ import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
default: 'h-9 px-4 py-2',
xs: 'h-7 rounded px-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
@@ -1,10 +1,10 @@
<script setup lang="ts">
import type { PaginationEllipsisProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '~/lib/utils'
import { DotsHorizontalIcon } from '@radix-icons/vue'
import { PaginationEllipsis } from 'radix-vue'
import { computed } from 'vue'
import { cn } from '~/lib/utils'
const props = defineProps<PaginationEllipsisProps & { class?: HTMLAttributes['class'] }>()
@@ -18,7 +18,7 @@ const delegatedProps = computed(() => {
<template>
<PaginationEllipsis v-bind="delegatedProps" :class="cn('w-9 h-9 flex items-center justify-center', props.class)">
<slot>
<Icon name="i-radix-icons-dots-horizontal" />
<DotsHorizontalIcon />
</slot>
</PaginationEllipsis>
</template>
@@ -1,11 +1,13 @@
<script setup lang="ts">
import type { PaginationFirstProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import {
Button,
} from '~/components/pub/ui/button'
import { cn } from '~/lib/utils'
import { DoubleArrowLeftIcon } from '@radix-icons/vue'
import { PaginationFirst } from 'radix-vue'
import { computed } from 'vue'
import { Button } from '~/components/pub/ui/button'
import { cn } from '~/lib/utils'
const props = withDefaults(defineProps<PaginationFirstProps & { class?: HTMLAttributes['class'] }>(), {
asChild: true,
@@ -20,9 +22,9 @@ const delegatedProps = computed(() => {
<template>
<PaginationFirst v-bind="delegatedProps">
<Button :class="cn('h-9 w-9 p-0', props.class)" variant="outline">
<Button :class="cn('w-9 h-9 p-0', props.class)" variant="outline">
<slot>
<Icon name="i-radix-icons-double-arrow-left" />
<DoubleArrowLeftIcon />
</slot>
</Button>
</PaginationFirst>
@@ -1,11 +1,13 @@
<script setup lang="ts">
import type { PaginationLastProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import {
Button,
} from '~/components/pub/ui/button'
import { cn } from '~/lib/utils'
import { DoubleArrowRightIcon } from '@radix-icons/vue'
import { PaginationLast } from 'radix-vue'
import { computed } from 'vue'
import { Button } from '~/components/pub/ui/button'
import { cn } from '~/lib/utils'
const props = withDefaults(defineProps<PaginationLastProps & { class?: HTMLAttributes['class'] }>(), {
asChild: true,
@@ -20,9 +22,9 @@ const delegatedProps = computed(() => {
<template>
<PaginationLast v-bind="delegatedProps">
<Button :class="cn('h-9 w-9 p-0', props.class)" variant="outline">
<Button :class="cn('w-9 h-9 p-0', props.class)" variant="outline">
<slot>
<Icon name="i-radix-icons-double-arrow-right" />
<DoubleArrowRightIcon />
</slot>
</Button>
</PaginationLast>
@@ -1,11 +1,13 @@
<script setup lang="ts">
import type { PaginationNextProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import {
Button,
} from '~/components/pub/ui/button'
import { cn } from '~/lib/utils'
import { ChevronRightIcon } from '@radix-icons/vue'
import { PaginationNext } from 'radix-vue'
import { computed } from 'vue'
import { Button } from '~/components/pub/ui/button'
import { cn } from '~/lib/utils'
const props = withDefaults(defineProps<PaginationNextProps & { class?: HTMLAttributes['class'] }>(), {
asChild: true,
@@ -20,9 +22,9 @@ const delegatedProps = computed(() => {
<template>
<PaginationNext v-bind="delegatedProps">
<Button :class="cn('h-9 w-9 p-0', props.class)" variant="outline">
<Button :class="cn('w-9 h-9 p-0', props.class)" variant="outline">
<slot>
<Icon name="i-radix-icons-chevron-right" />
<ChevronRightIcon />
</slot>
</Button>
</PaginationNext>
@@ -1,11 +1,13 @@
<script setup lang="ts">
import type { PaginationPrevProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import {
Button,
} from '~/components/pub/ui/button'
import { cn } from '~/lib/utils'
import { ChevronLeftIcon } from '@radix-icons/vue'
import { PaginationPrev } from 'radix-vue'
import { computed } from 'vue'
import { Button } from '~/components/pub/ui/button'
import { cn } from '~/lib/utils'
const props = withDefaults(defineProps<PaginationPrevProps & { class?: HTMLAttributes['class'] }>(), {
asChild: true,
@@ -20,9 +22,9 @@ const delegatedProps = computed(() => {
<template>
<PaginationPrev v-bind="delegatedProps">
<Button :class="cn('h-9 w-9 p-0', props.class)" variant="outline">
<Button :class="cn('w-9 h-9 p-0', props.class)" variant="outline">
<slot>
<Icon name="i-radix-icons-chevron-left" />
<ChevronLeftIcon />
</slot>
</Button>
</PaginationPrev>
+8
View File
@@ -17,4 +17,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>
+1 -2
View File
@@ -1,5 +1,4 @@
import type { ClassValue } from 'clsx'
import { clsx } from 'clsx'
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
@@ -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>
+1 -1
View File
@@ -8,7 +8,7 @@ definePageMeta({
const route = useRoute()
useHead({
title: () => route.meta.title,
title: () => route.meta.title as string,
})
</script>
+7 -9
View File
@@ -1,20 +1,18 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"$schema": "https://radix.shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tsConfigPath": ".nuxt/tsconfig.json",
"tailwind": {
"config": "",
"css": "app/assets/css/tailwind.css",
"config": "tailwind.config.ts",
"css": "app/assets/css/main.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"framework": "nuxt",
"aliases": {
"components": "~/components/pub",
"composables": "~/composables",
"utils": "~/lib/utils",
"ui": "~/components/pub/ui",
"lib": "~/lib"
},
"iconLibrary": "lucide"
"utils": "~/lib/utils"
}
}
+6 -1
View File
@@ -1,17 +1,22 @@
import process from 'node:process'
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
devtools: { enabled: true },
runtimeConfig: {
API_ORIGIN: process.env.API_ORIGIN || 'https://main-api.dev-hopis.sabbi.id',
},
ssr: false,
modules: [
'@unocss/nuxt',
'shadcn-nuxt',
'@vueuse/nuxt',
'@nuxt/eslint',
'@nuxt/icon',
'@pinia/nuxt',
'@nuxtjs/color-mode',
'@nuxtjs/tailwindcss',
'shadcn-nuxt',
],
css: ['@unocss/reset/tailwind.css', '~/assets/css/main.css'],
+6 -4
View File
@@ -2,6 +2,7 @@
"name": "nuxt-app",
"type": "module",
"private": true,
"packageManager": "pnpm@8.15.9+sha512.499434c9d8fdd1a2794ebf4552b3b25c0a633abcee5bb15e7b5de90f32f47b513aca98cd5cfd001c31f0db454bc3804edccd578501e4ca293a6816166bbd9f81",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
@@ -16,6 +17,7 @@
"@iconify-json/lucide": "^1.2.30",
"@iconify-json/radix-icons": "^1.2.2",
"@internationalized/date": "^3.7.0",
"@radix-icons/vue": "^1.0.0",
"@unovis/ts": "^1.5.1",
"@unovis/vue": "^1.5.1",
"embla-carousel": "^8.5.2",
@@ -23,7 +25,7 @@
"h3": "^1.15.4",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.4.1",
"reka-ui": "^2.4.1"
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@antfu/eslint-config": "^4.10.1",
@@ -31,12 +33,13 @@
"@nuxt/icon": "^1.15.0",
"@nuxt/test-utils": "^3.19.2",
"@nuxtjs/color-mode": "^3.5.2",
"@nuxtjs/tailwindcss": "6.14.0",
"@pinia/nuxt": "^0.11.2",
"@unocss/eslint-plugin": "^66.0.0",
"@unocss/nuxt": "^66.0.0",
"@vee-validate/zod": "^4.15.0",
"@vue/test-utils": "^2.4.6",
"@vueuse/core": "^12.2.0",
"@vueuse/core": "^12.8.2",
"@vueuse/math": "^12.2.0",
"@vueuse/nuxt": "^12.2.0",
"class-variance-authority": "^0.7.1",
@@ -65,6 +68,5 @@
"vue-sonner": "^1.3.0",
"vue-tsc": "^2.1.10",
"zod": "^3.24.2"
},
"packageManager": "pnpm@8.15.9+sha512.499434c9d8fdd1a2794ebf4552b3b25c0a633abcee5bb15e7b5de90f32f47b513aca98cd5cfd001c31f0db454bc3804edccd578501e4ca293a6816166bbd9f81"
}
}
+654 -88
View File
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// Mock data lengkap
const mockData = [
{
id: 'RSC001',
resource_type: 'Encounter',
patient: {
name: 'Ahmad Wepe',
mrn: 'RM001234',
},
status: 2, // 0: failed, 1: pending, 2: success
updated_at: '2025-01-15',
fhir_id: 'ENC-00123',
},
{
id: 'RSC002',
resource_type: 'Patient',
patient: {
name: 'Siti Aminah',
mrn: 'RM001235',
},
status: 1,
updated_at: '2025-01-10',
fhir_id: 'PAT-001235',
},
{
id: 'RSC003',
resource_type: 'Observation',
patient: {
name: 'Budi Antono',
mrn: 'RM001236',
},
status: 0,
updated_at: '2025-01-11',
fhir_id: 'OBS-001236',
},
{
id: 'RSC004',
resource_type: 'Encounter',
patient: {
name: 'Maria Sari',
mrn: 'RM001237',
},
status: 2,
updated_at: '2025-01-12',
fhir_id: 'ENC-001237',
},
{
id: 'RSC005',
resource_type: 'Patient',
patient: {
name: 'Joko Widodo',
mrn: 'RM001238',
},
status: 1,
updated_at: '2025-01-13',
fhir_id: 'PAT-001238',
},
{
id: 'RSC006',
resource_type: 'Observation',
patient: {
name: 'Dewi Sartika',
mrn: 'RM001239',
},
status: 2,
updated_at: '2025-01-14',
fhir_id: 'OBS-001239',
},
{
id: 'RSC007',
resource_type: 'Encounter',
patient: {
name: 'Rudi Hartono',
mrn: 'RM001240',
},
status: 0,
updated_at: '2025-01-16',
fhir_id: 'ENC-001240',
},
{
id: 'RSC008',
resource_type: 'Patient',
patient: {
name: 'Sri Mulyani',
mrn: 'RM001241',
},
status: 2,
updated_at: '2025-01-17',
fhir_id: 'PAT-001241',
}
]
// Ekstrak parameter filter dari request body
const {
status,
resource_type,
date_from,
date_to,
search,
page = 1,
limit = 10
} = body
let filteredData = [...mockData]
// Filter berdasarkan status
if (status !== undefined && status !== null && status !== '') {
filteredData = filteredData.filter(item => item.status === Number(status))
}
// Filter berdasarkan resource_type (transaction type)
if (resource_type && resource_type !== 'all') {
filteredData = filteredData.filter(item =>
item.resource_type.toLowerCase() === resource_type.toLowerCase()
)
}
// Filter berdasarkan rentang tanggal
if (date_from) {
filteredData = filteredData.filter(item =>
new Date(item.updated_at) >= new Date(date_from)
)
}
if (date_to) {
filteredData = filteredData.filter(item =>
new Date(item.updated_at) <= new Date(date_to)
)
}
// Filter berdasarkan pencarian nama pasien atau MRN
if (search) {
filteredData = filteredData.filter(item =>
item.patient.name.toLowerCase().includes(search.toLowerCase()) ||
item.patient.mrn.toLowerCase().includes(search.toLowerCase()) ||
item.fhir_id.toLowerCase().includes(search.toLowerCase())
)
}
// Pagination
const totalItems = filteredData.length
const totalPages = Math.ceil(totalItems / limit)
const offset = (page - 1) * limit
const paginatedData = filteredData.slice(offset, offset + limit)
// Simulasi delay untuk loading state
await new Promise(resolve => setTimeout(resolve, 300))
return {
success: true,
data: paginatedData,
meta: {
total: totalItems,
page: Number(page),
limit: Number(limit),
total_pages: totalPages,
has_next: page < totalPages,
has_prev: page > 1
},
filters: {
status,
resource_type,
date_from,
date_to,
search
}
}
})
+86
View File
@@ -0,0 +1,86 @@
import animate from 'tailwindcss-animate'
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class'],
safelist: ['dark'],
prefix: '',
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
xl: 'calc(var(--radius) + 4px)',
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: 0 },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 },
},
'collapsible-down': {
from: { height: 0 },
to: { height: 'var(--radix-collapsible-content-height)' },
},
'collapsible-up': {
from: { height: 'var(--radix-collapsible-content-height)' },
to: { height: 0 },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'collapsible-down': 'collapsible-down 0.2s ease-in-out',
'collapsible-up': 'collapsible-up 0.2s ease-in-out',
},
},
},
plugins: [animate],
}
+1
View File
@@ -29,4 +29,5 @@ export default defineConfig({
rules: [
// Custom rules if needed
],
inspector: true,
})