diff --git a/README.md b/README.md index 4b3f181a..1094dab9 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,66 @@ # SIMRS - FE + RSSA - Front End ## Framework Guide + - [Vue Style Guide](https://vuejs.org/style-guide) - [Nuxt Style Guide](https://nuxt.com/docs/4.x/guide) ## Configuration + - `nuxt.config.ts`
Nuxt configuration file - `.env`
Some environment variables ## Directory Structure for `app/` + - `app.vue`: Main layout - `components` : Contains all reusable UI components. - `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/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` +## Directory Structure for `app/pages` + - `pages/auth` : Authentication related pages. - `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 ## Workflows + The basic development workflow follows these steps: ### Define Your Data in `models/` + - Create data definitions or interfaces. - These should represent the structure of the data used across your app. ### Build UI Components in `components/app` + - Create reusable UI and app specific components. - Keep components pure, avoid making HTTP requests directly within them. - They receive data via props and emit events upward. ### 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 flow. - Also responsible for managing state, side effects, and interactions. ### Create Pages in `pages/` + - Pages load the appropriate flow from `components/flow/`. - They do not contain UI or logic directly, just route level layout or guards. ## Git Workflows + The basic git workflow follows these steps: + 1. Create a new branch on `dev` - branch name should be `feat/` or `fix/` 2. Make your changes diff --git a/app/components/app/patient/entry-form.vue b/app/components/app/patient/entry-form.vue index 89d8b1bf..0b636bcf 100644 --- a/app/components/app/patient/entry-form.vue +++ b/app/components/app/patient/entry-form.vue @@ -1,9 +1,37 @@ - + diff --git a/app/components/app/patient/list-cfg.ts b/app/components/app/patient/list-cfg.ts new file mode 100644 index 00000000..c1131bec --- /dev/null +++ b/app/components/app/patient/list-cfg.ts @@ -0,0 +1,118 @@ +import type { + Col, + KeyLabel, + RecComponent, + RecStrFuncComponent, + RecStrFuncUnknown, + Th, +} from '~/components/pub/nav/types' + +type SmallDetailDto = any + +const action = defineAsyncComponent(() => import('~/components/pub/nav/dropdown-action-dud.vue')) + +export const cols: Col[] = [ + {}, + {}, + {}, + { width: 100 }, + { width: 120 }, + {}, + {}, + {}, + { width: 100 }, + { width: 100 }, + {}, + { width: 50 }, +] + +export const header: Th[][] = [ + [ + { label: 'Nama' }, + { label: 'Rekam Medis' }, + { label: 'KTP' }, + { label: 'Tgl Lahir' }, + { label: 'Umur' }, + { label: 'JK' }, + { label: 'Pendidikan' }, + { label: 'Status' }, + { label: '' }, + ], +] + +export const keys = [ + 'name', + 'medicalRecord_number', + 'identity_number', + 'birth_date', + 'patient_age', + 'gender', + 'education', + 'status', + 'action', +] + +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 || ''}` + }, + identity_number: (rec: unknown): unknown => { + const recX = rec as SmallDetailDto + if (recX.identity_number?.substring(0, 5) === 'BLANK') { + return '(TANPA NIK)' + } + return recX.identity_number + }, + birth_date: (rec: unknown): unknown => { + const recX = rec as SmallDetailDto + if (typeof recX.birth_date == 'object' && recX.birth_date) { + return (recX.birth_date as Date).toLocaleDateString() + } else if (typeof recX.birth_date == 'string') { + return (recX.birth_date as string).substring(0, 10) + } + return recX.birth_date + }, + patient_age: (rec: unknown): unknown => { + const recX = rec as SmallDetailDto + return recX.birth_date?.split('T')[0] + }, + gender: (rec: unknown): unknown => { + const recX = rec as SmallDetailDto + if (typeof recX?.gender_code !== 'number' && recX?.gender_code !== '') { + return 'Tidak Diketahui' + } + return recX.gender_code + }, + education: (rec: unknown): unknown => { + const recX = rec as SmallDetailDto + if (typeof recX.education_code == 'number' && recX.education_code >= 0) { + return recX.education_code + } else if (typeof recX.education_code) { + return recX.education_code + } + return '-' + }, +} + +export const funcComponent: RecStrFuncComponent = { + action(rec, idx) { + const res: RecComponent = { + idx, + rec: rec as object, + component: action, + } + return res + }, +} + +export const funcHtml: RecStrFuncUnknown = { + patient_address(_rec) { + return '-' + }, +} diff --git a/app/components/app/patient/list.vue b/app/components/app/patient/list.vue index e69de29b..3267842c 100644 --- a/app/components/app/patient/list.vue +++ b/app/components/app/patient/list.vue @@ -0,0 +1,19 @@ + + + diff --git a/app/components/flow/patient/list.vue b/app/components/flow/patient/list.vue index c3a39fce..8537ce3b 100644 --- a/app/components/flow/patient/list.vue +++ b/app/components/flow/patient/list.vue @@ -3,7 +3,10 @@ import type { Summary } from '~/components/pub/base/summary-card.type' import type { HeaderPrep, RefSearchNav } from '~/components/pub/nav/types' import { Calendar, Hospital, UserCheck, UsersRound } from 'lucide-vue-next' +const data = ref([]) + const refSearchNav: RefSearchNav = { + onClick: () => { // open filter modal }, @@ -20,6 +23,9 @@ const isLoading = reactive({ summary: false, table: false, }) +const recId = ref(0) +const recAction = ref('') +const recItem = ref(null) const hreaderPrep: HeaderPrep = { title: 'Pasien', @@ -67,7 +73,7 @@ async function getPatientSummary() { try { isLoading.summary = true - await new Promise((resolve) => setTimeout(resolve, 1500)) + await new Promise((resolve) => setTimeout(resolve, 500)) } catch (error) { console.error('Error fetching patient summary:', error) // Keep default/existing data on error @@ -77,20 +83,26 @@ async function getPatientSummary() { } async function getPatientList() { - // const response = await xfetch('/api/v1/patient') - // console.log('data patient', response) + const resp = await xfetch('/api/v1/patient') + console.log('data patient', resp) + if (resp.success) { + data.value = (resp.body as Record).data + } } onMounted(() => { getPatientSummary() getPatientList() }) + +provide('rec_id', recId) +provide('rec_action', recAction) +provide('rec_item', recItem) diff --git a/app/components/layout/Header.vue b/app/components/layout/Header.vue index 1da0a9ac..369b29ea 100644 --- a/app/components/layout/Header.vue +++ b/app/components/layout/Header.vue @@ -37,7 +37,7 @@ watch( if (val) { links.value = setLinks() } - } + }, ) diff --git a/app/components/layout/SidebarNavFooter.vue b/app/components/layout/SidebarNavFooter.vue index d3b4552e..0e07e822 100644 --- a/app/components/layout/SidebarNavFooter.vue +++ b/app/components/layout/SidebarNavFooter.vue @@ -22,73 +22,75 @@ const showModalTheme = ref(false) diff --git a/app/components/layout/SidebarNavGroup.vue b/app/components/layout/SidebarNavGroup.vue index f129edef..e6c74ee6 100644 --- a/app/components/layout/SidebarNavGroup.vue +++ b/app/components/layout/SidebarNavGroup.vue @@ -9,7 +9,7 @@ withDefaults( }>(), { size: 'default', - } + }, ) const { setOpenMobile } = useSidebar() diff --git a/app/components/layout/SidebarNavLink.vue b/app/components/layout/SidebarNavLink.vue index b720b3be..55b0d2b5 100644 --- a/app/components/layout/SidebarNavLink.vue +++ b/app/components/layout/SidebarNavLink.vue @@ -22,6 +22,7 @@ const { setOpenMobile } = useSidebar() diff --git a/app/components/pub/base/error.vue b/app/components/pub/base/error.vue new file mode 100644 index 00000000..c1ebc354 --- /dev/null +++ b/app/components/pub/base/error.vue @@ -0,0 +1,50 @@ + + + diff --git a/app/components/pub/form/block.vue b/app/components/pub/form/block.vue index 5f4988d5..27015f7a 100644 --- a/app/components/pub/form/block.vue +++ b/app/components/pub/form/block.vue @@ -5,7 +5,7 @@ defineProps<{ diff --git a/app/components/pub/form/field-group.vue b/app/components/pub/form/field-group.vue index 8e901636..784870a7 100644 --- a/app/components/pub/form/field-group.vue +++ b/app/components/pub/form/field-group.vue @@ -34,7 +34,15 @@ const classVal = computed(() => { diff --git a/app/components/pub/form/label.vue b/app/components/pub/form/label.vue new file mode 100644 index 00000000..b1ad2d55 --- /dev/null +++ b/app/components/pub/form/label.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/app/components/pub/nav/data-table.vue b/app/components/pub/nav/data-table.vue new file mode 100644 index 00000000..9dc72099 --- /dev/null +++ b/app/components/pub/nav/data-table.vue @@ -0,0 +1,46 @@ + + + diff --git a/app/components/pub/nav/dropdown-action-du.vue b/app/components/pub/nav/dropdown-action-du.vue new file mode 100644 index 00000000..52c999fb --- /dev/null +++ b/app/components/pub/nav/dropdown-action-du.vue @@ -0,0 +1,69 @@ + + + diff --git a/app/components/pub/nav/dropdown-action-ducd.vue b/app/components/pub/nav/dropdown-action-ducd.vue new file mode 100644 index 00000000..a4073a93 --- /dev/null +++ b/app/components/pub/nav/dropdown-action-ducd.vue @@ -0,0 +1,89 @@ + + + diff --git a/app/components/pub/nav/dropdown-action-dud.vue b/app/components/pub/nav/dropdown-action-dud.vue new file mode 100644 index 00000000..5a9c30a9 --- /dev/null +++ b/app/components/pub/nav/dropdown-action-dud.vue @@ -0,0 +1,82 @@ + + + diff --git a/app/components/pub/nav/dropdown-action-ud.vue b/app/components/pub/nav/dropdown-action-ud.vue new file mode 100644 index 00000000..0ef020d7 --- /dev/null +++ b/app/components/pub/nav/dropdown-action-ud.vue @@ -0,0 +1,69 @@ + + + diff --git a/app/components/pub/nav/footer/cs.vue b/app/components/pub/nav/footer/cs.vue index 96725a05..d51bf74a 100644 --- a/app/components/pub/nav/footer/cs.vue +++ b/app/components/pub/nav/footer/cs.vue @@ -1,12 +1,24 @@ - + diff --git a/app/components/pub/nav/footer/csd.vue b/app/components/pub/nav/footer/csd.vue new file mode 100644 index 00000000..78874932 --- /dev/null +++ b/app/components/pub/nav/footer/csd.vue @@ -0,0 +1,28 @@ + + + diff --git a/app/components/pub/nav/header/prep.vue b/app/components/pub/nav/header/prep.vue index 8d856333..ab37900d 100644 --- a/app/components/pub/nav/header/prep.vue +++ b/app/components/pub/nav/header/prep.vue @@ -32,7 +32,7 @@ function btnClick() { diff --git a/app/components/pub/nav/types.ts b/app/components/pub/nav/types.ts index 91b53410..aa4d130b 100644 --- a/app/components/pub/nav/types.ts +++ b/app/components/pub/nav/types.ts @@ -82,3 +82,12 @@ export interface KeyNames { key: string label: string } + +export interface LinkItem { + label: string + icon?: string + href?: string // to cover the needs of stating full external origins full url + action?: string // for local paths + onClick?: (event: Event) => void + headerStatus?: boolean +} diff --git a/app/components/pub/ui/input/Input.vue b/app/components/pub/ui/input/Input.vue index 81140b40..2c7e0141 100644 --- a/app/components/pub/ui/input/Input.vue +++ b/app/components/pub/ui/input/Input.vue @@ -1,7 +1,7 @@ diff --git a/app/composables/useRBAC.ts b/app/composables/useRBAC.ts new file mode 100644 index 00000000..6228f37f --- /dev/null +++ b/app/composables/useRBAC.ts @@ -0,0 +1,48 @@ +import type { Permission, RoleAccess } from '~/models/role' + +/** + * Check if user has access to a page + */ +export function useRBAC() { + // NOTE: this roles was dummy for testing only, it should taken from the user store + // const authStore = useAuthStore() + + const checkRole = (roleAccess: RoleAccess, _userRoles?: string[]): boolean => { + const roles = ['admisi'] + return roles.some((role: string) => role in roleAccess) + } + + const checkPermission = (roleAccess: RoleAccess, permission: Permission, _userRoles?: string[]): boolean => { + 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 permissions = new Set() + + roles.forEach((role) => { + if (roleAccess[role]) { + roleAccess[role].forEach((permission) => permissions.add(permission)) + } + }) + + return Array.from(permissions) + } + + const hasCreateAccess = (roleAccess: RoleAccess) => checkPermission(roleAccess, 'C') + const hasReadAccess = (roleAccess: RoleAccess) => checkPermission(roleAccess, 'R') + const hasUpdateAccess = (roleAccess: RoleAccess) => checkPermission(roleAccess, 'U') + const hasDeleteAccess = (roleAccess: RoleAccess) => checkPermission(roleAccess, 'D') + + return { + checkRole, + checkPermission, + getUserPermissions, + hasCreateAccess, + hasReadAccess, + hasUpdateAccess, + hasDeleteAccess, + } +} diff --git a/app/error.vue b/app/error.vue new file mode 100644 index 00000000..27c04cc0 --- /dev/null +++ b/app/error.vue @@ -0,0 +1,11 @@ + + + + + diff --git a/app/layouts/default.vue b/app/layouts/default.vue index d63792ae..6e6c32ce 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -1,4 +1,21 @@ - + - + diff --git a/app/lib/page-permission.ts b/app/lib/page-permission.ts new file mode 100644 index 00000000..797dd077 --- /dev/null +++ b/app/lib/page-permission.ts @@ -0,0 +1,12 @@ +import type { RoleAccess } from '~/models/role' + +export const PAGE_PERMISSIONS = { + '/patient': { + doctor: ['R'], + nurse: ['R'], + admisi: ['C', 'R', 'U', 'D'], + pharmacy: ['R'], + billing: ['R'], + management: ['R'], + }, +} as const satisfies Record diff --git a/app/middleware/auth.global.ts b/app/middleware/auth.global.ts index e3b98900..e2b76d91 100644 --- a/app/middleware/auth.global.ts +++ b/app/middleware/auth.global.ts @@ -1,4 +1,6 @@ export default defineNuxtRouteMiddleware((to) => { + if (to.meta.public) return + const { $pinia } = useNuxtApp() if (import.meta.client) { @@ -7,12 +9,7 @@ export default defineNuxtRouteMiddleware((to) => { 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') + return navigateTo('/401') } } }) diff --git a/app/middleware/rbac.ts b/app/middleware/rbac.ts new file mode 100644 index 00000000..8528c2ca --- /dev/null +++ b/app/middleware/rbac.ts @@ -0,0 +1,31 @@ +import { PAGE_PERMISSIONS } from '~/lib/page-permission' + +export default defineNuxtRouteMiddleware((to) => { + if (to.meta.public) return + + const { $pinia } = useNuxtApp() + if (import.meta.server) { + const authStore = useUserStore($pinia) + // Check specific page permissions if defined in config + const pagePermissions = PAGE_PERMISSIONS[to.path as keyof typeof PAGE_PERMISSIONS] + if (pagePermissions) { + const { checkRole } = useRBAC() + if (!checkRole(pagePermissions)) { + return navigateTo('/403') + } + } + + // Fallback to meta roles + 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 hasRequiredRole = requiredRoles.some((role) => userRoles.includes(role)) + + if (!hasRequiredRole) { + return navigateTo('/403') + } + } + } +}) diff --git a/app/models/role.ts b/app/models/role.ts new file mode 100644 index 00000000..6c6097a9 --- /dev/null +++ b/app/models/role.ts @@ -0,0 +1,22 @@ +import type { PAGE_PERMISSIONS } from '~/lib/page-permission' + +export interface User { + id: string + name: string + email: string +} + +export interface AuthState { + user: User | null + roles: string[] + token: string | null +} + +export type Permission = 'C' | 'R' | 'U' | 'D' + +export interface RoleAccess { + [role: string]: Permission[] +} + +export type PagePath = keyof typeof PAGE_PERMISSIONS +export type PagePermission = (typeof PAGE_PERMISSIONS)[PagePath] diff --git a/app/pages/(error)/401.vue b/app/pages/(error)/401.vue new file mode 100644 index 00000000..b60f313f --- /dev/null +++ b/app/pages/(error)/401.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/app/pages/(error)/403.vue b/app/pages/(error)/403.vue new file mode 100644 index 00000000..985bc412 --- /dev/null +++ b/app/pages/(error)/403.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/app/pages/(error)/404.vue b/app/pages/(error)/404.vue new file mode 100644 index 00000000..a857fca1 --- /dev/null +++ b/app/pages/(error)/404.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/app/pages/(error)/500.vue b/app/pages/(error)/500.vue new file mode 100644 index 00000000..7c6cb120 --- /dev/null +++ b/app/pages/(error)/500.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/app/pages/(error)/503.vue b/app/pages/(error)/503.vue new file mode 100644 index 00000000..ccd86c2c --- /dev/null +++ b/app/pages/(error)/503.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/app/pages/(features)/patient/add.vue b/app/pages/(features)/patient/add.vue index 12063ef7..30b25cf4 100644 --- a/app/pages/(features)/patient/add.vue +++ b/app/pages/(features)/patient/add.vue @@ -1,9 +1,34 @@ diff --git a/app/pages/(features)/patient/index.vue b/app/pages/(features)/patient/index.vue index f1db5b53..13e0c451 100644 --- a/app/pages/(features)/patient/index.vue +++ b/app/pages/(features)/patient/index.vue @@ -1,11 +1,35 @@ diff --git a/app/pages/auth/login.vue b/app/pages/auth/login.vue index eb157c8c..b2e8f89a 100644 --- a/app/pages/auth/login.vue +++ b/app/pages/auth/login.vue @@ -1,6 +1,7 @@ diff --git a/eslint.config.js b/eslint.config.js index e786c1c0..4879bee5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,14 +18,14 @@ export default withNuxt( { rules: { // Basic rules - quotes: ['error', 'single', { avoidEscape: true }], + 'quotes': ['error', 'single', { avoidEscape: true }], 'style/no-trailing-spaces': ['error', { ignoreComments: true }], 'no-console': 'off', // Relax strict formatting rules 'style/brace-style': 'off', // Allow inline if - curly: ['error', 'multi-line'], // Only require braces for multi-line + 'curly': ['error', 'multi-line'], // Only require braces for multi-line 'style/arrow-parens': 'off', // UnoCSS - make it warning instead of error, or disable completely @@ -48,6 +48,6 @@ export default withNuxt( rules: { 'style/no-trailing-spaces': 'off', }, - } - ) + }, + ), ) diff --git a/package.json b/package.json index a77d07a3..74a2234b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nuxt-app", - "private": true, "type": "module", + "private": true, "scripts": { "build": "nuxt build", "dev": "nuxt dev", diff --git a/server/api/[...req].ts b/server/api/[...req].ts index 88e27d59..3fd45b16 100644 --- a/server/api/[...req].ts +++ b/server/api/[...req].ts @@ -23,7 +23,7 @@ export default defineEventHandler(async (event) => { if (headers['content-type']) forwardHeaders.set('Content-Type', headers['content-type']) forwardHeaders.set('Authorization', `Bearer ${bearer}`) - let body: any = undefined + let body: any if (['POST', 'PATCH'].includes(method!)) { if (headers['content-type']?.includes('multipart/form-data')) { body = await readBody(event) diff --git a/uno.config.ts b/uno.config.ts index 48e7c051..fb109dd6 100644 --- a/uno.config.ts +++ b/uno.config.ts @@ -1,4 +1,4 @@ -import { defineConfig, presetWind, presetAttributify, presetIcons } from 'unocss' +import { defineConfig, presetAttributify, presetIcons, presetWind } from 'unocss' export default defineConfig({ presets: [