+1
-1
@@ -2,7 +2,7 @@
|
||||
"useTabs": false,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"trailingComma": "all",
|
||||
"printWidth": 120,
|
||||
"semi": false,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
|
||||
@@ -12,15 +12,15 @@ RSSA - Front End
|
||||
## Directory Structure for `app/`
|
||||
- `app.vue`: Main layout
|
||||
- `components` : Contains all reusable UI components.
|
||||
- `components/flows` : Entry point for business logic and workflows. Pages or routes call these flow components to handle API requests and process application logic
|
||||
- `components/app` : View-layer components that manage and present data. These are used within `flows/` to render or handle specific parts of the UI, and return results back to the flow
|
||||
- `components/flow` : Entry point for business logic and workflows. Pages or routes call these flow components to handle API requests and process application logic
|
||||
- `components/app` : View-layer components that manage and present data. These are used within `flow/` to render or handle specific parts of the UI, and return results back to the flow
|
||||
- `components/pub` : Public/shared components used across different parts of the app.
|
||||
- `composables` : Contains reusable logic and utility functions (e.g. composables, hooks)..
|
||||
- `layouts` : Reusable UI layout patterns used across pages.
|
||||
|
||||
## Directory Structure for `app/pages`
|
||||
- `pages/auth` : Authentication related pages.
|
||||
- `pages/(features)` : Grouped feature modules that reflect specific business flows or domains.
|
||||
- `pages/(features)` : Grouped feature modules that reflect specific business flow or domains.
|
||||
|
||||
## Directory Structure for `server/`
|
||||
- `server/api` : API or proxy requests
|
||||
@@ -37,13 +37,13 @@ The basic development workflow follows these steps:
|
||||
- Keep components pure, avoid making HTTP requests directly within them.
|
||||
- They receive data via props and emit events upward.
|
||||
|
||||
### Business Logic in `components/flows`
|
||||
### Business Logic in `components/flow`
|
||||
- This layer connects the UI with the logic (API calls, validations, navigation).
|
||||
- It composes components from `components/app/`, `components/pub/`, and other flows.
|
||||
- It composes components from `components/app/`, `components/pub/`, and other flow.
|
||||
- Also responsible for managing state, side effects, and interactions.
|
||||
|
||||
### Create Pages in `pages/`
|
||||
- Pages load the appropriate flow from `components/flows/`.
|
||||
- Pages load the appropriate flow from `components/flow/`.
|
||||
- They do not contain UI or logic directly, just route level layout or guards.
|
||||
|
||||
## Git Workflows
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
|
||||
const email = ref('demo@gmail.com')
|
||||
const password = ref('password')
|
||||
const isLoading = ref(false)
|
||||
|
||||
async function onSubmit(event: Event) {
|
||||
event.preventDefault()
|
||||
if (!email.value || !password.value) return
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const { data: respData } = await useFetch('/api/v1/authentication/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: 'system',
|
||||
password: 'the-SYSTEM-1234',
|
||||
}),
|
||||
})
|
||||
|
||||
const resp = respData.value
|
||||
if (!resp) throw new Error('No response')
|
||||
|
||||
const { data: rawdata, meta } = resp
|
||||
console.log('DATA', rawdata)
|
||||
console.log('META', meta)
|
||||
|
||||
if (meta.status === 'verified') {
|
||||
await nextTick()
|
||||
navigateTo('/')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="grid gap-6" @submit="onSubmit">
|
||||
<div class="grid gap-2">
|
||||
<Label for="email"> Email </Label>
|
||||
<Input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="name@example.com"
|
||||
:disabled="isLoading"
|
||||
auto-capitalize="none"
|
||||
auto-complete="email"
|
||||
auto-correct="off"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<div class="flex items-center">
|
||||
<Label for="password"> Password </Label>
|
||||
</div>
|
||||
<Input id="password" v-model="password" type="password" :disabled="isLoading" />
|
||||
</div>
|
||||
<Button type="submit" class="w-full" :disabled="isLoading">
|
||||
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
|
||||
Login
|
||||
</Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import type { z } from 'zod'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
import { useForm } from 'vee-validate'
|
||||
|
||||
interface Props {
|
||||
schema: z.ZodSchema<any>
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [data: any]
|
||||
}>()
|
||||
|
||||
const { handleSubmit, defineField, errors, meta } = useForm({
|
||||
validationSchema: toTypedSchema(props.schema),
|
||||
initialValues: {
|
||||
name: '',
|
||||
password: '',
|
||||
},
|
||||
})
|
||||
|
||||
const [name, nameAttrs] = defineField('name')
|
||||
const [password, passwordAttrs] = defineField('password')
|
||||
|
||||
const onSubmit = handleSubmit(async (values) => {
|
||||
try {
|
||||
await emit('submit', values)
|
||||
} catch (error) {
|
||||
console.error('Submission failed:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="grid gap-6" @submit="onSubmit">
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Username</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="name"
|
||||
v-bind="nameAttrs"
|
||||
:disabled="isLoading"
|
||||
:class="{ 'border-red-500': errors.name }"
|
||||
/>
|
||||
<span v-if="errors.name" class="text-sm text-red-500">
|
||||
{{ errors.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
v-model="password"
|
||||
v-bind="passwordAttrs"
|
||||
type="password"
|
||||
:disabled="isLoading"
|
||||
:class="{ 'border-red-500': errors.password }"
|
||||
/>
|
||||
<span v-if="errors.password" class="text-sm text-red-500">
|
||||
{{ errors.password }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button type="submit" class="w-full" :disabled="isLoading || !meta.valid">
|
||||
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
|
||||
Login
|
||||
</Button>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div>entry form</div>
|
||||
|
||||
<div>
|
||||
<PubNavFooterCs />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { z } from 'zod'
|
||||
|
||||
const loginSchema = z.object({
|
||||
name: z.string().min(6, 'Please enter a valid username'),
|
||||
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||
})
|
||||
|
||||
const { login } = useUserStore()
|
||||
|
||||
type LoginFormData = z.infer<typeof loginSchema>
|
||||
|
||||
const isLoading = ref(false)
|
||||
const apiErrors = ref<Record<string, string>>({})
|
||||
|
||||
async function onSubmit(data: LoginFormData) {
|
||||
isLoading.value = true
|
||||
|
||||
const result = await xfetch('/api/v1/authentication/login', 'POST', {
|
||||
name: data.name,
|
||||
password: data.password,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
const { data: rawdata, meta } = result.body
|
||||
if (meta.status === 'verified') {
|
||||
await login(rawdata)
|
||||
await navigateTo('/')
|
||||
}
|
||||
} else {
|
||||
if (result.errors) {
|
||||
Object.entries(result.errors).forEach(
|
||||
([field, errorInfo]: [string, any]) => (apiErrors.value[field] = errorInfo.message),
|
||||
)
|
||||
} else {
|
||||
apiErrors.value.general = result.error?.message || result.message || 'Login failed'
|
||||
}
|
||||
}
|
||||
|
||||
isLoading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppAuthLogin :schema="loginSchema" :is-loading="isLoading" @submit="onSubmit" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,5 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<AppPatientEntryForm />
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
const refSearchNav = {
|
||||
onClick: () => {
|
||||
// open filter modal
|
||||
},
|
||||
onInput: (_val: string) => {
|
||||
// filter patient list
|
||||
},
|
||||
onClear: () => {
|
||||
// clear url param
|
||||
},
|
||||
}
|
||||
|
||||
const hreaderPrep: HeaderPrep = {
|
||||
title: 'Pasien',
|
||||
icon: 'bi bi-journal-check',
|
||||
addNav: {
|
||||
label: 'Tambah',
|
||||
onClick: () => navigateTo('/patient/add'),
|
||||
},
|
||||
}
|
||||
|
||||
// NOTE: example api
|
||||
async function getPatientList() {
|
||||
const { data } = await xfetch('/api/v1/patient')
|
||||
console.log('data patient', data)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getPatientList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PubNavHeaderPrep :prep="{ ...hreaderPrep }" :ref-search-nav="refSearchNav" icon="i-lucide-add" />
|
||||
</template>
|
||||
@@ -1,17 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
const navMenu: any[] = [
|
||||
{
|
||||
heading: 'General',
|
||||
heading: 'Menu Utama',
|
||||
items: [
|
||||
{
|
||||
title: 'Home',
|
||||
title: 'Dashboard',
|
||||
icon: 'i-lucide-home',
|
||||
link: '/',
|
||||
},
|
||||
{
|
||||
title: 'Home',
|
||||
icon: 'i-lucide-home',
|
||||
link: '/',
|
||||
title: 'Pasien',
|
||||
icon: 'i-lucide-users',
|
||||
link: '/patient',
|
||||
},
|
||||
{
|
||||
title: 'Rehabilitasi Medik',
|
||||
icon: 'i-lucide-heart',
|
||||
link: '/rehabilitasi',
|
||||
},
|
||||
{
|
||||
title: 'Rawat Jalan',
|
||||
icon: 'i-lucide-stethoscope',
|
||||
link: '/rawat-jalan',
|
||||
},
|
||||
{
|
||||
title: 'Rawat Inap',
|
||||
icon: 'i-lucide-building-2',
|
||||
link: '/rawat-inap',
|
||||
},
|
||||
{
|
||||
title: 'VClaim BPJS',
|
||||
icon: 'i-lucide-refresh-cw',
|
||||
link: '/vclaim',
|
||||
badge: 'Live',
|
||||
},
|
||||
{
|
||||
title: 'SATUSEHAT',
|
||||
icon: 'i-lucide-database',
|
||||
link: '/satusehat',
|
||||
badge: 'FHIR',
|
||||
},
|
||||
{
|
||||
title: 'Medical Records',
|
||||
icon: 'i-lucide-file-text',
|
||||
link: '/medical-records',
|
||||
},
|
||||
{
|
||||
title: 'Laporan',
|
||||
icon: 'i-lucide-clipboard-list',
|
||||
link: '/laporan',
|
||||
},
|
||||
{
|
||||
title: 'Monitoring',
|
||||
icon: 'i-lucide-bar-chart-3',
|
||||
link: '/monitoring',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -43,15 +85,15 @@ const teams: {
|
||||
},
|
||||
]
|
||||
|
||||
const user: {
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
} = {
|
||||
name: 'John Doe',
|
||||
email: 'john.doe@email.com',
|
||||
avatar: '/avatars/avatartion.png',
|
||||
}
|
||||
// const user: {
|
||||
// name: string
|
||||
// email: string
|
||||
// avatar: string
|
||||
// } = {
|
||||
// name: '',
|
||||
// email: '',
|
||||
// avatar: '/',
|
||||
// }
|
||||
|
||||
// const { sidebar } = useAppSettings()
|
||||
const sidebar = {
|
||||
@@ -65,14 +107,20 @@ const sidebar = {
|
||||
<Sidebar :collapsible="sidebar.collapsible" :side="sidebar.side" :variant="sidebar.variant">
|
||||
<SidebarHeader>
|
||||
<LayoutSidebarNavHeader :teams="teams" />
|
||||
<Search />
|
||||
<!-- <Search /> -->
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup v-for="(nav, indexGroup) in navMenu" :key="indexGroup">
|
||||
<SidebarGroupLabel v-if="nav.heading">
|
||||
{{ nav.heading }}
|
||||
</SidebarGroupLabel>
|
||||
<component :is="resolveNavItemComponent(item)" v-for="(item, index) in nav.items" :key="index" :item="item" />
|
||||
<component
|
||||
:is="resolveNavItemComponent(item)"
|
||||
v-for="(item, index) in nav.items"
|
||||
:key="index"
|
||||
:item="item"
|
||||
class="mb-2"
|
||||
/>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup class="mt-auto">
|
||||
<component
|
||||
@@ -85,7 +133,7 @@ const sidebar = {
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<LayoutSidebarNavFooter :user="user" />
|
||||
<LayoutSidebarNavFooter />
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
@@ -6,7 +6,7 @@ defineProps<{
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex h-dvh items-center justify-center bg-[url('/rssa-login-page.jpg')] px-4 lg:max-w-none lg:px-0"
|
||||
class="relative flex h-dvh items-center justify-center px-4 lg:max-w-none lg:px-0"
|
||||
:class="{ 'flex-row-reverse': reverse }"
|
||||
>
|
||||
<div class="bg-muted relative hidden h-full flex-1 flex-col p-10 text-white lg:flex dark:border-r">
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { useSidebar } from '~/components/pub/ui/sidebar'
|
||||
|
||||
defineProps<{
|
||||
user: {
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
}
|
||||
}>()
|
||||
// defineProps<{
|
||||
// user: {
|
||||
// name: string
|
||||
// email: string
|
||||
// avatar: string
|
||||
// }
|
||||
// }>()
|
||||
|
||||
const { isMobile } = useSidebar()
|
||||
const { logout } = useUserStore()
|
||||
const userStore = useUserStore().user
|
||||
|
||||
function handleLogout() {
|
||||
navigateTo('/auth/login')
|
||||
logout()
|
||||
}
|
||||
|
||||
const showModalTheme = ref(false)
|
||||
@@ -28,19 +31,19 @@ const showModalTheme = ref(false)
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="user.avatar" :alt="user.name" />
|
||||
<AvatarImage src="" :alt="userStore?.user_name" />
|
||||
<AvatarFallback class="rounded-lg">
|
||||
{{
|
||||
user.name
|
||||
.split(' ')
|
||||
userStore?.user_name
|
||||
?.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
}}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ user.name }}</span>
|
||||
<span class="truncate text-xs">{{ user.email }}</span>
|
||||
<span class="truncate font-semibold">{{ userStore?.user_name }}</span>
|
||||
<span class="truncate text-xs">{{ userStore?.user_email }}</span>
|
||||
</div>
|
||||
<Icon name="i-lucide-chevrons-up-down" class="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
@@ -53,19 +56,19 @@ const showModalTheme = ref(false)
|
||||
<DropdownMenuLabel class="p-0 font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="user.avatar" :alt="user.name" />
|
||||
<AvatarImage src="" :alt="userStore?.user_name" />
|
||||
<AvatarFallback class="rounded-lg">
|
||||
{{
|
||||
user.name
|
||||
.split(' ')
|
||||
userStore?.user_name
|
||||
?.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
}}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ user.name }}</span>
|
||||
<span class="truncate text-xs">{{ user.email }}</span>
|
||||
<span class="truncate font-semibold">{{ userStore?.user_name }}</span>
|
||||
<span class="truncate text-xs">{{ userStore?.user_email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
@@ -73,8 +76,8 @@ const showModalTheme = ref(false)
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem @click="showModalTheme = true">
|
||||
<Icon name="i-lucide-paintbrush" />
|
||||
Theme
|
||||
<Icon name="i-lucide-user" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -86,16 +89,6 @@ const showModalTheme = ref(false)
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
<Dialog v-model:open="showModalTheme">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Customize</DialogTitle>
|
||||
<DialogDescription class="text-muted-foreground text-xs"> Customize & Preview in Real Time </DialogDescription>
|
||||
</DialogHeader>
|
||||
<ThemeCustomize />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -9,7 +9,7 @@ withDefaults(
|
||||
}>(),
|
||||
{
|
||||
size: 'default',
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const { setOpenMobile } = useSidebar()
|
||||
@@ -19,7 +19,11 @@ const { setOpenMobile } = useSidebar()
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton as-child :tooltip="item.title" :size="size">
|
||||
<NuxtLink :to="item.link" @click="setOpenMobile(false)">
|
||||
<NuxtLink
|
||||
:to="item.link"
|
||||
class="group flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200"
|
||||
@click="setOpenMobile(false)"
|
||||
>
|
||||
<Icon :name="item.icon || ''" mode="svg" />
|
||||
<span>{{ item.title }}</span>
|
||||
<span
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-between">
|
||||
<div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Icon name="i-lucide-pencil" class="mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { HeaderPrep, RefSearchNav } from '../types.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
prep: HeaderPrep
|
||||
refSearchNav: RefSearchNav
|
||||
}>()
|
||||
|
||||
function emitSearchNavClick() {
|
||||
props.refSearchNav.onClick()
|
||||
}
|
||||
|
||||
function onInput(event: Event) {
|
||||
props.refSearchNav.onInput((event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
function btnClick() {
|
||||
props.prep.addNav?.onClick()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="ml-3 text-lg font-bold text-gray-900">
|
||||
{{ prep.title }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="ml-3 text-lg text-gray-900">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
class="w-full rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 sm:text-sm"
|
||||
@click="emitSearchNavClick"
|
||||
@input="onInput"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="prep.addNav" class="m-2 flex items-center">
|
||||
<Button size="md" class="rounded-md border border-gray-300 px-4 py-2 text-white sm:text-sm" @click="btnClick">
|
||||
<Icon name="i-lucide-plus" class="mr-2 h-4 w-4 align-middle" />
|
||||
{{ prep.addNav.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ComponentType } from '@unovis/ts'
|
||||
|
||||
export interface ListItemDto {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface RecComponent {
|
||||
idx?: number
|
||||
rec: object
|
||||
props?: any
|
||||
component: ComponentType
|
||||
}
|
||||
|
||||
export interface Col {
|
||||
span?: number
|
||||
classVal?: string
|
||||
style?: string
|
||||
width?: number // specific for width
|
||||
widthUnit?: string // specific for width
|
||||
}
|
||||
|
||||
export interface Th {
|
||||
label: string
|
||||
colSpan?: number
|
||||
rowSpan?: number
|
||||
classVal?: string
|
||||
childClassVal?: string
|
||||
style?: string
|
||||
childStyle?: string
|
||||
hideOnSm?: boolean
|
||||
}
|
||||
|
||||
export interface ButtonNav {
|
||||
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'negative' | 'ghost' | 'link'
|
||||
classVal?: string
|
||||
classValExt?: string
|
||||
icon?: string
|
||||
label: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export interface QuickSearchNav {
|
||||
inputClass?: string
|
||||
inputPlaceHolder?: string
|
||||
btnClass?: string
|
||||
btnIcon?: string
|
||||
btnLabel?: string
|
||||
mainField?: string
|
||||
searchParams: object
|
||||
onSubmit?: (searchParams: object) => void
|
||||
}
|
||||
|
||||
export interface RefSearchNav {
|
||||
onInput: (val: string) => void
|
||||
onClick: () => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
// prepared header for relatively common usage
|
||||
export interface HeaderPrep {
|
||||
title?: string
|
||||
icon?: string
|
||||
refSearchNav?: RefSearchNav
|
||||
quickSearchNav?: QuickSearchNav
|
||||
filterNav?: ButtonNav
|
||||
addNav?: ButtonNav
|
||||
printNav?: ButtonNav
|
||||
}
|
||||
|
||||
export interface KeyLabel {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
export type FuncRecUnknown = (rec: unknown, idx: number) => unknown
|
||||
export type FuncComponent = (rec: unknown, idx: number) => RecComponent
|
||||
export type RecStrFuncUnknown = Record<string, FuncRecUnknown>
|
||||
export type RecStrFuncComponent = Record<string, FuncComponent>
|
||||
|
||||
export interface KeyNames {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export interface XError {
|
||||
code: string
|
||||
message: string
|
||||
expectedVal?: string
|
||||
givenVal?: string
|
||||
}
|
||||
|
||||
export type XErrors = Record<string, XError>
|
||||
|
||||
export interface XfetchResult {
|
||||
success: boolean
|
||||
status_code: number
|
||||
body: object | any
|
||||
errors?: XErrors | undefined
|
||||
error?: XError | undefined
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type XfetchMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||
|
||||
export async function xfetch(
|
||||
url: string,
|
||||
method: XfetchMethod = 'GET',
|
||||
input?: object | FormData,
|
||||
headers?: any,
|
||||
_type = 'json',
|
||||
): Promise<XfetchResult> {
|
||||
let success = false
|
||||
let body: object | any = {}
|
||||
let errors: XErrors = {}
|
||||
let error: XError | undefined = {
|
||||
code: '',
|
||||
message: '',
|
||||
}
|
||||
let message: string | undefined = ''
|
||||
|
||||
if (!headers) {
|
||||
headers = {}
|
||||
}
|
||||
if (input && !(input instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await $fetch.raw(url, {
|
||||
method,
|
||||
headers,
|
||||
body: input instanceof FormData ? input : JSON.stringify(input),
|
||||
})
|
||||
|
||||
body = res._data
|
||||
success = true
|
||||
return { success, status_code: res.status, body, errors, error, message }
|
||||
} catch (fetchError: any) {
|
||||
const status = fetchError.response?.status || 500
|
||||
const resJson = fetchError.data
|
||||
|
||||
if (resJson?.errors) {
|
||||
errors = resJson.errors
|
||||
} else if (resJson?.code && resJson?.message) {
|
||||
error = { code: resJson.code, message: resJson.message }
|
||||
} else if (resJson?.message) {
|
||||
message = resJson.message
|
||||
} else {
|
||||
message = fetchError.message || 'Something went wrong'
|
||||
}
|
||||
|
||||
return { success, status_code: status, body, errors, error, message }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
const { $pinia } = useNuxtApp()
|
||||
|
||||
if (import.meta.client) {
|
||||
const userStore = useUserStore($pinia)
|
||||
|
||||
console.log('currRole', userStore.userRole)
|
||||
console.log('isAuth', userStore.isAuthenticated)
|
||||
if (!userStore.isAuthenticated) {
|
||||
return navigateTo('/auth/login')
|
||||
}
|
||||
|
||||
const allowedRoles = to.meta.roles as string[] | undefined
|
||||
if (allowedRoles && !allowedRoles.includes(userStore.userRole)) {
|
||||
return navigateTo('/unauthorized')
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,7 +0,0 @@
|
||||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
// TODO: change this to actual api
|
||||
const user = true
|
||||
if (!user) {
|
||||
return navigateTo('/auth/login')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
roles: ['sys', 'doc'],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>detail pasien</div>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
roles: ['sys', 'doc'],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>edit pasien</div>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
roles: ['sys', 'doc'],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FlowPatientAdd />
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
roles: ['sys', 'doc'],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<FlowPatientList />
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,7 +10,7 @@ definePageMeta({
|
||||
<div class="grid gap-2 text-center">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Login RSSA</h1>
|
||||
</div>
|
||||
<AppAuthSignIn />
|
||||
<FlowAuthLogin />
|
||||
</div>
|
||||
</LayoutAuth>
|
||||
</template>
|
||||
|
||||
+5
-3
@@ -2,9 +2,11 @@
|
||||
import { Activity, CreditCard, DollarSign, Users } from 'lucide-vue-next'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
roles: ['sys', 'doc'],
|
||||
})
|
||||
|
||||
const { userRole } = useUserStore()
|
||||
|
||||
const dataCard = ref({
|
||||
totalRevenue: 0,
|
||||
totalRevenueDesc: 0,
|
||||
@@ -61,7 +63,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="text-2xl font-bold tracking-tight">Dashboard</h2>
|
||||
<h2 class="text-2xl font-bold tracking-tight">Dashboard {{ userRole }}</h2>
|
||||
<div class="flex items-center space-x-2"></div>
|
||||
</div>
|
||||
<main class="flex flex-1 flex-col gap-4 md:gap-8">
|
||||
@@ -147,7 +149,7 @@ onMounted(() => {
|
||||
<CardTitle>Overview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="pl-2">
|
||||
<DashboardOverview />
|
||||
<!-- <DashboardOverview /> -->
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.$pinia.use(piniaPluginPersistedstate)
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
export const useUserStore = defineStore(
|
||||
'user',
|
||||
() => {
|
||||
const user = ref<any | null>(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!user.value)
|
||||
const userRole = computed(() => user.value?.user_position_code || '')
|
||||
|
||||
const login = async (userData: any) => {
|
||||
user.value = userData
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
user.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
isAuthenticated,
|
||||
userRole,
|
||||
login,
|
||||
logout,
|
||||
}
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: 'user',
|
||||
pick: ['user'],
|
||||
},
|
||||
},
|
||||
)
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
// app/types/index.d.ts
|
||||
import type { Pinia } from 'pinia'
|
||||
|
||||
declare module '#app' {
|
||||
interface NuxtApp {
|
||||
$pinia: Pinia
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
interface ComponentCustomProperties {
|
||||
$pinia: Pinia
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -12,6 +12,7 @@ export default withNuxt(
|
||||
quotes: 'single',
|
||||
// Less strict formatting
|
||||
jsx: false,
|
||||
trailingComma: 'all',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
"embla-carousel": "^8.5.2",
|
||||
"embla-carousel-vue": "^8.5.2",
|
||||
"h3": "^1.15.4",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.4.1",
|
||||
"reka-ui": "^2.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+6556
-7927
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
import { defineEventHandler, getCookie, getRequestHeaders, getRequestURL, readBody } from 'h3'
|
||||
|
||||
const API_ORIGIN = process.env.API_ORIGIN as string
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { method } = event.node.req
|
||||
const headers = getRequestHeaders(event)
|
||||
const url = getRequestURL(event)
|
||||
const pathname = url.pathname.replace(/^\/api/, '')
|
||||
|
||||
const targetUrl = API_ORIGIN + pathname + (url.search || '')
|
||||
|
||||
const verificationId = headers['verification-id'] as string | undefined
|
||||
let bearer = ''
|
||||
if (verificationId) {
|
||||
bearer = getCookie(event, `Verification-${verificationId}`) || ''
|
||||
if (!bearer) bearer = getCookie(event, 'authentication') || ''
|
||||
} else {
|
||||
bearer = getCookie(event, 'authentication') || ''
|
||||
}
|
||||
|
||||
const forwardHeaders = new Headers()
|
||||
if (headers['content-type']) forwardHeaders.set('Content-Type', headers['content-type'])
|
||||
forwardHeaders.set('Authorization', `Bearer ${bearer}`)
|
||||
|
||||
let body: any = undefined
|
||||
if (['POST', 'PATCH'].includes(method!)) {
|
||||
if (headers['content-type']?.includes('multipart/form-data')) {
|
||||
body = await readBody(event)
|
||||
} else {
|
||||
body = await readBody(event)
|
||||
forwardHeaders.set('Content-Type', 'application/json')
|
||||
body = JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(targetUrl, {
|
||||
method,
|
||||
headers: forwardHeaders,
|
||||
body,
|
||||
})
|
||||
|
||||
return res
|
||||
})
|
||||
Reference in New Issue
Block a user