client initial commit

This commit is contained in:
Methapon2001 2023-11-23 08:47:44 +07:00
parent b5f98baa2b
commit dd1547d7c2
No known key found for this signature in database
GPG key ID: 849924FEF46BD132
70 changed files with 18446 additions and 0 deletions

28
Services/client/.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View file

@ -0,0 +1,5 @@
{
"tabWidth": 2,
"semi": false,
"singleQuote": true
}

103
Services/client/README.md Normal file
View file

@ -0,0 +1,103 @@
# ระบบ BMA eHR (eHR กรุงเทพมหานคร)
สำหรับ User
## โครงสร้างโฟลเดอร์
ภายใต้ src จะประกอบด้วยโฟลเดอร์ที่จำเป็นดังนี้
1. **assets** : สำหรับเก็บไฟล์ภาพกราฟิก + css
2. **components** : สำหรับเก็บ Component ส่วนกลางที่สามารถเรียกใช้จากระบบใดก็ได้
3. **helpers** : สำหรับเก็บ filter, composable ที่เรียกใช้จากระบบใดก็ได้
4. **modules** : สำหรับเก็บไฟล์โค้ดหลัก ภายในจะแบ่งเป็นโฟลเดอร์ย่อยของระบบต่าง ๆ ทั้ง 17 ระบบ 17 โฟลเดอร์ ภายใต้โฟลเดอร์ย่อยของระบบจะเป็นโครงสร้างแบบเดียวกับ vue template เช่น ภายใต้โฟลเดอร์ 01_metadata (ระบบข้อมูลหลัก) ประกอบด้วยโฟลเดอร์
- **components** : โฟลเดอร์เก็บ Component ที่ใช้เฉพาะระบบข้อมูลหลัก
- **views** : โฟลเดอร์เก็บ View/Layout ที่ใช้เฉพาะระบบข้อมูลหลัก
## รูปแบบ SFC ที่แนะนำให้ใช้ใน Vue 3
ลำดับการวางโค้ดใน Vue 3 จะย้ายแท็ก script ขึ้นก่อนแท็ก template ส่วนแท็ก style จะอยู่ลำดับล่างสุดเหมือนเดิม
- แท็ก script ควรมี setup และ lang="ts" บอกเสมอ
- แท็ก style ควรกำหนด lang และ scoped ด้วย
ตัวอย่างด้านล่าง
```
<script setup lang="ts">
</script>
<template>
</template>
<style lang="scss" scoped>
</style>
```
## การอ้างอิง Path
Vue Template ได้สร้าง shortcut ไว้ให้แล้วด้วยการใส่เครื่องหมาย '@' นำหน้า ซึ่ง @ แทน src ดังนั้นพาธใด ๆ ก็ต้องจะอยู่ภายใน @ ทั้งหมด เช่น ต้องการเรียก src/components ให้ใส่ว่า "@/components" ถ้าเป็นไปได้ไม่ควรใช้ relative path
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
npm run test:unit
```
### Run End-to-End Tests with [Cypress](https://www.cypress.io/)
```sh
npm run test:e2e:dev
```
This runs the end-to-end tests against the Vite development server.
It is much faster than the production build.
But it's still recommended to test the production build with `test:e2e` before deploying (e.g. in CI environments):
```sh
npm run build
npm run test:e2e
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

View file

@ -0,0 +1,8 @@
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}',
baseUrl: 'http://localhost:4173',
},
})

View file

@ -0,0 +1,8 @@
// https://docs.cypress.io/api/introduction/api.html
describe('My First Test', () => {
it('visits the app root url', () => {
cy.visit('/')
cy.contains('h1', 'You did it!')
})
})

View file

@ -0,0 +1,10 @@
{
"extends": "@vue/tsconfig/tsconfig.web.json",
"include": ["./**/*", "../support/**/*"],
"compilerOptions": {
"isolatedModules": false,
"target": "es5",
"lib": ["es5", "dom"],
"types": ["cypress"]
}
}

View file

@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View file

@ -0,0 +1,39 @@
/// <reference types="cypress" />
// ***********************************************
// This example commands.ts shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
// declare global {
// namespace Cypress {
// interface Chainable {
// login(email: string, password: string): Chainable<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }
export {}

View file

@ -0,0 +1,20 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')

1
Services/client/env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ระบบทรัพยากรบุคคล</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

10255
Services/client/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,59 @@
{
"name": "ehr_portfolio",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "run-p type-check build-only",
"preview": "vite preview",
"test:unit": "vitest --environment jsdom --root src/",
"test:e2e": "start-server-and-test preview :4173 'cypress run --e2e'",
"test:e2e:dev": "start-server-and-test 'vite dev --port 4173' :4173 'cypress open --e2e'",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier . --write"
},
"dependencies": {
"@fullcalendar/core": "^6.1.8",
"@fullcalendar/daygrid": "^6.1.8",
"@fullcalendar/interaction": "^6.1.8",
"@fullcalendar/list": "^6.1.8",
"@fullcalendar/timegrid": "^6.1.8",
"@fullcalendar/vue3": "^6.1.8",
"@quasar/extras": "^1.15.8",
"@vuepic/vue-datepicker": "^5.2.1",
"axios": "^1.6.2",
"keycloak-js": "^22.0.5",
"pinia": "^2.1.4",
"quasar": "^2.11.1",
"vite-plugin-pwa": "^0.16.7",
"vue": "^3.2.45",
"vue-router": "^4.1.6"
},
"devDependencies": {
"@quasar/vite-plugin": "^1.6.0",
"@rushstack/eslint-patch": "^1.1.4",
"@types/jsdom": "^20.0.1",
"@types/node": "^18.11.12",
"@vitejs/plugin-vue": "^4.0.0",
"@vitejs/plugin-vue-jsx": "^3.0.0",
"@vue/eslint-config-prettier": "^7.0.0",
"@vue/eslint-config-typescript": "^11.0.0",
"@vue/test-utils": "^2.2.6",
"@vue/tsconfig": "^0.1.3",
"cypress": "^12.0.2",
"eslint": "^8.22.0",
"eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-vue": "^9.3.0",
"jsdom": "^20.0.3",
"npm-run-all": "^4.1.5",
"prettier": "^2.7.1",
"sass": "^1.32.12",
"start-server-and-test": "^1.15.2",
"typescript": "~4.7.4",
"vite": "^4.0.0",
"vitest": "^0.25.6",
"vue-tsc": "^1.0.12"
}
}

