No files matched your search
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "es5",
|
"trailingComma": "all",
|
||||||
"printWidth": 120,
|
"printWidth": 120,
|
||||||
"semi": false,
|
"semi": false,
|
||||||
"plugins": ["prettier-plugin-tailwindcss"]
|
"plugins": ["prettier-plugin-tailwindcss"]
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ RSSA - Front End
|
|||||||
## Directory Structure for `app/`
|
## Directory Structure for `app/`
|
||||||
- `app.vue`: Main layout
|
- `app.vue`: Main layout
|
||||||
- `components` : Contains all reusable UI components.
|
- `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/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 `flows/` to render or handle specific parts of the UI, and return results back to the flow
|
- `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.
|
- `components/pub` : Public/shared components used across different parts of the app.
|
||||||
- `composables` : Contains reusable logic and utility functions (e.g. composables, hooks)..
|
- `composables` : Contains reusable logic and utility functions (e.g. composables, hooks)..
|
||||||
- `layouts` : Reusable UI layout patterns used across pages.
|
- `layouts` : Reusable UI layout patterns used across pages.
|
||||||
|
|
||||||
## Directory Structure for `app/pages`
|
## Directory Structure for `app/pages`
|
||||||
- `pages/auth` : Authentication related 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/`
|
## Directory Structure for `server/`
|
||||||
- `server/api` : API or proxy requests
|
- `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.
|
- Keep components pure, avoid making HTTP requests directly within them.
|
||||||
- They receive data via props and emit events upward.
|
- 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).
|
- 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.
|
- Also responsible for managing state, side effects, and interactions.
|
||||||
|
|
||||||
### Create Pages in `pages/`
|
### 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.
|
- They do not contain UI or logic directly, just route level layout or guards.
|
||||||
|
|
||||||
## Git Workflows
|
## Git Workflows
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Loader2 } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const email = ref('[email protected]')
|
|
||||||
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="[email protected]"
|
|
||||||
: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">
|
<script setup lang="ts">
|
||||||
const navMenu: any[] = [
|
const navMenu: any[] = [
|
||||||
{
|
{
|
||||||
heading: 'General',
|
heading: 'Menu Utama',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: 'Home',
|
title: 'Dashboard',
|
||||||
icon: 'i-lucide-home',
|
icon: 'i-lucide-home',
|
||||||
link: '/',
|
link: '/',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Home',
|
title: 'Pasien',
|
||||||
icon: 'i-lucide-home',
|
icon: 'i-lucide-users',
|
||||||
link: '/',
|
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: {
|
// const user: {
|
||||||
name: string
|
// name: string
|
||||||
email: string
|
// email: string
|
||||||
avatar: string
|
// avatar: string
|
||||||
} = {
|
// } = {
|
||||||
name: 'John Doe',
|
// name: '',
|
||||||
email: '[email protected]',
|
// email: '',
|
||||||
avatar: '/avatars/avatartion.png',
|
// avatar: '/',
|
||||||
}
|
// }
|
||||||
|
|
||||||
// const { sidebar } = useAppSettings()
|
// const { sidebar } = useAppSettings()
|
||||||
const sidebar = {
|
const sidebar = {
|
||||||
@@ -65,14 +107,20 @@ const sidebar = {
|
|||||||
<Sidebar :collapsible="sidebar.collapsible" :side="sidebar.side" :variant="sidebar.variant">
|
<Sidebar :collapsible="sidebar.collapsible" :side="sidebar.side" :variant="sidebar.variant">
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<LayoutSidebarNavHeader :teams="teams" />
|
<LayoutSidebarNavHeader :teams="teams" />
|
||||||
<Search />
|
<!-- <Search /> -->
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarGroup v-for="(nav, indexGroup) in navMenu" :key="indexGroup">
|
<SidebarGroup v-for="(nav, indexGroup) in navMenu" :key="indexGroup">
|
||||||
<SidebarGroupLabel v-if="nav.heading">
|
<SidebarGroupLabel v-if="nav.heading">
|
||||||
{{ nav.heading }}
|
{{ nav.heading }}
|
||||||
</SidebarGroupLabel>
|
</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>
|
||||||
<SidebarGroup class="mt-auto">
|
<SidebarGroup class="mt-auto">
|
||||||
<component
|
<component
|
||||||
@@ -85,7 +133,7 @@ const sidebar = {
|
|||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<LayoutSidebarNavFooter :user="user" />
|
<LayoutSidebarNavFooter />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ defineProps<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<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 }"
|
: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">
|
<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">
|
<script setup lang="ts">
|
||||||
import { useSidebar } from '~/components/pub/ui/sidebar'
|
import { useSidebar } from '~/components/pub/ui/sidebar'
|
||||||
|
|
||||||
defineProps<{
|
// defineProps<{
|
||||||
user: {
|
// user: {
|
||||||
name: string
|
// name: string
|
||||||
email: string
|
// email: string
|
||||||
avatar: string
|
// avatar: string
|
||||||
}
|
// }
|
||||||
}>()
|
// }>()
|
||||||
|
|
||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar()
|
||||||
|
const { logout } = useUserStore()
|
||||||
|
const userStore = useUserStore().user
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
navigateTo('/auth/login')
|
navigateTo('/auth/login')
|
||||||
|
logout()
|
||||||
}
|
}
|
||||||
|
|
||||||
const showModalTheme = ref(false)
|
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"
|
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
>
|
>
|
||||||
<Avatar class="h-8 w-8 rounded-lg">
|
<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">
|
<AvatarFallback class="rounded-lg">
|
||||||
{{
|
{{
|
||||||
user.name
|
userStore?.user_name
|
||||||
.split(' ')
|
?.split(' ')
|
||||||
.map((n) => n[0])
|
.map((n) => n[0])
|
||||||
.join('')
|
.join('')
|
||||||
}}
|
}}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span class="truncate font-semibold">{{ user.name }}</span>
|
<span class="truncate font-semibold">{{ userStore?.user_name }}</span>
|
||||||
<span class="truncate text-xs">{{ user.email }}</span>
|
<span class="truncate text-xs">{{ userStore?.user_email }}</span>
|
||||||
</div>
|
</div>
|
||||||
<Icon name="i-lucide-chevrons-up-down" class="ml-auto size-4" />
|
<Icon name="i-lucide-chevrons-up-down" class="ml-auto size-4" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
@@ -53,19 +56,19 @@ const showModalTheme = ref(false)
|
|||||||
<DropdownMenuLabel class="p-0 font-normal">
|
<DropdownMenuLabel class="p-0 font-normal">
|
||||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
<Avatar class="h-8 w-8 rounded-lg">
|
<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">
|
<AvatarFallback class="rounded-lg">
|
||||||
{{
|
{{
|
||||||
user.name
|
userStore?.user_name
|
||||||
.split(' ')
|
?.split(' ')
|
||||||
.map((n) => n[0])
|
.map((n) => n[0])
|
||||||
.join('')
|
.join('')
|
||||||
}}
|
}}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span class="truncate font-semibold">{{ user.name }}</span>
|
<span class="truncate font-semibold">{{ userStore?.user_name }}</span>
|
||||||
<span class="truncate text-xs">{{ user.email }}</span>
|
<span class="truncate text-xs">{{ userStore?.user_email }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
@@ -73,8 +76,8 @@ const showModalTheme = ref(false)
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem @click="showModalTheme = true">
|
<DropdownMenuItem @click="showModalTheme = true">
|
||||||
<Icon name="i-lucide-paintbrush" />
|
<Icon name="i-lucide-user" />
|
||||||
Theme
|
Profile
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -86,16 +89,6 @@ const showModalTheme = ref(false)
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
@@ -9,7 +9,7 @@ withDefaults(
|
|||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
size: 'default',
|
size: 'default',
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const { setOpenMobile } = useSidebar()
|
const { setOpenMobile } = useSidebar()
|
||||||
@@ -19,7 +19,11 @@ const { setOpenMobile } = useSidebar()
|
|||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton as-child :tooltip="item.title" :size="size">
|
<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" />
|
<Icon :name="item.icon || ''" mode="svg" />
|
||||||
<span>{{ item.title }}</span>
|
<span>{{ item.title }}</span>
|
||||||
<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">
|
<div class="grid gap-2 text-center">
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Login RSSA</h1>
|
<h1 class="text-2xl font-semibold tracking-tight">Login RSSA</h1>
|
||||||
</div>
|
</div>
|
||||||
<AppAuthSignIn />
|
<FlowAuthLogin />
|
||||||
</div>
|
</div>
|
||||||
</LayoutAuth>
|
</LayoutAuth>
|
||||||
</template>
|
</template>
|
||||||
+5
-3
@@ -2,9 +2,11 @@
|
|||||||
import { Activity, CreditCard, DollarSign, Users } from 'lucide-vue-next'
|
import { Activity, CreditCard, DollarSign, Users } from 'lucide-vue-next'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: 'auth',
|
roles: ['sys', 'doc'],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { userRole } = useUserStore()
|
||||||
|
|
||||||
const dataCard = ref({
|
const dataCard = ref({
|
||||||
totalRevenue: 0,
|
totalRevenue: 0,
|
||||||
totalRevenueDesc: 0,
|
totalRevenueDesc: 0,
|
||||||
@@ -61,7 +63,7 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex w-full flex-col gap-4">
|
<div class="flex w-full flex-col gap-4">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<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 class="flex items-center space-x-2"></div>
|
||||||
</div>
|
</div>
|
||||||
<main class="flex flex-1 flex-col gap-4 md:gap-8">
|
<main class="flex flex-1 flex-col gap-4 md:gap-8">
|
||||||
@@ -147,7 +149,7 @@ onMounted(() => {
|
|||||||
<CardTitle>Overview</CardTitle>
|
<CardTitle>Overview</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="pl-2">
|
<CardContent class="pl-2">
|
||||||
<DashboardOverview />
|
<!-- <DashboardOverview /> -->
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<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',
|
quotes: 'single',
|
||||||
// Less strict formatting
|
// Less strict formatting
|
||||||
jsx: false,
|
jsx: false,
|
||||||
|
trailingComma: 'all',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
"embla-carousel": "^8.5.2",
|
"embla-carousel": "^8.5.2",
|
||||||
"embla-carousel-vue": "^8.5.2",
|
"embla-carousel-vue": "^8.5.2",
|
||||||
"h3": "^1.15.4",
|
"h3": "^1.15.4",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
|
"pinia-plugin-persistedstate": "^4.4.1",
|
||||||
"reka-ui": "^2.4.1"
|
"reka-ui": "^2.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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