first commit

This commit is contained in:
Warunee Tamkoo 2023-12-20 10:04:43 +07:00
commit 288971d1d7
60 changed files with 1546 additions and 0 deletions

19
.eslintrc.js Normal file
View file

@ -0,0 +1,19 @@
module.exports = {
root: true,
env: {
node: true,
es2022: true,
},
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/typescript/recommended',
],
rules: {
// 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
// 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
'vue/no-mutating-props': 'off',
// '@typescript-eslint/no-explicit-any': 'off',
'vue/multi-word-component-names': 'off'
},
}

39
.github/workflows/build-local.yaml vendored Normal file
View file

@ -0,0 +1,39 @@
# use for local build with act
name: build-local
run-name: build-local ${{ github.actor }}
on:
workflow_dispatch:
env:
REGISTRY: docker.frappet.com
IMAGE_NAME: demo/bma-ehr-app
jobs:
# act workflow_dispatch -W .github/workflows/build-local.yaml --input IMAGE_VER=test-v1
build-local:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# skip Set up QEMU because it fail on act and container
- name: Gen Version
id: gen_ver
run: |
if [[ $GITHUB_REF == 'refs/tags/'* ]]; then
IMAGE_VER='${GITHUB_REF/refs\/tags\//}'
else
IMAGE_VER=${{ github.event.inputs.IMAGE_VER }}
fi
if [[ $IMAGE_VER == '' ]]; then
IMAGE_VER='test-vBeta'
fi
echo '::set-output name=image_ver::'$IMAGE_VER
- name: Test Version
run: |
echo $GITHUB_REF
echo ${{ steps.gen_ver.outputs.image_ver }}
- name: Build and load local docker image
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64
load: true
tags: ${{env.REGISTRY}}/${{env.IMAGE_NAME}}:${{ steps.gen_ver.outputs.image_ver }},${{env.REGISTRY}}/${{env.IMAGE_NAME}}:latest

86
.github/workflows/release.yaml vendored Normal file
View file

@ -0,0 +1,86 @@
name: release-test
run-name: release-test ${{ github.actor }}
on:
# push:
# tags:
# - 'v[0-9]+.[0-9]+.[0-9]+'
# tags-ignore:
# - '2.*'
# Allow run workflow manually from Action tab
workflow_dispatch:
env:
REGISTRY: docker.frappet.com
IMAGE_NAME: ehr/bma-ehr-checkin
DEPLOY_HOST: frappet.com
COMPOSE_PATH: /home/frappet/docker/bma-ehr-checkin
TOKEN_LINE: uxuK5hDzS2DsoC5piJBrWRLiz8GgY7iMZZldOWsDDF0
jobs:
# act workflow_dispatch -W .github/workflows/release.yaml --input IMAGE_VER=test-v1 -s DOCKER_USER=sorawit -s DOCKER_PASS=P@ssword -s SSH_PASSWORD=P@ssw0rd
release-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# skip Set up QEMU because it fail on act and container
# Gen Version try to get version from tag or inut
- name: Gen Version
id: gen_ver
run: |
if [[ $GITHUB_REF == 'refs/tags/'* ]]; then
IMAGE_VER='${GITHUB_REF/refs\/tags\//}'
else
IMAGE_VER=${{ github.event.inputs.IMAGE_VER }}
fi
if [[ $IMAGE_VER == '' ]]; then
IMAGE_VER='test-vBeta'
fi
echo '::set-output name=image_ver::'$IMAGE_VER
- name: Check Version
run: |
echo $GITHUB_REF
echo ${{ steps.gen_ver.outputs.image_ver }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login in to registry
uses: docker/login-action@v2
with:
registry: ${{env.REGISTRY}}
username: ${{secrets.DOCKER_USER}}
password: ${{secrets.DOCKER_PASS}}
- name: Build and push docker image
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64
push: true
tags: ${{env.REGISTRY}}/${{env.IMAGE_NAME}}:${{ steps.gen_ver.outputs.image_ver }},${{env.REGISTRY}}/${{env.IMAGE_NAME}}:latest
- name: Remote Deployment
uses: appleboy/ssh-action@v0.1.8
with:
host: ${{env.DEPLOY_HOST}}
username: frappet
password: ${{ secrets.SSH_PASSWORD }}
port: 22
script: |
cd "${{env.COMPOSE_PATH}}"
docker-compose pull
docker-compose up -d
echo "${{ steps.gen_ver.outputs.image_ver }}"> success
- uses: snow-actions/line-notify@v1.1.0
if: success()
with:
access_token: ${{ env.TOKEN_LINE }}
message: |
-Success✅✅✅
Image: ${{env.IMAGE_NAME}}
Version: ${{ github.event.inputs.IMAGE_VER }}
By: ${{secrets.DOCKER_USER}}
- uses: snow-actions/line-notify@v1.1.0
if: failure()
with:
access_token: ${{ env.TOKEN_LINE }}
message: |
-Failure❌❌❌
Image: ${{env.IMAGE_NAME}}
Version: ${{ github.event.inputs.IMAGE_VER }}
By: ${{secrets.DOCKER_USER}}

25
.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
package-lock.json

5
.prettierrc.json Normal file
View file

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

21
Dockerfile Normal file
View file

@ -0,0 +1,21 @@
# docker build . -t docker.frappet.com/demo/fe:latest
FROM node:lts as build-stage
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY ./ .
RUN npm run build
FROM nginx as production-stage
RUN mkdir /app
COPY --from=build-stage /app/dist /app
COPY nginx.conf /etc/nginx/nginx.conf
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod u+x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]