6056
Services/client/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -0,0 +1,27 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_175_1580)">
<rect width="256" height="256" rx="64" fill="url(#paint0_linear_175_1580)"/>
<path d="M90.5827 178.585C76.9239 169.784 66.7599 156.498 61.84 141.012C56.9201 125.527 57.552 108.81 63.6269 93.7405L77.2723 99.2411C72.4768 111.137 71.978 124.333 75.8618 136.558C79.7455 148.782 87.769 159.27 98.5512 166.217L90.5827 178.585Z" fill="white"/>
<path d="M72.5857 77.1982C81.2389 65.9773 93.0997 57.6531 106.594 53.3302L111.082 67.3413C100.43 70.7537 91.0671 77.3249 84.2362 86.1827L72.5857 77.1982Z" fill="white"/>
<path d="M125.368 50.0667C138.482 49.4932 151.493 52.6262 162.908 59.1068C174.324 65.5874 183.683 75.1533 189.912 86.708C196.142 98.2628 198.989 111.339 198.129 124.438C197.269 137.536 192.735 150.128 185.048 160.769L173.124 152.154C179.192 143.754 182.771 133.814 183.45 123.474C184.13 113.133 181.881 102.81 176.964 93.6887C172.046 84.5671 164.658 77.0155 155.646 71.8995C146.634 66.7836 136.363 64.3102 126.01 64.763L125.368 50.0667Z" fill="white"/>
<path d="M158.578 176.695C171.315 177.787 182.226 180.102 189.347 183.223C196.468 186.344 199.332 190.066 197.423 193.719C195.515 197.372 188.959 200.716 178.935 203.151C168.911 205.585 156.078 206.95 142.743 206.999C129.409 207.048 116.449 205.778 106.196 203.418C95.9419 201.058 89.0674 197.763 86.8089 194.125C84.5505 190.487 87.0563 186.746 93.8755 183.574C100.695 180.402 111.38 178.008 124.008 176.822L130.801 182.364C122.941 183.102 116.291 184.592 112.046 186.567C107.802 188.541 106.242 190.87 107.648 193.134C109.053 195.398 113.332 197.449 119.714 198.918C126.097 200.387 134.163 201.177 142.463 201.147C150.762 201.116 158.75 200.267 164.989 198.752C171.228 197.236 175.309 195.155 176.497 192.881C177.685 190.607 175.903 188.291 171.47 186.348C167.038 184.406 160.247 182.965 152.318 182.285L158.578 176.695Z" fill="white"/>
<path d="M129.445 119.061L143.1 117.268L158.957 122.199L160.279 141.923L141.779 182.268L123.279 135.647L129.445 119.061Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M162.771 141.484C163.95 138.254 164.311 134.797 163.822 131.404C163.333 128.004 162.006 124.77 159.955 121.973C157.903 119.177 155.187 116.901 152.036 115.34C148.886 113.778 145.393 112.975 141.855 113.001C138.317 113.026 134.837 113.878 131.71 115.485C128.583 117.091 125.902 119.405 123.893 122.231C121.884 125.056 120.607 128.309 120.169 131.715C119.731 135.121 120.147 138.579 121.379 141.796L121.397 141.79L139.233 182.527C139.382 183.464 139.941 184.333 140.8 184.806C142.182 185.568 143.815 185.014 144.448 183.568L162.561 142.196C162.662 141.966 162.731 141.726 162.771 141.484ZM157.71 139.138C158.44 136.883 158.646 134.496 158.308 132.15C157.942 129.611 156.951 127.194 155.418 125.105C153.886 123.016 151.856 121.316 149.502 120.149C147.149 118.982 144.539 118.382 141.896 118.401C139.252 118.42 136.653 119.057 134.317 120.257C131.981 121.457 129.977 123.186 128.476 125.297C126.975 127.408 126.021 129.838 125.694 132.383C125.43 134.434 125.581 136.511 126.132 138.499C126.223 138.635 126.304 138.782 126.374 138.94L142.074 174.801L157.557 139.438C157.603 139.334 157.654 139.234 157.71 139.138Z" fill="white"/>
<path d="M154.279 134.268C154.279 140.895 148.906 146.268 142.279 146.268C135.651 146.268 130.279 140.895 130.279 134.268C130.279 127.64 135.651 122.268 142.279 122.268C148.906 122.268 154.279 127.64 154.279 134.268ZM135.881 134.268C135.881 137.801 138.746 140.665 142.279 140.665C145.812 140.665 148.676 137.801 148.676 134.268C148.676 130.734 145.812 127.87 142.279 127.87C138.746 127.87 135.881 130.734 135.881 134.268Z" fill="#22C2B2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M125 113.782C127.924 111.387 131.308 109.533 135 108.373L135 73H125V113.782Z" fill="white"/>
<mask id="path-10-inside-1_175_1580" fill="white">
<path fill-rule="evenodd" clip-rule="evenodd" d="M115.81 129.049L95.9287 148.93L103 156.001L115.856 143.145C115.296 140.856 115 138.463 115 136.002C115 133.608 115.28 131.28 115.81 129.049Z"/>
</mask>
<path fill-rule="evenodd" clip-rule="evenodd" d="M115.81 129.049L95.9287 148.93L103 156.001L115.856 143.145C115.296 140.856 115 138.463 115 136.002C115 133.608 115.28 131.28 115.81 129.049Z" fill="white"/>
<path d="M95.9287 148.93L92.3932 145.394L88.8576 148.93L92.3932 152.465L95.9287 148.93ZM115.81 129.049L120.675 130.203L124.748 113.04L112.274 125.513L115.81 129.049ZM103 156.001L99.4642 159.537L103 163.072L106.535 159.537L103 156.001ZM115.856 143.145L119.391 146.681L121.38 144.691L120.713 141.959L115.856 143.145ZM99.4642 152.465L119.345 132.584L112.274 125.513L92.3932 145.394L99.4642 152.465ZM106.535 152.465L99.4642 145.394L92.3932 152.465L99.4642 159.537L106.535 152.465ZM112.32 139.61L99.4642 152.465L106.535 159.537L119.391 146.681L112.32 139.61ZM120.713 141.959C120.248 140.055 120 138.061 120 136.002H110C110 138.866 110.345 141.657 110.998 144.332L120.713 141.959ZM120 136.002C120 134 120.234 132.059 120.675 130.203L110.945 127.894C110.326 130.501 110 133.217 110 136.002H120Z" fill="white" mask="url(#path-10-inside-1_175_1580)"/>
</g>
<defs>
<linearGradient id="paint0_linear_175_1580" x1="134.047" y1="3.33166e-08" x2="133.047" y2="256" gradientUnits="userSpaceOnUse">
<stop stop-color="#4CE2D3"/>
<stop offset="1" stop-color="#02A998"/>
</linearGradient>
<clipPath id="clip0_175_1580">
<rect width="256" height="256" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

View file

@ -0,0 +1,8 @@
{
"realm": "vue",
"auth-server-url": "https://edm-id.frappet.synology.me/",
"ssl-required": "external",
"resource": "vuejs",
"public-client": true,
"confidential-port": 0
}

View file

@ -0,0 +1,2 @@
User-agent: *
Disallow:

View file

@ -0,0 +1,13 @@
<script setup lang="ts"></script>
<template>
<div id="azay-admin-app">
<router-view v-slot="{ Component }">
<transition>
<component :is="Component" />
</transition>
</router-view>
</div>
</template>
<style scoped></style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View file

