Merge remote-tracking branch 'origin/dev' into feat/doctor
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
RSSA - Front End
|
||||
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Read this following instructions before doing your job
|
||||
|
||||
## Framework Guide
|
||||
|
||||
- [Vue Style Guide](https://vuejs.org/style-guide)
|
||||
@@ -54,9 +58,30 @@ The basic development workflow follows these steps:
|
||||
|
||||
### Create Pages in `pages/`
|
||||
|
||||
- Define permissions and guards for each page.
|
||||
- Pages load the appropriate flow from `components/flow/`.
|
||||
- They do not contain UI or logic directly, just route level layout or guards.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- Under the script setup block, putting things in group with the following order:
|
||||
- Imports → all dependencies, sorted by external, alias (~), and relative imports.
|
||||
- Props & Emits → clearly define component inputs & outputs.
|
||||
- State & Computed → all ref, reactive, and computed variables grouped together.
|
||||
- Lifecycle Hooks → grouped by mounting → updating → unmounting order.
|
||||
- Functions → async first (fetching, API calls), then utility & event handlers.
|
||||
- Watchers → if needed, put them at the bottom.
|
||||
- Template → keep clean and minimal logic, use methods/computed instead of inline JS.
|
||||
- Declaration Naming
|
||||
- Uses PascalCase for `type` naming
|
||||
- Uses camelCase for `variable` and `const` naming
|
||||
- Underscore can be used to indicates that a variable has derived from an attribute of an object<br />
|
||||
for example: `person_name` indicates it is an attribute `name` from object `person`
|
||||
- Looping
|
||||
- Uses `i`, `j`, `k` as variable for `for` looping index
|
||||
- Uses `item` as object instantition for `forEach` loop callback
|
||||
- Uses `item` as object instantition for `v-for` loop callback in the template
|
||||
|
||||
## Git Workflows
|
||||
|
||||
The basic git workflow follows these steps:
|
||||
|
||||
@@ -14,5 +14,7 @@ const dir = computed(() => (textDirection.value === 'rtl' ? 'rtl' : 'ltr'))
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
</ConfigProvider>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Calendar, Hospital, UserCheck, UsersRound } from 'lucide-vue-next'
|
||||
const data = ref([])
|
||||
|
||||
const refSearchNav: RefSearchNav = {
|
||||
|
||||
onClick: () => {
|
||||
// open filter modal
|
||||
},
|
||||
@@ -29,7 +28,7 @@ const recItem = ref<any>(null)
|
||||
|
||||
const hreaderPrep: HeaderPrep = {
|
||||
title: 'Pasien',
|
||||
icon: 'i-lucide-add',
|
||||
icon: 'i-lucide-users',
|
||||
addNav: {
|
||||
label: 'Tambah',
|
||||
onClick: () => navigateTo('/patient/add'),
|
||||
@@ -102,7 +101,7 @@ provide('rec_item', recItem)
|
||||
|
||||
<template>
|
||||
<PubNavHeaderPrep :prep="{ ...hreaderPrep }" :ref-search-nav="refSearchNav" />
|
||||
<div class="flex flex-1 flex-col gap-4 md:gap-8">
|
||||
<div class="my-4 flex flex-1 flex-col gap-4 md:gap-8">
|
||||
<div class="grid gap-4 md:grid-cols-2 md:gap-8 lg:grid-cols-4">
|
||||
<template v-if="isLoading.summary">
|
||||
<PubBaseSummaryCard v-for="n in 4" :key="n" is-skeleton />
|
||||
|
||||
@@ -1,82 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
const navMenu: any[] = [
|
||||
{
|
||||
heading: 'Menu Utama',
|
||||
items: [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
icon: 'i-lucide-home',
|
||||
link: '/',
|
||||
},
|
||||
{
|
||||
title: 'Pasien',
|
||||
icon: 'i-lucide-users',
|
||||
link: '/patient',
|
||||
},
|
||||
{
|
||||
title: 'Doctor',
|
||||
icon: 'i-lucide-cross',
|
||||
link: '/doctor',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const navMenuBottom: any[] = [
|
||||
{
|
||||
title: 'Help & Support',
|
||||
icon: 'i-lucide-circle-help',
|
||||
link: 'https://github.com/simrs/simrs-fe',
|
||||
},
|
||||
]
|
||||
|
||||
function resolveNavItemComponent(item: any): any {
|
||||
if ('children' in item) return resolveComponent('LayoutSidebarNavGroup')
|
||||
|
||||
return resolveComponent('LayoutSidebarNavLink')
|
||||
}
|
||||
const navMenu: {
|
||||
heading: string
|
||||
items: Array<any>
|
||||
} = ref({
|
||||
heading: 'Menu Utama',
|
||||
items: [],
|
||||
})
|
||||
|
||||
const teams: {
|
||||
name: string
|
||||
@@ -89,39 +18,55 @@ const teams: {
|
||||
plan: 'Saiful Anwar Hospital',
|
||||
},
|
||||
]
|
||||
|
||||
// const user: {
|
||||
// name: string
|
||||
// email: string
|
||||
// avatar: string
|
||||
// } = {
|
||||
// name: '',
|
||||
// email: '',
|
||||
// avatar: '/',
|
||||
// }
|
||||
|
||||
// const { sidebar } = useAppSettings()
|
||||
const sidebar = {
|
||||
collapsible: 'offcanvas', // 'offcanvas' | 'icon' | 'none'
|
||||
side: 'left', // 'left' | 'right'
|
||||
variant: 'sidebar', // 'sidebar' | 'floating' | 'inset'
|
||||
}
|
||||
const navMenuBottom: any[] = [
|
||||
{
|
||||
title: 'Help & Support',
|
||||
icon: 'i-lucide-circle-help',
|
||||
link: 'https://github.com/simrs/simrs-fe',
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
await setMenu()
|
||||
})
|
||||
|
||||
function resolveNavItemComponent(item: any): any {
|
||||
if ('children' in item) return resolveComponent('LayoutSidebarNavGroup')
|
||||
|
||||
return resolveComponent('LayoutSidebarNavLink')
|
||||
}
|
||||
|
||||
async function setMenu() {
|
||||
const position_code = 'sys'
|
||||
const res = await fetch(`/side-menu-items/${position_code}.json`)
|
||||
const rawMenu = await res.text()
|
||||
navMenu.value.items = JSON.parse(rawMenu)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sidebar :collapsible="sidebar.collapsible" :side="sidebar.side" :variant="sidebar.variant">
|
||||
<SidebarHeader>
|
||||
<LayoutSidebarNavHeader :teams="teams" />
|
||||
<!-- <Search /> -->
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup v-for="(nav, indexGroup) in navMenu" :key="indexGroup">
|
||||
<SidebarGroupLabel v-if="nav.heading">
|
||||
{{ nav.heading }}
|
||||
<SidebarGroup v-if="navMenu.items.length > 0">
|
||||
<SidebarGroupLabel>
|
||||
{{ navMenu.heading }}
|
||||
</SidebarGroupLabel>
|
||||
<component :is="resolveNavItemComponent(item)" v-for="(item, index) in nav.items" :key="index" :item="item"
|
||||
class="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">
|
||||
<div v-for="n in 10" :key="n" class="my-4 h-8 animate-pulse rounded bg-gray-200 py-2"></div>
|
||||
</div>
|
||||
</template>
|
||||
<SidebarGroup class="mt-auto">
|
||||
<component :is="resolveNavItemComponent(item)" v-for="(item, index) in navMenuBottom" :key="index" :item="item"
|
||||
size="sm" />
|
||||
|
||||
@@ -50,7 +50,7 @@ const showModalTheme = ref(false)
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg bg-white"
|
||||
:side="isMobile ? 'bottom' : 'right'"
|
||||
align="end"
|
||||
>
|
||||
@@ -76,13 +76,13 @@ const showModalTheme = ref(false)
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem @click="showModalTheme = true">
|
||||
<DropdownMenuItem class="hover:bg-gray-100" @click="showModalTheme = true">
|
||||
<Icon name="i-lucide-user" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="handleLogout">
|
||||
<DropdownMenuItem class="hover:bg-gray-100" @click="handleLogout">
|
||||
<Icon name="i-lucide-log-out" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -24,13 +24,7 @@ const openCollapsible = ref(false)
|
||||
<CollapsibleTrigger as-child>
|
||||
<SidebarMenuButton :tooltip="item.title" :size="size">
|
||||
<Icon :name="item.icon || ''" mode="svg" />
|
||||
<span>{{ item.title }}</span>
|
||||
<span
|
||||
v-if="item.new"
|
||||
class="bg-#adfa1d rounded-md px-1.5 py-0.5 text-xs leading-none text-black no-underline group-hover:no-underline"
|
||||
>
|
||||
New
|
||||
</span>
|
||||
<span class="mx-2">{{ item.title }}</span>
|
||||
<Icon
|
||||
name="i-lucide-chevron-right"
|
||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
@@ -41,14 +35,13 @@ const openCollapsible = ref(false)
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem v-for="subItem in item.children" :key="subItem.title">
|
||||
<SidebarMenuSubButton as-child>
|
||||
<NuxtLink :to="subItem.link" @click="setOpenMobile(false)">
|
||||
<NuxtLink
|
||||
:to="subItem.link"
|
||||
class="mx-4 rounded-lg py-5 text-sm font-medium transition-all duration-200"
|
||||
active-class="bg-primary text-white"
|
||||
@click="setOpenMobile(false)"
|
||||
>
|
||||
<span>{{ subItem.title }}</span>
|
||||
<span
|
||||
v-if="subItem.new"
|
||||
class="bg-#adfa1d rounded-md px-1.5 py-0.5 text-xs leading-none text-black no-underline group-hover:no-underline"
|
||||
>
|
||||
New
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
|
||||
@@ -21,7 +21,7 @@ const { setOpenMobile } = useSidebar()
|
||||
<SidebarMenuButton as-child :tooltip="item.title" :size="size">
|
||||
<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"
|
||||
class="group flex items-center gap-3 rounded-lg px-3 py-3 text-sm font-medium transition-all duration-200"
|
||||
active-class="bg-primary text-white"
|
||||
@click="setOpenMobile(false)"
|
||||
>
|
||||
|
||||
@@ -43,7 +43,8 @@ const router = useRouter()
|
||||
</template>
|
||||
<div class="mt-6 flex gap-4">
|
||||
<Button variant="outline" @click="router.back()"> Kembali </Button>
|
||||
<Button @click="router.push('/')"> Kembali Ke Dashboard </Button>
|
||||
<Button v-if="statusCode === 401" @click="router.push('/auth/login')">Login</Button>
|
||||
<Button v-else @click="router.push('/')">Kembali ke Dashboard</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ 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" />
|
||||
{{ prep.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,21 +5,22 @@ import type { Permission, RoleAccess } from '~/models/role'
|
||||
*/
|
||||
export function useRBAC() {
|
||||
// NOTE: this roles was dummy for testing only, it should taken from the user store
|
||||
// const authStore = useAuthStore()
|
||||
const authStore = useUserStore()
|
||||
|
||||
const checkRole = (roleAccess: RoleAccess, _userRoles?: string[]): boolean => {
|
||||
const roles = ['admisi']
|
||||
const roles = authStore.userRole
|
||||
return roles.some((role: string) => role in roleAccess)
|
||||
}
|
||||
|
||||
const checkPermission = (roleAccess: RoleAccess, permission: Permission, _userRoles?: string[]): boolean => {
|
||||
const roles = ['admisi']
|
||||
const roles = authStore.userRole
|
||||
// const roles = ['admisi']
|
||||
return roles.some((role: string) => roleAccess[role]?.includes(permission))
|
||||
}
|
||||
|
||||
const getUserPermissions = (roleAccess: RoleAccess, _userRoles?: string[]): Permission[] => {
|
||||
// const roles = userRoles || authStore.roles
|
||||
const roles = ['admisi']
|
||||
const roles = authStore.userRole
|
||||
// const roles = ['admisi']
|
||||
const permissions = new Set<Permission>()
|
||||
|
||||
roles.forEach((role) => {
|
||||
|
||||
@@ -55,6 +55,10 @@ export async function xfetch(
|
||||
const status = fetchError.response?.status || 500
|
||||
const resJson = fetchError.data
|
||||
|
||||
if (status === 401 && import.meta.client) {
|
||||
clearStore()
|
||||
}
|
||||
|
||||
if (resJson?.errors) {
|
||||
errors = resJson.errors
|
||||
} else if (resJson?.code && resJson?.message) {
|
||||
@@ -68,3 +72,10 @@ export async function xfetch(
|
||||
return { success, status_code: status, body, errors, error, message }
|
||||
}
|
||||
}
|
||||
|
||||
function clearStore() {
|
||||
const { $pinia } = useNuxtApp()
|
||||
const userStore = useUserStore($pinia)
|
||||
userStore.logout()
|
||||
navigateTo('/401')
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ const contentContent = computed(() => {
|
||||
max-width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
border-radius: 0.375rem;
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
|
||||
@@ -57,6 +58,7 @@ const contentContent = computed(() => {
|
||||
padding-bottom: 5rem; /* pb-20 */
|
||||
border-width: 1px;
|
||||
background-color: white !important;
|
||||
border-radius: 0.375rem;
|
||||
border-color: rgb(226 232 240); /* slate-200 */
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -76,9 +78,9 @@ const contentContent = computed(() => {
|
||||
.cf-frame-width {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 0.75rem;
|
||||
background-color: white;
|
||||
border: 1px solid rgb(226 232 240);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.cf-frame {
|
||||
@@ -86,6 +88,7 @@ const contentContent = computed(() => {
|
||||
margin-right: auto;
|
||||
padding: 0.75rem;
|
||||
background-color: white;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid rgb(226 232 240);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ export default defineNuxtRouteMiddleware((to) => {
|
||||
if (to.meta.public) return
|
||||
|
||||
const { $pinia } = useNuxtApp()
|
||||
|
||||
if (import.meta.client) {
|
||||
const userStore = useUserStore($pinia)
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ export default defineNuxtRouteMiddleware((to) => {
|
||||
const requiredRoles = to.meta.roles as string[]
|
||||
if (requiredRoles && requiredRoles.length > 0) {
|
||||
// FIXME: change this dummy roles, when api is ready
|
||||
// const userRoles = authStore.roles
|
||||
const userRoles = ['admisi']
|
||||
const userRoles = authStore.userRole
|
||||
// const userRoles = ['admisi']
|
||||
const hasRequiredRole = requiredRoles.some((role) => userRoles.includes(role))
|
||||
|
||||
if (!hasRequiredRole) {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div>Examination Queue</div>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div>Examination</div>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div>Registration Queue</div>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div>Registration</div>
|
||||
</template>
|
||||
+3
-2
@@ -2,9 +2,10 @@ export const useUserStore = defineStore(
|
||||
'user',
|
||||
() => {
|
||||
const user = ref<any | null>(null)
|
||||
// const token = useCookie('authentication')
|
||||
|
||||
const isAuthenticated = computed(() => !!user.value)
|
||||
const userRole = computed(() => user.value?.user_position_code || '')
|
||||
// const userRole = computed(() => user.value?.user_position_code || '')
|
||||
|
||||
const login = async (userData: any) => {
|
||||
user.value = userData
|
||||
@@ -17,7 +18,7 @@ export const useUserStore = defineStore(
|
||||
return {
|
||||
user,
|
||||
isAuthenticated,
|
||||
userRole,
|
||||
userRole: ['admisi'],
|
||||
login,
|
||||
logout,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
[
|
||||
{
|
||||
"title": "Dashboard",
|
||||
"icon": "i-lucide-home",
|
||||
"link": "/"
|
||||
},
|
||||
{
|
||||
"title": "Rawat Jalan",
|
||||
"icon": "i-lucide-stethoscope",
|
||||
"children": [
|
||||
{
|
||||
"title": "Antrian Pendaftaran",
|
||||
"icon": "i-lucide-stethoscope",
|
||||
"link": "/outpatient/registration-queue"
|
||||
},
|
||||
{
|
||||
"title": "Pendaftaran",
|
||||
"icon": "i-lucide-building-2",
|
||||
"link": "/outpatient/registration"
|
||||
},
|
||||
{
|
||||
"title": "Antrian Pemeriksaan",
|
||||
"icon": "i-lucide-stethoscope",
|
||||
"link": "/outpatient/examination-queue"
|
||||
},
|
||||
{
|
||||
"title": "Pendaftaran",
|
||||
"icon": "i-lucide-building-2",
|
||||
"link": "/outpatient/examination"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Pasien",
|
||||
"icon": "i-lucide-users",
|
||||
"link": "/patient"
|
||||
},
|
||||
{
|
||||
"title": "Rehabilitasi Medik",
|
||||
"icon": "i-lucide-heart",
|
||||
"link": "/rehabilitasi"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user