30
README.md Normal file
View file

@ -0,0 +1,30 @@
# BMA ระบบลงเวลาเข้า-ออกงาน
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run dev
```
### Compiles and minifies for production
```
npm run build
npm run preview
```
### Format and fixes files
```
npm run format
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

8
cypress.config.ts Normal file
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',
},
})

20
entrypoint.sh Normal file
View file

@ -0,0 +1,20 @@
#!/bin/sh
ROOT_DIR=/app
# Replace env vars in JavaScript files
echo "Replacing env constants in JS"
for file in $ROOT_DIR/assets/app.*.js* $ROOT_DIR/js/app.*.js* $ROOT_DIR/index.html $ROOT_DIR/precache-manifest*.js $ROOT_DIR/assets/index*.js*;
do
echo "Processing $file ...";
sed -i 's|VITE_REALM_KEYCLOAK|'${VITE_REALM_KEYCLOAK}'|g' $file
sed -i 's|VITE_CLIENTID_KEYCLOAK|'${VITE_CLIENTID_KEYCLOAK}'|g' $file
sed -i 's|VITE_URL_KEYCLOAK|'${VITE_URL_KEYCLOAK}'|g' $file
sed -i 's|VITE_API_URI_CONFIG|'${VITE_API_URI_CONFIG}'|g' $file
done
echo "Starting Nginx"
nginx -g 'daemon off;'

1
env.d.ts vendored Normal file
View file

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

29
index.html Normal file
View file

@ -0,0 +1,29 @@
<!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>BMA ระบบลงเวลาเข้า-ออกงาน</title>
<style>
html,
body,
#viewDiv {
padding: 0;
margin: 0;
height: 100%;
width: 100%;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script src="https://js.arcgis.com/4.19/"></script>
<link
rel="stylesheet"
href="https://js.arcgis.com/4.19/esri/themes/light/main.css"
/>
<script src="https://maps.googleapis.com/maps/api/js?key=AIzaSyBzPSF5NxUZ1G8DKBnJvJPTqCR0Ct2xf58&libraries=places"></script>
</body>
</html>

30
nginx.conf Normal file
View file

@ -0,0 +1,30 @@
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
server_name localhost;
location / {
root /app;
index index.html;
try_files $uri $uri/ /index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}

53
package.json Normal file
View file