@ -0,0 +1,219 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia'
import FileItemAction from '@/components/FileItemAction.vue'
import { useTreeDataStore } from '@/stores/tree-data'
const DEPT_NAME = ['ตู้เอกสาร', 'ลิ้นชัก', 'แฟ้ม', 'แฟ้มย่อย']
const { currentFolder, currentFile, currentDept } = storeToRefs(
useTreeDataStore()
)
const { getFolder, createFolder, editFolder } = useTreeDataStore()
const drawer = ref<boolean>(false)
const drawerStatus = ref<'edit' | 'create'>('create')
const input = ref<string>('')
const editPathname = ref<string>('')
const currentIcon = computed(() =>
currentDept.value === 0
? 'mdi-file-cabinet'
: currentDept.value === 1
? 'inbox'
: 'o_folder_open'
)
const props = withDefaults(
defineProps<{ action: boolean; viewMode: 'view_list' | 'view_module' }>(),
{
action: false,
}
)
</script>
<template>
<div class="q-mt-md">
<div class="q-gutter-md">
<div
:key="value.name"
v-for="value in currentFolder"
class="inline-block"
>
<div class="box border-radius-inherit">
<q-card flat @click="() => getFolder(value.pathname)">
<q-card-section class="column justify-center relative q-px-xl">
<q-icon :name="currentIcon" size="6em" color="primary" />
<div
class="absolute"
style="top: 0.5rem; right: 0.5rem"
v-if="props.action"
>
<file-item-action
:editname="value.name"
:pathname="value.pathname"
@editname="
() => {
drawer = !drawer
drawerStatus = 'edit'
editPathname = value.pathname
}
"
/>
</div>
<span class="text-center q-pt-md">{{ value.name }}</span>
</q-card-section>
</q-card>
</div>
</div>
<div class="inline-block" v-if="props.action && currentDept < 4">
<div class="dashed border-radius-inherit">
<q-card
flat
@click="
() => {
drawer = !drawer
drawerStatus = 'create'
}
"
>
<q-card-section class="column justify-center relative q-px-xl">
<q-icon
:name="currentIcon"
class="add-icon"
size="6em"
color="primary"
/>
<q-btn round class="add-button" color="white" size="10px">
<q-icon name="add" color="primary" size="1.5rem"></q-icon>
</q-btn>
<span class="text-center q-pt-md"
>สราง{{ DEPT_NAME[currentDept] }}ใหม</span
>
</q-card-section>
</q-card>
</div>
</div>
</div>
</div>
<div class="q-mt-md">
<div class="q-gutter-md">
<div v-for="value in currentFile" :key="value.title" class="inline-block">
<div class="box border-radius-inherit">
<q-card flat @click="">
<q-card-section class="column justify-center relative q-px-xl">
<q-icon name="description" size="6em" color="primary" />
<div
class="absolute"
style="top: 0.5rem; right: 0.5rem"
v-if="props.action"
>
<!-- TODO: Edit file data -->
</div>
<span class="text-center q-pt-md">{{ value.title }}</span>
</q-card-section>
</q-card>
</div>
</div>
<div class="inline-block" v-if="props.action && currentDept > 2">
<div class="dashed border-radius-inherit">
<q-card flat @click="() => (drawer = !drawer)">
<q-card-section class="column justify-center relative q-px-xl">
<q-icon
name="description"
class="add-icon"
size="6em"
color="primary"
/>
<q-btn round class="add-button" color="white" size="10px">
<q-icon name="add" color="primary" size="1.5rem"></q-icon>
</q-btn>
<span class="text-center q-pt-md">สรางไฟลใหม</span>
</q-card-section>
</q-card>
</div>
</div>
</div>
</div>
<q-drawer
class="q-pa-md"
side="right"
v-model="drawer"
bordered
:width="300"
:breakpoint="500"
overlay
>
<q-toolbar class="q-mb-md q-pa-none">
<q-toolbar-title>
<span class="text-weight-bold" v-if="drawerStatus == 'edit'"
>แกไขช{{ DEPT_NAME[currentDept] }}</span
>
<span class="text-weight-bold" v-if="drawerStatus == 'create'"
>สราง{{ DEPT_NAME[currentDept] }}</span
>
</q-toolbar-title>
<q-btn
flat
round
dense
icon="close"
v-close-popup
color="red"
@click="drawer = !drawer"
/>
</q-toolbar>
<span class="text-weight-bold">{{ DEPT_NAME[currentDept] }}</span>
<q-input
class="q-my-md"
outlined
v-model="input"
:placeholder="`กรอกชื่อ${DEPT_NAME[currentDept]}`"
dense
/>
<q-btn
class="q-px-md"
label="บันทึก"
type="submit"
color="primary"
dense
@click="
() => {
drawer = !drawer
drawerStatus === 'create'
? createFolder(input)
: editFolder(input, editPathname)
input = ''
}
"
/>
</q-drawer>
</template>
<style lang="scss" scoped>
.box {
display: inline-block;
border: 2px solid #f1f2f4;
border-radius: 8px;
cursor: pointer;
}
.dashed {
opacity: 0.4;
display: inline-block;
border: 2px solid #babdc3;
border-radius: 8px;
cursor: pointer;
border-style: dashed;
}
.add-icon {
margin: auto auto;
}
.add-button {
position: absolute;
top: 75px;
right: 45px;
background-color: white;
}
</style>

View file

@ -0,0 +1,92 @@
<script lang="ts" setup>
import { useTreeDataStore } from '@/stores/tree-data'
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
const { currentDept } = storeToRefs(useTreeDataStore())
const { deleteFolder, editFolder } = useTreeDataStore()
defineProps<{
pathname: string
editname: string
}>()
defineEmits([
'editname',
'deletename',
])
const editdrawer = ref(false)
const DEPT_NAME = ['ตู้เอกสาร', 'ลิ้นชัก', 'แฟ้ม', 'แฟ้มย่อย']
const editInput = ref<string>()
</script>
<template>
<q-btn @click.stop icon="more_vert" color="grey" flat dense>
<q-menu auto-close>
<q-list dense>
<q-item clickable>
<q-item-section @click="() => (
$emit('editname')
)">
<div class="row items-center">
<q-icon name="edit" color="positive" />
<span class="q-ml-sm">แกไข</span>
</div>
</q-item-section>
</q-item>
<q-item clickable>
<q-item-section @click="() => deleteFolder(pathname)">
<div class="row items-center">
<q-icon name="delete" color="negative" />
<span class="q-ml-sm">ลบ</span>
</div>
</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
<!-- <q-drawer
class="q-pa-md"
side="left"
v-model="editdrawer"
bordered
:width="300"
:breakpoint="500"
overlay
>
<q-toolbar class="q-mb-md q-pa-none">
<q-toolbar-title>
<span class="text-weight-bold">แกไขช{{ DEPT_NAME[currentDept] }}</span>
</q-toolbar-title>
<q-btn
flat
round
dense
icon="close"
v-close-popup
color="red"
@click="editdrawer = !editdrawer"
/>
</q-toolbar>
<span class="text-weight-bold">{{ DEPT_NAME[currentDept] }}</span>
<q-input
class="q-my-md"
outlined
v-model="editInput"
:placeholder="`กรอกชื่อ${DEPT_NAME[currentDept]}`"
dense
/>
<q-btn
class="q-px-md"
label="บันทึก"
type="submit"
color="primary"
dense
@click="
() => {
editdrawer = !editdrawer
editFolder(editname,editInput)
editInput = ''
}
"
/>
</q-drawer> -->
</template>

