first commit
9
.editorconfig
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
5
.env.example
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
VITE_KC_URL=
|
||||||
|
VITE_KC_REALM=
|
||||||
|
VITE_KC_CLIENT_ID=
|
||||||
|
VITE_MANAGEMENT_ROLE=admin
|
||||||
8
.eslintignore
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
/dist
|
||||||
|
/src-capacitor
|
||||||
|
/src-cordova
|
||||||
|
/.quasar
|
||||||
|
/node_modules
|
||||||
|
.eslintrc.cjs
|
||||||
|
/src-ssr
|
||||||
|
/quasar.config.*.temporary.compiled*
|
||||||
61
.eslintrc.cjs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
|
||||||
|
parserOptions: {
|
||||||
|
parser: require.resolve('@typescript-eslint/parser'),
|
||||||
|
extraFileExtensions: ['.vue'],
|
||||||
|
},
|
||||||
|
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
es2021: true,
|
||||||
|
node: true,
|
||||||
|
'vue/setup-compiler-macros': true,
|
||||||
|
},
|
||||||
|
|
||||||
|
extends: [
|
||||||
|
// https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#usage
|
||||||
|
// ESLint typescript rules
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
|
||||||
|
// Uncomment any of the lines below to choose desired strictness,
|
||||||
|
// but leave only one uncommented!
|
||||||
|
// See https://eslint.vuejs.org/rules/#available-rules
|
||||||
|
'plugin:vue/vue3-essential',
|
||||||
|
|
||||||
|
// https://github.com/prettier/eslint-config-prettier#installation
|
||||||
|
// usage with Prettier, provided by 'eslint-config-prettier'.
|
||||||
|
'prettier',
|
||||||
|
],
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
// required to apply rules which need type information
|
||||||
|
'@typescript-eslint',
|
||||||
|
|
||||||
|
// https://eslint.vuejs.org/user-guide/#why-doesn-t-it-work-on-vue-files
|
||||||
|
// required to lint *.vue files
|
||||||
|
'vue',
|
||||||
|
],
|
||||||
|
|
||||||
|
globals: {
|
||||||
|
ga: 'readonly', // Google Analytics
|
||||||
|
cordova: 'readonly',
|
||||||
|
__statics: 'readonly',
|
||||||
|
__QUASAR_SSR__: 'readonly',
|
||||||
|
__QUASAR_SSR_SERVER__: 'readonly',
|
||||||
|
__QUASAR_SSR_CLIENT__: 'readonly',
|
||||||
|
__QUASAR_SSR_PWA__: 'readonly',
|
||||||
|
process: 'readonly',
|
||||||
|
Capacitor: 'readonly',
|
||||||
|
chrome: 'readonly',
|
||||||
|
},
|
||||||
|
|
||||||
|
// add your custom rules here
|
||||||
|
rules: {
|
||||||
|
quotes: ['warn', 'single', { avoidEscape: true }],
|
||||||
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': 'warn',
|
||||||
|
'prefer-promise-reject-errors': 'off',
|
||||||
|
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||||
|
},
|
||||||
|
};
|
||||||
37
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
.DS_Store
|
||||||
|
.thumbs.db
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Quasar core related directories
|
||||||
|
.quasar
|
||||||
|
/dist
|
||||||
|
/quasar.config.*.temporary.compiled*
|
||||||
|
|
||||||
|
# Cordova related directories and files
|
||||||
|
/src-cordova/node_modules
|
||||||
|
/src-cordova/platforms
|
||||||
|
/src-cordova/plugins
|
||||||
|
/src-cordova/www
|
||||||
|
|
||||||
|
# Capacitor related directories and files
|
||||||
|
/src-capacitor/www
|
||||||
|
/src-capacitor/node_modules
|
||||||
|
|
||||||
|
# BEX related directories and files
|
||||||
|
/src-bex/www
|
||||||
|
/src-bex/js/core
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
|
||||||
|
# local .env files
|
||||||
|
.env.local*
|
||||||
5
.npmrc
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# pnpm-related options
|
||||||
|
shamefully-hoist=true
|
||||||
|
strict-peer-dependencies=false
|
||||||
|
# to get the latest compatible packages when creating the project https://github.com/pnpm/pnpm/issues/6463
|
||||||
|
resolution-mode=highest
|
||||||
5
.prettierrc
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"htmlWhitespaceSensitivity": "ignore"
|
||||||
|
}
|
||||||
15
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"editorconfig.editorconfig",
|
||||||
|
"vue.volar",
|
||||||
|
"wayou.vscode-todo-highlight"
|
||||||
|
],
|
||||||
|
"unwantedRecommendations": [
|
||||||
|
"octref.vetur",
|
||||||
|
"hookyqr.beautify",
|
||||||
|
"dbaeumer.jshint",
|
||||||
|
"ms-vscode.vscode-typescript-tslint-plugin"
|
||||||
|
]
|
||||||
|
}
|
||||||
9
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"editor.bracketPairColorization.enabled": true,
|
||||||
|
"editor.guides.bracketPairs": true,
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.codeActionsOnSave": ["source.fixAll.eslint"],
|
||||||
|
"eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"],
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib"
|
||||||
|
}
|
||||||
21
Dockerfile
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
FROM node:20-slim as build-stage
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM alpine as production-stage
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add miniserve --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community
|
||||||
|
|
||||||
|
COPY --from=build-stage /app/dist/spa .
|
||||||
|
COPY --from=build-stage /app/entrypoint.sh ./entrypoint.sh
|
||||||
|
|
||||||
|
RUN chmod u+x ./entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
0
README.md
Normal file
13
compose.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- 3000:8080
|
||||||
|
environment:
|
||||||
|
- API_BASE_URL=
|
||||||
|
- KC_URL=
|
||||||
|
- KC_REALM=dev
|
||||||
|
- KC_CLIENT_ID=dev
|
||||||
|
- MANAGEMENT_ROLE=admin
|
||||||
12
entrypoint.sh
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
for file in /app/assets/*.js
|
||||||
|
do
|
||||||
|
sed -i 's|ENV_API_BASE_URL|'${API_BASE_URL}'|g' $file
|
||||||
|
sed -i 's|ENV_KC_URL|'${KC_URL}'|g' $file
|
||||||
|
sed -i 's|ENV_KC_REALM|'${KC_REALM}'|g' $file
|
||||||
|
sed -i 's|ENV_KC_CLIENT_ID|'${KC_CLIENT_ID}'|g' $file
|
||||||
|
sed -i 's|ENV_MANAGEMENT_ROLE|'${MANAGEMENT_ROLE}'|g' $file
|
||||||
|
done
|
||||||
|
|
||||||
|
miniserve --spa --index index.html
|
||||||
32
index.html
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title><%= productName %></title>
|
||||||
|
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="description" content="<%= productDescription %>" />
|
||||||
|
<meta name="format-detection" content="telephone=no" />
|
||||||
|
<meta name="msapplication-tap-highlight" content="no" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="32x32"
|
||||||
|
href="public/icons/favicon-32x32.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="16x16"
|
||||||
|
href="public/icons/favicon-16x16.png"
|
||||||
|
/>
|
||||||
|
<link rel="icon" type="image/ico" href="favicon.ico" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- quasar:entry-point -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7264
package-lock.json
generated
Normal file
49
package.json
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
{
|
||||||
|
"name": "jobs-worker-service",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Jobs Worker Service",
|
||||||
|
"productName": "Jobs Worker Service",
|
||||||
|
"author": "Chamomind",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"lint": "eslint --ext .js,.ts,.vue ./",
|
||||||
|
"format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore",
|
||||||
|
"test": "echo \"No test specified\" && exit 0",
|
||||||
|
"dev": "quasar dev",
|
||||||
|
"build": "quasar build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@quasar/extras": "^1.16.9",
|
||||||
|
"@tato30/vue-pdf": "^1.9.5",
|
||||||
|
"axios": "^1.6.8",
|
||||||
|
"keycloak-js": "^24.0.2",
|
||||||
|
"mime": "^4.0.1",
|
||||||
|
"moment": "^2.30.1",
|
||||||
|
"open-props": "^1.6.21",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"quasar": "^2.15.1",
|
||||||
|
"socket.io-client": "^4.7.5",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-i18n": "^9.10.2",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@intlify/unplugin-vue-i18n": "^4.0.0",
|
||||||
|
"@quasar/app-vite": "2.0.0-beta.2",
|
||||||
|
"@types/node": "^20.11.30",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.4.0",
|
||||||
|
"@typescript-eslint/parser": "^7.4.0",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-vue": "^9.24.0",
|
||||||
|
"prettier": "^3.2.5",
|
||||||
|
"typescript": "^5.4.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^24 || ^22 || ^20 || ^18",
|
||||||
|
"npm": ">= 6.13.4",
|
||||||
|
"yarn": ">= 1.21.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
4154
pnpm-lock.yaml
generated
Normal file
BIN
public/book.png
Normal file
|
After Width: | Height: | Size: 266 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/icons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 891 B |
BIN
public/icons/favicon-256x256.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
public/icons/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/icons/favicon-512x512.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/logo.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/logo_jws.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
50
quasar.config.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
/* eslint-env node */
|
||||||
|
|
||||||
|
// Configuration for your app
|
||||||
|
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
|
||||||
|
|
||||||
|
import { configure } from 'quasar/wrappers';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
export default configure((ctx) => {
|
||||||
|
return {
|
||||||
|
eslint: {
|
||||||
|
fix: true,
|
||||||
|
warnings: true,
|
||||||
|
errors: true,
|
||||||
|
},
|
||||||
|
boot: ['i18n', 'axios', 'components'],
|
||||||
|
css: ['app.scss'],
|
||||||
|
extras: ['mdi-v7'],
|
||||||
|
build: {
|
||||||
|
target: {
|
||||||
|
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
|
||||||
|
node: 'node20',
|
||||||
|
},
|
||||||
|
vueRouterMode: 'history',
|
||||||
|
vitePlugins: [
|
||||||
|
[
|
||||||
|
'@intlify/unplugin-vue-i18n/vite',
|
||||||
|
{
|
||||||
|
ssr: ctx.modeName === 'ssr',
|
||||||
|
include: [fileURLToPath(new URL('./src/i18n', import.meta.url))],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
devServer: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
open: false,
|
||||||
|
},
|
||||||
|
framework: {
|
||||||
|
config: {},
|
||||||
|
plugins: ['Dark', 'Dialog'],
|
||||||
|
iconSet: 'mdi-v7',
|
||||||
|
cssAddon: true,
|
||||||
|
},
|
||||||
|
animations: [],
|
||||||
|
pwa: {
|
||||||
|
workboxMode: 'GenerateSW', // 'GenerateSW' or 'InjectManifest'
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
5
src/App.vue
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<script setup lang="ts"></script>
|
||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
<style lang="scss"></style>
|
||||||
69
src/boot/axios.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
import { boot } from 'quasar/wrappers';
|
||||||
|
import { getToken } from 'src/services/keycloak';
|
||||||
|
import { dialog } from 'src/stores/utils';
|
||||||
|
import useLoader from 'stores/loader';
|
||||||
|
|
||||||
|
declare module '@vue/runtime-core' {
|
||||||
|
interface ComponentCustomProperties {
|
||||||
|
$axios: AxiosInstance;
|
||||||
|
$api: AxiosInstance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Be careful when using SSR for cross-request state pollution
|
||||||
|
// due to creating a Singleton instance here;
|
||||||
|
// If any client changes this (global) instance, it might be a
|
||||||
|
// good idea to move this instance creation inside of the
|
||||||
|
// "export default () => {}" function below (which runs individually
|
||||||
|
// for each client)
|
||||||
|
const api = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL });
|
||||||
|
|
||||||
|
function parseError(
|
||||||
|
status: number,
|
||||||
|
body?: { status: number; message: string },
|
||||||
|
) {
|
||||||
|
if (status === 422) return 'ข้อมูลไม่ถูกต้อง กรุณาตรวจสอบใหม่อีกครั้ง';
|
||||||
|
if (body && body.message) return body.message;
|
||||||
|
|
||||||
|
return 'เกิดข้อผิดพลาดทำให้ระบบไม่สามารถทำงานได้ กรุณาลองใหม่ในภายหลัง';
|
||||||
|
}
|
||||||
|
|
||||||
|
api.interceptors.request.use(async (config) => {
|
||||||
|
useLoader().show();
|
||||||
|
config.headers.Authorization = `Bearer ${await getToken()}`;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(res) => {
|
||||||
|
useLoader().hide();
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
useLoader().hide();
|
||||||
|
dialog({
|
||||||
|
color: 'negative',
|
||||||
|
icon: 'priority_high',
|
||||||
|
title: 'เกิดข้อผิดพลาด',
|
||||||
|
actionText: 'ตกลง',
|
||||||
|
persistent: true,
|
||||||
|
message: parseError(err.response.status, err.response.data),
|
||||||
|
action: () => {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default boot(({ app }) => {
|
||||||
|
// for use inside Vue files (Options API) through this.$axios and this.$api
|
||||||
|
|
||||||
|
app.config.globalProperties.$axios = axios;
|
||||||
|
// ^ ^ ^ this will allow you to use this.$axios (for Vue Options API form)
|
||||||
|
// so you won't necessarily have to import axios in each vue file
|
||||||
|
|
||||||
|
app.config.globalProperties.$api = api;
|
||||||
|
// ^ ^ ^ this will allow you to use this.$api (for Vue Options API form)
|
||||||
|
// so you can easily perform requests against your app's API
|
||||||
|
});
|
||||||
|
|
||||||
|
export { api };
|
||||||
8
src/boot/components.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { boot } from 'quasar/wrappers';
|
||||||
|
import GlobalDialog from 'components/GlobalDialog.vue';
|
||||||
|
import GlobalLoading from 'components/GlobalLoading.vue';
|
||||||
|
|
||||||
|
export default boot(({ app }) => {
|
||||||
|
app.component('global-dialog', GlobalDialog);
|
||||||
|
app.component('global-loading', GlobalLoading);
|
||||||
|
});
|
||||||
33
src/boot/i18n.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { boot } from 'quasar/wrappers';
|
||||||
|
import { createI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import messages from 'src/i18n';
|
||||||
|
|
||||||
|
export type MessageLanguages = keyof typeof messages;
|
||||||
|
// Type-define 'en-US' as the master schema for the resource
|
||||||
|
export type MessageSchema = (typeof messages)['en-US'];
|
||||||
|
|
||||||
|
// See https://vue-i18n.intlify.dev/guide/advanced/typescript.html#global-resource-schema-type-definition
|
||||||
|
/* eslint-disable @typescript-eslint/no-empty-interface */
|
||||||
|
declare module 'vue-i18n' {
|
||||||
|
// define the locale messages schema
|
||||||
|
export interface DefineLocaleMessage extends MessageSchema {}
|
||||||
|
|
||||||
|
// define the datetime format schema
|
||||||
|
export interface DefineDateTimeFormat {}
|
||||||
|
|
||||||
|
// define the number format schema
|
||||||
|
export interface DefineNumberFormat {}
|
||||||
|
}
|
||||||
|
/* eslint-enable @typescript-eslint/no-empty-interface */
|
||||||
|
|
||||||
|
export default boot(({ app }) => {
|
||||||
|
const i18n = createI18n({
|
||||||
|
locale: 'th-th',
|
||||||
|
legacy: false,
|
||||||
|
messages,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set i18n instance on app
|
||||||
|
app.use(i18n);
|
||||||
|
});
|
||||||
76
src/components/DrawerComponent.vue
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const currentRoute = ref<string>('');
|
||||||
|
const labelMenu: {
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
}[] = [
|
||||||
|
{ label: 'Dashboard', icon: 'file-account-outline' },
|
||||||
|
{ label: 'จัดการสาขา', icon: 'add' },
|
||||||
|
{ label: 'จัดการผู้ใช้งาน', icon: 'add' },
|
||||||
|
{ label: 'จัดการหลักสูตร', icon: 'add' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const leftDrawerOpen = defineModel<boolean>('leftDrawerOpen', {
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Drawer -->
|
||||||
|
<q-drawer v-model="leftDrawerOpen" side="left" show-if-above>
|
||||||
|
<div class="main-bar">
|
||||||
|
<div
|
||||||
|
class="column items-center justify-center q-pa-xl cursor-pointer"
|
||||||
|
@click="$router.push('/')"
|
||||||
|
id="btn-drawer-home"
|
||||||
|
>
|
||||||
|
<q-img src="/logo.png" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="drawer-menu" class="q-pr-sm">
|
||||||
|
<q-item
|
||||||
|
v-for="v in labelMenu"
|
||||||
|
:key="v.label"
|
||||||
|
clickable
|
||||||
|
@click="currentRoute = v.label"
|
||||||
|
:class="{ active: currentRoute === v.label, dark: $q.dark.isActive }"
|
||||||
|
>
|
||||||
|
<q-item-section id="btn-drawer-back " class="test">
|
||||||
|
<q-item-label>
|
||||||
|
<!-- <q-icon :name="v.icon" size="sm" class="q-mr-xs" /> -->
|
||||||
|
{{ v.label }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
</q-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
#drawer-menu :deep(.q-item) {
|
||||||
|
color: var(--gray-6);
|
||||||
|
|
||||||
|
border-top-right-radius: 10px;
|
||||||
|
border-bottom-right-radius: 10px;
|
||||||
|
}
|
||||||
|
#drawer-menu :deep(.q-item.active) {
|
||||||
|
--_drawer-item-background-color: var(--brand-1) !important;
|
||||||
|
background-color: var(--_drawer-item-background-color) !important;
|
||||||
|
|
||||||
|
color: white;
|
||||||
|
border-left: 10px solid $secondary;
|
||||||
|
|
||||||
|
&.dark {
|
||||||
|
--_drawer-item-background-color: var(--gray-10) !important;
|
||||||
|
border: 1px solid var(--brand-1);
|
||||||
|
border-left: 10px solid $secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.test {
|
||||||
|
border: 1px solid blue;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
59
src/components/GlobalDialog.vue
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
color?: string;
|
||||||
|
icon?: string;
|
||||||
|
actionText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
persistent?: boolean;
|
||||||
|
action?: (...args: unknown[]) => void;
|
||||||
|
cancel?: (...args: unknown[]) => void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<q-dialog ref="dialogRef" :persistent="persistent || false">
|
||||||
|
<q-card class="q-pa-sm" style="min-width: 300px; max-width: 80%">
|
||||||
|
<q-card-section class="row q-pa-sm">
|
||||||
|
<div
|
||||||
|
class="q-pr-md"
|
||||||
|
v-if="icon"
|
||||||
|
style="display: flex; align-items: center"
|
||||||
|
>
|
||||||
|
<q-avatar
|
||||||
|
:icon="icon"
|
||||||
|
size="xl"
|
||||||
|
font-size="25px"
|
||||||
|
color="grey-2"
|
||||||
|
:text-color="color || 'primary'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col text-dark">
|
||||||
|
<span class="text-bold block">{{ title }}</span>
|
||||||
|
<span class="block">{{ message }}</span>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right" v-if="action || cancel">
|
||||||
|
<q-btn
|
||||||
|
id="btn-cancel-dialog"
|
||||||
|
v-if="cancel"
|
||||||
|
@click="cancel"
|
||||||
|
:label="cancelText || $t('cancel')"
|
||||||
|
:color="color || 'primary'"
|
||||||
|
v-close-popup
|
||||||
|
flat
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
id="btn-ok-dialog"
|
||||||
|
v-if="action"
|
||||||
|
@click="action"
|
||||||
|
:label="actionText || $t('ok')"
|
||||||
|
:color="color || 'primary'"
|
||||||
|
v-close-popup
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss"></style>
|
||||||
20
src/components/GlobalLoading.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
visibility: boolean;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-inner-loading :showing="visibility" class="loader q-gutter-y-xl">
|
||||||
|
<q-spinner size="80px" color="primary" />
|
||||||
|
<span class="text-h5 text-weight-bold text-primary">
|
||||||
|
{{ $t('loading') }}
|
||||||
|
</span>
|
||||||
|
</q-inner-loading>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.loader {
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
214
src/components/UsersDetailCardComponent.vue
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import AppBox from './app/AppBox.vue';
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
dark?: boolean;
|
||||||
|
width?: string;
|
||||||
|
color: 'purple' | 'green';
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
width: '300px',
|
||||||
|
deletable: false,
|
||||||
|
color: 'green',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<div style="aspect-ratio: 9/16" class="flip">
|
||||||
|
<AppBox
|
||||||
|
style="padding: 0"
|
||||||
|
:class="`${$q.dark.isActive ? 'dark ' : ''} color__${color}`"
|
||||||
|
class="surface-1 front"
|
||||||
|
bordered
|
||||||
|
>
|
||||||
|
<div class="row justify-center">
|
||||||
|
<q-card-section class="q-pt-xl img">
|
||||||
|
<q-avatar size="100px">
|
||||||
|
<img src="https://cdn.quasar.dev/img/avatar1.jpg" />
|
||||||
|
</q-avatar>
|
||||||
|
</q-card-section>
|
||||||
|
</div>
|
||||||
|
<div class="box-title">
|
||||||
|
<div class="rounded title">นิติบุคคล</div>
|
||||||
|
</div>
|
||||||
|
<q-card-section class="no-padding">
|
||||||
|
<div class="column items-center justify-center">
|
||||||
|
<div class="row">นางสาวสุดใจ แสนดี</div>
|
||||||
|
<div class="row q-mb-md">HQ0001</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<q-scroll-area
|
||||||
|
class="front-scroll"
|
||||||
|
style="border-radius: 0px 0px 12px 12px"
|
||||||
|
>
|
||||||
|
<q-card-section v-for="v in [1, 2]" :key="v" class="overflow">
|
||||||
|
<div class="color-front-text">ชื่อ บริษัท/นิติบุคคล ภาษาไทย</div>
|
||||||
|
<div>บริษัทเฟรปเป้</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-scroll-area>
|
||||||
|
</div>
|
||||||
|
</AppBox>
|
||||||
|
|
||||||
|
<AppBox
|
||||||
|
style="padding: 0"
|
||||||
|
class="back"
|
||||||
|
:class="`${$q.dark.isActive ? 'dark ' : ''} color__${color}`"
|
||||||
|
bordered
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="row q-pl-md q-pb-md items-center"
|
||||||
|
:class="{
|
||||||
|
'surface-1': !$q.dark.isActive,
|
||||||
|
'surface-2': $q.dark.isActive,
|
||||||
|
}"
|
||||||
|
style="border-radius: 12px 12px 0px 0px"
|
||||||
|
>
|
||||||
|
<q-card-section class="q-pt-xl img">
|
||||||
|
<q-avatar size="50px">
|
||||||
|
<img src="https://cdn.quasar.dev/img/avatar1.jpg" />
|
||||||
|
</q-avatar>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<div class="col-7 q-pl-md">
|
||||||
|
<div class="row">นางสาวสุขใจ แสนดี</div>
|
||||||
|
<div class="row">HQ0001</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-scroll-area
|
||||||
|
class="back-scroll"
|
||||||
|
:class="{
|
||||||
|
'surface-1': $q.dark.isActive,
|
||||||
|
'surface-2': !$q.dark.isActive,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<q-card-section class="q-pa-md">
|
||||||
|
<div
|
||||||
|
v-for="v in [1, 2]"
|
||||||
|
:key="v"
|
||||||
|
class="bordered row q-pa-sm q-mb-sm rounded bg-color-text-1"
|
||||||
|
>
|
||||||
|
<div class="col-2 flex flex-center">{{ v }}</div>
|
||||||
|
<div class="col-10 rounded q-pa-sm bg-color-text-2">HQ0001</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-scroll-area>
|
||||||
|
</AppBox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.color-front-text {
|
||||||
|
color: var(--color-front-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 300px;
|
||||||
|
height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flip {
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
transition: transform 0.4s;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.front,
|
||||||
|
.back {
|
||||||
|
width: 100%;
|
||||||
|
height: min-content;
|
||||||
|
position: absolute;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.front,
|
||||||
|
.back {
|
||||||
|
--_color: var(--teal-6);
|
||||||
|
--_color-dark: var(--_color);
|
||||||
|
--_bg-front-scroll: hsla(var(--_color) / 0.05);
|
||||||
|
--_bg-back-detail-1: white;
|
||||||
|
--_bg-back-detail-2: hsla(var(--_color) / 0.05) !important;
|
||||||
|
|
||||||
|
.img {
|
||||||
|
background: hsl(var(--_color)) !important;
|
||||||
|
border-bottom-left-radius: 100px;
|
||||||
|
border-bottom-right-radius: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
width: 50%;
|
||||||
|
height: 25px;
|
||||||
|
background: hsl(var(--_color)) !important;
|
||||||
|
position: relative;
|
||||||
|
bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.front-scroll {
|
||||||
|
border-radius: 0px 0px 5px 5px;
|
||||||
|
background: var(--_bg-front-scroll) !important;
|
||||||
|
height: 254px;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-scroll {
|
||||||
|
height: 370px;
|
||||||
|
max-width: 300px;
|
||||||
|
|
||||||
|
border-radius: 0px 0px 15px 15px;
|
||||||
|
|
||||||
|
.bg-color-text-1 {
|
||||||
|
background: var(--_bg-back-detail-1);
|
||||||
|
}
|
||||||
|
.bg-color-text-2 {
|
||||||
|
background: var(--_bg-back-detail-2) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dark {
|
||||||
|
color: hsl(--_color-dark) !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
|
||||||
|
--_bg-back-avatar: var(--gray-11) !important;
|
||||||
|
--_bg-back-detail-1: var(--gray-9) !important;
|
||||||
|
--_bg-front-scroll: var(--gray-11);
|
||||||
|
--_bg-back-detail-2: hsla(var(--_color) / 0.1) !important;
|
||||||
|
}
|
||||||
|
&.color__purple {
|
||||||
|
--_color: var(--purple-11-hsl);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.color__green {
|
||||||
|
--_color: var(--green-9-hsl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
background: #f5f5f5;
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded {
|
||||||
|
border-radius: 5px 5px 5px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
23
src/components/app/AppBox.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
defineProps<{ dark?: boolean; bordered?: boolean }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-box" :class="{ dark, 'app-box__bordered': bordered }">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-box {
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--surface-1);
|
||||||
|
border-radius: var(--radius-3);
|
||||||
|
padding: var(--size-4);
|
||||||
|
box-shadow: var(--shadow-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-box__bordered {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
25
src/components/app/AppCircle.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
defineProps<{ bordered?: boolean; dark?: boolean }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-circle" :class="{ dark, 'app-circle__bordered': bordered }">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-circle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-round);
|
||||||
|
background-color: var(--surface-1);
|
||||||
|
aspect-ratio: var(--ratio-square);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-circle__bordered {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
112
src/components/home/MenuItem.vue
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import AppBox from 'components/app/AppBox.vue';
|
||||||
|
import AppCircle from 'components/app/AppCircle.vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
list: {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
caption: string;
|
||||||
|
color:
|
||||||
|
| 'green'
|
||||||
|
| 'red'
|
||||||
|
| 'orange'
|
||||||
|
| 'cyan'
|
||||||
|
| 'camo'
|
||||||
|
| 'purple'
|
||||||
|
| 'violet'
|
||||||
|
| 'indigo'
|
||||||
|
| 'lime';
|
||||||
|
}[];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="menu-container">
|
||||||
|
<AppBox
|
||||||
|
class="column inline-flex items-center"
|
||||||
|
v-for="(v, i) in list"
|
||||||
|
:key="i"
|
||||||
|
:bordered="$q.dark.isActive"
|
||||||
|
>
|
||||||
|
<AppCircle
|
||||||
|
:class="`q-pa-sm menu-icon menu-icon__${v.color}${($q.dark.isActive && ' dark') || ''}`"
|
||||||
|
:bordered="$q.dark.isActive"
|
||||||
|
>
|
||||||
|
<q-icon size="3rem" :name="v.icon" />
|
||||||
|
</AppCircle>
|
||||||
|
<div class="column items-center q-mt-md text-center">
|
||||||
|
<span style="font-weight: var(--font-weight-8)">{{ v.title }}</span>
|
||||||
|
<span style="color: rgba(130 130 130 / 0.7)">{{ v.caption }}</span>
|
||||||
|
</div>
|
||||||
|
</AppBox>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.menu-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: var(--size-6);
|
||||||
|
& > * {
|
||||||
|
transition: 100ms border-color ease-in-out;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--brand-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-icon {
|
||||||
|
--_color: var(--gray-7-hsl);
|
||||||
|
--_color-dark: var(--_color);
|
||||||
|
color: hsl(var(--_color)) !important;
|
||||||
|
background-color: hsla(var(--_color) / 0.1) !important;
|
||||||
|
|
||||||
|
&.dark {
|
||||||
|
color: hsl(--_color-dark) !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.menu-icon__green {
|
||||||
|
--_color: var(--green-6-hsl);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.menu-icon__red {
|
||||||
|
--_color: var(--red-8-hsl);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.menu-icon__orange {
|
||||||
|
--_color: var(--orange-6-hsl);
|
||||||
|
--_color-dark: var(--orange-5-hsl);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.menu-icon__cyan {
|
||||||
|
--_color: var(--cyan-5-hsl);
|
||||||
|
--_color-dark: var(--cyan-4-hsl);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.menu-icon__camo {
|
||||||
|
--_color: var(--camo-5-hsl);
|
||||||
|
--_color-dark: var(--cyan-4-hsl);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.menu-icon__purple {
|
||||||
|
--_color: var(--purple-3-hsl);
|
||||||
|
--_color-dark: var(--purple-5-hsl);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.menu-icon__violet {
|
||||||
|
--_color: var(--violet-5-hsl);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.menu-icon__indigo {
|
||||||
|
--_color: var(--indigo-9-hsl);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.menu-icon__lime {
|
||||||
|
--_color: var(--lime-11-hsl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
112
src/components/home/PersonCard.vue
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import AppBox from 'components/app/AppBox.vue';
|
||||||
|
import AppCircle from '../app/AppCircle.vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
list: {
|
||||||
|
name: string;
|
||||||
|
badge: string;
|
||||||
|
detail: { label: string; value: string }[];
|
||||||
|
male?: boolean;
|
||||||
|
female?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}[];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="person-container">
|
||||||
|
<AppBox
|
||||||
|
:bordered="$q.dark.isActive"
|
||||||
|
class="column"
|
||||||
|
style="padding: 0"
|
||||||
|
v-for="(v, i) in list"
|
||||||
|
:key="i"
|
||||||
|
>
|
||||||
|
<div class="q-pa-md column items-center">
|
||||||
|
<AppCircle class="surface-2 avatar q-mb-md" bordered>Avatar</AppCircle>
|
||||||
|
|
||||||
|
<span>{{ v.name }}</span>
|
||||||
|
<span
|
||||||
|
class="badge"
|
||||||
|
:class="{
|
||||||
|
'bg-gender': v.male || v.female,
|
||||||
|
'bg-gender__male': v.male,
|
||||||
|
'bg-gender__female': v.female,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ v.badge }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<q-separator />
|
||||||
|
<div
|
||||||
|
class="q-py-sm q-px-xl person-detail rounded-b"
|
||||||
|
:class="{
|
||||||
|
'bg-gender': v.male || v.female,
|
||||||
|
'bg-gender__light': v.male || v.female,
|
||||||
|
'bg-gender__male': v.male,
|
||||||
|
'bg-gender__female': v.female,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div v-for="(d, j) in v.detail" :key="j">
|
||||||
|
<span>{{ d.label }}</span>
|
||||||
|
<span>{{ d.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppBox>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.person-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--size-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
block-size: 7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: var(--radius-6);
|
||||||
|
background-color: var(--surface-2);
|
||||||
|
padding: 0 var(--size-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-detail {
|
||||||
|
display: grid;
|
||||||
|
flex-grow: 1;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--size-2);
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
& > :first-child {
|
||||||
|
font-size: var(--font-size-0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.bg-gender {
|
||||||
|
color: hsla(var(--_fg));
|
||||||
|
background-color: hsl(var(--_bg));
|
||||||
|
|
||||||
|
&.bg-gender__light {
|
||||||
|
color: unset;
|
||||||
|
background-color: hsla(var(--_bg) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bg-gender__male {
|
||||||
|
--_fg: 0 100 100%;
|
||||||
|
--_bg: var(--blue-5-hsl);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bg-gender__female {
|
||||||
|
--_fg: 0 100 100%;
|
||||||
|
--_bg: var(--pink-7-hsl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
56
src/css/app.scss
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
@import 'open-props/postcss/style';
|
||||||
|
|
||||||
|
@import 'open-props/colors-hsl';
|
||||||
|
@import 'open-props/shadows';
|
||||||
|
@import 'open-props/zindex';
|
||||||
|
|
||||||
|
html {
|
||||||
|
--brand-1: #035aa1;
|
||||||
|
--brand-2: #f50000;
|
||||||
|
|
||||||
|
--border-color: var(--gray-4);
|
||||||
|
|
||||||
|
--foreground: black;
|
||||||
|
--background: var(--gray-1);
|
||||||
|
--surface-0: var(--background);
|
||||||
|
--surface-1: white;
|
||||||
|
--surface-2: var(--gray-1);
|
||||||
|
--surface-3: var(--gray-2);
|
||||||
|
|
||||||
|
color: var(--foreground);
|
||||||
|
background-color: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.dark, .body--dark) {
|
||||||
|
--brand-1: var(--blue-7);
|
||||||
|
--brand-2: #f50000;
|
||||||
|
|
||||||
|
--border-color: var(--gray-8);
|
||||||
|
|
||||||
|
--foreground: white;
|
||||||
|
--background: var(--gray-10);
|
||||||
|
--surface-0: var(--background);
|
||||||
|
--surface-1: var(--gray-11);
|
||||||
|
--surface-2: var(--gray-10);
|
||||||
|
--surface-3: var(--gray-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bordered {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded {
|
||||||
|
border-radius: var(--radius-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-1 {
|
||||||
|
background-color: var(--surface-1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-2 {
|
||||||
|
background-color: var(--surface-2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-3 {
|
||||||
|
background-color: var(--surface-3) !important;
|
||||||
|
}
|
||||||
40
src/css/quasar.variables.scss
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+Thai:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||||
|
|
||||||
|
$typography-font-family: 'Noto Sans Thai', sans-serif !default;
|
||||||
|
|
||||||
|
$primary: var(--brand-1);
|
||||||
|
$secondary: var(--brand-2);
|
||||||
|
$accent: #9c27b0;
|
||||||
|
|
||||||
|
$dark: var(--gray-11);
|
||||||
|
$dark-page: var(--gray-10);
|
||||||
|
|
||||||
|
$positive: #00bd9d;
|
||||||
|
$negative: #cc0004;
|
||||||
|
$info: #328bf3;
|
||||||
|
$warning: #ffc224;
|
||||||
|
|
||||||
|
$separator-color: var(--border-color);
|
||||||
|
$separator-dark-color: var(--border-color);
|
||||||
|
|
||||||
|
.q-separator {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-field__control {
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-field--outlined .q-field__control {
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
transition: none;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::before {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
declare namespace NodeJS {
|
||||||
|
interface ProcessEnv {
|
||||||
|
NODE_ENV: string;
|
||||||
|
VUE_ROUTER_MODE: 'hash' | 'history' | 'abstract' | undefined;
|
||||||
|
VUE_ROUTER_BASE: string | undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/i18n/en-US/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
// This is just an example,
|
||||||
|
// so you can safely delete all default props below
|
||||||
|
|
||||||
|
export default {
|
||||||
|
ok: 'Confirm',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
failed: 'Failed',
|
||||||
|
success: 'Success',
|
||||||
|
search: 'Search',
|
||||||
|
download: 'Download',
|
||||||
|
save: 'Save',
|
||||||
|
};
|
||||||
7
src/i18n/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import enUS from './en-US';
|
||||||
|
import th from './th-th';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
'en-US': enUS,
|
||||||
|
'th-th': th,
|
||||||
|
};
|
||||||
9
src/i18n/th-th/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export default {
|
||||||
|
ok: 'ยืนยัน',
|
||||||
|
cancel: 'ยกเลิก',
|
||||||
|
failed: 'เกิดข้อผิดพลาด',
|
||||||
|
success: 'สำเร็จ',
|
||||||
|
search: 'ค้นหา',
|
||||||
|
download: 'ดาวน์โหลด',
|
||||||
|
save: 'บันทึก',
|
||||||
|
};
|
||||||
54
src/layouts/MainLayout.vue
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
|
||||||
|
import useLoader from 'stores/loader';
|
||||||
|
import DrawerComponent from 'components/DrawerComponent.vue';
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
const loaderStore = useLoader();
|
||||||
|
|
||||||
|
const { visible } = storeToRefs(loaderStore);
|
||||||
|
|
||||||
|
const leftDrawerOpen = ref($q.screen.gt.sm);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-layout view="lHh Lpr lFf">
|
||||||
|
<q-header style="background-color: var(--background)">
|
||||||
|
<div class="row items-center justify-between q-py-md q-px-lg">
|
||||||
|
<q-btn
|
||||||
|
round
|
||||||
|
unelevated
|
||||||
|
id="drawer-toggler"
|
||||||
|
icon="mdi-backburger"
|
||||||
|
:class="{ bordered: $q.dark.isActive }"
|
||||||
|
class="surface-2"
|
||||||
|
style="color: var(--gray-6)"
|
||||||
|
@click="leftDrawerOpen = !leftDrawerOpen"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
round
|
||||||
|
unelevated
|
||||||
|
id="drawer-toggler"
|
||||||
|
icon="mdi-switch"
|
||||||
|
:class="{ bordered: $q.dark.isActive }"
|
||||||
|
class="surface-2"
|
||||||
|
style="color: var(--gray-6)"
|
||||||
|
@click="$q.dark.toggle()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-header>
|
||||||
|
|
||||||
|
<q-page-container style="background-color: transparent">
|
||||||
|
<q-page class="q-px-lg q-pa-md">
|
||||||
|
<router-view />
|
||||||
|
</q-page>
|
||||||
|
</q-page-container>
|
||||||
|
|
||||||
|
<drawer-component v-model:leftDrawerOpen="leftDrawerOpen" />
|
||||||
|
|
||||||
|
<global-loading :visibility="visible" />
|
||||||
|
</q-layout>
|
||||||
|
</template>
|
||||||
112
src/pages/MainPage.vue
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import MenuItem from 'components/home/MenuItem.vue';
|
||||||
|
import PersonCard from 'src/components/home/PersonCard.vue';
|
||||||
|
import UsersDetailCardComponent from 'components/UsersDetailCardComponent.vue';
|
||||||
|
|
||||||
|
const menu = [
|
||||||
|
{
|
||||||
|
icon: 'mdi-home',
|
||||||
|
color: 'green',
|
||||||
|
title: 'Home',
|
||||||
|
caption: 'Home Caption',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'mdi-home',
|
||||||
|
color: 'red',
|
||||||
|
title: 'Home',
|
||||||
|
caption: 'Home Caption',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'mdi-home',
|
||||||
|
color: 'orange',
|
||||||
|
title: 'Home',
|
||||||
|
caption: 'Home Caption',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'mdi-home',
|
||||||
|
color: 'cyan',
|
||||||
|
title: 'Home',
|
||||||
|
caption: 'Home Caption',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'mdi-home',
|
||||||
|
color: 'camo',
|
||||||
|
title: 'Home',
|
||||||
|
caption: 'Home Caption',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'mdi-home',
|
||||||
|
color: 'purple',
|
||||||
|
title: 'Home',
|
||||||
|
caption: 'Home Caption',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'mdi-home',
|
||||||
|
color: 'violet',
|
||||||
|
title: 'Home',
|
||||||
|
caption: 'Home Caption',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'mdi-home',
|
||||||
|
color: 'indigo',
|
||||||
|
title: 'Home',
|
||||||
|
caption: 'Home Caption',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'mdi-home',
|
||||||
|
color: 'lime',
|
||||||
|
title: 'Home',
|
||||||
|
caption: 'Home Caption',
|
||||||
|
},
|
||||||
|
] satisfies InstanceType<typeof MenuItem>['$props']['list'];
|
||||||
|
|
||||||
|
const person = [
|
||||||
|
{
|
||||||
|
name: 'Person 1',
|
||||||
|
badge: 'Badge 1',
|
||||||
|
detail: [
|
||||||
|
{ label: 'Label 1', value: 'Detail 1' },
|
||||||
|
{ label: 'Label 2', value: 'Detail 2' },
|
||||||
|
],
|
||||||
|
male: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Person 1',
|
||||||
|
badge: 'Badge 1',
|
||||||
|
detail: [
|
||||||
|
{ label: 'Label 1', value: 'Detail 1' },
|
||||||
|
{ label: 'Label 2', value: 'Detail 2' },
|
||||||
|
{ label: 'Label 3', value: 'Detail 3' },
|
||||||
|
],
|
||||||
|
female: true,
|
||||||
|
},
|
||||||
|
] satisfies InstanceType<typeof PersonCard>['$props']['list'];
|
||||||
|
|
||||||
|
const input = ref('');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-input
|
||||||
|
label="test"
|
||||||
|
v-model="input"
|
||||||
|
:rules="[(v) => (!!v && v.length > 3) || 'Required']"
|
||||||
|
outlined
|
||||||
|
></q-input>
|
||||||
|
|
||||||
|
<PersonCard :list="person" class="q-mb-md" />
|
||||||
|
|
||||||
|
<MenuItem :list="menu" />
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<users-detail-card-component class="q-pa-md" v-for="v in [1, 2]" :key="v" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.person {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--size-6);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
23
src/pages/error/NotFound.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 30vh">404</div>
|
||||||
|
|
||||||
|
<div class="text-h2" style="opacity: 0.4">Oops. Nothing here...</div>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
class="q-mt-xl"
|
||||||
|
color="white"
|
||||||
|
text-color="blue"
|
||||||
|
unelevated
|
||||||
|
to="/"
|
||||||
|
label="Go Home"
|
||||||
|
no-caps
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts"></script>
|
||||||
9
src/quasar.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
// Forces TS to apply `@quasar/app-vite` augmentations of `quasar` package
|
||||||
|
// Removing this would break `quasar/wrappers` imports as those typings are declared
|
||||||
|
// into `@quasar/app-vite`
|
||||||
|
// As a side effect, since `@quasar/app-vite` reference `quasar` to augment it,
|
||||||
|
// this declaration also apply `quasar` own
|
||||||
|
// augmentations (eg. adds `$q` into Vue component context)
|
||||||
|
/// <reference types="@quasar/app-vite" />
|
||||||
42
src/router/index.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { route } from 'quasar/wrappers';
|
||||||
|
import {
|
||||||
|
createMemoryHistory,
|
||||||
|
createRouter,
|
||||||
|
createWebHashHistory,
|
||||||
|
createWebHistory,
|
||||||
|
} from 'vue-router';
|
||||||
|
import { login } from 'src/services/keycloak';
|
||||||
|
import routes from './routes';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If not building with SSR mode, you can
|
||||||
|
* directly export the Router instantiation;
|
||||||
|
*
|
||||||
|
* The function below can be async too; either use
|
||||||
|
* async/await or return a Promise which resolves
|
||||||
|
* with the Router instance.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default route(function (/* { store, ssrContext } */) {
|
||||||
|
const createHistory = process.env.SERVER
|
||||||
|
? createMemoryHistory
|
||||||
|
: process.env.VUE_ROUTER_MODE === 'history'
|
||||||
|
? createWebHistory
|
||||||
|
: createWebHashHistory;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
login().then(() => {
|
||||||
|
const Router = createRouter({
|
||||||
|
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||||
|
routes,
|
||||||
|
|
||||||
|
// Leave this as is and make changes in quasar.conf.js instead!
|
||||||
|
// quasar.conf.js -> build -> vueRouterMode
|
||||||
|
// quasar.conf.js -> build -> publicPath
|
||||||
|
history: createHistory(process.env.VUE_ROUTER_BASE),
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve(Router);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
25
src/router/routes.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { RouteRecordRaw } from 'vue-router';
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirect: { name: 'Home' },
|
||||||
|
component: () => import('layouts/MainLayout.vue'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Home',
|
||||||
|
component: () => import('pages/MainPage.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Always leave this as last one,
|
||||||
|
// but you can also remove it
|
||||||
|
{
|
||||||
|
path: '/:catchAll(.*)*',
|
||||||
|
component: () => import('pages/error/NotFound.vue'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default routes;
|
||||||
61
src/services/keycloak.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import Keycloak from 'keycloak-js';
|
||||||
|
|
||||||
|
const keycloak = new Keycloak({
|
||||||
|
url: import.meta.env.VITE_KC_URL,
|
||||||
|
realm: import.meta.env.VITE_KC_REALM,
|
||||||
|
clientId: import.meta.env.VITE_KC_CLIENT_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
let init = false;
|
||||||
|
|
||||||
|
export const keycloakInstance = keycloak;
|
||||||
|
|
||||||
|
export async function login(cb?: (...args: unknown[]) => void) {
|
||||||
|
if (import.meta.env.VITE_KC_DISABLED) return cb?.();
|
||||||
|
|
||||||
|
const auth = !init
|
||||||
|
? await keycloak
|
||||||
|
.init({
|
||||||
|
onLoad: 'login-required',
|
||||||
|
responseMode: 'query',
|
||||||
|
checkLoginIframe: false,
|
||||||
|
})
|
||||||
|
.catch((e) => console.dir(e))
|
||||||
|
: await keycloak.login().catch((e) => console.dir(e));
|
||||||
|
|
||||||
|
if (auth) init = true;
|
||||||
|
if (auth && cb) cb();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
await keycloak.logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getToken() {
|
||||||
|
await keycloak.updateToken(60).catch(() => login());
|
||||||
|
return keycloak.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserId(): string | undefined {
|
||||||
|
return keycloak.tokenParsed?.sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUsername(): string | undefined {
|
||||||
|
return keycloak.tokenParsed?.preferred_username;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getName(): string | undefined {
|
||||||
|
return keycloak.tokenParsed?.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEmail(): string | undefined {
|
||||||
|
return keycloak.tokenParsed?.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRole(): string[] | undefined {
|
||||||
|
return keycloak.tokenParsed?.roles ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLoggedIn() {
|
||||||
|
return !!keycloak.token;
|
||||||
|
}
|
||||||
10
src/shims-vue.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
32
src/stores/index.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { store } from 'quasar/wrappers';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import { Router } from 'vue-router';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When adding new properties to stores, you should also
|
||||||
|
* extend the `PiniaCustomProperties` interface.
|
||||||
|
* @see https://pinia.vuejs.org/core-concepts/plugins.html#typing-new-store-properties
|
||||||
|
*/
|
||||||
|
declare module 'pinia' {
|
||||||
|
export interface PiniaCustomProperties {
|
||||||
|
readonly router: Router;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If not building with SSR mode, you can
|
||||||
|
* directly export the Store instantiation;
|
||||||
|
*
|
||||||
|
* The function below can be async too; either use
|
||||||
|
* async/await or return a Promise which resolves
|
||||||
|
* with the Store instance.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default store((/* { ssrContext } */) => {
|
||||||
|
const pinia = createPinia();
|
||||||
|
|
||||||
|
// You can add Pinia plugins here
|
||||||
|
// pinia.use(SomePiniaPlugin)
|
||||||
|
|
||||||
|
return pinia;
|
||||||
|
});
|
||||||
21
src/stores/loader/index.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const useLoader = defineStore('loader-store', () => {
|
||||||
|
const visible = ref(false);
|
||||||
|
|
||||||
|
return {
|
||||||
|
visible,
|
||||||
|
toggle() {
|
||||||
|
visible.value = !visible.value;
|
||||||
|
},
|
||||||
|
show() {
|
||||||
|
visible.value = true;
|
||||||
|
},
|
||||||
|
hide() {
|
||||||
|
visible.value = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default useLoader;
|
||||||
558
src/stores/storage/index.ts
Normal file
|
|
@ -0,0 +1,558 @@
|
||||||
|
import { computed, reactive, ref } from 'vue';
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
import { api } from 'src/boot/axios';
|
||||||
|
|
||||||
|
import useLoader from '../loader';
|
||||||
|
import { useUtils } from '../utils';
|
||||||
|
|
||||||
|
type Path = string;
|
||||||
|
|
||||||
|
export interface StorageFolder {
|
||||||
|
pathname: string;
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
createdAt: string;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorageFile {
|
||||||
|
pathname: string;
|
||||||
|
path: string;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
fileType: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
author: string;
|
||||||
|
category: string[];
|
||||||
|
keyword: string[];
|
||||||
|
updatedAt: string;
|
||||||
|
updatedBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Structure extends StorageFolder {
|
||||||
|
folder: Structure[];
|
||||||
|
file: StorageFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tree = Structure[];
|
||||||
|
|
||||||
|
export type SearchOptions = {
|
||||||
|
path?: string[];
|
||||||
|
recursive?: boolean;
|
||||||
|
exact?: boolean;
|
||||||
|
within?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchInfo = {
|
||||||
|
field: string;
|
||||||
|
value: string;
|
||||||
|
exact?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchOperator = {
|
||||||
|
AND?: (SearchInfo | SearchOperator)[];
|
||||||
|
OR?: (SearchInfo | SearchOperator)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileMetadata = {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string[];
|
||||||
|
keyword?: string[];
|
||||||
|
author?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizePath(path: string | string[]) {
|
||||||
|
return Array.isArray(path)
|
||||||
|
? path.join('/') + '/'
|
||||||
|
: path.split('/').filter(Boolean).join('/') + '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStorage = defineStore('storageStore', () => {
|
||||||
|
const utils = useUtils();
|
||||||
|
const loader = useLoader();
|
||||||
|
const init = ref(false);
|
||||||
|
const folder = ref<Record<Path, StorageFolder[]>>({});
|
||||||
|
const file = ref<Record<Path, StorageFile[]>>({});
|
||||||
|
const tree = computed(() => {
|
||||||
|
let structure: Tree = [];
|
||||||
|
|
||||||
|
// parse list of folder and list of file into tree
|
||||||
|
Object.entries(folder.value).forEach(([key, value]) => {
|
||||||
|
const arr = key.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
// Once run then it is init
|
||||||
|
if (!init.value) init.value = true;
|
||||||
|
|
||||||
|
if (arr.length === 0) {
|
||||||
|
structure = value.map((v) => ({
|
||||||
|
pathname: v.pathname,
|
||||||
|
name: v.name,
|
||||||
|
createdAt: v.createdAt,
|
||||||
|
createdBy: v.createdBy,
|
||||||
|
folder: [],
|
||||||
|
file: [],
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
let current: Structure | undefined;
|
||||||
|
|
||||||
|
// traverse into tree
|
||||||
|
arr.forEach((v, i) => {
|
||||||
|
current =
|
||||||
|
i === 0
|
||||||
|
? structure.find((x) => x.name === v)
|
||||||
|
: current?.folder.find((x) => x.name === v);
|
||||||
|
});
|
||||||
|
|
||||||
|
// set data in tree (object is references to the same object)
|
||||||
|
if (current) {
|
||||||
|
current.folder = value.map((v) => ({
|
||||||
|
pathname: v.pathname,
|
||||||
|
name: v.name,
|
||||||
|
createdAt: v.createdAt,
|
||||||
|
createdBy: v.createdBy,
|
||||||
|
folder: [],
|
||||||
|
file: [],
|
||||||
|
}));
|
||||||
|
current.file = file.value[key] ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return structure;
|
||||||
|
});
|
||||||
|
const currentFile = ref<StorageFile>();
|
||||||
|
const currentInfo = reactive<{
|
||||||
|
path: string;
|
||||||
|
dept: number;
|
||||||
|
}>({
|
||||||
|
path: '',
|
||||||
|
dept: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!init.value) goto(sessionStorage.getItem('path') || '');
|
||||||
|
|
||||||
|
async function getStorage(path: string = '') {
|
||||||
|
const arr = path.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
const res = await api.post<(typeof folder.value)[string]>('/storage/list', {
|
||||||
|
operation: 'folder',
|
||||||
|
path: arr,
|
||||||
|
});
|
||||||
|
if (res.status === 200 && res.data && Array.isArray(res.data))
|
||||||
|
folder.value[normalizePath(path)] = res.data.sort((a, b) =>
|
||||||
|
a.pathname.localeCompare(b.pathname),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStorageFile(path: string = '') {
|
||||||
|
const arr = path.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
const res = await api.post<(typeof file.value)[string]>('/storage/list', {
|
||||||
|
operation: 'file',
|
||||||
|
path: arr,
|
||||||
|
});
|
||||||
|
if (res.status === 200 && res.data && Array.isArray(res.data))
|
||||||
|
file.value[normalizePath(path)] = res.data.sort((a, b) =>
|
||||||
|
a.pathname.localeCompare(b.pathname),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goto(path: string = '', force = false) {
|
||||||
|
loader.show();
|
||||||
|
const arr = path.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
// get all parent to the root structure
|
||||||
|
// this will also triggher init structure as it get root structure
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
const current = normalizePath(arr.slice(0, i - arr.length));
|
||||||
|
if (!folder.value[current] || force) await getStorage(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
path = normalizePath(path);
|
||||||
|
|
||||||
|
// only get this path once, after that will get from socket.io-client instead
|
||||||
|
if (!folder.value[path] || force) await getStorage(path);
|
||||||
|
if (!file.value[path] || force) await getStorageFile(path);
|
||||||
|
|
||||||
|
currentFile.value = undefined;
|
||||||
|
currentInfo.path = path;
|
||||||
|
currentInfo.dept = path.split('/').filter(Boolean).length;
|
||||||
|
sessionStorage.setItem('path', path);
|
||||||
|
loader.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gotoParent() {
|
||||||
|
const arr = currentInfo.path.split('/').filter(Boolean);
|
||||||
|
await goto(normalizePath(arr.slice(0, -1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// socket.io zone
|
||||||
|
const socket = io(new URL(import.meta.env.VITE_API_BASE_URL).origin);
|
||||||
|
|
||||||
|
socket.on('connect', () => console.info('Socket.io connected.'));
|
||||||
|
socket.on('disconnect', () => console.info('Socket.io disconnected.'));
|
||||||
|
socket.on(
|
||||||
|
'FolderCreate',
|
||||||
|
(data: {
|
||||||
|
pathname: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
createdBy: string;
|
||||||
|
}) => {
|
||||||
|
const arr = data.pathname.split('/').filter(Boolean);
|
||||||
|
const path = normalizePath(arr.slice(0, -1));
|
||||||
|
|
||||||
|
if (folder.value[path]) {
|
||||||
|
const idx = folder.value[path].findIndex(
|
||||||
|
(v) => v.pathname === data.pathname,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (idx === -1) {
|
||||||
|
folder.value[path].push({
|
||||||
|
pathname: data.pathname,
|
||||||
|
name: data.name,
|
||||||
|
createdAt: data.createdAt,
|
||||||
|
createdBy: data.createdBy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
folder.value[path].sort((a, b) => a.pathname.localeCompare(b.pathname));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// NOTE:
|
||||||
|
// API planned to make new endpoint that can move and rename in one go.
|
||||||
|
// Need to change if api handle move and rename file instead of just edit.
|
||||||
|
socket.on('FolderMove', (data: { from: string; to: string }) => {
|
||||||
|
const src = data.from.split('/').filter(Boolean);
|
||||||
|
const dst = data.to.split('/').filter(Boolean);
|
||||||
|
const path = normalizePath(src.slice(0, -1));
|
||||||
|
|
||||||
|
if (folder.value[path]) {
|
||||||
|
const val = folder.value[path].find((v) => v.pathname === data.from);
|
||||||
|
|
||||||
|
if (val) {
|
||||||
|
val.pathname = data.to;
|
||||||
|
val.name = dst[dst.length - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const regex = new RegExp(`^${data.from}`);
|
||||||
|
|
||||||
|
for (const key in folder.value) {
|
||||||
|
if (key.startsWith(data.from)) {
|
||||||
|
folder.value[key.replace(regex, data.to)] = folder.value[key].map(
|
||||||
|
(v) => {
|
||||||
|
v.pathname = v.pathname.replace(regex, data.to);
|
||||||
|
return v;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
delete folder.value[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key in file.value) {
|
||||||
|
if (key.startsWith(data.from)) {
|
||||||
|
file.value[key.replace(regex, data.to)] = file.value[key].map((v) => {
|
||||||
|
v.pathname = v.pathname.replace(regex, data.to);
|
||||||
|
return v;
|
||||||
|
});
|
||||||
|
delete file.value[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('FolderDelete', (data: { pathname: string }) => {
|
||||||
|
for (const key in folder.value) {
|
||||||
|
if (key.startsWith(data.pathname)) {
|
||||||
|
delete folder.value[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key in file.value) {
|
||||||
|
if (key.startsWith(data.pathname)) {
|
||||||
|
delete file.value[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const arr = data.pathname.split('/').filter(Boolean);
|
||||||
|
const path = normalizePath(arr.slice(0, -1));
|
||||||
|
|
||||||
|
if (folder.value[path]) {
|
||||||
|
folder.value[path] = folder.value[path].filter(
|
||||||
|
(v) => v.pathname !== data.pathname,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentInfo.path.length >= normalizePath(arr).length &&
|
||||||
|
currentInfo.path.startsWith(normalizePath(arr))
|
||||||
|
) {
|
||||||
|
utils.openDialog({
|
||||||
|
title: 'แจ้งเตือน',
|
||||||
|
message: 'ข้อมูลที่คุณกำลังเข้าถึงอยู่ถูกลบ',
|
||||||
|
});
|
||||||
|
goto();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('FileUpload', (data: StorageFile) => {
|
||||||
|
const arr = data.pathname.split('/').filter(Boolean);
|
||||||
|
const path = normalizePath(arr.slice(0, -1));
|
||||||
|
|
||||||
|
if (file.value[path]) {
|
||||||
|
const idx = file.value[path].findIndex(
|
||||||
|
(v) => v.pathname === data.pathname,
|
||||||
|
);
|
||||||
|
if (idx !== -1) file.value[path][idx] = data;
|
||||||
|
else file.value[path].push(data);
|
||||||
|
|
||||||
|
file.value[path].sort((a, b) => a.pathname.localeCompare(b.pathname));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('FileDelete', (data: { pathname: string }) => {
|
||||||
|
const arr = data.pathname.split('/').filter(Boolean);
|
||||||
|
const path = normalizePath(arr.slice(0, -1));
|
||||||
|
|
||||||
|
if (file.value[path]) {
|
||||||
|
file.value[path] = file.value[path].filter(
|
||||||
|
(v) => v.pathname !== data.pathname,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('FileMove', (data: { from: StorageFile; to: StorageFile }) => {
|
||||||
|
const arr = data.from.pathname.split('/').filter(Boolean);
|
||||||
|
const path = normalizePath(arr.slice(0, -1));
|
||||||
|
|
||||||
|
if (file.value[path]) {
|
||||||
|
const idx = file.value[path].findIndex(
|
||||||
|
(v) => v.pathname === data.from.pathname,
|
||||||
|
);
|
||||||
|
if (idx !== -1) file.value[path][idx] = data.to;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('FileUpdate', (data: StorageFile) => {
|
||||||
|
const arr = data.pathname.split('/').filter(Boolean);
|
||||||
|
const path = normalizePath(arr.slice(0, -1));
|
||||||
|
|
||||||
|
if (file.value[path]) {
|
||||||
|
const idx = file.value[path].findIndex(
|
||||||
|
(v) => v.pathname === data.pathname,
|
||||||
|
);
|
||||||
|
if (idx !== -1) file.value[path][idx] = data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('FileUploadRequest', (data: StorageFile) => {
|
||||||
|
const arr = data.pathname.split('/').filter(Boolean);
|
||||||
|
const path = normalizePath(arr.slice(0, -1));
|
||||||
|
|
||||||
|
if (file.value[path]) {
|
||||||
|
const idx = file.value[path].findIndex(
|
||||||
|
(v) => v.pathname === data.pathname,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (idx !== -1) file.value[path][idx] = data;
|
||||||
|
else file.value[path].push(data);
|
||||||
|
|
||||||
|
file.value[path].sort((a, b) => a.pathname.localeCompare(b.pathname));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createFolder(name: string, path: string = currentInfo.path) {
|
||||||
|
loader.show();
|
||||||
|
if (
|
||||||
|
folder.value[normalizePath(path)]?.findIndex((v) => v.name === name) !==
|
||||||
|
-1
|
||||||
|
) {
|
||||||
|
utils.openDialog({
|
||||||
|
title: 'แจ้งเตือน',
|
||||||
|
message: `พบชื่อ \"${name}\" ซ้ำในระบบ`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const arrayPath: string[] = path.split('/').filter(Boolean);
|
||||||
|
await api.post('/storage/folder', {
|
||||||
|
path: arrayPath,
|
||||||
|
name: name.replace(/^\./, ''),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
loader.hide();
|
||||||
|
}
|
||||||
|
async function editFolder(name: string, path: string) {
|
||||||
|
loader.show();
|
||||||
|
const arrayPath: string[] = path.split('/').filter(Boolean);
|
||||||
|
const beforeName = arrayPath.pop();
|
||||||
|
|
||||||
|
await api.put('/storage/folder', {
|
||||||
|
from: {
|
||||||
|
name: beforeName,
|
||||||
|
path: arrayPath,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
name: name,
|
||||||
|
path: arrayPath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
loader.hide();
|
||||||
|
}
|
||||||
|
async function deleteFolder(path: string) {
|
||||||
|
loader.show();
|
||||||
|
await api.delete<(typeof file.value)[string]>('/storage/folder', {
|
||||||
|
data: { path: path.split('/').filter(Boolean) },
|
||||||
|
});
|
||||||
|
loader.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFile(
|
||||||
|
file: File,
|
||||||
|
data: FileMetadata,
|
||||||
|
path: string = currentInfo.path,
|
||||||
|
) {
|
||||||
|
const arr = path.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
loader.show();
|
||||||
|
const res = await api.post('/storage/file', {
|
||||||
|
path: arr,
|
||||||
|
file: file.name,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
if (res && res.status === 200 && res.data && res.data.uploadUrl) {
|
||||||
|
await axios
|
||||||
|
.put(res.data.uploadUrl, file, {
|
||||||
|
headers: { 'Content-Type': file.type },
|
||||||
|
onUploadProgress: (e) => console.log(e),
|
||||||
|
})
|
||||||
|
.catch((e) => console.error(e));
|
||||||
|
}
|
||||||
|
loader.hide();
|
||||||
|
}
|
||||||
|
async function updateFile(pathname: string, data: FileMetadata, file?: File) {
|
||||||
|
const arr = pathname.split('/');
|
||||||
|
|
||||||
|
if (arr.length < 1) return;
|
||||||
|
|
||||||
|
loader.show();
|
||||||
|
|
||||||
|
const srcFile = arr.pop();
|
||||||
|
|
||||||
|
const res = await api.put('/storage/file', {
|
||||||
|
...data,
|
||||||
|
from: {
|
||||||
|
file: srcFile,
|
||||||
|
path: arr,
|
||||||
|
},
|
||||||
|
to: file?.name
|
||||||
|
? {
|
||||||
|
file: file.name,
|
||||||
|
path: arr,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
upload: !!file,
|
||||||
|
});
|
||||||
|
if (res && res.status === 200 && res.data && res.data.uploadUrl) {
|
||||||
|
await axios
|
||||||
|
.put(res.data.uploadUrl, file, {
|
||||||
|
headers: { 'Content-Type': file?.type },
|
||||||
|
onUploadProgress: (e) => console.log(e),
|
||||||
|
})
|
||||||
|
.catch((e) => console.error(e));
|
||||||
|
}
|
||||||
|
loader.hide();
|
||||||
|
}
|
||||||
|
async function deleteFile(pathname: string) {
|
||||||
|
const arr = pathname.split('/');
|
||||||
|
|
||||||
|
if (arr.length < 1) return;
|
||||||
|
|
||||||
|
loader.show();
|
||||||
|
|
||||||
|
await api.delete('/storage/file', {
|
||||||
|
data: { path: arr.slice(0, -1), file: arr[arr.length - 1] },
|
||||||
|
});
|
||||||
|
loader.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchFile(params: SearchOperator & SearchOptions) {
|
||||||
|
loader.show();
|
||||||
|
|
||||||
|
return (await api
|
||||||
|
.post(`/search${params.within ? `?within=${params.within}` : ''}`, {
|
||||||
|
...params,
|
||||||
|
within: undefined,
|
||||||
|
})
|
||||||
|
.then((r) => r.data)
|
||||||
|
.finally(loader.hide)) as StorageFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFileUrl(pathname: string | undefined) {
|
||||||
|
if (!pathname) return;
|
||||||
|
const arr = pathname.split('/');
|
||||||
|
const file = arr.pop();
|
||||||
|
const res = await api.post<StorageFile & { downloadUrl: string }>(
|
||||||
|
'/storage/file/download',
|
||||||
|
{ path: arr, file },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.status === 200 && res.data && res.data.downloadUrl) {
|
||||||
|
return res.data.downloadUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(pathname: string | undefined) {
|
||||||
|
if (!pathname) return;
|
||||||
|
|
||||||
|
const arr = pathname.split('/');
|
||||||
|
const file = arr.pop();
|
||||||
|
|
||||||
|
const res = await api.post<StorageFile & { downloadUrl: string }>(
|
||||||
|
'/storage/file/download',
|
||||||
|
{ path: arr, file },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.status === 200 && res.data && res.data.downloadUrl) {
|
||||||
|
await axios
|
||||||
|
.get(res.data.downloadUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
responseType: 'blob',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: res.data.fileType,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = window.URL.createObjectURL(r.data);
|
||||||
|
a.download = res.data.fileName;
|
||||||
|
a.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// information
|
||||||
|
currentInfo,
|
||||||
|
currentFile,
|
||||||
|
folder,
|
||||||
|
file,
|
||||||
|
tree,
|
||||||
|
// fetch
|
||||||
|
getStorage,
|
||||||
|
getStorageFile,
|
||||||
|
// traverse
|
||||||
|
goto,
|
||||||
|
gotoParent,
|
||||||
|
// operation
|
||||||
|
createFolder,
|
||||||
|
editFolder,
|
||||||
|
deleteFolder,
|
||||||
|
createFile,
|
||||||
|
updateFile,
|
||||||
|
deleteFile,
|
||||||
|
searchFile,
|
||||||
|
getFileUrl,
|
||||||
|
downloadFile,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default useStorage;
|
||||||
10
src/stores/store-flag.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
// THIS FEATURE-FLAG FILE IS AUTOGENERATED,
|
||||||
|
// REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
|
||||||
|
import 'quasar/dist/types/feature-flag';
|
||||||
|
|
||||||
|
declare module 'quasar/dist/types/feature-flag' {
|
||||||
|
interface QuasarFeatureFlags {
|
||||||
|
store: true;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/stores/utils/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { Dialog } from 'quasar';
|
||||||
|
import GlobalDialog from 'components/GlobalDialog.vue';
|
||||||
|
|
||||||
|
export function dialog(opts: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
color?: string;
|
||||||
|
icon?: string;
|
||||||
|
persistent?: boolean;
|
||||||
|
actionText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
action?: (...args: unknown[]) => unknown;
|
||||||
|
cancel?: (...args: unknown[]) => unknown;
|
||||||
|
}) {
|
||||||
|
Dialog.create({
|
||||||
|
component: GlobalDialog,
|
||||||
|
componentProps: opts,
|
||||||
|
});
|
||||||
|
}
|
||||||
10
src/types.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
export type Result<T> = {
|
||||||
|
result: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaginationResult<T> = {
|
||||||
|
result: T[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
22
src/utils/datetime.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import 'moment/dist/locale/th';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
|
moment.locale('th');
|
||||||
|
|
||||||
|
export function setLocale(locale: string) {
|
||||||
|
moment.locale(locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateFormat(
|
||||||
|
date?: string | Date | null,
|
||||||
|
fullmonth = false,
|
||||||
|
time = false,
|
||||||
|
) {
|
||||||
|
const m = moment(date);
|
||||||
|
|
||||||
|
if (!m.isValid()) return '';
|
||||||
|
|
||||||
|
const month = m.format(fullmonth ? 'MMMM' : 'MMM');
|
||||||
|
|
||||||
|
return m.format(`DD ${month} YYYY ${time ? ' HH:mm' : ''}`);
|
||||||
|
}
|
||||||
6
tsconfig.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"extends": "@quasar/app-vite/tsconfig-preset",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "."
|
||||||
|
}
|
||||||
|
}
|
||||||