@ -0,0 +1,53 @@
{
"name": "bma-ehr-publish",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "run-p build-only",
"preview": "vite preview --port 3008",
"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": {
"@quasar/extras": "^1.15.8",
"@vuepic/vue-datepicker": "^5.2.1",
"moment": "^2.29.4",
"pinia": "^2.1.4",
"quasar": "^2.11.1",
"register-service-worker": "^1.7.2",
"vite-plugin-pwa": "^0.16.7",
"vue": "^3.2.45",
"vue-router": "^4.1.6"
},
"devDependencies": {
"@quasar/vite-plugin": "^1.3.0",
"@rushstack/eslint-patch": "^1.1.4",
"@types/jsdom": "^20.0.1",
"@types/node": "^18.18.10",
"@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"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 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: 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

2
public/robots.txt Normal file
View file

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

30
src/App.vue Normal file
View file

@ -0,0 +1,30 @@
<template>
<!-- <nav>
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</nav> -->
<router-view />
</template>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
nav {
padding: 30px;
}
nav a {
font-weight: bold;
color: #2c3e50;
}
nav a.router-link-exact-active {
color: #42b983;
}
</style>

7
src/api/api.message.ts Normal file
View file

@ -0,0 +1,7 @@
import env from './index'
const message = `${env.API_URI}/message`
export default {
msgNotificate: `${message}/my-notifications`,
msgId: (id: string) => `${message}/my-notifications/${id}`,
}

30
src/api/index.ts Normal file
View file

@ -0,0 +1,30 @@
/**config api */
import { ref } from 'vue'
const env = ref<string>(process.env.NODE_ENV || 'development')
export const apiUrlConfig = import.meta.env.VITE_API_URI_CONFIG
// if (process.env.VUE_APP_TEST) {
// env = "test";
// }
const config = ref<any>({
development: {
// API_URI: "https://localhost:7260/api",
API_URI: 'https://bma-ehr.frappet.synology.me/api/v1',
},
test: {
API_URI: 'http://localhost:5010/api/v1',
},
production: {
// API_URI: "https://localhost:5010",
API_URI: apiUrlConfig,
},
})
const API_URI = ref<string>(config.value[env.value].API_URI)
export default {
env: env.value,
config: config.value,
API_URI: API_URI.value,
}

17
src/app.config.ts Normal file
View file

@ -0,0 +1,17 @@
/**ใช้รวมไฟล์ย่อยๆ ของ api แต่ละไฟล์ */
/** API ระบบลงเวลา */
import message from '@/api/api.message'
// environment variables
export const s3ClusterUrl = import.meta.env.VITE_S3CLUSTER_PUBLIC_URL
const API = {
/**message */
...message,
}
export default {
API: API,
s3ClusterUrl,
}

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
src/assets/map1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View file

@ -0,0 +1,79 @@
<template>
<q-dialog ref="dialogRef" @hide="onDialogHide" persistent>
<q-card class="q-pa-sm">
<q-card-section class="row">
<div class="q-pr-md">
<q-avatar :icon="icon" size="lg" font-size="25px" color="blue-1" :text-color="color" />
</div>
<div class="col text-dark">
<span class="text-bold">{{ title }}</span>
<br />
<span>{{ message }}</span>
</div>
</q-card-section>
<q-card-actions align="right" class="bg-white text-teal" v-if="onlycancel">
<q-btn label="ตกลง" flat color="grey-8" @click="onDialogCancel" />
<!-- <q-btn :label="textOk" :color="color" @click="onOKClick" /> -->
</q-card-actions>
<q-card-actions align="right" class="bg-white text-teal" v-else>
<q-btn label="ยกเลิก" flat color="grey-8" @click="onDialogCancel" />
<q-btn :label="textOk" :color="color" @click="onOKClick" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { useDialogPluginComponent } from "quasar"
const props = defineProps({
color: {
type: String,
default: "primary",
},
textOk: {
type: String,
default: "ตกลง",
},
title: {
type: String,
default: "หัวข้อ?",
},
message: {
type: String,
default: "ข้อความ",
},
icon: {
type: String,
default: "question_mark",
},
onlycancel: {
type: Boolean,
default: false,
},
})
defineEmits([
// REQUIRED; need to specify some events that your
// component will emit through useDialogPluginComponent()
...useDialogPluginComponent.emits,
])
const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
// dialogRef - Vue ref to be applied to QDialog
// onDialogHide - Function to be used as handler for @hide on QDialog
// onDialogOK - Function to call to settle dialog with "ok" outcome
// example: onDialogOK() - no payload
// example: onDialogOK({ /*...*/ }) - with payload
// onDialogCancel - Function to call to settle dialog with "cancel" outcome
// this is part of our example (so not required)
function onOKClick() {
// on OK, it is REQUIRED to
// call onDialogOK (with optional payload)
onDialogOK()
// or with payload: onDialogOK({ ... })
// ...and it will also hide the dialog automatically
}
</script>

View file

@ -0,0 +1,27 @@
<template>
<q-toolbar>
<q-toolbar-title class="text-subtitle2 text-bold">{{
tittle
}}</q-toolbar-title>
<q-btn
icon="close"
unelevated
round
dense
@click="close"
style="color: #ff8080; background-color: #ffdede"
/>
</q-toolbar>
</template>
<script setup lang="ts">
const props = defineProps({
tittle: String,
close: {
type: Function,
default: () => console.log("not function"),
},
});
function close() {
props.close();
}
</script>

186
src/components/Table.vue Normal file
View file

@ -0,0 +1,186 @@
<template>
<div class="q-pb-sm row">
<div>
<q-btn
size="14px"
flat
dense
color="blue"
@click="add"
icon="mdi-plus"
>
<q-tooltip>เพมขอม</q-tooltip>
</q-btn>
</div>
<q-space />
<div class="items-center q-gutter-sm" style="display: flex">
<!-- นหาขอความใน table -->
<q-input
standout
dense
:model-value="inputfilter"
ref="filterRef"
@update:model-value="updateInput"
outlined
debounce="300"
placeholder="ค้นหา"
style="max-width: 200px"
>
<template v-slot:append>
<q-icon v-if="inputfilter == ''" name="search" />
<q-icon
v-if="inputfilter !== ''"
name="clear"
class="cursor-pointer"
@click="resetFilter"
/>
</template>
</q-input>
<!-- แสดงคอลมนใน table -->
<q-select
:model-value="inputvisible"
@update:model-value="updateVisible"
:display-value="$q.lang.table.columns"
multiple
outlined
dense
:options="attrs.columns"
options-dense
option-value="name"
map-options
emit-value
style="min-width: 150px"
class="gt-xs"
>
<template> </template>
</q-select>
</div>
</div>
<div>
<q-table
ref="table"
flat
bordered
class="custom-table2"
v-bind="attrs"
virtual-scroll
:virtual-scroll-sticky-size-start="48"
dense
:pagination-label="paginationLabel"
:pagination="initialPagination"
:rows-per-page-options="[0]"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span class="text-weight-medium" v-html="col.label" />
</q-th>
<q-th auto-width v-if="inputShow" />
</q-tr>
</template>
<template #body="props">
<slot v-bind="props" name="columns"></slot>
</template>
</q-table>
</div>
</template>
<script setup lang="ts">
import { ref, useAttrs } from "vue";
const attrs = ref<any>(useAttrs());
const table = ref<any>(null);
const filterRef = ref<any>(null);
const initialPagination = ref({
rowsPerPage: 0,
});
const props = defineProps({
count: Number,
pass: Number,
notpass: Number,
inputfilter: String,
name: String,
icon: String,
inputvisible: Array,
editvisible: Boolean,
inputShow: Boolean,
add: {
type: Function,
default: () => console.log("not function"),
},
});
const emit = defineEmits([
"update:inputfilter",
"update:inputvisible",
"update:editvisible",
]);
const updateInput = (value: string | number | null) => {
emit("update:inputfilter", value);
};
const updateVisible = (value: []) => {
emit("update:inputvisible", value);
};
const paginationLabel = (start: string, end: string, total: string) => {
return start + "-" + end + " ใน " + total;
};
const resetFilter = () => {
// reset X
emit("update:inputfilter", "");
filterRef.value.focus();
};
const add = () => {
props.add();
};
</script>
<style lang="scss">
.icon-color {
color: #4154b3;
}
.custom-table2 {
max-height: 64vh;
.q-table tr:nth-child(odd) td {
background: white;
}
.q-table tr:nth-child(even) td {
background: #f8f8f8;
}
.q-table thead tr {
background: #ecebeb;
}
.q-table thead tr th {
position: sticky;
}
.q-table td:nth-of-type(2) {
z-index: 3 !important;
}
.q-table th:nth-of-type(2),
.q-table td:nth-of-type(2) {
position: sticky;
left: 0;
z-index: 1;
}
/* this will be the loading indicator */
.q-table thead tr:last-child th {
/* height of all previous header rows */
top: 48px;
}
.q-table thead tr:first-child th {
top: 0;
}
}
</style>

View file

@ -0,0 +1,6 @@
interface DataOption {
id: string
name: string
}
export type { DataOption}

View file

@ -0,0 +1,13 @@
interface Noti {
id: string
body: string
receiverUserId: string
type: string
payload: null
isOpen: false
receiveDate: Date | null
openDate: null
createdFullName: string
}
export type { Noti }

35
src/main.ts Normal file
View file

@ -0,0 +1,35 @@
import { createApp, defineAsyncComponent } from 'vue'
import App from '@/App.vue'
import '@/registerServiceWorker'
import router from '@/router'
import { createPinia } from 'pinia'
import { Quasar, Dialog, Notify, Loading } from 'quasar'
import '@vuepic/vue-datepicker/dist/main.css'
import quasarUserOptions from '@/quasar-user-options'
import 'quasar/src/css/index.sass'
import th from 'quasar/lang/th'
const app = createApp(App)
const pinia = createPinia()
app.use(router)
app.use(pinia)
app.use(Quasar, {
...quasarUserOptions,
plugins: {
Notify,
Dialog,
Loading,
},
lang: th,
})
app.component(
'datepicker',
defineAsyncComponent(() => import('@vuepic/vue-datepicker')),
)
app.mount('#app')

22
src/plugins/filters.ts Normal file
View file

@ -0,0 +1,22 @@
/**
* GLOABL Filters
* - Helper Functions
*/
const filters = {
/**
* compactNumber Social Media 1,000 1K 1,000,000 1M
* : {{ $filters.compactNumber(value) }}
*
* @param val
* @returns
*/
compactNumber (val: number) {
const formatter = Intl.NumberFormat('en', { notation: 'compact'})
return formatter.format(val)
}
}
export default filters;

View file

@ -0,0 +1,11 @@
// import "./styles/quasar.scss"
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,34 @@
/* eslint-disable no-console */
import { register } from 'register-service-worker'
if (process.env.NODE_ENV === 'production') {
register('registerSW.js', {
ready() {
console.log(
'App is being served from cache by a service worker.\n' +
'For more details, visit https://goo.gl/AFskqB'
)
},
registered() {
console.log('Service worker has been registered.')
},
cached() {
console.log('Content has been cached for offline use.')
},
updatefound() {
console.log('New content is downloading.')
},
updated() {
console.log('New content is available; please refresh.')
},
offline() {
console.log(
'No internet connection found. App is running in offline mode.'
)
},
error(error) {
console.error('Error during service worker registration:', error)
},
})
}

30
src/router/index.ts Normal file
View file

@ -0,0 +1,30 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
meta: {
Auth: true,
},
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/ErrorNotFoundPage.vue'),
meta: {
Auth: true,
},
},
],
})
router.beforeEach((to, from, next) => {
next();
})
export default router