View file

@ -0,0 +1,36 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useSearchDataStore } from '@/stores/searched-data'
const { foundFile } = storeToRefs(useSearchDataStore())
</script>
<template>
<div class="q-mt-md" v-if="foundFile.length > 0">
<div class="q-gutter-md">
<div v-for="value in foundFile" :key="value.title" class="inline-block">
<div class="box border-radius-inherit">
<q-card flat @click="">
<q-card-section class="column justify-center relative q-px-xl">
<q-icon name="description" size="6em" color="primary" />
<span class="text-center q-pt-md">{{ value.title }}</span>
</q-card-section>
</q-card>
</div>
</div>
</div>
</div>
<div class="q-mt-md" v-if="foundFile.length == 0">
<span>ไมพบรายการทนหา</span>
</div>
</template>
<style scoped lang="scss">
.box {
display: inline-block;
border: 2px solid #f1f2f4;
border-radius: 8px;
cursor: pointer;
}
</style>

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
import { watch, ref } from 'vue'
const props = defineProps({
visibility: Boolean,
})
const loaderVisibility = ref<boolean>(props.visibility)
watch(props, () => (loaderVisibility.value = props.visibility))
</script>
<template>
<q-inner-loading :showing="loaderVisibility" class="loader">
<q-spinner-cube size="80px" color="primary" />
</q-inner-loading>
</template>
<style lang="sass">
.loader
z-index: 1000
</style>

View file

@ -0,0 +1,122 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useTreeDataStore } from '@/stores/tree-data'
import { useSearchDataStore } from '@/stores/searched-data'
import FileItem from '@/components/FileItem.vue'
import TreeExplorer from '@/components/TreeExplorer.vue'
import SearchBar from '@/modules/01_user/components/SearchBar.vue'
import FileSearched from '@/components/FileSearched.vue'
const DEPT_NAME = ['ตู้เอกสาร', 'ลิ้นชัก', 'แฟ้ม', 'แฟ้มย่อย', 'ไฟล์']
const { isSearch } = storeToRefs(useSearchDataStore())
const { data, currentDept } = storeToRefs(useTreeDataStore())
const { getCabinet, gotoParent } = useTreeDataStore()
const viewMode = ref<'view_list' | 'view_module'>('view_list')
const inputSearch = ref<string>()
const props = defineProps<{
mode: 'admin' | 'user'
}>()
onMounted(getCabinet)
</script>
<template>
<section id="header" class="q-px-md q-pt-md q-pb-none">
<div class="q-my-md row items-center">
<div class="col">
<h5 class="q-my-none" v-if="mode === 'admin'">จัดเก็บเอกสาร</h5>
<h5 class="q-my-none" v-else>สืบค้นเอกสาร</h5>
</div>
<div class="col-3" v-if="mode === 'admin'">
<q-input
rounded
outlined
dense
label="ค้นหา"
bg-color="white"
v-model="inputSearch"
>
<template v-slot:append><q-icon name="search" /></template>
</q-input>
</div>
</div>
</section>
<section
class="row q-col-gutter-md q-pa-md"
:class="{ reverse: props.mode === 'user' }"
id="cabinet"
>
<div class="col-12 col-md-3">
<div class="bg-white rounded-borders shadow-5">
<div
class="q-px-md q-py-sm text-primary bg-grey-1"
id="container-header"
>
<span class="block q-my-sm text-weight-bold">ดเกบเอกสาร</span>
</div>
<q-separator />
<div class="q-pa-md">
<tree-explorer :data="data" :level="1" />
</div>
</div>
</div>
<div class="col">
<!-- <file-download /> -->
<div class="bg-white rounded-borders shadow-5 relative">
<search-bar v-if="mode === 'user'" />
<div class="bg-white q-pa-md">
<div class="row items-center justify-between">
<span class="text-h6">
<q-btn
flat
dense
class="q-mr-sm q-px-sm"
v-if="currentDept > 0"
@click="() => gotoParent()"
>
<q-icon name="arrow_back" size="1rem" color="primary"
/></q-btn>
<span>{{ DEPT_NAME[currentDept] }}</span>
<q-btn
v-if="mode === 'admin' && viewMode === 'view_module'"
class="q-px-md q-ml-md"
label="สร้างตู้เก็บเอกสาร"
type="submit"
color="primary"
dense
icon="add"
@click=""
/>
</span>
<q-btn
flat
color="blue-grey-2"
:icon="viewMode"
@click="
() => {
viewMode =
viewMode === 'view_list' ? 'view_module' : 'view_list'
}
"
/>
</div>
<div>
<file-searched v-if="isSearch === true" />
<file-item
:viewMode="viewMode"
:action="props.mode === 'admin'"
v-if="isSearch === false"
/>
</div>
</div>
</div>
</div>
</section>
</template>
<style lang="scss" scoped>
.pointer {
cursor: pointer;
}
</style>

View file

@ -0,0 +1,62 @@
<script setup lang="ts">
import { ref } from 'vue'
import KeyCloakService from '@/services/KeyCloakService'
const dropdownOpen = ref<boolean>(false)
const accountName = ref<string>()
accountName.value = KeyCloakService.GetUserName()
</script>
<template>
<div
@click="() => (dropdownOpen = !dropdownOpen)"
class="row q-px-md cursor"
id="app-toolbar-title"
>
<div class="col">
<q-avatar>
<img :src="`https://cdn.quasar.dev/img/avatar1.jpg`" />
</q-avatar>
</div>
<div class="cow">
<div class="row q-pl-sm">
<span class="text-body1">
{{ accountName }}
</span>
</div>
<div class="row q-pl-sm">
<span class="text-caption text-grey"> เจาหนาท </span>
</div>
</div>
</div>
<q-btn-dropdown stretch flat v-model="dropdownOpen">
<q-list>
<q-item
clickable
v-close-popup
tabindex="0"
@click="
() => {
KeyCloakService.CallLogOut()
}
"
>
<q-item-section avatar>
<q-avatar icon="logout" color="primary" text-color="white" caption>
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>Logout</q-item-label>
</q-item-section>
</q-item>
<q-separator inset spaced></q-separator>
</q-list>
</q-btn-dropdown>
</template>
<style lang="scss" scoped>
.cursor {
cursor: pointer;
}
</style>

View file

@ -0,0 +1,52 @@
<script setup lang="ts">
import { useTreeDataStore, type TreeDataFolder } from '@/stores/tree-data'
const { getFolder } = useTreeDataStore()
const props = withDefaults(
defineProps<{
data: TreeDataFolder[]
level: number
}>(),
{
level: 0,
}
)
</script>
<template>
<div>
<q-list v-for="folder in data" class="rounded-borders">
<q-expansion-item
@click="() => getFolder(folder.pathname, false)"
:header-inset-level="level * 0.25"
:group="level.toString()"
:hide-expand-icon="level === 4"
:icon="
level === 1
? 'mdi-file-cabinet'
: level === 2
? 'inbox'
: 'o_folder_open'
"
:label="folder.name"
v-model="folder.status"
>
<tree-explorer
v-if="folder.folder"
:data="folder.folder"
:level="props.level + 1"
/>
</q-expansion-item>
</q-list>
</div>
</template>
<style lang="scss">
.q-item[aria-expanded='true'] {
color: $primary;
}
.q-item[aria-expanded='true'] .q-icon {
color: $primary;
}
</style>

View file

@ -0,0 +1,44 @@
import { createApp, defineAsyncComponent } from 'vue'
import { createPinia } from 'pinia'
import { Dialog, Quasar, Loading } from 'quasar'
import th from 'quasar/lang/th'
import App from './App.vue'
import HttpService from '@/services/HttpService'
import quasarUserOptions from './quasar-user-options'
import router from './router'
import 'quasar/src/css/index.sass'
import '@vuepic/vue-datepicker/dist/main.css'
const app = createApp(App)
const pinia = createPinia()
app.use(router)
app.use(pinia)
app.use(Quasar, {
...quasarUserOptions,
plugins: {
Dialog,
Loading,
},
lang: th,
})
app.component(
'full-loader',
defineAsyncComponent(() => import('@/components/FullLoader.vue'))
)
app.component(
'datepicker',
defineAsyncComponent(() => import('@vuepic/vue-datepicker'))
)
app.mount('#app')
HttpService.configureAxiosKeycloak()
console.log(import.meta.env)

View file

@ -0,0 +1,220 @@
<script setup lang="ts">
import { ref } from 'vue'
const isAdvncSearchClick = ref<boolean>(false)
const optionsField = [
{ label: 'ชื่อเรื่อง (title)', value: 'title' },
{ label: 'คำสำคัญ (keyword)', value: 'keyword' },
]
const optionsOp = [
{ label: 'และ', value: 'AND' },
{ label: 'หรือ', value: 'OR' },
]
const advSearchData = ref<
{
op: 'AND' | 'OR'
field: 'title' | 'keyword'
value: string
}[]
>([])
const props = defineProps<{
field: string
value: string
keyword?: string
description?: string
searchSubmit: Function
submitSearchData: {
AND: {
field?: string
value?: string
}[]
OR: {
field: string
value: string
}[]
}
}>()
defineExpose({
clearAdvData,
advSearchData,
})
defineEmits([
'update:field',
'update:value',
'update:keyword',
'update:description',
])
function addSearchData() {
advSearchData.value.push({
op: 'AND',
field: 'title',
value: '',
})
}
function clearAdvData() {
advSearchData.value = []
}
</script>
<template>
<div>
<q-btn
outline
color="primary"
icon="mdi-tools"
label="ค้นหาขั้นสูง"
@click="() => (isAdvncSearchClick = true)"
/>
</div>
<q-dialog v-model="isAdvncSearchClick">
<q-card style="width: 1000px; max-width: 80vw">
<q-toolbar class="bg-grey-1 q-py-sm q-px-lg">
<q-toolbar-title>
<span class="text-weight-bold">นหาขนส</span>
</q-toolbar-title>
<q-btn flat round dense icon="close" v-close-popup color="red" />
</q-toolbar>
<q-separator />
<div class="col">
<q-card-section class="row q-mt-sm">
<div class="col">
<div class="row q-col-gutter-md q-mb-md items-center">
<div class="col-12 col-md-3">
<q-select
dense
outlined
emit-value
map-options
:options="optionsField"
:model-value="props.field"
@update:model-value="(value) => $emit('update:field', value)"
/>
</div>
<div class="col-12 col-md-8">
<q-input
dense
outlined
placeholder="เอกสาร"
:model-value="props.value"
@update:model-value="(value) => $emit('update:value', value)"
/>
</div>
<div class="col-1">
<q-btn
dense
color="teal"
icon="mdi-plus"
v-if="advSearchData.length === 0"
@click="addSearchData"
/>
</div>
</div>
<div
class="row q-col-gutter-md q-mb-md items-center"
v-for="(_, index) in advSearchData"
>
<div class="col-4 col-md-2">
<q-select
dense
outlined
emit-value
map-options
v-model="advSearchData[index].op"
:options="optionsOp"
/>
</div>
<div class="col-8 col-md-3">
<q-select
dense
outlined
emit-value
map-options
v-model="advSearchData[index].field"
:options="optionsField"
/>
</div>
<div class="col-12 col-md-6">
<q-input
dense
outlined
v-model="advSearchData[index].value"
placeholder="เอกสาร"
/>
</div>
<div class="col-1">
<q-btn
dense
color="teal"
icon="mdi-plus"
v-if="index === advSearchData.length - 1"
@click="addSearchData"
/>
<q-btn
dense
flat
icon="mdi-minus"
color="red"
v-if="index === advSearchData.length - 1"
@click="() => advSearchData.pop()"
/>
</div>
</div>
</div>
</q-card-section>
<q-card-section class="row q-mb-md">
<div class="col">
<span> </span>
<q-separator class="q-mb-lg" />
<div class="row q-col-gutter-md">
<div class="col-12 col-md-3 q-gutter-y-sm">
<span class="text-weight-bold">คำสำค</span>
<q-input
dense
outlined
placeholder="กรอกคำสำคัญ"
:model-value="props.keyword"
@update:model-value="
(value) => $emit('update:keyword', value)
"
/>
</div>
<div class="col-12 col-md-4 q-gutter-y-sm">
<span class="text-weight-bold">รายละเอยดของเอกสาร</span>
<q-input
dense
outlined
placeholder="กรอกรายละเอียด"
:model-value="props.description"
@update:model-value="
(value) => $emit('update:description', value)
"
/>
</div>
</div>
</div>
</q-card-section>
<q-separator />
<q-toolbar class="row justify-end">
<div class="q-py-md">
<q-btn
color="primary"
label="ค้นหา"
icon="mdi-magnify"
v-close-popup
@click="() => props.searchSubmit()"
/>
</div>
</q-toolbar>
</div>
</q-card>
</q-dialog>
</template>

View file