1
src/shims-vue.d.ts vendored Normal file
View file

@ -0,0 +1 @@
declare module '*.vue'

327
src/stores/mixin.ts Normal file
View file

@ -0,0 +1,327 @@
import { defineStore } from 'pinia'
import CustomComponent from '@/components/CustomDialog.vue'
import { Loading, QSpinnerCube } from 'quasar'
export const useCounterMixin = defineStore('mixin', () => {
function date2Thai(srcDate: Date, isFullMonth = false, isTime = false) {
if (srcDate == null) {
return null
;`
`
}
const date = new Date(srcDate)
const isValidDate = Boolean(+date)
if (!isValidDate) return srcDate.toString()
if (isValidDate && date.getFullYear() < 1000) return srcDate.toString()
const fullMonthThai = [
'มกราคม',
'กุมภาพันธ์',
'มีนาคม',
'เมษายน',
'พฤษภาคม',
'มิถุนายน',
'กรกฎาคม',
'สิงหาคม',
'กันยายน',
'ตุลาคม',
'พฤศจิกายน',
'ธันวาคม',
]
const abbrMonthThai = [
'ม.ค.',
'ก.พ.',
'มี.ค.',
'เม.ย.',
'พ.ค.',
'มิ.ย.',
'ก.ค.',
'ส.ค.',
'ก.ย.',
'ต.ค.',
'พ.ย.',
'ธ.ค.',
]
let dstYear = 0
if (date.getFullYear() > 2500) {
dstYear = date.getFullYear()
} else {
dstYear = date.getFullYear() + 543
}
let dstMonth = ''
if (isFullMonth) {
dstMonth = fullMonthThai[date.getMonth()]
} else {
dstMonth = abbrMonthThai[date.getMonth()]
}
let dstTime = ''
if (isTime) {
const H = date.getHours().toString().padStart(2, '0')
const M = date.getMinutes().toString().padStart(2, '0')
// const S = date.getSeconds().toString().padStart(2, "0")
// dstTime = " " + H + ":" + M + ":" + S + " น."
dstTime = ' ' + H + ':' + M + ' น.'
}
return (
date.getDate().toString().padStart(2, '0') +
' ' +
dstMonth +
' ' +
dstYear +
dstTime
)
}
const showLoader = () => {
Loading.show({
spinner: QSpinnerCube,
spinnerSize: 140,
spinnerColor: 'primary',
backgroundColor: 'white',
})
}
const hideLoader = () => {
Loading.hide()
}
function covertDateObject(date: string) {
if (date) {
const dateParts = date.split('/')
// ประกาศตัวแปรเพื่อเก็บค่าวันที่, เดือน, และ ปี
const day = parseInt(dateParts[0], 10)
const month = parseInt(dateParts[1], 10) - 1
const year = parseInt(dateParts[2], 10) + 2500
// สร้างอ็อบเจ็กต์ Date ด้วยค่าที่ได้
const dateObject = new Date(year, month, day)
return date2Thai(dateObject)
}
}
type OkCallback = () => void
type CancelCallback = () => void
function dialogConfirm(
q: any,
ok?: OkCallback,
title?: string, // ถ้ามี cancel action ใส่เป็น null
desc?: string, // ถ้ามี cancel action ใส่เป็น null
cancel?: CancelCallback
) {
q.dialog({
component: CustomComponent,
componentProps: {
title: title && title != null ? title : 'ยืนยันการบันทึก',
message:
desc && desc != null
? desc
: 'ต้องการยืนยันการบันทึกข้อมูลนี้ใช่หรือไม่?',
icon: 'info',
color: 'public',
textOk: 'ตกลง',
onlycancel: false,
},
})
.onOk(() => {
if (ok) ok()
})
.onCancel(() => {
if (cancel) cancel()
})
}
const messageError = (q: any, e: any = '') => {
// q.dialog.hide();
if (e.response !== undefined) {
if (e.response.data.status !== undefined) {
if (e.response.data.status == 401) {
//invalid_token
q.dialog({
component: CustomComponent,
componentProps: {
title: `พบข้อผิดพลาด`,
message: `ล็อกอินหมดอายุ กรุณาล็อกอินใหม่อีกครั้ง`,
icon: 'warning',
color: 'red',
onlycancel: true,
},
})
} else {
const message = e.response.data.result ?? e.response.data.message
q.dialog({
component: CustomComponent,
componentProps: {
title: `พบข้อผิดพลาด`,
message: `${message}`,
icon: 'warning',
color: 'red',
onlycancel: true,
},
})
}
} else {
if (e.response.status == 401) {
//invalid_token
q.dialog({
component: CustomComponent,
componentProps: {
title: `พบข้อผิดพลาด`,
message: `ล็อกอินหมดอายุ กรุณาล็อกอินใหม่อีกครั้ง`,
icon: 'warning',
color: 'red',
onlycancel: true,
},
})
} else if (e.response.data.successful === false) {
q.dialog({
component: CustomComponent,
componentProps: {
title: `พบข้อผิดพลาด`,
message: e.response.data.message,
icon: 'warning',
color: 'red',
onlycancel: true,
},
})
} else {
q.dialog({
component: CustomComponent,
componentProps: {
title: `พบข้อผิดพลาด`,
message: `ข้อมูลผิดพลาดทำให้เกิดการไม่ตอบสนองต่อการเรียกใช้งานดูเว็บไซต์`,
icon: 'warning',
color: 'red',
onlycancel: true,
},
})
}
}
} else {
q.dialog({
component: CustomComponent,
componentProps: {
title: `พบข้อผิดพลาด`,
message: `ข้อมูลผิดพลาดทำให้เกิดการไม่ตอบสนองต่อการเรียกใช้งานดูเว็บไซต์`,
icon: 'warning',
color: 'red',
onlycancel: true,
},
})
}
}
const success = (q: any, val: string) => {
// useQuasar ไม่สามารถใช้นอกไฟล์ .vue
if (val !== '') {
return q.notify({
message: val,
color: 'primary',
icon: 'mdi-information',
position: 'bottom-right',
multiLine: true,
timeout: 1000,
badgeColor: 'positive',
classes: 'my-notif-class',
})
}
}
function notify(q: any, val: string) {
if (val !== '') {
q.notify({
color: 'teal-10',
message: val,
icon: 'mdi-information',
position: 'bottom-right',
multiLine: true,
timeout: 7000,
actions: [{ label: 'ปิด', color: 'white', handler: () => {} }],
})
}
}
function dialogRemove(
q: any,
ok?: () => void,
title?: string, // ถ้ามี cancel action ใส่เป็น null
desc?: string, // ถ้ามี cancel action ใส่เป็น null
cancel?: () => void
) {
q.dialog({
component: CustomComponent,
componentProps: {
title: title && title != null ? title : 'ยืนยันการลบข้อมูล',
message:
desc && desc != null
? desc
: 'ต้องการยืนยันการลบข้อมูลนี้ใช่หรือไม่?',
icon: 'delete',
color: 'red',
textOk: 'ตกลง',
onlycancel: false,
},
})
.onOk(() => {
if (ok) ok()
})
.onCancel(() => {
if (cancel) cancel()
})
}
function monthYear2Thai(month: number, year: number, isFullMonth = false) {
const date = new Date(`${year}-${month + 1}-1`)
const fullMonthThai = [
'มกราคม',
'กุมภาพันธ์',
'มีนาคม',
'เมษายน',
'พฤษภาคม',
'มิถุนายน',
'กรกฎาคม',
'สิงหาคม',
'กันยายน',
'ตุลาคม',
'พฤศจิกายน',
'ธันวาคม',
]
const abbrMonthThai = [
'ม.ค.',
'ก.พ.',
'มี.ค.',
'เม.ย.',
'พ.ค.',
'มิ.ย.',
'ก.ค.',
'ส.ค.',
'ก.ย.',
'ต.ค.',
'พ.ย.',
'ธ.ค.',
]
let dstYear = 0
if (date.getFullYear() > 2500) {
dstYear = date.getFullYear()
} else {
dstYear = date.getFullYear() + 543
}
let dstMonth = ''
if (isFullMonth) {
dstMonth = fullMonthThai[date.getMonth()]
} else {
dstMonth = abbrMonthThai[date.getMonth()]
}
return dstMonth + ' ' + dstYear
}
return {
date2Thai,
monthYear2Thai,
showLoader,
hideLoader,
covertDateObject,
dialogConfirm,
messageError,
success,
notify,
dialogRemove,
}
})