@ -0,0 +1,188 @@
<script setup lang="ts">
import axios from 'axios'
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import type { EhrFile } from '@/stores/tree-data'
import AdvancedSearch from '@/modules/01_user/components/AdvancedSearch.vue'
import { useSearchDataStore } from '@/stores/searched-data'
import { useLoader } from '@/stores/loader'
const loaderStore = useLoader()
const { isSearch } = storeToRefs(useSearchDataStore())
const { getFoundFile } = useSearchDataStore()
const advSearchComp = ref<InstanceType<typeof AdvancedSearch>>()
const isAdvSearchCall = ref<boolean>(false)
const optionsField = [
{ label: 'ชื่อเรื่อง (title)', value: 'title' },
{ label: 'คำสำคัญ (keyword)', value: 'keyword' },
]
const searchData = ref<{
field: string
value: string
keyword?: string
description?: string
}>({
field: 'title',
value: '',
keyword: '',
description: '',
})
const submitSearchData = ref<{
AND: {
field?: string
value?: string
}[]
OR: { field: string; value: string }[]
}>({
AND: [],
OR: [],
})
function clearSearchData() {
searchData.value = {
field: 'title',
value: '',
keyword: '',
description: '',
}
submitSearchData.value = {
AND: [],
OR: [],
}
advSearchComp.value?.clearAdvData()
isAdvSearchCall.value = false
isSearch.value = false
}
async function searchSubmit() {
submitSearchData.value = {
AND: [],
OR: [],
}
if (searchData.value.field && searchData.value) {
submitSearchData.value?.AND.push({
field: searchData.value.field,
value: searchData.value.value,
})
if (searchData.value.keyword) {
submitSearchData.value?.AND.push({
field: 'keyword',
value: searchData.value.keyword,
})
}
if (searchData.value.description) {
submitSearchData.value?.AND.push({
field: 'description',
value: searchData.value.description,
})
}
}
if (isAdvSearchCall.value) {
if (
advSearchComp.value?.advSearchData &&
advSearchComp.value.advSearchData.length > 0
) {
advSearchComp.value?.advSearchData.forEach(
(d: { field: string; value: string; op: string }) => {
if (d.field && d.value) {
if (d.op === 'AND') {
submitSearchData.value.AND.push({
field: d.field,
value: d.value,
})
} else if (d.op === 'OR') {
submitSearchData.value.OR.push({ field: d.field, value: d.value })
}
}
}
)
}
}
try {
loaderStore.show()
isAdvSearchCall.value = true
const res = await axios.post<EhrFile[]>(
`${import.meta.env.VITE_API_ENDPOINT}/search`,
submitSearchData.value
)
getFoundFile(res.data)
isSearch.value = true
} catch (error) {
console.error('Error during the request', error)
} finally {
loaderStore.hide()
}
}
</script>
<template>
<div class="q-py-lg q-px-md bg-grey-1">
<div class="row items-center q-gutter-md">
<div class="col-12 col-md-3">
<q-select
dense
outlined
emit-value
map-options
class="bg-white"
v-model="searchData.field"
:options="optionsField"
/>
</div>
<div class="col-12 col-md-grow">
<q-input
class="bg-white"
dense
outlined
v-model="searchData.value"
placeholder="เอกสาร"
@keydown.enter.prevent="searchSubmit"
/>
</div>
<div v-if="isAdvSearchCall === false">
<q-btn
class="col-12 col-md-2"
color="primary"
label="ค้นหา"
icon="mdi-magnify"
@click="() => searchSubmit()"
/>
</div>
</div>
<div
v-if="isAdvSearchCall"
class="row items-center justify-between q-gutter-y-md q-mt-xs"
>
<div>
<advanced-search
ref="advSearchComp"
:searchSubmit="searchSubmit"
:submit-search-data="submitSearchData"
v-model:field="searchData.field"
v-model:value="searchData.value"
v-model:keyword="searchData.keyword"
v-model:description="searchData.description"
/>
</div>
<div class="q-gutter-x-md">
<q-btn
color="primary"
label="ค้นหา"
icon="mdi-magnify"
@click="searchSubmit"
/>
<q-btn
color="orange-12"
label="ล้างข้อมูล"
icon="close"
@click="clearSearchData"
/>
</div>
</div>
</div>
<q-separator />
</template>

View file

@ -0,0 +1,9 @@
// const adminHomePage = () => import('@/modules/02_transfer/views/Main.vue')
export default [
{
path: '/',
name: 'UserHomePage',
component: () => import('@/modules/01_user/views/homePage.vue')
}
]

View file

@ -0,0 +1,8 @@
<script setup lang="ts">
import PageLayout from '@/components/PageLayout.vue'
</script>
<template>
<page-layout mode="user" />
</template>
<style scoped></style>

View file

@ -0,0 +1,9 @@
// const adminHomePage = () => import('@/modules/02_transfer/views/Main.vue')
export default [
{
path: '/admin',
name: 'AdminHomePage',
component: () => import('@/modules/02_admin/views/homePage.vue')
}
]

View file

@ -0,0 +1,18 @@
<script setup lang="ts">
import PageLayout from '@/components/PageLayout.vue'
</script>
<template>
<page-layout mode="admin" />
</template>
<style scoped lang="scss">
#container > [class^='col'] > * {
border: 1px solid $separator-color;
overflow: hidden;
}
#container-header {
background-color: #fafafa;
}
</style>

View file

@ -0,0 +1,10 @@
import '@quasar/extras/material-icons/material-icons.css'
import '@quasar/extras/material-icons-outlined/material-icons-outlined.css'
import '@quasar/extras/fontawesome-v5/fontawesome-v5.css'
import '@quasar/extras/mdi-v4/mdi-v4.css'
// To be used on app.use(Quasar, { ... })
export default {
config: {},
plugins: {},
}

View file

@ -0,0 +1,55 @@
import { createRouter, createWebHistory } from 'vue-router'
import UserModule from '@/modules/01_user/router'
import AdminModule from '@/modules/02_admin/router'
import KeyCloakService from '@/services/KeyCloakService'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'UserModule',
component: () => import('@/views/MainLayout.vue'),
children: [...UserModule],
meta: {
statusAccount: false,
},
},
{
path: '/admin',
name: 'AdminModule',
component: () => import('@/views/MainLayout.vue'),
beforeEnter: (_to, _from, next) => {
const token = KeyCloakService.GetAccesToken()
if (token) {
next()
} else {
KeyCloakService.CallLogin(() => {
const tokenAfterLogin = KeyCloakService.GetAccesToken()
if (tokenAfterLogin) {
next()
} else {
console.error('ไม่สามารถดึง Token หลังจากล็อกอินได้')
next('/')
}
})
}
},
meta: {
statusAccount: true,
},
children: [...AdminModule],
},
/**
* 404 Not Found
* ref: https://router.vuejs.org/guide/essentials/dynamic-matching.html#catch-all-404-not-found-route
*/
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/ErrorNotFoundPage.vue'),
},
],
})
export default router

View file

@ -0,0 +1,12 @@
/**
***** DEPRECATED - Must be delete later *****
*/
/** async/await
* @param view "ชี่อไฟล์".vue
* @param folder "folderในsrc" [Ex. /src/"folder"] default=views
*/
export function load(view: string, folder: string = 'views') {
// console.log(`@/${folder}/${view}.vue`);
return async () => await import(`@/${folder}/${view}.vue`)
}

View file

@ -0,0 +1,37 @@
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'
import axios from 'axios'
import KeyCloakService from '@/services/KeyCloakService'
const HttpMethods = {
GET: 'GET',
POST: 'POST',
DELETE: 'DELETE',
}
const _axios = axios.create()
const cb = (config: InternalAxiosRequestConfig) => {
config.headers.Authorization = `Bearer ${KeyCloakService.GetAccesToken()}`
console.log(config.headers)
return config
}
const configureAxiosKeycloak = (): void => {
_axios.interceptors.request.use(
(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
if (KeyCloakService.IsLoggedIn()) {
KeyCloakService.UpdateToken(cb(config))
}
return config
}
)
}
const getAxiosClient = (): AxiosInstance => _axios
const HttpService = {
HttpMethods,
configureAxiosKeycloak,
getAxiosClient,
}
export default HttpService

View file

@ -0,0 +1,71 @@
import Keycloak from "keycloak-js";
const keycloakInstance = new Keycloak();
interface CallbackOneParam<T1 = void, T2 = void> {
(param1: T1): T2;
}
/**
* Initializes Keycloak instance and calls the provided callback function if successfully authenticated.
*
* @param onAuthenticatedCallback
*/
const Login = (onAuthenticatedCallback: CallbackOneParam): void => {
keycloakInstance
.init({ onLoad: "login-required" })
.then(function (authenticated) {
authenticated ? onAuthenticatedCallback() : alert("non authenticated");
})
.catch((e) => {
console.dir(e);
console.log(`keycloak init exception: ${e}`);
});
};
const UserName = (): string | undefined =>
keycloakInstance?.tokenParsed?.preferred_username;
const Token = (): string | undefined => keycloakInstance?.token;
const IdToken = (): string | undefined => keycloakInstance?.idToken;
const LogOut = () => keycloakInstance.logout();
/*
const UserRoles = (): string[] | undefined => {
if (keycloakInstance.resourceAccess === undefined) return undefined;
if (keycloakInstance.resourceAccess["express-client"] === undefined) return undefined;
return keycloakInstance.resourceAccess["express-client"].roles;
};
*/
const UserRoles = ():string[] =>{
return DecodeToken()?.role
}
const updateToken = (successCallback: any) =>
keycloakInstance.updateToken(5).then(successCallback).catch(doLogin);
const doLogin = keycloakInstance.login;
const isLoggedIn = () => !!keycloakInstance.token;
const DecodeToken = ()=>{return keycloakInstance.tokenParsed}
const DecodeIdToken = ()=>{return keycloakInstance.idTokenParsed}
const KeycloakService = {
CallLogin: Login,
GetUserName: UserName,
GetAccesToken: Token,
GetIdToken: IdToken,
CallLogOut: LogOut,
GetUserRoles: UserRoles,
UpdateToken: updateToken,
IsLoggedIn: isLoggedIn,
GetDecodeToken:DecodeToken,
GetDecodeIdToken:DecodeIdToken
};
export default KeycloakService;

10
Services/client/src/shims-vue.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
/* eslint-disable */
/// <reference types="vite/client" />
// Mocks all files ending in `.vue` showing them as plain Vue instances
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View file

@ -0,0 +1,20 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useLoader = defineStore('data', () => {
const loader = ref<boolean>(false)
function show() {
loader.value = true
}
function hide() {
loader.value = false
}
return {
loader,
show,
hide,
}
})

View file

@ -0,0 +1,18 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import type { EhrFile } from '@/stores/tree-data'
export const useSearchDataStore = defineStore('searched', () => {
const foundFile = ref<EhrFile[]>([])
const isSearch = ref<Boolean>(false)
async function getFoundFile(data: EhrFile[]) {
foundFile.value = data
}
return {
isSearch,
foundFile,
getFoundFile,
}
})

View file

@ -0,0 +1,246 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useLoader } from '@/stores/loader'
import HttpService from '@/services/HttpService'
const axiosClient = HttpService.getAxiosClient()
const apiEndpoint: string = import.meta.env.VITE_API_ENDPOINT
export interface EhrFolder {
pathname: string
name: string
createdAt: string
createdBy: string
}
export interface EhrFile {
pathname: string
fileName: string
fileSize: string
fileType: string
title: string
description: string
category: string[]
keyword: string[]
updatedAt: string
updatedBy: string
createdAt: string
createdBy: string
}
export interface TreeDataFolder {
pathname: string
name: string
status: boolean
folder: TreeDataFolder[]
file?: EhrFile[]
}
export const useTreeDataStore = defineStore('changeCabinet', () => {
const loader = useLoader()
const data = ref<TreeDataFolder[]>([])
const currentFolder = ref<TreeDataFolder[]>([])
const currentFile = ref<EhrFile[]>([])
const currentPath = ref<string>('')
const currentDept = ref<number>(0)
async function getCabinet() {
const res = await axiosClient.get<EhrFolder[]>(`${apiEndpoint}cabinet`)
data.value = currentFolder.value = res.data.map((v) => ({
...v,
status: false,
folder: [],
}))
}
async function getFolder(pathname: string, updateStatus = true) {
loader.show()
const pathArray: string[] = pathname.split('/').filter(Boolean)
currentDept.value = pathArray.length
if (pathArray.length >= 4) {
currentFolder.value = []
await getFile(pathname)
let current: (typeof data.value)[number] | undefined
current = data.value.find((v) => v.name === pathArray[0])
if (current && updateStatus) current.status = true
current = current?.folder.find((v) => v.name === pathArray[1])
if (current && updateStatus) current.status = true
current = current?.folder.find((v) => v.name === pathArray[2])
if (current && updateStatus) current.status = true
current = current?.folder.find((v) => v.name === pathArray[3])
if (current && updateStatus) current.status = true
return loader.hide()
}
let requestPath = 'cabinet'
if (pathArray.length >= 1) requestPath += `/${pathArray[0]}/drawer`
if (pathArray.length >= 2) requestPath += `/${pathArray[1]}/folder`
if (pathArray.length >= 3) requestPath += `/${pathArray[2]}/subfolder`
currentPath.value = pathname
const res = await axiosClient.get<EhrFolder[]>(
`${apiEndpoint}${requestPath}`
)
const list = res.data.map((v) => ({
...v,
status: false,
folder: [],
}))
let current: (typeof data.value)[number] | undefined
if (pathArray.length >= 1) {
current = data.value.find((v) => v.name === pathArray[0])
if (current && updateStatus) {
current.status = true
}
}
if (pathArray.length >= 2) {
current = current?.folder.find((v) => v.name === pathArray[1])
if (current && updateStatus) {
current.status = true
}
}
if (pathArray.length >= 3) {
current = current?.folder.find((v) => v.name === pathArray[2])
if (current && updateStatus) {
current.status = true
}
}
if (current) current.folder = list
currentFolder.value = list
await getFile(pathname)
return loader.hide()
}
async function getFile(pathname: string) {
loader.show()
const pathArray: string[] = pathname.split('/').filter(Boolean)
if (pathArray.length <= 2) {
currentFile.value = []
return loader.hide()
}
let requestPath = `cabinet/${pathArray[0]}/drawer/${pathArray[1]}`
if (pathArray.length >= 3) requestPath += `/folder/${pathArray[2]}`
if (pathArray.length >= 4) requestPath += `/subfolder/${pathArray[3]}`
requestPath += '/file'
const res = await axiosClient.get<EhrFile[]>(`${apiEndpoint}${requestPath}`)
currentFile.value = res.data
return loader.hide()
}
async function createFolder(name: string | undefined) {
loader.show()
if (!name) return loader.hide()
const pathArray: string[] = currentPath.value.split('/').filter(Boolean)
let requestPath = 'cabinet'
if (pathArray.length >= 1) requestPath += `/${pathArray[0]}/drawer`
if (pathArray.length >= 2) requestPath += `/${pathArray[1]}/folder`
if (pathArray.length >= 3) requestPath += `/${pathArray[2]}/subfolder`
await axiosClient.post(`${apiEndpoint}${requestPath}`, {
name,
})
if (currentDept.value === 0) await getCabinet()
else await getFolder(currentPath.value)
return loader.hide()
}
async function editFolder(name: string | undefined, pathname: string) {
loader.show()
if (!name) return loader.hide()
const pathArray: string[] = pathname.split('/').filter(Boolean)
let requestPath = 'cabinet'
if (pathArray.length >= 1) requestPath += `/${pathArray[0]}`
if (pathArray.length >= 2) requestPath += `/drawer/${pathArray[1]}`
if (pathArray.length >= 3) requestPath += `/folder/${pathArray[2]}`
if (pathArray.length >= 4) requestPath += `/subfolder/${pathArray[3]}`
await axiosClient.put(`${apiEndpoint}${requestPath}`, {
name: name,
})
if (currentDept.value === 0) await getCabinet()
else await getFolder(currentPath.value)
return loader.hide()
}
async function deleteFolder(pathname: string) {
loader.show()
const pathArray: string[] = pathname.split('/').filter(Boolean)
let requestPath = 'cabinet'
if (pathArray.length >= 1) requestPath += `/${pathArray[0]}`
if (pathArray.length >= 2) requestPath += `/drawer/${pathArray[1]}`
if (pathArray.length >= 3) requestPath += `/folder/${pathArray[2]}`
if (pathArray.length >= 4) requestPath += `/subfolder/${pathArray[3]}`
await axiosClient.delete(`${apiEndpoint}${requestPath}`)
if (currentDept.value === 0) await getCabinet()
else await getFolder(currentPath.value)
return loader.hide()
}
async function gotoParent() {
const pathname = currentPath.value.split('/').filter(Boolean)
pathname.pop()
if (pathname.length === 0) {
const cabinet = currentFolder.value.find((v) => v.status === true)
if (cabinet) {
cabinet.status = false
}
}
await getFolder(pathname.join('/') + '/')
}
return {
data,
currentFolder,
currentFile,
currentDept,
getCabinet,
getFolder,
gotoParent,
createFolder,
deleteFolder,
editFolder,
}
})