View file

@ -0,0 +1,139 @@
// FILE (create it): src/quasar-variables.sass
$primary: #02A998
$secondary: #016987
$accent: #9C27B0
// $dark: #1D1D1D
$dark: #35473C
$positive: #21BA45
$negative: #C10015
$info: #31CCEC
$warning: #F2C037
$add: #00aa86
.text-add
color: $add !important
.bg-add
background: $add !important
$edit: #019fc4
.text-edit
color: $edit !important
.bg-edit
background: $edit !important
$public: #016987
.text-public
color: $public !important
.bg-public
background: $public !important
$save: #4154b3
.text-save
color: $save !important
.bg-save
background: $save !important
$nativetab: #c8d3db
.text-nativetab
color: $nativetab !important
.bg-nativetab
background: $nativetab !important
$activetab: #4a5568
.text-activetab
color: $activetab !important
.bg-activetab
background: $activetab !important
.inputgreen .q-field__prefix,
.inputgreen .q-field__suffix,
.inputgreen .q-field__input,
.inputgreen .q-field__native
color: rgb(6, 136, 77)
@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
#azay-app,
div
font-family: $noto-thai !important
text-rendering: optimizeLegibility
-webkit-font-smoothing: antialiased
-moz-osx-font-smoothing: grayscale
$separator-color: #EDEDED !default
.bg-teal-1
background: #e0f2f1a6 !important
.table_ellipsis
max-width: 200px
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
.table_ellipsis:hover
word-wrap: break-word
overflow: visible
white-space: normal
.table_ellipsis2
max-width: 25vw
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
.table_ellipsis2:hover
word-wrap: break-word
overflow: visible
white-space: normal
transition: width 2s
$muti-tab: #87d4cc
.text-muti-tab
color: $muti-tab !important
.bg-muti-tab
background: $muti-tab !important
/* editor */
.q-editor
font-size: 1rem
line-height: 1.5rem
font-weight: 400
.q-editor h1, .q-menu h1
font-size: 1.5rem
line-height: 2rem
font-weight: 400
margin-block-start: 0em
margin-block-end: 0em
.q-editor h2, .q-menu h2
font-size: 1.25rem
line-height: 1.5rem
font-weight: 400
margin-block-start: 0em
margin-block-end: 0em
.q-editor h3, .q-menu h3
font-size: 1.1rem
line-height: 1.5rem
font-weight: 400
margin-block-start: 0em
margin-block-end: 0em
.q-editor p, .q-menu p
margin: 0
/* q-tree */
.q-tree
color: #c8d3db

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>

11
src/views/HomeView.vue Normal file
View file

@ -0,0 +1,11 @@
<script setup lang="ts"></script>
<template>
<div class="col-12 row justify-center">
<div class="col-xs-12 col-sm-12 col-md-12">
<q-card flat class="row col-12 cardNone">
Publish
</q-card>
</div>
</div>
</template>

16
tsconfig.app.json Normal file
View file

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

14
tsconfig.config.json Normal file
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"]
}
}

14
tsconfig.json Normal file
View file

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

9
tsconfig.vitest.json Normal file
View file

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

66
vite.config.js Normal file
View file

@ -0,0 +1,66 @@
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-Checkin',
short_name: 'EHR Checkin',
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)),
},
},
build: {
target: 'esnext',
},
server: {
port: 3011,
},
optimizeDeps: {
include: ['esri-loader'],
},
})