View file

@ -0,0 +1,24 @@
$primary: #2196f3
$secondary: #f7f9fa
$accent: #9C27B0
$dark: #35473C
$positive: #21BA45
$negative: #C10015
$info: #31CCEC
$warning: #F2C037
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+Thai:wght@100;200;300;400;500;600;700;800;900&display=swap')
$noto-thai: 'Noto Sans Thai', sans-serif
body
font-family: $noto-thai !important
text-rendering: optimizeLegibility
-webkit-font-smoothing: antialiased
-moz-osx-font-smoothing: grayscale
$separator-color: #EDEDED !default
$shadow-color: #c1c1c7 !default

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "Error404NotFound",
});
</script>
<template>
<div
class="fullscreen bg-secondary text-white text-center q-pa-md flex flex-center"
>
<div>
<div class="text-h1">ไมพบหนาทองการ</div>
<div class="text-h2">(404 Page Not Found)</div>
<q-btn
class="q-mt-xl"
color="white"
text-color="secondary"
unelevated
to="/"
label="กลับไปหน้าหลัก"
no-caps
/>
</div>
</div>
</template>

View file

@ -0,0 +1,44 @@
<script setup lang="ts">
import { useLoader } from '@/stores/loader'
import { storeToRefs } from 'pinia'
import profile from '@/components/Profile.vue'
const loaderStore = useLoader()
const { loader } = storeToRefs(loaderStore)
</script>
<template>
<q-layout view="hHh lpR fFf">
<q-header class="bg-white text-black" bordered>
<q-toolbar class="q-py-sm">
<q-img
src="@/assets/logo.png"
spinner-color="white"
style="height: 32px; max-width: 32px"
/>
<div class="column q-px-md" id="app-toolbar-title">
<span class="text-body1">ระบบทรพยากรบคคล</span>
<span class="text-caption text-grey">
ดเกบขอมลผลการประเม
</span>
</div>
<q-space></q-space>
<profile v-if="$route.meta.statusAccount" />
</q-toolbar>
</q-header>
<q-page-container>
<QPage>
<router-view :key="$route.fullPath" />
</QPage>
</q-page-container>
<full-loader :visibility="loader" />
</q-layout>
</template>
<style scoped>
.q-layout {
background: var(--q-secondary);
}
</style>

View file

@ -0,0 +1,13 @@
{
"extends": "@vue/tsconfig/tsconfig.web.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"lib": ["dom", "es2015", "es2018", "es2018.promise"]
}
}

View file

@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.node.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*"
],
"compilerOptions": {
"composite": true,
"ignoreDeprecations": "5.0",
"types": ["node"]
}
}

View file

@ -0,0 +1,14 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.config.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}

View file

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.app.json",
"exclude": [],
"compilerOptions": {
"composite": true,
"ignoreDeprecations": "5.0",
"lib": [],
"types": ["node", "jsdom"]
}
}

View file

@ -0,0 +1,60 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import { quasar, transformAssetUrls } from '@quasar/vite-plugin'
import { VitePWA } from 'vite-plugin-pwa'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue({
template: { transformAssetUrls },
}),
quasar({
sassVariables: 'src/style/quasar-variables.sass',
}),
vueJsx(),
VitePWA({
registerType: 'autoUpdate',
injectRegister: 'auto',
workbox: {
cleanupOutdatedCaches: true,
globPatterns: ['**/*.*'],
},
includeAssets: ['icons/safari-pinned-tab.svg'],
manifest: {
name: 'BMA EHR Portfolio',
short_name: 'EHR Portfolio',
theme_color: '#ffffff',
icons: [
{
src: 'icons/android-chrome-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: 'icons/android-chrome-512x512.png',
sizes: '512x512',
type: 'image/png',
},
{
src: 'icons/android-chrome-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: ['any', 'maskable'],
},
],
},
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 3010,
},
})